diff --git a/.changeset/ai-harness-plugins.md b/.changeset/ai-harness-plugins.md index f0812eb769..7dd3112006 100644 --- a/.changeset/ai-harness-plugins.md +++ b/.changeset/ai-harness-plugins.md @@ -2,10 +2,11 @@ '@tanstack/ai-harness': minor --- -Add first-party plugins in `@tanstack/ai-harness/plugins` (a Node entry): +Add first-party plugins. `@tanstack/ai-harness/plugins` loads no Node module. The plugins that read files are in the Node entry `@tanstack/ai-harness/plugins/node`. - `permissions()`: rules for tools, paths, and commands, the `default`, `plan`, `acceptEdits`, and `bypass` modes, questions answered with once, always, or reject, saved "always" rules with `/permissions` and `/permissions forget `, and `listSavedPermissions` / `deleteSavedPermission` for server code. The rules apply in subagent runs too. -- `agents()`: agent profiles with their own prompt, tools, model, rules, and step limit, in code or in Markdown files, `/agent` to switch the primary agent, and the built-in `build`, `plan`, `general`, and `explore` agents. -- `question()`, `todos()`, `title()`, `modelPicker()`, `projectInstructions()`, `fileCommands()`, `compact()`, `usage()` (with your own prices for calls that have no provider cost), and `boundToolOutput()`. +- `agents()` (in `plugins/node`): agent profiles with their own prompt, tools, model, rules, and step limit, in code or in Markdown files, `/agent` to switch the primary agent, and the built-in `build`, `plan`, `general`, and `explore` agents. +- `question()`, `todos()`, `title()`, `modelPicker()`, `compact()`, and `usage()` (with your own prices for calls that have no provider cost). +- `projectInstructions()`, `fileCommands()`, and `boundToolOutput()`, in `plugins/node`. `PermissionRules`, `PermissionResources`, `PermissionDecisionCapability`, and `decidePermission` are also exported from the package root. They load no Node module, so an edge-safe plugin can read the permission rules. diff --git a/.changeset/ai-harness-transport.md b/.changeset/ai-harness-transport.md index 9f0d8ebd40..7115dcda27 100644 --- a/.changeset/ai-harness-transport.md +++ b/.changeset/ai-harness-transport.md @@ -4,8 +4,8 @@ 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 agent-transcript` (the messages of a child agent run), `GET snapshot`, `GET describe`, `sessions`, and the media routes cover the rest. -- `handleHarnessSocket()` serves the same session over a WebSocket. +- `createHarnessHandler()` from `@tanstack/ai-harness/server` 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 agent-transcript` (the messages of a child agent run), `GET snapshot`, `GET describe`, `sessions`, and the media routes cover the rest. +- `handleHarnessSocket()`, from the same entry, 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. +- `oauthConnector()` and the OAuth helpers from `@tanstack/ai-harness/oauth` (`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/.changeset/harness-acp-agent.md b/.changeset/harness-acp-agent.md index f860ff5c0a..8034ce646c 100644 --- a/.changeset/harness-acp-agent.md +++ b/.changeset/harness-acp-agent.md @@ -2,4 +2,4 @@ '@tanstack/ai-acp': minor --- -`@tanstack/ai-acp/agent` adds `createAcpAgent` and `serveAcp`, which serve a harness as an ACP v2 agent (experimental, like the draft protocol). Tool approvals become permission requests, cancel cancels the running turn, and image, audio, and embedded file blocks go to the media store of the session. `session/list` lists the sessions of the harness from the session index of the host. `@tanstack/ai-acp` now uses `@agentclientprotocol/sdk` 1.5. The existing ACP client adapters keep working. +`@tanstack/ai-acp/agent` adds `createAcpAgent` and `serveAcp`, which serve a harness as an ACP v2 agent (experimental, like the draft protocol). Tool approvals become permission requests, cancel cancels the running turn, and image, audio, and embedded file blocks go to the media store of the session. `session/list` lists the sessions of the harness from the session index of the host. `@tanstack/ai-acp` now uses `@agentclientprotocol/sdk` 1.8, pinned to `~1.8.0` because the agent uses its experimental v2 entry point. The existing ACP client adapters keep working. diff --git a/.changeset/harness-cli-handler-options.md b/.changeset/harness-cli-handler-options.md index c2f77f3e99..fa70df563f 100644 --- a/.changeset/harness-cli-handler-options.md +++ b/.changeset/harness-cli-handler-options.md @@ -2,4 +2,4 @@ '@tanstack/ai-harness-cli': patch --- -`--serve` passes `canAccess` and `mediaSecret` to the harness handler, which needs both. +`--serve` passes `canAccess` and `media: { secret }` to the harness handler, which needs both. diff --git a/.changeset/harness-secure-defaults.md b/.changeset/harness-secure-defaults.md index d8f7ec8847..1cd5b98d5f 100644 --- a/.changeset/harness-secure-defaults.md +++ b/.changeset/harness-secure-defaults.md @@ -5,5 +5,5 @@ Make the transport defaults safe for a server with many instances. - `createHarnessHandler` and `handleHarnessSocket` need `canAccess`. To let every principal use every thread, pass `canAccess: () => true`. -- On a durable host (`stores.log`), `createHarnessHandler` throws without `mediaSecret`, so a media URL that one instance signs works on every instance. +- On a durable host (`stores.log`), `createHarnessHandler` throws without `media: { secret }`, so a media URL that one instance signs works on every instance. - `oauthConnector` needs `login`. Use `'device'` on a server, and `'loopback'` only for a harness on the user's own machine, such as a CLI. diff --git a/.changeset/harness-tool-ops.md b/.changeset/harness-tool-ops.md index 0e8d098ef3..ada22bbc2d 100644 --- a/.changeset/harness-tool-ops.md +++ b/.changeset/harness-tool-ops.md @@ -6,7 +6,7 @@ Run a harness tool with no model turn, add host records from a client, and add a per-run system preamble. -- `session.tool(name, args?, options?)` runs one harness tool with no model +- `session.tool({ name, args?, meta?, inputId?, principal? })` runs one harness tool with no model turn, for example from a schedule or a webhook. The call goes through the same middleware, input check, and `permissions()` rules as a model call, and the call and its result join the transcript. A tool that asks for approval @@ -15,7 +15,7 @@ Run a harness tool with no model turn, add host records from a client, and add a event. `args` is typed from the input schema of a harness tool. - `expose.tools` lists the harness tools a client may run with a `{ op: 'tool', name, args?, meta? }` input, or with the new - `client.tool(name, args?, options?)`. The list is typed from the harness + `client.tool({ name, args?, meta?, inputId? })`. The list is typed from the harness `tools`, and `defineHarness` throws for a name that is not in `tools`. Any other tool is refused with `not_exposed`. The capabilities document marks each tool with `exposed`. diff --git a/.changeset/subagent-context-extend.md b/.changeset/subagent-context-extend.md index ac193c83aa..5b837ee22a 100644 --- a/.changeset/subagent-context-extend.md +++ b/.changeset/subagent-context-extend.md @@ -6,3 +6,4 @@ A host can add fields to the `ctx` an agent's `run` gets. - `subagents.binding.extend(ctx)` returns the fields to add. A child's `ctx.chat({ subagents })` passes it down, so the fields reach nested children too. - `SubagentContextExtension` is an empty interface that `SubagentRunContext` includes. A package augments it to type its fields, for example `ctx.harness` from `@tanstack/ai-harness`. +- `registerSubagentContextDefaults` in `@tanstack/ai/adapter-internals` adds default fields to the `ctx` of every agent run, also in a plain `chat()`. diff --git a/docs/config.json b/docs/config.json index 5eded52077..57dfafcd41 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1010,7 +1010,8 @@ { "label": "Share one log between sessions", "to": "harness/shared-logs", - "addedAt": "2026-10-13" + "addedAt": "2026-10-13", + "updatedAt": "2026-10-16" }, { "label": "Write a plugin", diff --git a/docs/harness/agents.md b/docs/harness/agents.md index 315b6b7355..cfefb87c9e 100644 --- a/docs/harness/agents.md +++ b/docs/harness/agents.md @@ -21,7 +21,8 @@ import { readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { toolDefinition } from '@tanstack/ai' import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' -import { agents, permissions } from '@tanstack/ai-harness/plugins' +import { permissions } from '@tanstack/ai-harness/plugins' +import { agents } from '@tanstack/ai-harness/plugins/node' import { anthropicText } from '@tanstack/ai-anthropic' import { memoryPersistence } from '@tanstack/ai-persistence' import { z } from 'zod' @@ -63,18 +64,18 @@ const coder = defineHarness({ const host = createHarnessHost({ persistence: memoryPersistence() }) const session = await host.open(coder, { threadId: 'dark-mode' }) -await session.command('agent', 'plan') +await session.command({ name: 'agent', input: 'plan' }) const plan = await session.send('How do I add a dark mode?') console.log(plan.text) -await session.command('agent', 'build') +await session.command({ name: 'agent', input: 'build' }) await session.send('Do step 1.') ``` 1. The `plan` agent reads the code and gives numbered steps. It has no tool that changes a file. 2. The `build` agent changes the files, after your answer to each [permission question](./permissions). -- `session.command('agent')` with no input shows the current agent and the list: `Agent: build. Agents: build, plan.` +- `session.command({ name: 'agent' })` with no input shows the current agent and the list: `Agent: build. Agents: build, plan.` - A switch applies at the next turn. The same switch is the `agent` setting. `session.describe().config` lists its options for a menu: @@ -115,7 +116,7 @@ Give `agents()` a profile. This one reviews the changes with a stronger model: ```ts group=harness-agents import type { AnyTextAdapter } from '@tanstack/ai' -import type { AgentProfile } from '@tanstack/ai-harness/plugins' +import type { AgentProfile } from '@tanstack/ai-harness/plugins/node' const review: AgentProfile = { name: 'review', diff --git a/docs/harness/auth.md b/docs/harness/auth.md index e631942bb7..45c2da95c4 100644 --- a/docs/harness/auth.md +++ b/docs/harness/auth.md @@ -143,7 +143,7 @@ view.on('signIn', (signIn) => { console.log(`Connect ${signIn.connector} to continue the turn.`) }) -export const connectGithub = () => view.command('connect:github') +export const connectGithub = () => view.command({ name: 'connect:github' }) ``` After the sign-in, the turn finishes its work with no new message. diff --git a/docs/harness/coding-agent.md b/docs/harness/coding-agent.md index a8bffe5f11..c5644a9edc 100644 --- a/docs/harness/coding-agent.md +++ b/docs/harness/coding-agent.md @@ -13,7 +13,7 @@ keywords: - undo --- -You want your own coding agent in the terminal. It reads and edits files, and runs commands after you approve them. When a turn goes wrong, you undo it. The first-party plugins in `@tanstack/ai-harness/plugins` and `@tanstack/ai-harness/plugins/coding` give you those parts. You pick the model and the rules. +You want your own coding agent in the terminal. It reads and edits files, and runs commands after you approve them. When a turn goes wrong, you undo it. The first-party plugins in `@tanstack/ai-harness/plugins`, `@tanstack/ai-harness/plugins/node`, and `@tanstack/ai-harness/plugins/coding` give you those parts. You pick the model and the rules. ## 1. Define the agent @@ -28,14 +28,13 @@ import { } from '@tanstack/ai-harness' import { compact, - fileCommands, modelPicker, permissions, - projectInstructions, title, todos, usage, } from '@tanstack/ai-harness/plugins' +import { fileCommands, projectInstructions } from '@tanstack/ai-harness/plugins/node' import { snapshots, workspaceTools } from '@tanstack/ai-harness/plugins/coding' import { openaiText } from '@tanstack/ai-openai' import { memoryPersistence } from '@tanstack/ai-persistence' @@ -92,7 +91,7 @@ while (line !== 'exit') { if (line.startsWith('/')) { // `/undo`, `/model fast`, `/mode plan`, and the other plugin commands. const [name = '', ...args] = line.slice(1).split(' ') - console.log(await session.command(name, args.join(' ') || undefined)) + console.log(await session.command({ name, input: args.join(' ') || undefined })) } else { const turn = session.send(line) turn.onEvent('TEXT_MESSAGE_CONTENT', ({ event }) => { @@ -112,7 +111,7 @@ terminal.close() await host.close() ``` -Both plugin entries run only in Node. The coding tools use the file system and the shell of the machine. A line that starts with `/` runs a command of a plugin. Any other line is a prompt. +The `plugins/node` and `plugins/coding` entries run only in Node. The coding tools use the file system and the shell of the machine. A line that starts with `/` runs a command of a plugin. Any other line is a prompt. ## 2. Run it diff --git a/docs/harness/coding-tools-backends.md b/docs/harness/coding-tools-backends.md index 1641fe93e3..04ffd2ae84 100644 --- a/docs/harness/coding-tools-backends.md +++ b/docs/harness/coding-tools-backends.md @@ -246,7 +246,7 @@ A formatter runs code from the project, like `prettier.config.js`. The model can One tool result with a full log or a big JSON file fills the context of the model. `boundToolOutput()` cuts every tool result that is too long: ```ts group=harness-coding-tools-backends -import { boundToolOutput } from '@tanstack/ai-harness/plugins' +import { boundToolOutput } from '@tanstack/ai-harness/plugins/node' export const bounded = defineHarness({ name: 'acme/bounded-coder', diff --git a/docs/harness/coding-tools.md b/docs/harness/coding-tools.md index 577d2a0a99..eff0057b4e 100644 --- a/docs/harness/coding-tools.md +++ b/docs/harness/coding-tools.md @@ -285,7 +285,7 @@ const repos = defineHarness({ const host = createHarnessHost({ persistence: memoryPersistence() }) const session = await host.open(repos, { threadId: 'fix-login' }) -await session.configure({ cwd: 'web-app' }) +await session.configure({ settings: { cwd: 'web-app' } }) ``` - Relative paths start in `/srv/repos/web-app`, and `bash` runs there. diff --git a/docs/harness/compaction.md b/docs/harness/compaction.md index aca25bbc1c..5bf570766f 100644 --- a/docs/harness/compaction.md +++ b/docs/harness/compaction.md @@ -21,7 +21,7 @@ Set `durable: true`. Compaction then writes a `tanstack.compaction` record into 1. Create the compaction middleware with `durable: true`. 2. Add it to `middleware` in `defineHarness`. -3. Give the host `projectCompaction` in its `project` option. +3. Give the harness `projectCompaction` in its `durability.project` option. ```ts group=harness-compaction import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' @@ -50,12 +50,12 @@ const assistant = defineHarness({ name: 'acme/assistant', adapter: openaiText('gpt-6.1-sol'), middleware: [compaction], + durability: { project: { version: 'v1', record: projectCompaction } }, }) const { runs, metadata } = memoryPersistence().stores const host = createHarnessHost({ persistence: { stores: { log: memoryLogStore(), runs, metadata } }, - project: { version: 'v1', record: projectCompaction }, }) ``` @@ -95,7 +95,7 @@ Sometimes a summary does not apply: ## Fold your own records too -If your host already folds records of its own, call `projectCompaction` first: +If your harness already folds records of its own, call `projectCompaction` first: ```ts group=harness-compaction import type { ProjectOptions } from '@tanstack/ai-harness' diff --git a/docs/harness/connect.md b/docs/harness/connect.md index 8d6b23afe1..93482b85c0 100644 --- a/docs/harness/connect.md +++ b/docs/harness/connect.md @@ -18,7 +18,8 @@ Your harness runs on a server, but the people who use it are in a browser or ano 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 { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' @@ -42,7 +43,7 @@ export const handler = createHarnessHandler({ `canAccess` is required. It decides which threads each principal can use. To let every principal use every thread, pass `canAccess: () => true`. -On a durable host (`stores.log`), also set `mediaSecret`. It signs media URLs, so every instance must use the same value. Without a log store, the default is a random key, and signed URLs stop working after a restart. +On a durable host (`stores.log`), also set `media: { secret }`. The secret signs media URLs, so every instance must use the same value. Without a log store, the default is a random key, and signed URLs stop working after a restart. The handler answers these paths under your route: @@ -81,10 +82,10 @@ export const team = defineHarness({ | 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)`. | +| `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)`, or with `/name` in a [session view](./custom-ui) on a client. | -| `tools` | Run these harness tools with no model turn, with `client.tool(name, args)`. See [Run a tool without a model](./tool-runs). | +| `commands` | Run these commands with `client.command({ name })`, or with `/name` in a [session view](./custom-ui) on a client. | +| `tools` | Run these harness tools with no model turn, with `client.tool({ name, args })`. See [Run a tool without a model](./tool-runs). | | `records` | Add records of these types to the thread with `client.append(records)`. See [Let a client add records](./session-log#let-a-client-add-records). | - Any other input of these kinds gets `{ status: 'rejected', reason: 'not_exposed' }`. @@ -127,7 +128,8 @@ A background agent runs for minutes, and the user wants to correct it from the p ```ts group=harness-connect-agents import { defineAgent } from '@tanstack/ai' -import { createHarnessHandler, createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' @@ -309,7 +311,7 @@ The join has these rules: 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 { handleHarnessSocket } from '@tanstack/ai-harness/server' import type { WebSocketLike } from '@tanstack/ai' export function onUpgrade(socket: WebSocketLike) { diff --git a/docs/harness/custom-ui.md b/docs/harness/custom-ui.md index d262a8d1da..ecf160444c 100644 --- a/docs/harness/custom-ui.md +++ b/docs/harness/custom-ui.md @@ -167,19 +167,19 @@ Send work: - `view.send(text)`: sends a prompt. While a turn runs, the text steers that turn. Text that starts with `/` runs a command, for example `/goal all tests pass`. - `view.followUp(text)`: the message waits, and runs as its own turn after the current work. - `view.continue()`: starts a turn from the saved messages, with no new message. -- `view.command(name, input)`: runs a command, for example from a button. -- `view.upload(bytes, { name, mimeType })`: stores a file. Send the record you get back with `view.send(text, [record])`. See [Show media](#show-media). +- `view.command({ name, input })`: runs a command, for example from a button. +- `view.upload({ body, mimeType, name })`: stores a file. Send the record you get back with `view.send(text, [record])`. See [Show media](#show-media). Change the session: - `view.setConfig({ key, value })`: changes a config value, for example `view.setConfig({ key: 'mode', value: 'plan' })`. -- `view.configure(settings)`: changes the stored settings of the thread, for example `view.configure({ model: 'strong' })`. -- `view.reset(note)`: the model starts fresh from the next turn, with your `note` first. See [Reset with a handoff note](./fork-and-reset#reset-with-a-handoff-note). +- `view.configure({ settings })`: changes the stored settings of the thread, for example `view.configure({ settings: { model: 'strong' } })`. +- `view.reset({ note })`: the model starts fresh from the next turn, with your `note` first. See [Reset with a handoff note](./fork-and-reset#reset-with-a-handoff-note). - `view.revert(messageId)` and `view.unrevert()`: go back to a message, and undo that. Stop work: -- `view.cancel()`: cancels the running turn. `view.cancel(operationId)` cancels one operation, for example a background agent. +- `view.cancel()`: cancels the running turn. `view.cancel({ operationId })` cancels one operation, for example a background agent. - `view.cancelInput(inputId)`: drops a message from `waitingInputs` before it runs. `view.setDelivery({ inputId, delivery: 'steer' })` moves it into the running turn instead. - `view.dispose()`: stops the view when your screen closes. After that, each action throws an error. @@ -224,7 +224,19 @@ if (agent) { `agent.cancel()` stops the run, and the runs it started. -A session and a `HarnessClient` can do all of this. If you give `createSessionView` your own `SessionViewSource`, `send`, `resolve`, and `cancel` are required. `send` takes `mode` for a steer or a follow-up, and `to` for a message to an agent run. Without `reset` or another optional member, its action adds an `error` notice instead. +A session and a `HarnessClient` can do all of this. You can also give `createSessionView` your own `SessionViewSource`. Its methods take the same arguments as the session methods, for example `resolve({ resume })` and `cancel({ operationId })`. `send` takes `mode` for a steer or a follow-up, and `to` for a message to an agent run. + +A source must have these members: + +- Inputs: `send`, `resolve`, `cancel`, `answer`, `command`, and `setConfig`. +- Reads: `events`, `snapshot`, `transcript`, and `describe`. + +These members are optional: + +- `continue`, `cancelInput`, `setDelivery`, `configure`, `reset`, `revert`, and `unrevert`. Without one, its view action adds an `error` notice. +- `upload` or `putMedia`. If the source has neither, `view.upload` rejects. +- `mediaUrl` and `loadMedia`. Without `mediaUrl`, media parts have no `url`. Without `loadMedia`, `load()` rejects. +- `agentTranscript`. Without it, a reload shows no child parts. ## 4. Listen for events @@ -395,14 +407,15 @@ To send files with a message, give their records to `view.send`. This code sends ```ts group=harness-custom-ui import { readFile } from 'node:fs/promises' -const screenshot = await session.putMedia(await readFile('./bug.png'), { +const screenshot = await session.putMedia({ + body: await readFile('./bug.png'), mimeType: 'image/png', name: 'bug.png', }) await view.send('What is wrong in this screenshot?', [screenshot]) ``` -In a browser, get the record from `client.upload(file, { name: file.name, mimeType: file.type })`. The user message then shows the file in `media`. +In a browser, get the record from `client.upload({ body: file, mimeType: file.type, name: file.name })`. The user message then shows the file in `media`. ## Show subagents @@ -446,7 +459,8 @@ Your harness runs on a server, and your UI is a web page. Give `createSessionVie Serve the session with `createHarnessHandler`, and mount the handler on `/api/harness`: ```ts group=harness-custom-ui-server -import { createHarnessHandler, createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' diff --git a/docs/harness/durable-sessions.md b/docs/harness/durable-sessions.md index d18ea03936..805b68afeb 100644 --- a/docs/harness/durable-sessions.md +++ b/docs/harness/durable-sessions.md @@ -143,7 +143,7 @@ The session claims its thread for its host when the thread gets work: - A queued turn that the session recovers. - A background agent run. -The session renews the claim on the lease timer: every `lease.renewMs`, for `lease.ttlMs`. See [One host drives a thread](#one-host-drives-a-thread) to set them. +The session renews the claim on the lease timer: every `ownership.renewMs`, for `ownership.ttlMs`. See [One host drives a thread](#one-host-drives-a-thread) to set them. The session gives the claim back when the thread is idle: no queued or running turn, no waiting steer, and no running agent. A thread that waits for a human (an approval or a sign-in) is idle, so a sweep never takes it. @@ -341,12 +341,12 @@ When the owner stops, its claim expires. Then the next host that opens the threa Without `workClaims`, a host cannot see the owner. When a second host writes to the thread, the first host's session stops, and its running turn fails with a clear error. -The lease decides when a stopped host counts as gone. Change its timing on the host: +The lease decides when a stopped host counts as gone. Change its timing with the `ownership` option of the host: ```ts group=harness-durable const leasedHost = createHarnessHost({ persistence: { stores: { log: memoryLogStore(), runs } }, - lease: { ttlMs: 60_000, renewMs: 20_000 }, + ownership: { ttlMs: 60_000, renewMs: 20_000 }, }) ``` @@ -374,7 +374,7 @@ Your job queue or submission table can already have leases. Give the host a `Lea - `release`: the host gives the lease back when the attempt ends. It also gets `ownerId` and `expiresAt`. - `isAlive`: after a crash, the host asks if another host still runs the attempt. -A host with `leases` does not need `runs`. If you give both, `leases` decides. The `lease` option sets the timing, as in the section above. +A host with `leases` does not need `runs`. If you give both, `leases` decides. The `ownership` option sets the timing, as in the section above. ```ts group=harness-durable import type { LeaseStore, TurnLease, TurnLeaseKey } from '@tanstack/ai-persistence' diff --git a/docs/harness/fork-and-reset.md b/docs/harness/fork-and-reset.md index f17ae9dcc6..11c834eb00 100644 --- a/docs/harness/fork-and-reset.md +++ b/docs/harness/fork-and-reset.md @@ -79,14 +79,14 @@ export async function editAndResend(threadId: string, messageId: string, text: s ## Reset with a handoff note -`session.reset(note)` starts a fresh model context. From the next turn, the model sees only the note and what comes after the reset: +`session.reset({ note })` starts a fresh model context. From the next turn, the model sees only the note and what comes after the reset: ```ts group=harness-fork-and-reset export async function startFresh(threadId: string) { const session = await host.open(assistant, { threadId }) - return session.reset( - 'Summary: the user moves the billing service to the new region. The DNS change is done. The database move is next.', - ) + return session.reset({ + note: 'Summary: the user moves the billing service to the new region. The DNS change is done. The database move is next.', + }) } ``` diff --git a/docs/harness/goal.md b/docs/harness/goal.md index 045d37c0c6..e27cb2b2d7 100644 --- a/docs/harness/goal.md +++ b/docs/harness/goal.md @@ -38,7 +38,7 @@ const session = await host.open(coder, { threadId: 'fix-tests' }) ## 2. Give it a goal ```ts group=harness-goal -await session.command('goal', 'all tests pass') +await session.command({ name: 'goal', input: 'all tests pass' }) ``` The first turn starts. Each new turn tells the model the reason of the last check: @@ -47,7 +47,7 @@ The first turn starts. Each new turn tells the model the reason of the last chec Keep working on the goal: all tests pass. Last check: The date parser test still fails. ``` -Run `session.command('goal')` to see the result: +Run `session.command({ name: 'goal' })` to see the result: ```text Goal: all tests pass diff --git a/docs/harness/inputs.md b/docs/harness/inputs.md index 386a240f66..a003b4a82e 100644 --- a/docs/harness/inputs.md +++ b/docs/harness/inputs.md @@ -185,7 +185,7 @@ A message that arrives while the model writes its final answer still gets an ans 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'` or `mode: 'steer'` message is the running turn. A cancel with that id cancels the turn. -- A `busy: 'steer'` message 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. +- A `busy: 'steer'` message also has its own operation, `joined`. To stop it before it joins, call `joined.cancel()` or `session.cancel({ operationId: 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. @@ -215,7 +215,7 @@ await migration - `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)`. +- 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: diff --git a/docs/harness/media.md b/docs/harness/media.md index d12e77e3f9..09018f0106 100644 --- a/docs/harness/media.md +++ b/docs/harness/media.md @@ -21,7 +21,8 @@ A session saves a file with `putMedia`. `mediaPart(record)` makes the content pa ```ts group=harness-media-code import { readFile } from 'node:fs/promises' -import { createHarnessHost, defineHarness, mediaPart } from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { mediaPart } from '@tanstack/ai-harness/media' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' @@ -33,7 +34,8 @@ const assistant = defineHarness({ const host = createHarnessHost({ persistence: memoryPersistence() }) const session = await host.open(assistant, { threadId: 'thread-1' }) -const scan = await session.putMedia(await readFile('./receipt.png'), { +const scan = await session.putMedia({ + body: await readFile('./receipt.png'), mimeType: 'image/png', name: 'receipt.png', }) @@ -50,7 +52,7 @@ console.log(turn.text) The session also has: - `getMedia(id)`: the record, or `null` when the thread has no file with this id. -- `loadMedia(id, range?)`: the bytes, or one range of them. +- `loadMedia({ id, range })`: the bytes, or one range of them. `range` is optional. - `mediaUrl(id)`: `{ url }`, with a data URL for a file up to 1 MB. A bigger file gets `{}`. Use `loadMedia` for it, and serve the bytes from your own route. ## Media that agents make @@ -128,7 +130,7 @@ A media record has: - `source`: `'user'` for a file that a user sent, and `'generated'` for a file that an agent made. - `runId` and `subagentRunId`: the turn and the child agent run that made the file. -At the end of a turn, the harness adds its records to the last assistant message, in `metadata.harness.media`. So the media comes back after a restart. Read the records with `mediaOfMessage(message)`. A failed or cancelled turn keeps its media in the store only. +At the end of a turn, the harness adds its records to the last assistant message, in `metadata.harness.media`. So the media comes back after a restart. Read the records with `mediaOfMessage(message)` from `@tanstack/ai-harness/media`. A failed or cancelled turn keeps its media in the store only. - If the harness cannot save a file, the agent still gets its result. The turn publishes a `harness.plugin.warning` event. - An agent that you run from code (`session.agents`, or `ctx.agents` in a plugin) also saves its media and publishes the event. This media is not added to the transcript. diff --git a/docs/harness/overview.md b/docs/harness/overview.md index 42ab38fabe..29c796ef1a 100644 --- a/docs/harness/overview.md +++ b/docs/harness/overview.md @@ -31,6 +31,36 @@ octane: @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai- +## Which import to use + +Import each name from the entry that holds it. Only four entries load Node modules: `plugins/node`, `plugins/coding`, `build`, and `worker`. All other entries also load on an edge runtime. One exception at run time: the loopback helpers of `oauth` (`startLoopbackReceiver` and `loopbackLogin`) start a local Node server, so call them only on Node. + +For your app and its routes: + +| Import | Holds | +| --- | --- | +| `@tanstack/ai-harness` | `defineHarness`, `createHarnessHost`, `definePlugin`, `durableTool`, `harnessText`, `oauthConnector`, and the session and agent types. | +| `@tanstack/ai-harness/server` | `createHarnessHandler` and `handleHarnessSocket`. For your own transport: `applyInput`, `parseHarnessInput`, `parseControlFrame`, `capabilitiesOf`, and `HARNESS_PROTOCOL_VERSION`. | +| `@tanstack/ai-harness/client` | `createHarnessClient`, a typed client for a browser or another process. | +| `@tanstack/ai-harness/view` | `createSessionView`, the live state of a session for a UI. | + +For plugins: + +| Import | Holds | +| --- | --- | +| `@tanstack/ai-harness/plugins` | The first-party plugins that load no Node module: `permissions`, `modelPicker`, `todos`, `goal`, `providerKeys`, and more. | +| `@tanstack/ai-harness/plugins/node` | The first-party plugins that read files. Node only: `agents`, `builtInAgents`, `fileCommands`, `projectInstructions`, and `boundToolOutput`. | +| `@tanstack/ai-harness/plugins/coding` | The coding tools. Node only: `workspaceTools`, `snapshots`, and `formatter`. | + +For helpers and builds: + +| Import | Holds | +| --- | --- | +| `@tanstack/ai-harness/oauth` | The OAuth helpers: `loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more. | +| `@tanstack/ai-harness/media` | The media helpers: `mediaPart`, `mediaOfMessage`, `MediaError`, and more. | +| `@tanstack/ai-harness/build` | `buildHarness` and `artifactText`, to ship a harness as a worker. Node only. | +| `@tanstack/ai-harness/worker` | `runHarnessWorker`, which runs a harness on stdin and stdout. Node only. | + ## 1. Define the harness `defineHarness` takes the `chat()` options you know (`adapter`, `systemPrompts`, `tools`, `middleware`, `subagents`), plus a `name`. Importing it starts nothing. diff --git a/docs/harness/permissions.md b/docs/harness/permissions.md index d2a59313c4..b08fb8aeab 100644 --- a/docs/harness/permissions.md +++ b/docs/harness/permissions.md @@ -87,10 +87,10 @@ Switch the mode from server code: ```ts group=harness-permissions await session.setConfig({ key: 'mode', value: 'plan' }) // Or run the command. It returns 'Mode: plan.' -await session.command('mode', 'plan') +await session.command({ name: 'mode', input: 'plan' }) ``` -`session.command('mode')` with no input shows the current mode. In `acceptEdits` mode, an edit outside `root` or of a `.env` file still asks. +`session.command({ name: 'mode' })` with no input shows the current mode. In `acceptEdits` mode, an edit outside `root` or of a `.env` file still asks. ### Keep the mode on the server @@ -251,7 +251,7 @@ A call that asks waits for one of three answers: ### See and forget saved answers -1. Run `session.command('permissions')`. The command lists the saved rules of the project: +1. Run `session.command({ name: 'permissions' })`. The command lists the saved rules of the project: ```text Saved rules: @@ -259,7 +259,7 @@ Saved rules: 2. read_file .env ``` -2. Run `session.command('permissions', 'forget 2')` to delete rule 2. The open session asks again at the next `read_file` call for `.env`. +2. Run `session.command({ name: 'permissions', input: 'forget 2' })` to delete rule 2. The open session asks again at the next `read_file` call for `.env`. A client can run the command only when `expose.commands` has `permissions`. diff --git a/docs/harness/plugins.md b/docs/harness/plugins.md index e0b9907427..b5f1fe22b5 100644 --- a/docs/harness/plugins.md +++ b/docs/harness/plugins.md @@ -80,7 +80,7 @@ export const greeter = definePlugin({ }) ``` -- `session.command('greet', { name: 'Ada' })` runs the command. A Standard Schema input (zod here) is checked, and the command gets the parsed value. A JSON Schema input is not checked, the same as a JSON Schema tool input. +- `session.command({ name: 'greet', input: { name: 'Ada' } })` runs the command. A Standard Schema input (zod here) is checked, and the command gets the parsed value. A JSON Schema input is not checked, the same as a JSON Schema tool input. - `session.setConfig({ key: 'tone', value: 'warm' })` changes the setting. A value the option does not accept is rejected. - `ask` waits for `session.answer({ questionId, value })`. The question goes to `session.onQuestion` listeners, and it stays in `session.snapshot().pendingQuestions` until someone answers. @@ -402,4 +402,4 @@ If a `setup` throws, the session keeps working with no plugins. The promise reje - Plugins that share services, lists, and events without knowing each other. - Commands, agents, and whole plugins that change while the session runs. -Next: add the first-party plugins from `@tanstack/ai-harness/plugins`. [Ask before risky tool calls](./permissions) and [Switch between agent profiles](./agents) are two of them. +Next: add the first-party plugins from `@tanstack/ai-harness/plugins` and `@tanstack/ai-harness/plugins/node`. [Ask before risky tool calls](./permissions) and [Switch between agent profiles](./agents) are two of them. diff --git a/docs/harness/provider-keys.md b/docs/harness/provider-keys.md index 399851f599..628728227e 100644 --- a/docs/harness/provider-keys.md +++ b/docs/harness/provider-keys.md @@ -93,7 +93,7 @@ const stopAsking = session.onEvent('CUSTOM', async ({ event }) => { if (question) await session.answer({ questionId: question.questionId, value: 'sk-proj-...' }) }) -console.log(await session.command('connect:openai')) // Connected to OpenAI (key ...abcd). +console.log(await session.command({ name: 'connect:openai' })) // Connected to OpenAI (key ...abcd). stopAsking() ``` diff --git a/docs/harness/session-log.md b/docs/harness/session-log.md index 95fb95cee8..4169368759 100644 --- a/docs/harness/session-log.md +++ b/docs/harness/session-log.md @@ -43,7 +43,7 @@ await session.append([{ type: 'app.signal', text: 'The build failed.' }]) ## Fold records into the model context -By default, the model does not see your records. Give the host a `project` function that folds a record into the messages. It gets one object, `{ messages, record }`, and returns the new list, or `undefined` for no change: +By default, the model does not see your records. Set `durability.project` on the harness to fold a record into the messages. Its `record` function gets one object, `{ messages, record }`, and returns the new list, or `undefined` for no change: ```ts group=harness-session-log import type { ProjectOptions } from '@tanstack/ai-harness' @@ -58,9 +58,10 @@ const project: ProjectOptions = { }, } -const projectedHost = createHarnessHost({ - persistence: { stores: { log, runs: memoryPersistence().stores.runs } }, - project, +export const projected = defineHarness({ + name: 'acme/projected', + adapter: openaiText('gpt-6.1-sol'), + durability: { project }, }) ``` @@ -72,29 +73,33 @@ const projectedHost = createHarnessHost({ ## Let a client add records -A webhook or a browser knows something that the model needs: the build failed, or a payment arrived. Let it add a record to the thread, and the next turn reads the record through `project`. +A webhook or a browser knows something that the model needs: the build failed, or a payment arrived. Let it add a record to the thread, and the next turn reads the record through `durability.project`. -1. On the server, list the record types in `expose.records`. Serve the harness from the host that has `project`: +1. On the server, list the record types in `expose.records`, and set `durability.project` on the same harness: ```ts group=harness-session-log -import { createHarnessHandler } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' export const watcher = defineHarness({ name: 'acme/build-watcher', adapter: openaiText('gpt-6.1-sol'), expose: { records: ['app.signal'] }, + durability: { project }, }) +// A durable host signs media URLs with the same secret on every instance. +const mediaSecret = process.env.HARNESS_MEDIA_SECRET +if (!mediaSecret) throw new Error('Set HARNESS_MEDIA_SECRET.') + export const handler = createHarnessHandler({ - host: projectedHost, + host, harness: watcher, authorize: (request) => request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}` ? { id: 'ci-bot' } : null, canAccess: () => true, - // A durable host signs media URLs with the same secret on every instance. - mediaSecret: process.env.HARNESS_MEDIA_SECRET, + media: { secret: mediaSecret }, }) ``` @@ -140,12 +145,12 @@ console.log(await messages.loadThread('thread-1')) ## Tune how text streams into the log -A durable host merges streamed text and tool call arguments for `coalesceMs` (default 100) before it appends them. A message start or end, a tool call start or end, and your own records append at once. +A durable host merges streamed text and tool call arguments for `log.coalesceMs` milliseconds (default 100) before it appends them. A message start or end, a tool call start or end, and your own records append at once. ```ts group=harness-session-log const smoothHost = createHarnessHost({ persistence: { stores: { log, runs: memoryPersistence().stores.runs } }, - coalesceMs: 250, + log: { coalesceMs: 250 }, }) ``` diff --git a/docs/harness/shared-logs.md b/docs/harness/shared-logs.md index 868f790455..12af3aa9a7 100644 --- a/docs/harness/shared-logs.md +++ b/docs/harness/shared-logs.md @@ -66,7 +66,7 @@ Here the child record and the link in the parent land together, or not at all. ## Keep state for the whole log -Give the host a `reduce` option. It folds every host record of the log, from all sessions, in log order. Then `host.logState` reads the result: +Give the harness a `durability.reduce` option. It folds every host record of the log, from all sessions, in log order. Then `host.logState` reads the result: ```ts group=harness-shared-logs import type { ReduceOptions } from '@tanstack/ai-harness' @@ -84,23 +84,25 @@ const reduce: ReduceOptions = { : state, } -const trackedHost = createHarnessHost({ - persistence: { stores: { log, runs, metadata } }, - reduce, +const tracker = defineHarness({ + name: 'acme/tracker', + adapter: openaiText('gpt-6.1-sol'), + durability: { reduce }, }) -const tracked = await trackedHost.open(assistant, { threadId: 'agent-2' }) +const tracked = await host.open(tracker, { threadId: 'agent-2' }) await tracked.append([ { type: 'app.child_created', thread: 'task-2', task: 'research' }, ]) -console.log(trackedHost.logState('agent-2')?.tasks) +console.log(host.logState({ harness: tracker, logId: 'agent-2' })?.tasks) ``` - `record` must be pure. It returns the next state, or the same state for no change. -- `host.logState(logId)` works for a log that has an open session in this host. Otherwise it returns `undefined`. +- `host.logState({ harness, logId })` works for a log that has an open session in this host. Otherwise it returns `undefined`. - The type of the state flows from `initial` and `record`. Here `logState` returns `Children | undefined`. +- If sessions of several harnesses share one log, the harness of the first session that opens the log folds it. For the other harnesses, `logState` returns `undefined`. - Change `version` when you change `record`. A fold checkpoint with another version is ignored. -- `project` still works. It folds a host record into the messages of its own session only. +- `durability.project` also works with `reduce`. It folds a host record into the messages of its own session only. ## One writer per log @@ -124,5 +126,5 @@ console.log(await messages.loadThread('task-1')) - Many sessions in one durable log, each with its own transcript. - Appends that reach several sessions and land all together or not at all. -- One state for the whole log, from `reduce`, that you read with `host.logState`. +- One state for the whole log, from `durability.reduce`, that you read with `host.logState`. - A clear rule for conflicts: one writer, and a fresh fold on the next open. diff --git a/docs/harness/shared-threads.md b/docs/harness/shared-threads.md index 6da9613c88..305ec28fb2 100644 --- a/docs/harness/shared-threads.md +++ b/docs/harness/shared-threads.md @@ -19,11 +19,8 @@ Several people write in one thread: a team in one chat, or a customer and a supp `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 { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' @@ -92,7 +89,7 @@ const session = await host.open(assistant, { threadId: 'team-thread' }) const ada = { id: 'ada', tenantId: 'acme' } session.send('Summarize the open issues.', { principal: ada }) -session.command('connect:github', undefined, { principal: ada }) +session.command({ name: 'connect:github', principal: ada }) ``` A command acts for the person who runs it. In its `run` context, `session.principal` is that person, and `session.send` runs as that person. diff --git a/docs/harness/snapshots.md b/docs/harness/snapshots.md index e2533874ad..790719cb9d 100644 --- a/docs/harness/snapshots.md +++ b/docs/harness/snapshots.md @@ -52,9 +52,9 @@ const session = await host.open(coder, { threadId: 'fix-login' }) ## 2. Undo and redo a turn 1. Send a prompt that changes files: `await session.send('Fix the login form.')`. -2. Run `await session.command('undo')`. It returns the number of files that it put back, for example `Undid the last turn. Files restored: 3.` +2. Run `await session.command({ name: 'undo' })`. It returns the number of files that it put back, for example `Undid the last turn. Files restored: 3.` 3. Look at the files that the turn changed: they are as they were before the turn. The turn is gone from the conversation too. -4. To bring back the files and the turn, run `await session.command('redo')`. It returns `Redid the last turn.` +4. To bring back the files and the turn, run `await session.command({ name: 'redo' })`. It returns `Redid the last turn.` In a terminal app, the user types `/undo` and `/redo`. See [Build a coding agent](./coding-agent). @@ -64,7 +64,7 @@ In a terminal app, the user types `/undo` and `/redo`. See [Build a coding agent - The snapshots skip the files that `.gitignore` names, like `node_modules`. `/undo` does not touch them. - A new turn clears `/redo`. -In your own UI, add `expose: { commands: ['undo', 'redo'] }` to `defineHarness`. A client can run only the commands that the harness exposes. Then call `client.command('undo')`. The result text arrives as a `harness.command.result` event. +In your own UI, add `expose: { commands: ['undo', 'redo'] }` to `defineHarness`. A client can run only the commands that the harness exposes. Then call `client.command({ name: 'undo' })`. The result text arrives as a `harness.command.result` event. ## See what a step changed diff --git a/docs/harness/subagents.md b/docs/harness/subagents.md index 8d637a4f03..90b0e37ca4 100644 --- a/docs/harness/subagents.md +++ b/docs/harness/subagents.md @@ -122,7 +122,10 @@ export const migrations = definePlugin({ 2. Put each side effect in `ctx.harness.step.do(name, fn)`. The agent code runs again from its start, and a finished step returns its stored value. 3. Give each step a name that is the same on every run, and return a JSON value from `fn`. -`ctx.harness` is on the context of every agent run that a session starts. Outside such a run, `step.do` runs `fn` each time. +`ctx.harness` gives the agent `step` and `agents`. When your app loads `@tanstack/ai-harness`, every agent run has it. A run that a session starts from code gets the full context. In other runs, for example an agent that the model calls as a tool, or an agent in a plain `chat()`: + +- `step.do` runs `fn` each time. +- `agents.start` throws an error. What the next host does: diff --git a/docs/harness/thread-settings.md b/docs/harness/thread-settings.md index 9de666b134..cf22b24cf6 100644 --- a/docs/harness/thread-settings.md +++ b/docs/harness/thread-settings.md @@ -41,9 +41,11 @@ const host = createHarnessHost({ persistence: memoryPersistence() }) const session = await host.open(assistant, { threadId: 'review-thread' }) const receipt = await session.configure({ - model: 'strong', - reasoning: 'high', - instructions: 'Answer in French.', + settings: { + model: 'strong', + reasoning: 'high', + instructions: 'Answer in French.', + }, }) console.log(receipt.status) // 'accepted' console.log(session.settings()) // { model: 'strong', reasoning: 'high', instructions: 'Answer in French.' } @@ -52,7 +54,7 @@ console.log(session.settings()) // { model: 'strong', reasoning: 'high', instruc The settings apply from the next turn. A field that you leave out stays as it is. Set a field to `null` to clear it: ```ts group=harness-thread-settings -await session.configure({ instructions: null }) +await session.configure({ settings: { instructions: null } }) ``` The six fields: @@ -81,7 +83,7 @@ const { models, settings } = session.describe() console.log(models) // ['fast', 'strong'] console.log(settings.model) // 'strong' -await session.configure({ model: 'fast' }) +await session.configure({ settings: { model: 'fast' } }) ``` ## What wins @@ -108,7 +110,7 @@ For `reasoning`, the `overrides` of the prompt win, then the stored setting, the ```ts group=harness-thread-settings const task = await host.open(assistant, { threadId: 'fix-login' }) -await task.configure({ cwd: 'web-app' }) +await task.configure({ settings: { cwd: 'web-app' } }) console.log(task.settings().cwd) // 'web-app' ``` diff --git a/docs/harness/tool-runs.md b/docs/harness/tool-runs.md index 8895278d43..8bf41abc7f 100644 --- a/docs/harness/tool-runs.md +++ b/docs/harness/tool-runs.md @@ -17,7 +17,7 @@ A cron job or a webhook needs to run one tool, not hold a conversation. You want ## Run a tool from server code -Call `session.tool(name, args?)`. A schedule or a webhook handler calls it. +Call `session.tool({ name, args })`. A schedule or a webhook handler calls it. ```ts group=harness-tool-runs import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' @@ -43,7 +43,7 @@ const host = createHarnessHost({ persistence: memoryPersistence() }) const session = await host.open(dashboard, { threadId: 'ops-thread' }) // From a cron job, every hour: -const run = session.tool('fetchStats', { window: '1h' }) +const run = session.tool({ name: 'fetchStats', args: { window: '1h' } }) const { text } = await run ``` @@ -53,18 +53,18 @@ const { text } = await run - While an approval waits, `session.tool` is refused with `pending_interrupts`. Answer the approval first. - Pass `inputId` to make the run idempotent, as [`send`](./inputs) does. -TypeScript types `args` from the input schema of a harness tool. `session.tool('fetchStats', { window: 1 })` does not compile. A plugin tool takes any `args`, and its input schema checks them when the tool runs. +TypeScript types `args` from the input schema of a harness tool. `session.tool({ name: 'fetchStats', args: { window: 1 } })` does not compile. A plugin tool takes any `args`, and its input schema checks them when the tool runs. ## Tag a run with its trigger Pass `meta` to record why the tool ran, for example the schedule or the webhook event. The harness sends it as a `tanstack.injection` custom event with the tool call id. ```ts group=harness-tool-runs -const fromWebhook = session.tool( - 'fetchStats', - { window: '24h' }, - { meta: { trigger: 'webhook', deliveryId: 'whk-99' } }, -) +const fromWebhook = session.tool({ + name: 'fetchStats', + args: { window: '24h' }, + meta: { trigger: 'webhook', deliveryId: 'whk-99' }, +}) await fromWebhook ``` @@ -91,7 +91,7 @@ This works for a model call and for `session.tool`. Your own `context` data stay ## Let a client run a tool 1. Add the tool to `expose.tools`. TypeScript suggests the names in `tools`, and `defineHarness` throws for any other name. -2. Run it from the client with `client.tool(name, args?, options?)`. `args` is typed from the tool's input schema. +2. Run it from the client with `client.tool({ name, args, meta, inputId })`. `args` is typed from the tool's input schema. ```ts group=harness-tool-runs-client import { createHarnessClient } from '@tanstack/ai-harness/client' @@ -103,11 +103,11 @@ const client = createHarnessClient({ headers: { authorization: 'Bearer my-token' }, }) -const receipt = await client.tool( - 'fetchStats', - { window: '1h' }, - { inputId: 'hourly-stats-09' }, -) +const receipt = await client.tool({ + name: 'fetchStats', + args: { window: '1h' }, + inputId: 'hourly-stats-09', +}) ``` - The receipt has your `inputId`. It is `queued` while another turn runs. diff --git a/docs/harness/turn-control.md b/docs/harness/turn-control.md index e121275f88..d3c36357e1 100644 --- a/docs/harness/turn-control.md +++ b/docs/harness/turn-control.md @@ -186,7 +186,7 @@ A message with `busy: 'steer'` waits while a turn runs. Before each model call, The join stops at the first message that: -- has an abort request (`session.cancel(operationId)` on it), or +- has an abort request (`session.cancel({ operationId })` on it), or - `turn.canJoin` refuses. 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. diff --git a/docs/harness/usage.md b/docs/harness/usage.md index f206d819e2..338198c363 100644 --- a/docs/harness/usage.md +++ b/docs/harness/usage.md @@ -140,7 +140,7 @@ export const priced = defineHarness({ }) ``` -`session.command('usage')` reads `session.usage()` and shows the total. When the thread used more than one model, it also shows a line for each model: +`session.command({ name: 'usage' })` reads `session.usage()` and shows the total. When the thread used more than one model, it also shows a line for each model: ```text 3 model calls, 41200 input tokens (30000 cache read, 0 cache write), 2100 output tokens, 43300 total. Cost: $0.0432. diff --git a/packages/ai-acp/package.json b/packages/ai-acp/package.json index 4893a07c80..47a45e5bb8 100644 --- a/packages/ai-acp/package.json +++ b/packages/ai-acp/package.json @@ -48,7 +48,7 @@ "test:types": "tsc" }, "dependencies": { - "@agentclientprotocol/sdk": "^1.5.0" + "@agentclientprotocol/sdk": "~1.8.0" }, "peerDependencies": { "@tanstack/ai": "workspace:^", diff --git a/packages/ai-acp/src/agent/index.ts b/packages/ai-acp/src/agent/index.ts index cc43fdde84..2aedd26b39 100644 --- a/packages/ai-acp/src/agent/index.ts +++ b/packages/ai-acp/src/agent/index.ts @@ -6,12 +6,8 @@ import { agent, ndJsonStream, } from '@agentclientprotocol/sdk/experimental/v2' -import { - InputRejectedError, - MediaError, - kindOf, - mediaPart, -} from '@tanstack/ai-harness' +import { InputRejectedError } from '@tanstack/ai-harness' +import { MediaError, kindOf, mediaPart } from '@tanstack/ai-harness/media' import type { AgentApp, AgentContext, @@ -79,7 +75,9 @@ async function storedPart( `The data of ${info.name} is not base64.`, ) } - return mediaPart(await session.putMedia(Buffer.from(data, 'base64'), info)) + return mediaPart( + await session.putMedia({ body: Buffer.from(data, 'base64'), ...info }), + ) } /** One ACP prompt block as a content part, or `undefined` to skip it. */ @@ -312,7 +310,7 @@ export function createAcpAgent(options: AcpAgentOptions): AgentApp { payload: 'optionId' in outcome && outcome.optionId === 'allow', }) } - const receipt = await session.resolve(resume) + const receipt = await session.resolve({ resume }) // For example `busy`: a later prompt runs already. if (receipt.status === 'rejected') throw new InputRejectedError(receipt) return { @@ -331,12 +329,12 @@ export function createAcpAgent(options: AcpAgentOptions): AgentApp { const closeInterrupts = async (session: HarnessSession) => { const open = session.snapshot().pendingInterrupts if (open.length === 0) return true - const receipt = await session.resolve( - open.map((interrupt) => ({ + const receipt = await session.resolve({ + resume: open.map((interrupt) => ({ interruptId: interrupt.id, status: 'cancelled' as const, })), - ) + }) if (receipt.status === 'rejected') throw new InputRejectedError(receipt) // ponytail: the reply of the model to the cancel stays out of the client. const turn = receipt.operationId diff --git a/packages/ai-acp/tests/agent-media.test.ts b/packages/ai-acp/tests/agent-media.test.ts index fe9df9b4ba..2d11baf822 100644 --- a/packages/ai-acp/tests/agent-media.test.ts +++ b/packages/ai-acp/tests/agent-media.test.ts @@ -2,11 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { EventType } from '@tanstack/ai' import { client } from '@agentclientprotocol/sdk/experimental/v2' import { memoryPersistence } from '@tanstack/ai-persistence' -import { - createHarnessHost, - defineHarness, - mediaOfMessage, -} from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { mediaOfMessage } from '@tanstack/ai-harness/media' import { createAcpAgent } from '../src/agent' import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' import type { @@ -14,7 +11,8 @@ import type { ContentBlock, InitializeResponse, } from '@agentclientprotocol/sdk/experimental/v2' -import type { MediaOptions, MediaRecord } from '@tanstack/ai-harness' +import type { MediaRecord } from '@tanstack/ai-harness' +import type { MediaOptions } from '@tanstack/ai-harness/media' const now = () => Date.now() @@ -207,7 +205,7 @@ describe('ACP agent media', () => { }, }, ]) - expect(await session.loadMedia(record.id)).toEqual( + expect(await session.loadMedia({ id: record.id })).toEqual( new Uint8Array([1, 2, 3, 4]), ) }, diff --git a/packages/ai-code-mode/tests/harness.test.ts b/packages/ai-code-mode/tests/harness.test.ts index ed11a2a565..6ec6b0be34 100644 --- a/packages/ai-code-mode/tests/harness.test.ts +++ b/packages/ai-code-mode/tests/harness.test.ts @@ -209,7 +209,9 @@ async function firstToolNames(options: { tools: options.tools, plugins: () => options.plugins, }), - { threadId: 't' }, + { + threadId: 't', + }, ) await session.send('hi') await host.close() @@ -231,7 +233,9 @@ describe('codeMode', () => { tools: [tools.lookup, tools.remove, tools.askUser], plugins: () => [writes, notion, codeMode({ driver })], }), - { threadId: 't' }, + { + threadId: 't', + }, ) const turn = await session.send('look it up') expect(turn.text).toBe('done') @@ -268,7 +272,9 @@ describe('codeMode', () => { codeMode({ driver, include: (tool) => tool.name === 'remove' }), ], }), - { threadId: 'picked' }, + { + threadId: 'picked', + }, ) await picked.send('hi') expect(calls[0].tools.map((tool: { name: string }) => tool.name)).toEqual([ @@ -283,7 +289,9 @@ describe('codeMode', () => { tools: [tools.remove], plugins: () => [codeMode({ driver })], }), - { threadId: 'none' }, + { + threadId: 'none', + }, ) await none.send('hi') expect(calls[1].tools.map((tool: { name: string }) => tool.name)).toEqual([ @@ -309,7 +317,9 @@ describe('codeMode', () => { tools: [tools.lookup, tools.remove], plugins: () => [codeMode({ driver, lazy: true })], }), - { threadId: 't' }, + { + threadId: 't', + }, ) expect((await session.send('what can you call?')).text).toBe('found it') @@ -362,7 +372,9 @@ describe('codeMode', () => { ], plugins: () => [codeMode({ driver })], }), - { threadId: 't' }, + { + threadId: 't', + }, ) await session.send('search') // web_search clashes with web-search after the rename, so it stays direct. @@ -503,9 +515,12 @@ describe('codeMode', () => { codeMode({ driver }), ], }), - { threadId: 't' }, + { + threadId: 't', + }, ) - if (mode === 'plan') await session.command('mode', 'plan') + if (mode === 'plan') + await session.command({ name: 'mode', input: 'plan' }) expect((await session.send('look it up')).text).toBe('done') // The program ran: a question would wait, and plan mode would deny it. expect(seen).toHaveLength(1) @@ -530,7 +545,9 @@ describe('codeMode', () => { codeMode({ driver }), ], }), - { threadId: 't' }, + { + threadId: 't', + }, ) const turn = session.send('look it up') await vi.waitFor(() => @@ -568,7 +585,9 @@ describe('codeMode', () => { }), ], }), - { threadId: 't' }, + { + threadId: 't', + }, ) await session.send('first') expect(JSON.stringify(calls[0].systemPrompts)).toContain('external_lookup') diff --git a/packages/ai-harness-cli/src/attach.ts b/packages/ai-harness-cli/src/attach.ts index 880689a407..c576fcd855 100644 --- a/packages/ai-harness-cli/src/attach.ts +++ b/packages/ai-harness-cli/src/attach.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' -import { mediaPart, mimeTypeOf } from '@tanstack/ai-harness' +import { mediaPart, mimeTypeOf } from '@tanstack/ai-harness/media' import type { ContentPart } from '@tanstack/ai' import type { HarnessSession, @@ -73,7 +73,8 @@ export async function attach( if (file === undefined) continue rest += text.slice(end, token.index) end = token.index + token[0].length - const record = await session.putMedia(file.bytes, { + const record = await session.putMedia({ + body: file.bytes, mimeType: file.mimeType, name: file.name, }) @@ -109,7 +110,7 @@ export async function saveMedia( `Cannot save ${JSON.stringify(name)}: it is not a plain file name.`, ) } - const bytes = await session.loadMedia(record.id) + const bytes = await session.loadMedia({ id: record.id }) await mkdir(dir, { recursive: true }) const dot = name.lastIndexOf('.') const stem = dot > 0 ? name.slice(0, dot) : name diff --git a/packages/ai-harness-cli/src/commands.ts b/packages/ai-harness-cli/src/commands.ts index 6d34b7d0d0..ddd90d332d 100644 --- a/packages/ai-harness-cli/src/commands.ts +++ b/packages/ai-harness-cli/src/commands.ts @@ -56,7 +56,7 @@ async function runCommand( ? show(outcome.value) : `/${name} failed: ${outcome.error instanceof Error ? outcome.error.message : String(outcome.error)}` const settled = Promise.resolve( - session.command(name, input).then( + session.command({ name, input }).then( (value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error }), ), diff --git a/packages/ai-harness-cli/src/index.ts b/packages/ai-harness-cli/src/index.ts index 190ba420bd..0bb0170dd0 100644 --- a/packages/ai-harness-cli/src/index.ts +++ b/packages/ai-harness-cli/src/index.ts @@ -93,17 +93,17 @@ function attachingSource(session: HarnessSession) { operation.then(undefined, () => {}) return operation.receipt }), - resolve: (resume) => session.resolve(resume), - cancel: (operationId) => session.cancel(operationId), + resolve: (options) => session.resolve(options), + cancel: (options) => session.cancel(options), answer: (answer) => session.answer(answer), - command: (name, input) => session.command(name, input), + command: (options) => session.command(options), setConfig: (change) => session.setConfig(change), events: (options) => session.events(options), snapshot: () => session.snapshot(), transcript: () => session.transcript(), describe: () => session.describe(), mediaUrl: (id) => session.mediaUrl(id), - loadMedia: (id) => session.loadMedia(id), + loadMedia: (options) => session.loadMedia(options), } return source } diff --git a/packages/ai-harness-cli/src/print.ts b/packages/ai-harness-cli/src/print.ts index 9265a931b7..9051008fd6 100644 --- a/packages/ai-harness-cli/src/print.ts +++ b/packages/ai-harness-cli/src/print.ts @@ -1,5 +1,6 @@ import { EventType } from '@tanstack/ai' -import { HARNESS_EVENTS, isMediaRecord } from '@tanstack/ai-harness' +import { HARNESS_EVENTS } from '@tanstack/ai-harness' +import { isMediaRecord } from '@tanstack/ai-harness/media' import { attach, saveMediaLine } from './attach' import type { HarnessSession } from '@tanstack/ai-harness' diff --git a/packages/ai-harness-cli/src/serve.ts b/packages/ai-harness-cli/src/serve.ts index ed8c574847..0a709c9f49 100644 --- a/packages/ai-harness-cli/src/serve.ts +++ b/packages/ai-harness-cli/src/serve.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { randomBytes, timingSafeEqual } from 'node:crypto' -import { createHarnessHandler } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { loadOptional } from './optional' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ReadableStream as NodeReadableStream } from 'node:stream/web' @@ -104,7 +104,7 @@ export async function serve( // One principal holds the token, so it can use every thread. canAccess: () => true, // ponytail: the token signs media URLs, so they work as long as it does. - mediaSecret: options.token, + media: { secret: options.token }, }) const mcp = await mcpServer(options) const route = (request: Request) => { diff --git a/packages/ai-harness-cli/src/session-view.ts b/packages/ai-harness-cli/src/session-view.ts index 29dea42704..937b1504a2 100644 --- a/packages/ai-harness-cli/src/session-view.ts +++ b/packages/ai-harness-cli/src/session-view.ts @@ -13,7 +13,7 @@ export function resolveAll( status: 'resolved', payload: approved, })) - return session.resolve(resume) + return session.resolve({ resume }) } /** Open an http(s) URL in the default browser. No shell is involved. */ diff --git a/packages/ai-harness-cli/tests/attach.test.ts b/packages/ai-harness-cli/tests/attach.test.ts index 1f0aaea15b..26db146163 100644 --- a/packages/ai-harness-cli/tests/attach.test.ts +++ b/packages/ai-harness-cli/tests/attach.test.ts @@ -10,11 +10,8 @@ import { it, vi, } from 'vitest' -import { - createHarnessHost, - defineHarness, - mediaIdOf, -} from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { mediaIdOf } from '@tanstack/ai-harness/media' import { memoryPersistence } from '@tanstack/ai-persistence' import { attach, defaultMediaDir, saveMedia } from '../src/attach' import type { AnyTextAdapter } from '@tanstack/ai' @@ -78,7 +75,9 @@ beforeAll(async () => { host = createHarnessHost({ persistence: memoryPersistence() }) session = await host.open( defineHarness({ name: 'test/attach', adapter: idleModel() }), - { threadId: 't' }, + { + threadId: 't', + }, ) }) @@ -98,7 +97,7 @@ async function storedFiles(input: UserInput) { return Promise.all( ids.map(async (id) => ({ name: (await session.getMedia(id))?.name, - text: new TextDecoder().decode(await session.loadMedia(id)), + text: new TextDecoder().decode(await session.loadMedia({ id })), })), ) } @@ -161,7 +160,8 @@ describe('attach', () => { describe('saveMedia', () => { it('makes the folder and adds a number instead of overwriting a file', async () => { - const record = await session.putMedia(new TextEncoder().encode('hello'), { + const record = await session.putMedia({ + body: new TextEncoder().encode('hello'), mimeType: 'image/png', name: 'cat.png', }) @@ -184,7 +184,8 @@ describe('saveMedia', () => { it.each(['../escaped.png', 'sub/escaped.png', 'sub\\escaped.png', '..'])( 'refuses the name %s', async (name) => { - const record = await session.putMedia(new TextEncoder().encode('x'), { + const record = await session.putMedia({ + body: new TextEncoder().encode('x'), mimeType: 'image/png', name, }) diff --git a/packages/ai-harness-cli/tests/child-view.test.ts b/packages/ai-harness-cli/tests/child-view.test.ts index f06771b9fa..c62dcd7568 100644 --- a/packages/ai-harness-cli/tests/child-view.test.ts +++ b/packages/ai-harness-cli/tests/child-view.test.ts @@ -117,7 +117,9 @@ describe('line mode with a child agent', () => { adapter: lead, subagents: { agents: [helper] }, }), - { threadId: 't' }, + { + threadId: 't', + }, ) const input = new PassThrough() let out = '' diff --git a/packages/ai-harness-cli/tests/commands.test.ts b/packages/ai-harness-cli/tests/commands.test.ts index e1d9c650cf..314c99914a 100644 --- a/packages/ai-harness-cli/tests/commands.test.ts +++ b/packages/ai-harness-cli/tests/commands.test.ts @@ -145,7 +145,9 @@ async function open(plugins = true) { adapter: model(), ...(plugins ? { agents: [counter], plugins: () => [tools] } : {}), }), - { threadId: 't' }, + { + threadId: 't', + }, ) const say = async (line: string) => { const result = await handleLine(session, line) diff --git a/packages/ai-harness/package.json b/packages/ai-harness/package.json index 51920da03c..1ca73466a6 100644 --- a/packages/ai-harness/package.json +++ b/packages/ai-harness/package.json @@ -22,7 +22,9 @@ "src" ], "type": "module", - "sideEffects": false, + "sideEffects": [ + "./dist/esm/agents.js" + ], "module": "./dist/esm/index.js", "types": "./dist/esm/index.d.ts", "exports": { @@ -30,6 +32,18 @@ "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js" }, + "./server": { + "types": "./dist/esm/server.d.ts", + "import": "./dist/esm/server.js" + }, + "./oauth": { + "types": "./dist/esm/oauth.d.ts", + "import": "./dist/esm/oauth.js" + }, + "./media": { + "types": "./dist/esm/media-entry.d.ts", + "import": "./dist/esm/media-entry.js" + }, "./client": { "types": "./dist/esm/client.d.ts", "import": "./dist/esm/client.js" @@ -50,6 +64,10 @@ "types": "./dist/esm/first-party/index.d.ts", "import": "./dist/esm/first-party/index.js" }, + "./plugins/node": { + "types": "./dist/esm/first-party/node.d.ts", + "import": "./dist/esm/first-party/node.js" + }, "./plugins/coding": { "types": "./dist/esm/first-party/coding/index.d.ts", "import": "./dist/esm/first-party/coding/index.js" diff --git a/packages/ai-harness/src/agents.ts b/packages/ai-harness/src/agents.ts index 681d717c2d..27bb361fca 100644 --- a/packages/ai-harness/src/agents.ts +++ b/packages/ai-harness/src/agents.ts @@ -4,6 +4,7 @@ import type { InferSchemaType, SchemaInput, } from '@tanstack/ai' +import { registerSubagentContextDefaults } from '@tanstack/ai/adapter-internals' import type { ToolStep } from './durable-tool' import type { AgentStartOptions } from './session/index' import type { AgentRun } from './types' @@ -147,7 +148,8 @@ export interface HarnessAgentContext { /** * `ctx.harness` outside a run that a session started from code (a tool-mode - * child of a chat turn): `step.do` runs `fn` each time, `agents.start` throws. + * child of a chat turn, or a run that a plain `chat()` starts): `step.do` + * runs `fn` each time, `agents.start` throws. */ export const NO_SESSION_CONTEXT: HarnessAgentContext = { step: { do: async (_name, fn) => fn() }, @@ -160,14 +162,19 @@ export const NO_SESSION_CONTEXT: HarnessAgentContext = { }, } +// A run that a plain chat() starts gets NO_SESSION_CONTEXT, so ctx.harness is +// always there. The sideEffects field of package.json keeps this module. +registerSubagentContextDefaults(() => ({ harness: NO_SESSION_CONTEXT })) + declare module '@tanstack/ai' { /** * `ctx.harness` in the `run` of an agent: `step` and `agents`. A harness * session gives them to each agent run it starts from code (a session * agent, `ctx.harness.agents.start`, a background child), and to the - * children those runs start through `ctx.chat({ subagents })`. A run - * that a plain `chat()` starts outside a harness has no `harness` at - * run time: such an agent reads `ctx.harness` only inside a harness. + * children those runs start through `ctx.chat({ subagents })`. Any + * other run, for example one that a plain `chat()` starts, gets + * `NO_SESSION_CONTEXT`: `step.do` runs `fn` each time, and `agents.start` + * throws. */ interface SubagentContextExtension { harness: HarnessAgentContext diff --git a/packages/ai-harness/src/client.ts b/packages/ai-harness/src/client.ts index f260b1b218..4e973ffdb8 100644 --- a/packages/ai-harness/src/client.ts +++ b/packages/ai-harness/src/client.ts @@ -2,7 +2,7 @@ // 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 { ModelMessage } from '@tanstack/ai' import type { LogRecord, SessionIndexEntry, @@ -21,13 +21,18 @@ import type { SessionDescription, SessionSnapshot } from './session/index' import type { SessionEntryChange } from './session-entry' import type { BusyPolicy, + CancelOptions, + CommandOptions, + ConfigureOptions, Cursor, ForkPoint, HarnessInput, MediaRecord, + MediaUpload, Receipt, + ResetOptions, + ResolveOptions, SessionEvent, - ThreadSettingsChange, UserInput, WaitingInput, } from './types' @@ -105,8 +110,25 @@ export interface HarnessClient { inputId?: string ephemeral?: ReadonlyArray }) => Promise - resolve: (resume: Array) => Promise - cancel: (operationId?: string) => Promise + /** + * Answer the interrupts of the last turn, as `session.resolve` does. + * + * @example + * ```ts + * await client.resolve({ resume: [{ interruptId, status: 'resolved' }] }) + * ``` + */ + resolve: (options: ResolveOptions) => Promise + /** + * Stop the operation `operationId`, or the running turn without it, as + * `session.cancel` does. + * + * @example + * ```ts + * await client.cancel({ operationId }) + * ``` + */ + cancel: (options?: CancelOptions) => Promise /** * Cancel an input that waits (see `snapshot().waitingInputs`). An input * that started is refused with `not_waiting`: use `cancel`. @@ -123,18 +145,32 @@ export interface HarnessClient { agents: ClientAgentHandles /** Answer a question from a command or a plugin. */ answer: (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 + /** + * Run the plugin command `name`. Its result arrives as a + * `harness.command.result` event. + * + * @example + * ```ts + * await client.command({ name: 'mode', input: 'plan' }) + * ``` + */ + command: (options: CommandOptions) => Promise /** * Run a harness tool with no model turn, as `session.tool` does. The * harness must list it in `expose.tools`. `args` is typed from the * tool's input schema. + * + * @example + * ```ts + * await client.tool({ name: 'sync_inbox', args: { folder: 'INBOX' } }) + * ``` */ - tool: ['name']>( - name: TName, - args?: HarnessToolInput, - options?: { inputId?: string; meta?: Record }, - ) => Promise + tool: ['name']>(options: { + name: TName + args?: HarnessToolInput + meta?: Record + inputId?: string + }) => Promise /** * Add host records to the session log, as `session.append` does. The * harness must list each record type in `expose.records`. @@ -142,13 +178,25 @@ export interface HarnessClient { append: (records: ReadonlyArray) => Promise /** Change a session setting. */ setConfig: (change: { key: string; value: unknown }) => Promise - /** Change the stored settings of the thread. See `session.configure`. */ - configure: (settings: ThreadSettingsChange) => Promise + /** + * Change the stored settings of the thread. See `session.configure`. + * + * @example + * ```ts + * await client.configure({ settings: { model: 'strong' } }) + * ``` + */ + configure: (options: ConfigureOptions) => Promise /** * Start a fresh model context from the next turn. The model sees `note` * first, when you give one. The transcript keeps every message. + * + * @example + * ```ts + * await client.reset({ note: 'The user wants a new plan.' }) + * ``` */ - reset: (note?: string, options?: { inputId?: string }) => Promise + reset: (options?: ResetOptions) => Promise /** * Go back to the message `messageId`, as `session.revert` does. The * harness must list `'undo'` in `expose.commands`. @@ -192,18 +240,29 @@ export interface HarnessClient { * 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. + * + * @example + * ```ts + * const record = await client.upload({ body: file, mimeType: file.type, name: file.name }) + * ``` */ upload: ( - body: Blob | ArrayBuffer | Uint8Array, - info: { name: string; mimeType: string }, + file: MediaUpload, ) => Promise /** * A signed URL for a media file, for ``, `