From 3906d0ac0c1dabed36f2b33a1a1e8c88476d8df6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Tue, 13 Oct 2026 23:01:32 +0200 Subject: [PATCH 01/14] feat(ai-harness): serve sessions over HTTP and WebSocket, with a typed client and OAuth connectors --- .changeset/ai-harness-transport.md | 11 + docs/config.json | 20 + docs/harness/auth.md | 168 ++++ docs/harness/connect.md | 331 +++++++ docs/harness/durable-sessions.md | 2 +- docs/harness/inputs.md | 327 +++++++ docs/harness/plugins.md | 4 +- docs/harness/shared-threads.md | 195 ++++ docs/harness/subagents.md | 2 + docs/harness/thread-settings.md | 2 + docs/harness/turn-control.md | 4 +- docs/harness/usage.md | 2 +- packages/ai-harness/package.json | 4 + packages/ai-harness/src/client.ts | 589 +++++++++++++ packages/ai-harness/src/connectors.ts | 102 +++ packages/ai-harness/src/http.ts | 834 ++++++++++++++++++ packages/ai-harness/src/index.ts | 31 + packages/ai-harness/src/oauth.ts | 366 ++++++++ packages/ai-harness/src/protocol.ts | 410 +++++++++ packages/ai-harness/tests/auth-wait.test.ts | 519 +++++++++++ .../ai-harness/tests/client-media.test.ts | 159 ++++ .../ai-harness/tests/client-reads.test.ts | 145 +++ packages/ai-harness/tests/client.test.ts | 232 +++++ packages/ai-harness/tests/http-media.test.ts | 386 ++++++++ packages/ai-harness/tests/oauth-edges.test.ts | 334 +++++++ packages/ai-harness/tests/protocol.test.ts | 473 ++++++++++ packages/ai-harness/tests/run-route.test.ts | 613 +++++++++++++ .../ai-harness/tests/transport-edges.test.ts | 618 +++++++++++++ packages/ai-harness/vite.config.ts | 2 +- testing/e2e/fixtures/harness-turn/basic.json | 29 + .../e2e/fixtures/harness/agent-messages.json | 12 + testing/e2e/fixtures/harness/media.json | 37 + .../e2e/fixtures/harness/protocol-inputs.json | 253 ++++++ testing/e2e/fixtures/harness/protocol.json | 20 + .../e2e/fixtures/harness/thread-controls.json | 50 ++ testing/e2e/src/lib/harness-protocol-tools.ts | 41 + testing/e2e/src/routeTree.gen.ts | 21 + .../e2e/src/routes/api.harness-protocol.$.ts | 298 +++++++ .../e2e/tests/harness-agent-messages.spec.ts | 150 ++++ testing/e2e/tests/harness-media.spec.ts | 175 ++++ .../e2e/tests/harness-protocol-inputs.spec.ts | 685 ++++++++++++++ testing/e2e/tests/harness-protocol.spec.ts | 596 +++++++++++++ .../e2e/tests/harness-thread-controls.spec.ts | 328 +++++++ 43 files changed, 9573 insertions(+), 7 deletions(-) create mode 100644 .changeset/ai-harness-transport.md create mode 100644 docs/harness/auth.md create mode 100644 docs/harness/connect.md create mode 100644 docs/harness/inputs.md create mode 100644 docs/harness/shared-threads.md create mode 100644 packages/ai-harness/src/client.ts create mode 100644 packages/ai-harness/src/connectors.ts create mode 100644 packages/ai-harness/src/http.ts create mode 100644 packages/ai-harness/src/oauth.ts create mode 100644 packages/ai-harness/src/protocol.ts create mode 100644 packages/ai-harness/tests/auth-wait.test.ts create mode 100644 packages/ai-harness/tests/client-media.test.ts create mode 100644 packages/ai-harness/tests/client-reads.test.ts create mode 100644 packages/ai-harness/tests/client.test.ts create mode 100644 packages/ai-harness/tests/http-media.test.ts create mode 100644 packages/ai-harness/tests/oauth-edges.test.ts create mode 100644 packages/ai-harness/tests/protocol.test.ts create mode 100644 packages/ai-harness/tests/run-route.test.ts create mode 100644 packages/ai-harness/tests/transport-edges.test.ts create mode 100644 testing/e2e/fixtures/harness-turn/basic.json create mode 100644 testing/e2e/fixtures/harness/agent-messages.json create mode 100644 testing/e2e/fixtures/harness/media.json create mode 100644 testing/e2e/fixtures/harness/protocol-inputs.json create mode 100644 testing/e2e/fixtures/harness/protocol.json create mode 100644 testing/e2e/fixtures/harness/thread-controls.json create mode 100644 testing/e2e/src/lib/harness-protocol-tools.ts create mode 100644 testing/e2e/src/routes/api.harness-protocol.$.ts create mode 100644 testing/e2e/tests/harness-agent-messages.spec.ts create mode 100644 testing/e2e/tests/harness-media.spec.ts create mode 100644 testing/e2e/tests/harness-protocol-inputs.spec.ts create mode 100644 testing/e2e/tests/harness-protocol.spec.ts create mode 100644 testing/e2e/tests/harness-thread-controls.spec.ts diff --git a/.changeset/ai-harness-transport.md b/.changeset/ai-harness-transport.md new file mode 100644 index 0000000000..79d96c7d01 --- /dev/null +++ b/.changeset/ai-harness-transport.md @@ -0,0 +1,11 @@ +--- +'@tanstack/ai-harness': minor +--- + +Serve a harness session to clients. + +- `createHarnessHandler()` serves a session over HTTP. `POST run` is standard AG-UI, so `useChat` with `fetchServerSentEvents` works on a harness, with approvals, client tools, and a reload that joins the running turn. `POST control`, `GET events`, `GET transcript`, `GET snapshot`, `GET describe`, `sessions`, and the media routes cover the rest. +- `handleHarnessSocket()` serves the same session over a WebSocket. +- `expose` on `defineHarness()` names the agents, settings, config keys, and commands that a client can use. Any other input of those kinds gets `rejected` with `not_exposed`. +- `createHarnessClient()` from `@tanstack/ai-harness/client` is a typed, browser-safe client. It sends inputs, reads the session, and streams events that resume from a cursor. +- `oauthConnector()` and the OAuth helpers (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per sender. `token({ wait: true })` stops the turn with an `auth_required` interrupt until the user signs in. diff --git a/docs/config.json b/docs/config.json index 0d0de346cd..02a28d1429 100644 --- a/docs/config.json +++ b/docs/config.json @@ -933,6 +933,16 @@ "to": "harness/media", "addedAt": "2026-10-13" }, + { + "label": "Connect clients", + "to": "harness/connect", + "addedAt": "2026-10-13" + }, + { + "label": "Send inputs safely", + "to": "harness/inputs", + "addedAt": "2026-10-13" + }, { "label": "Durable sessions", "to": "harness/durable-sessions", @@ -988,6 +998,16 @@ "to": "harness/usage", "addedAt": "2026-10-13" }, + { + "label": "Auth and connectors", + "to": "harness/auth", + "addedAt": "2026-10-13" + }, + { + "label": "Share a thread between people", + "to": "harness/shared-threads", + "addedAt": "2026-10-13" + }, { "label": "Run agents from a harness", "to": "harness/subagents", diff --git a/docs/harness/auth.md b/docs/harness/auth.md new file mode 100644 index 0000000000..55321d44cd --- /dev/null +++ b/docs/harness/auth.md @@ -0,0 +1,168 @@ +--- +title: Auth and connectors +id: harness-auth +order: 7 +description: "Let users sign in to GitHub and other OAuth services from the harness. Tokens stay in your credential store and never reach the model." +keywords: + - tanstack ai + - harness + - oauth + - connectors + - credentials +--- + +Your agent needs to open a pull request, so it needs the user's GitHub token. The user should sign in once, in the browser, and the model should never see the token. `oauthConnector` does that: it adds `/connect github`, stores the token, and hands it to your tools. + +## 1. Add a connector + +```ts group=harness-auth +import { toolDefinition } from '@tanstack/ai' +import { defineHarness, oauthConnector } from '@tanstack/ai-harness' +import { openaiText } from '@tanstack/ai-openai' + +const github = oauthConnector({ + id: 'github', + label: 'GitHub', + oauth: { + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + deviceUrl: 'https://github.com/login/device/code', + clientId: 'your-github-oauth-app-client-id', + scopes: ['repo'], + }, + tools: (token) => [ + toolDefinition({ name: 'list_issues', description: 'List my open issues' }).server( + async () => { + const response = await fetch('https://api.github.com/issues', { + headers: { authorization: `Bearer ${await token()}` }, + }) + return response.json() + }, + ), + ], +}) + +export const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), + plugins: () => [github], +}) +``` + +`token()` returns a fresh access token. It refreshes an expired token when the service gave a refresh token. + +## 2. Sign in + +- The user runs `/connect github`. +- The CLI opens the browser. The harness listens on `127.0.0.1` on a random port for one callback, with PKCE and a random `state`. +- The token goes into the credential store. `/disconnect github` deletes it. + +Set `login: 'device'` for SSH sessions, containers, and CI. The CLI then shows a code to enter on the service's page. + +If a tool runs before sign-in, it stops with a `harness.auth_required` event. The CLI shows which service to connect. + +## Wait for the sign-in + +A turn from a background agent or an email runs while nobody watches it. That turn can need a sign-in. Without a sign-in, the tool fails, and the work of that turn stops. Pass `wait: true` to `token()`. Then the turn waits for the sign-in and continues after it. + +```ts group=harness-auth-wait +import { toolDefinition } from '@tanstack/ai' +import { defineHarness, oauthConnector } from '@tanstack/ai-harness' +import { openaiText } from '@tanstack/ai-openai' + +const github = oauthConnector({ + id: 'github', + label: 'GitHub', + // The server cannot open a browser for the user, so show a code. + login: 'device', + oauth: { + authorizationUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + deviceUrl: 'https://github.com/login/device/code', + clientId: 'your-github-oauth-app-client-id', + scopes: ['repo'], + }, + tools: (token) => [ + toolDefinition({ name: 'list_issues', description: 'List my open issues' }).server( + async () => { + const response = await fetch('https://api.github.com/issues', { + headers: { authorization: `Bearer ${await token({ wait: true })}` }, + }) + return response.json() + }, + ), + ], +}) + +export const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), + plugins: () => [github], + // A web app starts the sign-in with this command. + expose: { commands: ['connect:github'] }, +}) +``` + +In your own plugin, use `ctx.credentials.require('github', { wait: true })`. + +When the credential is missing, the turn stops with an interrupt: + +- The interrupt has `reason: 'auth_required'`. Its `metadata['tanstack:interruptPayload'].request.connector` is `'github'`. +- The sign-in belongs to the sender of the stopped turn. When that person saves the credential, the same turn continues as that person, and the tool runs again. The save can be `/connect github`, the `connect:github` command, or `ctx.credentials.set`. +- A credential that another person saves never continues the turn. +- To stop the wait, resolve the interrupt with `status: 'cancelled'`. Then the tool fails, the same as without `wait`. +- On a durable host, the wait continues after a restart. +- `wait` is for a tool of the running chat turn. Do not pass it in a command, a background agent, or a hook. The credentials of a command never wait. +- Provider keys (`ctx.keys.require`) do not wait. + +In a web app, run the connector command when the user presses your connect button. The session sends a `harness.auth_required` event with the `url` and the `userCode` to show. A client can run only the commands in `expose.commands`, so the harness above exposes `connect:github` (see [Choose what clients can change](./connect#choose-what-clients-can-change)): + +```ts group=harness-auth-wait-client +import { createHarnessClient } from '@tanstack/ai-harness/client' + +const client = createHarnessClient({ url: '/api/harness', threadId: 'user-1-thread' }) + +export const connectGithub = () => client.command('connect:github') +``` + +After the sign-in, the turn finishes its work with no new message. + +## Keep credentials + +The host reads credentials from `stores.credentials`, keyed by the sender of the running turn. Without it, credentials live in memory until the process stops. When several people write in one thread, see [Share a thread between people](./shared-threads). + +```ts group=harness-auth +import { defineCredentialStore } from '@tanstack/ai-persistence' +import type { Credential } from '@tanstack/ai-persistence' + +const saved = new Map() +const keyOf = (userId: string | undefined, id: string) => `${userId ?? 'tenant'}:${id}` + +export const credentials = defineCredentialStore({ + get: async (scope, id) => saved.get(keyOf(scope.userId, id)) ?? null, + set: async (scope, id, credential) => { + saved.set(keyOf(scope.userId, id), credential) + }, + delete: async (scope, id) => { + saved.delete(keyOf(scope.userId, id)) + }, + list: async (scope) => + [...saved.entries()] + .filter(([key]) => key.startsWith(`${scope.userId ?? 'tenant'}:`)) + .map(([key, credential]) => ({ id: key.split(':')[1] ?? key, type: credential.type })), +}) +``` + +- Encrypt tokens at rest in a real store. +- `list` returns ids and types only, never the secret values. +- A credential saved without a `userId` belongs to the whole tenant. A user who has no credential of their own uses it. See [Share one credential with the whole organization](./shared-threads#share-one-credential-with-the-whole-organization). + +## Read credentials in your own plugin + +`ctx.credentials.require('github')` returns the credential, or stops with `auth_required` when the user has not signed in. Plugins read only the credentials of the sender of the running turn. Outside a turn, they read the credentials of the person who opened the session. + +## What you have now + +- Browser and device-code sign-in for any OAuth service. +- Tools that get a fresh token without the model seeing it. +- Turns that wait for a sign-in and then finish their work. diff --git a/docs/harness/connect.md b/docs/harness/connect.md new file mode 100644 index 0000000000..f86f11659c --- /dev/null +++ b/docs/harness/connect.md @@ -0,0 +1,331 @@ +--- +title: Connect clients to a harness +id: harness-connect +order: 2 +description: "Serve a harness session over HTTP, SSE, or WebSocket. Talk to it from a web app or another chat() call." +keywords: + - tanstack ai + - harness + - AG-UI + - websocket + - harnessText +--- + +Your harness runs on a server, but the people who use it are in a browser or another agent. `createHarnessHandler` serves a session over HTTP. A web app talks to it with `createHarnessClient`, and another `chat()` call with `harnessText`. + +## Serve the session over HTTP + +Mount one fetch handler on a route. `authorize` is required, so no endpoint is open by accident. + +```ts group=harness-connect +import { defineHarness, createHarnessHandler, createHarnessHost } from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { openaiText } from '@tanstack/ai-openai' + +export const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), +}) + +const host = createHarnessHost({ persistence: memoryPersistence() }) + +export const handler = createHarnessHandler({ + host, + harness: assistant, + authorize: (request) => + request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}` + ? { id: 'user-1' } + : null, + canAccess: (principal, threadId) => threadId.startsWith(`${principal.id}-`), +}) +``` + +The handler answers these paths under your route: + +- `GET capabilities`: the AG-UI capabilities, with the agents in `expose.agents`. +- `POST run`: standard AG-UI. One request runs one prompt and streams it as SSE. The prompt keeps every content part of the last user message, for example an image. `useChat` works with it. See [Use it from useChat](#use-it-from-usechat). +- `GET run?threadId=`: the saved messages, the running turn, and the waiting approvals, for a `useChat` that loads the thread after a reload. +- `GET run?runId=`: the turn with that run id as SSE, from its first event. A reloaded `useChat` joins a running turn with it. +- `GET events?threadId=`: every event of the session as SSE. Each event id is a cursor, so a reconnect with `Last-Event-ID` continues where it stopped. +- `POST control`: send `{ threadId, input }`, for example `{ op: 'prompt', message }`. You get a receipt back. Agents, settings, config keys, and commands need `expose`. See [Choose what clients can change](#choose-what-clients-can-change). +- `GET snapshot?threadId=`: the status, running operations, and waiting approvals. + +## Choose what clients can change + +A client can send a prompt and answer questions. Other inputs can change how the session works, so a client can use only what the harness exposes. Nothing is exposed by default. + +Name what a client can use in `expose`: + +```ts group=harness-connect +export const team = defineHarness({ + name: 'acme/team-assistant', + adapter: openaiText('gpt-6.1-sol'), + expose: { settings: ['instructions'], commands: ['undo'] }, +}) +``` + +| Field | What a client can do | +| --- | --- | +| `agents` | Run these agents, for example with `client.agents..start(input)`. | +| `settings` | Change these [thread settings](./thread-settings) with `client.configure(settings)`. | +| `config` | Set these plugin config keys with `client.setConfig(key, value)`. | +| `commands` | Run these commands with `client.command(name)`. | + +- Any other input of these kinds gets `{ status: 'rejected', reason: 'not_exposed' }`. +- `GET describe` and `client.describe()` list only the exposed commands and config keys. A UI built from them shows only what a client can use. +- Server code that calls the session is not limited. `session.setConfig()` and `session.command()` work for every key and command, and `session.describe()` lists all of them. +- `defineHarness` checks the names in `agents`. Plugins add their config keys and commands when a session opens, so it does not check those names. + +## Talk to it from a web app + +`createHarnessClient` wraps those endpoints. Import the harness with `import type`, so no server code reaches the browser. + +```ts group=harness-connect-client +import { createHarnessClient } from '@tanstack/ai-harness/client' +import type { assistant } from './harness' + +const client = createHarnessClient({ + url: '/api/harness', + threadId: 'user-1-thread', + headers: { authorization: 'Bearer my-token' }, +}) + +await client.prompt('Summarize my open tickets.') + +for await (const entry of client.events()) { + if (entry.event.type === 'TEXT_MESSAGE_CONTENT') { + console.log(entry.event.delta) + } +} +``` + +`events()` reconnects after a network error and continues from the last cursor. A second tab, a phone, or a reload all see the same session. + +To upload files and show the media that your agents make, read [Send and show media](./media). + +## Send a message to a running agent + +A background agent runs for minutes, and the user wants to correct it from the page, or ask for one more change after it ends. The browser sends the run a message with `client.sendToAgent`. + +1. On the server, list the agent in `expose.agents`. A client can start and message only those agents: + +```ts group=harness-connect-agents +import { defineAgent } from '@tanstack/ai' +import { createHarnessHandler, createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { openaiText } from '@tanstack/ai-openai' + +const drafter = defineAgent({ + name: 'drafter', + description: 'Drafts a blog post', + run: (ctx) => ctx.chat({ adapter: openaiText('gpt-6.1-sol'), stream: false }), +}) + +export const studio = defineHarness({ + name: 'acme/studio', + adapter: openaiText('gpt-6.1-sol'), + agents: [drafter], + expose: { agents: ['drafter'] }, +}) + +export const handler = createHarnessHandler({ + host: createHarnessHost({ persistence: memoryPersistence() }), + harness: studio, + authorize: (request) => + request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}` + ? { id: 'user-1' } + : null, + canAccess: (principal, threadId) => threadId.startsWith(`${principal.id}-`), +}) +``` + +2. In the browser, send the message to the id of the run: + +```ts group=harness-connect-agents-client +import { createHarnessClient } from '@tanstack/ai-harness/client' +import type { studio } from './harness' + +const client = createHarnessClient({ + url: '/api/harness', + threadId: 'user-1-thread', + headers: { authorization: 'Bearer my-token' }, +}) + +export async function correct(operationId: string) { + const receipt = await client.sendToAgent(operationId, 'Cite papers, not blogs.') + if (receipt.status === 'rejected') { + console.warn(receipt.reason) + } +} + +export function addTitle(operationId: string) { + return client.sendToAgent(operationId, 'Add a title.', { mode: 'followUp' }) +} +``` + +The id of the run is the `operationId` in the receipt of `client.agents.drafter.start()`. + +- `mode: 'steer'` (the default): the message joins the next model call of the run. +- `mode: 'followUp'`: the agent runs again after the run ends. `receipt.operationId` is the id of the new run. + +`sendToAgent` posts an `agentMessage` input to `POST control`. It runs as the user that `authorize` returned. The input looks like this: + +```json +{ "op": "agentMessage", "operationId": "op-agent-m1x2-3", "message": "Add a title.", "mode": "followUp" } +``` + +The session rejects the message with a `reason` in these cases: + +- `not_running`: the session does not know the run. Another host can run it, or the id is wrong. +- `not_exposed`: the agent of the run is not in `expose.agents`. + +For how steers and follow-ups run on the server, see [Message a running agent](./subagents#message-a-running-agent). + +## Use it from useChat + +Your app already uses `useChat`. You want the same chat screen on a harness, with approvals, tools that run in the browser, and the thread after a reload. Point `useChat` at the `run` path of the handler. + +1. Put the tools in a file that the server and the browser both import: + +```ts group=harness-connect-tools +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +export const deploy = toolDefinition({ + name: 'deploy', + description: 'Deploy the app. A person must approve it.', + needsApproval: true, + inputSchema: z.object({ env: z.string() }), + outputSchema: z.object({ ok: z.boolean() }), +}) +``` + +2. On the server, give the harness the tool with its implementation: + +```ts group=harness-connect-tools-server +import { defineHarness } from '@tanstack/ai-harness' +import { openaiText } from '@tanstack/ai-openai' +import { deploy } from './tools' + +export const deployer = defineHarness({ + name: 'acme/deployer', + adapter: openaiText('gpt-6.1-sol'), + tools: [ + deploy.server(async () => { + // Your deploy code runs here. + return { ok: true } + }), + ], +}) +``` + +Serve `deployer` with `createHarnessHandler`, as in [Serve the session over HTTP](#serve-the-session-over-http). + +3. In the browser, give `useChat` the same tool definitions: + +```tsx group=harness-connect-tools-client +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { deploy } from './tools' + +export function Chat() { + const { messages, interrupts, sendMessage } = useChat({ + threadId: 'user-1-thread', + connection: fetchServerSentEvents('/api/harness/run'), + tools: [deploy.client()] as const, + // The input's context: tools and plugins read it. + forwardedProps: { page: '/releases' }, + }) + + return ( + <> +

{messages.length} messages

+ {interrupts.map((interrupt) => + interrupt.kind === 'tool-approval' ? ( + + ) : null, + )} + + + ) +} +``` + +The approval reaches the harness, and the tool runs on the server. What else `POST run` does for `useChat`: + +- Each turn runs as the `runId` of its request. A retry with the same `runId` runs once. The same `runId` with another message gets `409`. +- A tool with only a `.client()` implementation runs in the browser, and the same turn gets its result. +- A question from `ctx.session.ask` arrives on the stream of the turn that waits for it. +- The `forwardedProps` of the request are the input's [context](./inputs#send-context-with-a-message). +- With `persistence: true`, `useChat` loads the thread from `GET run?threadId=`. +- If a turn still runs after a reload, `useChat` joins it with `GET run?runId=`. The turn shows from its first event, and its answer continues to stream. +- With more than one server, send `GET run?runId=` to the server that runs the turn. Only that server has the live events of the turn. + +To keep the thread after a reload, set `persistence: true` in `useChat`: + +```tsx group=harness-connect-tools-client +export function ReloadableChat() { + const { messages } = useChat({ + threadId: 'user-1-thread', + connection: fetchServerSentEvents('/api/harness/run'), + tools: [deploy.client()] as const, + persistence: true, + }) + return

{messages.length} messages

+} +``` + +The join has these rules: + +- `canAccess` decides who can join, the same as for every other path. +- The turn must run on the host that answers the request. On another host, the join gets `404`. +- A turn that ended gives the events that the session still keeps, and then the stream ends. + +## Use a WebSocket + +For one connection that carries events and inputs, authorize the upgrade, then hand the socket to `handleHarnessSocket`: + +```ts group=harness-connect +import { handleHarnessSocket } from '@tanstack/ai-harness' +import type { WebSocketLike } from '@tanstack/ai' + +export function onUpgrade(socket: WebSocketLike) { + handleHarnessSocket({ host, harness: assistant, socket, principal: { id: 'user-1' } }) +} +``` + +The client sends `{ type: 'harness.subscribe', threadId }` first. After that, each `harness.input` frame gets a `harness.receipt`, and every event arrives as a `harness.event` frame with its cursor. + +## Use it as the model of another chat + +`harnessText` turns a harness into a text adapter. The outer chat sends a message, and the harness runs a full turn with its own tools, plugins, and agents: + +```ts group=harness-connect +import { chat } from '@tanstack/ai' +import { harnessText } from '@tanstack/ai-harness' + +const stream = chat({ + adapter: harnessText(assistant, { host }), + messages: [{ role: 'user', content: 'Fix the failing test.' }], + threadId: 'outer-thread', +}) +``` + +Each outer thread gets its own inner session, so the harness keeps its own history. The inner session gets every content part of the last user message, so images and files go through too. + +If a plugin picks the model of the harness, the harness has no adapter to read its input kinds from. Pass them with `harnessText(assistant, { host, inputModalities: ['text', 'image'] })`. + +## What you have now + +- One handler that serves standard AG-UI and the session stream. +- Clients that can change only what you expose. +- A `useChat` screen on the harness, with approvals, tools that run in the browser, and a reload that joins the running turn. +- A typed web client that reconnects and resumes from a cursor. +- Messages from the browser to a running agent. +- A WebSocket, and a harness you can call from `chat()`. + +Next: send more than a prompt with [inputs](./inputs). diff --git a/docs/harness/durable-sessions.md b/docs/harness/durable-sessions.md index 87cb43cd8c..882a01c3c1 100644 --- a/docs/harness/durable-sessions.md +++ b/docs/harness/durable-sessions.md @@ -65,7 +65,7 @@ When a host opens the thread and finds a turn whose lease expired, it checks the 4. The time limit passed: the input ends `failed`, with the error code `timeout`. 5. Else the turn runs again as the next attempt, and clients get a `harness.operation.resumed` event. -An input that was stored but never ran, runs now. `session.settled(inputId)` resolves to how an input ended. +An input that was stored but never ran, runs now. `session.settled(inputId)` resolves to how an input ended. See [Send inputs safely](./inputs). A background agent that you started with `resume: true` runs again on the next host. See [Keep a background agent going after a crash](./subagents#keep-a-background-agent-going-after-a-crash). For any other background agent, when its lease expired, the next host: diff --git a/docs/harness/inputs.md b/docs/harness/inputs.md new file mode 100644 index 0000000000..6b3cfdd7d8 --- /dev/null +++ b/docs/harness/inputs.md @@ -0,0 +1,327 @@ +--- +title: Send inputs safely +id: harness-inputs +order: 4 +description: "Give each prompt your own id, so a network retry does not run it twice. Ask the session how an input ended, and cancel or move a message that waits." +keywords: + - tanstack ai + - harness + - inputId + - idempotency + - settled + - steer + - cancelInput + - setDelivery + - background +--- + +A network retry can send the same prompt twice, and a client that reconnects does not know how its last prompt ended. Give each input your own id. A retry with the same id runs nothing again, and the session tells you how the input ended. + +## Send a prompt with your own id + +On the server, pass `inputId` to `prompt`. `await turn.receipt` resolves when the session stored the input. + +```ts group=harness-inputs +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' +import { openaiText } from '@tanstack/ai-openai' + +const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), +}) +const host = createHarnessHost({ + persistence: { + stores: { log: memoryLogStore(), runs: memoryPersistence().stores.runs }, + }, +}) +const session = await host.open(assistant, { threadId: 'user-1-thread' }) + +const turn = session.prompt('Summarize the report.', { inputId: 'req-42' }) +const receipt = await turn.receipt +console.log(receipt.status) // 'accepted' or 'queued' +``` + +- The same id and the same message: you get the first input's operation, and the model is not called again. +- The same id and another message: the receipt has `status: 'rejected'` and `reason: 'conflict'`, and `await turn` rejects with `InputRejectedError`. +- `steer`, `followUp`, and `resolve` take `{ inputId }` too. +- [`configure`](./thread-settings) and [`reset`](./fork-and-reset#reset-with-a-handoff-note) are inputs too. They take `{ inputId }` and run once per id. + +## Retry from the client + +The client sends the same `inputId` again after a network error. The second call gets the first receipt. + +```ts group=harness-inputs-client +import { createHarnessClient } from '@tanstack/ai-harness/client' +import type { AnyHarness } from '@tanstack/ai-harness' + +const client = createHarnessClient({ + url: '/api/harness', + threadId: 'user-1-thread', +}) + +const send = () => client.prompt('Summarize the report.', { inputId: 'req-42' }) +const receipt = await send().catch(send) +console.log(receipt.operationId) +``` + +Make the id once per user action (a button press), and keep it for every retry of that action. + +## Wait for the outcome + +On the server, `session.settled(inputId)` waits until the input ends. On a durable host it reads the log, so it also works after a restart and on another host that opens the thread. + +```ts group=harness-inputs +const settlement = await session.settled('req-42') +if (settlement.outcome === 'failed') { + console.log(settlement.error?.code) // 'attempts_exhausted', 'timeout', or undefined +} +``` + +- `completed`: the turn has its answer. +- `failed`: the turn failed. `error.code` is set for a limit. +- `aborted`: someone cancelled the turn. +- `interrupted`: the turn waits for human input. The `resolve` that answers it is a new input. + +If the turn fails after an approved tool ran (for example, the next model call fails), the interrupt is used up, and the tool does not run again. Its result stays in the transcript. To go on, send a new message. If a resume stopped before its tool ran, the interrupt stays open, and you can resolve it again. + +A client gets the same answer as a `harness.input.settled` event: + +```ts group=harness-inputs-client +for await (const entry of client.events()) { + const { event } = entry + if (event.type === 'CUSTOM' && event.name === 'harness.input.settled') { + console.log(event.value) + } +} +``` + +## Continue without a new message + +Sometimes the transcript already holds what the model must answer, for example a tool result that your own system stored. Start a turn with `continue`, and the model answers the stored transcript. No new user message goes in: + +```ts group=harness-inputs +const continued = session.continue({ inputId: 'req-43' }) +await continued +``` + +From the client: + +```ts group=harness-inputs-client +const continuedReceipt = await client.continue({ inputId: 'req-43' }) +``` + +- `inputId` works as for `prompt`: a retry with the same id runs once. +- `continue` takes `overrides`, `principal`, and `context` too, as `prompt` does. +- When the turn starts, the last message of the transcript must be a user message or a tool result. Else the input is rejected with the reason `nothing_to_continue`, and `await continued` rejects with `InputRejectedError`. +- A `continue` that a host left after a crash runs on the next host, as a prompt does. + +## Give the model a note for one turn + +A reminder such as "call `finish` or `give_up`" helps the model in one turn, but it does not belong in the transcript. Pass it as `ephemeral` to `prompt` or `continue`: + +```ts group=harness-inputs +const reminded = session.continue({ + inputId: 'req-44', + ephemeral: [{ role: 'user', content: 'Call finish or give_up now.' }], +}) +await reminded +``` + +From the client: + +```ts group=harness-inputs-client +const remindedReceipt = await client.prompt('Summarize the report.', { + inputId: 'req-45', + ephemeral: [{ role: 'user', content: 'Answer in three bullets.' }], +}) +``` + +- Each model call of the turn gets the messages right after the turn's own message (or the last stored message, for `continue`), before the answers and tool results of the turn. +- The transcript, the session log, and the message store never keep them. +- A retry with the same `inputId` gets the first input, also when its `ephemeral` messages are different. +- A turn that recovery runs again after a crash has no `ephemeral` messages, because the log does not keep them. +- From the client, `ephemeral` takes user messages only. On the server, `session.prompt` and `session.continue` take any message. + +To add a note from a hook when the model stops, see [Remind the model without saving the reminder](./turn-control#remind-the-model-without-saving-the-reminder). + +## Send a message while a turn runs + +With `busy: 'steer'`, a prompt joins the running turn. It reaches the model at the next model call, in the order the messages arrived, and it ends with that turn. + +```ts group=harness-inputs +const running = session.prompt('Plan the migration.', { inputId: 'req-43' }) +const joined = session.prompt('Keep it under five steps.', { + busy: 'steer', + inputId: 'req-44', +}) +// One turn answers both, so both get its result. +await Promise.all([running, joined]) +console.log((await session.settled('req-44')).operationId === running.id) // true +``` + +A message that arrives while the model writes its final answer still gets an answer in the same turn. + +Waiting `steer` inputs join before each model call, in the order they arrived. They join as a prefix: the join stops at the first input that has an abort request, or that `turn.canJoin` refuses. The inputs after it wait. + +- The `operationId` in the receipt of a `busy: 'steer'` prompt or a `session.steer` input is the running turn. A cancel with that id cancels the turn. +- A `busy: 'steer'` prompt also has its own operation, `joined`. To stop it before it joins, call `joined.cancel()` or `session.cancel(joined.id)`. It does not join, and it settles `aborted` when the running turn ends. +- The session rejects a cancel that comes after the join started, with `reason: 'not_running'`. +- A refused or late input runs as its own turn after the running one. + +To choose which inputs join, or to add records when they join, see [Choose which messages join a turn](./turn-control#choose-which-messages-join-a-turn). + +## Cancel or move a waiting message + +A user sends two more messages while a turn runs. Before they run, the user wants to cancel one, and wants the other to reach the running turn now. `session.inputs()` lists the inputs that wait. `cancelInput` cancels one, and `setDelivery` moves one: + +```ts group=harness-inputs +const migration = session.prompt('Plan the migration.', { inputId: 'req-45' }) +// The turn runs, so these two wait for their own turns. +await session.followUp('Then write the tests.', { inputId: 'req-46' }) +await session.followUp('Use tabs, not spaces.', { inputId: 'req-47' }) + +for (const input of session.inputs()) { + console.log(input.inputId, input.delivery, input.message) +} + +await session.cancelInput('req-46') +await session.setDelivery('req-47', 'steer') +await migration +``` + +- `inputs()` lists the inputs in the order they run: first the steers for the running turn, then the queued turns. +- `delivery: 'steer'`: the input joins the running turn at the next model call. `'queue'`: it runs as its own turn, after the turns before it. +- A cancelled input never runs, also after a restart. It settles `aborted`. +- On a durable host, the log keeps a new delivery, and a restart obeys it. Without a log, the delivery stays in memory. +- If the input started, or joins the running turn now, the receipt has `status: 'rejected'` and `reason: 'not_waiting'`. To stop it, cancel its operation with `session.cancel(operationId)`. + +A client reads the waiting inputs from the snapshot, and sends the same two inputs: + +```ts group=harness-inputs-client +const { waitingInputs = [] } = await client.snapshot() +for (const input of waitingInputs) { + console.log(input.inputId, input.delivery) +} + +await client.cancelInput('req-46') +await client.setDelivery('req-47', 'steer') +``` + +Over `POST control`, they are `{ op: 'cancelInput', inputId }` and `{ op: 'setDelivery', inputId, delivery }`. In these two inputs, `inputId` is the id of the input that waits. A move sends a `harness.input.delivery` event with `inputId` and `delivery`, so every client can update its list. + +## Move a running tool call to the background + +A long command or a subagent blocks the turn, and the user wants to go on. `session.background()` moves the running tool call to the background: + +```ts group=harness-inputs +const build = session.prompt('Run the full test suite.', { inputId: 'req-48' }) +// Later, while the `bash` call of that turn runs: +const moved = await session.background() +console.log(moved.status) // 'accepted', or 'rejected' with 'not_running' +await build +``` + +- The tool call returns at once. The model gets a short note with the job id, and the turn goes on. +- The job keeps running. When it ends, the model gets its result as a note, and an idle session starts a new turn. +- Without an argument, it moves every running call that supports it. Pass a `toolCallId` to move one call. +- `bash` and the single `subagent` tool support it. Other tools keep running as before. +- With no running call that supports it, the receipt has `status: 'rejected'` and `reason: 'not_running'`. +- On a durable host (one with `stores.log`), the log keeps each job until it ends. When a crash stops a job, the next host adds the note "Background job call-1 stopped when the host restarted." +- Without `stores.log`, the job lives in memory only. A crash stops it, and no note comes. + +A client sends `{ op: 'background' }` over `POST control`. It runs no new code, so it needs no `expose` entry: + +```ts group=harness-inputs-client +await fetch('/api/harness/control', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + threadId: 'user-1-thread', + input: { op: 'background' }, + }), +}) +``` + +Your own tool can support it too. Pass its work to `detach` from the tool context, and race the two promises: + +```ts group=harness-inputs +const longReport = toolDefinition({ + name: 'longReport', + description: 'Build the monthly report', + inputSchema: z.object({}), +}).server((_input, toolContext) => { + const work = new Promise((resolve) => { + setTimeout(() => resolve('The report is ready.'), 60_000) + }) + const detach = toolContext?.detach + return detach ? Promise.race([work, detach(work)]) : work +}) +``` + +## Send context with a message + +Your tools often need to know what the user looks at: the open record, the page, or the locale. Send it as the `context` of the input. The session stores it with the input, so a turn that runs again after a restart gets the same value. + +On the server, a tool reads it from its tool context: + +```ts group=harness-inputs +import { toolDefinition } from '@tanstack/ai' +import { z } from 'zod' + +const openRecord = toolDefinition({ + name: 'openRecord', + description: 'Read the id of the record that the user has open', + inputSchema: z.object({}), +}).server(async (_input, toolContext) => { + const context = toolContext?.context + const recordId = + typeof context === 'object' && context !== null && 'recordId' in context + ? String(context.recordId) + : 'none' + return { recordId } +}) + +const records = defineHarness({ + name: 'acme/records', + adapter: openaiText('gpt-6.1-sol'), + tools: [openRecord], +}) +const recordSession = await host.open(records, { threadId: 'user-1-records' }) + +await recordSession.prompt('Summarize the record I have open.', { + context: { recordId: 'inv-42' }, +}) +``` + +`prompt`, `steer`, and `followUp` take `context`. A client sends it in a `control` input: + +```ts group=harness-inputs-client +await fetch('/api/harness/control', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + threadId: 'user-1-thread', + input: { + op: 'prompt', + message: 'Summarize the record I have open.', + context: { recordId: 'inv-42' }, + }, + }), +}) +``` + +On `POST run`, the AG-UI `forwardedProps` of the request are the `context`, for example `useChat({ forwardedProps })`. See [Connect clients to a harness](./connect). + +- The `context` is part of the input for the `inputId` check. An input with the same id and another `context` gets `'conflict'`. +- When `HarnessConfig.context` and the input `context` are both plain objects, tools get them merged, and the harness value wins for a key in both. In all other cases, tools get the harness value. If the harness has no value, tools get the input value. +- Plugins and the router get it as `turn.context`. See [Pick the model of a turn](./plugins#pick-the-model-of-a-turn). +- The `context` is client data, so do not trust it. Who sent the input is the principal from `authorize`. See [Share a thread](./shared-threads). + +## What you have now + +- Retries that never run a prompt twice. +- The outcome of each input by its id, also after a restart. +- Messages that join a running turn in order. +- Waiting messages that a user can cancel, or move into the running turn, and running jobs that a user can move to the background. +- Context from the client that tools read, also after a restart. diff --git a/docs/harness/plugins.md b/docs/harness/plugins.md index 05a2143851..78fa9d4518 100644 --- a/docs/harness/plugins.md +++ b/docs/harness/plugins.md @@ -248,8 +248,8 @@ The `turn` has these fields: | `operationId` | The id of the operation that runs the turn. | | `inputId` | The id of the input, when the turn has one. | | `message` | The user message of the input. `undefined` for a resolve and a continue. | -| `context` | The `context` of the input. It is client data, so do not trust it. | -| `principal` | Who sent the input. Default: the principal that opened the session. | +| `context` | The [context of the input](./inputs#send-context-with-a-message). It is client data, so do not trust it. | +| `principal` | Who sent the input. Default: the principal that opened the session. See [Share a thread](./shared-threads). | | `overrides` | The [overrides of the prompt](./turn-control#give-one-prompt-its-own-settings), when it has some. | The session uses the picks like this: diff --git a/docs/harness/shared-threads.md b/docs/harness/shared-threads.md new file mode 100644 index 0000000000..dbffc09ad4 --- /dev/null +++ b/docs/harness/shared-threads.md @@ -0,0 +1,195 @@ +--- +title: Share a thread between people +id: harness-shared-threads +order: 7 +description: "Let several people write in one thread. Each message runs as its sender, with the credentials and the provider key of that person." +keywords: + - tanstack ai + - harness + - shared thread + - principal + - tenantId + - canJoin +--- + +Several people write in one thread: a team in one chat, or a customer and a support agent. A message from Bob must run as Bob. His GitHub token and his provider key must do the work, not those of the person who opened the thread first. The harness runs each message as the person who sent it. + +## 1. Return the sender from `authorize` + +`authorize` decides who sends each request. Each message runs as the principal that it returns. If your users belong to organizations, add `tenantId`: + +```ts group=harness-shared-threads +import { + createHarnessHandler, + createHarnessHost, + defineHarness, +} from '@tanstack/ai-harness' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { openaiText } from '@tanstack/ai-openai' + +export const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), +}) + +const host = createHarnessHost({ persistence: memoryPersistence() }) + +// Your own lookups: a session token to a user, and a thread to its members. +const users = new Map([ + ['Bearer token-ada', { id: 'ada', tenantId: 'acme' }], + ['Bearer token-bob', { id: 'bob', tenantId: 'acme' }], +]) +const members = new Map([['team-thread', ['ada', 'bob']]]) + +export const handler = createHarnessHandler({ + host, + harness: assistant, + authorize: (request) => users.get(request.headers.get('authorization') ?? '') ?? null, + canAccess: (principal, threadId) => + members.get(threadId)?.includes(principal.id) ?? false, +}) +``` + +You pass nothing else. The sender of each request is used everywhere: + +- The log keeps the sender of each message. After a restart, a message runs again as its sender. +- The tools of a turn get the credentials of the sender, in `ctx.credentials`. +- The model call uses the provider key of the sender, in `ctx.keys`. So the sender pays for the call. +- In a plugin, `ctx.session.principal` is the sender of the running turn. Outside a turn, it is the person who opened the session first. +- Commands, for example `connect:github`, run as their sender. So a sign-in belongs to the person who signed in. A turn that waits for a sign-in continues only when its own sender signs in. + +`handleHarnessSocket` works the same way. Every input on the socket runs as the `principal` that you give it. Context that a client sends with a message is client data, and it never changes the sender. See [Send context with a message](./inputs#send-context-with-a-message). + +## 2. Connect each person + +Each person opens the same thread with their own token. This snippet shows two clients. In a real app, each person has one client in their own browser: + +```ts group=harness-shared-threads-client +import { createHarnessClient } from '@tanstack/ai-harness/client' +import type { assistant } from './harness' + +const ada = createHarnessClient({ + url: '/api/harness', + threadId: 'team-thread', + headers: { authorization: 'Bearer token-ada' }, +}) +const bob = createHarnessClient({ + url: '/api/harness', + threadId: 'team-thread', + headers: { authorization: 'Bearer token-bob' }, +}) + +await ada.prompt('Open an issue for the login bug.') +await bob.prompt('List my open pull requests.') +``` + +Bob sends his message while the turn of Ada runs. His message waits. Then it runs as its own turn, as Bob. + +On the server, give the sender to `prompt` yourself. `steer`, `followUp`, `resolve`, and `command` take `principal` too. Without it, the message runs as the person who opened the session: + +```ts group=harness-shared-threads +const session = await host.open(assistant, { threadId: 'team-thread' }) +const ada = { id: 'ada', tenantId: 'acme' } + +session.prompt('Summarize the open issues.', { principal: ada }) +session.command('connect:github', undefined, { principal: ada }) +``` + +A command acts for the person who runs it. In its `run` context, `session.principal` is that person, and `session.prompt` runs as that person. + +If you write a command that saves a sign-in or a key, save it with the `credentials` of the second `run` argument. Those credentials belong to the person who runs the command. The `ctx.credentials` of `setup` belong to the sender of the running turn: + +```ts group=harness-shared-threads +import { defineCommand, definePlugin } from '@tanstack/ai-harness' +import { z } from 'zod' + +export const linearKey = definePlugin({ + name: 'acme/linear-key', + setup: () => ({ + commands: { + 'connect:linear': defineCommand({ + description: 'Save a Linear API key', + input: z.object({ key: z.string() }), + run: async ({ key }, { credentials }) => { + await credentials.set('linear', { type: 'api_key', value: key }) + return 'Connected to Linear.' + }, + }), + }, + }), +}) +``` + +## 3. Keep other people out of a running turn + +A message with `busy: 'steer'` can join the turn that runs now. A joined message runs with the credentials of the sender of that turn. So without `turn.canJoin`, a waiting message joins the running turn only when the same person sent both: + +- A message from another person waits. It runs as its own turn after the running one, as its sender. +- The waiting messages join as a prefix, in the order they arrived. So a message that waits also holds back the messages that wait after it. + +Set `canJoin` to choose your own rule. It gets the sender of the waiting message as `principal`, and the sender of the running turn as `turnPrincipal`. This harness keeps the default rule, and it also lets your build bot join any turn: + +```ts group=harness-shared-threads +export const team = defineHarness({ + name: 'acme/team', + adapter: openaiText('gpt-6.1-sol'), + turn: { + canJoin: ({ principal, turnPrincipal }) => + principal?.id === 'build-bot' || + (principal?.id === turnPrincipal?.id && + principal?.tenantId === turnPrincipal?.tenantId), + }, +}) +``` + +A background agent that a turn starts keeps the provider key of the sender of that turn. It keeps the key also after the turn ends. The wake turn after the agent runs as the person who started the agent. + +One limit applies to code that runs after its turn ended, for example a tool of a background agent. That code reads `ctx.credentials` and `ctx.session.principal` of the turn that runs at that time. In an agent, use `ctx.keys`. In a command, use the `credentials` of its `run` context. + +## 4. Keep organizations apart + +The credential scope of a turn has the `userId` and the `tenantId` of the sender. So one person in two organizations has two sets of credentials. The memory store of `memoryPersistence()` keeps them apart. In your own credential store, put `scope.tenantId` in the key: + +```ts group=harness-shared-threads +import type { Scope } from '@tanstack/ai' + +export const keyOf = (scope: Scope, id: string) => + `${scope.tenantId ?? '-'}:${scope.userId ?? '-'}:${id}` +``` + +Use `keyOf` in `get`, `set`, `delete`, and `list` of the store in [Keep credentials](./auth#keep-credentials). + +### Share one credential with the whole organization + +Some services have one key for the whole organization, for example a company account. Save that key once, with no `userId`. Every user of the tenant can then use it: + +```ts group=harness-shared-threads +import type { CredentialStore } from '@tanstack/ai-persistence' + +// Your admin code. A credential without a userId belongs to the tenant. +export async function shareKey( + store: CredentialStore, + tenantId: string, + value: string, +) { + await store.set({ threadId: 'admin', tenantId }, 'linear', { + type: 'api_key', + value, + }) +} +``` + +The store keys credentials by user and tenant, so the `threadId` of this scope has no effect. + +- A turn reads the credential of its sender first. If the sender has none, the turn reads the credential of the tenant. +- A save from a turn goes to the sender only. This includes `connect:` and `ctx.credentials.set`. The credential of the tenant does not change. +- `ctx.credentials.list()` shows the credentials of the sender and of the tenant. For the same id, the credential of the sender wins. +- Use an API key for a credential of the tenant. If the turn refreshes an OAuth token of the tenant, the new token goes to the sender only. + +## What you have now + +- Each message runs as the person who sent it, also after a restart. +- Tools and model calls use the credentials and the provider key of the sender. +- A message from another person waits for its own turn. +- Sign-ins and keys belong to the person who saved them. +- The credentials of two organizations stay apart, and one key can serve a whole organization. diff --git a/docs/harness/subagents.md b/docs/harness/subagents.md index 835fd0cce3..fce15eeb74 100644 --- a/docs/harness/subagents.md +++ b/docs/harness/subagents.md @@ -178,6 +178,8 @@ A steer joins only a run of the same sender. A steer from another sender runs as An agent written as plain code, with no `ctx.chat` call, makes no model call. It gets a steer only as a follow-up. +To message a run from the browser, see [Send a message to a running agent](./connect#send-a-message-to-a-running-agent). + ### Find a run by its id Your server often has only the id of a run, for example from a button in the UI. Use the session: diff --git a/docs/harness/thread-settings.md b/docs/harness/thread-settings.md index f4c9867f18..9de666b134 100644 --- a/docs/harness/thread-settings.md +++ b/docs/harness/thread-settings.md @@ -70,6 +70,8 @@ An unknown field, model, or plugin, or a bad value, gets a receipt with `status: `configure` is an input, so the log keeps who changed what. Each change sends a `harness.settings.changed` event with the new `settings`. +A client can change only the fields in `expose.settings` of the harness. See [Choose what clients can change](./connect#choose-what-clients-can-change). + ## 3. Build a model menu `session.describe()` returns the stored `settings` and the names of the `models`, so a UI can build a menu: diff --git a/docs/harness/turn-control.md b/docs/harness/turn-control.md index 1c74861d53..d2221388e1 100644 --- a/docs/harness/turn-control.md +++ b/docs/harness/turn-control.md @@ -83,7 +83,7 @@ const reminded = defineHarness({ - The transcript, the session log, and the message store never keep them. - A return with only `ephemeral` messages also sends the model back to work, and it counts for `maxFinishCycles`. - `onJoin` can return `ephemeral` messages too. They go to the model call of the join. -- A reminder that you send in your own loop, one turn at a time, does not count for `maxFinishCycles`. Pass it as `ephemeral` to `prompt` or `continue`. Each model call of that turn gets it, and no store keeps it. +- A reminder that you send in your own loop, one turn at a time, does not count for `maxFinishCycles`. Pass it as `ephemeral` to `prompt` or `continue`. Each model call of that turn gets it, and no store keeps it. See [Give the model a note for one turn](./inputs#give-the-model-a-note-for-one-turn). ## Retry model errors @@ -191,7 +191,7 @@ The join stops at the first message that: That message and the ones after it wait. `canJoin` runs again before each model call and at each final answer, so it can run more than once for one message. Keep it a quick check with no side effects. What still waits when the turn ends runs as its own turn, before other queued turns. A cancelled one settles `aborted` and does not run. -A joined message runs with the credentials of the sender of the turn. So without `turn.canJoin`, a waiting message joins the running turn only when the same person sent both. A message from another person waits and runs as its own turn after the running one, as its sender. Joins are a prefix, so it also holds back the messages that wait after it. Set `canJoin` to choose your own rule. Compare `principal` with `turnPrincipal` to keep one person's message out of another person's turn. +A joined message runs with the credentials of the sender of the turn. So without `turn.canJoin`, a waiting message joins the running turn only when the same person sent both. A message from another person waits and runs as its own turn after the running one, as its sender. Joins are a prefix, so it also holds back the messages that wait after it. Set `canJoin` to choose your own rule. Compare `principal` with `turnPrincipal` to keep one person's message out of another person's turn. See [Keep other people out of a running turn](./shared-threads#3-keep-other-people-out-of-a-running-turn). `onJoin` runs before the model call that the joined messages reach. It can return messages, which come after the joined ones, and records, which land in the same append as the join. Records need a durable host. diff --git a/docs/harness/usage.md b/docs/harness/usage.md index 2acdbbbd16..c537cd3fb6 100644 --- a/docs/harness/usage.md +++ b/docs/harness/usage.md @@ -55,7 +55,7 @@ At the end of each call, the provider reports its usage. These calls count: - The subagents that a turn starts, for the same sender. - Background agents, for the person who started the agent. -A call that fails before it ends does not count. Each message runs as its sender, so in a thread that several people use, `bySender` shows what each person used. +A call that fails before it ends does not count. Each message runs as its sender, so in a [shared thread](./shared-threads), `bySender` shows what each person used. ## Where the totals live diff --git a/packages/ai-harness/package.json b/packages/ai-harness/package.json index 5d45fa9b9d..fd59be8e31 100644 --- a/packages/ai-harness/package.json +++ b/packages/ai-harness/package.json @@ -29,6 +29,10 @@ ".": { "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js" + }, + "./client": { + "types": "./dist/esm/client.d.ts", + "import": "./dist/esm/client.js" } }, "scripts": { diff --git a/packages/ai-harness/src/client.ts b/packages/ai-harness/src/client.ts new file mode 100644 index 0000000000..54631826ea --- /dev/null +++ b/packages/ai-harness/src/client.ts @@ -0,0 +1,589 @@ +// Browser-safe: this module has type-only imports from the rest of the +// package, plus `media-ref`, which has type-only imports itself. So no server +// code (adapters, stores, secrets) reaches a client. +import { isMediaRecord } from './media-ref' +import type { ModelMessage, RunAgentResumeItem } from '@tanstack/ai' +import type { + SessionIndexEntry, + SessionIndexPage, +} from '@tanstack/ai-persistence' +import type { AgentInputOf } from './agents' +import type { AnyHarness, HarnessAgentsOf, SessionMetadataOf } from './define' +import type { HostEvent } from './host' +import type { SessionDescription, SessionSnapshot } from './session' +import type { SessionEntryChange } from './session-entry' +import type { + BusyPolicy, + Cursor, + ForkPoint, + HarnessInput, + MediaRecord, + Receipt, + SessionEvent, + ThreadSettingsChange, + UserInput, + WaitingInput, +} from './types' + +export { + MEDIA_URL_PREFIX, + kindOf, + mediaIdOf, + mediaOfMessage, + mediaPart, +} from './media-ref' +export type { ForkPoint, MediaKind, MediaRecord } from './types' +export type { + HostEvent, + HostSessionDeletedEvent, + HostSessionEvent, + HostStatusEvent, +} from './host' + +export interface HarnessClientOptions { + /** The base URL of `createHarnessHandler`, for example `/api/harness`. */ + url: string + threadId: string + /** Extra headers, for example `Authorization`. */ + headers?: Record | (() => Record) + fetch?: typeof fetch + /** Wait before a reconnect of `events()`. Default 1000 ms. */ + reconnectDelayMs?: number +} + +type StartArgs = + AgentInputOf extends undefined + ? [input?: undefined, options?: { detached?: boolean }] + : [input: AgentInputOf, options?: { detached?: boolean }] + +/** `client.agents`: start an exposed agent, typed from the harness. */ +export type ClientAgentHandles = { + [TAgent in HarnessAgentsOf as TAgent['name']]: { + start: (...args: StartArgs) => Promise + } +} + +export interface HarnessClient { + /** + * Send a prompt. `inputId` is an id you choose: a retry with the same id + * and message gets the first receipt and does not run again. `ephemeral` + * are messages for the model calls of this turn only. See + * `session.prompt`. + */ + prompt: ( + message: UserInput, + options?: { + busy?: BusyPolicy + inputId?: string + ephemeral?: ReadonlyArray + }, + ) => Promise + steer: ( + message: UserInput, + options?: { inputId?: string }, + ) => Promise + followUp: ( + message: UserInput, + options?: { inputId?: string }, + ) => Promise + /** + * Start a turn from the stored transcript, with no new message. See + * `session.continue`. + */ + continue: (options?: { + inputId?: string + ephemeral?: ReadonlyArray + }) => Promise + resolve: (resume: Array) => Promise + cancel: (operationId?: string) => Promise + /** + * Cancel an input that waits (see `snapshot().waitingInputs`). An input + * that started is refused with `not_waiting`: use `cancel`. + */ + cancelInput: (inputId: string) => Promise + /** + * Move an input that waits: `'steer'` joins the running turn, `'queue'` + * runs it as its own turn later. + */ + setDelivery: ( + inputId: string, + delivery: WaitingInput['delivery'], + ) => Promise + agents: ClientAgentHandles + /** Answer a question from a command or a plugin. */ + answer: (questionId: string, value: unknown) => Promise + /** Run a plugin command. Its result arrives as a `harness.command.result` event. */ + command: (name: string, input?: unknown) => Promise + /** Change a session setting. */ + setConfig: (key: string, value: unknown) => Promise + /** Change the stored settings of the thread. See `session.configure`. */ + configure: (settings: ThreadSettingsChange) => Promise + /** + * Start a fresh model context from the next turn. The model sees `note` + * first, when you give one. The transcript keeps every message. + */ + reset: (note?: string, options?: { inputId?: string }) => Promise + /** + * Go back to the message `messageId`, as `session.revert` does. The + * harness must list `'undo'` in `expose.commands`. + */ + revert: (messageId: string) => Promise + /** End the revert that stands, as `session.unrevert` does. */ + unrevert: () => Promise + /** + * Send a message to the agent run `operationId`, as `AgentRun.send` + * does. The harness must list the run's agent in `expose.agents`. + */ + sendToAgent: ( + operationId: string, + message: UserInput, + options?: { mode?: 'steer' | 'followUp'; inputId?: string }, + ) => Promise + /** + * The session events from `from` (exclusive). Reconnects after a network + * error and resumes from the last cursor. Ends when `signal` aborts. + * `onConnection` reports `'open'` for each connection and `'reconnecting'` + * before each new try. + */ + events: (options?: { + from?: Cursor + signal?: AbortSignal + onConnection?: (state: 'open' | 'reconnecting') => void + }) => AsyncIterable + /** + * The status changes and the session changes of all your sessions on the + * server (`host.events()`). First the current status of each open session. + * It ends when `signal` aborts or the server closes the stream. Call it + * again to reconnect: the current statuses come first again. + */ + hostEvents: (options?: { signal?: AbortSignal }) => AsyncIterable + snapshot: () => Promise + /** The saved messages of the thread. */ + transcript: () => Promise> + /** + * The commands, settings, and tools of the session. Only the commands in + * `expose.commands` and the config keys in `expose.config`. + */ + describe: () => Promise + /** + * Store a file in the media store of the thread. Send the record in a + * prompt with `mediaPart(record)`. Throws when the handler refuses the + * file, for example `413` for a file over the size limit. + */ + upload: ( + body: Blob | ArrayBuffer | Uint8Array, + info: { name: string; mimeType: string }, + ) => Promise + /** + * A signed URL for a media file, for ``, `