diff --git a/.changeset/ai-byok-credential-cas.md b/.changeset/ai-byok-credential-cas.md deleted file mode 100644 index d6e768e28a..0000000000 --- a/.changeset/ai-byok-credential-cas.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@tanstack/ai': minor ---- - -`CredentialStore` can now do compare-and-set writes. It has two new optional methods, `getVersioned` and `setIf`, with the same shape as the pair on `MetadataStore`. A store without them still works. `memoryCredentialStore` has both. - -The new `readCredentialEntry` in `@tanstack/ai/byok/server` 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`. - -`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/ai-harness-cli.md b/.changeset/ai-harness-cli.md index a8b479839b..e1af00f698 100644 --- a/.changeset/ai-harness-cli.md +++ b/.changeset/ai-harness-cli.md @@ -5,8 +5,8 @@ Add `@tanstack/ai-harness-cli`: `runCli(harness)` runs a harness from the terminal. - Line mode reads one message or `/command` per line, hides a secret answer while the user types, and sends `@path` files. In a terminal, `/cancel` and a message typed while a turn runs act at once: the message joins the running turn. When a turn waits, each next line answers one wait: `y` or `n` for an approval (`once`, `always`, or `reject` for an approval of `permissions()`), and an answer read with the schema for a question. Pass `ui` to show your own screen on a session view instead. -- `-p "prompt"` runs one prompt for scripts and CI, and `--output ndjson` prints every AG-UI event. `-p` exits with code 3 (`EXIT.needsAction`) at the first wait of the turn: an approval, a question, or a sign-in. The exit code for this case changed from 2 to 3. A script that checks for exit code 2 must check for 3. -- `--serve` serves the session protocol over HTTP behind a bearer token. -- `--acp` and `--mcp` load `@tanstack/ai-acp` and `@tanstack/ai-mcp` only when you use the flag. A missing package, or one without harness support, fails that flag with a clear message. Any other error while the package loads is reported, not hidden. The `--dashboard` flag was removed. +- `-p "prompt"` runs one prompt for scripts and CI, and `--output ndjson` prints every AG-UI event. `-p` exits with code 3 (`EXIT.needsAction`) at the first wait of the turn: an approval, a question, or a sign-in. +- `--serve` serves the session protocol over HTTP behind a bearer token. It signs media URLs with its own key, never with the token: `--media-key` or `HARNESS_MEDIA_KEY` (at least 32 bytes), or a new random key at each start. Without a set key, signed media URLs stop working after a restart. On a host without `access`, it also passes `openAccess`: the `principal` of `runCli`, or `true` without one. So each request with the token runs as the user of the CLI. +- `--acp` and `--mcp` load `@tanstack/ai-acp` and `@tanstack/ai-mcp` only when you use the flag. A missing package, or one without harness support, fails that flag with a clear message. Any other error while the package loads is reported, not hidden. - In every mode, the first SIGINT or SIGTERM cancels the running turn. A second one, or one while no turn runs, stops the CLI, and `runCli` resolves to 130 (SIGINT) or 143 (SIGTERM). The CLI closes a host that it made with `recoverable: true`, or releases its session handle on your own host and does not wait for a turn that ignores the cancel. The `signals` option takes another signal source, for example an `EventEmitter` in a test. - `host` and `principal` options let the CLI use your durable host and run as your user. Every mode opens its sessions as that principal. On a host with `access`, `--serve` reads the user of each request with `identify`, and `--mcp` does not start, because stdio has no login. diff --git a/.changeset/ai-harness-core.md b/.changeset/ai-harness-core.md index 28644196f4..904f0a34cd 100644 --- a/.changeset/ai-harness-core.md +++ b/.changeset/ai-harness-core.md @@ -8,7 +8,7 @@ Add `@tanstack/ai-harness`: build an agent harness on TanStack AI. A harness kee - `createHarnessHost()` opens sessions. Each `host.open` gives its own handle to the live session of the thread. `session.close()`, or `await using`, releases the handle. A dropped handle is released by the garbage collector, with one warning. After `close()`, each call on the handle throws. When the session closes under a handle that is not released (its log failed, or a delete ran), the next call on the handle opens it again. - `lifecycle` on `createHarnessHost()` sets how sessions live. A session that no handle holds closes after it was idle for `idleCloseMs` (default 60 000). `maxOpen` (default 500) first closes the least recently used idle sessions. A session is not idle while a background job of this host runs. `retention` (`settledOperations`, `settledAgentRuns`, `eventBytes`) bounds the settled operations, agent runs, receipts, events, and log tail that each session keeps in memory. The log keeps the `MESSAGES_SNAPSHOT` that core sends when a run pauses at an interrupt, so a rejoin from the log rebuilds the paused run. `host.close({ recoverable })` stops the work of every session and refuses later opens. With `recoverable: true`, the host gives each log that has work back with a wake, so a worker takes it at once. - `host.observe(harness, { threadId, principal })` reads a thread with no session: its events, transcript, snapshot, media, usage, and entry. It sets up no plugins, writes nothing, and never takes the log, so a read works while another host owns the thread. Its snapshot follows the same rules as `session.snapshot()`, without what only an open session knows (a running turn, operations, queues). `view.logState()` reads the `durability.reduce` fold. -- `host.sessions.delete` closes the sessions of a thread and of its child threads. Then it deletes the log of the session tree (with the settings, config, plugin state, interrupted turn, runs, interrupts, usage, revert data, and the inputs that wait), the index entries, the uploaded media, the generated artifacts, and the log checkpoint. The first-party plugins also end the thread: `workspaceTools()` closes an owned sandbox with `'closed'`, and `snapshots()` removes the shadow repository. This needs the index entry, and the harness on that host: a host that opened it, or one with it in the `harnesses` option of `createHarnessHost()`. Else the host warns. A child thread is ended in the log of its root. While the delete runs, an open of the thread throws. With `access`, a thread with no index entry throws `AccessDeniedError`. +- `host.sessions.delete` closes the sessions of a thread and of its child threads. Then it deletes the log of the session tree (with the settings, config, plugin state, interrupted turn, runs, interrupts, usage, revert data, and the inputs that wait), the index entries, the uploaded media, and the generated artifacts. The first-party plugins also end the thread: `workspaceTools()` closes an owned sandbox with `'closed'`, and `snapshots()` removes the shadow repository. This needs the index entry, and the harness on that host: a host that opened it, or one with it in the `harnesses` option of `createHarnessHost()`. Else the host warns. A child thread is ended in the log of its root. While the delete runs, an open of the thread throws. With `access`, a thread with no index entry throws `AccessDeniedError`. - A session takes `send` (a prompt, a steer into the running turn, a follow-up, or a message to an agent run with `to`), `continue`, `reset`, `resolve`, `cancel`, and `background` inputs. Each returns an operation with a receipt, and the session streams typed events. `host.isOpen(harness, { threadId })` says whether this host holds that session open. - The session methods take one object: `cancelInput({ inputId, principal? })`, `revert({ messageId, principal? })`, `background({ toolCallId?, principal? })`, `getMedia({ id })`, `mediaUrl({ id })`, and `agentTranscript({ subagentRunId })`. - `session.append({ records, inputId?, principal? })` returns a `Receipt`. A second append with the same `inputId` writes nothing. Without `inputId`, the append gets a new input id, and the log keeps it as an input. The `harness.revert` record keeps the principal of `revert`, and the `harness.job` record keeps the principal of `background`. @@ -31,7 +31,7 @@ Add `@tanstack/ai-harness`: build an agent harness on TanStack AI. A harness kee - `ctx.harness.state({ key, initial })` gives the agents of a session a shared state by key, with `get` and `update`. The log keeps it, and a fork copies it. `STATE_SNAPSHOT` has `{ plugins, shared }`. - A routed agent takes steers at its next model call, and late joins in the same turn, and `turn.beforeFinish` and `turn.onModelError` run for it. - Every host keeps each session tree in one log, `stores.log` from `@tanstack/ai-persistence`. `memoryPersistence()` has a memory log, and a host with no `persistence` uses it. The log holds the inputs, events, transcripts, runs, interrupts, settings, config, plugin state, agent results, schedules, and tool steps, and the host folds them when a thread opens. A root thread and its child threads (agent runs and subagents) write to the log of the root. `session.logState()` reads the `durability.reduce` fold of the whole tree. A harness with another `durability.project` or `durability.reduce` than the one that folds an open log throws when it opens that log. With a log store that survives a restart, the next host recovers a turn that a crash stopped. -- One host owns a log at a time: the first write takes it, one timer per host renews it, and a host that loses it stops its work on it. A read never takes the log. An input that reaches another host waits in the input lane of the log, and the owner runs it (receipt `queued`). `ownership: { ttlMs, renewMs }` sets the timing. `renewMs` must be less than `ttlMs`, else `createHarnessHost` throws a `RangeError`. With only `ttlMs`, `renewMs` is a third of it. +- One host owns a log at a time: the first write takes it, one timer per host renews it, and a host that loses it stops its work on it. A host gives a log back when it has no work. Another host recovers the work of a log only after its ownership expires. A read never takes the log. An input that reaches another host waits in the input lane of the log, and the owner runs it (receipt `queued`) with its JSON overrides (`model`, `modelOptions`, `reasoning`, and `promptCache`). An input to a thread that was deleted is refused: the receipt is `rejected` with the reason `deleted`, and an awaited operation rejects with `InputRejectedError` ("The thread was deleted."). `ownership: { ttlMs, renewMs }` sets the timing. `renewMs` must be less than `ttlMs`, else `createHarnessHost` throws a `RangeError`. With only `ttlMs`, `renewMs` is a third of it. - When the owner refuses an offered input (busy, or an id that the log has with another payload), `session.settled()` on the host that offered it rejects with `InputRejectedError`. The log gets a `harness.offer.rejected` record. An offer that comes while the owner appends is not missed. - A host that does not own a thread opens it while the owner runs a background job. Settings, config values, and the interrupted turn follow the changes of other hosts. `configure` merges into the latest stored settings. - The owner writes a snapshot of the folded log to the log store after `log.snapshot.afterBytes` of new records (default 1 000 000) or `log.snapshot.afterMs` (default 300 000), and a new host opens from it. With `lifecycle.retention.logRecords: 'since-snapshot'` (default `'all'`), the owner truncates the records before each snapshot. A reader whose cursor is in the dropped part gets one `harness.gap` event (`EventGap`), then the stream goes on. With `'since-snapshot'`, a thread whose snapshot the host cannot use (a changed `reduce` version or `project`) cannot open, and rejects with `LogTruncatedError`. `session.revert()` works with `'since-snapshot'`: the snapshot keeps the last 500 file steps. A plugin sets which of its records the snapshot keeps with `keepRecords: { [type]: count }` on its definition, and reads them, oldest first, with `ctx.session.keptRecords(type)`. The fold keeps the last 100 ended runs, run nodes, interrupts, and agent results, and drops the turn overrides of a settled input, so a long thread does not grow its fold and its snapshot with each turn. @@ -39,7 +39,7 @@ Add `@tanstack/ai-harness`: build an agent harness on TanStack AI. A harness kee - `durableTool()` keeps the result of each step. A crash can cut a step, and then it runs again, so `step.do` passes `{ idempotencyKey, epoch }` to `fn`. Give the key to your system, so it refuses the second side effect. `step.ask({ name, message, schema })` asks the user: the log keeps the question, any host can take the answer, and a run again after a stop returns the logged answer. A `step.do` value must be JSON: a `Date`, a `Map`, or a class instance is a type error. `step.sleep({ name, ms })` waits in the process, and the log keeps the wake time, so a worker continues the call after a stop. A sleep longer than 30 s on a thread that only a worker runs gives the thread back with the wake, so it keeps no worker slot. `durability.truncatedToolResult` sets the text of a tool call cut at the output limit. Without it, a cut answer ends the turn. - `turn.onModelError`, `retryTransientErrors()`, `turn.beforeFinish`, and `turn.canJoin` control how a turn retries, continues, and ends. - Every approval, question, sign-in, client tool, and human takeover that a run waits for is a `Wait` in the session log (`waitId`, `kind`, `runId`, `schema`, `expiresAt`, `message`). `WaitKind` is `approval`, `question`, `sign-in`, `client-tool`, or `human`. `waitKind`, `isApproval`, `waitOfInterrupt`, and `waitOfQuestion` come from the root and from `./view`. `snapshot().pendingWaits` lists the open waits: each one with its `interrupt` when a chat run stopped for it, and `turn: true` for a wait of the stopped turn. It is the one wait view of a snapshot. and `session.waitFor({ runId, signal })` resolves with the next one. A wait ends `answered`, `cancelled`, or `expired`. A wait with `expiresAt` expires on time. Only `sign-in` has a default limit: 15 minutes. -- A wait that only the log holds keeps no host busy: the session goes idle, and an answer on any host continues the run. `resolve` answers the waits with an `interrupt`: of a stopped turn, or of one background agent run per call. `answer` answers the waits with no `interrupt` (a `step.ask` question, an MCP question, the `human` wait of a handoff), checks the answer against the JSON Schema of the wait, and keeps a secret out of the log. A `resolve` of such a wait is refused with `use_answer`, and a second answer to a wait that one answer ends now is refused. `host.submit` checks an answer against the wait that it answers. +- A wait that only the log holds keeps no host busy: the session goes idle, and an answer on any host continues the run. `resolve` answers the waits with an `interrupt`: of a stopped turn, or of one background agent run per call. `answer` answers the waits with no `interrupt` (a `step.ask` question, an MCP question, the `human` wait of a handoff), checks the answer against the JSON Schema of the wait, and keeps a secret out of the log. A `resolve` of such a wait is refused with `use_answer`, and a second answer to a wait that one answer ends is refused. `host.submit` checks an answer against the wait that it answers. - A run waits for a person only at fixed points: `permissions()` before a tool call, the `question` tool, `step.ask` in a durable tool, a command (`CommandContext.step`), or an agent run, and the live waits of MCP elicitation and OAuth sign-in. `ctx.session.ask` throws anywhere else, with an error that names `step.ask`, the `question` tool, and `PermissionResources`. A command that called `step.ask` runs again from the top after a host stop. `Question` takes `expiresAt` and `signal`. - A background agent run stops with status `waiting` on a wait, for example an approval of `permissions()`, and continues after the answer, also after a restart. An agent that the model calls as a tool inside a turn cannot wait: a call of it that needs an approval is refused. - The `operator` access action answers a `human` wait of any run, also with a resolve. A `human` wait takes only `operator`. `membersOnly()` gives it only to the role `operator`, so the user who waits for a person cannot act as that person. `membersOnly({ operator: { roles } })` sets the roles that have it. `authorize` gets the `wait` (`waitId`, `kind`) of an `answer` or `operator` action. diff --git a/.changeset/ai-persistence-host-heartbeat.md b/.changeset/ai-persistence-host-heartbeat.md deleted file mode 100644 index a997320915..0000000000 --- a/.changeset/ai-persistence-host-heartbeat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-persistence': minor ---- - -`LogStore` gets `heartbeat({ hostId, ttlMs })`. A pin (`setAffinity`) now counts only while its host is alive: `acquire` and `listOrphaned` ignore the affinity of a host with no live heartbeat, so the logs of a host that stopped go to other hosts. A host that is alive keeps its pins, also on the logs that it released. A custom store must add `heartbeat` and this rule. `memoryLogStore()` and the conformance suite have both. diff --git a/.changeset/byok-credential-store.md b/.changeset/byok-credential-store.md index 76c6540880..2b3af2222c 100644 --- a/.changeset/byok-credential-store.md +++ b/.changeset/byok-credential-store.md @@ -9,5 +9,12 @@ BYOK keys can now live on the server. `@tanstack/ai/byok` exports the `Credentia - `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/harness-acp-agent.md b/.changeset/harness-acp-agent.md index 291e840bb9..734b6058fa 100644 --- a/.changeset/harness-acp-agent.md +++ b/.changeset/harness-acp-agent.md @@ -4,4 +4,4 @@ `@tanstack/ai-acp/agent` adds `createAcpAgent` and `serveAcp`, which serve a harness as an ACP v2 agent (experimental, like the draft protocol). Each wait of a turn goes to the editor by its kind: an approval becomes a permission request (with `allow_always` only when the answer schema takes `always`), a question or a `human` wait becomes a form elicitation, and a sign-in becomes a URL elicitation, when the editor declares them in `initialize`. A wait that the editor cannot answer is cancelled. Cancel cancels the running turn, and image, audio, and embedded file blocks go to the media store of the session. A prompt takes its place in the queue before its media is saved, and gives the place back when the session is idle. `session/list` lists the sessions of the harness from the session index of the host. The `principal` option is the user of the editor: each session opens as that user, and the list shows the sessions of that user. It is required on a host with `access`. The agent holds one session handle per ACP session. `session/close` releases it, and the close of the connection releases them all (a session whose prompt runs keeps its handle until that prompt ends), so the host closes each session on idle. `@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. -One agent app serves one connection, and reads the editor capabilities from that connection. A second connection gets a clear error. For more connections, make one app for each: `new AcpServer({ createAgent: () => createAcpAgent(options) })`. A `request_permission` names the real tool call id. A session that the agent made or opened in its connection takes prompts after the agent released its handle, also on a host with no `stores.sessions`. +One agent app serves one connection, and reads the editor capabilities from that connection. A second connection gets a clear error. For more connections, make one app for each: `new AcpServer({ createAgent: () => createAcpAgent(options) })`. A session that the agent made or opened in its connection takes prompts after the agent released its handle, also on a host with no `stores.sessions`. diff --git a/.changeset/harness-cli-handler-options.md b/.changeset/harness-cli-handler-options.md deleted file mode 100644 index bd1a0fc1f0..0000000000 --- a/.changeset/harness-cli-handler-options.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness-cli': patch ---- - -`--serve` signs media URLs with its own key, never with the token: `--media-key` or `HARNESS_MEDIA_KEY` (at least 32 bytes), or a new random key at each start. Without a set key, signed media URLs stop working after a restart. On a host without `access`, it also passes `openAccess`: the `principal` of `runCli`, or `true` without one. So each request with the token runs as the user of the CLI. On a host with `access`, `identify` reads the user of each request. diff --git a/.changeset/harness-forward-inputs.md b/.changeset/harness-forward-inputs.md deleted file mode 100644 index 79e298f3ea..0000000000 --- a/.changeset/harness-forward-inputs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': patch ---- - -An input that reaches a host while another host owns the thread waits in the input lane of the log, and the owner runs it (receipt `queued`). The running turn on the owner goes on. The input keeps its JSON overrides (`model`, `modelOptions`, `reasoning`, and `promptCache`). An answer to a question from `ctx.session.ask` goes to the host that asked, and the log keeps a secret answer as `[secret]`. An input to a thread that was deleted is refused: the receipt is `rejected` with the reason `deleted`, and an awaited operation rejects with `InputRejectedError` ("The thread was deleted."). diff --git a/.changeset/harness-log-newer-records.md b/.changeset/harness-log-newer-records.md index f614a2ed2e..671dc6ca55 100644 --- a/.changeset/harness-log-newer-records.md +++ b/.changeset/harness-log-newer-records.md @@ -2,4 +2,4 @@ '@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 fold checkpoint from a newer harness is ignored. +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-recover-live-owner.md b/.changeset/harness-recover-live-owner.md deleted file mode 100644 index 8f69bc1fea..0000000000 --- a/.changeset/harness-recover-live-owner.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-harness': patch ---- - -One host owns the log of a thread at a time. The first write of a host takes the log, one timer per host renews every log that it owns, and the host gives a log back when it has no work. A host that opens a thread while another live host owns its log does not recover that work: the work runs again only after that ownership expires. A host that loses a log stops its work on it, and its sessions still read the thread. The `ownership` option of `createHarnessHost` sets `ttlMs` and `renewMs`. diff --git a/.changeset/harness-tool-ops.md b/.changeset/harness-tool-ops.md index 4e0078cb69..bbc0e07b88 100644 --- a/.changeset/harness-tool-ops.md +++ b/.changeset/harness-tool-ops.md @@ -25,7 +25,7 @@ Run a harness tool with no model turn, add host records from a client, and add a `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 now throws when the server is created, + 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 diff --git a/.changeset/harness-turn-and-body-limits.md b/.changeset/harness-turn-and-body-limits.md index c3a8dc216c..b840f01acd 100644 --- a/.changeset/harness-turn-and-body-limits.md +++ b/.changeset/harness-turn-and-body-limits.md @@ -2,9 +2,9 @@ '@tanstack/ai-harness': patch --- -Fix how a turn takes steers, settings, and plugin removals, and limit the size of client requests. +How a turn takes steers, settings, and plugin removals, and the size limit of client requests. -- A steer that arrives during a `session.tool()` turn waits, and then runs as its own turn. Before, the tool turn took it, did not answer it, and settled it `completed`. +- A steer that arrives during a `session.tool()` 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. diff --git a/.changeset/persistence-session-stores.md b/.changeset/persistence-session-stores.md index ddb20839d0..e27f52a3f7 100644 --- a/.changeset/persistence-session-stores.md +++ b/.changeset/persistence-session-stores.md @@ -4,7 +4,7 @@ Add opt-in session stores. The chat and generation middleware work as before without them. -- `log` (`LogStore`): an append-only log for each session tree, with 14 methods, each with one object argument. Records: `append`, `read`, and `subscribe`. Ownership: `acquire`, `renew`, `release`, `setAffinity`, and `listOrphaned`. The input lane: `offer` and `offered`, consumed in the same write with `append({ consumes })`. The owner or the first offer names the harness of a log for `listOrphaned`: a later offer does not change it. Snapshots and lifecycle: `getSnapshot`, `putSnapshot`, `truncate`, and `deleteLog`. `acquire` returns `expired: true` when it takes the log from another owner whose ownership expired with no release. Each fenced write carries the epoch from `acquire`, and a write with an old epoch rejects with `OwnershipLostError`. A writer that never calls `acquire` appends with `UNOWNED_EPOCH`. `setAffinity`, `putSnapshot`, and `truncate` need the owner, so they refuse `UNOWNED_EPOCH`. After `deleteLog`, every fenced write fails until the next `acquire`, and `offer` returns the new `OfferResult` `'deleted'` and stores nothing, so a late write or input cannot bring a deleted log back. `append` rejects with `LogConflictError` when another writer took the position, and `read` rejects with `LogTruncatedError` before the truncation point. `memoryLogStore()` is the reference store. `memoryLogStore({ state })` with one `memoryLogState()` lets two store instances share one log. The conformance kit needs `twin`, a second store set with a new log store instance on the same backend, for the `log` cases across instances. +- `log` (`LogStore`): an append-only log for each session tree, with 15 methods, each with one object argument. Records: `append`, `read`, and `subscribe`. Ownership: `acquire`, `renew`, `release`, `setAffinity`, `heartbeat`, and `listOrphaned`. A pin (`setAffinity`) counts only while its host is alive: `acquire` and `listOrphaned` ignore the affinity of a host with no live `heartbeat({ hostId, ttlMs })`, so the logs of a host that stopped go to other hosts. A host that is alive keeps its pins, also on the logs that it released. The input lane: `offer` and `offered`, consumed in the same write with `append({ consumes })`. The owner or the first offer names the harness of a log for `listOrphaned`: a later offer does not change it. Snapshots and lifecycle: `getSnapshot`, `putSnapshot`, `truncate`, and `deleteLog`. `acquire` returns `expired: true` when it takes the log from another owner whose ownership expired with no release. Each fenced write carries the epoch from `acquire`, and a write with an old epoch rejects with `OwnershipLostError`. A writer that never calls `acquire` appends with `UNOWNED_EPOCH`. `setAffinity`, `putSnapshot`, and `truncate` need the owner, so they refuse `UNOWNED_EPOCH`. After `deleteLog`, every fenced write fails until the next `acquire`, and `offer` returns the new `OfferResult` `'deleted'` and stores nothing, so a late write or input cannot bring a deleted log back. `append` rejects with `LogConflictError` when another writer took the position, and `read` rejects with `LogTruncatedError` before the truncation point. `memoryLogStore()` is the reference store. `memoryLogStore({ state })` with one `memoryLogState()` lets two store instances share one log. The conformance kit needs `twin`, a second store set with a new log store instance on the same backend, for the `log` cases across instances. - `sessions` (`SessionIndexStore`): one index entry per tenant and thread. The key is `{ tenantId?, threadId }` (`SessionIndexKey`), so one thread id in two tenants is two entries. An entry has `members` (`SessionMember`, with the role `owner`, `member`, `viewer`, or `operator`). The owner is the member with the role `owner`. The store has `get(key)`, `getVersioned(key)`, `upsert(entry)`, `upsertIf({ entry, revision })` for compare-and-set writes, `list`, and `delete(key)`, a hard delete. `list` reads one tenant, filters by member, parent thread, title `search`, `harness` name, and exact `metadata` values, and pages with a cursor. `defineLogStore` and `defineSessionIndexStore` type an adapter. `memoryPersistence()` now also has `log` and `sessions`. `runPersistenceConformance` runs the cases of each session store when the store is present, so an existing backend needs no `skip` entry. diff --git a/.changeset/persistence-store-shapes.md b/.changeset/persistence-store-shapes.md deleted file mode 100644 index d7e2e44d2f..0000000000 --- a/.changeset/persistence-store-shapes.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@tanstack/ai-persistence': patch ---- - -The session store docs allow a content-hash revision (an ETag of S3 or Netlify Blobs) that comes back for the same entry. diff --git a/docs/harness/compaction.md b/docs/harness/compaction.md index 1d7693947a..6a0e24a939 100644 --- a/docs/harness/compaction.md +++ b/docs/harness/compaction.md @@ -110,7 +110,7 @@ const project: ProjectOptions = { } ``` -Change `version` when you change the function. A fold checkpoint with another version is ignored. +Change `version` when you change the function. A log snapshot with another version is not used, so the host folds the log again from the first record. ## Retry after a context overflow diff --git a/docs/harness/durable-sessions.md b/docs/harness/durable-sessions.md index 7cb2f5172a..e0476aa03b 100644 --- a/docs/harness/durable-sessions.md +++ b/docs/harness/durable-sessions.md @@ -29,7 +29,7 @@ const host = createHarnessHost({ persistence: memoryPersistence() }) - `memoryPersistence()` has a memory `log`. A host with no `persistence` uses it too. - To keep the log in your database, implement the [`LogStore` contract](../persistence/store-reference#logstore). A host whose log store survives a restart is a durable host. -- `metadata` is optional. With it, the host keeps a fold checkpoint of each log, so a long thread opens faster. +- `metadata` is optional. It keeps data wider than one thread, for example the saved rules of `permissions()`. ### Keep a long log fast to open diff --git a/docs/harness/lifecycle.md b/docs/harness/lifecycle.md index 321c4d9f07..0fc7052b49 100644 --- a/docs/harness/lifecycle.md +++ b/docs/harness/lifecycle.md @@ -181,10 +181,9 @@ await leanHost.sessions.delete({ threadId: 'chat-1' }) First, `delete` closes the sessions of the thread and of its child threads, for every handle. The host finds the children through the `parentThreadId` of their index entries. Then it deletes this data: -- The log, with everything in it: the transcripts, the settings, the plugin config and state, the runs, the interrupts, the usage, and the inputs that wait for an owner. +- The log, with everything in it: the transcripts, the settings, the plugin config and state, the runs, the interrupts, the usage, the snapshots, and the inputs that wait for an owner. - The index entry of each thread. - The uploaded media and the generated artifacts. -- The log checkpoint in `stores.metadata`. The first-party plugins also end the thread: diff --git a/docs/harness/overview.md b/docs/harness/overview.md index c8aeb8bdc9..c2fc33de00 100644 --- a/docs/harness/overview.md +++ b/docs/harness/overview.md @@ -201,7 +201,7 @@ Each host keeps every thread in a session log: the transcript, the inputs, the r - `log`: the session log. Required. - `sessions`: one index entry per thread, for a list of sessions. See [List, rename, and fork sessions](./sessions). -- `metadata`: data wider than one thread, for example the saved rules of `permissions()`, and a fold checkpoint so a long thread opens faster. +- `metadata`: data wider than one thread, for example the saved rules of `permissions()`. `memoryPersistence()` has every store, but it keeps them in memory only. To keep the log in your database, implement the [`LogStore` contract](../persistence/store-reference#logstore). Then a turn continues after a crash or a deploy. See [Durable sessions](./durable-sessions). diff --git a/docs/harness/session-log.md b/docs/harness/session-log.md index ba6e782c32..6fc3130ab6 100644 --- a/docs/harness/session-log.md +++ b/docs/harness/session-log.md @@ -94,7 +94,7 @@ export const projected = defineHarness({ - `record` must be pure: the same log always folds to the same context, live and after a restart. - The model sees the change at its next model call. A message that arrives during a tool call comes after the tool result. -- Change `version` when you change `record`. A fold checkpoint with another version is ignored. +- Change `version` when you change `record`. A log snapshot with another version is not used, so the host folds the log again from the first record. - For compaction, use `projectCompaction` from `@tanstack/ai-compaction`. See [Compact a harness session](./compaction). - A chat middleware in a harness turn can append records itself with `getLogRecords(ctx).append([...])` from `@tanstack/ai`. The records land in the log at once. A plugin's middleware reads the writer with `getLogRecords(ctx, { optional: true })`. A plugin cannot list `LogRecordsCapability` in `requires`. diff --git a/docs/harness/shared-logs.md b/docs/harness/shared-logs.md index d5dbe00a5e..9188d27943 100644 --- a/docs/harness/shared-logs.md +++ b/docs/harness/shared-logs.md @@ -97,7 +97,7 @@ console.log((await tracked.logState())?.tasks) - `record` must be pure. It returns the next state, or the same state for no change. - The type of the state flows from `initial` and `record`. Here `logState()` resolves to `Children | undefined`. A harness without `reduce` gets `undefined`. - The fold comes from the log, so a session on another machine gets the same state. -- Change `version` when you change `record`. A fold checkpoint with another version is ignored. +- Change `version` when you change `record`. A log snapshot with another version is not used, so the host folds the log again from the first record. - `durability.project` also works with `reduce`. It folds a host record into the messages of its own thread only. - Every harness that opens the same log must pass the same `project` and `reduce` objects. When a harness with other ones opens a log that is open, `host.open` throws, and so do the reads of `host.observe`. diff --git a/docs/harness/thread-settings.md b/docs/harness/thread-settings.md index aa05330a2a..20726ab2fa 100644 --- a/docs/harness/thread-settings.md +++ b/docs/harness/thread-settings.md @@ -68,7 +68,7 @@ The six fields: | `plugins` | `{ remove: [names] }`: the turns leave out what those plugins give. | | `cwd` | The working folder of the thread. See [The working folder](#the-working-folder). | -An unknown field, model, or plugin, or a bad value, gets a receipt with `status: 'rejected'` and the reason. `stores.metadata` keeps the settings, so they stay after a restart. Without a metadata store, they stay in memory. A [fork](./fork-and-reset) copies them. +An unknown field, model, or plugin, or a bad value, gets a receipt with `status: 'rejected'` and the reason. The settings are in the log, so a durable log store keeps them after a restart. A [fork](./fork-and-reset) copies them. `configure` is an input, so the log keeps who changed what. Each change sends a `harness.settings.changed` event with the new `settings`. A session on another host reads the change too, so its next turn uses the new settings. diff --git a/docs/harness/usage.md b/docs/harness/usage.md index 7646cfb45f..6c48f10b00 100644 --- a/docs/harness/usage.md +++ b/docs/harness/usage.md @@ -64,11 +64,7 @@ A call that fails before it ends does not count. Each message runs as its sender ## Where the totals live -The totals stay with the thread: - -- The host keeps one `harness.usage` record per call in the log. The log keeps the totals after a restart. -- Another host with `stores.metadata` keeps the totals there. -- A host with neither keeps them in memory. +The totals stay with the thread. The host keeps one `harness.usage` record per call in the log, so a durable log store keeps the totals after a restart. A [fork of a thread](./fork-and-reset) starts at zero. diff --git a/packages/ai-harness-cli/tests/cli.test.ts b/packages/ai-harness-cli/tests/cli.test.ts index 0ce1a7ae19..ecfc3cb3a7 100644 --- a/packages/ai-harness-cli/tests/cli.test.ts +++ b/packages/ai-harness-cli/tests/cli.test.ts @@ -15,7 +15,7 @@ import { import { question as questionTool } from '@tanstack/ai-harness/plugins' import { createMCPClient } from '@tanstack/ai-mcp' import { memoryPersistence } from '@tanstack/ai-persistence' -import { EXIT, USAGE, parseCliArgs, runCli } from '../src' +import { EXIT, parseCliArgs, runCli } from '../src' import { serve } from '../src/serve' import { asStdin, @@ -88,13 +88,6 @@ describe('parseCliArgs', () => { }) expect(parseCliArgs([])).toMatchObject({ mcp: false, yes: false }) }) - - it('refuses --dashboard as an unknown flag', () => { - expect(() => parseCliArgs(['--dashboard', 'http://127.0.0.1:1'])).toThrow( - "Unknown option '--dashboard'", - ) - expect(USAGE).not.toContain('--dashboard') - }) }) describe('print mode', () => { diff --git a/packages/ai-harness/src/define.ts b/packages/ai-harness/src/define.ts index 10e8c51a74..b95c125a10 100644 --- a/packages/ai-harness/src/define.ts +++ b/packages/ai-harness/src/define.ts @@ -177,7 +177,7 @@ export interface HarnessDurability< timeoutMs?: number /** * Decide how an input that a crashed host left recovers: an input that - * never ran, and a turn whose lease expired. The context has the decision + * never ran, and a turn whose host stopped. The context has the decision * the harness takes by default. Return `undefined` to keep it. Return * `{ action: 'run', overrides }` to run the turn with `TurnOverrides`. */ diff --git a/packages/ai-harness/src/first-party/coding/formatter.ts b/packages/ai-harness/src/first-party/coding/formatter.ts index 18ed926c82..5d540de4ee 100644 --- a/packages/ai-harness/src/first-party/coding/formatter.ts +++ b/packages/ai-harness/src/first-party/coding/formatter.ts @@ -353,16 +353,6 @@ export function projectFormatters( } } -/** - * The `afterWrite` hook of {@link formatter}. See {@link projectFormatters}. - */ -export function formatOnWrite( - options: FormatterOptions & { root: string }, - report: Parameters[1], -) { - return projectFormatters(options, report).afterWrite -} - /** * Format each file that `write_file`, `edit_file`, and `patch` write, with * the formatter that the project uses for its file type. Use it with diff --git a/packages/ai-harness/src/host.ts b/packages/ai-harness/src/host.ts index 7138107eaf..35af219cd0 100644 --- a/packages/ai-harness/src/host.ts +++ b/packages/ai-harness/src/host.ts @@ -356,12 +356,11 @@ export interface HostSessions

{ * Change the entry of a thread from app code, with no session open: its * `title`, `pinned`, or `metadata`. Each field you pass replaces the stored * one. Pass a function of the current entry to change part of `metadata`: - * with a store that has `upsertIf`, it runs again when another host wrote - * first, so neither change is lost. The harness `metadataSchema` checks - * `metadata`, and types it. Resolves to the changed entry, or `undefined` - * for a thread that has no entry. Throws `SessionMetadataError` for - * metadata the schema refuses, and an error when the entry names another - * harness. + * it runs again when another host wrote first, so neither change is lost. + * The harness `metadataSchema` checks `metadata`, and types it. Resolves + * to the changed entry, or `undefined` for a thread that has no entry. + * Throws `SessionMetadataError` for metadata the schema refuses, and an + * error when the entry names another harness. * * @example * ```ts diff --git a/packages/ai-harness/src/http.ts b/packages/ai-harness/src/http.ts index 5b4efe5fbb..213650345c 100644 --- a/packages/ai-harness/src/http.ts +++ b/packages/ai-harness/src/http.ts @@ -817,7 +817,6 @@ export function createHarnessHandler

( return json({ error: 'unknown run' }, 404) } // Without a cursor, from the start of the run: its first input record. - // A fold snapshot from before input positions does not know it: from 0. return cursorResponse( (signal) => followOperation( diff --git a/packages/ai-harness/src/log-records.ts b/packages/ai-harness/src/log-records.ts index 44b52130fa..cab7cde815 100644 --- a/packages/ai-harness/src/log-records.ts +++ b/packages/ai-harness/src/log-records.ts @@ -771,8 +771,8 @@ export function foldThreadRecord(state: ThreadStateFold, record: LogRecord) { return { ...state, runs: withEntry(state.runs, record.runId, next), - // A change of other fields (a lease, a checkpoint) keeps the status - // of the node. + // A change of other fields (a checkpoint) keeps the status of the + // node. tree: record.patch.status === undefined ? state.tree diff --git a/packages/ai-harness/src/log.ts b/packages/ai-harness/src/log.ts index 252d8b63c5..33d7542071 100644 --- a/packages/ai-harness/src/log.ts +++ b/packages/ai-harness/src/log.ts @@ -274,11 +274,8 @@ export interface InputState { input: HarnessInput principal?: { id: string; tenantId?: string } at: number - /** - * The position of the input record. The events of its run come after it. - * Unknown in a fold snapshot from before this field. - */ - seq?: number + /** The position of the input record. The events of its run come after it. */ + seq: number status: 'pending' | 'applied' | 'joined' | 'rejected' | 'settled' operationId?: string attempt: number @@ -305,9 +302,9 @@ export interface LogState { messages: Array /** * The position of the last transcript record. The events after it are not - * in the transcript. Unknown after a fold snapshot with no value for this field. + * in the transcript. */ - transcriptSeq?: number + transcriptSeq: number /** In admission order. */ inputs: Map toolResults: Map @@ -393,7 +390,7 @@ export function runStartOf( ): Cursor | undefined { let first: number | undefined for (const input of state.inputs.values()) { - if (input.operationId !== operationId || input.seq === undefined) continue + if (input.operationId !== operationId) continue if (first === undefined || input.seq < first) first = input.seq } return first === undefined ? undefined : String(first - 1) @@ -812,8 +809,7 @@ export function sessionOf(state: SharedLogState, thread: string) { * thread id of the root session: one log keeps the root and all of its child * threads (agent chains and subagent runs). * - * The other stores that the harness keys by thread (metadata and media) use - * the same key, with the thread id as `logId`. + * The media stores use the same key, with the thread id as `logId`. * * @example * ```ts @@ -912,8 +908,7 @@ interface SnapshotVersions { /** * The version of the snapshot state shape. A snapshot of another version is - * not used: the fold starts at the first record. Version 4 was the metadata - * checkpoint, which a host does not read any more. + * not used: the fold starts at the first record. */ export const SNAPSHOT_VERSION = '5' @@ -942,9 +937,12 @@ function serializeThread(thread: ThreadStateFold) { function parseThread(value: unknown) { if (!isRecord(value) || !isRecord(value.settings)) return undefined const { config, plugins, agentResults, turnOverrides, schedules } = value - const { runs, tree, interrupts } = value - // A snapshot with no run tree is not used: a fold from the start makes it. + const { runs, tree, interrupts, shared, waits, transfers } = value + // A snapshot with a missing field is not used: a fold from the start makes it. const isShaped = + Array.isArray(shared) && + Array.isArray(waits) && + Array.isArray(transfers) && Array.isArray(config) && Array.isArray(plugins) && Array.isArray(agentResults) && @@ -959,12 +957,7 @@ function parseThread(value: unknown) { settings: value.settings as ThreadSettings, config: new Map(config as Array<[string, unknown]>), plugins: new Map(plugins as Array<[string, unknown]>), - // A state with no shared state gets an empty value. - shared: new Map( - Array.isArray(value.shared) - ? (value.shared as Array<[string, unknown]>) - : [], - ), + shared: new Map(shared as Array<[string, unknown]>), interrupted: (value.interrupted ?? null) as InterruptedTurnRecord | null, agentResults: new Map(agentResults as Array<[string, AgentResultRecord]>), turnOverrides: new Map( @@ -974,24 +967,14 @@ function parseThread(value: unknown) { runs: new Map(runs as Array<[string, RunRecord]>), tree: new Map(tree as Array<[string, RunTreeNode]>), interrupts: new Map(interrupts as Array<[string, InterruptRecord]>), - // A state with no waits gets an empty value. - waits: new Map( - Array.isArray(value.waits) - ? (value.waits as Array<[string, WaitState]>) - : [], - ), - // A state with no transfers gets an empty value. - transfers: new Map( - Array.isArray(value.transfers) - ? (value.transfers as Array<[string, TransferRecord]>) - : [], - ), + waits: new Map(waits as Array<[string, WaitState]>), + transfers: new Map(transfers as Array<[string, TransferRecord]>), // A state with no active agent: the main model answers. activeAgent: typeof value.activeAgent === 'string' || isHumanTarget(value.activeAgent) ? (value.activeAgent as ThreadStateFold['activeAgent']) : null, - // A state with no workspace gets an empty value. + // A thread with no workspace binding has null. workspace: isRecord(value.workspace) ? (value.workspace as WorkspaceBinding) : null, @@ -1032,12 +1015,20 @@ function serialize(state: SharedLogState, versions: SnapshotVersions) { /** One session of a snapshot at `seq`, or `undefined` when it is not shaped. */ function parseSession(value: unknown, seq: number) { if (!isRecord(value)) return undefined - const { messages, inputs, toolResults, steps } = value + const { messages, inputs, toolResults, steps, started, runUsage } = value + const { jobs, notes, keptRecords, transcriptSeq, usage } = value const isShaped = Array.isArray(messages) && Array.isArray(inputs) && Array.isArray(toolResults) && - Array.isArray(steps) + Array.isArray(steps) && + Array.isArray(started) && + Array.isArray(runUsage) && + Array.isArray(jobs) && + Array.isArray(notes) && + Array.isArray(keptRecords) && + typeof transcriptSeq === 'number' && + isSessionUsage(usage) const thread = parseThread(value.thread) if (!isShaped || !thread) return undefined const revert = revertOf(value.revert) @@ -1045,53 +1036,27 @@ function parseSession(value: unknown, seq: number) { const session: LogState = { seq, messages: (messages as Array).map(revive), - // A state with no `transcriptSeq` gets an empty value. - ...(typeof value.transcriptSeq === 'number' - ? { transcriptSeq: value.transcriptSeq } - : {}), + transcriptSeq, inputs: new Map( (inputs as Array).map((input) => [input.inputId, input]), ), toolResults: new Map(toolResults as Array<[string, ModelMessage]>), - // A state with no `started` gets an empty value. started: new Map( - Array.isArray(value.started) - ? (value.started as Array< - [string, { name: string; replay: 'safe' | 'never' }] - >) - : [], + started as Array<[string, { name: string; replay: 'safe' | 'never' }]>, ), steps: new Map(steps as Array<[string, unknown]>), - // A state with no usage totals gets an empty value, and totals from - // before `bySource` get an empty map. - usage: isSessionUsage(value.usage) - ? { bySource: {}, ...value.usage } - : emptyUsage(), - // A state with no run sums gets an empty value. - runUsage: new Map( - Array.isArray(value.runUsage) - ? (value.runUsage as Array<[string, UsageCounts]>) - : [], - ), - // A state with no reverts gets an empty value. + usage, + runUsage: new Map(runUsage as Array<[string, UsageCounts]>), + // A thread with no revert data has null. ...(revert ? { revert } : {}), - // A state with no jobs and notes gets an empty value. A job with a - // backend id is a pair. + // A job with a backend id is a pair. jobs: new Map( - (Array.isArray(value.jobs) - ? (value.jobs as Array) - : [] - ).map((job) => (typeof job === 'string' ? [job, undefined] : job)), - ), - notes: new Map( - Array.isArray(value.notes) - ? (value.notes as Array<[string, string]>) - : [], + (jobs as Array).map((job) => + typeof job === 'string' ? [job, undefined] : job, + ), ), - // A state with no kept records gets an empty value. - keptRecords: Array.isArray(value.keptRecords) - ? (value.keptRecords as Array) - : [], + notes: new Map(notes as Array<[string, string]>), + keptRecords: keptRecords as Array, thread, } return session diff --git a/packages/ai-harness/src/observability.ts b/packages/ai-harness/src/observability.ts index 6a2422fb65..31d5bcea41 100644 --- a/packages/ai-harness/src/observability.ts +++ b/packages/ai-harness/src/observability.ts @@ -149,12 +149,6 @@ export function parseTraceparent(value: unknown): SpanContext | undefined { } } -/** The W3C `traceparent` header value of a span context. */ -export function traceparentOf(context: SpanContext): string { - const flags = (context.traceFlags & 0xff).toString(16).padStart(2, '0') - return `00-${context.traceId}-${context.spanId}-${flags}` -} - /** A context whose span is `span`: a span started in it is its child. */ const parentContext = (span: Span | undefined): Context | undefined => span && otel ? otel.trace.setSpan(otel.context.active(), span) : undefined diff --git a/packages/ai-harness/src/purge.ts b/packages/ai-harness/src/purge.ts index feeb491aae..5ab36b0d39 100644 --- a/packages/ai-harness/src/purge.ts +++ b/packages/ai-harness/src/purge.ts @@ -1,18 +1,13 @@ import { resolveArtifactBlobKey } from '@tanstack/ai-persistence' import { uploadBlobPrefix } from './media' -import { USAGE } from './session/helpers' import type { ArtifactStore, BlobStore } from '@tanstack/ai-persistence' import type { HarnessPersistence } from './host' -/** The metadata rows that a purge deletes for a thread, keyed by its store key. */ -const THREAD_METADATA = [USAGE, 'harness:revert'] - /** * Delete the stored data of one thread: its log (with its settings, config, - * plugin state, interrupted turn, runs, and interrupts), its media, and the - * harness metadata rows. `storeId` is the store key of the thread, from `logIdOf`. It is also - * the store id of the thread's own log. A store the persistence does not - * have is skipped. + * plugin state, interrupted turn, runs, interrupts, usage, and snapshots) and + * its media. `storeId` is the store key of the thread, from `logIdOf`. It is + * also the store id of the thread's own log. */ export async function purgeThreadData({ storeId, @@ -24,8 +19,7 @@ export async function purgeThreadData({ /** The media stores of the host, which can be memory stores. */ media: { artifacts: ArtifactStore; blobs: BlobStore } }) { - const { log, metadata } = stores - await log.deleteLog({ logId: storeId }) + await stores.log.deleteLog({ logId: storeId }) // Uploads and generated media are both artifacts of the thread. const records = await media.artifacts.listForThread(storeId) @@ -44,9 +38,4 @@ export async function purgeThreadData({ for (const object of page.objects) await media.blobs.delete(object.key) cursor = page.truncated ? page.cursor : undefined } while (cursor !== undefined) - - if (!metadata) return - for (const namespace of THREAD_METADATA) { - await metadata.delete(namespace, storeId) - } } diff --git a/packages/ai-harness/src/session-entry.ts b/packages/ai-harness/src/session-entry.ts index 10fa035f8a..08230e2514 100644 --- a/packages/ai-harness/src/session-entry.ts +++ b/packages/ai-harness/src/session-entry.ts @@ -27,9 +27,8 @@ export interface SessionEntryChange> { /** * A change, or a function that makes the change from the current entry. Use * the function to change part of `metadata`: when another host writes the - * entry first, the function runs again on the new entry (with a store that - * has `upsertIf`), so neither change is lost. Return `undefined` to write - * nothing. + * entry first, the function runs again on the new entry, so neither change + * is lost. Return `undefined` to write nothing. */ export type SessionEntryUpdate< TMetadata = Record, diff --git a/packages/ai-harness/src/session/harness-session.ts b/packages/ai-harness/src/session/harness-session.ts index 70a47cd6d7..a74a7b70b4 100644 --- a/packages/ai-harness/src/session/harness-session.ts +++ b/packages/ai-harness/src/session/harness-session.ts @@ -645,8 +645,8 @@ export class SessionImpl ...(stores.sessions ? { sessions: stores.sessions } : {}), }, } + // The `harness.tool.started` records are the running tools of a turn. this.checkpoint = checkpointMiddleware({ - runs, messages: engineMessages, onToolStart: ({ toolCallId, name, replay }) => writer.append([ @@ -775,7 +775,6 @@ export class SessionImpl */ protected async cutOffAnswer(writer: LogWriter, operationId: string) { const from = writer.state.transcriptSeq - if (from === undefined) return { text: '', thinking: [] } let text = '' // By reasoning message id, in stream order. const blocks = new Map() @@ -1182,12 +1181,9 @@ export class SessionImpl }, } : { action: 'run' } - const pending = [ - ...(run?.checkpoint?.pendingTools ?? []), - ...[...writer.state.started] - .filter(([toolCallId]) => !writer.state.toolResults.has(toolCallId)) - .map(([toolCallId, tool]) => ({ toolCallId, ...tool })), - ] + const pending = [...writer.state.started] + .filter(([toolCallId]) => !writer.state.toolResults.has(toolCallId)) + .map(([toolCallId, tool]) => ({ toolCallId, ...tool })) // Before the decision, so the hook has it when it settles the input. A // resolve repairs nothing. const interruptedTools = diff --git a/packages/ai-harness/src/session/helpers.ts b/packages/ai-harness/src/session/helpers.ts index 6940ffb708..6f56c3f151 100644 --- a/packages/ai-harness/src/session/helpers.ts +++ b/packages/ai-harness/src/session/helpers.ts @@ -107,9 +107,6 @@ export function snapshotFrom({ } } -/** A metadata row of usage totals that `host.sessions.delete` still deletes. */ -export const USAGE = 'harness:usage' - /** * The log record of how the agent run `runId` ended. Its result is a JSON * copy. A result that is not JSON (a cycle) is left out. @@ -862,7 +859,7 @@ export function lastAssistantText(messages: ReadonlyArray) { /** * True when chat() runs its routed path for this bag: the router picks, and * the engine does not run. So the middleware that gives the engine its - * history and its run lease does not run, and the turn does that work. + * history and its run record does not run, and the turn does that work. */ export function isRoutedBag( bag: Pick | undefined, diff --git a/packages/ai-harness/src/session/types.ts b/packages/ai-harness/src/session/types.ts index 74daaef19d..aa2aa9978f 100644 --- a/packages/ai-harness/src/session/types.ts +++ b/packages/ai-harness/src/session/types.ts @@ -527,7 +527,7 @@ export interface InterruptedTurn { handoff?: Omit /** * Who sent the input of the stopped turn. Only this principal's saved - * credential answers its sign-ins. A copy from an older version has none. + * credential answers its sign-ins. */ principal?: { id: string; tenantId?: string } /** The `context` of the stopped turn's input, so the resume sees it too. */ diff --git a/packages/ai-harness/src/usage.ts b/packages/ai-harness/src/usage.ts index 8430801a76..37248792a0 100644 --- a/packages/ai-harness/src/usage.ts +++ b/packages/ai-harness/src/usage.ts @@ -126,15 +126,14 @@ export function addUsage(totals: SessionUsage, call: UsageCall) { } } -/** Is `value` usage totals that the harness wrote? Old totals have no `bySource`. */ -export function isSessionUsage( - value: unknown, -): value is Omit & Partial { +/** Is `value` usage totals that the harness wrote? */ +export function isSessionUsage(value: unknown): value is SessionUsage { return ( typeof value === 'object' && value !== null && 'total' in value && 'byModel' in value && - 'bySender' in value + 'bySender' in value && + 'bySource' in value ) } diff --git a/packages/ai-harness/tests/agent-limits.test.ts b/packages/ai-harness/tests/agent-limits.test.ts index ffedc190a0..92d5f3c54e 100644 --- a/packages/ai-harness/tests/agent-limits.test.ts +++ b/packages/ai-harness/tests/agent-limits.test.ts @@ -672,8 +672,7 @@ describe('per-run limits of an agent run', () => { await host.close() }) - // flaky: fails in CI under load (Nx hides which test), skipped until it is made deterministic. - it.skip('stops a permanent model error after 3 calls, also when onModelError always answers retry', async () => { + it('stops a permanent model error after 3 calls, also when onModelError always answers retry', async () => { const model = mockAdapter(() => runError('invalid api key')) const worker = defineAgent({ name: 'worker', diff --git a/packages/ai-harness/tests/agent-resume.test.ts b/packages/ai-harness/tests/agent-resume.test.ts index c0f01c6649..59f62d7844 100644 --- a/packages/ai-harness/tests/agent-resume.test.ts +++ b/packages/ai-harness/tests/agent-resume.test.ts @@ -28,7 +28,7 @@ const alice = { id: 'alice' } /** * Host A starts the background agent `worker`. It reserves an id in a step, * and its model calls `lookup`, then waits in its second model call. Then - * host A stops: its log refuses writes, and its run lease expires. Host B + * host A stops: its log refuses writes, and its ownership expires. Host B * opens the thread on the same stores. */ async function stopDuringAgent(options: { diff --git a/packages/ai-harness/tests/build-stub.test.ts b/packages/ai-harness/tests/build-stub.test.ts index af1582f361..a2479fd109 100644 --- a/packages/ai-harness/tests/build-stub.test.ts +++ b/packages/ai-harness/tests/build-stub.test.ts @@ -161,8 +161,7 @@ describe('buildHarness with a stand-in bundler', () => { ).toEqual([]) }) - // flaky: hits the 5 s timeout under a parallel test load; passes alone. - it.skip('also compiles an executable when asked', async () => { + it('also compiles an executable when asked', async () => { const outfile = join(tmp, 'studio-bin') const { executable } = await buildHarness({ entry, diff --git a/packages/ai-harness/tests/coding-formatter.test.ts b/packages/ai-harness/tests/coding-formatter.test.ts index 77984a88b2..b1e09550aa 100644 --- a/packages/ai-harness/tests/coding-formatter.test.ts +++ b/packages/ai-harness/tests/coding-formatter.test.ts @@ -10,8 +10,8 @@ import { } from '../src/first-party/permissions' import { FormatFailed, - formatOnWrite, formatter, + projectFormatters, } from '../src/first-party/coding/formatter' import { FormatterRewrote } from '../src/first-party/coding/events' import { mockAdapter, questionsOf, text, toolCall } from './helpers' @@ -95,7 +95,7 @@ async function formatIn( ) { const { backend, runs } = project(files, exec) const failures: Array = [] - const afterWrite = formatOnWrite( + const { afterWrite } = projectFormatters( { root, backend, ...options }, { onFailure: (failure) => failures.push(failure) }, ) @@ -110,7 +110,7 @@ const mine: Formatter = { command: (file) => `mine ${file}`, } -describe('formatOnWrite', () => { +describe('projectFormatters', () => { it.each([ ['.prettierrc', prettier, 'a.ts', 'npx --no-install prettier --write'], [ @@ -211,7 +211,7 @@ describe('formatOnWrite', () => { it('keeps POSIX paths in a sandbox, also on a Windows host', async () => { const { backend, runs } = project(prettier, ok, 'sh') - const afterWrite = formatOnWrite( + const { afterWrite } = projectFormatters( { root: '/workspace', backend }, { onFailure: () => undefined }, ) @@ -239,7 +239,7 @@ describe('formatOnWrite', () => { return readdir(dir) } const failures: Array = [] - const afterWrite = formatOnWrite( + const { afterWrite } = projectFormatters( { root, backend }, { onFailure: (failure) => failures.push(failure) }, ) @@ -252,7 +252,7 @@ describe('formatOnWrite', () => { it('checks the config files once, at the first write', async () => { const { backend, runs, put } = project({}) - const afterWrite = formatOnWrite( + const { afterWrite } = projectFormatters( { root, backend }, { onFailure: () => undefined }, ) @@ -305,7 +305,7 @@ describe('formatOnWrite', () => { return { exitCode: 0, stdout: '', stderr: '' } }) const rewrites: Array<{ path: string; formatter: string }> = [] - const afterWrite = formatOnWrite( + const { afterWrite } = projectFormatters( { root, backend: files.backend }, { onFailure: () => undefined, diff --git a/packages/ai-harness/tests/harness-agent.test.ts b/packages/ai-harness/tests/harness-agent.test.ts index 84d364e137..82f31e51cc 100644 --- a/packages/ai-harness/tests/harness-agent.test.ts +++ b/packages/ai-harness/tests/harness-agent.test.ts @@ -219,8 +219,7 @@ describe('harnessAgent in a harness session', () => { await host.close() }) - // flaky: fails in CI under load (Nx hides which test), skipped until it is made deterministic. - it.skip('counts the child model calls in the root usage, and the budget of the run stops the child', async () => { + it('counts the child model calls in the root usage, and the budget of the run stops the child', async () => { const inner = mockAdapter([ () => withTokens(toolCall('noop', {}), 60), () => text('never'), diff --git a/packages/ai-harness/tests/log-stores.test.ts b/packages/ai-harness/tests/log-stores.test.ts index 1fc229223a..98952e85ae 100644 --- a/packages/ai-harness/tests/log-stores.test.ts +++ b/packages/ai-harness/tests/log-stores.test.ts @@ -114,7 +114,7 @@ const deploy = toolDefinition({ inputSchema: z.object({ env: z.string() }), }) -/** A host on `log` only: no run store and no interrupt store. */ +/** A host with only a `log` store. */ async function openOn( log: LogStore, replies: Array, diff --git a/packages/ai-harness/tests/log.test.ts b/packages/ai-harness/tests/log.test.ts index 489cc95b9f..f80cfbdff9 100644 --- a/packages/ai-harness/tests/log.test.ts +++ b/packages/ai-harness/tests/log.test.ts @@ -25,6 +25,7 @@ import { import { HostOwnership, LogOwnedElsewhereError } from '../src/ownership' import { CRASHING_OWNERSHIP, wrapLogStore, writeAsOtherHost } from './helpers' import { HARNESS_EVENTS } from '../src/types' +import { emptyUsage } from '../src/usage' import type { ModelMessage, StreamChunk } from '@tanstack/ai' import type { LogRecord, LogSnapshot, LogStore } from '@tanstack/ai-persistence' import type { @@ -988,6 +989,29 @@ describe('snapshots', () => { runs: [], tree: [], interrupts: [], + waits: [], + transfers: [], + shared: [], + activeAgent: null, + workspace: null, + ...overrides, + }) + + /** One session of a snapshot, as `serialize` writes it. */ + const snapshotSession = (overrides: Record = {}) => ({ + messages: [user('marker', 'from the snapshot')], + transcriptSeq: 1, + inputs: [], + toolResults: [], + started: [], + steps: [], + usage: emptyUsage(), + runUsage: [], + revert: null, + jobs: [], + notes: [], + keptRecords: [], + thread: threadOf(), ...overrides, }) @@ -1001,18 +1025,7 @@ describe('snapshots', () => { version, state: { version: 'v1', - sessions: [ - [ - THREAD, - { - messages: [user('marker', 'from the snapshot')], - inputs: [], - toolResults: [], - steps: [], - thread, - }, - ], - ], + sessions: [[THREAD, snapshotSession({ thread })]], reduced: null, ...overrides, }, @@ -1062,31 +1075,6 @@ describe('snapshots', () => { expect([...state.thread.config]).toEqual([['level', 'high']]) }) - it('gives usage totals from a snapshot before bySource an empty bySource', async () => { - const snapshot = snapshotOf({}) - const total = { - calls: 1, - promptTokens: 7, - completionTokens: 3, - totalTokens: 10, - cachedTokens: 0, - cacheWriteTokens: 0, - } - const [first] = (snapshot.state as { sessions: Array<[string, any]> }) - .sessions - first![1].usage = { total, byModel: { 'mock/m': total }, bySender: {} } - const store = await seededStore(snapshot) - - const state = await loadSession({ store, project }) - - expect(state.usage).toEqual({ - total, - byModel: { 'mock/m': total }, - bySender: {}, - bySource: {}, - }) - }) - it('folds the workspace binding and the backend ids of jobs, from records and from a snapshot', async () => { const binding = { kind: 'sandbox', @@ -1112,14 +1100,11 @@ describe('snapshots', () => { sessions: [ [ THREAD, - { + snapshotSession({ messages: [], - inputs: [], - toolResults: [], - steps: [], jobs: [['bash-1', 'p9'], 'bash-2'], thread: threadOf({ workspace: binding }), - }, + }), ], ], }) @@ -1144,6 +1129,19 @@ describe('snapshots', () => { ], ['another projection version', snapshotOf({ version: 'v0' })], ['sessions that are not shaped', snapshotOf({ sessions: 'x' })], + [ + 'usage totals with no bySource', + snapshotOf({ + sessions: [ + [ + THREAD, + snapshotSession({ + usage: { total: emptyUsage().total, byModel: {}, bySender: {} }, + }), + ], + ], + }), + ], [ 'a record type from a newer harness', snapshotOf({ recordTypes: ['harness.transcript', 'harness.later'] }), diff --git a/packages/ai-harness/tests/observability.test.ts b/packages/ai-harness/tests/observability.test.ts index d18b840612..1fe9fb51c0 100644 --- a/packages/ai-harness/tests/observability.test.ts +++ b/packages/ai-harness/tests/observability.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - noopTracer, - parseTraceparent, - traceparentOf, - withSpan, -} from '../src/observability' +import { noopTracer, parseTraceparent, withSpan } from '../src/observability' import { fakeSpan } from './helpers' import type { Tracer } from '@opentelemetry/api' @@ -35,7 +30,7 @@ function recordingTracer() { } describe('traceparent', () => { - it('reads a valid value and writes it back the same', () => { + it('reads a valid value', () => { const context = parseTraceparent(TRACEPARENT) expect(context).toEqual({ traceId: '4bf92f3577b34da6a3ce929d0e0e4736', @@ -43,7 +38,6 @@ describe('traceparent', () => { traceFlags: 1, isRemote: true, }) - expect(traceparentOf(context!)).toBe(TRACEPARENT) }) it.each([ @@ -66,16 +60,6 @@ describe('traceparent', () => { parseTraceparent(`${TRACEPARENT.replace(/^00/, '01')}-extra`), ).toMatchObject({ spanId: '00f067aa0ba902b7' }) }) - - it('writes the flags as two hex characters', () => { - expect( - traceparentOf({ - traceId: 'a'.repeat(32), - spanId: 'b'.repeat(16), - traceFlags: 0, - }), - ).toBe(`00-${'a'.repeat(32)}-${'b'.repeat(16)}-00`) - }) }) describe('withSpan', () => { diff --git a/packages/ai-harness/tests/purge.test.ts b/packages/ai-harness/tests/purge.test.ts index 1b0f7f1e13..800c75f72c 100644 --- a/packages/ai-harness/tests/purge.test.ts +++ b/packages/ai-harness/tests/purge.test.ts @@ -14,9 +14,6 @@ import type { HarnessPersistence } from '../src' const encoder = new TextEncoder() -/** The metadata rows of a thread that a delete removes. */ -const ROWS = ['harness:usage', 'harness:revert'] - const level = definePlugin({ name: 'test/level', setup: () => ({ @@ -28,18 +25,16 @@ const level = definePlugin({ /** The stores of a host, with or without a session index. */ function persistenceOf(withIndex: boolean) { - const { log, metadata, sessions, artifacts, blobs } = - memoryPersistence().stores + const { log, sessions, artifacts, blobs } = memoryPersistence().stores const persistence: HarnessPersistence = { stores: { log, - metadata, artifacts, blobs, ...(withIndex ? { sessions } : {}), }, } - return { persistence, metadata, artifacts, blobs, sessions } + return { persistence, artifacts, blobs, sessions } } function setup(withIndex = true) { @@ -68,8 +63,7 @@ function storedTranscript( describe('host.sessions.delete', () => { it('purges the thread and its child', async () => { - const { host, harness, persistence, metadata, artifacts, blobs, sessions } = - setup() + const { host, harness, persistence, artifacts, blobs, sessions } = setup() const key = logIdOf({ logId: 't1' }) // The child: a thread with its own transcript, then an entry under t1. @@ -113,10 +107,6 @@ describe('host.sessions.delete', () => { createdAt: 1, }) await session.close() - // Rows that a turn writes only in some cases: seed them. - for (const namespace of ROWS) { - await metadata.set(namespace, key, { seeded: true }) - } expect(await storedTranscript(persistence, 't1')).not.toEqual([]) expect(await storedTranscript(persistence, 'child-1')).not.toEqual([]) @@ -129,9 +119,6 @@ describe('host.sessions.delete', () => { expect(await artifacts.listForThread(key)).toEqual([]) expect((await blobs.list({ prefix: 'artifacts/' })).objects).toEqual([]) expect(await artifacts.get(upload.id)).toBeNull() - for (const namespace of ROWS) { - expect(await metadata.get(namespace, key)).toBeNull() - } // The settings and config were in the log, so they went with it. const reopened = await host.open(harness, { threadId: 't1' }) expect(reopened.settings()).toEqual({}) diff --git a/packages/ai-harness/tests/leases.test.ts b/packages/ai-harness/tests/recovery.test.ts similarity index 98% rename from packages/ai-harness/tests/leases.test.ts rename to packages/ai-harness/tests/recovery.test.ts index 049f65ad96..c986b45fc5 100644 --- a/packages/ai-harness/tests/leases.test.ts +++ b/packages/ai-harness/tests/recovery.test.ts @@ -41,7 +41,7 @@ const waiter = defineAgent({ ), }) -/** The stores of a durable host with no run store: only a log. */ +/** The stores of a durable host: only a log. */ function logOnlyPersistence(log: LogStore = memoryLogStore()) { return { stores: { log } } } @@ -60,7 +60,7 @@ async function openHost( ...(ownership ? { ownership } : {}), }) const session = await host.open( - defineHarness({ name: 'test/leases', adapter, tools }), + defineHarness({ name: 'test/recovery', adapter, tools }), { threadId: THREAD, }, @@ -114,7 +114,7 @@ async function openCutOff( ...(ownership ? { ownership } : {}), }) const session = await host.open( - defineHarness({ name: 'test/leases', adapter, ...options }), + defineHarness({ name: 'test/recovery', adapter, ...options }), { threadId: THREAD, }, @@ -126,7 +126,7 @@ async function openCutOff( const roles = (messages: ReadonlyArray<{ role: string; content: unknown }>) => messages.map((message) => [message.role, message.content]) -describe('a durable host with no run store', () => { +describe('a durable host with only a log', () => { it('lets the caller answer an interrupted turn as soon as it ends', async () => { const log = memoryLogStore() // A store that releases after a timer, like one over the network. @@ -201,7 +201,7 @@ describe('a durable host with no run store', () => { }) const session = await first.open( defineHarness({ - name: 'test/leases', + name: 'test/recovery', adapter: mockAdapter([]).adapter, agents: [pricer, waiter], }), @@ -338,7 +338,7 @@ describe('the runs in the log of a durable host', () => { const log = memoryLogStore() const persistence = { stores: { log } } const harness = defineHarness({ - name: 'test/leases', + name: 'test/recovery', adapter: mockAdapter([]).adapter, agents: [waiter], }) @@ -386,7 +386,7 @@ describe('close({ recoverable: true })', () => { const { adapter, calls } = mockAdapter(replies) const host = createHarnessHost({ persistence }) const session = await host.open( - defineHarness({ name: 'test/leases', adapter, ...options }), + defineHarness({ name: 'test/recovery', adapter, ...options }), { threadId: THREAD, }, diff --git a/packages/ai-harness/tests/resume-edges.test.ts b/packages/ai-harness/tests/resume-edges.test.ts index 37fd5036d0..ece13d0996 100644 --- a/packages/ai-harness/tests/resume-edges.test.ts +++ b/packages/ai-harness/tests/resume-edges.test.ts @@ -64,7 +64,7 @@ describe('crash recovery edges', () => { ).not.toContain(JSON.stringify(INTERRUPTED_TOOL_RESULT)) }) - it('releases the lease timer when a turn fails', async () => { + it('fails the turn and closes the host when the model throws', async () => { const persistence = memoryPersistence() const { adapter } = mockAdapter([ () => diff --git a/packages/ai-harness/tests/resume.test.ts b/packages/ai-harness/tests/resume.test.ts index 312a9c66d9..95e7987cbb 100644 --- a/packages/ai-harness/tests/resume.test.ts +++ b/packages/ai-harness/tests/resume.test.ts @@ -74,6 +74,12 @@ const runIn = ( runId: string, ) => storedRun(persistence.stores.log, { threadId: 't1' }, runId) +/** The records in the log of thread `t1`. */ +const recordsIn = async (persistence: ReturnType) => + (await persistence.stores.log.read({ logId: logIdOf({ logId: 't1' }) })).map( + (entry) => entry.record, + ) + /** Can another host acquire the log of thread `t1` now? */ const isTakeable = (persistence: ReturnType) => canOtherHostTake(persistence.stores.log, logIdOf({ logId: 't1' })) @@ -99,7 +105,7 @@ describe('checkpoints and ownership', () => { () => text('done'), ]) const session = await host.open( - defineHarness({ name: 'test/lease', adapter, tools: [lookup] }), + defineHarness({ name: 'test/resume', adapter, tools: [lookup] }), { threadId: 't1', }, @@ -109,18 +115,24 @@ describe('checkpoints and ownership', () => { await started.opened // Another host cannot take the thread while the turn runs. expect(await isTakeable(persistence)).toBe(false) - const running = await runIn(persistence, turn.id) - expect(running?.checkpoint?.pendingTools).toEqual([ - { toolCallId: 'call-1', name: 'lookup', replay: 'safe' }, - ]) + expect(await recordsIn(persistence)).toContainEqual({ + type: 'harness.tool.started', + toolCallId: 'call-1', + name: 'lookup', + replay: 'safe', + }) // The assistant message with the tool call is saved before the tool runs. const saved = await session.transcript() expect(saved.at(-1)?.toolCalls?.[0]?.function.name).toBe('lookup') release.open() await turn - const finished = await runIn(persistence, turn.id) - expect(finished?.checkpoint?.pendingTools).toEqual([]) + expect(await recordsIn(persistence)).toContainEqual( + expect.objectContaining({ + type: 'harness.tool.result', + toolCallId: 'call-1', + }), + ) await host.close() }) @@ -130,7 +142,7 @@ describe('checkpoints and ownership', () => { const { adapter } = mockAdapter([]) const session = await host.open( defineHarness({ - name: 'test/agent-lease', + name: 'test/agent-resume', adapter, agents: [waiter], }), diff --git a/packages/ai-harness/tests/run-route-hosts.test.ts b/packages/ai-harness/tests/run-route-hosts.test.ts index b2e26164d5..badcfa789d 100644 --- a/packages/ai-harness/tests/run-route-hosts.test.ts +++ b/packages/ai-harness/tests/run-route-hosts.test.ts @@ -34,7 +34,7 @@ function twoHosts(options: { snapshot?: SnapshotTiming } = {}) { } const release = gate() // RUN_STARTED, then the rest after `release`. By RUN_STARTED, host A holds - // the run lease, so host B only reads. + // the log, so host B only reads. const reply: Reply = () => (async function* () { const [started, ...rest] = text('Hello from A.') diff --git a/packages/ai-harness/tests/run-route.test.ts b/packages/ai-harness/tests/run-route.test.ts index 37754387f1..2a3e11ae64 100644 --- a/packages/ai-harness/tests/run-route.test.ts +++ b/packages/ai-harness/tests/run-route.test.ts @@ -169,8 +169,7 @@ describe('POST run', () => { await host.close() }) - // flaky: fails in CI under load (Nx hides which test), skipped until it is made deterministic. - it.skip('streams the parent run to its end when a resume answers a wait of a harnessAgent child', async () => { + it('streams the parent run to its end when a resume answers a wait of a harnessAgent child', async () => { deployed.length = 0 const child = defineHarness({ name: 'test/deploy-child', diff --git a/packages/ai-harness/tests/workspace-binding.test.ts b/packages/ai-harness/tests/workspace-binding.test.ts index d299059675..7581e06b28 100644 --- a/packages/ai-harness/tests/workspace-binding.test.ts +++ b/packages/ai-harness/tests/workspace-binding.test.ts @@ -317,8 +317,7 @@ describe('the workspace binding in the session log', () => { await again.host.close() }) - // flaky: fails in CI under load (Nx hides which test), skipped until it is made deterministic. - it.skip('keeps the pin of a live host on its idle thread, after its ownership expired', async () => { + it('keeps the pin of a live host on its idle thread, after its ownership expired', async () => { const persistence = memoryPersistence() const { log } = persistence.stores const { adapter } = readTurn('c0') diff --git a/packages/ai-mcp/skills/ai-mcp/SKILL.md b/packages/ai-mcp/skills/ai-mcp/SKILL.md index 5547233f8d..6125186640 100644 --- a/packages/ai-mcp/skills/ai-mcp/SKILL.md +++ b/packages/ai-mcp/skills/ai-mcp/SKILL.md @@ -871,13 +871,17 @@ How it works: for the connections. - A server that fails gets the status `failed` with its error. The tools of the other servers still work. -- The status of a server is `connecting`, `connected`, or `failed`. `/mcp` - prints it. A UI reads `snapshot().plugins['tanstack/mcp'].servers`. +- The status of a server is `connecting`, `connected`, `reconnecting`, or + `failed`. `/mcp` prints it. A UI reads + `snapshot().plugins['tanstack/mcp'].servers`. +- When a server closes, `mcp()` connects again with backoff. It lists the + tools again on `list_changed`. - A `stdio` server runs as one child process (Node only) for the sessions of the host that use the same `mcp()` object. It stops when the last of them closes. An `mcp()` call inside `plugins` starts one per session. - A server request for input (MCP elicitation, spec 2025 and 2026) becomes - a session question. The tool call waits for `session.answer()`. + a session question. The tool call waits for + `session.answer({ questionId, value })`. Options for each server: @@ -885,6 +889,8 @@ Options for each server: of that server. - `oauth: true` (`http` only): sign in like `mcpConnector`. The user runs `/connect `, and the tools come after the sign-in. +- `needsApproval`: ask the user before a tool of that server runs. +- `toolName`: give the tools of that server other names. ## Abort signal — cancelling in-flight MCP calls diff --git a/packages/ai-persistence/skills/ai-persistence/SKILL.md b/packages/ai-persistence/skills/ai-persistence/SKILL.md index 17c0c39f07..7e96156bf9 100644 --- a/packages/ai-persistence/skills/ai-persistence/SKILL.md +++ b/packages/ai-persistence/skills/ai-persistence/SKILL.md @@ -170,10 +170,11 @@ for a real one: **The persistence stores can hold secrets too:** - `log`, the input lane: when another host owns the thread, a user's answer - to a secret question (a pasted key) waits here as an offer until the owner - applies it. The log records keep the answer as `[secret]`. Keep the offers - in a store you trust as much as the credentials. -- `messages`, `log`: never put a key in a message, tool input, or metadata. + to a secret question (a pasted key) does not go into the offer. The host + keeps the value in the `credentials` store under a one-time + `harness:answer:*` key, and the offer has only a reference to it. The log + records keep the answer as `[secret]`. +- `log`: never put a key in a message, tool input, or metadata. The harness scrubs known secrets from the errors it sends, but it cannot scrub what it never saw as a secret.