From 58b65bc0ff7f34b7618ac0ecb44eebe9c5e28c62 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Thu, 22 Oct 2026 14:49:34 +0200 Subject: [PATCH] chore: merge Tau changesets so the release changelog query fits changesets/changelog-github sends one GraphQL field per changelog line. Tau's 48 changesets share one squash commit, so that commit repeated 950 times in one query, and GitHub failed it with validationTimeout. Merge the 11 core changesets into tau-core and the 18 ai-harness-only changesets into ai-harness-core. The planned versions do not change. --- .changeset/ai-harness-coding-tools.md | 20 ---- .changeset/ai-harness-core.md | 114 +++++++++++++++++++ .changeset/ai-harness-goal.md | 5 - .changeset/ai-harness-plugins.md | 18 --- .changeset/ai-harness-provider-keys.md | 5 - .changeset/ai-harness-transport.md | 23 ---- .changeset/ai-harness-view.md | 14 --- .changeset/ai-subagent-binding-interrupts.md | 16 --- .changeset/ai-tool-guard.md | 11 -- .changeset/ai-websocket-buffered-amount.md | 5 - .changeset/approval-raw-edits-resume.md | 9 -- .changeset/byok-credential-store.md | 20 ---- .changeset/chat-resume-should-continue.md | 5 - .changeset/harness-build-worker.md | 5 - .changeset/harness-define-plugin-keys.md | 5 - .changeset/harness-file-changed.md | 5 - .changeset/harness-join-any-instance.md | 9 -- .changeset/harness-log-newer-records.md | 5 - .changeset/harness-logger.md | 5 - .changeset/harness-permission-call.md | 5 - .changeset/harness-redaction.md | 12 -- .changeset/harness-round5-asks.md | 23 ---- .changeset/harness-secure-defaults.md | 10 -- .changeset/harness-tool-ops.md | 35 ------ .changeset/harness-turn-and-body-limits.md | 14 --- .changeset/harness-view-experimental.md | 5 - .changeset/nested-subagent-resume.md | 6 - .changeset/subagent-binding-debug.md | 5 - .changeset/subagent-context-extend.md | 8 -- .changeset/tau-core.md | 112 ++++++++++++++++++ 30 files changed, 226 insertions(+), 308 deletions(-) delete mode 100644 .changeset/ai-harness-coding-tools.md delete mode 100644 .changeset/ai-harness-goal.md delete mode 100644 .changeset/ai-harness-plugins.md delete mode 100644 .changeset/ai-harness-provider-keys.md delete mode 100644 .changeset/ai-harness-transport.md delete mode 100644 .changeset/ai-harness-view.md delete mode 100644 .changeset/ai-subagent-binding-interrupts.md delete mode 100644 .changeset/ai-tool-guard.md delete mode 100644 .changeset/ai-websocket-buffered-amount.md delete mode 100644 .changeset/approval-raw-edits-resume.md delete mode 100644 .changeset/byok-credential-store.md delete mode 100644 .changeset/chat-resume-should-continue.md delete mode 100644 .changeset/harness-build-worker.md delete mode 100644 .changeset/harness-define-plugin-keys.md delete mode 100644 .changeset/harness-file-changed.md delete mode 100644 .changeset/harness-join-any-instance.md delete mode 100644 .changeset/harness-log-newer-records.md delete mode 100644 .changeset/harness-logger.md delete mode 100644 .changeset/harness-permission-call.md delete mode 100644 .changeset/harness-redaction.md delete mode 100644 .changeset/harness-round5-asks.md delete mode 100644 .changeset/harness-secure-defaults.md delete mode 100644 .changeset/harness-tool-ops.md delete mode 100644 .changeset/harness-turn-and-body-limits.md delete mode 100644 .changeset/harness-view-experimental.md delete mode 100644 .changeset/nested-subagent-resume.md delete mode 100644 .changeset/subagent-binding-debug.md delete mode 100644 .changeset/subagent-context-extend.md create mode 100644 .changeset/tau-core.md diff --git a/.changeset/ai-harness-coding-tools.md b/.changeset/ai-harness-coding-tools.md deleted file mode 100644 index 216f8f9008..0000000000 --- a/.changeset/ai-harness-coding-tools.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -Add coding tools in the new Node entry `@tanstack/ai-harness/plugins/coding`: - -- `workspaceTools({ root })` gives `read_file` (text pages, images, PDFs), `write_file`, `edit_file` or `patch` (`editStyle`), `list_files`, `grep`, `bash` (with `background`), `webfetch`, and `websearch` (with a `SearchProvider`). With `permissions()`, edits, `bash`, and `webfetch` ask first, and the rules also apply to the real path of a link. `outside: 'ask'` makes a path outside `root` need an approval. `permissions()` decides outside paths and the real paths of links before the call runs, so a tool never asks while it runs. The `cwd` thread setting gives each thread its own working folder. -- `workspaceTools({ workspace: { bind, reattach? } })` gives each thread its own workspace. The root of the workspace is the boundary of every tool. The session log keeps where the workspace is (`harness.workspace.bound`). On another host, `reattach` gives the workspace back (for example a resumed sandbox), or `'pinned'` or `'gone'`. A gone workspace sends `tanstack/workspace-tools:stranded`. With this plugin first, `snapshots()` and `formatter()` use the workspace of each thread, and their `root` is optional. -- `createHarnessHost({ ownership: { hostId } })` gives a host a stable id. Then a thread that uses a folder on that host is pinned to it, and other hosts refuse its tool calls. Without `hostId`, threads are not pinned. A host with `hostId` sends a heartbeat to the log store, and its pins lapse when no heartbeat came for `ownership.expiresAfterMs`: then another host reattaches its threads. `host.unpin({ harness, threadId })` unpins a thread from a host that does not come back, once its ownership expired. -- `workspaceRoot(base, ...parts)` gives a folder in `base` for a tenant and a thread id. It encodes each part, so a thread id from a client cannot leave `base` or reach another thread. -- Commands, `rg`, `git`, and the formatters get only a short list of variables of the server process, plus the `env` of the backend call. Git runs without the hooks and the `fsmonitor` of the project. -- The tools resolve links with `realpath` under the file lock, and fail when a backend cannot resolve a path. A write fails when the file changed after the tool read it in the same chat run, so a lead and its subagents do not lose each other's edits. The file locks are shared by the sessions of a host, by the sandbox (or host) and the real path of the file. -- Background jobs: at most 8 per session (`maxJobs`), the newest 1 MiB of output per job, and the events `tanstack/workspace-tools:job-started`, `job-output`, and `job-ended`. A running command that `session.moveToolCallToBackground()` moves counts as a job, and its process id goes to the log. A job that an earlier host left running is stopped at the next tool call, with a note to the model. When the backend cannot check the job (for example with no `ps`), the log keeps it, and the note says that it can still run. The wake turn of a job runs as the user who started it. `session.jobs()` and `jobs()` of a `host.observe` view list the jobs that did not end (`JobInfo`: the job id, the thread, the command or tool name, who moved it, when it started, and `running` on this host). The `bash` spill file has `[redacted]` in place of each secret of the session. -- `read_file` sends images and PDFs up to 256 KB. `webfetch` gives the model the first 256 KB of a page. -- A `WorkspaceBackend` runs the tools. `hostBackend` is the default. `realpath` is required. `spawn` can give a `jobId`, `exec` can give one with `onStart`, and `killJob`, `binding`, and `close` are optional. `killJob` rejects when it cannot check that the process is the job. On Windows, commands do not run a program from the working folder by its name. -- `snapshots()` adds `/undo` and `/redo`, which restore only the files of the turn (git 2.26 or newer), file restore for `session.revert()`, and a diff for each model step. Each thread of each tenant has its own shadow repository, which never stores `.env` files. A step keeps only the files of its own run. The steps and the undo point are in the session log, so another host with the same repository can undo. A revert uses the last 500 steps. `host.sessions.delete` removes the repository of the thread, and `purgeSnapshots()` does it from your own code. A failed snapshot sends `tanstack/snapshots:failed`. -- `formatter()` formats each written file with the formatter of the project. With `permissions()`, the rules decide the formatter with the call that writes, before that call runs: one approval covers both, and the formatter runs after the write. An `always` answer is tied to a hash of the formatter config files. A write of a formatter config below the root, or under `node_modules`, asks each time, and an `always` answer saves no rule for that formatter run. -- `WorkspaceHooks` lets other plugins run code after a read or a write. - -`@vscode/ripgrep` and `turndown` are optional peer dependencies. diff --git a/.changeset/ai-harness-core.md b/.changeset/ai-harness-core.md index a5b3493049..e255d176de 100644 --- a/.changeset/ai-harness-core.md +++ b/.changeset/ai-harness-core.md @@ -53,3 +53,117 @@ Add `@tanstack/ai-harness`: build an agent harness on TanStack AI. A harness kee - `onEvent`, `onQuestion`, `onUsage`, and `onMedia` also get the events that another host writes to the log of the session. An event fires once. The status of `host.events()` counts that work too, for the sessions that are open on the host. - The router gets `model`: `model.chat({ adapter, options, label })` in `routing.router` (`HarnessRouterContext.model`) calls a model with the keys of the sender, and a cancel of the turn stops it. - `session.usage()` has `bySource`: the model calls that are not a turn or an agent run, keyed by `plugin:`, `router`, or `transfer`. Each `UsageCall` and `harness.usage` event of such a call has `source`, and `label` when the call has one. The summary of `host.transfer` with `messages: 'summary'` counts in the source thread under `transfer`, with the label `summary`, and stops after 2 minutes. + +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, and the `default`, `plan`, `acceptEdits`, and `bypass` modes. It decides every call of a model message before the calls run, and opens one approval wait for each call that asks, so the turn stops and no process waits. An answer is `true` or `false`, `'once'`, `'always'`, or `'reject'`, or `{ answer, message? }`. It also has saved "always" rules with `/permissions` and `/permissions forget `, and `listSavedPermissions` / `deleteSavedPermission` for server code. The rules apply in subagent runs too. It decides in `onGuardToolCall`, on the final checked arguments. +- `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. A subagent run uses the tools and the model of the turn that started it. A run whose turn is not in the memory of this host fails with a clear error. A run that code starts needs a profile `model`. +- `question()`: a `question` tool for the lead turn. Each question is a wait of the turn, answered with `resolve`. Agent runs do not get the tool. +- `todos()`, `title()`, `modelPicker()`, `compact()`, and `usage()` (with your own prices for calls that have no provider cost). +- `goal()` runs each turn as the user who set the goal. +- `projectInstructions()`, `fileCommands()`, and `boundToolOutput()`, in `plugins/node`. + +A plugin runs agents with `ctx.agents.start`, `ctx.agents.run`, and `ctx.agents.group({ concurrency }, (g) => [g.start(agent, input)])`, with the same types as `ctx.harness.agents` in agent code. + +`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. + +`ctx.model.chat({ adapter, options, label, run, timeoutMs })` calls a model from a plugin, for example a judge or a title. It uses the keys of the user of the run, the budget of the run applies, and it stops when the run stops or after `timeoutMs` (default 2 minutes). Its usage counts in the session under `plugin:` in `session.usage().bySource`. `goal()`, `title()`, and `compact()` call their models this way, with the labels `judge`, `title`, and `compact`. + +Serve a harness session to clients. + +- `createHarnessHandler()` from `@tanstack/ai-harness/server` serves a session over HTTP, as the user that the host `access` reads from each request. `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`, and `GET describe` cover one session. `sessions` (with the `share` and `unshare` ops), `GET host-events`, and the media routes cover the rest. A `host.events()` reader listens from the call, and one that falls more than 1 000 changes behind stops, and reads again for the current statuses. A refused action answers 403. A read of a thread that does not exist answers 404 and starts no session. A signed media URL still needs a user: `getUser` reads the user of the request, and that user needs `read` on the thread, in the tenant of the URL. For a request with no login, `access.getGuestUser({ tenantId })` can give a guest, and `isAllowed` decides `read` for it. The guest exists only on the signed media route. `requestUsers` from `@tanstack/ai-harness/server` gives the user of each request with the same rule, for your own server. The read routes read a thread with `host.observe`, so they set up no plugins. `GET describe` opens the session. +- `handleHarnessSocket()`, from the same entry, serves the same session over a WebSocket. It takes the upgrade `request` and reads the user from it. It handles the frames of one connection in order. +- A `command` or an `agent` input over HTTP or the socket runs once for one `inputId`, and the `inputId` of its receipt is the id of the client. +- `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. Without `from`, `client.events()` starts at the newest event (the cursor of the snapshot), so it does not send the whole history first. This is the default: a reader that needs the history (to rebuild a transcript, for example) must pass `from: '0'` or a saved cursor. When the snapshot answer has no string `cursor`, `client.events()` throws and does not retry. `client.events()` connects again after `idleTimeoutMs` (default 60 000) with no bytes. The `prompt` input of the protocol and `client.send` take `as: 'operator'` for the reply of an operator after a handoff to a person. +- The client methods take one object: `cancelInput`, `revert`, `agentTranscript`, `mediaUrl`, `appendRecords`, and `putMedia`. `client.sessions.list`, `rename`, `delete`, `update`, `fork`, `share`, and `unshare` manage the sessions. `HarnessErrorCode`, the type of `HarnessProtocolError.code`, comes from the main, `/client`, and `/server` entries. +- `oauthConnector()` and the OAuth helpers from `@tanstack/ai-harness/oauth` (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per user. A tool calls `token({ run: ctx.context.run })` for the token of the user of its run. `token({ run, wait: true })` stops the turn with an `auth_required` interrupt until the user signs in. `deviceLogin`, `loopbackLogin`, `startLoopbackReceiver`, `exchangeCode`, and `refreshCredential(config, credential, { fetch, signal })` stop at once when their `signal` aborts, so a cancel or a session close stops a running `/connect`. `OAuthConfig.issuer` makes `loopbackLogin` refuse a code from another issuer (RFC 9207). The loopback deadline (`timeoutMs`) does not keep the process alive. +- `run.credentials.refresh(id, update, { signal })` refreshes a saved credential once for all callers on a host, keyed by tenant, user, and id. It saves with `setIf` when the store has `getVersioned` and `setIf`, and uses the other host's token on a conflict. Without that pair, it does a plain `set`. It saves to the scope where it found the credential, so a tenant token stays the tenant's. `update` gets a signal that only `host.close()` aborts. An abort of `signal` rejects only that call. `oauthConnector` refreshes this way, and `refreshCredential` keeps the `client` of the old credential. +- The session stream speaks protocol version 2 on every transport: `GET events`, the socket, and the stdio worker. Each event is an AG-UI wire chunk in a `harness.event` frame, with `cursor`, `threadId`, and `operationId`. The AG-UI `RUN_STARTED` of a child agent run has `parentRunId`, so a client can rebuild the run tree. +- The first frame of a stream is `harness.hello` with the version `v`. A client asks for a version with `?v=` or with `v` in `harness.subscribe`. Another version gets a `harness.error` frame with the code `unsupported_version`, and the stream closes (socket code 4400). +- A `harness.error` frame has a `code` (`unauthorized`, `forbidden`, `invalid_frame`, `unsupported_version`, `slow_consumer`, or `internal`) and a `requestId`. On the socket and the stdio worker, the `requestId` is the id of the frame that failed. Every answer of the handler has an `X-Request-Id` header: the id that the client sent when it is a short safe id, else a new UUID. +- Streams stay open and protect the server. SSE streams send a `: ping` comment and the socket sends a `harness.ping` frame when idle. SSE reads the log only as fast as the client reads, and the socket waits while its `bufferedAmount` is over the limit. A client that does not read in time gets a `slow_consumer` error frame, and the stream closes (socket code 4408). A slow client loses no events: it connects again from its last cursor. `createHarnessClient` does this in `events()` and `hostEvents()`, and ends the read with a `HarnessProtocolError` (with its `code`) for any other error frame. +- `presence: { onConnect, onDisconnect }` on `createHarnessHost` reports each client stream of `GET events` and of the socket (after its subscribe), as a `PresenceEvent` with a new `connectionId`, the user, the thread, the transport, and `resumed`. `onDisconnect` runs once per stream, with the reason `closed`, `revoked`, or `error`. The host does not wait for a hook, and a hook that fails only warns. +- `stream: { heartbeatMs, slowConsumerMs, sseQueueBytes, socketBufferBytes }` on `createHarnessHandler` and `handleHarnessSocket` changes these values. The defaults are 20 s, 30 s, 1 MiB, and 8 MiB. A value that is not a finite number above 0 throws at the start. `HarnessStreamOptions` is the type. +- A reader whose events are gone gets one `harness.gap` event, with `reason: 'truncated'` (a truncate of the log) or `'evicted'` (the memory feed of a session dropped them). The stream then goes on from `resumeAt`. +- A W3C `traceparent` goes with a message: as the `traceparent` header of `POST control` and `POST run`, or as the `traceparent` field of a `harness.input` frame. `ClientSendOptions.traceparent` makes `client.send` send the header. +- A `cancel` input stops a turn from any host that reads the same log, so `client.cancel({ operationId })` works behind a load balancer. A closed connection still never cancels work. To stop a `useChat` turn, call `client.cancel` with the `runId` of `useChat` in the stop handler. + +Add `createSessionView()` in `@tanstack/ai-harness/view`. It turns a session (local, or remote through `createHarnessClient()`) into one live TanStack Store state that works with any UI library. + +- The state: messages with their parts, tool calls, approvals, client tools, other interrupts, questions, sign-ins, `waits` (every open wait, with its kind), child agents with their results and a `status` of `running` or `waiting`, media with signed URLs, waiting inputs, usage, models, settings, commands, and plugin state. +- The waits of background agent runs show in `approvals`, `clientTools`, and `interrupts`. The view answers each one at once: with `resolve`, or with `answer` for a `human` wait that no turn holds. +- Actions: `send` (a prompt, a steer, or a `/command`), `followUp`, `continueWithoutMessage`, `putMedia`, `cancel`, `cancelInput`, `changeInputDelivery`, `updateThreadSettings`, `reset`, `revert`, `loadAgent`, and the answers to approvals (with edited arguments), client tools, interrupts, and questions. `cancelInput`, `revert`, `approve({ id, ...options })`, `reject({ id, payload? })`, `notice({ text })`, and `putMedia` take one object, and so does the `reject({ payload? })` of an approval item. +- Typed events: `view.on('approval' | 'interrupt' | 'turnEnd' | ..., handler)` and plugin events. +- In a harness with `routing`, an assistant message has `agent`, the root agent that wrote it. After a handoff, the next agent writes a new message in the same turn. +- `view.send(text, { as: 'operator' })` sends the reply of an operator while a handoff to a person is open. The reply shows in each open view at once. +- A view that opens while a turn runs shows the turn from its start. After a reload, each child agent shows its card, and `view.loadAgent({ id })` fills it with its own messages. +- When the stream sends a `harness.gap` event, the view stops its read, loads the snapshot and the transcript again, then reads on from the new cursor. No turn shows twice. + +Mark `createSessionView`, `SessionViewState`, and `SessionViewEvents` as `@experimental`. Document that event cursors are opaque strings: positions in the session log. + +Add the `goal({ judge, maxRounds })` plugin to `@tanstack/ai-harness/plugins`. `/goal ` sets a goal and starts a turn. After each turn, the judge model checks the goal, and the plugin starts the next turn until the goal is met, the round limit is reached, a turn waits for approval or fails, or the user sends a message. `/goal`, `/goal stop`, and `/goal resume` show and control it. The `GoalMet` event fires when the goal is met, and `selectGoal(session.snapshot())` reads the goal for a UI. + +Add the `providerKeys({ providers })` plugin to `@tanstack/ai-harness/plugins`. Users of a shipped harness connect their own model provider in the app, with no `.env` file. It adds `/connect:`, `/disconnect:`, and `/keys`. `/connect:` runs the provider's `signIn` when it has one, else it opens `keyUrl` when set and asks the user to paste the key with `step.ask` and `secret: true`. The log keeps the question as a wait, so a user on any host can answer it, and the log never keeps the key. The key is saved in the credential store of the user, and a `keyedAdapter(...)` for that provider uses it. `/keys` shows where each key of the user comes from: a saved key (the last 4 characters only), the env var, or no key. The plugin state shows only the env vars that the session reads, as every member of the thread reads it. On a host with access without `allowServerEnvKeys`, no env var counts. + +Add coding tools in the new Node entry `@tanstack/ai-harness/plugins/coding`: + +- `workspaceTools({ root })` gives `read_file` (text pages, images, PDFs), `write_file`, `edit_file` or `patch` (`editStyle`), `list_files`, `grep`, `bash` (with `background`), `webfetch`, and `websearch` (with a `SearchProvider`). With `permissions()`, edits, `bash`, and `webfetch` ask first, and the rules also apply to the real path of a link. `outside: 'ask'` makes a path outside `root` need an approval. `permissions()` decides outside paths and the real paths of links before the call runs, so a tool never asks while it runs. The `cwd` thread setting gives each thread its own working folder. +- `workspaceTools({ workspace: { bind, reattach? } })` gives each thread its own workspace. The root of the workspace is the boundary of every tool. The session log keeps where the workspace is (`harness.workspace.bound`). On another host, `reattach` gives the workspace back (for example a resumed sandbox), or `'pinned'` or `'gone'`. A gone workspace sends `tanstack/workspace-tools:stranded`. With this plugin first, `snapshots()` and `formatter()` use the workspace of each thread, and their `root` is optional. +- `createHarnessHost({ ownership: { hostId } })` gives a host a stable id. Then a thread that uses a folder on that host is pinned to it, and other hosts refuse its tool calls. Without `hostId`, threads are not pinned. A host with `hostId` sends a heartbeat to the log store, and its pins lapse when no heartbeat came for `ownership.expiresAfterMs`: then another host reattaches its threads. `host.unpin({ harness, threadId })` unpins a thread from a host that does not come back, once its ownership expired. +- `workspaceRoot(base, ...parts)` gives a folder in `base` for a tenant and a thread id. It encodes each part, so a thread id from a client cannot leave `base` or reach another thread. +- Commands, `rg`, `git`, and the formatters get only a short list of variables of the server process, plus the `env` of the backend call. Git runs without the hooks and the `fsmonitor` of the project. +- The tools resolve links with `realpath` under the file lock, and fail when a backend cannot resolve a path. A write fails when the file changed after the tool read it in the same chat run, so a lead and its subagents do not lose each other's edits. The file locks are shared by the sessions of a host, by the sandbox (or host) and the real path of the file. +- Background jobs: at most 8 per session (`maxJobs`), the newest 1 MiB of output per job, and the events `tanstack/workspace-tools:job-started`, `job-output`, and `job-ended`. A running command that `session.moveToolCallToBackground()` moves counts as a job, and its process id goes to the log. A job that an earlier host left running is stopped at the next tool call, with a note to the model. When the backend cannot check the job (for example with no `ps`), the log keeps it, and the note says that it can still run. The wake turn of a job runs as the user who started it. `session.jobs()` and `jobs()` of a `host.observe` view list the jobs that did not end (`JobInfo`: the job id, the thread, the command or tool name, who moved it, when it started, and `running` on this host). The `bash` spill file has `[redacted]` in place of each secret of the session. +- `read_file` sends images and PDFs up to 256 KB. `webfetch` gives the model the first 256 KB of a page. +- A `WorkspaceBackend` runs the tools. `hostBackend` is the default. `realpath` is required. `spawn` can give a `jobId`, `exec` can give one with `onStart`, and `killJob`, `binding`, and `close` are optional. `killJob` rejects when it cannot check that the process is the job. On Windows, commands do not run a program from the working folder by its name. +- `snapshots()` adds `/undo` and `/redo`, which restore only the files of the turn (git 2.26 or newer), file restore for `session.revert()`, and a diff for each model step. Each thread of each tenant has its own shadow repository, which never stores `.env` files. A step keeps only the files of its own run. The steps and the undo point are in the session log, so another host with the same repository can undo. A revert uses the last 500 steps. `host.sessions.delete` removes the repository of the thread, and `purgeSnapshots()` does it from your own code. A failed snapshot sends `tanstack/snapshots:failed`. +- `formatter()` formats each written file with the formatter of the project. With `permissions()`, the rules decide the formatter with the call that writes, before that call runs: one approval covers both, and the formatter runs after the write. An `always` answer is tied to a hash of the formatter config files. A write of a formatter config below the root, or under `node_modules`, asks each time, and an `always` answer saves no rule for that formatter run. +- `WorkspaceHooks` lets other plugins run code after a read or a write. + +`@vscode/ripgrep` and `turndown` are optional peer dependencies. + +`@tanstack/ai-harness/build` and `@tanstack/ai-harness/worker`: `buildHarness` bundles a harness with Bun into a worker artifact (`harness.js` and a `harness.manifest.json` with the sha256 digest), and can also build a single executable. `artifactText(dir)` checks the digest and uses the artifact as the model of a `chat()` call, with one worker process per thread. `runHarnessWorker` runs a harness on stdin and stdout with the session frames of the harness protocol. + +`definePlugin` refuses a `setup()` contribution, such as `adapter` or `tools`, at the top level of a definition, where it would be ignored. Return it from `setup()`. + +`write_file`, `edit_file`, and `patch` send `FileChanged` (`tanstack/workspace-tools:file-changed`) with a unified patch and its line counts for each file that they change. At the end of each model step, `snapshots()` sends it for the files that other tools, like `bash`, changed: at most 100 for each step. `/undo`, `/redo`, `session.revert()`, and `session.unrevert()` also send it for each file that they put back. + +Any server instance that reads the same log store can serve a run. + +- `POST run` sends the log cursor of each event as its SSE `id`. A client that is cut off sends the same request with `Last-Event-ID`, and any instance continues the stream from there. The turn does not run again. +- `GET run?runId=&threadId=` finds a turn that another instance runs. It streams the turn from the log of `threadId`, from its start or from `Last-Event-ID`, until the turn ends. It reads the log from the start of the turn, not from the start of the log. A turn rebuilt after a restart (a retried `POST run`) also streams from its start. Without `threadId`, only the instance that runs the turn finds it, and another instance answers `400`. The join request of a `ChatClient` sends only the run id, so put `threadId` in the URL of the connection. +- `GET run?threadId=` shows a turn that another instance runs as `activeRun`, so a reloaded `useChat` joins it. + +A host that folds a `harness.*` log record type it does not know (a newer harness wrote it, for example during a rolling deploy) reads the log but refuses to write it, with `LogVersionError`. A log snapshot from a newer harness is not used. + +`createHarnessHost({ logger })` takes a `Logger` from `@tanstack/ai` for the warnings and errors of the harness and its `chat()` calls (default: `ConsoleLogger`). Plugins get it as `ctx.logger`, and a logger that throws does not stop the harness. + +`PermissionCallCapability` gives a plugin that runs tool calls itself, like code mode, the decision that `permissions()` makes for one call: `allow`, `deny` with the error that the model gets, or `ask` with the approval question. It uses the current mode, the saved `always` answers, and the resources of the call args, and `settle(reply)` applies the user's answer as a direct approval does. + +Keep the secrets of a session out of what it keeps and sends. + +- Each session replaces every secret that it gave out with `[redacted]`. These are the values of `run.keys` (also from env vars) and `run.credentials`, connector credentials and OAuth tokens, and secret answers (each string field of an answer with fields). It scrubs log records, feed events, plugin state (also in a `Map` or a `Set`), notes, the model context, and tool results, also after the own middleware of an agent run. It also scrubs the error frames of the HTTP handler, the WebSocket, and the stdio worker. It finds the raw, JSON-escaped, and URL-encoded forms, and skips values shorter than 6 characters. A secret split across two streaming deltas stays in those two events, and a secret that is JSON-escaped two times is not found. +- `scrubSecrets` from `@tanstack/ai-harness` is the one from `@tanstack/ai/byok`. +- A `step.do` value that holds a secret of the session throws, on the first run and on a replay, so the log never keeps a secret. Read the secret outside `step.do`. +- A retry of an input with a secret in it is a duplicate, not a conflict: the check compares the input without the secrets, as the log keeps it. +- The log keeps the final result of each tool call: the result after every `onAfterToolCall` hook (also the agent's own middleware in an agent run), scrubbed. A replacement from a hook wins, also on an error. Recovery after a crash gives the model that result. +- An answer to a `secret: true` question that reaches a host that does not own the log goes through the `credentials` store of the host, as a one-time reference with the credential type `secret_answer` and an `expiresAt` 24 hours later. Only the reference goes into the input lane, and the owner reads and deletes it. The references that nobody read go when the question ends. A kept answer for a run again goes when the run ends. Without `createHarnessHost({ credentials })`, the answer is rejected with `secret_answer_needs_credentials`. A used, expired, or unknown reference is rejected with `unknown_secret_answer`. `credentials.list` does not show the `secret_answer` type. + +Make the transport defaults safe for a server with many instances. + +- `createHarnessHandler` and `handleHarnessSocket` read the user of each request with the host `access`. On a host without `access`, pass `openAccess: true` (no user) or `openAccess: { user }` (one local user), else they throw at the start. So a server never serves every thread to everyone by mistake. +- `media: { secret }` signs the media URLs of `createHarnessHandler`. Set the same secret on every instance, so a media URL that one instance signs works on every instance. Without it, the handler signs with a random key of its own, and a URL works only on that instance. A host with `ownership` options needs `media`, else the handler throws at the start. On another host without `media`, the first handler logs one warning. A signed URL names its harness and its user, and the handler checks that user again when it serves the file. A URL of a deleted thread answers 404. +- `media: { keys: [newKey, oldKey] }` rotates the key: the first key signs, and every key checks. Each key must be at least 32 bytes, else the handler throws at the start. +- `oauthConnector` needs `login`. Use `'device'` on a server, and `'loopback'` only for a harness on the user's own machine, such as a CLI. + +How a turn takes steers, settings, and plugin removals, and the size limit of client requests. + +- A steer that arrives during a `session.runTool()` turn waits, and then runs as its own turn. +- A turn keeps the config values that it started with. A config change while it runs, for example `/mode bypass`, applies from the next turn. +- `configure({ settings: { plugins: { remove } } })` removes everything that those plugins give the thread: also their agents, subagents, commands, agent and generation middleware, and the items that they add for other plugins (for example the permission rules of `agents()`). A remove that leaves a plugin (of any lifetime) or a harness middleware without a capability that it needs is rejected. +- When two plugins give a tool with the same name from `discoverTools`, the first one stays, and clients get one `harness.plugin.warning` event that names both plugins. +- `createHarnessHandler` reads each JSON body up to `media.maxBodyBytes` (default 32 MiB). A bigger body answers 413. `handleHarnessSocket` closes the socket with code 1009 on a bigger frame. The WebSocket server reads the whole frame first, so also set its own frame limit (for example `maxPayload` of `ws`). +- Client media is checked part by part before any part is stored, so a refused input stores nothing. A retried input with the same `inputId` stores its media no second time, and gets the first receipt. +- A client input with a provider file handle (`source.type: 'file'`) is refused with `media_url_not_allowed` unless the harness sets `media.allowProviderFiles: true`. Server code can still send file handles. +- `POST run` moves the inline bytes of the last user message to the media store, as `POST control` does, so the transcript keeps a `harness-media:` reference. A refused part answers 413 (`media_too_large`), 415 (`media_not_allowed`), or 400 (`media_invalid`, `media_url_not_allowed`). diff --git a/.changeset/ai-harness-goal.md b/.changeset/ai-harness-goal.md deleted file mode 100644 index 52b3d593a8..0000000000 --- a/.changeset/ai-harness-goal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -Add the `goal({ judge, maxRounds })` plugin to `@tanstack/ai-harness/plugins`. `/goal ` sets a goal and starts a turn. After each turn, the judge model checks the goal, and the plugin starts the next turn until the goal is met, the round limit is reached, a turn waits for approval or fails, or the user sends a message. `/goal`, `/goal stop`, and `/goal resume` show and control it. The `GoalMet` event fires when the goal is met, and `selectGoal(session.snapshot())` reads the goal for a UI. diff --git a/.changeset/ai-harness-plugins.md b/.changeset/ai-harness-plugins.md deleted file mode 100644 index 0c2ca1a70d..0000000000 --- a/.changeset/ai-harness-plugins.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -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, and the `default`, `plan`, `acceptEdits`, and `bypass` modes. It decides every call of a model message before the calls run, and opens one approval wait for each call that asks, so the turn stops and no process waits. An answer is `true` or `false`, `'once'`, `'always'`, or `'reject'`, or `{ answer, message? }`. It also has saved "always" rules with `/permissions` and `/permissions forget `, and `listSavedPermissions` / `deleteSavedPermission` for server code. The rules apply in subagent runs too. It decides in `onGuardToolCall`, on the final checked arguments. -- `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. A subagent run uses the tools and the model of the turn that started it. A run whose turn is not in the memory of this host fails with a clear error. A run that code starts needs a profile `model`. -- `question()`: a `question` tool for the lead turn. Each question is a wait of the turn, answered with `resolve`. Agent runs do not get the tool. -- `todos()`, `title()`, `modelPicker()`, `compact()`, and `usage()` (with your own prices for calls that have no provider cost). -- `goal()` runs each turn as the user who set the goal. -- `projectInstructions()`, `fileCommands()`, and `boundToolOutput()`, in `plugins/node`. - -A plugin runs agents with `ctx.agents.start`, `ctx.agents.run`, and `ctx.agents.group({ concurrency }, (g) => [g.start(agent, input)])`, with the same types as `ctx.harness.agents` in agent code. - -`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. - -`ctx.model.chat({ adapter, options, label, run, timeoutMs })` calls a model from a plugin, for example a judge or a title. It uses the keys of the user of the run, the budget of the run applies, and it stops when the run stops or after `timeoutMs` (default 2 minutes). Its usage counts in the session under `plugin:` in `session.usage().bySource`. `goal()`, `title()`, and `compact()` call their models this way, with the labels `judge`, `title`, and `compact`. diff --git a/.changeset/ai-harness-provider-keys.md b/.changeset/ai-harness-provider-keys.md deleted file mode 100644 index 3539e664a5..0000000000 --- a/.changeset/ai-harness-provider-keys.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -Add the `providerKeys({ providers })` plugin to `@tanstack/ai-harness/plugins`. Users of a shipped harness connect their own model provider in the app, with no `.env` file. It adds `/connect:`, `/disconnect:`, and `/keys`. `/connect:` runs the provider's `signIn` when it has one, else it opens `keyUrl` when set and asks the user to paste the key with `step.ask` and `secret: true`. The log keeps the question as a wait, so a user on any host can answer it, and the log never keeps the key. The key is saved in the credential store of the user, and a `keyedAdapter(...)` for that provider uses it. `/keys` shows where each key of the user comes from: a saved key (the last 4 characters only), the env var, or no key. The plugin state shows only the env vars that the session reads, as every member of the thread reads it. On a host with access without `allowServerEnvKeys`, no env var counts. diff --git a/.changeset/ai-harness-transport.md b/.changeset/ai-harness-transport.md deleted file mode 100644 index c0cbab2f9c..0000000000 --- a/.changeset/ai-harness-transport.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -Serve a harness session to clients. - -- `createHarnessHandler()` from `@tanstack/ai-harness/server` serves a session over HTTP, as the user that the host `access` reads from each request. `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`, and `GET describe` cover one session. `sessions` (with the `share` and `unshare` ops), `GET host-events`, and the media routes cover the rest. A `host.events()` reader listens from the call, and one that falls more than 1 000 changes behind stops, and reads again for the current statuses. A refused action answers 403. A read of a thread that does not exist answers 404 and starts no session. A signed media URL still needs a user: `getUser` reads the user of the request, and that user needs `read` on the thread, in the tenant of the URL. For a request with no login, `access.getGuestUser({ tenantId })` can give a guest, and `isAllowed` decides `read` for it. The guest exists only on the signed media route. `requestUsers` from `@tanstack/ai-harness/server` gives the user of each request with the same rule, for your own server. The read routes read a thread with `host.observe`, so they set up no plugins. `GET describe` opens the session. -- `handleHarnessSocket()`, from the same entry, serves the same session over a WebSocket. It takes the upgrade `request` and reads the user from it. It handles the frames of one connection in order. -- A `command` or an `agent` input over HTTP or the socket runs once for one `inputId`, and the `inputId` of its receipt is the id of the client. -- `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. Without `from`, `client.events()` starts at the newest event (the cursor of the snapshot), so it does not send the whole history first. This is the default: a reader that needs the history (to rebuild a transcript, for example) must pass `from: '0'` or a saved cursor. When the snapshot answer has no string `cursor`, `client.events()` throws and does not retry. `client.events()` connects again after `idleTimeoutMs` (default 60 000) with no bytes. The `prompt` input of the protocol and `client.send` take `as: 'operator'` for the reply of an operator after a handoff to a person. -- The client methods take one object: `cancelInput`, `revert`, `agentTranscript`, `mediaUrl`, `appendRecords`, and `putMedia`. `client.sessions.list`, `rename`, `delete`, `update`, `fork`, `share`, and `unshare` manage the sessions. `HarnessErrorCode`, the type of `HarnessProtocolError.code`, comes from the main, `/client`, and `/server` entries. -- `oauthConnector()` and the OAuth helpers from `@tanstack/ai-harness/oauth` (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per user. A tool calls `token({ run: ctx.context.run })` for the token of the user of its run. `token({ run, wait: true })` stops the turn with an `auth_required` interrupt until the user signs in. `deviceLogin`, `loopbackLogin`, `startLoopbackReceiver`, `exchangeCode`, and `refreshCredential(config, credential, { fetch, signal })` stop at once when their `signal` aborts, so a cancel or a session close stops a running `/connect`. `OAuthConfig.issuer` makes `loopbackLogin` refuse a code from another issuer (RFC 9207). The loopback deadline (`timeoutMs`) does not keep the process alive. -- `run.credentials.refresh(id, update, { signal })` refreshes a saved credential once for all callers on a host, keyed by tenant, user, and id. It saves with `setIf` when the store has `getVersioned` and `setIf`, and uses the other host's token on a conflict. Without that pair, it does a plain `set`. It saves to the scope where it found the credential, so a tenant token stays the tenant's. `update` gets a signal that only `host.close()` aborts. An abort of `signal` rejects only that call. `oauthConnector` refreshes this way, and `refreshCredential` keeps the `client` of the old credential. -- The session stream speaks protocol version 2 on every transport: `GET events`, the socket, and the stdio worker. Each event is an AG-UI wire chunk in a `harness.event` frame, with `cursor`, `threadId`, and `operationId`. The AG-UI `RUN_STARTED` of a child agent run has `parentRunId`, so a client can rebuild the run tree. -- The first frame of a stream is `harness.hello` with the version `v`. A client asks for a version with `?v=` or with `v` in `harness.subscribe`. Another version gets a `harness.error` frame with the code `unsupported_version`, and the stream closes (socket code 4400). -- A `harness.error` frame has a `code` (`unauthorized`, `forbidden`, `invalid_frame`, `unsupported_version`, `slow_consumer`, or `internal`) and a `requestId`. On the socket and the stdio worker, the `requestId` is the id of the frame that failed. Every answer of the handler has an `X-Request-Id` header: the id that the client sent when it is a short safe id, else a new UUID. -- Streams stay open and protect the server. SSE streams send a `: ping` comment and the socket sends a `harness.ping` frame when idle. SSE reads the log only as fast as the client reads, and the socket waits while its `bufferedAmount` is over the limit. A client that does not read in time gets a `slow_consumer` error frame, and the stream closes (socket code 4408). A slow client loses no events: it connects again from its last cursor. `createHarnessClient` does this in `events()` and `hostEvents()`, and ends the read with a `HarnessProtocolError` (with its `code`) for any other error frame. -- `presence: { onConnect, onDisconnect }` on `createHarnessHost` reports each client stream of `GET events` and of the socket (after its subscribe), as a `PresenceEvent` with a new `connectionId`, the user, the thread, the transport, and `resumed`. `onDisconnect` runs once per stream, with the reason `closed`, `revoked`, or `error`. The host does not wait for a hook, and a hook that fails only warns. -- `stream: { heartbeatMs, slowConsumerMs, sseQueueBytes, socketBufferBytes }` on `createHarnessHandler` and `handleHarnessSocket` changes these values. The defaults are 20 s, 30 s, 1 MiB, and 8 MiB. A value that is not a finite number above 0 throws at the start. `HarnessStreamOptions` is the type. -- A reader whose events are gone gets one `harness.gap` event, with `reason: 'truncated'` (a truncate of the log) or `'evicted'` (the memory feed of a session dropped them). The stream then goes on from `resumeAt`. -- A W3C `traceparent` goes with a message: as the `traceparent` header of `POST control` and `POST run`, or as the `traceparent` field of a `harness.input` frame. `ClientSendOptions.traceparent` makes `client.send` send the header. -- A `cancel` input stops a turn from any host that reads the same log, so `client.cancel({ operationId })` works behind a load balancer. A closed connection still never cancels work. To stop a `useChat` turn, call `client.cancel` with the `runId` of `useChat` in the stop handler. diff --git a/.changeset/ai-harness-view.md b/.changeset/ai-harness-view.md deleted file mode 100644 index 0fdf013d0d..0000000000 --- a/.changeset/ai-harness-view.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -Add `createSessionView()` in `@tanstack/ai-harness/view`. It turns a session (local, or remote through `createHarnessClient()`) into one live TanStack Store state that works with any UI library. - -- The state: messages with their parts, tool calls, approvals, client tools, other interrupts, questions, sign-ins, `waits` (every open wait, with its kind), child agents with their results and a `status` of `running` or `waiting`, media with signed URLs, waiting inputs, usage, models, settings, commands, and plugin state. -- The waits of background agent runs show in `approvals`, `clientTools`, and `interrupts`. The view answers each one at once: with `resolve`, or with `answer` for a `human` wait that no turn holds. -- Actions: `send` (a prompt, a steer, or a `/command`), `followUp`, `continueWithoutMessage`, `putMedia`, `cancel`, `cancelInput`, `changeInputDelivery`, `updateThreadSettings`, `reset`, `revert`, `loadAgent`, and the answers to approvals (with edited arguments), client tools, interrupts, and questions. `cancelInput`, `revert`, `approve({ id, ...options })`, `reject({ id, payload? })`, `notice({ text })`, and `putMedia` take one object, and so does the `reject({ payload? })` of an approval item. -- Typed events: `view.on('approval' | 'interrupt' | 'turnEnd' | ..., handler)` and plugin events. -- In a harness with `routing`, an assistant message has `agent`, the root agent that wrote it. After a handoff, the next agent writes a new message in the same turn. -- `view.send(text, { as: 'operator' })` sends the reply of an operator while a handoff to a person is open. The reply shows in each open view at once. -- A view that opens while a turn runs shows the turn from its start. After a reload, each child agent shows its card, and `view.loadAgent({ id })` fills it with its own messages. -- When the stream sends a `harness.gap` event, the view stops its read, loads the snapshot and the transcript again, then reads on from the new cursor. No turn shows twice. diff --git a/.changeset/ai-subagent-binding-interrupts.md b/.changeset/ai-subagent-binding-interrupts.md deleted file mode 100644 index b7d9a69386..0000000000 --- a/.changeset/ai-subagent-binding-interrupts.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@tanstack/ai': minor ---- - -Add `interrupts` to `SubagentBinding`. - -- A host sets `binding.interrupts` to give interrupt definitions to every `ctx.chat` call of an agent run. Then the host middleware in `chatMiddleware` can stop the child with these interrupts. -- The own `interrupts` of the call win. When a host definition has the same id as one of the call, the host definition is not added. -- A child's `ctx.chat({ subagents })` passes the definitions on to its own children. -- A plain `chat()` call does not get them. - -Add `lastChatMiddleware` to `SubagentBinding`. It runs after the own middleware of each `ctx.chat` call of an agent run, so it sees the final model context and tool results. A child's `ctx.chat({ subagents })` passes it on, after the child's own. - -Add `validateJsonSchema(schema, value)`. It checks a value against a JSON Schema with `@cfworker/json-schema`, as a tool input is checked, but with no repair. The result has the Standard Schema shape: `{ value }` or `{ issues }`. The root also exports the `StandardSchemaV1` and `StandardJSONSchemaV1` types. - -An agent `run` that throws the shape of `MCPInputRequiredError` (`name`, `kind`, `request`) stops the child as `suspended`, as a tool does, and the parent run stops with its interrupt. The interrupt has a generic binding and no tool call. Its id is the `interruptId` of the thrown value, else `mcp_input_`, and its message is the error message. The resume runs the child again, and `ctx.resume` has the answer. diff --git a/.changeset/ai-tool-guard.md b/.changeset/ai-tool-guard.md deleted file mode 100644 index 30087006ea..0000000000 --- a/.changeset/ai-tool-guard.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@tanstack/ai': minor ---- - -Add the `onGuardToolCall` middleware hook for checks that must always run, such as permissions. - -- It runs for every tool call, after all `onBeforeToolCall` hooks and after the `inputSchema` check. `hookCtx.args` holds the final checked arguments that the tool gets. -- The guard of every middleware runs, in order, until one returns a decision. An earlier `onBeforeToolCall` decision does not skip it. -- Return `{ type: 'skip', result }` to deny the call. The tool does not run, and the model gets `result`, the same as an `onBeforeToolCall` skip. Return `{ type: 'abort', reason }` to abort the run. -- It runs before a server tool runs and before a client tool goes to the client. A guard cannot change the arguments. -- The new `GuardToolCallDecision` type is exported. diff --git a/.changeset/ai-websocket-buffered-amount.md b/.changeset/ai-websocket-buffered-amount.md deleted file mode 100644 index 5391cc79e7..0000000000 --- a/.changeset/ai-websocket-buffered-amount.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai': patch ---- - -`WebSocketLike` has an optional `bufferedAmount`. A server can read it to stop sending to a slow socket. A socket without it works as before. diff --git a/.changeset/approval-raw-edits-resume.md b/.changeset/approval-raw-edits-resume.md deleted file mode 100644 index 305d549292..0000000000 --- a/.changeset/approval-raw-edits-resume.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@tanstack/ai': minor -'@tanstack/ai-persistence': patch ---- - -Approved edits to tool arguments stay raw until the final input check. - -- `@tanstack/ai`: a resume no longer rejects approved `editedArgs` that do not fit the tool's `inputSchema`. The edit goes through `onBeforeToolCall` and the final input check, like the model's arguments. If it still does not fit, the approval is used, the tool does not run, and the model gets the input error as the tool result. The transcript keeps the approved edit as it was sent, but only when the call passed the check. -- `@tanstack/ai-persistence`: `withPersistence` binds the client-tool phase of an approved tool call to its approval. After a restart, the client result resumes with the approval that came before it, and a stale or missing approval fails the resume. diff --git a/.changeset/byok-credential-store.md b/.changeset/byok-credential-store.md deleted file mode 100644 index 2b3af2222c..0000000000 --- a/.changeset/byok-credential-store.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@tanstack/ai': minor ---- - -BYOK keys can now live on the server. `@tanstack/ai/byok` exports the `CredentialStore` contract, the `Credential` and `CredentialScope` types, and `defineCredentialStore`. A `CredentialScope` has `tenantId` and `userId`. Its `threadId` is optional, and a store ignores it. `@tanstack/ai/byok/server` adds: - -- `storedProviderKeys(store, scope)`: provider keys from the `api_key` credentials of a user, then of the tenant, then the provider's `env` names. Pass `keys.adapter(...)` to `chat()` and `keys.get` to `subagents.keys`. -- `readCredential(store, scope, id)`: one credential, the user's own first, then the tenant's. A user with no tenant reads only their own. -- `tenantScopeOf(scope)`: the tenant scope of a user scope, or `undefined` when the scope has no user or no tenant. -- `memoryCredentialStore()`: a store in memory, for development and tests. -- `envKeyEntry(provider)`: the first `provider.env` name that is set, with its value. -- `readCredentialEntry(store, scope, id)`: reads a credential like `readCredential`. It also tells you which scope had the credential (the user or the tenant), and gives its revision when the store has `getVersioned`. - -`CredentialStore` has two optional methods for compare-and-set writes, `getVersioned` and `setIf`, with the same shape as the pair on `MetadataStore`. A store without them still works. `memoryCredentialStore` has both. - -Keep the store in your own database or in a secrets manager, and encrypt the secrets at rest. An `oauth` credential can keep the OAuth `client` it was issued to. `list` never returns secret values. - -`scrubSecrets` from `@tanstack/ai/byok` now also removes the JSON-escaped and URL-encoded forms of each secret. It skips a secret that is shorter than 6 characters, so a short value does not remove common words. - -`Credential` has a new type, `secret_answer` (`{ value, expiresAt }`). The harness keeps a secret answer to a question there for a short time and deletes it once read. Leave this type out of a list of connected accounts. `memoryCredentialStore().list` returns its `expiresAt`. diff --git a/.changeset/chat-resume-should-continue.md b/.changeset/chat-resume-should-continue.md deleted file mode 100644 index 9179d7e862..0000000000 --- a/.changeset/chat-resume-should-continue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai': patch ---- - -`chat()` asks the middleware `onShouldContinue` after it runs the pending tool calls of a resume, before the next model call. A middleware that returns `false` there ends the run with no more model calls. The loop strategy is not asked at that point. diff --git a/.changeset/harness-build-worker.md b/.changeset/harness-build-worker.md deleted file mode 100644 index 490e722096..0000000000 --- a/.changeset/harness-build-worker.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -`@tanstack/ai-harness/build` and `@tanstack/ai-harness/worker`: `buildHarness` bundles a harness with Bun into a worker artifact (`harness.js` and a `harness.manifest.json` with the sha256 digest), and can also build a single executable. `artifactText(dir)` checks the digest and uses the artifact as the model of a `chat()` call, with one worker process per thread. `runHarnessWorker` runs a harness on stdin and stdout with the session frames of the harness protocol. diff --git a/.changeset/harness-define-plugin-keys.md b/.changeset/harness-define-plugin-keys.md deleted file mode 100644 index 91b6ef15d7..0000000000 --- a/.changeset/harness-define-plugin-keys.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': patch ---- - -`definePlugin` refuses a `setup()` contribution, such as `adapter` or `tools`, at the top level of a definition, where it would be ignored. Return it from `setup()`. diff --git a/.changeset/harness-file-changed.md b/.changeset/harness-file-changed.md deleted file mode 100644 index 12806d03a9..0000000000 --- a/.changeset/harness-file-changed.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -`write_file`, `edit_file`, and `patch` send `FileChanged` (`tanstack/workspace-tools:file-changed`) with a unified patch and its line counts for each file that they change. At the end of each model step, `snapshots()` sends it for the files that other tools, like `bash`, changed: at most 100 for each step. `/undo`, `/redo`, `session.revert()`, and `session.unrevert()` also send it for each file that they put back. diff --git a/.changeset/harness-join-any-instance.md b/.changeset/harness-join-any-instance.md deleted file mode 100644 index dc47ad6053..0000000000 --- a/.changeset/harness-join-any-instance.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@tanstack/ai-harness': patch ---- - -Any server instance that reads the same log store can serve a run. - -- `POST run` sends the log cursor of each event as its SSE `id`. A client that is cut off sends the same request with `Last-Event-ID`, and any instance continues the stream from there. The turn does not run again. -- `GET run?runId=&threadId=` finds a turn that another instance runs. It streams the turn from the log of `threadId`, from its start or from `Last-Event-ID`, until the turn ends. It reads the log from the start of the turn, not from the start of the log. A turn rebuilt after a restart (a retried `POST run`) also streams from its start. Without `threadId`, only the instance that runs the turn finds it, and another instance answers `400`. The join request of a `ChatClient` sends only the run id, so put `threadId` in the URL of the connection. -- `GET run?threadId=` shows a turn that another instance runs as `activeRun`, so a reloaded `useChat` joins it. diff --git a/.changeset/harness-log-newer-records.md b/.changeset/harness-log-newer-records.md deleted file mode 100644 index 671dc6ca55..0000000000 --- a/.changeset/harness-log-newer-records.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': patch ---- - -A host that folds a `harness.*` log record type it does not know (a newer harness wrote it, for example during a rolling deploy) reads the log but refuses to write it, with `LogVersionError`. A log snapshot from a newer harness is not used. diff --git a/.changeset/harness-logger.md b/.changeset/harness-logger.md deleted file mode 100644 index 68f354055b..0000000000 --- a/.changeset/harness-logger.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -`createHarnessHost({ logger })` takes a `Logger` from `@tanstack/ai` for the warnings and errors of the harness and its `chat()` calls (default: `ConsoleLogger`). Plugins get it as `ctx.logger`, and a logger that throws does not stop the harness. diff --git a/.changeset/harness-permission-call.md b/.changeset/harness-permission-call.md deleted file mode 100644 index 51ed715dd8..0000000000 --- a/.changeset/harness-permission-call.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -`PermissionCallCapability` gives a plugin that runs tool calls itself, like code mode, the decision that `permissions()` makes for one call: `allow`, `deny` with the error that the model gets, or `ask` with the approval question. It uses the current mode, the saved `always` answers, and the resources of the call args, and `settle(reply)` applies the user's answer as a direct approval does. diff --git a/.changeset/harness-redaction.md b/.changeset/harness-redaction.md deleted file mode 100644 index 3db1857a62..0000000000 --- a/.changeset/harness-redaction.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@tanstack/ai-harness': minor ---- - -Keep the secrets of a session out of what it keeps and sends. - -- Each session replaces every secret that it gave out with `[redacted]`. These are the values of `run.keys` (also from env vars) and `run.credentials`, connector credentials and OAuth tokens, and secret answers (each string field of an answer with fields). It scrubs log records, feed events, plugin state (also in a `Map` or a `Set`), notes, the model context, and tool results, also after the own middleware of an agent run. It also scrubs the error frames of the HTTP handler, the WebSocket, and the stdio worker. It finds the raw, JSON-escaped, and URL-encoded forms, and skips values shorter than 6 characters. A secret split across two streaming deltas stays in those two events, and a secret that is JSON-escaped two times is not found. -- `scrubSecrets` from `@tanstack/ai-harness` is the one from `@tanstack/ai/byok`. -- A `step.do` value that holds a secret of the session throws, on the first run and on a replay, so the log never keeps a secret. Read the secret outside `step.do`. -- A retry of an input with a secret in it is a duplicate, not a conflict: the check compares the input without the secrets, as the log keeps it. -- The log keeps the final result of each tool call: the result after every `onAfterToolCall` hook (also the agent's own middleware in an agent run), scrubbed. A replacement from a hook wins, also on an error. Recovery after a crash gives the model that result. -- An answer to a `secret: true` question that reaches a host that does not own the log goes through the `credentials` store of the host, as a one-time reference with the credential type `secret_answer` and an `expiresAt` 24 hours later. Only the reference goes into the input lane, and the owner reads and deletes it. The references that nobody read go when the question ends. A kept answer for a run again goes when the run ends. Without `createHarnessHost({ credentials })`, the answer is rejected with `secret_answer_needs_credentials`. A used, expired, or unknown reference is rejected with `unknown_secret_answer`. `credentials.list` does not show the `secret_answer` type. diff --git a/.changeset/harness-round5-asks.md b/.changeset/harness-round5-asks.md deleted file mode 100644 index 5bf51455c8..0000000000 --- a/.changeset/harness-round5-asks.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@tanstack/ai': minor -'@tanstack/ai-compaction': minor -'@tanstack/ai-harness': minor -'@tanstack/ai-persistence': minor -'@tanstack/ai-sandbox': minor ---- - -Add the harness asks from the fifth findings list. - -- `chat({ outputSchema, stream: true })` now checks the full result against a Standard Schema. A failure ends with `RUN_ERROR` `structured-output-validation-failed`, with the model text in `metadata.tanstack.rawText`. -- `session.send(prompt, { outputSchema })` gives the turn a typed `structured` value. It is per send only, and the HTTP handler does not take it. -- A server tool reads run capabilities with `context.get(capability)` and `context.getOptional(capability)`, for example the sandbox under `withSandbox`. -- Readers keep the full history after a durable compaction. `session.transcript()`, `GET transcript`, and the chat hydration show every message. The model still gets the compacted context. -- `createHarnessHost({ acceptInput })` sees each input after the access check and before the log keeps it. It can change the input or refuse it. -- A plugin `adapter()` pick with `override: true` wins over the stored thread model. -- `onModelError` can return `{ retry: true, adapter }` to retry on another model. `ModelErrorContext` has `adapter` and `model`. -- Middleware reads `ctx.context.run.source` and `label` to tell a turn call from a plugin, router, or transfer call. -- `host.sessions.archive()` and `unarchive()`. Lists hide archived threads unless you pass `withArchived: true`. -- `host.sessions.eraseUser({ harness, threadId, userId, replies })` replaces one person's messages with `[removed]`, deletes their media, and cuts the log before a new snapshot. The new `erase` access action guards it. -- `HarnessRecord` is exported, for a typed `LogStore` wrapper. -- Coding-tool file calls pass their abort signal through `WorkspaceBackend` to the sandbox. -- `withCompaction` limits (`maxTokens`, `contextWindow`, `background.atTokens`) can be a function of `{ model }`. diff --git a/.changeset/harness-secure-defaults.md b/.changeset/harness-secure-defaults.md deleted file mode 100644 index 6f187ee8e7..0000000000 --- a/.changeset/harness-secure-defaults.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@tanstack/ai-harness': patch ---- - -Make the transport defaults safe for a server with many instances. - -- `createHarnessHandler` and `handleHarnessSocket` read the user of each request with the host `access`. On a host without `access`, pass `openAccess: true` (no user) or `openAccess: { user }` (one local user), else they throw at the start. So a server never serves every thread to everyone by mistake. -- `media: { secret }` signs the media URLs of `createHarnessHandler`. Set the same secret on every instance, so a media URL that one instance signs works on every instance. Without it, the handler signs with a random key of its own, and a URL works only on that instance. A host with `ownership` options needs `media`, else the handler throws at the start. On another host without `media`, the first handler logs one warning. A signed URL names its harness and its user, and the handler checks that user again when it serves the file. A URL of a deleted thread answers 404. -- `media: { keys: [newKey, oldKey] }` rotates the key: the first key signs, and every key checks. Each key must be at least 32 bytes, else the handler throws at the start. -- `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 deleted file mode 100644 index 2ddc4d7494..0000000000 --- a/.changeset/harness-tool-ops.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@tanstack/ai': minor -'@tanstack/ai-harness': minor -'@tanstack/ai-mcp': minor ---- - -Run a harness tool with no model turn, add host records from a client, and add a per-run system preamble. - -- `session.runTool({ name, args?, meta?, inputId?, user? })` 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 - stops the turn with an interrupt. While interrupts wait, the call is refused - with `pending_interrupts`. `meta` is echoed as a `tanstack.injection` custom - 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.runTool({ 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`. -- `expose.records` lists the host record types a client may add with an - `{ op: 'append', records }` input, or with the new `client.appendRecords({ records })`. - A record of another type, or with a `thread` field, is refused with - `not_exposed`. The session log keeps the records. -- `createHarnessMcpServer` adds a `tool_` tool for each tool in - `expose.tools`, and an `append` tool when `expose.records` has types. A tool - input with its own `threadId` field throws when the server is created, - because MCP uses `threadId` to pick the thread. -- `session.send(message, { systemPreamble })` prepends per-run, server-only - system prompts ahead of the harness's own system prompts (for example - host-provided memory). A client input cannot set them. -- A tool call in a harness turn gets `threadId` and `runId` in its execution - context. `ToolExecutionContext` in `@tanstack/ai` has the two new optional - fields for a host to set. diff --git a/.changeset/harness-turn-and-body-limits.md b/.changeset/harness-turn-and-body-limits.md deleted file mode 100644 index e1c3938cc5..0000000000 --- a/.changeset/harness-turn-and-body-limits.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@tanstack/ai-harness': patch ---- - -How a turn takes steers, settings, and plugin removals, and the size limit of client requests. - -- A steer that arrives during a `session.runTool()` turn waits, and then runs as its own turn. -- A turn keeps the config values that it started with. A config change while it runs, for example `/mode bypass`, applies from the next turn. -- `configure({ settings: { plugins: { remove } } })` removes everything that those plugins give the thread: also their agents, subagents, commands, agent and generation middleware, and the items that they add for other plugins (for example the permission rules of `agents()`). A remove that leaves a plugin (of any lifetime) or a harness middleware without a capability that it needs is rejected. -- When two plugins give a tool with the same name from `discoverTools`, the first one stays, and clients get one `harness.plugin.warning` event that names both plugins. -- `createHarnessHandler` reads each JSON body up to `media.maxBodyBytes` (default 32 MiB). A bigger body answers 413. `handleHarnessSocket` closes the socket with code 1009 on a bigger frame. The WebSocket server reads the whole frame first, so also set its own frame limit (for example `maxPayload` of `ws`). -- Client media is checked part by part before any part is stored, so a refused input stores nothing. A retried input with the same `inputId` stores its media no second time, and gets the first receipt. -- A client input with a provider file handle (`source.type: 'file'`) is refused with `media_url_not_allowed` unless the harness sets `media.allowProviderFiles: true`. Server code can still send file handles. -- `POST run` moves the inline bytes of the last user message to the media store, as `POST control` does, so the transcript keeps a `harness-media:` reference. A refused part answers 413 (`media_too_large`), 415 (`media_not_allowed`), or 400 (`media_invalid`, `media_url_not_allowed`). diff --git a/.changeset/harness-view-experimental.md b/.changeset/harness-view-experimental.md deleted file mode 100644 index 700a912b5c..0000000000 --- a/.changeset/harness-view-experimental.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': patch ---- - -Mark `createSessionView`, `SessionViewState`, and `SessionViewEvents` as `@experimental`. Document that event cursors are opaque strings: positions in the session log. diff --git a/.changeset/nested-subagent-resume.md b/.changeset/nested-subagent-resume.md deleted file mode 100644 index a948686be9..0000000000 --- a/.changeset/nested-subagent-resume.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@tanstack/ai': patch -'@tanstack/ai-persistence': patch ---- - -A nested subagent that waits on an interrupt now continues after the resume under `withPersistence`. Before, it ran again from its prompt. Core rebuilds the card of the nested child from the store and takes only its id from the client card. `LoadChild` takes an optional `parentSubagentRunId`, and returns `interruptIds` and `parentToolCallId`. diff --git a/.changeset/subagent-binding-debug.md b/.changeset/subagent-binding-debug.md deleted file mode 100644 index 89532d73b7..0000000000 --- a/.changeset/subagent-binding-debug.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai': patch ---- - -Add `SubagentBinding.debug`, the `debug` option for the model calls of child agents that set no `debug` of their own. diff --git a/.changeset/subagent-context-extend.md b/.changeset/subagent-context-extend.md deleted file mode 100644 index 34239c85b3..0000000000 --- a/.changeset/subagent-context-extend.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@tanstack/ai': minor ---- - -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`. Make each field optional, because a run that a plain `chat()` starts has no host. diff --git a/.changeset/tau-core.md b/.changeset/tau-core.md new file mode 100644 index 0000000000..e9047ea2ce --- /dev/null +++ b/.changeset/tau-core.md @@ -0,0 +1,112 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-compaction': minor +'@tanstack/ai-harness': minor +'@tanstack/ai-mcp': minor +'@tanstack/ai-persistence': minor +'@tanstack/ai-sandbox': minor +--- + +Core support for the harness: subagent binding options, the `onGuardToolCall` hook, server-side BYOK keys, harness tool runs, checked streamed `outputSchema` results, and resume fixes. + +Add the harness asks from the fifth findings list. + +- `chat({ outputSchema, stream: true })` now checks the full result against a Standard Schema. A failure ends with `RUN_ERROR` `structured-output-validation-failed`, with the model text in `metadata.tanstack.rawText`. +- `session.send(prompt, { outputSchema })` gives the turn a typed `structured` value. It is per send only, and the HTTP handler does not take it. +- A server tool reads run capabilities with `context.get(capability)` and `context.getOptional(capability)`, for example the sandbox under `withSandbox`. +- Readers keep the full history after a durable compaction. `session.transcript()`, `GET transcript`, and the chat hydration show every message. The model still gets the compacted context. +- `createHarnessHost({ acceptInput })` sees each input after the access check and before the log keeps it. It can change the input or refuse it. +- A plugin `adapter()` pick with `override: true` wins over the stored thread model. +- `onModelError` can return `{ retry: true, adapter }` to retry on another model. `ModelErrorContext` has `adapter` and `model`. +- Middleware reads `ctx.context.run.source` and `label` to tell a turn call from a plugin, router, or transfer call. +- `host.sessions.archive()` and `unarchive()`. Lists hide archived threads unless you pass `withArchived: true`. +- `host.sessions.eraseUser({ harness, threadId, userId, replies })` replaces one person's messages with `[removed]`, deletes their media, and cuts the log before a new snapshot. The new `erase` access action guards it. +- `HarnessRecord` is exported, for a typed `LogStore` wrapper. +- Coding-tool file calls pass their abort signal through `WorkspaceBackend` to the sandbox. +- `withCompaction` limits (`maxTokens`, `contextWindow`, `background.atTokens`) can be a function of `{ model }`. + +Run a harness tool with no model turn, add host records from a client, and add a per-run system preamble. + +- `session.runTool({ name, args?, meta?, inputId?, user? })` 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 + stops the turn with an interrupt. While interrupts wait, the call is refused + with `pending_interrupts`. `meta` is echoed as a `tanstack.injection` custom + 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.runTool({ 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`. +- `expose.records` lists the host record types a client may add with an + `{ op: 'append', records }` input, or with the new `client.appendRecords({ records })`. + A record of another type, or with a `thread` field, is refused with + `not_exposed`. The session log keeps the records. +- `createHarnessMcpServer` adds a `tool_` tool for each tool in + `expose.tools`, and an `append` tool when `expose.records` has types. A tool + input with its own `threadId` field throws when the server is created, + because MCP uses `threadId` to pick the thread. +- `session.send(message, { systemPreamble })` prepends per-run, server-only + system prompts ahead of the harness's own system prompts (for example + host-provided memory). A client input cannot set them. +- A tool call in a harness turn gets `threadId` and `runId` in its execution + context. `ToolExecutionContext` in `@tanstack/ai` has the two new optional + fields for a host to set. + +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`. Make each field optional, because a run that a plain `chat()` starts has no host. + +Add `interrupts` to `SubagentBinding`. + +- A host sets `binding.interrupts` to give interrupt definitions to every `ctx.chat` call of an agent run. Then the host middleware in `chatMiddleware` can stop the child with these interrupts. +- The own `interrupts` of the call win. When a host definition has the same id as one of the call, the host definition is not added. +- A child's `ctx.chat({ subagents })` passes the definitions on to its own children. +- A plain `chat()` call does not get them. + +Add `lastChatMiddleware` to `SubagentBinding`. It runs after the own middleware of each `ctx.chat` call of an agent run, so it sees the final model context and tool results. A child's `ctx.chat({ subagents })` passes it on, after the child's own. + +Add `validateJsonSchema(schema, value)`. It checks a value against a JSON Schema with `@cfworker/json-schema`, as a tool input is checked, but with no repair. The result has the Standard Schema shape: `{ value }` or `{ issues }`. The root also exports the `StandardSchemaV1` and `StandardJSONSchemaV1` types. + +An agent `run` that throws the shape of `MCPInputRequiredError` (`name`, `kind`, `request`) stops the child as `suspended`, as a tool does, and the parent run stops with its interrupt. The interrupt has a generic binding and no tool call. Its id is the `interruptId` of the thrown value, else `mcp_input_`, and its message is the error message. The resume runs the child again, and `ctx.resume` has the answer. + +Add `SubagentBinding.debug`, the `debug` option for the model calls of child agents that set no `debug` of their own. + +Add the `onGuardToolCall` middleware hook for checks that must always run, such as permissions. + +- It runs for every tool call, after all `onBeforeToolCall` hooks and after the `inputSchema` check. `hookCtx.args` holds the final checked arguments that the tool gets. +- The guard of every middleware runs, in order, until one returns a decision. An earlier `onBeforeToolCall` decision does not skip it. +- Return `{ type: 'skip', result }` to deny the call. The tool does not run, and the model gets `result`, the same as an `onBeforeToolCall` skip. Return `{ type: 'abort', reason }` to abort the run. +- It runs before a server tool runs and before a client tool goes to the client. A guard cannot change the arguments. +- The new `GuardToolCallDecision` type is exported. + +BYOK keys can now live on the server. `@tanstack/ai/byok` exports the `CredentialStore` contract, the `Credential` and `CredentialScope` types, and `defineCredentialStore`. A `CredentialScope` has `tenantId` and `userId`. Its `threadId` is optional, and a store ignores it. `@tanstack/ai/byok/server` adds: + +- `storedProviderKeys(store, scope)`: provider keys from the `api_key` credentials of a user, then of the tenant, then the provider's `env` names. Pass `keys.adapter(...)` to `chat()` and `keys.get` to `subagents.keys`. +- `readCredential(store, scope, id)`: one credential, the user's own first, then the tenant's. A user with no tenant reads only their own. +- `tenantScopeOf(scope)`: the tenant scope of a user scope, or `undefined` when the scope has no user or no tenant. +- `memoryCredentialStore()`: a store in memory, for development and tests. +- `envKeyEntry(provider)`: the first `provider.env` name that is set, with its value. +- `readCredentialEntry(store, scope, id)`: reads a credential like `readCredential`. It also tells you which scope had the credential (the user or the tenant), and gives its revision when the store has `getVersioned`. + +`CredentialStore` has two optional methods for compare-and-set writes, `getVersioned` and `setIf`, with the same shape as the pair on `MetadataStore`. A store without them still works. `memoryCredentialStore` has both. + +Keep the store in your own database or in a secrets manager, and encrypt the secrets at rest. An `oauth` credential can keep the OAuth `client` it was issued to. `list` never returns secret values. + +`scrubSecrets` from `@tanstack/ai/byok` now also removes the JSON-escaped and URL-encoded forms of each secret. It skips a secret that is shorter than 6 characters, so a short value does not remove common words. + +`Credential` has a new type, `secret_answer` (`{ value, expiresAt }`). The harness keeps a secret answer to a question there for a short time and deletes it once read. Leave this type out of a list of connected accounts. `memoryCredentialStore().list` returns its `expiresAt`. + +Approved edits to tool arguments stay raw until the final input check. + +- `@tanstack/ai`: a resume no longer rejects approved `editedArgs` that do not fit the tool's `inputSchema`. The edit goes through `onBeforeToolCall` and the final input check, like the model's arguments. If it still does not fit, the approval is used, the tool does not run, and the model gets the input error as the tool result. The transcript keeps the approved edit as it was sent, but only when the call passed the check. +- `@tanstack/ai-persistence`: `withPersistence` binds the client-tool phase of an approved tool call to its approval. After a restart, the client result resumes with the approval that came before it, and a stale or missing approval fails the resume. + +A nested subagent that waits on an interrupt now continues after the resume under `withPersistence`. Before, it ran again from its prompt. Core rebuilds the card of the nested child from the store and takes only its id from the client card. `LoadChild` takes an optional `parentSubagentRunId`, and returns `interruptIds` and `parentToolCallId`. + +`chat()` asks the middleware `onShouldContinue` after it runs the pending tool calls of a resume, before the next model call. A middleware that returns `false` there ends the run with no more model calls. The loop strategy is not asked at that point. + +`WebSocketLike` has an optional `bufferedAmount`. A server can read it to stop sending to a slow socket. A socket without it works as before.