From 7d946f16cc94be5234b54943596cd5c840cb965b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sat, 17 Oct 2026 18:12:50 +0200 Subject: [PATCH 01/15] fix(ai-harness): a settled operation keeps its status, a media signer with key rotation, and agents() rule docs that match the code --- docs/harness/agents.md | 4 +- docs/harness/permissions.md | 2 +- packages/ai-harness/src/first-party/agents.ts | 11 +- packages/ai-harness/src/media-signing.ts | 114 ++++++++++++++++++ packages/ai-harness/src/operation.ts | 12 ++ .../ai-harness/tests/media-signing.test.ts | 82 +++++++++++++ .../tests/operation-settled.test.ts | 71 +++++++++++ 7 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 packages/ai-harness/src/media-signing.ts create mode 100644 packages/ai-harness/tests/media-signing.test.ts create mode 100644 packages/ai-harness/tests/operation-settled.test.ts diff --git a/docs/harness/agents.md b/docs/harness/agents.md index cfefb87c9e..2f9c4e6e17 100644 --- a/docs/harness/agents.md +++ b/docs/harness/agents.md @@ -146,7 +146,7 @@ export const withReview = agents({ | `model` | A model id. `adapter` turns it into an adapter. Without it, a primary agent keeps the harness model, and a subagent uses the model of the main turn. | | `system` | Text for the system prompt of each turn of this agent. | | `tools` | The tools that this agent can call, by name. A `*` matches any text, for example `todo_*`. Without it, the agent gets every tool. | -| `permissions` | [Permission rules](./permissions#write-rules) that count while this agent is the primary agent. | +| `permissions` | [Permission rules](./permissions#write-rules) that count while this agent is the primary agent. At that time, they also apply in each subagent run. | | `steps` | The step limit. See [Limit the steps](#limit-the-steps). | | `hidden` | `true` leaves the agent out of the `agent` command and setting. The model can still start it as a subagent. | @@ -217,7 +217,7 @@ A subagent run stops after 50 model calls, or when an answer is cut at the outpu ## Known limits -- The `permissions` of a profile count only while it is the primary agent. When the profile runs as a subagent, its own rules do not apply. Put rules that must hold in every run in `permissions({ rules })`. See [Rules in subagents](./permissions#rules-in-subagents). +- A subagent run uses the `permissions` of the primary agent, not the rules of its own profile. For example, when `build` is the primary agent and starts `review`, the `build` rules apply and the `review` rules do not. Put rules that must hold in every run in `permissions({ rules })`. See [Rules in subagents](./permissions#rules-in-subagents). - A Markdown agent cannot set `permissions`. Put those rules in a profile in code. - On Amazon Bedrock, the model can still call a tool on the last call. The tool does not run, but the turn can end with no text answer. - A subagent that starts before the first turn of the session has no tools. It also needs its own `model`. diff --git a/docs/harness/permissions.md b/docs/harness/permissions.md index b08fb8aeab..bebec6c7c0 100644 --- a/docs/harness/permissions.md +++ b/docs/harness/permissions.md @@ -287,7 +287,7 @@ The same check runs in every agent run of the session: - Each call in a child run gets the same rules and the same mode. This includes the agents that the model calls, the agents that plugins start, and their children. - A question from a child waits in the same session. Answer it the same way. - In `plan` mode, a child cannot change a file. Its call gets the tool error `This tool is not allowed in plan mode.` -- The `permissions` of an [agent profile](./agents) count only while that profile is the primary agent. They do not apply when the profile runs as a subagent. +- The `permissions` of an [agent profile](./agents) count while that profile is the primary agent, in its turns and in each child run. A child run does not use the rules of its own profile. - [Coding agents](./coding-agents#plan-mode) such as Claude Code start read-only in `plan` mode. ## Rules for your own tool diff --git a/packages/ai-harness/src/first-party/agents.ts b/packages/ai-harness/src/first-party/agents.ts index 2b8970c11a..42ca5dca0e 100644 --- a/packages/ai-harness/src/first-party/agents.ts +++ b/packages/ai-harness/src/first-party/agents.ts @@ -46,7 +46,10 @@ export interface AgentProfile { tools?: ReadonlyArray /** * Permission rules that count while this agent is the primary agent. - * `permissions()` reads them. They do not apply in a subagent run. + * `permissions()` reads them at each tool call, in every run of the + * session: the turns of the primary agent and the subagent runs too. A + * subagent run checks the rules of the primary agent at that time, not + * the rules of its own profile. */ permissions?: ReadonlyArray /** @@ -322,8 +325,10 @@ export function agents(options: { }) // A profile's rules count only while it is the primary agent. At other - // times the getter gives a tool name that matches no tool. - // ponytail: subagent runs get no profile rules, that needs the run in PermissionRules + // times the getter gives a tool name that matches no tool. The check + // runs in the lead turn and in each agent run, so a subagent run gets + // the rules of the primary agent. + // ponytail: a subagent run does not get the rules of its own profile, that needs the run in PermissionRules const rules = profiles.flatMap((profile) => (profile.permissions ?? []).map((rule) => PermissionRules.item({ diff --git a/packages/ai-harness/src/media-signing.ts b/packages/ai-harness/src/media-signing.ts new file mode 100644 index 0000000000..259a59367d --- /dev/null +++ b/packages/ai-harness/src/media-signing.ts @@ -0,0 +1,114 @@ +import { base64url } from './utils' + +/** + * How a media signer gets its keys. + * + * - `secret`: one key. It signs and verifies. + * - `keys`: a key list, to rotate keys. The first key signs. Every key + * verifies, so a URL that an old key signed works until it expires. + * + * Each key must be at least {@link MIN_KEY_BYTES} bytes (UTF-8). + */ +export type MediaSignerOptions = + | { secret: string } + | { keys: ReadonlyArray } + +/** The smallest key length in bytes. HMAC SHA-256 wants 32 bytes or more. */ +export const MIN_KEY_BYTES = 32 + +const HMAC = { name: 'HMAC', hash: 'SHA-256' } + +const encoder = new TextEncoder() + +/** Base64url to bytes, or `undefined` for a value that is not base64url. */ +function fromBase64url(value: string) { + try { + const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/')) + return Uint8Array.from(binary, (char) => char.charCodeAt(0)) + } catch { + return undefined + } +} + +/** The keys of `options`, first the key that signs. */ +function keysOf(options: MediaSignerOptions) { + return 'secret' in options ? [options.secret] : options.keys +} + +/** `bytes` as an HMAC SHA-256 key that signs and verifies. */ +function importHmacKey(bytes: Uint8Array) { + return crypto.subtle.importKey('raw', bytes, HMAC, false, ['sign', 'verify']) +} + +/** + * Make a signer for media URLs. It signs with HMAC SHA-256 through Web Crypto + * (`crypto.subtle`), so it runs on Node, Deno, Bun, and edge runtimes. + * + * `payload` is the text that the URL must keep unchanged, for example the + * harness name, tenant, thread, media id, user, and expiry, one per line. A + * signature is base64url without padding. + * + * Throws an `Error` when there is no key, or when a key is shorter than + * {@link MIN_KEY_BYTES} bytes (UTF-8). + * + * @example + * ```ts + * const signer = createMediaSigner({ keys: [newSecret, oldSecret] }) + * const sig = await signer.sign(payload) + * const isValid = await signer.verify(payload, sig) + * ``` + */ +export function createMediaSigner(options: MediaSignerOptions) { + const encoded = keysOf(options).map((secret) => encoder.encode(secret)) + const [signingBytes, ...otherBytes] = encoded + if (signingBytes === undefined) { + throw new Error('Media signing needs at least one key.') + } + for (const [index, bytes] of encoded.entries()) { + if (bytes.byteLength < MIN_KEY_BYTES) { + throw new Error( + `Media signing key ${index} is ${bytes.byteLength} bytes. Use a key of at least ${MIN_KEY_BYTES} bytes (UTF-8).`, + ) + } + } + // Import each key one time, on first use. + let imported: + | { signing: Promise; all: Promise> } + | undefined + const cryptoKeys = () => { + if (imported) return imported + const signing = importHmacKey(signingBytes) + imported = { + signing, + all: Promise.all([signing, ...otherBytes.map(importHmacKey)]), + } + return imported + } + + return { + /** The base64url signature of `payload`, made with the first key. */ + async sign(payload: string) { + const signature = await crypto.subtle.sign( + 'HMAC', + await cryptoKeys().signing, + encoder.encode(payload), + ) + return base64url(new Uint8Array(signature)) + }, + /** + * True when one of the keys signed `payload` as `signature`. Each key + * checks in constant time (`crypto.subtle.verify`). Every key checks, so + * the time does not tell which key matched. + */ + async verify(payload: string, signature: string) { + const bytes = fromBase64url(signature) + if (bytes === undefined) return false + const data = encoder.encode(payload) + const keys = await cryptoKeys().all + const results = await Promise.all( + keys.map((key) => crypto.subtle.verify('HMAC', key, bytes, data)), + ) + return results.includes(true) + }, + } +} diff --git a/packages/ai-harness/src/operation.ts b/packages/ai-harness/src/operation.ts index 634505f474..0b762ff30d 100644 --- a/packages/ai-harness/src/operation.ts +++ b/packages/ai-harness/src/operation.ts @@ -105,7 +105,9 @@ export class OperationImpl implements Operation { return TERMINAL.has(this.current) } + /** Set the status. A settled operation keeps its final status. */ setStatus(status: OperationStatus): void { + if (this.isSettled()) return this.current = status } @@ -118,14 +120,24 @@ export class OperationImpl implements Operation { this.settleReceipt(receipt) } + /** + * Settle with `result`. The first settle wins: when the operation settled + * already, this call does nothing. + */ finish(status: 'completed' | 'interrupted', result: TResult): void { + if (this.isSettled()) return this.current = status this.resolveResult(result) // A reader of `events()` waits for the next event: it ends now instead. this.feed.wake() } + /** + * Settle with `error`. The first settle wins: when the operation settled + * already, this call does nothing. + */ fail(status: 'failed' | 'cancelled', error: unknown): void { + if (this.isSettled()) return this.current = status this.rejectResult(error) this.feed.wake() diff --git a/packages/ai-harness/tests/media-signing.test.ts b/packages/ai-harness/tests/media-signing.test.ts new file mode 100644 index 0000000000..46c819c642 --- /dev/null +++ b/packages/ai-harness/tests/media-signing.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { createMediaSigner } from '../src/media-signing' + +/** Two keys of 32 bytes or more. */ +const OLD_KEY = 'old-key-0123456789-0123456789-0123' +const NEW_KEY = 'new-key-0123456789-0123456789-0123' + +/** A payload in the form of a signed media URL: one field per line. */ +const PAYLOAD = [ + 'test/harness', + 'tenant-1', + 'thread-1', + 'media-1', + 'user-1', + '1900000000000', +].join('\n') + +describe('createMediaSigner', () => { + it('verifies a signature that it made', async () => { + const signer = createMediaSigner({ secret: NEW_KEY }) + const signature = await signer.sign(PAYLOAD) + + // Base64url of a 32-byte HMAC SHA-256, without padding. + expect(signature).toMatch(/^[A-Za-z0-9_-]{43}$/) + expect(await signer.verify(PAYLOAD, signature)).toBe(true) + }) + + it('refuses a payload that changed after it was signed', async () => { + const signer = createMediaSigner({ secret: NEW_KEY }) + const signature = await signer.sign(PAYLOAD) + const tampered = PAYLOAD.replace('user-1', 'user-2') + + expect(await signer.verify(tampered, signature)).toBe(false) + }) + + it('refuses a signature that is not base64url', async () => { + const signer = createMediaSigner({ secret: NEW_KEY }) + + expect(await signer.verify(PAYLOAD, '%%%')).toBe(false) + }) + + it('refuses a signature from a key that it does not have', async () => { + const other = createMediaSigner({ secret: OLD_KEY }) + const signer = createMediaSigner({ secret: NEW_KEY }) + + expect(await signer.verify(PAYLOAD, await other.sign(PAYLOAD))).toBe(false) + }) + + it('signs with the first key and verifies with every key', async () => { + const before = createMediaSigner({ secret: OLD_KEY }) + const after = createMediaSigner({ keys: [NEW_KEY, OLD_KEY] }) + const newOnly = createMediaSigner({ secret: NEW_KEY }) + const oldSignature = await before.sign(PAYLOAD) + const newSignature = await after.sign(PAYLOAD) + + expect(await after.verify(PAYLOAD, oldSignature)).toBe(true) + expect(await newOnly.verify(PAYLOAD, newSignature)).toBe(true) + expect(await before.verify(PAYLOAD, newSignature)).toBe(false) + }) + + it('throws for a key shorter than 32 bytes', () => { + expect(() => createMediaSigner({ secret: 'short-secret' })).toThrow( + 'Media signing key 0 is 12 bytes. Use a key of at least 32 bytes (UTF-8).', + ) + }) + + it('counts key length in UTF-8 bytes, not in characters', () => { + // 16 characters, 32 bytes: each character is 2 bytes in UTF-8. + const twoByteKey = 'é'.repeat(16) + + expect(() => createMediaSigner({ secret: twoByteKey })).not.toThrow() + expect(() => createMediaSigner({ secret: 'é'.repeat(15) })).toThrow( + 'Media signing key 0 is 30 bytes.', + ) + }) + + it('throws for an empty key list', () => { + expect(() => createMediaSigner({ keys: [] })).toThrow( + 'Media signing needs at least one key.', + ) + }) +}) diff --git a/packages/ai-harness/tests/operation-settled.test.ts b/packages/ai-harness/tests/operation-settled.test.ts new file mode 100644 index 0000000000..ede46cce73 --- /dev/null +++ b/packages/ai-harness/tests/operation-settled.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { SessionFeed } from '../src/feed' +import { OperationImpl } from '../src/operation' +import { tapFeed } from '../src/session/helpers' + +/** Room for every event of a test. */ +const EVENT_BYTES = 1_000_000 + +/** + * A new operation on its own feed. Its cancel fails it as `cancelled`, the + * same as a session does for an operation that did not start. + */ +function createOperation() { + const feed = tapFeed( + new SessionFeed({ eventBytes: EVENT_BYTES }), + () => {}, + new Set(), + ) + return new OperationImpl('chat', feed, (operation) => { + operation.fail('cancelled', new Error('Cancelled.')) + return Promise.resolve({ inputId: 'input-1', status: 'accepted' }) + }) +} + +describe('a settled operation', () => { + it('stays completed when fail comes after finish', async () => { + const operation = createOperation() + operation.finish('completed', 'first answer') + operation.fail('failed', new Error('Too late.')) + + expect(operation.status()).toBe('completed') + await expect(operation).resolves.toBe('first answer') + }) + + it('stays failed when finish comes after fail', async () => { + const operation = createOperation() + operation.fail('failed', new Error('Model error.')) + operation.finish('completed', 'too late') + + expect(operation.status()).toBe('failed') + await expect(operation).rejects.toThrow('Model error.') + }) + + it('stays cancelled when finish comes after cancel', async () => { + const operation = createOperation() + await operation.cancel() + operation.finish('completed', 'too late') + + expect(operation.status()).toBe('cancelled') + await expect(operation).rejects.toThrow('Cancelled.') + }) + + it('keeps the first result when finish comes twice', async () => { + const operation = createOperation() + operation.finish('interrupted', 'first answer') + operation.finish('completed', 'second answer') + + expect(operation.status()).toBe('interrupted') + await expect(operation).resolves.toBe('first answer') + }) + + it('keeps its final status when setStatus comes after it settled', async () => { + const operation = createOperation() + operation.finish('completed', 'answer') + operation.setStatus('running') + + expect(operation.status()).toBe('completed') + expect(operation.isSettled()).toBe(true) + await expect(operation).resolves.toBe('answer') + }) +}) From 3868e4fe4a59e001d15eb2f950729e52e488b42e Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sat, 17 Oct 2026 18:27:27 +0200 Subject: [PATCH 02/15] fix(ai-harness): every plugin contribution has an owner, ctx.on cleans up, inline media goes to the media store, and capabilities report the socket only when mounted --- docs/config.json | 2 +- docs/harness/media.md | 68 +++++ packages/ai-harness/src/http.ts | 7 +- packages/ai-harness/src/inline-media.ts | 213 ++++++++++++++++ packages/ai-harness/src/media.ts | 8 + packages/ai-harness/src/plugins.ts | 233 +++++++++++++++--- packages/ai-harness/src/protocol.ts | 143 +++++++++-- .../ai-harness/tests/inline-media.test.ts | 210 ++++++++++++++++ .../ai-harness/tests/plugin-owners.test.ts | 174 +++++++++++++ packages/ai-harness/tests/protocol.test.ts | 44 ++++ 10 files changed, 1041 insertions(+), 61 deletions(-) create mode 100644 packages/ai-harness/src/inline-media.ts create mode 100644 packages/ai-harness/tests/inline-media.test.ts create mode 100644 packages/ai-harness/tests/plugin-owners.test.ts diff --git a/docs/config.json b/docs/config.json index bea2790407..de84fc8666 100644 --- a/docs/config.json +++ b/docs/config.json @@ -933,7 +933,7 @@ "label": "Send and show media", "to": "harness/media", "addedAt": "2026-10-13", - "updatedAt": "2026-10-16" + "updatedAt": "2026-10-17" }, { "label": "Connect clients", diff --git a/docs/harness/media.md b/docs/harness/media.md index 09018f0106..37cb69eb76 100644 --- a/docs/harness/media.md +++ b/docs/harness/media.md @@ -169,6 +169,74 @@ gpt-6.1-sol cannot read audio files. Add media.transcribe, or send a text summar In a child agent, `ctx.chat` gets the bytes of the files too. There, only `accepts` limits the kinds, because the harness does not know the adapter of the child. +## Files inside a client message + +A browser can put a file in the message itself, as base64 bytes or as a URL. Base64 bytes make the transcript, the log, and each model call big. A URL can point to a server that the model provider then fetches. So the harness checks each part of an input from a client before the turn starts. + +The browser sends the bytes in the message: + +```ts group=harness-media-client +import { createHarnessClient } from '@tanstack/ai-harness/client' +import type { assistant } from './harness' + +const client = createHarnessClient({ + url: '/api/harness', + threadId: 'user-1-thread', +}) + +async function toBase64(file: File) { + const bytes = new Uint8Array(await file.arrayBuffer()) + return btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')) +} + +export async function askAbout(file: File) { + const receipt = await client.send([ + { type: 'text', content: 'What is on this receipt?' }, + { + type: 'image', + source: { type: 'data', value: await toBase64(file), mimeType: file.type }, + }, + ]) + if (receipt.status === 'rejected') console.log(receipt.reason) +} +``` + +On the server, the harness does this for each part: + +- Base64 bytes (a `data` source, or a base64 `data:` URL) go to the media store. The message keeps a `harness-media:` reference, the same as a file from `putMedia`. `maxBytes`, `kinds`, and the model check apply. +- An `http:` or `https:` URL is refused. Set `externalUrls` to allow it. +- A URL with another scheme, for example `file:`, is always refused. + +Two options control this: + +```ts group=harness-media-inline +import { defineHarness } from '@tanstack/ai-harness' +import { openaiText } from '@tanstack/ai-openai' + +export const assistant = defineHarness({ + name: 'acme/assistant', + adapter: openaiText('gpt-6.1-sol'), + media: { + externalUrls: (url) => url.hostname === 'cdn.acme.com', + maxBodyBytes: 8 * 1024 * 1024, + }, +}) +``` + +- `externalUrls`: `true` allows every `http:` and `https:` URL. A function decides for each URL. Default `false`. +- `maxBodyBytes`: the largest request body or socket frame, in bytes. Default 32 MiB. + +If the harness refuses a part, no turn starts. The receipt is `rejected`, and its `reason` tells you why: + +| `reason` | Why | +| ----------------------- | ----------------------------------------------------- | +| `media_too_large` | The bytes are bigger than `maxBytes`. | +| `media_not_allowed` | The kind is not in `kinds`, or the type has no kind. | +| `media_invalid` | The bytes are not base64. | +| `media_url_not_allowed` | `externalUrls` refuses the URL, or the scheme is bad. | + +Server code that calls `session.send` directly is not checked. The checks are for inputs from a client. + ## Keep media across restarts The host keeps media in three stores of its persistence: diff --git a/packages/ai-harness/src/http.ts b/packages/ai-harness/src/http.ts index b0fbdb5e4e..ca9b6428e5 100644 --- a/packages/ai-harness/src/http.ts +++ b/packages/ai-harness/src/http.ts @@ -76,6 +76,11 @@ export interface HarnessHandlerOptions

{ */ secret: string } + /** + * Set `true` when the app also serves `handleHarnessSocket`, so the + * capabilities say that clients can use a WebSocket. Default `false`. + */ + socket?: boolean } /** How long a signed media URL works. */ @@ -756,7 +761,7 @@ export function createHarnessHandler

( const user = principal ? { principal } : {} if (request.method === 'GET' && route === 'capabilities') { - return json(capabilitiesOf(harness)) + return json(capabilitiesOf(harness, { socket: options.socket === true })) } if (request.method === 'POST' && route === 'run') { diff --git a/packages/ai-harness/src/inline-media.ts b/packages/ai-harness/src/inline-media.ts new file mode 100644 index 0000000000..23ae14a2f9 --- /dev/null +++ b/packages/ai-harness/src/inline-media.ts @@ -0,0 +1,213 @@ +// The checks on media that a client sends inside an input. Inline bytes go +// to the media store, so the transcript, the log, and every viewer get a +// small `harness-media:` reference. External URLs are refused unless the +// harness allows them. +import { MEDIA_URL_PREFIX, kindOf, mediaPart } from './media-ref' +import { MediaError } from './media' +import type { ContentPart } from '@tanstack/ai' +import type { MediaOptions } from './media' +import type { MediaRecord, MediaUpload } from './types' + +/** The default of `media.maxBodyBytes`: 32 MiB. */ +export const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024 + +/** + * Why a client content part was refused. It is the `reason` of the receipt. + * + * - `media_too_large`: the bytes are over `media.maxBytes`. + * - `media_not_allowed`: a MIME type with no media kind, or a kind not in + * `media.kinds`. + * - `media_invalid`: the data is not base64. + * - `media_url_not_allowed`: a URL that `media.externalUrls` refuses, or a + * URL scheme other than `http:`, `https:`, `data:`, and `harness-media:`. + */ +export type InlineMediaRefusal = + | 'media_too_large' + | 'media_not_allowed' + | 'media_invalid' + | 'media_url_not_allowed' + +/** What `storeInlineMedia` needs: the media options and a store. */ +export interface InlineMediaContext { + options: MediaOptions | undefined + /** Store the bytes, as `session.putMedia` does. */ + putMedia: (upload: MediaUpload) => Promise +} + +type MediaContentPart = Exclude + +const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/ +// `data:[;params];base64,`. Only base64 data URLs carry bytes. +const DATA_URL = /^data:([^,]*?);base64,(.*)$/s + +/** A refused part. `reason` goes to the receipt. */ +function refuse(reason: InlineMediaRefusal) { + return { refused: reason } +} + +/** The bytes of base64 `value`, or `undefined` when it is not base64. */ +function fromBase64(value: string) { + const clean = value.replace(/\s/g, '') + if (clean.length % 4 === 1 || !BASE64.test(clean)) return undefined + const NodeBuffer = globalThis.Buffer + if (typeof NodeBuffer === 'function') { + return new Uint8Array(NodeBuffer.from(clean, 'base64')) + } + return Uint8Array.from(atob(clean), (char) => char.charCodeAt(0)) +} + +/** The number of bytes that base64 `value` holds, without decoding it. */ +function base64Size(value: string) { + const clean = value.replace(/\s/g, '') + const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0 + return Math.floor((clean.length * 3) / 4) - padding +} + +/** True when `media.externalUrls` lets a part point to `url`. */ +function isUrlAllowed(url: URL, options: MediaOptions | undefined) { + const isWeb = url.protocol === 'http:' || url.protocol === 'https:' + if (!isWeb) return false + const allow = options?.externalUrls ?? false + return typeof allow === 'function' ? allow(url) : allow +} + +/** `part` with its bytes in the store, as a `harness-media:` reference. */ +async function storePart( + part: MediaContentPart, + data: { value: string; mimeType: string }, + context: InlineMediaContext, +) { + const { options } = context + const kind = kindOf(data.mimeType) + const isKindAllowed = + kind !== undefined && (options?.kinds ?? [kind]).includes(kind) + if (!isKindAllowed) return refuse('media_not_allowed') + const isTooLarge = + options?.maxBytes !== undefined && base64Size(data.value) > options.maxBytes + if (isTooLarge) return refuse('media_too_large') + const bytes = fromBase64(data.value) + if (bytes === undefined) return refuse('media_invalid') + try { + const record = await context.putMedia({ + body: bytes, + mimeType: data.mimeType, + name: kind, + }) + // Other fields, like provider metadata, stay on the part. + const stored: ContentPart = { ...part, ...mediaPart(record) } + return stored + } catch (error) { + if (!(error instanceof MediaError)) throw error + return refuse( + error.status === 413 ? 'media_too_large' : 'media_not_allowed', + ) + } +} + +/** One part, checked and stored, or the reason it was refused. */ +async function checkPart(part: ContentPart, context: InlineMediaContext) { + if (part.type === 'text') return part + const { source } = part + if (source.type === 'data') return storePart(part, source, context) + if (source.type !== 'url') return part + if (source.value.startsWith(MEDIA_URL_PREFIX)) return part + const dataUrl = DATA_URL.exec(source.value) + if (dataUrl) { + const [, mimeType = '', value = ''] = dataUrl + return storePart(part, { value, mimeType }, context) + } + const url = URL.canParse(source.value) ? new URL(source.value) : undefined + if (url === undefined || !isUrlAllowed(url, context.options)) { + return refuse('media_url_not_allowed') + } + return part +} + +/** + * Check the content parts that a client sent, at the trust boundary. Inline + * bytes (a `data` source, or a base64 `data:` URL) go to the media store + * with `putMedia`, and the part becomes a `harness-media:` reference. So the + * bytes are checked against `media.kinds` and `media.maxBytes`, and the model + * check of `harness-media:` parts runs on them. An `http:` or `https:` URL + * stays only when `media.externalUrls` allows it. Other URL schemes are + * refused. Text, `harness-media:` parts, and provider file handles stay as + * they are. + * + * Resolves to `{ parts }`, or to `{ refused }` with the reason of the first + * part that was refused. Parts stored before a refusal stay in the store. + * + * @example + * ```ts + * const checked = await storeInlineMedia(parts, { + * options: harness.media, + * putMedia: (upload) => session.putMedia(upload), + * }) + * if ('refused' in checked) return reject(checked.refused) + * session.send(checked.parts) + * ``` + */ +export async function storeInlineMedia( + parts: ReadonlyArray, + context: InlineMediaContext, +) { + const checked: Array = [] + for (const part of parts) { + const result = await checkPart(part, context) + if ('refused' in result) return result + checked.push(result) + } + return { parts: checked } +} + +/** + * The largest request body or socket frame, in bytes, that a transport + * reads: `media.maxBodyBytes`, or 32 MiB. + * + * @example + * ```ts + * if (frame.length > maxBodyBytesOf(harness.media)) socket.close(1009) + * ``` + */ +export function maxBodyBytesOf(options: MediaOptions | undefined) { + return options?.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES +} + +/** + * The body of `request` as text, read up to `media.maxBodyBytes`. Throws a + * `MediaError` with 413 when the `Content-Length` or the bytes read are over + * the limit. It stops reading at the limit, so a large body is not buffered. + * + * @example + * ```ts + * const body: unknown = JSON.parse(await readBodyText(request, harness.media)) + * ``` + */ +export async function readBodyText( + request: Request, + options: MediaOptions | undefined, +) { + const limit = maxBodyBytesOf(options) + const tooLarge = () => + new MediaError(413, `The body is bigger than the limit of ${limit} bytes.`) + const declared = Number(request.headers.get('content-length') ?? 0) + if (declared > limit) throw tooLarge() + if (request.body === null) return '' + const reader = request.body.getReader() + const chunks: Array = [] + let size = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + size += value.byteLength + if (size > limit) { + await reader.cancel() + throw tooLarge() + } + chunks.push(value) + } + const decoder = new TextDecoder() + return ( + chunks.map((chunk) => decoder.decode(chunk, { stream: true })).join('') + + decoder.decode() + ) +} diff --git a/packages/ai-harness/src/media.ts b/packages/ai-harness/src/media.ts index 620897f39e..bbe7dc20c6 100644 --- a/packages/ai-harness/src/media.ts +++ b/packages/ai-harness/src/media.ts @@ -39,6 +39,14 @@ export interface MediaOptions { accepts?: ReadonlyArray /** Turns audio into text first when the model cannot read audio. */ transcribe?: AnyTranscriptionAdapter + /** + * Whether a client input can send a content part with an `http:` or + * `https:` URL. The model provider can fetch that URL. Default `false`: the + * input is refused. A function decides for each URL. + */ + externalUrls?: boolean | ((url: URL) => boolean) + /** The largest request body or socket frame, in bytes. Default 32 MiB. */ + maxBodyBytes?: number } /** diff --git a/packages/ai-harness/src/plugins.ts b/packages/ai-harness/src/plugins.ts index 75139b7693..0498c70527 100644 --- a/packages/ai-harness/src/plugins.ts +++ b/packages/ai-harness/src/plugins.ts @@ -519,6 +519,12 @@ interface Owned { owner: string } +/** Agents, with the owner of each agent at the same index. */ +interface AgentList { + items: Array + owners: Array +} + /** Everything a set of mounted plugins contributes, plus its cleanup. */ export interface MountedPlugins { tools: Array @@ -556,6 +562,16 @@ export interface MountedPlugins { middleware: Array /** The owner of each item of `adapters`, at the same index. */ adapters: Array + /** The owner of each item of `agentMiddleware`, at the same index. */ + agentMiddleware: Array + /** The owner of each item of `generationMiddleware`, at the same index. */ + generationMiddleware: Array + /** The owner of each item of `agents`, at the same index. */ + agents: Array + /** The owner of each item of `subagents`, at the same index. */ + subagents: Array + /** The plugin that provided each capability of `values`. */ + capabilities: Array<{ name: string; owner: string }> } middleware: Array /** Chat middleware for every agent run. See `PluginContributions`. */ @@ -570,6 +586,15 @@ export interface MountedPlugins { values: CapabilityValues /** Dispose every plugin scope, newest first. Safe to call twice. */ dispose: () => Promise + /** + * A copy without everything that the plugins in `names` contributed: tools, + * prompts, every kind of middleware, agents, subagents, commands, config, + * adapter picks, discoverers, preparers, extension items, and capabilities. + * The copy is a snapshot: a later `ctx.agents.set` or `ctx.commands.set` + * changes this set only. It shares `dispose` with this set. It throws when + * a kept plugin requires a capability that a removed plugin provides. + */ + withoutOwners: (names: ReadonlyArray) => MountedPlugins } export interface MountEnvironment { @@ -752,6 +777,124 @@ export class CapabilityValues { all(): Array { return [...(this.parent?.all() ?? []), ...this.handles] } + + /** A copy without the `removed` handles. The parent stays the same. */ + without(removed: ReadonlySet) { + const copy = new CapabilityValues(this.parent) + for (const handle of this.handles) { + if (!removed.has(handle)) copy.provide(handle, this.get(handle)) + } + return copy + } +} + +/** + * Chat middleware that provides every capability of `values` to each chat + * run, so any chat middleware can read it with `getX(ctx)`. + */ +function capabilityBridgeOf(values: CapabilityValues) { + const handles = values.handles + if (handles.length === 0) return undefined + const bridge: AnyChatMiddleware = { + name: 'harness:plugin-capabilities', + provides: handles, + setup(ctx) { + for (const handle of handles) handle[1](ctx, values.get(handle)) + }, + } + return bridge +} + +/** What `withoutOwners` needs to know about a mount. */ +interface MountRecord { + plugins: ReadonlyArray + /** The plugin that provided each capability. */ + capabilityOwners: ReadonlyMap +} + +/** `mounted` without what the plugins in `names` contributed. */ +function withoutOwnersOf( + mounted: MountedPlugins, + record: MountRecord, + names: ReadonlyArray, +) { + const removed = new Set(names) + const isKept = (owner: string | undefined) => + owner === undefined || !removed.has(owner) + /** The items of `items` whose owner, at the same index, is kept. */ + const keep = (items: ReadonlyArray, owners: ReadonlyArray) => + items.filter((_item, index) => isKept(owners[index])) + const plugins = record.plugins.filter((plugin) => isKept(plugin.name)) + for (const plugin of plugins) { + for (const handle of plugin.requires ?? []) { + const owner = record.capabilityOwners.get(handle) + if (isKept(owner)) continue + throw new Error( + `Cannot remove plugin ${owner}: plugin ${plugin.name} requires its capability "${handle.capabilityName}".`, + ) + } + } + const removedHandles = new Set() + for (const [handle, owner] of record.capabilityOwners) { + if (!isKept(owner)) removedHandles.add(handle) + } + const values = mounted.values.without(removedHandles) + const { owners } = mounted + const keptTools = new Set( + owners.tools + .filter((entry) => isKept(entry.owner)) + .map((entry) => entry.name), + ) + const extensions = new Map>() + for (const [point, items] of mounted.extensions) { + extensions.set( + point, + items.filter((item) => isKept(item.owner)), + ) + } + const next: MountedPlugins = { + tools: mounted.tools.filter((tool) => keptTools.has(tool.name)), + prompts: keep( + mounted.prompts, + owners.prompts.map((entry) => entry.owner), + ), + middleware: keep(mounted.middleware, owners.middleware), + agentMiddleware: keep(mounted.agentMiddleware, owners.agentMiddleware), + generationMiddleware: keep( + mounted.generationMiddleware, + owners.generationMiddleware, + ), + commands: new Map( + [...mounted.commands].filter(([, entry]) => isKept(entry.owner)), + ), + config: new Map( + [...mounted.config].filter(([, entry]) => isKept(entry.owner)), + ), + extensions, + adapters: keep(mounted.adapters, owners.adapters), + discoverers: mounted.discoverers.filter((item) => isKept(item.owner)), + preparers: mounted.preparers.filter((item) => isKept(item.owner)), + agents: keep(mounted.agents, owners.agents), + subagents: keep(mounted.subagents, owners.subagents), + owners: { + plugins: owners.plugins.filter((plugin) => isKept(plugin.name)), + tools: owners.tools.filter((entry) => isKept(entry.owner)), + prompts: owners.prompts.filter((entry) => isKept(entry.owner)), + middleware: owners.middleware.filter(isKept), + adapters: owners.adapters.filter(isKept), + agentMiddleware: owners.agentMiddleware.filter(isKept), + generationMiddleware: owners.generationMiddleware.filter(isKept), + agents: owners.agents.filter(isKept), + subagents: owners.subagents.filter(isKept), + capabilities: owners.capabilities.filter((entry) => isKept(entry.owner)), + }, + capabilityBridge: capabilityBridgeOf(values), + values, + dispose: mounted.dispose, + withoutOwners: (more) => + withoutOwnersOf(next, { ...record, plugins }, more), + } + return next } function checkCapabilityOrder( @@ -852,7 +995,10 @@ export async function mountPlugins( const middlewareOwners: Array = [] const adapterOwners: Array = [] const agentMiddleware: Array = [] + const agentMiddlewareOwners: Array = [] const generationMiddleware: Array = [] + const generationMiddlewareOwners: Array = [] + const capabilityOwners = new Map() const commands = new Map() const config = new Map() const extensions = new Map>() @@ -862,12 +1008,15 @@ export async function mountPlugins( const adapters: MountedPlugins['adapters'] = [] const discoverers: MountedPlugins['discoverers'] = [] const preparers: MountedPlugins['preparers'] = [] - const agents: Array = [] - const subagents: Array = [] + // Each list keeps the owner of each agent at the same index. + const roots: AgentList = { items: [], owners: [] } + const subs: AgentList = { items: [], owners: [] } const services = env.services ?? NO_SERVICES - const remove = (name: string, list: Array) => { - const at = list.findIndex((item) => item.name === name) - if (at >= 0) list.splice(at, 1) + const remove = (name: string, list: AgentList) => { + const at = list.items.findIndex((item) => item.name === name) + if (at < 0) return + list.items.splice(at, 1) + list.owners.splice(at, 1) } // `ctx.commands.ready`: resolves after the last plugin is set up. A failed // mount leaves it pending, so no plugin acts on a session that never opens. @@ -913,6 +1062,7 @@ export async function mountPlugins( } values.provide(handle, value) provided.add(handle) + capabilityOwners.set(handle, plugin.name) }, agents: { list: () => env.registry.list(), @@ -923,17 +1073,19 @@ export async function mountPlugins( group: services.agents.group, set: (agent, options) => { env.registry.set(agent, plugin.name) - const list = options?.subagent ? subagents : agents - remove(agent.name, options?.subagent ? agents : subagents) - const at = list.findIndex((item) => item.name === agent.name) - list.splice(at < 0 ? list.length : at, 1, agent) + const list = options?.subagent ? subs : roots + remove(agent.name, options?.subagent ? roots : subs) + const at = list.items.findIndex((item) => item.name === agent.name) + const index = at < 0 ? list.items.length : at + list.items.splice(index, 1, agent) + list.owners.splice(index, 1, plugin.name) }, delete: (name) => { env.registry.delete(name, plugin.name) // Still there: another owner has it. if (env.registry.get(name)) return - remove(name, agents) - remove(name, subagents) + remove(name, roots) + remove(name, subs) }, }, collect: (point: ExtensionPoint): ReadonlyArray => { @@ -957,11 +1109,17 @@ export async function mountPlugins( }, emit: (event, value) => services.emit(plugin.name, event.name, value), on: (event, handler) => { - const off = services.on(event.name, (value) => + const stop = services.on(event.name, (value) => handler(value as never), ) // The listener goes away with the plugin, for example on a reload. - scope.signal.addEventListener('abort', off) + // `off` also removes this abort listener, so many `on` and `off` + // calls do not add up listeners on the plugin signal. + const off = () => { + scope.signal.removeEventListener('abort', off) + stop() + } + scope.signal.addEventListener('abort', off, { once: true }) return off }, state: (initial) => services.state(plugin.name, initial), @@ -1037,8 +1195,14 @@ export async function mountPlugins( middleware.push(item) middlewareOwners.push(plugin.name) } - agentMiddleware.push(...(contributions.agentMiddleware ?? [])) - generationMiddleware.push(...(contributions.generationMiddleware ?? [])) + for (const item of contributions.agentMiddleware ?? []) { + agentMiddleware.push(item) + agentMiddlewareOwners.push(plugin.name) + } + for (const item of contributions.generationMiddleware ?? []) { + generationMiddleware.push(item) + generationMiddlewareOwners.push(plugin.name) + } // The session reads commands and config only from its session plugins. const isSessionOnly = Object.keys(contributions.commands ?? {}).length > 0 || @@ -1092,11 +1256,13 @@ export async function mountPlugins( } for (const agent of contributions.agents ?? []) { env.registry.add(agent, plugin.name) - agents.push(agent) + roots.items.push(agent) + roots.owners.push(plugin.name) } for (const agent of contributions.subagents ?? []) { env.registry.add(agent, plugin.name) - subagents.push(agent) + subs.items.push(agent) + subs.owners.push(plugin.name) } } } @@ -1122,23 +1288,10 @@ export async function mountPlugins( throw error } - const bridgeHandles = values.handles - const capabilityBridge: AnyChatMiddleware | undefined = - bridgeHandles.length > 0 - ? { - name: 'harness:plugin-capabilities', - provides: bridgeHandles, - setup(ctx) { - for (const handle of bridgeHandles) { - handle[1](ctx, values.get(handle)) - } - }, - } - : undefined - markReady() let disposed: Promise | undefined - return { + const record: MountRecord = { plugins, capabilityOwners } + const mounted: MountedPlugins = { tools: tools .filter((entry) => entry.owner !== 'the harness') .map((entry) => entry.value), @@ -1152,8 +1305,8 @@ export async function mountPlugins( adapters, discoverers, preparers, - agents, - subagents, + agents: roots.items, + subagents: subs.items, owners: { plugins: plugins.map((plugin) => ({ name: plugin.name, @@ -1175,9 +1328,19 @@ export async function mountPlugins( })), middleware: middlewareOwners, adapters: adapterOwners, + agentMiddleware: agentMiddlewareOwners, + generationMiddleware: generationMiddlewareOwners, + agents: roots.owners, + subagents: subs.owners, + capabilities: [...capabilityOwners].map(([handle, owner]) => ({ + name: handle.capabilityName, + owner, + })), }, - capabilityBridge, + capabilityBridge: capabilityBridgeOf(values), values, dispose: () => (disposed ??= disposeAll(scopes)), + withoutOwners: (names) => withoutOwnersOf(mounted, record, names), } + return mounted } diff --git a/packages/ai-harness/src/protocol.ts b/packages/ai-harness/src/protocol.ts index 15ab562cc1..022e1458ad 100644 --- a/packages/ai-harness/src/protocol.ts +++ b/packages/ai-harness/src/protocol.ts @@ -2,9 +2,11 @@ import { convertSchemaToJsonSchema } from '@tanstack/ai' import { AccessDeniedError } from './access' import { isRecord } from './utils' import { createInputId, errorText } from './session/helpers' -import type { StreamChunk } from '@tanstack/ai' +import { storeInlineMedia } from './inline-media' +import type { ModelMessage, StreamChunk } from '@tanstack/ai' import type { AnyAgent } from './agents' import type { AnyHarness } from './define' +import type { InlineMediaContext } from './inline-media' import type { HarnessSession, SessionSnapshot } from './session/index' import type { Cursor, @@ -12,6 +14,7 @@ import type { Operation, Principal, Receipt, + UserInput, } from './types' /** The session-tier protocol version. Sent in `subscribe` and `hello`. */ @@ -66,6 +69,31 @@ const INPUT_OPS = new Set([ 'agentMessage', ]) +const PART_TYPES = new Set(['text', 'image', 'audio', 'video', 'document']) + +/** + * True for a content part with the fields the harness reads: a string + * `content` for text, else a `source` with a string `type` and `value`, and + * a string `mimeType` for a `data` source. + */ +function isContentPart(value: unknown) { + if (!isRecord(value) || typeof value.type !== 'string') return false + if (!PART_TYPES.has(value.type)) return false + if (value.type === 'text') return typeof value.content === 'string' + const { source } = value + if (!isRecord(source) || typeof source.type !== 'string') return false + if (typeof source.value !== 'string') return false + return source.type !== 'data' || typeof source.mimeType === 'string' +} + +/** True for a string, or an array of content parts. */ +function isMessageContent(value: unknown) { + return ( + typeof value === 'string' || + (Array.isArray(value) && value.every(isContentPart)) + ) +} + /** Check the shape of a client input. Throws with a short reason. */ export function parseHarnessInput(value: unknown): HarnessInput { if ( @@ -80,12 +108,10 @@ export function parseHarnessInput(value: unknown): HarnessInput { value.op === 'steer' || value.op === 'followUp' || value.op === 'agentMessage' - if ( - needsMessage && - typeof value.message !== 'string' && - !Array.isArray(value.message) - ) { - throw new Error(`Invalid input: ${value.op} needs a message.`) + if (needsMessage && !isMessageContent(value.message)) { + throw new Error( + `Invalid input: ${value.op} needs a message: text, or content parts.`, + ) } if (value.op === 'resolve' && !Array.isArray(value.resume)) { throw new Error('Invalid input: resolve needs a resume array.') @@ -166,7 +192,9 @@ export function parseHarnessInput(value: unknown): HarnessInput { // A client sends user messages only. An assistant or tool message from a // client could fake an answer or a tool result in the model context. const isUserMessage = (message: unknown) => - isRecord(message) && message.role === 'user' + isRecord(message) && + message.role === 'user' && + (message.content === null || isMessageContent(message.content)) if ( (value.op === 'prompt' || value.op === 'continue') && value.ephemeral !== undefined && @@ -220,6 +248,12 @@ export function parseControlFrame(data: string): ControlFrame { * its credentials. Rejects with `AccessDeniedError` when the host refuses * the input. * + * Media in a message, or in an `ephemeral` message, is checked first, as + * `storeInlineMedia` does: inline bytes go to the media store and the part + * becomes a `harness-media:` reference. An external URL needs + * `media.externalUrls`. A refused part gives a rejected receipt with the + * reason, for example `media_too_large`. + * * @example * ```ts * const receipt = await applyInput({ harness, session, input, principal }) @@ -253,27 +287,45 @@ export async function applyInput({ status: 'rejected', reason: 'not_exposed', } + const refused = (reason: string) => { + const receipt: Receipt = { + inputId: input.inputId ?? '', + status: 'rejected', + reason, + } + return receipt + } + const media: InlineMediaContext = { + options: harness.media, + putMedia: (upload) => session.putMedia(upload), + } switch (input.op) { - case 'prompt': + case 'prompt': { + const message = await checkedMessage(input.message, media) + if ('refused' in message) return refused(message.refused) + const ephemeral = await checkedEphemeral(input.ephemeral, media) + if ('refused' in ephemeral) return refused(ephemeral.refused) return turnReceipt( - session.send(input.message, { + session.send(message.content, { ...(input.busy ? { busy: input.busy } : {}), - ...(input.ephemeral ? { ephemeral: input.ephemeral } : {}), - ...sent, - }), - ) - case 'continue': - return turnReceipt( - session.continue({ - ...(input.ephemeral ? { ephemeral: input.ephemeral } : {}), + ...ephemeral.options, ...sent, }), ) + } + case 'continue': { + const ephemeral = await checkedEphemeral(input.ephemeral, media) + if ('refused' in ephemeral) return refused(ephemeral.refused) + return turnReceipt(session.continue({ ...ephemeral.options, ...sent })) + } case 'steer': - case 'followUp': + case 'followUp': { + const message = await checkedMessage(input.message, media) + if ('refused' in message) return refused(message.refused) return turnReceipt( - session.send(input.message, { ...sent, mode: input.op }), + session.send(message.content, { ...sent, mode: input.op }), ) + } case 'resolve': return session.resolve({ resume: input.resume, ...id }) case 'cancel': @@ -409,8 +461,10 @@ export async function applyInput({ reason: 'not_exposed', } } + const message = await checkedMessage(input.message, media) + if ('refused' in message) return refused(message.refused) return turnReceipt( - session.send(input.message, { + session.send(message.content, { to: input.operationId, ...id, ...(input.mode ? { mode: input.mode } : {}), @@ -420,6 +474,35 @@ export async function applyInput({ } } +/** `message` with its inline media stored, or why a part was refused. */ +async function checkedMessage(message: UserInput, media: InlineMediaContext) { + if (typeof message === 'string') return { content: message } + const checked = await storeInlineMedia(message, media) + return 'refused' in checked ? checked : { content: checked.parts } +} + +/** + * The `ephemeral` option of a turn, with the inline media of each message + * stored, or why a part was refused. No messages give no option. + */ +async function checkedEphemeral( + messages: ReadonlyArray | undefined, + media: InlineMediaContext, +) { + if (!messages) return { options: {} } + const ephemeral: Array = [] + for (const message of messages) { + if (!Array.isArray(message.content)) { + ephemeral.push(message) + continue + } + const checked = await storeInlineMedia(message.content, media) + if ('refused' in checked) return checked + ephemeral.push({ ...message, content: checked.parts }) + } + return { options: { ephemeral } } +} + /** * The receipt of a turn that a client started. Nobody may await the turn, * so its failure is handled here. A turn that the host refused throws its @@ -459,8 +542,20 @@ export function describeForClient( } } -/** The AG-UI capabilities document of a harness. */ -export function capabilitiesOf(harness: AnyHarness) { +/** + * The AG-UI capabilities document of a harness. Pass `socket: true` when the + * app also serves `handleHarnessSocket`, so `transport.websocket` tells + * clients that they can use a WebSocket. Default `false`. + * + * @example + * ```ts + * const capabilities = capabilitiesOf(assistant, { socket: true }) + * ``` + */ +export function capabilitiesOf( + harness: AnyHarness, + { socket = false }: { socket?: boolean } = {}, +) { const exposed = new Set(harness.expose?.agents ?? []) const subagents: ReadonlyArray = harness.subagents?.agents ?? [] const agents: ReadonlyArray = [ @@ -469,7 +564,7 @@ export function capabilitiesOf(harness: AnyHarness) { ] return { identity: { name: harness.name, type: 'tanstack-ai-harness' }, - transport: { streaming: true, websocket: true }, + transport: { streaming: true, websocket: socket }, tools: { supported: true, items: (harness.tools ?? []).map((tool) => ({ diff --git a/packages/ai-harness/tests/inline-media.test.ts b/packages/ai-harness/tests/inline-media.test.ts new file mode 100644 index 0000000000..eb973b5f95 --- /dev/null +++ b/packages/ai-harness/tests/inline-media.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from 'vitest' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { createHarnessHost, defineHarness } from '../src' +import { readBodyText, storeInlineMedia } from '../src/inline-media' +import { MediaError, createMediaStore } from '../src/media' +import { mediaIdOf } from '../src/media-entry' +import { applyInput } from '../src/server' +import { mockAdapter, text } from './helpers' +import type { ContentPart } from '@tanstack/ai' +import type { MediaOptions } from '../src/media' + +// 'aGVsbG8=' is base64 for "hello", 5 bytes. +const HELLO = 'aGVsbG8=' +const decoder = new TextDecoder() + +const inlineImage = (value = HELLO, mimeType = 'image/png') => { + const part: ContentPart = { + type: 'image', + source: { type: 'data', value, mimeType }, + } + return part +} + +const urlImage = (value: string) => { + const part: ContentPart = { type: 'image', source: { type: 'url', value } } + return part +} + +/** A media store of one thread, and `storeInlineMedia` on it. */ +function storeWith(options?: MediaOptions) { + const store = createMediaStore({ + persistence: memoryPersistence(), + threadId: 't1', + ...(options ? { options } : {}), + }) + const check = (parts: Array) => + storeInlineMedia(parts, { + options, + putMedia: ({ body, mimeType, name }) => + store.put(body, { mimeType, name }), + }) + return { store, check } +} + +/** The text of the bytes that the media part of `checked` points to. */ +async function storedText( + store: ReturnType['store'], + checked: Awaited['check']>>, +) { + if ('refused' in checked) throw new Error(`refused: ${checked.refused}`) + const id = checked.parts.map(mediaIdOf).find((item) => item !== undefined) + if (id === undefined) throw new Error('not a media reference') + return decoder.decode(await store.load(id)) +} + +describe('storeInlineMedia', () => { + it('moves an inline image into the store and sends a reference', async () => { + const { store, check } = storeWith() + + const checked = await check([ + { type: 'text', content: 'What is this?' }, + inlineImage(), + ]) + + expect(checked).toEqual({ + parts: [ + { type: 'text', content: 'What is this?' }, + { + type: 'image', + source: { + type: 'url', + value: expect.stringMatching(/^harness-media:./), + mimeType: 'image/png', + }, + }, + ], + }) + expect(await storedText(store, checked)).toBe('hello') + }) + + it('moves a base64 data URL into the store too', async () => { + const { store, check } = storeWith() + + const checked = await check([urlImage(`data:image/png;base64,${HELLO}`)]) + + expect(await storedText(store, checked)).toBe('hello') + }) + + it('refuses inline bytes over media.maxBytes', async () => { + const { check } = storeWith({ maxBytes: 4 }) + + expect(await check([inlineImage()])).toEqual({ refused: 'media_too_large' }) + }) + + it('refuses a kind that is not in media.kinds', async () => { + const { check } = storeWith({ kinds: ['document'] }) + + expect(await check([inlineImage()])).toEqual({ + refused: 'media_not_allowed', + }) + }) + + it('refuses data that is not base64', async () => { + const { check } = storeWith() + + expect(await check([inlineImage('not base64!')])).toEqual({ + refused: 'media_invalid', + }) + }) + + it('refuses an external URL by default, and keeps it when allowed', async () => { + const cat = urlImage('https://example.com/cat.png') + + expect(await storeWith().check([cat])).toEqual({ + refused: 'media_url_not_allowed', + }) + expect(await storeWith({ externalUrls: true }).check([cat])).toEqual({ + parts: [cat], + }) + const onlyCdn = storeWith({ + externalUrls: (url) => url.hostname === 'cdn.example.com', + }) + expect(await onlyCdn.check([cat])).toEqual({ + refused: 'media_url_not_allowed', + }) + }) + + it('refuses a URL scheme other than http and https, even when URLs are allowed', async () => { + const { check } = storeWith({ externalUrls: true }) + + expect(await check([urlImage('file:///etc/passwd')])).toEqual({ + refused: 'media_url_not_allowed', + }) + }) +}) + +describe('readBodyText', () => { + const post = (body: string) => + new Request('http://x/control', { method: 'POST', body }) + + it('reads a body up to media.maxBodyBytes', async () => { + expect(await readBodyText(post('12345'), { maxBodyBytes: 5 })).toBe('12345') + }) + + it('refuses a body over media.maxBodyBytes with 413', async () => { + const reading = readBodyText(post('123456'), { maxBodyBytes: 5 }) + + await expect(reading).rejects.toBeInstanceOf(MediaError) + await expect(reading).rejects.toMatchObject({ status: 413 }) + }) +}) + +describe('applyInput with media from a client', () => { + async function open(media?: MediaOptions) { + const { adapter, calls } = mockAdapter(() => text('ok')) + const harness = defineHarness({ name: 'test/inline', adapter, media }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open(harness, { threadId: 't1' }) + return { harness, host, session, calls } + } + + it('stores an inline image, keeps a reference in the transcript, and gives the model the bytes', async () => { + const { harness, host, session, calls } = await open() + + const receipt = await applyInput({ + harness, + session, + input: { op: 'prompt', inputId: 'in-1', message: [inlineImage()] }, + }) + await session.settled('in-1') + + expect(receipt.status).toBe('accepted') + const [saved] = await session.transcript() + const id = Array.isArray(saved?.content) + ? mediaIdOf(saved.content[0]) + : undefined + if (id === undefined) throw new Error('no media reference in transcript') + expect(decoder.decode(await session.loadMedia({ id }))).toBe('hello') + expect(calls[0].messages[0].content).toEqual([ + { + type: 'image', + source: { type: 'data', value: HELLO, mimeType: 'image/png' }, + }, + ]) + await host.close() + }) + + it('rejects a prompt with an external URL, and no turn runs', async () => { + const { harness, host, session, calls } = await open() + + const receipt = await applyInput({ + harness, + session, + input: { + op: 'prompt', + inputId: 'in-1', + message: [urlImage('https://example.com/cat.png')], + }, + }) + + expect(receipt).toEqual({ + inputId: 'in-1', + status: 'rejected', + reason: 'media_url_not_allowed', + }) + expect(calls).toEqual([]) + expect(await session.transcript()).toEqual([]) + await host.close() + }) +}) diff --git a/packages/ai-harness/tests/plugin-owners.test.ts b/packages/ai-harness/tests/plugin-owners.test.ts new file mode 100644 index 0000000000..b55c9b77db --- /dev/null +++ b/packages/ai-harness/tests/plugin-owners.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest' +import { createCapability, defineAgent, toolDefinition } from '@tanstack/ai' +import { AgentRegistry } from '../src/agents' +import { defineCommand } from '../src/commands' +import { configOption } from '../src/config' +import { createExtensionPoint, createPluginEvent } from '../src/extensions' +import { definePlugin, mountPlugins } from '../src/plugins' +import type { MountedPlugins, PluginSetupContext } from '../src/plugins' + +const env = () => ({ + threadId: 't1', + registry: new AgentRegistry(), + harnessTools: [], + harnessProvides: [], +}) + +const Rules = createExtensionPoint('test/rules') + +const agentNamed = (name: string) => + defineAgent({ name, description: name, run: async () => name }) + +/** A plugin `id` that contributes one item of every kind, each named `id`. */ +function contributor(id: string) { + const capability = createCapability()(`test/${id}-capability`) + const pick = () => undefined + const plugin = definePlugin({ + name: id, + provides: [capability], + setup: ({ provide }) => { + provide(capability, `${id} value`) + return { + tools: [ + toolDefinition({ name: `${id}_tool`, description: id }).server( + () => id, + ), + ], + prompts: [`${id} prompt`], + middleware: [{ name: `${id}-middleware` }], + agentMiddleware: [{ name: `${id}-agent-middleware` }], + generationMiddleware: [{ name: `${id}-generation-middleware` }], + agents: [agentNamed(`${id}-agent`)], + subagents: [agentNamed(`${id}-subagent`)], + commands: { + [`${id}-command`]: defineCommand({ description: id, run: () => id }), + }, + config: { + [`${id}-level`]: configOption.select({ + options: ['low', 'high'], + default: 'low', + }), + }, + contribute: [Rules.item(`${id} rule`)], + adapter: pick, + discoverTools: () => [], + prepareTools: ({ tools }) => tools, + } + }, + }) + return { plugin, capability, pick } +} + +/** The name or value of every contribution in `mounted`. */ +function contents(mounted: MountedPlugins) { + const names = (items: ReadonlyArray<{ name?: string }>) => + items.map((item) => item.name) + return { + plugins: mounted.owners.plugins.map((plugin) => plugin.name), + tools: names(mounted.tools), + prompts: mounted.prompts, + middleware: names(mounted.middleware), + agentMiddleware: names(mounted.agentMiddleware), + generationMiddleware: names(mounted.generationMiddleware), + agents: names(mounted.agents), + subagents: names(mounted.subagents), + commands: [...mounted.commands.keys()], + config: [...mounted.config.keys()], + rules: (mounted.extensions.get(Rules.name) ?? []).map((item) => item.value), + adapters: mounted.adapters, + discoverers: mounted.discoverers.map((item) => item.owner), + preparers: mounted.preparers.map((item) => item.owner), + capabilities: mounted.capabilityBridge?.provides ?? [], + } +} + +describe('MountedPlugins.withoutOwners', () => { + it('removes every kind of contribution of a plugin and keeps the others', async () => { + const a = contributor('a') + const b = contributor('b') + const mounted = await mountPlugins([a.plugin, b.plugin], env()) + + const kept = mounted.withoutOwners(['a']) + + expect(contents(kept)).toEqual({ + plugins: ['b'], + tools: ['b_tool'], + prompts: ['b prompt'], + middleware: ['b-middleware'], + agentMiddleware: ['b-agent-middleware'], + generationMiddleware: ['b-generation-middleware'], + agents: ['b-agent'], + subagents: ['b-subagent'], + commands: ['b-command'], + config: ['b-level'], + rules: ['b rule'], + adapters: [b.pick], + discoverers: ['b'], + preparers: ['b'], + capabilities: [b.capability], + }) + expect(kept.values.get(a.capability)).toBeUndefined() + expect(kept.values.get(b.capability)).toBe('b value') + // The mounted set itself keeps both plugins. + expect(contents(mounted).agents).toEqual(['a-agent', 'b-agent']) + expect(mounted.values.get(a.capability)).toBe('a value') + }) + + it('removes an agent that a plugin set after the mount', async () => { + let saved: PluginSetupContext | undefined + const late = definePlugin({ + name: 'late', + setup: (ctx) => { + saved = ctx + }, + }) + const mounted = await mountPlugins([late, contributor('b').plugin], env()) + saved?.agents.set(agentNamed('late-subagent'), { subagent: true }) + + expect(contents(mounted).subagents).toEqual(['b-subagent', 'late-subagent']) + expect(contents(mounted.withoutOwners(['late'])).subagents).toEqual([ + 'b-subagent', + ]) + }) + + it('refuses to remove a plugin whose capability a kept plugin requires', async () => { + const a = contributor('a') + const reader = definePlugin({ + name: 'reader', + requires: [a.capability], + }) + const mounted = await mountPlugins([a.plugin, reader], env()) + + expect(() => mounted.withoutOwners(['a'])).toThrow( + 'Cannot remove plugin a: plugin reader requires its capability "test/a-capability".', + ) + expect(contents(mounted.withoutOwners(['a', 'reader'])).plugins).toEqual([]) + }) +}) + +describe('ctx.on', () => { + it('leaves no abort listener on the plugin signal after off()', async () => { + let saved: PluginSetupContext | undefined + const listener = definePlugin({ + name: 'listener', + setup: (ctx) => { + saved = ctx + }, + }) + await mountPlugins([listener], env()) + if (!saved) throw new Error('setup did not run') + const signal = saved.resources.signal + const add = vi.spyOn(signal, 'addEventListener') + const removeListener = vi.spyOn(signal, 'removeEventListener') + const ping = createPluginEvent('test/ping') + + for (let index = 0; index < 3; index++) { + saved.on(ping, () => {})() + } + + const added = add.mock.calls.map((call) => call[1]) + const removed = removeListener.mock.calls.map((call) => call[1]) + expect(added).toHaveLength(3) + expect(added.filter((item) => !removed.includes(item))).toEqual([]) + }) +}) diff --git a/packages/ai-harness/tests/protocol.test.ts b/packages/ai-harness/tests/protocol.test.ts index ae7f843671..f64c8044b4 100644 --- a/packages/ai-harness/tests/protocol.test.ts +++ b/packages/ai-harness/tests/protocol.test.ts @@ -357,6 +357,50 @@ describe('parseHarnessInput', () => { ).toThrow("Invalid input: setDelivery needs 'steer' or 'queue'.") }) + it('refuses a message part without the fields the harness reads', () => { + // An image with no source, then a data source with no MIME type. + for (const part of [ + { type: 'image' }, + { type: 'image', source: { type: 'data', value: 'aGVsbG8=' } }, + ]) { + expect(() => + parseHarnessInput({ op: 'prompt', message: [part] }), + ).toThrow( + 'Invalid input: prompt needs a message: text, or content parts.', + ) + } + expect(() => + parseHarnessInput({ + op: 'continue', + ephemeral: [{ role: 'user', content: [{ type: 'audio' }] }], + }), + ).toThrow('Invalid input: ephemeral must be an array of user messages.') + }) + + it('reports the WebSocket in capabilities only when the app serves it', async () => { + const { host, harness } = setup() + const transportOf = async (socket?: boolean) => { + const handler = createHarnessHandler({ + host, + harness, + ...(socket === undefined ? {} : { socket }), + }) + const response = await handler( + new Request('http://x/api/harness/capabilities', { headers: auth }), + ) + const body: unknown = await response.json() + return body + } + + expect(await transportOf()).toMatchObject({ + transport: { streaming: true, websocket: false }, + }) + expect(await transportOf(true)).toMatchObject({ + transport: { streaming: true, websocket: true }, + }) + await host.close() + }) + it('checks the ephemeral messages of prompt and continue', () => { const refused = 'Invalid input: ephemeral must be an array of user messages.' From 5ce3e64f61e73648d2ce2c8b64f953067231df72 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sat, 17 Oct 2026 18:31:34 +0200 Subject: [PATCH 03/15] docs(ai-harness): say when capabilities report the WebSocket --- docs/harness/connect.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/harness/connect.md b/docs/harness/connect.md index bfd4ff7262..a4b0af0b7a 100644 --- a/docs/harness/connect.md +++ b/docs/harness/connect.md @@ -348,6 +348,7 @@ The client sends `{ type: 'harness.subscribe', threadId }` first. After that, ea - A user that `identify` refuses gets an `unauthorized` error frame, and the socket closes. - A user who cannot read the thread, or who loses access later, gets a `forbidden` error frame, and the socket closes. - On a host with no `access`, pass `openAccess`, the same as for `createHarnessHandler`. +- Pass `socket: true` to `createHarnessHandler` too. Then `GET capabilities` says `transport.websocket: true`, so clients know that they can use the socket. The default is `false`. ## Use it as the model of another chat From 08faf9ab7982a394065ae4365a5cdd896a61ab6c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sat, 17 Oct 2026 18:35:39 +0200 Subject: [PATCH 04/15] feat(ai): onGuardToolCall middleware hook that runs on the final arguments of every tool call --- .changeset/ai-tool-guard.md | 11 + docs/advanced/middleware.md | 33 +++ docs/config.json | 2 +- packages/ai/src/activities/chat/index.ts | 8 + .../src/activities/chat/middleware/compose.ts | 38 ++++ .../src/activities/chat/middleware/index.ts | 1 + .../src/activities/chat/middleware/types.ts | 38 +++- .../src/activities/chat/tools/tool-calls.ts | 57 +++++- packages/ai/src/index.ts | 1 + packages/ai/tests/middleware.test.ts | 192 ++++++++++++++++++ 10 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 .changeset/ai-tool-guard.md diff --git a/.changeset/ai-tool-guard.md b/.changeset/ai-tool-guard.md new file mode 100644 index 0000000000..30087006ea --- /dev/null +++ b/.changeset/ai-tool-guard.md @@ -0,0 +1,11 @@ +--- +'@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/docs/advanced/middleware.md b/docs/advanced/middleware.md index c52d5e90ea..6a843a8a9f 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -734,6 +734,38 @@ The `hookCtx` provides: | `toolName` | `string` | Tool name | | `toolCallId` | `string` | Tool call ID | +### onGuardToolCall + +A permission check in `onBeforeToolCall` can miss calls. An earlier middleware can return a decision first, and then your hook does not run. Put checks that must always run in `onGuardToolCall`: + +```typescript +import { type ChatMiddleware } from "@tanstack/ai"; + +const noDeletes: ChatMiddleware = { + name: "no-deletes", + onGuardToolCall: (ctx, hookCtx) => { + if (hookCtx.toolName === "deleteFile") { + return { type: "skip", result: { error: "Deleting files is not allowed." } }; + } + }, +}; +``` + +When the guard runs: + +- It runs for every tool call, after all `onBeforeToolCall` hooks and after the `inputSchema` check. +- `hookCtx.args` holds the final checked arguments. The tool gets these same arguments. +- The guard of every middleware runs, in array order, until one returns a decision. +- It runs just before a server tool runs, and before a client tool goes to the client. A call that waits for approval does not run it. + +| Decision | Effect | +|----------|--------| +| `void` / `undefined` | Allow the call. The next guard runs. | +| `{ type: 'skip', result }` | Deny the call. The tool does not run, and the model gets `result`, the same as an `onBeforeToolCall` skip. | +| `{ type: 'abort', reason? }` | Abort the entire chat run | + +A guard cannot change the arguments. To change them, use `onBeforeToolCall`. + ### onAfterToolCall Called after each tool execution (or skip). Every middleware runs, in array order. @@ -1025,6 +1057,7 @@ const stream = chat({ | `onStart` | Sequential | All run in order | | `onChunk` | **Piped** — chunks flow through each middleware | If first drops a chunk, later middleware never see it | | `onBeforeToolCall` | **First-win** — first non-void decision wins | Earlier middleware has priority | +| `onGuardToolCall` | Sequential. Every guard runs until one returns a decision | An earlier `onBeforeToolCall` decision never skips a guard | | `onShouldContinue` | **AND** — any explicit `false` stops the loop | Order only affects which middleware runs first when short-circuiting | | `onModelError` | Sequential. If any middleware calls `ctx.retry()` and the call has retries left, the model is called again | All run in order, so each one sees the error | | `onAfterToolCall` | **Piped**: each gets the result of the previous `replaceResult` | The last replacement wins | diff --git a/docs/config.json b/docs/config.json index de84fc8666..e613d4d6b2 100644 --- a/docs/config.json +++ b/docs/config.json @@ -353,7 +353,7 @@ "label": "Middleware", "to": "advanced/middleware", "addedAt": "2026-04-15", - "updatedAt": "2026-10-14" + "updatedAt": "2026-10-17" }, { "label": "Built-in Middleware", diff --git a/packages/ai/src/activities/chat/index.ts b/packages/ai/src/activities/chat/index.ts index b9c427f13b..437147dade 100644 --- a/packages/ai/src/activities/chat/index.ts +++ b/packages/ai/src/activities/chat/index.ts @@ -3990,6 +3990,14 @@ class TextEngine< toolCallId: toolCall.id, }) }, + onGuardToolCall: async (toolCall, tool, args) => + this.middlewareRunner.runOnGuardToolCall(this.middlewareCtx, { + toolCall, + tool, + args, + toolName: toolCall.function.name, + toolCallId: toolCall.id, + }), onAfterToolCall: async (info) => { this.logger.tools(`phase=after name=${info.toolName}`, { name: info.toolName, diff --git a/packages/ai/src/activities/chat/middleware/compose.ts b/packages/ai/src/activities/chat/middleware/compose.ts index 6940b5fbf2..f2f90d1ae1 100644 --- a/packages/ai/src/activities/chat/middleware/compose.ts +++ b/packages/ai/src/activities/chat/middleware/compose.ts @@ -11,6 +11,7 @@ import type { ChatMiddlewareContext, ErrorInfo, FinishInfo, + GuardToolCallDecision, InterruptBoundaryPhase, InterruptResolutionCollection, InterruptToolResume, @@ -539,6 +540,43 @@ export class MiddlewareRunner< return undefined } + /** + * Run onGuardToolCall through middleware in order. Every guard runs until + * one returns a decision, so an earlier `onBeforeToolCall` decision never + * skips a guard. Returns that decision, or undefined to allow the call. + */ + async runOnGuardToolCall( + ctx: ChatMiddlewareContext, + hookCtx: ToolCallHookContext, + ): Promise { + for (const mw of this.middlewares) { + if (mw.onGuardToolCall) { + const skip = shouldSkipInstrumentation(mw) + const start = Date.now() + const decision = await mw.onGuardToolCall(ctx, hookCtx) + const decided = decision !== undefined && decision !== null + if (!skip) { + this.logger.middleware( + `hook=onGuardToolCall middleware=${mw.name ?? 'unnamed'}`, + { middleware: mw.name ?? 'unnamed', hook: 'onGuardToolCall' }, + ) + aiEventClient.emit('middleware:hook:executed', { + ...instrumentCtx(ctx), + middlewareName: mw.name || 'unnamed', + hookName: 'onGuardToolCall', + iteration: ctx.iteration, + duration: Date.now() - start, + hasTransform: decided, + }) + } + if (decided) { + return decision + } + } + } + return undefined + } + /** * Run onAfterToolCall on all middleware in order. * A `replaceResult` decision changes `info.result` for the next middleware. diff --git a/packages/ai/src/activities/chat/middleware/index.ts b/packages/ai/src/activities/chat/middleware/index.ts index f7ffe81cd5..1e06958612 100644 --- a/packages/ai/src/activities/chat/middleware/index.ts +++ b/packages/ai/src/activities/chat/middleware/index.ts @@ -8,6 +8,7 @@ export type { StructuredOutputMiddlewareConfig, ToolCallHookContext, BeforeToolCallDecision, + GuardToolCallDecision, AfterToolCallDecision, AfterToolCallInfo, IterationInfo, diff --git a/packages/ai/src/activities/chat/middleware/types.ts b/packages/ai/src/activities/chat/middleware/types.ts index 101b920a6f..4669881560 100644 --- a/packages/ai/src/activities/chat/middleware/types.ts +++ b/packages/ai/src/activities/chat/middleware/types.ts @@ -432,7 +432,8 @@ export interface StructuredOutputMiddlewareConfig extends Omit< // =========================== /** - * Context provided to tool call hooks (onBeforeToolCall / onAfterToolCall). + * Context provided to tool call hooks (onBeforeToolCall / onGuardToolCall / + * onAfterToolCall). */ export interface ToolCallHookContext { /** The tool call being executed */ @@ -441,7 +442,8 @@ export interface ToolCallHookContext { tool: Tool | undefined /** * Parsed arguments for the tool call, before the `inputSchema` check: no - * schema defaults or transforms yet, and possibly invalid. + * schema defaults or transforms yet, and possibly invalid. In + * `onGuardToolCall`, these are the final checked arguments. */ args: unknown /** Name of the tool */ @@ -465,6 +467,20 @@ export type BeforeToolCallDecision = | { type: 'skip'; result: unknown } | { type: 'abort'; reason?: string } +/** + * Decision returned from onGuardToolCall. A guard cannot change the args. + * - undefined/void: allow the call. The next guard runs. + * - { type: 'skip', result }: deny the call. The tool does not run, and the + * model gets `result`, as for an `onBeforeToolCall` skip. + * - { type: 'abort', reason }: abort the entire chat run + */ +export type GuardToolCallDecision = + | void + | undefined + | null + | { type: 'skip'; result: unknown } + | { type: 'abort'; reason?: string } + /** * Outcome information provided to onAfterToolCall. */ @@ -858,6 +874,24 @@ export interface ChatMiddleware< hookCtx: ToolCallHookContext, ) => BeforeToolCallDecision | Promise + /** + * The guard tier. Called for every tool call, just before a server tool + * runs and before a client tool is sent to the client. Not called while + * the call waits for approval. + * + * It runs after all `onBeforeToolCall` hooks and after the `inputSchema` + * check, so `hookCtx.args` holds the final checked args that the tool gets. + * It also runs for calls that `onBeforeToolCall` let through unchanged. + * + * It is not first-win. The guard of each middleware runs in order, until + * one returns a decision. Use it for checks that must not be skipped, such + * as permissions. + */ + onGuardToolCall?: ( + ctx: ChatMiddlewareContext, + hookCtx: ToolCallHookContext, + ) => GuardToolCallDecision | Promise + /** * Called after a tool execution completes (success or failure). * Return `{ type: 'replaceResult', result }` to change the result that the diff --git a/packages/ai/src/activities/chat/tools/tool-calls.ts b/packages/ai/src/activities/chat/tools/tool-calls.ts index bd697e8edf..097f824d3b 100644 --- a/packages/ai/src/activities/chat/tools/tool-calls.ts +++ b/packages/ai/src/activities/chat/tools/tool-calls.ts @@ -36,6 +36,7 @@ import type { AfterToolCallDecision, AfterToolCallInfo, BeforeToolCallDecision, + GuardToolCallDecision, } from '../middleware/types' import type { McpResourceReadResult } from '../mcp/types' import type { @@ -210,6 +211,12 @@ export interface ToolExecutionMiddlewareHooks { tool: Tool | undefined, args: unknown, ) => Promise + /** Runs on the final checked args, just before the call runs. */ + onGuardToolCall?: ( + toolCall: ToolCall, + tool: Tool | undefined, + args: unknown, + ) => Promise onAfterToolCall?: (info: AfterToolCallInfo) => Promise } @@ -703,6 +710,31 @@ async function applyBeforeToolCallDecision( } const decision = await middlewareHooks.onBeforeToolCall(toolCall, tool, input) + return applyToolCallDecision( + decision, + toolCall, + tool, + input, + toolName, + middlewareHooks, + results, + ) +} + +/** + * Apply an onBeforeToolCall or onGuardToolCall decision. + * Returns the input to run with, or proceed false when the call was skipped + * (result already pushed). Throws MiddlewareAbortError on 'abort'. + */ +async function applyToolCallDecision( + decision: BeforeToolCallDecision | GuardToolCallDecision, + toolCall: ToolCall, + tool: Tool, + input: unknown, + toolName: string, + middlewareHooks: ToolExecutionMiddlewareHooks, + results: Array, +): Promise<{ proceed: true; input: unknown } | { proceed: false }> { if (!decision) { return { proceed: true, input } } @@ -734,7 +766,9 @@ async function applyBeforeToolCallDecision( return { proceed: false } } - return { proceed: true, input: decision.args } + return decision.type === 'transformArgs' + ? { proceed: true, input: decision.args } + : { proceed: true, input } } /** @@ -1285,6 +1319,27 @@ export async function* executeToolCalls( continue } + // The guard tier sees the final checked input. It always runs, also + // when an onBeforeToolCall decision came first, and cannot change it. + if (middlewareHooks?.onGuardToolCall) { + let guard: Awaited> + try { + guard = await applyToolCallDecision( + await middlewareHooks.onGuardToolCall(toolCall, tool, input), + toolCall, + tool, + input, + toolName, + middlewareHooks, + results, + ) + } catch (error) { + failures.push(error) + break + } + if (!guard.proceed) continue + } + // Create a ToolExecutionContext for this tool call with event emission const pendingEvents: Array = [] const inputResponse = resumeState?.inputResponses?.get(toolCall.id) diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 6e51a8aaf2..969a71e75f 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -281,6 +281,7 @@ export type { StructuredOutputMiddlewareConfig, ToolCallHookContext, BeforeToolCallDecision, + GuardToolCallDecision, AfterToolCallDecision, AfterToolCallInfo, IterationInfo, diff --git a/packages/ai/tests/middleware.test.ts b/packages/ai/tests/middleware.test.ts index 1929a591f9..c727defc8c 100644 --- a/packages/ai/tests/middleware.test.ts +++ b/packages/ai/tests/middleware.test.ts @@ -1504,6 +1504,198 @@ describe('chat() middleware', () => { }) }) + // ========================================================================== + // onGuardToolCall + // ========================================================================== + describe('onGuardToolCall', () => { + function oneCallAdapter(args: string) { + return createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('tc-1', 'myTool'), + ev.toolArgs('tc-1', args), + ev.toolEnd('tc-1', 'myTool', { input: JSON.parse(args) }), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], + }) + } + + it('runs after a transformArgs decision and sees the final checked args', async () => { + const executeFn = vi.fn(() => ({ result: 'done' })) + const tool = { + name: 'myTool', + description: 'Test tool', + inputSchema: z.object({ x: z.number(), unit: z.string().default('c') }), + execute: executeFn, + } + const { adapter } = oneCallAdapter('{"x":1}') + const guard = vi.fn() + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + tools: [tool], + middleware: [ + { + name: 'transformer', + onBeforeToolCall: () => ({ + type: 'transformArgs' as const, + args: { x: 42 }, + }), + }, + { name: 'guard', onGuardToolCall: guard }, + ], + }) + await collectChunks(stream as AsyncIterable) + + expect(guard).toHaveBeenCalledOnce() + expect(guard.mock.calls[0]![1]).toMatchObject({ + toolName: 'myTool', + toolCallId: 'tc-1', + args: { x: 42, unit: 'c' }, + }) + expect(executeFn).toHaveBeenCalledWith( + { x: 42, unit: 'c' }, + expect.anything(), + ) + }) + + it('a deny stops execute and the model gets it like an onBeforeToolCall skip', async () => { + const denial = { error: 'blocked by policy' } + + async function toolMessage(middleware: ChatMiddleware) { + const executeFn = vi.fn(() => ({ result: 'should not run' })) + const onAfterToolCall = vi.fn() + const { adapter, calls } = oneCallAdapter('{}') + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + tools: [serverTool('myTool', executeFn)], + middleware: [{ ...middleware, onAfterToolCall }], + }) + await collectChunks(stream as AsyncIterable) + expect(executeFn).not.toHaveBeenCalled() + expect(onAfterToolCall).toHaveBeenCalledOnce() + expect(onAfterToolCall.mock.calls[0]![1]).toMatchObject({ + ok: true, + result: denial, + }) + const messages = calls[1]!.messages as Array<{ role: string }> + return messages.find((m) => m.role === 'tool') + } + + const fromGuard = await toolMessage({ + name: 'guard', + onGuardToolCall: () => ({ type: 'skip' as const, result: denial }), + }) + const fromBefore = await toolMessage({ + name: 'before', + onBeforeToolCall: () => ({ type: 'skip' as const, result: denial }), + }) + + expect(fromGuard).toBeDefined() + expect(fromGuard).toEqual(fromBefore) + }) + + it('runs the guard of every middleware in order until one denies', async () => { + const executeFn = vi.fn(() => ({ result: 'done' })) + const order: Array = [] + const { adapter } = oneCallAdapter('{}') + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + tools: [serverTool('myTool', executeFn)], + middleware: [ + { name: 'a', onGuardToolCall: () => void order.push('a') }, + { name: 'b', onGuardToolCall: () => void order.push('b') }, + { + name: 'c', + onGuardToolCall: () => { + order.push('c') + return { type: 'skip' as const, result: { error: 'no' } } + }, + }, + { name: 'd', onGuardToolCall: () => void order.push('d') }, + ], + }) + await collectChunks(stream as AsyncIterable) + + expect(order).toEqual(['a', 'b', 'c']) + expect(executeFn).not.toHaveBeenCalled() + }) + + it('guards each call of a parallel batch', async () => { + const executeA = vi.fn(() => ({ a: 'ran' })) + const executeB = vi.fn(() => ({ b: 'ran' })) + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('tc-1', 'toolA'), + ev.toolArgs('tc-1', '{}'), + ev.toolEnd('tc-1', 'toolA', { input: {} }), + ev.toolStart('tc-2', 'toolB'), + ev.toolArgs('tc-2', '{}'), + ev.toolEnd('tc-2', 'toolB', { input: {} }), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], + ], + }) + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + tools: [serverTool('toolA', executeA), serverTool('toolB', executeB)], + toolExecution: 'parallel', + middleware: [ + { + name: 'guard', + onGuardToolCall: (_ctx, hookCtx) => + hookCtx.toolName === 'toolA' + ? { type: 'skip' as const, result: { error: 'no' } } + : undefined, + }, + ], + }) + await collectChunks(stream as AsyncIterable) + + expect(executeA).not.toHaveBeenCalled() + expect(executeB).toHaveBeenCalledOnce() + }) + + it('an abort from the guard ends the run', async () => { + const executeFn = vi.fn(() => ({ result: 'should not run' })) + const onAbort = vi.fn() + const { adapter } = oneCallAdapter('{}') + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + tools: [serverTool('myTool', executeFn)], + middleware: [ + { + name: 'guard', + onGuardToolCall: () => ({ + type: 'abort' as const, + reason: 'policy violation', + }), + onAbort, + }, + ], + }) + await collectChunks(stream as AsyncIterable) + + expect(executeFn).not.toHaveBeenCalled() + expect(onAbort).toHaveBeenCalledOnce() + expect(onAbort.mock.calls[0]![1].reason).toBe('policy violation') + }) + }) + // ========================================================================== // onConfig tools transform // ========================================================================== From d6026ce93f39688e820c1e4c5b7eee9f22b784e5 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sat, 17 Oct 2026 18:36:41 +0200 Subject: [PATCH 05/15] test(e2e): onGuardToolCall denies on the final args after a transformArgs --- testing/e2e/src/routes/api.middleware-test.ts | 29 +++++++++++++++ testing/e2e/src/routes/middleware-test.tsx | 1 + testing/e2e/tests/middleware.spec.ts | 36 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index f5d7cb672a..cccdabc1d5 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -187,6 +187,33 @@ const toolBadArgsMiddleware: ChatMiddleware = { }, } +// `tool-guard` mode: the first middleware rewrites the city, so +// onBeforeToolCall is done. The guard still runs, sees the rewritten city, +// and denies the call. +const toolRewriteMiddleware: ChatMiddleware = { + name: 'tool-rewrite', + onBeforeToolCall(_ctx, hookCtx) { + if (hookCtx.toolName !== 'get_weather') return undefined + return { type: 'transformArgs' as const, args: { city: 'Blocked City' } } + }, +} + +const toolGuardMiddleware: ChatMiddleware = { + name: 'tool-guard', + onGuardToolCall(_ctx, hookCtx) { + const args = hookCtx.args + const city = + typeof args === 'object' && args !== null && 'city' in args + ? args.city + : undefined + if (hookCtx.toolName !== 'get_weather' || city !== 'Blocked City') return + return { + type: 'skip' as const, + result: JSON.stringify({ guarded: true, city }), + } + }, +} + /** * Capability provide/consume flow (`capability` mode). * @@ -591,6 +618,8 @@ export const Route = createFileRoute('/api/middleware-test')({ middleware.push(toolReplaceMiddleware) if (middlewareMode === 'tool-bad-args') middleware.push(toolBadArgsMiddleware) + if (middlewareMode === 'tool-guard') + middleware.push(toolRewriteMiddleware, toolGuardMiddleware) if (middlewareMode === 'capability') { // Order matters: the provider's setup() must run before the // consumer reads the capability. Array order is preserved. diff --git a/testing/e2e/src/routes/middleware-test.tsx b/testing/e2e/src/routes/middleware-test.tsx index fc326cc99b..19da5b7511 100644 --- a/testing/e2e/src/routes/middleware-test.tsx +++ b/testing/e2e/src/routes/middleware-test.tsx @@ -20,6 +20,7 @@ const MIDDLEWARE_MODES = [ { id: 'tool-skip', label: 'Tool Skip (skip with custom result)' }, { id: 'tool-replace', label: 'Tool Replace (replace result after call)' }, { id: 'tool-bad-args', label: 'Tool Bad Args (invalid args rewrite)' }, + { id: 'tool-guard', label: 'Tool Guard (deny on final args)' }, { id: 'capability', label: 'Capability (provide/consume prefix)' }, { id: 'phase-recorder', label: 'Phase Recorder (capture phase + chunks)' }, { id: 'otel', label: 'OpenTelemetry (capture spans/metrics)' }, diff --git a/testing/e2e/tests/middleware.spec.ts b/testing/e2e/tests/middleware.spec.ts index 6e1d5b50c3..e0606981ff 100644 --- a/testing/e2e/tests/middleware.spec.ts +++ b/testing/e2e/tests/middleware.spec.ts @@ -167,6 +167,42 @@ test.describe('Middleware Lifecycle', () => { expect(toolResults[0].content).not.toContain('temperature') }) + test('onGuardToolCall denies on the final args after a transformArgs', async ({ + page, + testId, + aimockPort, + }) => { + const params = new URLSearchParams() + if (testId) params.set('testId', testId) + if (aimockPort) params.set('aimockPort', String(aimockPort)) + const qs = params.toString() + await page.goto(`/middleware-test${qs ? '?' + qs : ''}`) + await page.waitForTimeout(2000) + await page.locator('#mw-scenario-select').selectOption('with-tool') + await page.locator('#mw-mode-select').selectOption('tool-guard') + await page.locator('#mw-run-button').click() + + await page.waitForFunction( + () => + document + .querySelector('#mw-metadata') + ?.getAttribute('data-test-complete') === 'true', + { timeout: 10000 }, + ) + + const messagesJson = await page.locator('#mw-messages-json').textContent() + const messages = JSON.parse(messagesJson || '[]') + const toolResults = messages.flatMap((m: any) => + m.parts.filter((p: any) => p.type === 'tool-result'), + ) + // The rewrite ran first. The guard saw the new city and denied the call, + // so get_weather never ran. + expect(toolResults).toHaveLength(1) + expect(toolResults[0].content).toContain('guarded') + expect(toolResults[0].content).toContain('Blocked City') + expect(toolResults[0].content).not.toContain('temperature') + }) + test('the next model call replays the exact tool argument bytes', async ({ page, request, From 42c801480a26938381e58a39cc74e8b8854071ae Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sat, 17 Oct 2026 19:30:54 +0200 Subject: [PATCH 06/15] fix(ai-harness): tool turns take no steers, a turn keeps its config, removing a plugin removes all of it, body and frame limits, signing keys, and a warning on tool name clashes --- .changeset/harness-secure-defaults.md | 3 +- .changeset/harness-turn-and-body-limits.md | 12 ++ docs/config.json | 2 +- docs/harness/connect.md | 22 +++ docs/harness/mcp.md | 2 +- docs/harness/media.md | 6 +- docs/harness/permissions.md | 2 + docs/harness/thread-settings.md | 5 +- docs/harness/turn-control.md | 5 + packages/ai-harness/src/http.ts | 168 +++++++++++------- packages/ai-harness/src/plugins.ts | 13 +- packages/ai-harness/src/session/agents.ts | 4 +- packages/ai-harness/src/session/base.ts | 55 +++++- .../ai-harness/src/session/harness-session.ts | 2 + packages/ai-harness/src/session/helpers.ts | 41 ++--- packages/ai-harness/src/session/state.ts | 36 +++- packages/ai-harness/src/session/turn-parts.ts | 38 +++- packages/ai-harness/src/session/turn.ts | 48 ++--- packages/ai-harness/tests/agents.test.ts | 36 ++++ packages/ai-harness/tests/auth-wait.test.ts | 2 +- .../ai-harness/tests/discover-tools.test.ts | 56 ++++++ packages/ai-harness/tests/http-media.test.ts | 164 ++++++++++++++++- packages/ai-harness/tests/permissions.test.ts | 37 +++- .../ai-harness/tests/run-route-hosts.test.ts | 2 +- packages/ai-harness/tests/tool-op.test.ts | 45 ++++- .../ai-harness/tests/transport-edges.test.ts | 31 ++++ .../e2e/src/routes/api.harness-protocol.$.ts | 2 +- 27 files changed, 689 insertions(+), 150 deletions(-) create mode 100644 .changeset/harness-turn-and-body-limits.md diff --git a/.changeset/harness-secure-defaults.md b/.changeset/harness-secure-defaults.md index cbe404cdcb..73d5aedf09 100644 --- a/.changeset/harness-secure-defaults.md +++ b/.changeset/harness-secure-defaults.md @@ -5,5 +5,6 @@ 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: { principal }` (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 signed URL names its user, and the handler checks that user again when it serves the file. +- `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 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-turn-and-body-limits.md b/.changeset/harness-turn-and-body-limits.md new file mode 100644 index 0000000000..c8b61dfe1a --- /dev/null +++ b/.changeset/harness-turn-and-body-limits.md @@ -0,0 +1,12 @@ +--- +'@tanstack/ai-harness': patch +--- + +Fix how a turn takes steers, settings, and plugin removals, and limit the size 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 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 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. +- `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/docs/config.json b/docs/config.json index bf90553e7e..853c2b9488 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1049,7 +1049,7 @@ "label": "Ask before risky tool calls", "to": "harness/permissions", "addedAt": "2026-10-13", - "updatedAt": "2026-10-16" + "updatedAt": "2026-10-17" }, { "label": "Give your agent coding tools", diff --git a/docs/harness/connect.md b/docs/harness/connect.md index 70538715b9..aa2a122398 100644 --- a/docs/harness/connect.md +++ b/docs/harness/connect.md @@ -50,6 +50,27 @@ export const handler = createHarnessHandler({ host, harness: assistant }) When more than one instance serves the same URL, also set `media: { secret }`. The secret signs media URLs, so every instance must use the same value. Without it, the handler signs with a random key of its own, so a signed URL works only on this instance, until it restarts. +```ts group=harness-connect +const mediaKey = process.env.MEDIA_KEY +const oldMediaKey = process.env.OLD_MEDIA_KEY +if (!mediaKey || !oldMediaKey) { + throw new Error('Set MEDIA_KEY and OLD_MEDIA_KEY.') +} + +export const rotatingHandler = createHarnessHandler({ + host, + harness: assistant, + // The first key signs. Every key checks the URLs. + media: { keys: [mediaKey, oldMediaKey] }, +}) +``` + +- Each key must be at least 32 bytes. A shorter key throws at the start. +- To change the key, put the new key first in `keys` and keep the old key after it. A URL that the old key signed works until it expires (1 hour). Then remove the old key. +- A signed URL works only for this harness and for the user that it was signed for. When the thread is deleted, its URLs answer `404`. + +A request body over `media.maxBodyBytes` (default 32 MiB) answers `413`. See [Files inside a client message](./media#files-inside-a-client-message). + The handler answers these paths under your route. For chat and events: - `GET capabilities`: the AG-UI capabilities, with the agents in `expose.agents`. @@ -356,6 +377,7 @@ The client sends `{ type: 'harness.subscribe', threadId }` first. After that, ea - The host reads the user from the upgrade request with `identify`, the same as for HTTP. Every input on the socket runs as that user. - A user that `identify` refuses gets an `unauthorized` error frame, and the socket closes. - A user who cannot read the thread, or who loses access later, gets a `forbidden` error frame, and the socket closes. +- A frame over `media.maxBodyBytes` (default 32 MiB) gets an error frame, and the socket closes with code `1009`. - On a host with no `access`, pass `openAccess`, the same as for `createHarnessHandler`. - Pass `socket: true` to `createHarnessHandler` too. Then `GET capabilities` says `transport.websocket: true`, so clients know that they can use the socket. The default is `false`. diff --git a/docs/harness/mcp.md b/docs/harness/mcp.md index 4cc1700887..69621de887 100644 --- a/docs/harness/mcp.md +++ b/docs/harness/mcp.md @@ -211,7 +211,7 @@ export const lateTools = definePlugin({ }) ``` -- A tool whose name is already taken is skipped. The first tool keeps the name. +- A tool whose name is already taken is skipped. The first tool keeps the name. When two owners give the same name, clients get one `harness.plugin.warning` event that names both. - If `discoverTools` throws, the turn runs without that plugin's tools, and clients get a `harness.plugin.warning` event. ## What you have now diff --git a/docs/harness/media.md b/docs/harness/media.md index 37cb69eb76..86a47c0080 100644 --- a/docs/harness/media.md +++ b/docs/harness/media.md @@ -207,6 +207,8 @@ On the server, the harness does this for each part: - An `http:` or `https:` URL is refused. Set `externalUrls` to allow it. - A URL with another scheme, for example `file:`, is always refused. +`POST run` (the route of `useChat`) checks the parts of the last user message the same way. + Two options control this: ```ts group=harness-media-inline @@ -224,7 +226,7 @@ export const assistant = defineHarness({ ``` - `externalUrls`: `true` allows every `http:` and `https:` URL. A function decides for each URL. Default `false`. -- `maxBodyBytes`: the largest request body or socket frame, in bytes. Default 32 MiB. +- `maxBodyBytes`: the largest request body or socket frame, in bytes. Default 32 MiB. A bigger body answers `413`. A bigger frame closes the socket. If the harness refuses a part, no turn starts. The receipt is `rejected`, and its `reason` tells you why: @@ -235,6 +237,8 @@ If the harness refuses a part, no turn starts. The receipt is `rejected`, and it | `media_invalid` | The bytes are not base64. | | `media_url_not_allowed` | `externalUrls` refuses the URL, or the scheme is bad. | +`POST run` has no receipt. It answers with the `reason` as `error`, and with `413` for `media_too_large`, `415` for `media_not_allowed`, or `400` for the other two. + Server code that calls `session.send` directly is not checked. The checks are for inputs from a client. ## Keep media across restarts diff --git a/docs/harness/permissions.md b/docs/harness/permissions.md index bebec6c7c0..1e616b9769 100644 --- a/docs/harness/permissions.md +++ b/docs/harness/permissions.md @@ -92,6 +92,8 @@ await session.command({ name: 'mode', input: 'plan' }) `session.command({ name: 'mode' })` with no input shows the current mode. In `acceptEdits` mode, an edit outside `root` or of a `.env` file still asks. +A mode change applies from the next turn. A turn that runs keeps the mode that it started with, so a switch to `bypass` does not change its later tool calls. + ### Keep the mode on the server A client that can set `mode` can set `bypass`. Then every call runs with no question. So by default, a client cannot change the mode: diff --git a/docs/harness/thread-settings.md b/docs/harness/thread-settings.md index c8dafd4864..06f7135d61 100644 --- a/docs/harness/thread-settings.md +++ b/docs/harness/thread-settings.md @@ -101,8 +101,9 @@ For `reasoning`, the `overrides` of the prompt win, then the stored setting, the - A `tools` list keeps only the tools with those names. `{ remove }` drops the tools with those names. Either way, the tools of the prompt `overrides` stay. - The harness applies `tools` before the plugins prepare the tool list (`prepareTools`). So a plugin that wraps tools sees only the tools that a thread keeps. -- `plugins: { remove }` leaves the tools, prompts, middleware, and model pick of those plugins out of the turn. Plugins that run once per session stay mounted. -- The agents and subagents that a removed plugin gives stay available. +- `plugins: { remove }` leaves everything that those plugins give out of the thread: tools, prompts, middleware, the model pick, agents, subagents, commands, and the items that they add for other plugins. For example, a thread without `agents()` gets no subagent tools, no `/agent` command, and no permission rules of its agent profiles. +- Plugins that run once per session stay mounted, but they give the thread nothing. +- If a plugin that stays needs a capability of a removed plugin, the setting is rejected. ## The working folder diff --git a/docs/harness/turn-control.md b/docs/harness/turn-control.md index d68e5e5e59..ff352a64dd 100644 --- a/docs/harness/turn-control.md +++ b/docs/harness/turn-control.md @@ -191,6 +191,11 @@ The join stops at the first message that: That message and the ones after it wait. `canJoin` runs again before each model call and at each final answer, so it can run more than once for one message. Keep it a quick check with no side effects. What still waits when the turn ends runs as its own turn, before other queued turns. A cancelled one settles `aborted` and does not run. +Two kinds of turns take no messages. A message that arrives while one runs waits, and then runs as its own turn: + +- A `session.tool()` turn. It runs no model, so nothing could answer the message. See [Run a tool without a model](./tool-runs). +- A turn that `routing` sends to root agents. + A joined message runs with the credentials of the sender of the turn. So without `turn.canJoin`, a waiting message joins the running turn only when the same person sent both. A message from another person waits and runs as its own turn after the running one, as its sender. Joins are a prefix, so it also holds back the messages that wait after it. Set `canJoin` to choose your own rule. Compare `principal` with `turnPrincipal` to keep one person's message out of another person's turn. See [Keep other people out of a running turn](./shared-threads#5-keep-other-people-out-of-a-running-turn). `onJoin` runs before the model call that the joined messages reach. It can return messages, which come after the joined ones, and records, which land in the same append as the join. diff --git a/packages/ai-harness/src/http.ts b/packages/ai-harness/src/http.ts index cd5421dd57..fcc55ceb73 100644 --- a/packages/ai-harness/src/http.ts +++ b/packages/ai-harness/src/http.ts @@ -13,6 +13,8 @@ import { AccessDeniedError } from './access' import { accessOf, sessionOfTurn } from './host' import { activeRunIn } from './log-stores' import { MediaError } from './media' +import { maxBodyBytesOf, readBodyText, storeInlineMedia } from './inline-media' +import { createMediaSigner } from './media-signing' import { HARNESS_PROTOCOL_VERSION, applyInput, @@ -30,6 +32,8 @@ import type { ModelMessage, UIMessage, WebSocketLike } from '@tanstack/ai' import type { SessionIndexEntry, SessionMember } from '@tanstack/ai-persistence' import type { AnyHarness } from './define' import type { HarnessHost, HostEvent } from './host' +import type { InlineMediaRefusal } from './inline-media' +import type { MediaSignerOptions } from './media-signing' import type { HostFrame } from './protocol' import type { HarnessSession } from './session/index' import type { ObservedSession } from './session/observed-session' @@ -59,18 +63,19 @@ export interface HarnessHandlerOptions

{ */ openAccess?: OpenAccess

/** - * How the handler signs media URLs. Without it, the handler signs with a - * random key of its own, so a signed URL works only on this server, and - * only until it restarts. Set it when more than one server answers the - * same URL, so each server verifies the URLs of the others. + * How the handler signs media URLs (HMAC SHA-256). Without it, the handler + * signs with a random key of its own, so a signed URL works only on this + * server, and only until it restarts. Set it when more than one server + * answers the same URL, so each server verifies the URLs of the others. + * + * - `{ secret }`: one key. Use the same value on every server. + * - `{ keys }`: to rotate keys. The first key signs, and every key + * verifies, so a URL that an old key signed works until it expires. + * + * Each key must be at least 32 bytes (UTF-8). A shorter key throws at the + * start. */ - media?: { - /** - * The secret that signs media URLs (HMAC SHA-256). Use the same value on - * every server behind one URL. - */ - secret: string - } + media?: MediaSignerOptions /** * Set `true` when the app also serves `handleHarnessSocket`, so the * capabilities say that clients can use a WebSocket. Default `false`. @@ -81,8 +86,6 @@ export interface HarnessHandlerOptions

{ /** How long a signed media URL works. */ const SIGNED_URL_TTL_MS = 60 * 60 * 1000 -const HMAC = { name: 'HMAC', hash: 'SHA-256' } - // Every media answer: a browser must not guess another type, and a `text/*` // document must not run as a page of this origin. const MEDIA_HEADERS = { @@ -178,9 +181,28 @@ function cursorResponse( ) } +/** + * The status of a content part that `storeInlineMedia` refused: 413 for too + * many bytes, 415 for a kind the harness does not take, else 400. + */ +const REFUSAL_STATUS: Record = { + media_too_large: 413, + media_not_allowed: 415, + media_invalid: 400, + media_url_not_allowed: 400, +} + +/** True when `text` is more than `limit` bytes as UTF-8. */ +function isOverLimit(text: string, limit: number) { + // A UTF-16 code unit is 1 to 3 bytes, so only some texts need encoding. + if (text.length > limit) return true + if (text.length * 3 <= limit) return false + return encoder.encode(text).byteLength > limit +} + /** * A handler error as JSON: a refused action is 403, a `MediaError` keeps its - * status, the rest is 400. + * status (413 for a body over `media.maxBodyBytes`), the rest is 400. */ const failed = (error: unknown) => { if (error instanceof AccessDeniedError) return forbidden() @@ -263,16 +285,6 @@ function parseSessionOp(body: unknown) { } } -/** Base64url to bytes, or `undefined` for a value that is not base64url. */ -function fromBase64url(value: string) { - try { - const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/')) - return Uint8Array.from(binary, (char) => char.charCodeAt(0)) - } catch { - return undefined - } -} - /** * The bytes of a media file as a response, with `Range` support: 206 for a * slice, 416 for a range outside the file, and 404 for an id this thread @@ -469,8 +481,15 @@ function watchRevoke

( * The streams of a user close when `host.revoke` or `unshare` takes their * access. Each chat input runs as the principal of its request. * - * Signed media URLs use `media.secret`. Without it, they work only on this - * server (see `HarnessHandlerOptions.media`). + * Signed media URLs use the keys of `media`. Without it, they work only on + * this server (see `HarnessHandlerOptions.media`). A URL of a deleted file + * is 404. + * + * A request body over `media.maxBodyBytes` (default 32 MiB) is 413. Inline + * media of `POST run` goes to the media store, as in `POST control`. A + * refused part is 413 (`media_too_large`), 415 (`media_not_allowed`), or + * 400 (`media_invalid`, `media_url_not_allowed`), with the reason as + * `error`. * * Throws at the start for a host without `access` and without * `openAccess`, and for a host with both. @@ -547,18 +566,17 @@ export function createHarnessHandler

( } } - // ponytail: without `media.secret`, a random key per handler, so a signed - // URL works only on this server, until it restarts. - const secret = - media === undefined - ? crypto.getRandomValues(new Uint8Array(32)) - : encoder.encode(media.secret) - let key: Promise | undefined - const mediaKey = () => - (key ??= crypto.subtle.importKey('raw', secret, HMAC, false, [ - 'sign', - 'verify', - ])) + // ponytail: without `media`, a random key per handler, so a signed URL + // works only on this server, until it restarts. + const signer = createMediaSigner( + media ?? { secret: base64url(crypto.getRandomValues(new Uint8Array(32))) }, + ) + /** + * A request body as JSON, read up to `media.maxBodyBytes`. A bigger body is + * a `MediaError` with 413. + */ + const readJson = async (request: Request): Promise => + JSON.parse(await readBodyText(request, harness.media)) /** What a signed media URL holds: who may read which file, until when. */ interface SignedMedia { tenantId: string @@ -567,25 +585,18 @@ export function createHarnessHandler

( userId: string exp: string } + // The harness name and the user are in the signed text, so a URL works + // only for this harness and for the user that it was signed for. const signed = (media: SignedMedia) => - encoder.encode( - [ - harness.name, - media.tenantId, - media.threadId, - media.id, - media.userId, - media.exp, - ].join('\n'), - ) - const sign = async (media: SignedMedia) => { - const signature = await crypto.subtle.sign( - 'HMAC', - await mediaKey(), - signed(media), - ) - return base64url(new Uint8Array(signature)) - } + [ + harness.name, + media.tenantId, + media.threadId, + media.id, + media.userId, + media.exp, + ].join('\n') + const sign = (media: SignedMedia) => signer.sign(signed(media)) /** * Serve a signed media URL. The URL names its user: the read is checked as @@ -600,17 +611,10 @@ export function createHarnessHandler

( userId: param('userId'), exp: param('exp'), } - const signature = fromBase64url(param('sig')) const isValid = - signature !== undefined && Number(payload.exp) > Date.now() && // `verify` compares in constant time. - (await crypto.subtle.verify( - 'HMAC', - await mediaKey(), - signature, - signed(payload), - )) + (await signer.verify(signed(payload), param('sig'))) if (!isValid) return forbidden() const { tenantId, userId } = payload // A signed URL keeps the id and the tenant of its user, as the log does, @@ -755,7 +759,7 @@ export function createHarnessHandler

( } if (request.method === 'POST' && route === 'run') { - const params = await chatParamsFromRequestBody(await request.json()) + const params = await chatParamsFromRequestBody(await readJson(request)) const message = lastUserInput(params.messages) const session = await openFor(principal, params.threadId) // The turn runs as the request's run id, so an AG-UI client matches @@ -791,9 +795,23 @@ export function createHarnessHandler

( if (message === undefined) { return json({ error: 'no user message' }, 400) } + // Inline bytes go to the media store, and the message keeps a + // `harness-media:` reference, as `POST control` does. + const checked = Array.isArray(message) + ? await storeInlineMedia(message, { + options: harness.media, + putMedia: (upload) => session.putMedia(upload), + }) + : { parts: message } + if ('refused' in checked) { + return json( + { error: checked.refused }, + REFUSAL_STATUS[checked.refused], + ) + } // AG-UI `forwardedProps` are the input's context. const { forwardedProps } = params - const operation = session.send(message, { + const operation = session.send(checked.parts, { ...ids, ...(Object.keys(forwardedProps).length > 0 ? { context: forwardedProps } @@ -862,7 +880,7 @@ export function createHarnessHandler

( } if (request.method === 'POST' && route === 'control') { - const body: unknown = await request.json() + const body = await readJson(request) if (typeof body !== 'object' || body === null || !('threadId' in body)) { return json({ error: 'threadId is required' }, 400) } @@ -946,7 +964,7 @@ export function createHarnessHandler

( } if (request.method === 'POST' && route === 'sessions') { - const op = parseSessionOp(await request.json()) + const op = parseSessionOp(await readJson(request)) if (!op) return json({ error: 'not a valid sessions op' }, 400) const target = { threadId: op.threadId, ...user } const entry = await host.sessions.get(target) @@ -1258,13 +1276,25 @@ export function handleHarnessSocket

( socket.addEventListener('close', () => reader.abort()) socket.addEventListener('error', () => reader.abort()) + const frameLimit = maxBodyBytesOf(harness.media) socket.addEventListener('message', (message) => { + const data = String(message.data) + // A frame over `media.maxBodyBytes` is not parsed. The socket closes. + if (isOverLimit(data, frameLimit)) { + send({ + type: 'harness.error', + message: `The frame is bigger than the limit of ${frameLimit} bytes.`, + }) + socket.close(1009, 'frame too large') + reader.abort() + return + } void (async () => { try { const user = await who if (!user) return refuse('unauthorized') const { principal } = user - const frame = parseControlFrame(String(message.data)) + const frame = parseControlFrame(data) if (frame.type === 'harness.subscribe') { if (subscribed) throw new Error('Already subscribed.') const { threadId } = frame diff --git a/packages/ai-harness/src/plugins.ts b/packages/ai-harness/src/plugins.ts index 39c316b89e..99e8aaf9cb 100644 --- a/packages/ai-harness/src/plugins.ts +++ b/packages/ai-harness/src/plugins.ts @@ -632,6 +632,11 @@ export interface MountEnvironment { hostPlugin?: (plugin: HostPluginDefinition) => Promise /** The harness name a host plugin gets without `hostPlugin`. */ harnessName?: string + /** + * The plugins whose extension items `collect` hides now: the plugins that + * the thread removed. Read at each `collect` read. + */ + hiddenOwners?: () => ReadonlySet } const unavailable = (what: string) => () => { @@ -1101,7 +1106,13 @@ export async function mountPlugins( extensions.set(point.name, items) } const source = items - const live = () => source.map((item) => item.value as T) + const live = () => { + const hidden = env.hiddenOwners?.() + const shown = hidden?.size + ? source.filter((item) => !hidden.has(item.owner)) + : source + return shown.map((item) => item.value as T) + } // A live view: items contributed after this call appear too. Array // methods like `filter` also check that an index exists, so every // trap reads the items, not the empty target. diff --git a/packages/ai-harness/src/session/agents.ts b/packages/ai-harness/src/session/agents.ts index 4ed8a75456..250bd75ded 100644 --- a/packages/ai-harness/src/session/agents.ts +++ b/packages/ai-harness/src/session/agents.ts @@ -415,7 +415,7 @@ export abstract class SessionAgents< }) return { generationMiddleware: [ - ...(this.sessionPlugins?.generationMiddleware ?? []), + ...(this.threadPlugins()?.generationMiddleware ?? []), ...(runPlugins?.generationMiddleware ?? []), ...mediaCapture({ persistence: this.media, @@ -437,7 +437,7 @@ export abstract class SessionAgents< chatMiddleware: [ // First, so the plugin middleware after it reads the run too. runMiddleware(run), - ...(this.sessionPlugins?.agentMiddleware ?? []), + ...(this.threadPlugins()?.agentMiddleware ?? []), ...(runPlugins?.agentMiddleware ?? []), // A routed root agent gets the thread too, so a reset cuts it as well. resetCut, diff --git a/packages/ai-harness/src/session/base.ts b/packages/ai-harness/src/session/base.ts index 56e8e12ec4..22e731d4d9 100644 --- a/packages/ai-harness/src/session/base.ts +++ b/packages/ai-harness/src/session/base.ts @@ -98,6 +98,8 @@ import type { } from './types' import type { HarnessSession } from './harness-session' +const NO_NAMES: ReadonlySet = new Set() + /** * What a session keeps for its inputs, by input id: the duplicate check, the * receipt, the settlement, the turn operation, and the inputs that joined an @@ -389,6 +391,11 @@ export abstract class SessionBase { Set<(value: unknown) => void> >() protected readonly configValues = new Map() + /** + * The config values of the log when the running turn started. The turn + * uses them until it ends. + */ + protected turnConfig: ReadonlyMap | undefined /** The stored settings of this thread: `session.configure`. */ protected threadSettings: ThreadSettings = {} protected readonly questions = new Map< @@ -681,11 +688,55 @@ export abstract class SessionBase { } } - protected configValue(key: string): unknown { - if (this.configValues.has(key)) return this.configValues.get(key) + /** A config value. Plugins read the copy of the running turn, if any. */ + protected configValue( + key: string, + values: ReadonlyMap = this.turnConfig ?? this.configValues, + ): unknown { + if (values.has(key)) return values.get(key) return this.sessionPlugins?.config.get(key)?.option.default } + /** The last `removedPlugins()` and `threadPlugins()` results. */ + private threadPluginsCache: + | { + settings: ThreadSettings + removed: ReadonlySet + mounted?: MountedPlugins + plugins?: MountedPlugins + } + | undefined + + /** The plugins that the `plugins.remove` setting of this thread names. */ + protected removedPlugins(): ReadonlySet { + const settings = this.threadSettings + const remove = settings.plugins?.remove ?? [] + if (remove.length === 0) return NO_NAMES + if (this.threadPluginsCache?.settings !== settings) { + this.threadPluginsCache = { settings, removed: new Set(remove) } + } + return this.threadPluginsCache.removed + } + + /** + * The session plugins as this thread has them: without everything that + * the removed plugins contribute (tools, prompts, every kind of + * middleware, agents, subagents, commands, config, extension items, and + * capabilities). + */ + protected threadPlugins(): MountedPlugins | undefined { + const mounted = this.sessionPlugins + const removed = this.removedPlugins() + if (!mounted || removed.size === 0) return mounted + const cache = this.threadPluginsCache + if (!cache) return mounted + if (cache.mounted !== mounted || !cache.plugins) { + cache.mounted = mounted + cache.plugins = mounted.withoutOwners([...removed]) + } + return cache.plugins + } + /** Put each plugin's saved state in the snapshot before the first read. */ protected loadPluginState() { for (const [plugin, load] of this.stateLoaders) { diff --git a/packages/ai-harness/src/session/harness-session.ts b/packages/ai-harness/src/session/harness-session.ts index 33a3d2ee95..a3767aef63 100644 --- a/packages/ai-harness/src/session/harness-session.ts +++ b/packages/ai-harness/src/session/harness-session.ts @@ -178,6 +178,8 @@ export class SessionImpl< ), services: this.services, inherited: this.storeCapabilities(), + // A plugin that this thread removed gives `collect` nothing. + hiddenOwners: () => this.removedPlugins(), }, ) this.loadConfig() diff --git a/packages/ai-harness/src/session/helpers.ts b/packages/ai-harness/src/session/helpers.ts index 0d4e6162b8..05428dd476 100644 --- a/packages/ai-harness/src/session/helpers.ts +++ b/packages/ai-harness/src/session/helpers.ts @@ -25,7 +25,6 @@ import type { NamedTurnOverrides, ThreadRecord, } from '../log-records' -import type { MountedPlugins } from '../plugins' import type { HarnessInput, MediaKind, @@ -462,34 +461,22 @@ export function changeSettings( } /** - * `mounted` without what the `removed` plugins give a turn: their tools, - * prompts, middleware, adapter picks, discoverers, and preparers. + * The owner of each tool name of `tools`: the plugin from `owners`, else + * `the harness` (also for the turn's own tools). The first owner of a name + * stays. */ -export function withoutPlugins( - mounted: MountedPlugins, - removed: ReadonlySet, -): MountedPlugins { - const { owners } = mounted - const toolOwners = new Map( - owners.tools.map((entry) => [entry.name, entry.owner]), - ) - const isKept = (owner: string | undefined) => - owner === undefined || !removed.has(owner) - return { - ...mounted, - tools: mounted.tools.filter((tool) => isKept(toolOwners.get(tool.name))), - prompts: mounted.prompts.filter((_prompt, index) => - isKept(owners.prompts[index]?.owner), - ), - middleware: mounted.middleware.filter((_item, index) => - isKept(owners.middleware[index]), - ), - adapters: mounted.adapters.filter((_pick, index) => - isKept(owners.adapters[index]), - ), - discoverers: mounted.discoverers.filter((item) => isKept(item.owner)), - preparers: mounted.preparers.filter((item) => isKept(item.owner)), +export function toolOwners( + tools: ReadonlyArray, + owners: ReadonlyArray<{ name: string; owner: string }>, +) { + const byName = new Map() + for (const entry of owners) { + if (entry.owner === 'the harness' || byName.has(entry.name)) continue + byName.set(entry.name, entry.owner) } + return new Map( + tools.map((tool) => [tool.name, byName.get(tool.name) ?? 'the harness']), + ) } /** diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index a9c97e83fa..e5bc2e5cdb 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -301,7 +301,8 @@ export abstract class SessionState< result[key] = { option: entry.option, owner: entry.owner, - value: this.configValue(key), + // The latest value, also while a turn runs with its own copy. + value: this.configValue(key, this.configValues), } } return result @@ -394,6 +395,7 @@ export abstract class SessionState< let next: ThreadSettings try { next = changeSettings(this.threadSettings, settings, this.settingNames()) + this.checkRemovable(next) } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.reject(inputId, reason) @@ -429,6 +431,15 @@ export abstract class SessionState< return this.keep({ inputId, status: 'accepted' }) } + /** + * Throws when the plugins that `settings` removes provide a capability + * that a kept session plugin requires. + */ + protected checkRemovable(settings: ThreadSettings) { + const remove = settings.plugins?.remove ?? [] + if (remove.length > 0) this.sessionPlugins?.withoutOwners(remove) + } + /** The model and plugin names that a setting can use. */ protected settingNames(): SettingNames { return { @@ -444,7 +455,7 @@ export abstract class SessionState< owner: string input?: unknown }> { - return [...(this.sessionPlugins?.commands ?? [])].map(([name, entry]) => ({ + return [...(this.threadPlugins()?.commands ?? [])].map(([name, entry]) => ({ name, description: entry.command.description, owner: entry.owner, @@ -752,16 +763,27 @@ export abstract class SessionState< /** Read the config values of this thread from the log. */ protected loadConfig() { - const stored = this.writer.state.thread.config - for (const [key, value] of stored) { + for (const [key, value] of this.configSnapshot()) { + this.configValues.set(key, value) + } + } + + /** + * The config values in the log now. A turn keeps them until it ends, so a + * change while it runs applies at the next turn. + */ + protected configSnapshot(): Map { + const values = new Map() + for (const [key, value] of this.writer.state.thread.config) { const entry = this.sessionPlugins?.config.get(key) if (!entry) continue try { - this.configValues.set(key, checkConfigValue(key, entry.option, value)) + values.set(key, checkConfigValue(key, entry.option, value)) } catch { // A stored value an option no longer accepts falls back to the default. } } + return values } /** Read the stored settings of this thread from the log. */ @@ -770,11 +792,13 @@ export abstract class SessionState< const names = this.settingNames() for (const [key, value] of Object.entries(stored)) { try { - this.threadSettings = changeSettings( + const next = changeSettings( this.threadSettings, { [key]: value }, names, ) + this.checkRemovable(next) + this.threadSettings = next } catch { // A value the harness no longer takes (a removed model or plugin) // is dropped. diff --git a/packages/ai-harness/src/session/turn-parts.ts b/packages/ai-harness/src/session/turn-parts.ts index e746a392e5..47b0efb682 100644 --- a/packages/ai-harness/src/session/turn-parts.ts +++ b/packages/ai-harness/src/session/turn-parts.ts @@ -140,10 +140,14 @@ export abstract class SessionTurnParts< * Ask each plugin for the tools it found since the last turn, for `turn`. * A plugin that fails (for example an MCP server that is down) is skipped, * and clients get a `harness.plugin.warning` event. + * + * `taken` has the owner of each tool name. A found tool with a taken name + * is skipped: the earlier tool stays. The session warns once for each + * name and pair of owners. */ protected async discoverTools( discoverers: MountedPlugins['discoverers'], - taken: Set, + taken: Map, operation: OperationImpl, turn: TurnInfo, ): Promise> { @@ -152,8 +156,12 @@ export abstract class SessionTurnParts< try { const tools = await discover(turn) for (const tool of tools) { - if (taken.has(tool.name)) continue - taken.add(tool.name) + const first = taken.get(tool.name) + if (first !== undefined) { + this.warnToolClash(operation, tool.name, first, owner) + continue + } + taken.set(tool.name, owner) found.push(tool) } } catch (error) { @@ -163,6 +171,30 @@ export abstract class SessionTurnParts< return found } + /** The tool clashes this session warned about: name and both owners. */ + protected readonly toolClashes = new Set() + + /** Warn once that `owner` gives a tool `name` that `first` gives too. */ + protected warnToolClash( + operation: OperationImpl, + name: string, + first: string, + owner: string, + ) { + // A plugin that finds the same tool again on each turn is no clash. + if (first === owner) return + const key = JSON.stringify([name, first, owner]) + if (this.toolClashes.has(key)) return + this.toolClashes.add(key) + const label = (who: string) => + who === 'the harness' ? who : `plugin ${who}` + this.warn( + operation, + owner, + `Two tools are named "${name}": the tool of ${label(first)} is used, and the tool of ${label(owner)} is not.`, + ) + } + /** * Let each plugin change the tool list of this turn, in plugin order. A * plugin that fails leaves the list as it was, with a warning event. diff --git a/packages/ai-harness/src/session/turn.ts b/packages/ai-harness/src/session/turn.ts index 7d58f872f8..062a73b71b 100644 --- a/packages/ai-harness/src/session/turn.ts +++ b/packages/ai-harness/src/session/turn.ts @@ -24,8 +24,8 @@ import { storedPrincipal, turnContext, withRun, - withoutPlugins, settingTools, + toolOwners, resetMarker, resetContext, resetCut, @@ -125,7 +125,8 @@ export abstract class SessionTurn< } return } - const entry = this.sessionPlugins?.commands.get(name) + // A command of a plugin that this thread removed is unknown here. + const entry = this.threadPlugins()?.commands.get(name) if (!entry) { this.reject(inputId, 'unknown_command') operation.fail('failed', new Error(`Unknown command: ${name}`)) @@ -235,10 +236,12 @@ export abstract class SessionTurn< this.activeTurn = next.operation this.activeResume = next.resume !== undefined this.turnPrincipal = next.principal + this.turnConfig = this.configSnapshot() const ended = () => { this.activeResume = false this.activeTurn = undefined this.turnPrincipal = undefined + this.turnConfig = undefined for (const end of this.turnEnds) end() this.turnEnds.clear() this.drain() @@ -403,7 +406,11 @@ export abstract class SessionTurn< await this.flushNotes() // The stored settings of the thread, as they are when the turn starts. const stored = this.threadSettings - const removed = new Set(stored.plugins?.remove ?? []) + // A removed session plugin stays mounted, but gives this turn nothing: + // no tools, prompts, middleware, agents, extension items, or + // capabilities, also for the turn plugins below. + const removed = this.removedPlugins() + const session = this.threadPlugins() // The 'turn' plugins. A removed turn plugin is not set up. const perRun = this.plugins.filter( (plugin) => plugin.lifetime === 'turn' && !removed.has(plugin.name), @@ -415,28 +422,23 @@ export abstract class SessionTurn< registry: this.agentRegistry.fork(), harnessTools: [ ...(this.harness.tools ?? []), - ...(this.sessionPlugins?.tools ?? []), + ...(session?.tools ?? []), ], harnessProvides: (this.harness.middleware ?? []).flatMap( (middleware) => middleware.provides ?? [], ), - ...(this.sessionPlugins + ...(session ? { - inherited: this.sessionPlugins.values, - takenCommands: this.sessionPlugins.commands, - takenConfig: this.sessionPlugins.config, - inheritedExtensions: this.sessionPlugins.extensions, + inherited: session.values, + takenCommands: session.commands, + takenConfig: session.config, + inheritedExtensions: session.extensions, } : {}), // Bound to this turn's run, not read when a service is called. services: this.pluginServices(info), }) } - // A removed session plugin stays mounted, but gives this turn nothing. - const session = - this.sessionPlugins && removed.size > 0 - ? withoutPlugins(this.sessionPlugins, removed) - : this.sessionPlugins const bridges = [ session?.capabilityBridge, runPlugins?.capabilityBridge, @@ -510,7 +512,10 @@ export abstract class SessionTurn< ] const discovered = await this.discoverTools( [...(session?.discoverers ?? []), ...(runPlugins?.discoverers ?? [])], - new Set(staticTools.map((tool) => tool.name)), + toolOwners(staticTools, [ + ...(session?.owners.tools ?? []), + ...(runPlugins?.owners.tools ?? []), + ]), operation, info, ) @@ -608,6 +613,10 @@ export abstract class SessionTurn< } : undefined isRootPart = rootBag !== undefined + // A steer does not join a turn that `routing` sent to root agents, or + // a `session.tool()` turn: the tool turn has no model to answer it. It + // waits and runs as its own turn. + const takesJoins = !rootBag && !turn.tool /** The subagents of the next chat() call. */ let bag = rootBag ?? subagents let routed = isRoutedBag(bag) @@ -700,7 +709,7 @@ export abstract class SessionTurn< ), // Before the harness middleware, so its change of each model // call (`onConfig`) gets the messages that join at that call. - ...(rootBag ? [] : [this.steering(ephemeral)]), + ...(takesJoins ? [this.steering(ephemeral)] : []), // Before the harness middleware, so a compaction sees only the // context after a reset. resetCut, @@ -958,10 +967,9 @@ export abstract class SessionTurn< bag = subagents routed = isRoutedBag(bag) } else { - // A steer does not join a turn that `routing` sent to root agents. - // It waits and runs as its own turn. Claimed, so a cancel after - // this check cannot leave a run with no new input. - const hasLateJoin = !rootBag && (await this.claimJoins()) > 0 + // See `takesJoins`. Claimed, so a cancel after this check cannot + // leave a run with no new input. + const hasLateJoin = takesJoins && (await this.claimJoins()) > 0 if (!hasLateJoin) { const goesOn = await this.continueBeforeFinish( operation, diff --git a/packages/ai-harness/tests/agents.test.ts b/packages/ai-harness/tests/agents.test.ts index b1ea907ed2..30d26aa744 100644 --- a/packages/ai-harness/tests/agents.test.ts +++ b/packages/ai-harness/tests/agents.test.ts @@ -316,6 +316,42 @@ describe('agents', () => { await host.close() }) + it('gives a thread that removes agents() none of its subagents, rules, or commands', async () => { + const { adapter, calls } = mockAdapter([ + () => text('one'), + () => text('two'), + ]) + const careful: AgentProfile = { + name: 'careful', + description: 'Never writes', + mode: 'primary', + permissions: [{ tool: 'write_file', decision: 'deny' }], + } + const { host, session } = await open(adapter, [ + permissions(), + agents({ adapter: () => adapter, agents: [careful], default: 'careful' }), + ]) + + await session.send('one') + expect( + await session.configure({ + settings: { plugins: { remove: ['tanstack/agents'] } }, + }), + ).toMatchObject({ status: 'accepted' }) + await session.send('two') + + expect(toolNames(calls[0])).toEqual(['read_file', 'general', 'explore']) + // No subagent tools, and the deny rule of `careful` is gone. + expect(toolNames(calls[1])).toEqual(['read_file', 'write_file']) + expect(session.commands().map((command) => command.name)).not.toContain( + 'agent', + ) + await expect( + session.command({ name: 'agent', input: 'build' }), + ).rejects.toThrow(/Unknown command: agent/) + await host.close() + }) + it('replaces a built-in agent with one of the same name', async () => { const { adapter, calls } = mockAdapter([() => text('planned')]) const plan: AgentProfile = { diff --git a/packages/ai-harness/tests/auth-wait.test.ts b/packages/ai-harness/tests/auth-wait.test.ts index 677e887dab..cfb108d86c 100644 --- a/packages/ai-harness/tests/auth-wait.test.ts +++ b/packages/ai-harness/tests/auth-wait.test.ts @@ -494,7 +494,7 @@ describe('sign-ins in a shared thread', () => { host, harness, openAccess: { principal: bob }, - media: { secret: 'test-secret' }, + media: { secret: 'test-secret-of-at-least-32-bytes!' }, }) const session = await host.open(harness, { threadId: 't', diff --git a/packages/ai-harness/tests/discover-tools.test.ts b/packages/ai-harness/tests/discover-tools.test.ts index 69cd67fd5d..774cd64463 100644 --- a/packages/ai-harness/tests/discover-tools.test.ts +++ b/packages/ai-harness/tests/discover-tools.test.ts @@ -87,6 +87,62 @@ describe('discoverTools', () => { }) }) +describe('a tool name that two plugins give', () => { + it('keeps the tool of the earlier plugin and warns once with both names', async () => { + const finder = (name: string, reply: string) => + definePlugin({ + name, + setup: () => ({ discoverTools: () => [echoTool('search', reply)] }), + }) + const { adapter, calls } = mockAdapter([ + () => toolCall('search', {}), + () => text('found'), + () => text('again'), + ]) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open( + defineHarness({ + name: 'test/tool-clash', + adapter, + plugins: () => [ + finder('test/first', 'first'), + finder('test/second', 'second'), + ], + }), + { threadId: 't' }, + ) + const seen: Array = [] + const controller = new AbortController() + const reading = (async () => { + for await (const entry of session.events({ signal: controller.signal })) + seen.push(entry) + })() + + await session.send('search') + await session.send('search again') + + expect(JSON.stringify(calls[1].messages)).toContain('first') + expect(JSON.stringify(calls[1].messages)).not.toContain('second') + controller.abort() + await reading + const warnings = seen.filter( + (entry) => + entry.event.type === EventType.CUSTOM && + entry.event.name === 'harness.plugin.warning', + ) + expect(warnings.map((entry) => entry.event)).toMatchObject([ + { + value: { + plugin: 'test/second', + message: + 'Two tools are named "search": the tool of plugin test/first is used, and the tool of plugin test/second is not.', + }, + }, + ]) + await host.close() + }) +}) + describe('the default agent loop', () => { it('keeps calling the model past five steps', async () => { const steps = Array.from( diff --git a/packages/ai-harness/tests/http-media.test.ts b/packages/ai-harness/tests/http-media.test.ts index 0dccd1da6d..d59f65ac19 100644 --- a/packages/ai-harness/tests/http-media.test.ts +++ b/packages/ai-harness/tests/http-media.test.ts @@ -14,6 +14,10 @@ const NOW = 1_000_000 const user = { id: 'user-1' } +/** Media signing keys: each one is 32 bytes or more. */ +const SHARED = 'a-shared-secret-of-32-bytes-or-more' +const OLD_KEY = 'the-old-secret-of-32-bytes-or-more' + const allowUser = (request: Request) => request.headers.get('authorization') === 'Bearer good' ? user : null @@ -41,16 +45,25 @@ function setup(options: { media?: MediaOptions; secret?: string } = {}) { }, }) const host = hostWith(allowUser) - /** `secret` signs the media URLs. Without it, a random key per handler. */ + /** + * `secret` or `keys` sign the media URLs. Without them, a random key per + * handler. + */ const handlerWith = ( - handlerOptions: { host?: typeof host; secret?: string } = {}, + handlerOptions: { + host?: typeof host + secret?: string + keys?: Array + } = {}, ) => createHarnessHandler({ host: handlerOptions.host ?? host, harness, ...(handlerOptions.secret ? { media: { secret: handlerOptions.secret } } - : {}), + : handlerOptions.keys + ? { media: { keys: handlerOptions.keys } } + : {}), }) const handler = handlerWith({ secret: options.secret }) const call = (path: string, init: RequestInit = {}) => @@ -170,11 +183,11 @@ describe('media upload and signed URLs', () => { it('serves a signed URL on another instance whose identify refuses everyone', async () => { const { host, upload, signedPath, fetchSigned, hostWith, handlerWith } = - setup({ secret: 'shared' }) + setup({ secret: SHARED }) const record = await upload('user-1-a') const { path } = await signedPath(record) const other = hostWith(() => null) - const refuseAll = handlerWith({ host: other, secret: 'shared' }) + const refuseAll = handlerWith({ host: other, secret: SHARED }) const signed = await fetchSigned(path, refuseAll) const unsigned = await fetchSigned( @@ -248,6 +261,40 @@ describe('media upload and signed URLs', () => { await host.close() }) + it('serves a URL that an old key signed on a handler with keys [new, old]', async () => { + const { host, upload, signedPath, fetchSigned, handlerWith } = setup({ + secret: OLD_KEY, + }) + const record = await upload('user-1-a') + const { path } = await signedPath(record) + + const rotated = handlerWith({ keys: [SHARED, OLD_KEY] }) + const newOnly = handlerWith({ keys: [SHARED] }) + + expect((await fetchSigned(path, rotated)).status).toBe(200) + expect((await fetchSigned(path, newOnly)).status).toBe(403) + await host.close() + }) + + it('throws at the start for a signing key under 32 bytes', () => { + const { host, handlerWith } = setup() + + expect(() => handlerWith({ secret: 'shared' })).toThrow(/at least 32/) + void host.close() + }) + + it('refuses a signed URL of a file whose thread was deleted (404)', async () => { + const { host, upload, signedPath, fetchSigned } = setup() + const record = await upload('user-1-a') + const { path } = await signedPath(record) + + await host.sessions.delete({ threadId: 'user-1-a', principal: user }) + const response = await fetchSigned(path) + + expect(response.status).toBe(404) + await host.close() + }) + it('does not sign or serve the file of another thread (404)', async () => { const { host, call, upload } = setup() const record = await upload('user-1-a') @@ -374,12 +421,12 @@ describe('POST run with media parts', () => { label: string part: (record: MediaRecord) => ContentPart }> = [ - { label: 'a harness-media URL, and keeps the URL', part: mediaPart }, - { label: 'a data part, and keeps it as sent', part: () => data }, + { label: 'a harness-media URL', part: mediaPart }, + { label: 'a data part', part: () => data }, ] it.each(cases)( - 'gives the model the bytes of $label in the transcript', + 'gives the model the bytes of $label, and keeps a harness-media URL in the transcript', async ({ part }) => { const { host, call, upload, calls } = setup() const record = await upload('user-1-run') @@ -398,11 +445,110 @@ describe('POST run with media parts', () => { { type: 'text', content: 'What is this?' }, data, ]) + // A data part moved to the media store. Its URL names a new record. expect(transcript[0].content).toEqual([ { type: 'text', content: 'What is this?' }, - part(record), + { + type: 'image', + source: { + type: 'url', + value: expect.stringMatching(/^harness-media:/), + mimeType: 'image/png', + }, + }, ]) await host.close() }, ) + + const refused: Array<{ + label: string + media?: MediaOptions + part: ContentPart + status: number + error: string + }> = [ + { + label: 'bytes over media.maxBytes', + media: { maxBytes: 4 }, + part: data, + status: 413, + error: 'media_too_large', + }, + { + label: 'a kind not in media.kinds', + media: { kinds: ['audio'] }, + part: data, + status: 415, + error: 'media_not_allowed', + }, + { + label: 'an external URL', + part: { + type: 'image', + source: { type: 'url', value: 'https://example.com/cat.png' }, + }, + status: 400, + error: 'media_url_not_allowed', + }, + ] + + it.each(refused)( + 'refuses $label with $status, and starts no turn', + async ({ media, part, status, error }) => { + const { host, call, calls } = setup(media ? { media } : {}) + + const response = await call('run', { + method: 'POST', + body: runBody('user-1-run', [question, part]), + headers: { 'content-type': 'application/json' }, + }) + + expect(response.status).toBe(status) + expect(await response.json()).toEqual({ error }) + expect(calls).toHaveLength(0) + await host.close() + }, + ) +}) + +describe('request bodies over media.maxBodyBytes', () => { + const routes = [ + { + route: 'run', + body: runBody('user-1-run', [{ type: 'text', text: 'hi' }]), + }, + { + route: 'control', + body: JSON.stringify({ + threadId: 'user-1-run', + input: { op: 'prompt', message: 'hi' }, + }), + }, + { + route: 'sessions', + body: JSON.stringify({ + op: 'rename', + threadId: 'user-1-run', + title: 'x', + }), + }, + ] + + it.each(routes)('answers POST $route with 413', async ({ route, body }) => { + const { host, call, calls } = setup({ media: { maxBodyBytes: 64 } }) + + const response = await call(route, { + method: 'POST', + body: body.padEnd(65, ' '), + headers: { 'content-type': 'application/json' }, + }) + + expect(response.status).toBe(413) + expect(await response.json()).toEqual({ + error: 'The body is bigger than the limit of 64 bytes.', + }) + expect(calls).toHaveLength(0) + await host.close() + }) }) diff --git a/packages/ai-harness/tests/permissions.test.ts b/packages/ai-harness/tests/permissions.test.ts index eeef917e60..dd1d3ce438 100644 --- a/packages/ai-harness/tests/permissions.test.ts +++ b/packages/ai-harness/tests/permissions.test.ts @@ -12,7 +12,7 @@ import { listSavedPermissions, permissions, } from '../src/first-party/permissions' -import { mockAdapter, text, toolCall } from './helpers' +import { gate, mockAdapter, text, toolCall } from './helpers' import type { MetadataStore } from '@tanstack/ai-persistence' import type { HarnessPersistence, @@ -378,6 +378,41 @@ describe('permissions()', () => { await host.close() }) + it('keeps the mode of a turn until it ends: a change applies at the next turn', async () => { + const tools = fakeTools() + const started = gate() + const release = gate() + const { host, session } = await open( + memoryPersistence(), + [permissions({ root: ROOT }), tools.plugin], + [ + () => + (async function* () { + started.open() + await release.opened + yield* toolCall('bash', { command: 'ls' }, 'c1') + })(), + () => text('listed'), + () => toolCall('bash', { command: 'ls' }, 'c2'), + ], + ) + + const turn = session.send('list it') + await started.opened + await session.setConfig({ key: 'mode', value: 'bypass' }) + expect(session.config().mode?.value).toBe('bypass') + release.open() + // The running turn still has the default mode, so bash asks. + await answer(session, { answer: 'once' }) + await turn + + // The next turn has bypass, so bash runs with no question. + await session.send('list it again') + expect(session.snapshot().pendingQuestions).toHaveLength(0) + expect(tools.ran).toEqual(['bash', 'bash']) + await host.close() + }) + it('gives the model a tool error with the message of a reject', async () => { const tools = fakeTools() const { host, session, calls } = await open( diff --git a/packages/ai-harness/tests/run-route-hosts.test.ts b/packages/ai-harness/tests/run-route-hosts.test.ts index e0fde6b65f..d4ada632ca 100644 --- a/packages/ai-harness/tests/run-route-hosts.test.ts +++ b/packages/ai-harness/tests/run-route-hosts.test.ts @@ -48,7 +48,7 @@ function twoHosts() { const handler = createHarnessHandler({ host, harness, - media: { secret: 'shared' }, + media: { secret: 'a-shared-secret-of-32-bytes-or-more' }, }) const call = (path: string, init: RequestInit = {}) => handler(new Request(`http://h.test/api/${path}`, init)) diff --git a/packages/ai-harness/tests/tool-op.test.ts b/packages/ai-harness/tests/tool-op.test.ts index 0531fc6a3a..2f446fd784 100644 --- a/packages/ai-harness/tests/tool-op.test.ts +++ b/packages/ai-harness/tests/tool-op.test.ts @@ -5,7 +5,7 @@ import { memoryPersistence } from '@tanstack/ai-persistence' import { createHarnessHost, defineHarness } from '../src' import { applyInput, capabilitiesOf } from '../src/server' import { permissions } from '../src/first-party/permissions' -import { mockAdapter, text, toolCall } from './helpers' +import { gate, messageTexts, mockAdapter, text, toolCall } from './helpers' import type { HarnessPlugin } from '../src' import type { StreamChunk } from '@tanstack/ai' @@ -241,6 +241,49 @@ describe('{ op: "tool" }', () => { await host.close() }) + it('runs a steer sent during a tool turn as its own turn', async () => { + const started = gate() + const release = gate() + const slow = toolDefinition({ + name: 'slow_sync', + description: 'Waits for the test', + inputSchema: z.object({}), + }).server(async () => { + started.open() + await release.opened + return { synced: true } + }) + const { adapter, calls } = mockAdapter([() => text('answered')]) + const harness = defineHarness({ + name: 'test/tools-steer', + adapter, + tools: [slow], + expose: { tools: ['slow_sync'] }, + }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open(harness, { threadId: 't-tool-steer' }) + + const operation = session.tool({ name: 'slow_sync', args: {} }) + await started.opened + const receipt = await session.send('are you there?', { mode: 'steer' }) + .receipt + release.open() + await operation + + // The tool turn did not take the steer, so no model ran in it. + const toolTurn = await session.settled( + (await operation.receipt).inputId ?? '', + ) + expect(toolTurn.outcome).toBe('completed') + const steer = await session.settled(receipt.inputId) + expect(steer.outcome).toBe('completed') + expect(steer.operationId).not.toBe(operation.id) + expect(calls).toHaveLength(1) + expect(messageTexts(calls[0]).at(-1)).toBe('are you there?') + + await host.close() + }) + it('types expose.tools and the args from the harness tools', () => { expect(() => defineHarness({ diff --git a/packages/ai-harness/tests/transport-edges.test.ts b/packages/ai-harness/tests/transport-edges.test.ts index 33475fd337..d299f8097f 100644 --- a/packages/ai-harness/tests/transport-edges.test.ts +++ b/packages/ai-harness/tests/transport-edges.test.ts @@ -351,6 +351,37 @@ describe('WebSocket edges', () => { await host.close() }) + it('closes the socket on a frame over media.maxBodyBytes, and runs nothing', async () => { + const { host } = setup() + const { adapter, calls } = mockAdapter([() => text('hello')]) + const harness = defineHarness({ + name: 'test/transport', + adapter, + media: { maxBodyBytes: 200 }, + }) + const { socket, sent, closed, receive } = fakeSocket() + handleHarnessSocket({ host, harness, socket, request: upgrade }) + + receive({ type: 'harness.subscribe', threadId: 'user-1-big' }) + await vi.waitFor(() => + expect(sent.some((frame) => frame.type === 'harness.hello')).toBe(true), + ) + receive({ + type: 'harness.input', + requestId: 'p1', + input: { op: 'prompt', message: 'x'.repeat(300) }, + }) + + expect(sent.at(-1)).toEqual({ + type: 'harness.error', + message: 'The frame is bigger than the limit of 200 bytes.', + }) + expect(closed).toEqual([1009]) + expect(sent.some((frame) => frame.type === 'harness.receipt')).toBe(false) + expect(calls).toHaveLength(0) + await host.close() + }) + it('stops reading when the socket cannot send', async () => { const { host, harness } = setup() const { socket, receive } = fakeSocket({ failSend: true }) diff --git a/testing/e2e/src/routes/api.harness-protocol.$.ts b/testing/e2e/src/routes/api.harness-protocol.$.ts index 96c63dcc6f..c1766a369d 100644 --- a/testing/e2e/src/routes/api.harness-protocol.$.ts +++ b/testing/e2e/src/routes/api.harness-protocol.$.ts @@ -338,7 +338,7 @@ function handlerFor(request: Request) { harness, // Each request makes a new handler, so a fixed secret keeps a signed URL // working on the next request. - media: { secret: 'e2e-media-secret' }, + media: { secret: 'e2e-media-secret-of-32-bytes-or-more' }, }) } From 07f9377b17e5ac60103e67d0274507135e104264 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sat, 17 Oct 2026 19:46:25 +0200 Subject: [PATCH 07/15] fix(ai-harness): the media signer's key check covers an empty secret --- packages/ai-harness/src/http.ts | 5 ----- packages/ai-harness/tests/http-media.test.ts | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/ai-harness/src/http.ts b/packages/ai-harness/src/http.ts index 79d2abd293..fcc55ceb73 100644 --- a/packages/ai-harness/src/http.ts +++ b/packages/ai-harness/src/http.ts @@ -505,11 +505,6 @@ export function createHarnessHandler

( const { host, harness, media } = options const usersOf = requestUsers('createHarnessHandler', host, options.openAccess) const hasAccess = accessOf(host) !== undefined - if (media !== undefined && media.secret.length === 0) { - throw new Error( - 'createHarnessHandler needs a media.secret that is not empty.', - ) - } const openFor = (principal: P | undefined, threadId: string) => host.open(harness, { threadId, ...(principal ? { principal } : {}) }) diff --git a/packages/ai-harness/tests/http-media.test.ts b/packages/ai-harness/tests/http-media.test.ts index b3ea9852c5..816ffa4300 100644 --- a/packages/ai-harness/tests/http-media.test.ts +++ b/packages/ai-harness/tests/http-media.test.ts @@ -163,7 +163,7 @@ describe('media upload and signed URLs', () => { openAccess: true, media: { secret: '' }, }), - ).toThrow(/not empty/) + ).toThrow(/at least 32 bytes/) }) it('stores an upload, signs its URL for 1 hour, and serves the signed URL without auth', async () => { From 05159971166fb47d847effb0db2587b80c4055b6 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 18 Oct 2026 15:39:54 +0200 Subject: [PATCH 08/15] fix(ai-harness-cli): serve signs media URLs with a key derived from the token, so a short token meets the 32-byte key rule --- packages/ai-harness-cli/src/serve.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/ai-harness-cli/src/serve.ts b/packages/ai-harness-cli/src/serve.ts index a695efc495..71c58d8932 100644 --- a/packages/ai-harness-cli/src/serve.ts +++ b/packages/ai-harness-cli/src/serve.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' -import { randomBytes, timingSafeEqual } from 'node:crypto' +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' import { createHarnessHandler } from '@tanstack/ai-harness/server' import { loadOptional } from './optional' import type { IncomingMessage, ServerResponse } from 'node:http' @@ -134,8 +134,14 @@ export async function serve

( const isAllowed = (request: Request) => sameToken(request.headers.get('authorization'), options.token) const withAccess = host.hasAccess - // ponytail: the token signs media URLs, so they work as long as it does. - const media = { secret: options.token } + // ponytail: a key from the token signs media URLs, so they work as long + // as the token does. The hash gives the 32 bytes that signing needs, also + // for a short token. + const media = { + secret: createHash('sha256') + .update(`tanstack-ai-harness-cli media:${options.token}`) + .digest('hex'), + } const handler = withAccess ? createHarnessHandler({ host, harness, media }) : createHarnessHandler({ From 58b5cc6f68ba6a5adc23fb14e9f15fc6ba15cd34 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 19 Oct 2026 11:26:21 +0200 Subject: [PATCH 09/15] feat(ai-harness, ai-mcp, ai-acp, ai-code-mode, ai-harness-cli): the final review of the harness, with its fixes, one object per call, and the known limits (#1825) * fix(ai-harness, ai-mcp, ai-acp, ai-code-mode): final review fixes for durability, access, plugins, transports, integrations, and coding tools * fix(ai-acp): one agent app serves one connection, with editor capabilities read from that connection, and no private SDK field * fix(ai-harness): a thread delete ends it in first-party plugins, the bash spill is scrubbed, removed schedules go at once, agent run receipts resolve, and session.append takes one object with an optional inputId * refactor(ai-harness, ai-mcp, ai-harness-cli): session, client, and view methods take one object, the client and view use putMedia and client.sessions, and internal fields leave the public types * docs(ai-harness, ai-mcp, ai-acp, ai-code-mode): the final review changes, the fixed durable-tools and server-tools text, and a known limits section * fix(ai-harness): revoke and unshare reject when work does not stop, thread delete reaches child logs and known harnesses, plugins declare kept records, and the review fixes - host.revoke and host.sessions.unshare reject when the work stop fails (the access is gone already). unshare ends the reads before it stops the work. - createHarnessHost takes harnesses, so a delete on a new host ends the thread in its plugins. An unknown harness warns. A child thread ends in the log of its root. - A plugin declares keepRecords. The core fold reads them from the plugins of the harness that folds the log, and counts per type. snapshots() keeps its 500 steps this way. - revert and unrevert open the workspace of the thread from the log when no turn opened it on this host. - The harness.revert and harness.job records keep the principal. - append with no inputId goes through accept(), so its id is real. - A formatter config below the root, or a write under node_modules, asks again. - The base64 cache takes the largest encodedBytes. The client takes idleTimeoutMs. - An events() read listens for a revoke from the call, not the first pull. * docs(ai-harness): the harnesses option, keepRecords, idleTimeoutMs, a revoke that rejects, and one known limit less * feat(ai-harness): ctx.session.keptRecords reads the kept records of a plugin, and session.revert reads them the same way * fix(ai-harness): an always answer saves no rule for an @unsaved: action --- .changeset/ai-harness-coding-tools.md | 8 +- .changeset/ai-harness-core.md | 28 +- .changeset/ai-harness-plugins.md | 5 +- .changeset/ai-harness-transport.md | 6 +- .changeset/ai-harness-view.md | 4 +- .changeset/ai-mcp-harness.md | 6 +- .changeset/ai-sandbox-harness.md | 2 +- .changeset/code-mode-harness.md | 2 +- .changeset/harness-acp-agent.md | 2 + .changeset/harness-tool-ops.md | 2 +- docs/config.json | 32 +- docs/harness/access.md | 15 +- docs/harness/acp.md | 4 +- docs/harness/agents.md | 9 +- docs/harness/code-mode.md | 2 +- docs/harness/coding-tools-backends.md | 3 +- docs/harness/coding-tools.md | 4 +- docs/harness/coding-workspaces.md | 8 +- docs/harness/connect.md | 5 +- docs/harness/custom-ui.md | 28 +- docs/harness/deploy.md | 15 + docs/harness/durable-sessions.md | 9 +- docs/harness/durable-tools.md | 2 +- docs/harness/goal.md | 2 + docs/harness/inputs.md | 12 +- docs/harness/lifecycle.md | 26 +- docs/harness/mcp.md | 7 +- docs/harness/media.md | 5 +- docs/harness/overview.md | 2 + docs/harness/permissions.md | 4 +- docs/harness/plugins.md | 5 + docs/harness/protocol.md | 5 + docs/harness/session-log.md | 33 +- docs/harness/sessions.md | 2 +- docs/harness/shared-logs.md | 18 +- docs/harness/shared-threads.md | 6 +- docs/harness/snapshots.md | 14 +- docs/harness/subagents.md | 12 +- docs/harness/thread-settings.md | 6 +- docs/tools/server-tools.md | 2 +- examples/harness-cli/src/tui.tsx | 4 +- packages/ai-acp/src/agent/index.ts | 64 ++- packages/ai-acp/tests/agent.test.ts | 82 ++++ packages/ai-code-mode/src/harness.ts | 21 +- packages/ai-code-mode/tests/harness.test.ts | 34 ++ packages/ai-harness-cli/src/index.ts | 2 +- packages/ai-harness-cli/tests/attach.test.ts | 2 +- packages/ai-harness/src/agents.ts | 26 +- packages/ai-harness/src/client.ts | 189 ++++++--- packages/ai-harness/src/commands.ts | 30 +- packages/ai-harness/src/first-party/agents.ts | 48 ++- .../ai-harness/src/first-party/coding/bash.ts | 29 +- .../src/first-party/coding/formatter.ts | 205 ++++++++-- .../src/first-party/coding/snapshots.ts | 270 ++++++++---- .../src/first-party/coding/workspace.ts | 186 +++++++-- packages/ai-harness/src/first-party/goal.ts | 26 +- .../ai-harness/src/first-party/permissions.ts | 18 +- packages/ai-harness/src/host.ts | 275 +++++++++++-- packages/ai-harness/src/http.ts | 79 +++- packages/ai-harness/src/log.ts | 143 ++++++- packages/ai-harness/src/media.ts | 59 ++- packages/ai-harness/src/ownership.ts | 63 ++- packages/ai-harness/src/plugins.ts | 99 ++++- packages/ai-harness/src/protocol.ts | 42 +- packages/ai-harness/src/session/agents.ts | 90 +++- packages/ai-harness/src/session/base.ts | 82 +++- packages/ai-harness/src/session/handle.ts | 162 ++++++-- .../ai-harness/src/session/harness-session.ts | 41 ++ packages/ai-harness/src/session/helpers.ts | 6 +- packages/ai-harness/src/session/inputs.ts | 149 ++++++- .../src/session/observed-session.ts | 31 +- packages/ai-harness/src/session/requests.ts | 110 +++-- packages/ai-harness/src/session/state.ts | 199 ++++++--- packages/ai-harness/src/session/turn-parts.ts | 16 +- packages/ai-harness/src/session/turn.ts | 1 + packages/ai-harness/src/types.ts | 50 ++- packages/ai-harness/src/view/index.ts | 147 +++++-- packages/ai-harness/src/view/types.ts | 2 +- packages/ai-harness/tests/access-work.test.ts | 385 ++++++++++++++++++ packages/ai-harness/tests/agent-group.test.ts | 4 +- .../ai-harness/tests/agent-limits.test.ts | 4 +- .../ai-harness/tests/agent-resume.test.ts | 4 +- packages/ai-harness/tests/agent-runs.test.ts | 46 ++- packages/ai-harness/tests/agents.test.ts | 104 ++++- .../ai-harness/tests/background-tools.test.ts | 96 ++++- .../ai-harness/tests/client-media.test.ts | 12 +- .../ai-harness/tests/client-parse.test.ts | 67 ++- .../ai-harness/tests/client-reads.test.ts | 12 +- packages/ai-harness/tests/client.test.ts | 4 +- packages/ai-harness/tests/coding-bash.test.ts | 17 + .../ai-harness/tests/coding-formatter.test.ts | 120 +++++- packages/ai-harness/tests/coding-jobs.test.ts | 34 ++ .../ai-harness/tests/coding-snapshots.test.ts | 132 +++++- .../tests/durable-background.test.ts | 2 +- .../ai-harness/tests/durable-inputs.test.ts | 6 +- .../ai-harness/tests/durable-session.test.ts | 70 +++- packages/ai-harness/tests/fork.test.ts | 6 +- packages/ai-harness/tests/gap.test.ts | 54 ++- packages/ai-harness/tests/goal.test.ts | 44 +- packages/ai-harness/tests/helpers.ts | 7 +- packages/ai-harness/tests/http-access.test.ts | 44 ++ packages/ai-harness/tests/input-lane.test.ts | 163 +++++++- packages/ai-harness/tests/log-stores.test.ts | 30 +- packages/ai-harness/tests/log.test.ts | 35 +- packages/ai-harness/tests/media.test.ts | 59 +++ packages/ai-harness/tests/ownership.test.ts | 45 +- packages/ai-harness/tests/permissions.test.ts | 37 ++ .../ai-harness/tests/plugin-owners.test.ts | 59 +++ packages/ai-harness/tests/protocol.test.ts | 138 ++++++- packages/ai-harness/tests/redaction.test.ts | 52 ++- .../ai-harness/tests/resume-pending.test.ts | 21 +- packages/ai-harness/tests/retention.test.ts | 9 +- .../ai-harness/tests/session-media.test.ts | 8 +- .../ai-harness/tests/session-plugins.test.ts | 75 +++- .../ai-harness/tests/session-surface.test.ts | 21 + packages/ai-harness/tests/shared-log.test.ts | 40 +- packages/ai-harness/tests/snapshots.test.ts | 133 +++++- .../tests/subagent-children.test.ts | 17 +- .../ai-harness/tests/thread-settings.test.ts | 56 ++- packages/ai-harness/tests/transfer.test.ts | 4 +- .../ai-harness/tests/turn-control.test.ts | 8 +- .../tests/view-client-tools.test.ts | 2 +- packages/ai-harness/tests/view-media.test.ts | 2 +- packages/ai-harness/tests/view-paths.test.ts | 26 +- packages/ai-harness/tests/view.test.ts | 6 +- .../ai-harness/tests/workspace-tools.test.ts | 157 ++++++- packages/ai-mcp/src/connector.ts | 45 +- packages/ai-mcp/src/harness-plugin.ts | 57 ++- packages/ai-mcp/src/harness.ts | 20 +- .../ai-mcp/tests/connector-reconnect.test.ts | 97 +++++ packages/ai-mcp/tests/harness-elicit.test.ts | 32 +- .../ai-mcp/tests/harness-reconnect.test.ts | 18 + packages/ai-mcp/tests/harness.test.ts | 30 ++ testing/e2e/src/routes/api.coding-tools.ts | 2 +- 134 files changed, 5354 insertions(+), 868 deletions(-) create mode 100644 packages/ai-harness/tests/access-work.test.ts create mode 100644 packages/ai-mcp/tests/connector-reconnect.test.ts diff --git a/.changeset/ai-harness-coding-tools.md b/.changeset/ai-harness-coding-tools.md index 60394b4675..85f989ea92 100644 --- a/.changeset/ai-harness-coding-tools.md +++ b/.changeset/ai-harness-coding-tools.md @@ -5,15 +5,15 @@ 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`. +- `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. - 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. The file locks are shared by the sessions of a host. -- 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 job that an earlier host left running is stopped at the next tool call, with a note to the model. +- 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 job that an earlier host left running is stopped at the next tool call, with a note to the model. The wake turn of a job runs as the user who started it. 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`, and `killJob`, `binding`, and `close` are optional. 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. `purgeSnapshots()` removes the repository of a thread. 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. +- `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 d6edf3b8ae..918b251924 100644 --- a/.changeset/ai-harness-core.md +++ b/.changeset/ai-harness-core.md @@ -6,20 +6,22 @@ Add `@tanstack/ai-harness`: build an agent harness on TanStack AI. A harness kee - `defineHarness()` names an agent: its adapter, tools, system prompts, middleware, agents, subagents, routing, plugins, turn hooks, and durability options. - `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. A call on a handle after its session closed opens the session 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. `retention` (`settledOperations`, `settledAgentRuns`, `eventBytes`) bounds the settled operations, agent runs, receipts, events, and log tail that each session keeps in memory. The log does not keep the `MESSAGES_SNAPSHOT` that core sends at each interrupt (the log has the transcript): only `onEvent` listeners get it. `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. +- `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 does not keep the `MESSAGES_SNAPSHOT` that core sends at each interrupt (the log has the transcript): only `onEvent` listeners get it. `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. `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. +- `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. - 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. -- `createHarnessHost({ access })` decides who can use each session. `identify({ request })` reads the user of a request, and `authorize({ principal, action, session, run })` decides each action. The principal type comes from what `identify` returns. The host keeps each tenant apart, needs a `principal` on each call, and `host.open` returns a handle that acts only as its principal. A refused action throws `AccessDeniedError`. Without `access`, one user runs the host, with no checks. With `access`, keyed adapters and provider keys read env vars only when `access.envKeys` is `true`. -- `membersOnly()` is a ready `authorize` with the roles owner, member, and viewer. `host.sessions.share` and `host.sessions.unshare` change the members, and `host.sessions.fork` takes `members: 'self' | 'all'`. For apps with their own access tables, `host.revoke` takes the access of a user to a thread. `host.identify` reads the user of a request for your own routes. Each `host.sessions` method takes one object. +- 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`. +- `createHarnessHost({ access })` decides who can use each session. `identify({ request })` reads the user of a request, and `authorize({ principal, action, session, run })` decides each action. The principal type comes from what `identify` returns. The host keeps each tenant apart, needs a `principal` on each call, and `host.open` returns a handle that acts only as its principal. A refused action throws `AccessDeniedError`. The operations and the agent runs that a handle returns check access too. Without `access`, one user runs the host, with no checks. With `access`, keyed adapters and provider keys read env vars only when `access.envKeys` is `true`. +- `membersOnly()` is a ready `authorize` with the roles owner, member, and viewer. `host.sessions.share` and `host.sessions.unshare` change the members, and `host.sessions.fork` takes `members: 'self' | 'all'`. For apps with their own access tables, `host.revoke` takes the access of a user to a thread, and returns a `Promise`. `host.revoke` and `unshare` cancel the running and queued work of the user on every host, and remove the schedules of the user at once. When that work cannot be stopped, they reject, and the access is gone already. A running `events()` read of a handle or a view ends with `AccessDeniedError`, and later view calls throw. `host.identify` reads the user of a request for your own routes. Each `host.sessions` method takes one object. - A principal with a `tenantId` keeps its sessions in its tenant: one thread id in two tenants is two sessions, with two index entries and two logs. `logIdOf({ tenantId, logId })` gives the id of a log in the store. -- Every run has a run context with its user, and that user's credentials and provider keys: `ctx.context.run` in tools and middleware (type them with `HarnessRunContext`), `ctx.turn.run` in a turn plugin, and `run` in a command. The `ctx.credentials` and `ctx.keys` of a session plugin have no user, so each call throws. `ctx.agents.start(agent, input, { principal })` starts an agent for a user. The log keeps the sender of every input. The run context also has `epoch`, the ownership epoch of the log, and `idempotencyKey(step, scope?)`, a key for one side effect that is the same when the run runs again on any host. -- `definePlugin()` adds tools, prompts, middleware, agents, commands, config options, state, and extension points to a session or to one turn. With `lifetime: 'host'`, the host sets a plugin up once per harness and shares it with every session. It is cleaned up when the last session that uses it closes, or at `host.close()`. A prompt with `placement: 'context'` goes in one user message after the history, at each model call, so text that changes often (the todo list, the goal status, plan mode) keeps the provider prompt cache. The default `placement: 'system'` keeps a prompt in the system prompts. +- Every run has a run context with its user, and that user's credentials and provider keys: `ctx.context.run` in tools and middleware (type them with `HarnessRunContext`), `ctx.turn.run` in a turn plugin, and `run` in a command. The `ctx.credentials` and `ctx.keys` of a session plugin have no user, so each call throws. `ctx.agents.start(agent, input, { principal })` starts an agent for a user. `AgentRun.send` takes `principal?`, and the `send` and `cancel` of a run act as the user of the run. `session.agents..run` honors `principal`. The log keeps the sender of every input. The run context also has `epoch`, the ownership epoch of the log, and `idempotencyKey(step, scope?)`, a key for one side effect that is the same when the run runs again on any host. +- `definePlugin()` adds tools, prompts, middleware, agents, commands, config options, state, and extension points to a session or to one turn. With `lifetime: 'host'`, the host sets a plugin up once per harness and shares it with every session. It is cleaned up when the last session that uses it closes, or at `host.close()`. A prompt with `placement: 'context'` goes in one user message after the history, at each model call, so text that changes often (the todo list, the goal status, plan mode) keeps the provider prompt cache. The default `placement: 'system'` keeps a prompt in the system prompts. `prepareTools` gets `run`. A plugin's `ctx.session.send` and `note` take `principal?`, and default to the session owner. A thread that removes a plugin cannot run the agents of that plugin by name. - `routing.router` sends a turn to a root agent. The log keeps the active agent, and the router reads it as `activeAgent`. A router that returns `undefined` gives the turn to the active agent, else to the main model. One agent name makes that agent active, `'main'` clears it, and `{ handoff, note }` is a handoff from the router. `routing.after: 'end' | 'main'` (default `'end'`) says whether the main model answers after the routed agents. - Handoffs move a conversation to another root agent, and the target answers in the same turn. With `routing`, the main model and each root agent get a `handoff` tool. Code hands off with `ctx.harness.handoff({ to, note })` in a root agent, and with `ctx.context.run.handoff(...)` (`RunContext.handoff`) in a tool of the main model. Each handoff writes a `harness.handoff` record and streams a `harness.handoff` event (`from`, `to`, `note`, `by`, `runId`). A message that an agent writes has `metadata.harness.agent`. A turn hands off 10 times at most. An app tool named `handoff` fails the turn. - With `routing.humans: true`, a handoff to `{ human: true }` opens a `human` wait with no default expiry, and the turn ends `interrupted`. User messages during the pause go in the transcript, and no model answers them. `session.send(text, { as: 'operator' })` adds an operator reply. It needs the `operator` access action, and it is rejected with `no_human_wait` when no handoff to a person is open. The operator ends the pause with an answer to the `human` wait: `{ resume: true }` or `{ handBack: '' | 'main' }`. - `host.transfer({ from, to, envelope, principal, transferId })` moves a conversation to another thread, also of another harness, in the same tenant. Only the envelope moves: a note, then no message, a summary that the source model writes, or the settled transcript with its media. The principal needs `transfer` on the source and `send` on a target that exists. A new target has the principal as its only owner. Both logs get a `harness.transferred` record, the index entries link the threads in `metadata`, `host.events()` yields a `transferred` event, and a retry with the same `transferId` is safe. A fork stays in its harness: `host.fork` with a thread of another harness throws. -- Agents run from code with `session.agents..run()` and `.start()`. In agent code, `ctx.harness` gives `step.do()` (a finished step returns its stored value when the run runs again), `step.sleep({ name, ms })`, `agents`, `state`, and `handoff` (in a root agent). +- Agents run from code with `session.agents..run()` and `.start()`. `agentRun.receipt` resolves. In agent code, `ctx.harness` gives `step.do()` (a finished step returns its stored value when the run runs again), `step.sleep({ name, ms })`, `agents`, `state`, and `handoff` (in a root agent). - `ctx.harness.agents` in agent code and `ctx.agents` in a plugin have one type, `AgentActions`: `start(agent, input, options?)`, `run(agent, input, options?)`, and `group({ concurrency, onFailure, key }, (g) => [g.start(...)])`. The input type comes from the input schema of the agent, and the result type from its output. `group` resolves to the results in order. With `onFailure: 'collect'`, each entry is an `AgentSettled` (`{ ok, value }` or `{ ok, error }`). The children of a group over its `concurrency` wait as `queued`. - Each start takes `key`, `limits: { timeoutMs, maxAttempts }`, `budget: { maxTokens, maxCost }`, and `onModelError`. A run over its budget fails with the code `budget`, after the first model call over it. A run past `limits.timeoutMs` fails with the code `timeout`. A child never has more budget than its parent has left. `onModelError` is not in the log, so a run that another host takes over runs without it. - The log keeps every run of a session as a `RunNode` of one run tree (`turn`, `agent`, `tool`, or `command`), with its parent, its user, its limits, its budget, and its result. `agentRuns()` reads the tree, and `AgentRunInfo` has `runId`, `kind`, `budget`, `result`, and `error`. Each `harness.usage` record has the `runId` of its run, and an agent run that ends keeps the usage of the run and its children. @@ -29,10 +31,12 @@ 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. 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. -- 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`. -- `host.submit` stores an input for a thread and runs nothing. `session.schedule({ at, input, scheduleId, principal })` runs an input later, and `session.cancelSchedule({ scheduleId })` stops it. `createHarnessWorker({ host, harnesses, concurrency, signal, pollMs })` runs the threads that have work and no live host, and returns `{ stop }`. `host.resumePending` does one sweep. -- `durableTool()` runs side effects once. Its `step.do` passes `{ idempotencyKey, epoch }` to `fn`. `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. `step.sleep({ name, ms })` waits in the process, and the log keeps the wake time, so a worker continues the call after a stop. `durability.truncatedToolResult` sets the text of a tool call cut at the output limit. Without it, a cut answer ends the turn. +- 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. +- 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)`. +- `host.submit` stores an input for a thread and runs nothing. `session.schedule({ at, input, scheduleId, principal })` runs an input later, and `session.cancelSchedule({ scheduleId })` stops it. `createHarnessWorker({ host, harnesses, concurrency, signal, pollMs })` runs the threads that have work and no live host, and returns `{ stop }`. `host.resumePending` does one sweep. A swept thread that fails to open goes back with a wake in 30 s. +- `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. `step.sleep({ name, ms })` waits in the process, and the log keeps the wake time, so a worker continues the call after a stop. `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, 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 of a stopped turn and of a background agent run. `answer` answers the waits of `pendingQuestions` and a `human` wait that no turn holds. `host.submit` checks an answer against the wait that it answers. @@ -41,7 +45,7 @@ Add `@tanstack/ai-harness`: build an agent harness on TanStack AI. A harness kee - The `operator` access action answers a `human` wait of any run. `membersOnly()` gives it to the owner and to members. `authorize` gets the `wait` (`waitId`, `kind`) of an `answer` or `operator` action. - `session.settled({ inputId, signal })` takes one object. `signal` stops the wait for the settlement. - `session.onQuestion`, `session.onAuthRequired`, `session.onMedia`, and `session.onUsage` call a listener for the events that a user answers or sees, with a typed value. -- Sessions also have thread settings, fork and revert, usage totals, media files, the transcript of each child agent (`session.agentTranscript`), and `harnessText()` / `harnessAgent()` to use a harness as a text adapter or an agent. In a session, a `harnessAgent` child is a child run in the log of the root, with the user, keys, limits, and budget of the run that calls it. Its usage and its waits show in the parent session. +- Sessions also have thread settings, fork and revert, usage totals, media files, the transcript of each child agent (`session.agentTranscript`), and `harnessText()` / `harnessAgent()` to use a harness as a text adapter or an agent. In a session, a `harnessAgent` child is a child run in the log of the root, with the user, keys, limits, and budget of the run that calls it. Its usage and its waits show in the parent session. The base64 cache of media files is shared by all the sessions of a host. - `harnessText()` uses the principal of each call. An abort on a remote harness sends a cancel. The usage of the inner run reaches the outer `RUN_FINISHED`. Its SSE reader takes multi-line `data:` and CRLF. `harnessText()` and `harnessAgent()` forward events on one shared path. - `createHarnessHost({ observability: { tracer, onOperation } })` traces runs. `tracer` takes an OpenTelemetry tracer as it is: `HarnessTracer`, `HarnessSpan`, `HarnessSpanContext`, and `HarnessContext` are structural types, with no dependency on `@opentelemetry/api`. The host writes `harness.operation` spans (turns, agent runs, commands), and `harness.recovery`, `harness.wait`, and `harness.transfer` spans. A child run on the same host is a child span: the host passes a `HarnessContext` as the third argument of `startSpan`. `SendOptions.traceparent` stores a W3C `traceparent` with the input, and the run span on the host that runs it gets a link to it. - `onOperation` gets a `started` report and a `settled` report (`OperationReport`) for each operation that this host runs, with `status`, `durationMs`, `error`, and `usage`. A listener that throws gets a `harness.plugin.warning` event, and the work goes on. diff --git a/.changeset/ai-harness-plugins.md b/.changeset/ai-harness-plugins.md index 7af9d713d4..0c2ca1a70d 100644 --- a/.changeset/ai-harness-plugins.md +++ b/.changeset/ai-harness-plugins.md @@ -4,10 +4,11 @@ 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. -- `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. +- `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. diff --git a/.changeset/ai-harness-transport.md b/.changeset/ai-harness-transport.md index e335e751d6..8b7c395d9f 100644 --- a/.changeset/ai-harness-transport.md +++ b/.changeset/ai-harness-transport.md @@ -5,9 +5,11 @@ 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 that falls more than 1 000 events 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. 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. +- `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. Pass `from: '0'` to read from the start. The `prompt` input of the protocol and `client.send` take `as: 'operator'` for the reply of an operator after a handoff to a person. +- `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. Pass `from: '0'` to read from the start. `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`, `append`, and `putMedia`. `client.sessions.list`, `rename`, `delete`, `update`, and `fork` manage the sessions. - `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`, `operationId`, and `parentRunId` for a child run. A client can rebuild the run tree from these tags. diff --git a/.changeset/ai-harness-view.md b/.changeset/ai-harness-view.md index ba9775ccd7..ffeeb53dfb 100644 --- a/.changeset/ai-harness-view.md +++ b/.changeset/ai-harness-view.md @@ -6,9 +6,9 @@ Add `createSessionView()` in `@tanstack/ai-harness/view`. It turns a session (lo - 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`, `continue`, `upload`, `cancel`, `cancelInput`, `setDelivery`, `configure`, `reset`, `revert`, `loadAgent`, and the answers to approvals (with edited arguments), client tools, interrupts, and questions. +- Actions: `send` (a prompt, a steer, or a `/command`), `followUp`, `continue`, `putMedia`, `cancel`, `cancelInput`, `setDelivery`, `configure`, `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 loads the snapshot and the transcript again, then goes on from the new cursor. +- 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-mcp-harness.md b/.changeset/ai-mcp-harness.md index 40884948d7..25e544c5b2 100644 --- a/.changeset/ai-mcp-harness.md +++ b/.changeset/ai-mcp-harness.md @@ -5,10 +5,10 @@ Use MCP in a TanStack AI harness. - `@tanstack/ai-mcp/harness` adds `createHarnessMcpServer`. It serves a harness as an MCP server, so Claude Code, Claude Desktop, Cursor, or another agent can use it. The tools are `chat`, `steer`, `cancel`, `approve`, `reject`, `resolve`, `answer`, and `status`, plus `agent_` for each exposed agent and `command_` for each exposed command. Every tool takes an optional `threadId`. With `approvals: 'ask'` (the default), the client asks the user about each approval when it supports elicitation. Otherwise the approvals come back in the result. `approvals: 'auto'` approves every tool call. `resolve` answers every interrupt of a stopped turn in one call, or the waits of a background agent run. Every result lists the open waits of its work in `waits`, each with a `kind` from `waitKind` (`approval`, `question`, `sign-in`, `client-tool`, or `human`). The `status` tool lists every wait, and each background agent with `status: 'running'` or `'waiting'`. -- On a host with `access`, `createHarnessMcpServer` reads the user of each HTTP request with `identify`. The host checks each tool call of that user. A host without `access` needs `openAccess: true` (no user) or `openAccess: { principal }` (one local user), else the server throws at the start. Each tool call releases its session handle when it ends, so the session closes on idle. The `status` tool and the media reads read the thread with `host.observe`, so they set up no plugins. +- On a host with `access`, `createHarnessMcpServer` reads the user of each HTTP request with `identify`. The host checks each tool call of that user. A host without `access` needs `openAccess: true` (no user) or `openAccess: { principal }` (one local user), else the server throws at the start. Each tool call releases its session handle when it ends, so the session closes on idle. The `status` tool and the media reads read the thread with `host.observe`, so they set up no plugins. The server forgets a wait when its operation settles. - `@tanstack/ai-mcp/harness` also adds the `mcp()` harness plugin. It connects stdio and HTTP servers by name, gives the model their tools as `_`, and adds a `/mcp` status command. A stdio server starts once per host, and every session of the host shares it. `oauth: true` signs in to a server with `/connect `, for each user. -- `mcp()` connects again with backoff when a server closes, and lists the tools again on `list_changed`. Each server takes `needsApproval` and `toolName`. When two servers give a tool with the same name, `mcp()` warns once. +- `mcp()` connects again with backoff when a server closes, and lists the tools again on `list_changed`. A server that fails its first connect also tries again with backoff. `mcpConnector` and `oauth: true` servers connect again at the next turn, and list their tools again. Each server takes `needsApproval` and `toolName`. When two servers give a tool with the same name, `mcp()` warns once. - `@tanstack/ai-mcp/connector` adds `mcpConnector`: one OAuth MCP server as a harness plugin, with `needsApproval`, `prefix`, `scopes`, and `clientName`. The sign-in passes the `iss` of the callback on (RFC 9207). A cancel or a session close stops a running sign-in at once: the loopback stops listening, and the OAuth requests abort. When the MCP SDK refreshes a token, the connector saves it with `credentials.refresh` of the harness: to the scope of the sign-in, and with compare-and-set when the store has it. Each session still sends its own refresh request. When the server refuses a token, the connector deletes the saved sign-in only if it is the token that this session used. -- `mcp()` and `mcpConnector` answer the `elicitation/create` requests of MCP spec 2025 servers, and the input requests of spec 2026-07-28 servers, as session questions. One tool call can ask up to 5 times. Each question is a live wait of the run that made the tool call: it ends `cancelled` when that run stops. `mcp()` tracks the live questions for each server, so each question goes to the run that called the tool. +- `mcp()` and `mcpConnector` answer the `elicitation/create` requests of MCP spec 2025 servers, and the input requests of spec 2026-07-28 servers, as session questions. One tool call can ask up to 5 times. Each question is a live wait of the run that made the tool call: it ends `cancelled` when that run stops. `mcp()` tracks the live questions for each server, so each question goes to the run that called the tool. A spec 2025 request goes to the user only when all the running calls of that server belong to one run. Else the server gets `decline`. `@tanstack/ai-harness` is an optional peer dependency. Only the `./harness` and `./connector` entries need it. diff --git a/.changeset/ai-sandbox-harness.md b/.changeset/ai-sandbox-harness.md index 89b9f4e848..ea295c6dcf 100644 --- a/.changeset/ai-sandbox-harness.md +++ b/.changeset/ai-sandbox-harness.md @@ -6,6 +6,6 @@ Add `@tanstack/ai-sandbox/harness` for `@tanstack/ai-harness`: - `codingAgents({ sandbox, agents, workspace? })` is a harness plugin. The lead model gets one tool per coding agent (Claude Code, Codex, Grok Build, or any ACP agent). Each agent works in the sandbox, keeps its own session per thread (also after a restart), and starts read-only in the `plan` mode of `permissions()`. `/fresh [agent]` starts a new session. `workspace: 'shared'` (default) runs the agents of a thread one at a time in one sandbox. `'per-agent'` gives each agent its own sandbox. - `codingAgents` runs agents only on the host that owns the log of the thread, so `'shared'` runs one agent at a time also with many hosts. -- `sandboxWorkspaceBackend(sandbox, { owned? })` runs the coding tools of `@tanstack/ai-harness/plugins/coding` (`workspaceTools()`, `snapshots()`, `formatter()`) in a sandbox. It resolves links in the sandbox, reads one page of a file in the sandbox, and stops background jobs by their id, also from another host. `binding()` gives the provider and the sandbox id, so another host can resume the sandbox. `close()` stops the jobs of the backend, and with `owned: true` destroys the sandbox when the session closes. +- `sandboxWorkspaceBackend(sandbox, { owned? })` runs the coding tools of `@tanstack/ai-harness/plugins/coding` (`workspaceTools()`, `snapshots()`, `formatter()`) in a sandbox. It resolves links in the sandbox, reads one page of a file in the sandbox, and stops background jobs by their id, also from another host. `binding()` gives the provider and the sandbox id, so another host can resume the sandbox. `close()` stops the jobs of the backend. A session close closes it with `'moved'`, so an owned sandbox stays for the next host. `host.sessions.delete` closes it with `'closed'`, and with `owned: true` that destroys the sandbox. `@tanstack/ai-harness` is an optional peer dependency. diff --git a/.changeset/code-mode-harness.md b/.changeset/code-mode-harness.md index 6bcc9a3666..c9c649240c 100644 --- a/.changeset/code-mode-harness.md +++ b/.changeset/code-mode-harness.md @@ -2,4 +2,4 @@ '@tanstack/ai-code-mode': minor --- -Add `@tanstack/ai-code-mode/harness` with `codeMode({ driver })`: a harness plugin that gives the model one `execute_typescript` tool. The model writes a program that calls several tools, and the program runs in the isolate of any `@tanstack/ai-isolate-*` driver. By default, only tools that are safe to run without a question move into code mode: server tools with no `needsApproval` that the permission rules allow in plan mode, and never a tool that declares permission resources. `include` picks the tools yourself, but it never moves a tool that needs approval or that the rules deny or ask about, a safe tool with `metadata.codeMode: true` always moves, and `lazy: true` lists only the tool names and adds `discover_tools`. Tool names that are not JavaScript identifiers get a safe name inside the program. `@tanstack/ai-harness` is an optional peer dependency. +Add `@tanstack/ai-code-mode/harness` with `codeMode({ driver })`: a harness plugin that gives the model one `execute_typescript` tool. The model writes a program that calls several tools, and the program runs in the isolate of any `@tanstack/ai-isolate-*` driver. By default, only tools that are safe to run without a question move into code mode: server tools with no `needsApproval` that the permission rules allow in plan mode, and never a tool that declares permission resources. `include` picks the tools yourself, but it never moves a tool that needs approval or that the rules deny or ask about, a safe tool with `metadata.codeMode: true` always moves, and `lazy: true` lists only the tool names and adds `discover_tools`. Tool names that are not JavaScript identifiers get a safe name inside the program. The rules decide in the permission mode of the session, again at each turn, also for `include`. `@tanstack/ai-harness` is an optional peer dependency. diff --git a/.changeset/harness-acp-agent.md b/.changeset/harness-acp-agent.md index dd56551d0f..203b25a676 100644 --- a/.changeset/harness-acp-agent.md +++ b/.changeset/harness-acp-agent.md @@ -3,3 +3,5 @@ --- `@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, 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. diff --git a/.changeset/harness-tool-ops.md b/.changeset/harness-tool-ops.md index 5dbfbc10bd..4e0078cb69 100644 --- a/.changeset/harness-tool-ops.md +++ b/.changeset/harness-tool-ops.md @@ -20,7 +20,7 @@ Run a harness tool with no model turn, add host records from a client, and add a 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.append(records)`. + `{ op: 'append', records }` input, or with the new `client.append({ 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 diff --git a/docs/config.json b/docs/config.json index 87f9cdc21c..59f674170d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -927,30 +927,31 @@ "label": "Build your first harness", "to": "harness/overview", "addedAt": "2026-10-13", - "updatedAt": "2026-10-17" + "updatedAt": "2026-10-18" }, { "label": "Send and show media", "to": "harness/media", "addedAt": "2026-10-13", - "updatedAt": "2026-10-17" + "updatedAt": "2026-10-18" }, { "label": "Connect clients", "to": "harness/connect", "addedAt": "2026-10-13", - "updatedAt": "2026-10-18" + "updatedAt": "2026-10-19" }, { "label": "Write a client in any language", "to": "harness/protocol", - "addedAt": "2026-10-17" + "addedAt": "2026-10-17", + "updatedAt": "2026-10-19" }, { "label": "Access and tenants", "to": "harness/access", "addedAt": "2026-10-17", - "updatedAt": "2026-10-18" + "updatedAt": "2026-10-19" }, { "label": "Run in the terminal", @@ -962,7 +963,7 @@ "label": "Deploy a harness", "to": "harness/deploy", "addedAt": "2026-10-13", - "updatedAt": "2026-10-18" + "updatedAt": "2026-10-19" }, { "label": "Send inputs safely", @@ -995,7 +996,8 @@ { "label": "Lifecycle and retention", "to": "harness/lifecycle", - "addedAt": "2026-10-17" + "addedAt": "2026-10-17", + "updatedAt": "2026-10-19" }, { "label": "Run side effects once", @@ -1012,7 +1014,7 @@ "label": "Build on the session log", "to": "harness/session-log", "addedAt": "2026-10-13", - "updatedAt": "2026-10-18" + "updatedAt": "2026-10-19" }, { "label": "Control how a turn ends", @@ -1024,7 +1026,7 @@ "label": "Store settings per thread", "to": "harness/thread-settings", "addedAt": "2026-10-13", - "updatedAt": "2026-10-17" + "updatedAt": "2026-10-18" }, { "label": "Compact a long session", @@ -1048,7 +1050,7 @@ "label": "Write a plugin", "to": "harness/plugins", "addedAt": "2026-10-13", - "updatedAt": "2026-10-18" + "updatedAt": "2026-10-19" }, { "label": "Track usage and cost", @@ -1077,13 +1079,14 @@ { "label": "Run the coding tools for many threads", "to": "harness/coding-workspaces", - "addedAt": "2026-10-18" + "addedAt": "2026-10-18", + "updatedAt": "2026-10-19" }, { "label": "Undo a turn", "to": "harness/snapshots", "addedAt": "2026-10-13", - "updatedAt": "2026-10-18" + "updatedAt": "2026-10-19" }, { "label": "Build a coding agent", @@ -1131,12 +1134,13 @@ "label": "Code mode in a harness", "to": "harness/code-mode", "addedAt": "2026-10-13", - "updatedAt": "2026-10-17" + "updatedAt": "2026-10-18" }, { "label": "Work until a goal is met", "to": "harness/goal", - "addedAt": "2026-10-13" + "addedAt": "2026-10-13", + "updatedAt": "2026-10-18" }, { "label": "Run agents from a harness", diff --git a/docs/harness/access.md b/docs/harness/access.md index 4d433dd1fd..c58ec2be33 100644 --- a/docs/harness/access.md +++ b/docs/harness/access.md @@ -106,7 +106,7 @@ Each action covers these calls: - For `answer` and `operator`, `authorize` also gets `wait`: the `waitId` and the `kind` of the [wait](./waits) that the input answers, when the host knows it. A `human` wait takes either action: when `answer` is refused, the host asks for `operator`. - For `cancelSchedule`, `run` is the schedule: the `runId` is the schedule id, and the `principalId` is the user who made it. - `host.submit` and `schedule` check an answer or a cancel when you send it, with `run` from the log. A run that the log does not have is refused with `unknown_run`: `submit` returns a `rejected` receipt, and `schedule` throws `InputRejectedError`. -- A handle checks `read` once, when it opens. Each action that changes the session asks `authorize` again. +- A handle checks `read` once, when it opens. Each action that changes the session asks `authorize` again. This includes `cancel` and `send` on an operation or an agent run that the handle returns. ## What the host checks for you @@ -181,19 +181,22 @@ export const host = createHarnessHost({ }, }) -export function removeFromTeam(threadId: string, user: { id: string; tenantId: string }) { +export async function removeFromTeam(threadId: string, user: { id: string; tenantId: string }) { teams.get(threadId)?.delete(user.id) - host.revoke({ threadId, principal: user }) + await host.revoke({ threadId, principal: user }) } ``` -After `host.revoke`: +When the promise of `host.revoke` resolves: -- The streams of the user on the thread close. A socket gets a `forbidden` error frame. -- The open handles of the user on the thread throw `AccessDeniedError`. A new `host.open` asks `authorize` again. +- The running and queued work of the user on the thread is cancelled, on every host. The schedules of the user on the thread are removed. +- The streams of the user on the thread close. A socket gets a `forbidden` error frame. A running `events()` read of a handle or a view ends with `AccessDeniedError`. +- The open handles and views of the user on the thread throw `AccessDeniedError`. A new `host.open` asks `authorize` again. - The signed media URLs of the user stop working. A signed URL names its user, and the handler checks that user again when it serves the file. - `host.events()` yields a `revoked` event. +When the cancels cannot be sent (for example, the log store fails), the promise rejects. The access is gone already. Call `host.revoke` again to stop the work. `host.sessions.unshare` rejects the same way, after it removed the member. + `host.sessions.share` and `unshare` work only with `membersOnly()`. With your own tables, change the tables, then call `host.revoke`. ## Serve with no login diff --git a/docs/harness/acp.md b/docs/harness/acp.md index 15f717216b..8b9574b571 100644 --- a/docs/harness/acp.md +++ b/docs/harness/acp.md @@ -74,7 +74,7 @@ The agent sends each wait to the editor by its kind: | Kind | What the editor shows | | --- | --- | -| `approval` | A permission request. **Allow always** shows only for an approval of `permissions()`, because its answer schema takes `always`. | +| `approval` | A permission request with the id of the real tool call. **Allow always** shows only for an approval of `permissions()`, because its answer schema takes `always`. | | `question`, `human` | A form elicitation of the answer schema. | | `sign-in` | A URL elicitation with the sign-in page. | | `client-tool` | Nothing. The editor cannot run it, so the agent cancels it. | @@ -109,6 +109,8 @@ import { createAcpAgent } from '@tanstack/ai-acp/agent' export const app = createAcpAgent({ host, harness: assistant }) ``` +One agent app serves one connection. It reads the editor capabilities from that connection, and a second connection to it fails with an error. To serve more editors, for example over HTTP, make one app for each connection. With the `AcpServer` of `@agentclientprotocol/sdk/experimental/server`, pass `createAgent: () => createAcpAgent({ host, harness: assistant })`. + ## What you have now - Your harness as an agent in any ACP v2 editor. diff --git a/docs/harness/agents.md b/docs/harness/agents.md index 2b7f91ca0e..2d4e140054 100644 --- a/docs/harness/agents.md +++ b/docs/harness/agents.md @@ -143,7 +143,7 @@ export const withReview = agents({ | Field | What it does | | --- | --- | -| `model` | A model id. `adapter` turns it into an adapter. Without it, a primary agent keeps the harness model, and a subagent uses the model of the main turn. | +| `model` | A model id. `adapter` turns it into an adapter. Without it, a primary agent keeps the harness model, and a subagent uses the model of the turn that started it. | | `system` | Text for the system prompt of each turn of this agent. | | `tools` | The tools that this agent can call, by name. A `*` matches any text, for example `todo_*`. Without it, the agent gets every tool. | | `permissions` | [Permission rules](./permissions#write-rules) that count while this agent is the primary agent. At that time, they also apply in each subagent run. | @@ -208,9 +208,9 @@ A profile with the mode `subagent` or `all` is also a subagent. The main model g A subagent run gets these parts of its profile: -- The tools of the last main turn, filtered by its `tools`. +- The tools of the turn that started it, filtered by its `tools`. - Its `system` prompt. -- Its `model`, or the model of the main turn. +- Its `model`, or the model of the turn that started it. - Its step limit. A subagent run stops after 50 model calls, or when an answer is cut at the output limit. The harness also limits the depth and the number of children. See [Stay within limits](./subagents#stay-within-limits). To give one run from code its own budget and time limit, see [Give one run a budget and a time limit](./subagents#give-one-run-a-budget-and-a-time-limit). @@ -220,7 +220,8 @@ A subagent run stops after 50 model calls, or when an answer is cut at the outpu - A subagent run uses the `permissions` of the primary agent, not the rules of its own profile. For example, when `build` is the primary agent and starts `review`, the `build` rules apply and the `review` rules do not. Put rules that must hold in every run in `permissions({ rules })`. See [Rules in subagents](./permissions#rules-in-subagents). - A Markdown agent cannot set `permissions`. Put those rules in a profile in code. - On Amazon Bedrock, the model can still call a tool on the last call. The tool does not run, but the turn can end with no text answer. -- A subagent that starts before the first turn of the session has no tools. It also needs its own `model`. +- A subagent run gets the tools and the model of its turn from the memory of the host that ran that turn. On another host, or after a restart, the run fails with `Agent cannot go on here`. Start it again from a turn. +- A subagent run that your code starts, not a turn, has no tools of a turn. Give its profile a `model`. ## What you have now diff --git a/docs/harness/code-mode.md b/docs/harness/code-mode.md index 7c7d60d3b0..b6a66b42ed 100644 --- a/docs/harness/code-mode.md +++ b/docs/harness/code-mode.md @@ -99,7 +99,7 @@ export const onlyIssues = codeMode({ }) ``` -- `include` never moves a tool with `needsApproval`, or a tool that an `ask` or `deny` rule matches. That tool stays a normal tool call with its approval, and code mode warns once. +- `include` never moves a tool with `needsApproval`, or a tool that an `ask` or `deny` rule matches. That tool stays a normal tool call with its approval, and code mode warns once. The rules decide in the permission mode of the session, again at each turn. So in `plan` mode, a tool that edits stays a normal tool call. - Any other tool that `include` picks moves, also when a rule asks for it. Its calls inside the program do not ask, so pick only tools that are safe to run without a question. - With `include`, a call of `execute_typescript` gets the permission rules like any tool, for example a question with `default: 'ask'`. - A safe tool with `metadata.codeMode: true` moves even when `include` does not pick it. diff --git a/docs/harness/coding-tools-backends.md b/docs/harness/coding-tools-backends.md index c68126ed95..ca6b849f48 100644 --- a/docs/harness/coding-tools-backends.md +++ b/docs/harness/coding-tools-backends.md @@ -225,6 +225,7 @@ export const withToml = formatter({ - `builtins: false` turns the built-in formatters off. - `timeoutMs` limits each run. The default is 20 seconds. - In a sandbox or on your own backend, give `formatter()` the same `backend` as `workspaceTools()`. +- With [`workspaceTools({ workspace: { bind } })`](./coding-workspaces) before it, `formatter()` runs in the workspace of each thread. Leave out `root` and `backend`. A formatter that fails, stops at the time limit, or is not installed does not fail the tool call. The file stays as the tool wrote it, and the plugin sends `FormatFailed`. A formatter that changes the file sends `FormatterRewrote` with `{ path, formatter }`: @@ -251,7 +252,7 @@ The rules decide before the call runs, and the formatter runs after the write. T - When the formatter asks, the call asks one time for the write and the formatter, also in `acceptEdits` mode. Answer that [approval](./permissions#answer-an-approval) with `resolve`. - A `reject` stops the call. Nothing is written, and the formatter does not run. -- Answer `always` once, and the formatter runs without a question in this project. +- Answer `always` once, and the formatter runs without a question in this project. The answer is tied to a hash of the formatter config files, like `prettier.config.js` and `package.json`. When they change, the next write asks again. - A rule that denies the formatter keeps the write. The tool result says that the formatter did not run. - A rule can allow a formatter: `{ tool: 'formatter:prettier', decision: 'allow' }`. Without `permissions()`, the formatter runs without a question. diff --git a/docs/harness/coding-tools.md b/docs/harness/coding-tools.md index 0aa89709ae..a00faa5463 100644 --- a/docs/harness/coding-tools.md +++ b/docs/harness/coding-tools.md @@ -173,10 +173,10 @@ For `bash`: - The model gets the exit code and the last 1000 lines (20 KiB) of the output. - A command stops after 2 minutes, with exit code 124. Every process that it started stops too. Set another limit with `bashTimeoutMs`. The model can ask for up to 10 minutes. -- With `spillDir`, long output goes to a file in that folder, and the model gets the path. `spillDir` is relative to `root`, or absolute. +- With `spillDir`, long output goes to a file in that folder, and the model gets the path. `spillDir` is relative to `root`, or absolute. The file has `[redacted]` in place of each secret of the session, the same as the tool result. See [Keep tokens out of the log and the model](./auth#keep-tokens-out-of-the-log-and-the-model). - A command gets only a few variables of your process, like `PATH` and `HOME`. Your provider keys stay out of it. See [Keep server secrets out of commands](./coding-workspaces#2-keep-server-secrets-out-of-commands). -With `background: true`, the call returns at once with a job id. When the job ends, the model gets a note with the output, and a new turn starts. +With `background: true`, the call returns at once with a job id. When the job ends, the model gets a note with the output, and a new turn starts. That turn runs as the user whose run started the job. - The time limit does not apply to a background job. - At most 8 background jobs run at a time in a session. Set another limit with `maxJobs`. Each job keeps the newest 1 MiB of its output. diff --git a/docs/harness/coding-workspaces.md b/docs/harness/coding-workspaces.md index 3c78d2565f..7fb9eba82f 100644 --- a/docs/harness/coding-workspaces.md +++ b/docs/harness/coding-workspaces.md @@ -51,8 +51,9 @@ export const coder = defineHarness({ - The `root` that `bind` gives is the boundary of every tool. A path outside it is refused. With `outside: 'ask'`, a rule that allows the path, or `bypass` mode, lets a call use it. No approval can cover it, because the root is not known before the call. The `cwd` thread setting must stay inside it. - The tools resolve every link with `realpath` before they read or write, while they hold the lock on the file. So a link in one thread cannot lead into the folder of another thread. - The session log keeps a `harness.workspace.bound` record with the place of the workspace. [Move a thread to another server](#5-move-a-thread-to-another-server) uses it. +- Put `workspaceTools()` before [`snapshots()`](./snapshots) and [`formatter()`](./coding-tools-backends#format-each-file-after-a-write). Then they work in the workspace of each thread, and their `root` is optional. -For a sandbox per thread, create it in `bind`, and give the backend `owned: true`. Then the sandbox is destroyed when the session closes: +For a sandbox per thread, create it in `bind`, and give the backend `owned: true`. Then the thread owns the sandbox: ```ts group=harness-coding-workspaces import { sandboxWorkspaceBackend } from '@tanstack/ai-sandbox/harness' @@ -70,6 +71,9 @@ export const sandboxed = workspaceTools({ }) ``` +- A session close (an idle close, a deploy, or a move to another server) closes the backend with `'moved'`. The sandbox stays, so the next server can [reattach](#a-sandbox) it. +- [`host.sessions.delete`](./lifecycle#delete-a-thread-for-good) ends the thread. The host reattaches the sandbox and closes it with `'closed'`, so it is destroyed. The host that deletes must know the harness: it opened the harness, or it has the harness in its [`harnesses`](./lifecycle#delete-a-thread-for-good) option. Else it warns, and the sandbox stays. + ## 2. Keep server secrets out of commands A command gets only a few variables of the server process: @@ -118,6 +122,8 @@ With `background: true`, `bash` returns at once with a job id like `bash-1`. Two - At most 8 background jobs run at a time in a session. One more gives the model a tool error. Set another limit with `maxJobs`. - Each job keeps the newest 1 MiB of its output. +When a job ends, the model gets a note, and the wake turn runs as the user whose run started the job. + Each job sends events. Listen for them in a plugin: ```ts group=harness-coding-workspaces diff --git a/docs/harness/connect.md b/docs/harness/connect.md index f745b0e662..fe136501a2 100644 --- a/docs/harness/connect.md +++ b/docs/harness/connect.md @@ -145,7 +145,7 @@ export const team = defineHarness({ | `config` | Set these plugin config keys with `client.setConfig({ key, value })`. | | `commands` | Run these commands with `client.command({ name })`, or with `/name` in a [session view](./custom-ui) on a client. | | `tools` | Run these harness tools with no model turn, with `client.tool({ name, args })`. See [Run a tool without a model](./tool-runs). | -| `records` | Add records of these types to the thread with `client.append(records)`. See [Let a client add records](./session-log#let-a-client-add-records). | +| `records` | Add records of these types to the thread with `client.append({ records })`. See [Let a client add records](./session-log#let-a-client-add-records). | - Any other input of these kinds gets `{ status: 'rejected', reason: 'not_exposed' }`. - `GET describe` and `client.describe()` list only the exposed commands and config keys. A UI built from them shows only what a client can use. @@ -176,7 +176,7 @@ for await (const entry of client.events({ from: cursor })) { } ``` -`events()` reconnects after a network error and continues from the last cursor. A second tab, a phone, or a reload all see the same session. +`events()` reconnects after a network error and continues from the last cursor. It also reconnects when no bytes come for 60 s, so a dead connection (a laptop that slept) does not stay `open`. To change that time, pass `idleTimeoutMs` to `createHarnessClient`. Keep it larger than the [heartbeat](./protocol#stream-values) of the server, or the client reconnects again and again. A second tab, a phone, or a reload all see the same session. Without `from`, `events()` starts at the newest event, so it sends no old events. That is why the example reads the snapshot cursor before it sends. To read every event from the start of the session, pass `from: '0'`. @@ -464,6 +464,7 @@ The client sends `{ type: 'harness.subscribe', threadId }` first, and the host a - The host reads the user from the upgrade request with `identify`, the same as for HTTP. Every input on the socket runs as that user. - A user that `identify` refuses gets an `unauthorized` error frame, and the socket closes. +- The socket handles the frames of one connection in the order they came. So a prompt with a large image reaches the session before the frame after it. - A user who cannot read the thread, or who loses access later, gets a `forbidden` error frame, and the socket closes. - A frame over `media.maxBodyBytes` (default 32 MiB) gets an error frame, and the socket closes with code `1009`. - On a host with no `access`, pass `openAccess`, the same as for `createHarnessHandler`. diff --git a/docs/harness/custom-ui.md b/docs/harness/custom-ui.md index 2011a371ef..441ddb74a9 100644 --- a/docs/harness/custom-ui.md +++ b/docs/harness/custom-ui.md @@ -162,7 +162,7 @@ A notice has a `kind`: - `error`: a turn or an action failed. - `rejected`: the session did not accept a message. - `command`: the text result of a command. -- `ui`: a line that you added with `view.notice(text)`. +- `ui`: a line that you added with `view.notice({ text })`. ## 3. Act on the session @@ -172,18 +172,18 @@ Send work: - `view.followUp(text)`: the message waits, and runs as its own turn after the current work. - `view.continue()`: starts a turn from the saved messages, with no new message. - `view.command({ name, input })`: runs a command, for example from a button. -- `view.upload({ body, mimeType, name })`: stores a file. Send the record you get back with `view.send(text, [record])`. See [Show media](#show-media). +- `view.putMedia({ body, mimeType, name })`: stores a file. Send the record you get back with `view.send(text, [record])`. See [Show media](#show-media). Change the session: - `view.setConfig({ key, value })`: changes a config value, for example `view.setConfig({ key: 'mode', value: 'plan' })`. - `view.configure({ settings })`: changes the stored settings of the thread, for example `view.configure({ settings: { model: 'strong' } })`. - `view.reset({ note })`: the model starts fresh from the next turn, with your `note` first. See [Reset with a handoff note](./fork-and-reset#reset-with-a-handoff-note). -- `view.revert(messageId)` and `view.unrevert()`: go back to a message, and undo that. +- `view.revert({ messageId })` and `view.unrevert()`: go back to a message, and undo that. Stop work: - `view.cancel()`: cancels the running turn. `view.cancel({ operationId })` cancels one operation, for example a background agent. -- `view.cancelInput(inputId)`: drops a message from `waitingInputs` before it runs. `view.setDelivery({ inputId, delivery: 'steer' })` moves it into the running turn instead. +- `view.cancelInput({ inputId })`: drops a message from `waitingInputs` before it runs. `view.setDelivery({ inputId, delivery: 'steer' })` moves it into the running turn instead. - `view.dispose()`: stops the view when your screen closes. After that, each action throws an error. On a view with a [`HarnessClient`](#a-ui-in-the-browser), two actions need `expose` in the harness: @@ -197,7 +197,7 @@ See [Choose what clients can change](./connect#choose-what-clients-can-change). Answer what waits: -- `approval.approve()` and `approval.reject()`: answer one approval. `view.approve(id)` and `view.reject(id)` do the same by id. +- `approval.approve()` and `approval.reject()`: answer one approval. `view.approve({ id })` and `view.reject({ id })` do the same by id. - `view.approveAll()` and `view.rejectAll()`: answer all open approvals. - `call.resolve(output)` and `call.fail(message)`: answer one client tool call. - `request.resolve(value)` and `request.cancel()`: answer one item of `interrupts`. @@ -210,7 +210,7 @@ const [waiting] = view.store.get().approvals waiting?.approve({ editedArgs: { path: 'notes/draft.md' } }) ``` -`reject(payload)` sends data with a no, when the tool's approval schema takes some. +`reject({ payload })` sends data with a no, when the tool's approval schema takes some. If a turn waits for more than one approval, client tool, or interrupt, the turn continues after you answer all of them. @@ -239,7 +239,7 @@ A source must have these members: These members are optional: - `continue`, `cancelInput`, `setDelivery`, `configure`, `reset`, `revert`, and `unrevert`. Without one, its view action adds an `error` notice. -- `upload` or `putMedia`. If the source has neither, `view.upload` rejects. +- `putMedia`. Without it, `view.putMedia` rejects. - `mediaUrl` and `loadMedia`. Without `mediaUrl`, media parts have no `url`. Without `loadMedia`, `load()` rejects. - `agentTranscript`. Without it, `view.loadAgent` does nothing, so a saved child shows only its card. @@ -250,7 +250,7 @@ Some items need attention when they arrive. `view.on` calls your handler for eac ```ts group=harness-custom-ui view.on('approval', (approval) => { process.stdout.write('\u0007') - view.notice(`${approval.tool} waits for your answer.`) + view.notice({ text: `${approval.tool} waits for your answer.` }) }) ``` @@ -274,7 +274,7 @@ The view calls your handler after the item is in the state, so the handler can r The view also keeps its state right on its own: - Each event from `client.events()` or `session.events()` has the `threadId` it belongs to, and a `parentRunId` when a child run sent it. Use them to show the run tree in your own UI. -- When the server sends `harness.gap`, some events are gone. The view loads the snapshot and the transcript again, so the state is right after the gap. See [Write a client in any language](./protocol#when-events-are-missing). +- When the server sends `harness.gap`, some events are gone. The view stops its read, loads the snapshot and the transcript again, then reads on from the new snapshot. So no turn shows twice after the gap. See [Write a client in any language](./protocol#when-events-are-missing). ## Show a question @@ -290,7 +290,7 @@ When a question has a `url`, show the link. The user opens it, then answers. The ```ts group=harness-custom-ui view.on('question', (question) => { - if (question.url) view.notice(`Open ${question.url}, then answer.`) + if (question.url) view.notice({ text: `Open ${question.url}, then answer.` }) }) ``` @@ -355,7 +355,7 @@ This handler shows each one, and answers the region picker right away: ```ts group=harness-custom-ui view.on('interrupt', (request) => { - view.notice(request.message ?? `The turn waits for ${request.reason}.`) + view.notice({ text: request.message ?? `The turn waits for ${request.reason}.` }) if (request.reason === 'pick_region') request.resolve({ region: 'eu-west-1' }) }) ``` @@ -425,7 +425,7 @@ const screenshot = await session.putMedia({ await view.send('What is wrong in this screenshot?', [screenshot]) ``` -In a browser, get the record from `client.upload({ body: file, mimeType: file.type, name: file.name })`. The user message then shows the file in `media`. +In a browser, get the record from `client.putMedia({ body: file, mimeType: file.type, name: file.name })`. The user message then shows the file in `media`. ## Show subagents @@ -451,7 +451,7 @@ To show a line when a child ends, listen for `'agent'`: ```ts group=harness-custom-ui view.on('agent', (child) => { - if (child.status !== 'running') view.notice(`${child.name} is ${child.status}.`) + if (child.status !== 'running') view.notice({ text: `${child.name} is ${child.status}.` }) }) ``` @@ -466,7 +466,7 @@ import { createPluginEvent } from '@tanstack/ai-harness' const Deployed = createPluginEvent<{ env: string }>('acme/deploy:done') -view.on(Deployed, (done) => view.notice(`Deployed to ${done.env}`)) +view.on(Deployed, (done) => view.notice({ text: `Deployed to ${done.env}` })) ``` `state.plugins` is plugin data, so check its shape before you use it. diff --git a/docs/harness/deploy.md b/docs/harness/deploy.md index aaf3a80df2..c2df917448 100644 --- a/docs/harness/deploy.md +++ b/docs/harness/deploy.md @@ -159,6 +159,21 @@ await client.send('Where is my order?', traceparent ? { traceparent } : {}) The client sends it as the `traceparent` header. The `harness.operation` span of the turn gets a link to your span, on the server that runs the turn. To send it from another language, see [Traces](./protocol#traces). +## Known limits + +Check these before you run many servers or long threads. + +Across servers and restarts: + +- A [`harnessAgent`](./subagents#call-a-harness-as-a-child) child waits only in memory. Its parent run waits on the same host. Another host, or a crash, does not resume it. +- A [log snapshot](./durable-sessions#keep-a-long-log-fast-to-open) holds the full fold of the thread, so it grows with the thread. To keep the log small, drop the old records with [`logRecords: 'since-snapshot'`](./lifecycle#drop-the-records-before-a-snapshot). +- `host.resumePending` opens the threads that it takes one at a time. A `GET run` by run id looks through the open sessions one at a time too. + +Users and plugins: + +- With an [OAuth MCP server](./mcp#3-sign-in-to-an-oauth-server), the MCP SDK sends its own refresh request for each session. Only the save of the new token is single-flight, so two sessions can use a rotating refresh token at the same time. +- Plugin tools do not reach background agents. The `question` tool works only in a turn of the lead agent. + ## What you have now - The same harness in your server, on background workers, in a worker process, on another machine, or as a binary. diff --git a/docs/harness/durable-sessions.md b/docs/harness/durable-sessions.md index c4e280688a..7e0295b9fb 100644 --- a/docs/harness/durable-sessions.md +++ b/docs/harness/durable-sessions.md @@ -125,6 +125,8 @@ console.log(`Resumed ${resumed.length} threads`, resumed) It resolves with the threads that it took, as `{ threadId, harness, tenantId }` entries. Each thread opens in its own tenant, with no principal. +If a thread that the sweep took does not open, the host gives it back with a wake in 30 seconds. The next sweep or worker tries it again. + The `close` option sets what happens to a session that the sweep opened: - `'whenIdle'` (default): the session closes when its work ends. If the app opened the same thread too, the session stays open. @@ -276,8 +278,9 @@ On a serverless platform, two requests for one thread often reach two instances. - The first write of a host takes the log. From then on, this host owns the thread. - One timer renews every thread that the host owns. The host gives a thread back when it has no work. -- An input that reaches another instance waits in the log for the owner. The receipt is `queued`, and the owner runs it within a second. -- Every instance reads the thread. A client gets the events from the log, on any instance. A `POST run` on the second instance streams the turn that the owner runs. +- An input that reaches another instance waits in the log for the owner. The receipt is `queued`, and the owner runs it within a second. If the owner refuses it, `session.settled({ inputId })` on that instance rejects with `InputRejectedError`. See [Send inputs safely](./inputs). +- Every instance reads the thread, also while the owner runs a background job. A client gets the events from the log, on any instance. A `POST run` on the second instance streams the turn that the owner runs. +- The thread settings, the config values, and the state of an interrupted turn follow the changes that other hosts write. Set the timing with the `ownership` option of the host: @@ -289,7 +292,7 @@ const ownedHost = createHarnessHost({ ``` - `ttlMs`: how long the ownership lasts after each renewal. The default is 30 000. After it, another host can take the thread. -- `renewMs`: how often the timer renews. The default is 10 000. +- `renewMs`: how often the timer renews. The default is 10 000. When you set only `ttlMs`, the default is a third of `ttlMs`. It must be less than `ttlMs`, else `createHarnessHost` throws a `RangeError`. - `hostId`: a stable id for this host, for example the machine or pod name. With it, a thread whose coding tools use a folder on this host stays on this host. Other hosts refuse its tool calls. See [A folder on one server](./coding-workspaces#a-folder-on-one-server). An input that waits for the owner keeps its JSON overrides (`model`, `modelOptions`, `reasoning`, and `promptCache`). The `adapter` and `tools` overrides are objects, so the owner runs the input without them. diff --git a/docs/harness/durable-tools.md b/docs/harness/durable-tools.md index d4ebd5ff86..7c1d558aa1 100644 --- a/docs/harness/durable-tools.md +++ b/docs/harness/durable-tools.md @@ -127,7 +127,7 @@ The same tool works in plain `chat()`. There, `step.do` runs `fn` each time, and ## What you have now -- Side effects that do not repeat after a crash. +- Steps that keep their result after a crash. A step that a crash cuts can run again, so its idempotency key lets your system refuse the second side effect. - Questions to the user that any server can answer. - Your own records that land together with the tool batch, or not at all. - One tool definition for durable and plain runs. diff --git a/docs/harness/goal.md b/docs/harness/goal.md index e27cb2b2d7..2f39de279b 100644 --- a/docs/harness/goal.md +++ b/docs/harness/goal.md @@ -47,6 +47,8 @@ The first turn starts. Each new turn tells the model the reason of the last chec Keep working on the goal: all tests pass. Last check: The date parser test still fails. ``` +Each new turn runs as the user who set the goal. In a [shared thread](./shared-threads), the goal uses that user's credentials and keys, not the ones of the last person who wrote. + Run `session.command({ name: 'goal' })` to see the result: ```text diff --git a/docs/harness/inputs.md b/docs/harness/inputs.md index 0879da1f92..1eedb06604 100644 --- a/docs/harness/inputs.md +++ b/docs/harness/inputs.md @@ -63,6 +63,8 @@ console.log(receipt.operationId) Make the id once per user action (a button press), and keep it for every retry of that action. +This works for every input over HTTP and the socket. A `command` or an `agent` input that the client sends again with the same `inputId` also runs once. The `inputId` of its receipt is the id that the client sent. + ## Wait for the outcome On the server, `session.settled({ inputId })` waits until the input ends. It reads the log, so on a [durable host](./durable-sessions) it also works after a restart and on another host that opens the thread. @@ -79,6 +81,8 @@ if (settlement.outcome === 'failed') { `signal` is optional. When it aborts first, `settled` rejects with its reason. The input keeps running. +On a host that does not own the thread, the input waits for the owner. If the owner refuses it, `settled` rejects with `InputRejectedError`. The owner refuses an input when the thread is busy and the input has `busy: 'reject'`, or when the log has its id with another message. The log then has a `harness.offer.rejected` record. + - `completed`: the turn has its answer. - `failed`: the turn failed. `error.code` is set for a limit. - `aborted`: someone cancelled the turn. @@ -207,7 +211,7 @@ for (const input of session.inputs()) { console.log(input.inputId, input.delivery, input.message) } -await session.cancelInput('req-46') +await session.cancelInput({ inputId: 'req-46' }) await session.setDelivery({ inputId: 'req-47', delivery: 'steer' }) await migration ``` @@ -226,7 +230,7 @@ for (const input of waitingInputs) { console.log(input.inputId, input.delivery) } -await client.cancelInput('req-46') +await client.cancelInput({ inputId: 'req-46' }) await client.setDelivery({ inputId: 'req-47', delivery: 'steer' }) ``` @@ -246,11 +250,13 @@ await build - The tool call returns at once. The model gets a short note with the job id, and the turn goes on. - The job keeps running. When it ends, the model gets its result as a note, and an idle session starts a new turn. -- Without an argument, it moves every running call that supports it. Pass a `toolCallId` to move one call. +- Without an argument, it moves every running call that supports it. Pass `{ toolCallId }` to move one call. - `bash` and the single `subagent` tool support it. Other tools keep running as before. - With no running call that supports it, the receipt has `status: 'rejected'` and `reason: 'not_running'`. - The log keeps each job until it ends. When a crash stops a job, the next host adds the note "Background job call-1 stopped when the host restarted." +The session stays open while a job of this host runs, also when no handle holds it. A sweep with `close: 'whenIdle'` waits for these jobs too. The wake turn of a `bash` job runs as the user whose run started the job. + A client sends `{ op: 'background' }` over `POST control`. It runs no new code, so it needs no `expose` entry: ```ts group=harness-inputs-client diff --git a/docs/harness/lifecycle.md b/docs/harness/lifecycle.md index 0d948baf4c..dc6e2c24a7 100644 --- a/docs/harness/lifecycle.md +++ b/docs/harness/lifecycle.md @@ -55,10 +55,10 @@ const busyHost = createHarnessHost({ }) ``` -- `idleCloseMs`: how long an idle session with no handle stays open, in milliseconds. The default is 60 000. `0` closes it at once. +- `idleCloseMs`: how long an idle session with no handle stays open, in milliseconds. The default is 60 000. `0` closes it at once. A session is not idle while a background job of this host runs, for example a `bash` job. - `maxOpen`: how many sessions the host keeps open. The default is 500. An open past the limit first closes the least recently used sessions that are idle and that no handle holds. - If every open session is busy or held, the open goes on, and the host logs one warning. -- A close cleans up the plugins of the session. The transcript and the other data stay in the stores, so the next open continues the thread. +- A close cleans up the plugins of the session. The transcript and the other data stay in the stores, so the next open continues the thread. A workspace that `bind` gave the thread closes with `'moved'`, so an owned sandbox stays. - The idle timer does not keep the Node process alive. A plugin with `lifetime: 'host'` stays set up while another session uses it. See [Share one setup between sessions](./plugins#share-one-setup-between-sessions). @@ -115,11 +115,11 @@ What the view reads for the chat: - `transcript()`: the saved messages. - `snapshot()`: the status and the waiting approvals. When the session is open on this host, it also has the running operations. - `events({ from, signal })`: the events of the thread after a cursor. -- `agentTranscript(id)`: the messages of one child agent run. +- `agentTranscript({ subagentRunId })`: the messages of one child agent run. What the view reads for the rest: -- `getMedia(id)` and `loadMedia({ id, range })`: a media file and its bytes. +- `getMedia({ id })` and `loadMedia({ id, range })`: a media file and its bytes. - `usage()`: the token totals of the thread. - `entry()`: the index entry of the thread. - `logState()`: the `durability.reduce` fold of the log. See [One log for a session tree](./shared-logs#keep-state-for-the-whole-tree). @@ -166,6 +166,7 @@ const trimmedHost = createHarnessHost({ - `'all'` (default): the store keeps every record. - `'since-snapshot'`: after each snapshot, the owner truncates the records before it. The type is `LogRecordRetention`. - A reader whose cursor is in the dropped part gets one `harness.gap` event, then the stream goes on. See [Cursors](./connect#cursors). +- `session.revert()` still works. The snapshot keeps the last 500 file steps of [`snapshots()`](./snapshots#go-back-to-an-earlier-message). CAUTION: With `'since-snapshot'`, do not change `durability.reduce` or `durability.project` often. When the host cannot use the snapshot (its `reduce` version or its `project` changed, or the snapshot has a newer version), a thread with dropped records cannot open. The open rejects with `LogTruncatedError`. Keep `'all'` if you change `reduce` often. @@ -184,6 +185,23 @@ First, `delete` closes the sessions of the thread and of its child threads, for - The uploaded media and the generated artifacts. - The log checkpoint in `stores.metadata`. +The first-party plugins also end the thread: + +- [`workspaceTools()`](./coding-workspaces) reattaches an owned sandbox of the thread and closes it with `'closed'`, so it is destroyed. +- [`snapshots()`](./snapshots#remove-the-snapshots-of-a-thread) removes the snapshot repository of the thread. +- This needs the index entry of the thread, and the harness of the thread on this host. + +A host knows a harness when it opened a session of it. After a deploy or a restart, a new host has opened nothing. Give it the harnesses that it can delete threads of: + +```ts group=harness-lifecycle +const deleteHost = createHarnessHost({ + persistence: memoryPersistence(), + harnesses: [assistant], +}) +``` + +A host that does not know the harness of a thread still deletes the data. It warns, and the plugins do not end the thread: an owned sandbox stays. + - On a host with `access`, `delete` needs the `delete` action. A child thread is checked through its root thread. - A handle that calls again after the delete opens a new, empty thread. - A host without `stores.sessions` still closes the open sessions of the thread and deletes its data. It has no index entries, so it finds no child threads. diff --git a/docs/harness/mcp.md b/docs/harness/mcp.md index d489f3147a..5c162de5d2 100644 --- a/docs/harness/mcp.md +++ b/docs/harness/mcp.md @@ -87,14 +87,14 @@ In your own UI, read the same list from `session.snapshot().plugins['tanstack/mc ### When a server stops -A `stdio` server can crash, and a connection can close. Then `/mcp` shows `files: reconnecting`, and the turns run without the tools of that server. `mcp()` connects again: +A `stdio` server can crash, and a connection can close. Then `/mcp` shows `files: reconnecting`, and the turns run without the tools of that server. `mcp()` connects a server without `oauth: true` again: - It waits 0.5 s, then 1 s, then 2 s, and so on, up to 30 s between tries. A random part makes each wait shorter, so many sessions do not connect at the same moment. - It tries until the server connects, or until the session closes. For a `stdio` server, it tries until the host stops the server. - The next turn after the new connection has the tools again. All sessions that share a `stdio` server use the new process. -- A server that fails its first connect stays `failed`. It does not try again. +- A server that fails its first connect shows `failed`, and tries again with the same waits. A server that is not up yet, like a slow `npx`, gets its tools at a later turn. -This applies to the servers without `oauth: true`. +An OAuth server, also one from `mcpConnector`, connects again at the next turn after its connection closes. After `tools/list_changed`, the next turn lists its tools again. If that list fails, the old list stays. Some servers change their tools while they run. When a server sends `tools/list_changed`, the next turn lists the tools of that server again, and `/mcp` shows the new count. @@ -172,6 +172,7 @@ An MCP server can ask the user for input in the middle of a tool call. MCP calls - A form request becomes a question with the `message` and the requested `schema`. - A URL request becomes a question with a `url`. The user opens the page, then answers. The session does not open the page. - `mcp()` and `mcpConnector` both ask this way, for servers on MCP spec 2025 and on spec 2026-07-28. +- A spec 2025 request does not name the tool call that asks. So the session asks the user only when all the running calls of that server belong to one run. Else the server gets `decline`, so a question never goes to another user. - One tool call can ask more than once. On spec 2026-07-28, one call can ask up to 5 times. One more request fails the tool call. - The question is a live [wait](./waits) of the run that made the tool call. The host that runs the call keeps the thread until the answer comes. When that run stops, the wait ends `cancelled`. diff --git a/docs/harness/media.md b/docs/harness/media.md index 86a47c0080..8be96f5bdb 100644 --- a/docs/harness/media.md +++ b/docs/harness/media.md @@ -47,13 +47,14 @@ console.log(turn.text) ``` - The transcript keeps a `harness-media:` URL for the file. Only the model call gets the bytes. +- The host keeps up to 32 MB of base64 text in memory, for all its sessions together, so the next turn does not encode a file again. Above that, a turn encodes the oldest files again. - If the harness refuses the file, `putMedia` throws a `MediaError`. Its `status` is `413` for a file over the size limit, and `415` for a kind that the harness does not take. The session also has: -- `getMedia(id)`: the record, or `null` when the thread has no file with this id. +- `getMedia({ id })`: the record, or `null` when the thread has no file with this id. - `loadMedia({ id, range })`: the bytes, or one range of them. `range` is optional. -- `mediaUrl(id)`: `{ url }`, with a data URL for a file up to 1 MB. A bigger file gets `{}`. Use `loadMedia` for it, and serve the bytes from your own route. +- `mediaUrl({ id })`: `{ url }`, with a data URL for a file up to 1 MB. A bigger file gets `{}`. Use `loadMedia` for it, and serve the bytes from your own route. ## Media that agents make diff --git a/docs/harness/overview.md b/docs/harness/overview.md index 46868300bd..c8aeb8bdc9 100644 --- a/docs/harness/overview.md +++ b/docs/harness/overview.md @@ -205,6 +205,8 @@ Each host keeps every thread in a session log: the transcript, the inputs, the r `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). +Before you run many servers or long threads, read the [known limits](./deploy#known-limits). + ## Prompt caching Every session caches the stable start of its requests by default, with its `threadId` as the cache key. To change the default for every session, set `promptCache` on the harness: diff --git a/docs/harness/permissions.md b/docs/harness/permissions.md index 992741118a..7f1d145d3a 100644 --- a/docs/harness/permissions.md +++ b/docs/harness/permissions.md @@ -163,6 +163,8 @@ Two more rules apply to each tool: - A tool with no rule runs without a question. To ask for each tool that has no rule, set `default: 'ask'`. - When the last rule of a tool is a `deny` with no `resource`, the model does not get the tool. +The plugin decides each call in the guard hook, [`onGuardToolCall`](../advanced/middleware#onguardtoolcall). It runs after the input check and after every middleware that changes the arguments. So the rules see the arguments that the tool gets. + ### Paths - Inside `root`, a rule matches the path from `root`, for example `src/app.ts`. Outside `root`, it matches the full path, for example `/etc/hosts`. @@ -226,7 +228,7 @@ The rules decide the formatter as a part of the call that writes, before that ca | `plan` | Neither runs. No file changes in this mode. | - A `reject` stops the call. Nothing is written, and the formatter does not run. -- An `always` answer also saves `formatter:prettier` for the project, so the user answers once. +- An `always` answer also saves the formatter for the project, tied to a hash of its config files (`formatter:prettier@`). The user answers once, until those files change. - A rule that denies the formatter skips it. The write still runs, and the tool result says that the formatter did not run. - Without `permissions()`, the formatter runs without a question. diff --git a/docs/harness/plugins.md b/docs/harness/plugins.md index 8d6ed837cb..16d464cf79 100644 --- a/docs/harness/plugins.md +++ b/docs/harness/plugins.md @@ -224,6 +224,7 @@ export const counter = definePlugin({ - A key or a token of the session in the state goes into the log as `[redacted]`. `update` resolves with that same value. See [Keep tokens out of the log and the model](./auth#keep-tokens-out-of-the-log-and-the-model). - The updates of one plugin run one at a time. Each `update` function gets the value that the update before it wrote. - [`host.sessions.delete`](./lifecycle#delete-a-thread-for-good) deletes the state with the log. A fork does not copy it. +- For a long list of records, such as one record for each step, append records to the log and set `keepRecords`. Read them with `ctx.session.keptRecords(type)`. See [Keep records after a truncate](./session-log#keep-records-after-a-truncate). - Agents get a state with the same `get` and `update` from `ctx.harness.state({ key, initial })`. See [Share state between agents](./subagents#share-state-between-agents). ## Middleware in every agent @@ -299,6 +300,10 @@ Where each part of a plugin reads the run: - A session plugin (the default lifetime): `ctx.credentials` and `ctx.keys` have no user, so each call throws. `ctx.session.principal` is the owner of the session. - A plugin with `lifetime: 'host'`: its setup has no user and no session. Its tools read the user of each call from `ctx.context.run`. +`prepareTools` gets `run` too: the run of the turn, with its id and its user. + +A plugin that starts a turn picks its user. `ctx.session.send(text, { principal })` and `ctx.session.note(text, { wake: true, principal })` run that turn as `principal`. Without it, the turn runs as `ctx.session.principal`. To continue the work of a run, pass the `principal` of that run. + The run has these fields: | Field | What it is | diff --git a/docs/harness/protocol.md b/docs/harness/protocol.md index 40a5d882aa..f2ce26042a 100644 --- a/docs/harness/protocol.md +++ b/docs/harness/protocol.md @@ -245,6 +245,10 @@ The stream of a thread has the events of every run of its tree, in one order. To The status of a receipt is `accepted`, `queued`, or `rejected`. A `rejected` receipt has a `reason`, for example `not_exposed`, `not_running`, or `unknown_agent`. +Give each input an `inputId`, and send the same id again after a lost answer. A `prompt`, `command`, `agent`, or `append` input with the same `inputId` runs once. The `inputId` of its receipt is the id that you sent. + +The socket handles the frames of one connection in the order they came. + ## Cursors and reconnects Each `harness.event` has a `cursor`. On SSE, the `id` of each event is the same cursor. A cursor is an opaque string. Store the last one, and do not parse it. @@ -352,6 +356,7 @@ A long stream passes through proxies and slow networks. The server keeps it open - `POST run` and `GET run` also get the `: ping` comment. SSE parsers skip comments. - A slow client loses no events. The server reads them from the log when the client reads again. - Set the idle timeout of your proxy to more than the heartbeat. +- A client can use the heartbeat to find a dead connection. `createHarnessClient` connects again when no bytes come for 60 s (its `idleTimeoutMs`). If you set `heartbeatMs` to 60 s or more, set a larger `idleTimeoutMs` on the client. - The stdio worker has no heartbeat and does not use these values. To change a value, pass `stream` to the handler and to the socket. A value that you do not set keeps its default: diff --git a/docs/harness/session-log.md b/docs/harness/session-log.md index 302b9656b0..07a65a4934 100644 --- a/docs/harness/session-log.md +++ b/docs/harness/session-log.md @@ -32,15 +32,42 @@ const assistant = defineHarness({ const host = createHarnessHost({ persistence }) const session = await host.open(assistant, { threadId: 'thread-1' }) -await session.append([{ type: 'app.signal', text: 'The build failed.' }]) +await session.append({ records: [{ type: 'app.signal', text: 'The build failed.' }] }) ``` - A record is JSON with a `type`. Types that start with `harness.` belong to the harness, so `append` refuses them. -- `append` resolves when the records are in the log. +- `append` resolves with a receipt when the records are in the log. +- Pass `inputId` to make the append safe to send again: `session.append({ records, inputId: 'signal-7' })`. A second append with the same id writes nothing. Without `inputId`, the append gets a new id. The receipt has the id, as the log keeps it. `principal` sets who appends. The default is the user who opened the session. - A record can belong to a child thread of the same log. See [One log for a session tree](./shared-logs). - In a tool, use `append` from [`durableTool`](./durable-tools), so the records land with the tool batch. - A key or a token of the session in a record goes into the log as `[redacted]`. See [Keep tokens out of the log and the model](./auth#keep-tokens-out-of-the-log-and-the-model). +### Keep records after a truncate + +With [`logRecords: 'since-snapshot'`](./lifecycle#drop-the-records-before-a-snapshot), the store drops the records before each snapshot. A plugin can make the snapshot keep the last records of its own types with `keepRecords`, and read them back with `ctx.session.keptRecords(type)`: + +```ts group=harness-session-log +import { defineCommand, definePlugin } from '@tanstack/ai-harness' + +export const steps = definePlugin({ + name: 'acme/steps', + keepRecords: { 'acme/steps:step': 100 }, + setup: (ctx) => ({ + commands: { + steps: defineCommand({ + description: 'Count the kept steps', + run: async () => (await ctx.session.keptRecords('acme/steps:step')).length, + }), + }, + }), +}) +``` + +- The snapshot keeps the last 100 `acme/steps:step` records of each thread. +- `keptRecords` gives the kept records of that type of this thread, oldest first. It works before and after a truncate. +- [`snapshots()`](./snapshots#go-back-to-an-earlier-message) keeps its last 500 file steps this way, so `session.revert()` works after a truncate. +- The plugins of the harness of the root thread decide, because that harness folds the log. A type in two plugins keeps the larger count. + ## Fold records into the model context By default, the model does not see your records. Set `durability.project` on the harness to fold a record into the messages. Its `record` function gets one object, `{ messages, record }`, and returns the new list, or `undefined` for no change: @@ -122,7 +149,7 @@ const client = createHarnessClient({ headers: { authorization: 'Bearer my-token' }, }) -await client.append([{ type: 'app.signal', text: 'The build failed.' }]) +await client.append({ records: [{ type: 'app.signal', text: 'The build failed.' }] }) await client.continue() ``` diff --git a/docs/harness/sessions.md b/docs/harness/sessions.md index 21ea061fd2..119da1142c 100644 --- a/docs/harness/sessions.md +++ b/docs/harness/sessions.md @@ -143,7 +143,7 @@ A fork keeps the old chat. To go back in the same chat, revert it to a message: const [question] = await session.transcript() if (!question?.id) throw new Error('The chat has no messages.') -await session.revert(question.id) +await session.revert({ messageId: question.id }) // The transcript now ends at `question`. To bring the later messages back: await session.unrevert() ``` diff --git a/docs/harness/shared-logs.md b/docs/harness/shared-logs.md index dd811a1bf8..50ebc329db 100644 --- a/docs/harness/shared-logs.md +++ b/docs/harness/shared-logs.md @@ -40,7 +40,7 @@ const root = await host.open(assistant, { threadId: 'agent-1' }) - The log has the id of the root thread, in its tenant: `logIdOf({ tenantId, logId: 'agent-1' })`. - Each thread folds only its own transcript, inputs, and events from the log. A child never sees the messages of the root. -- A child thread does not open. Open its root, and read the child with `session.agentTranscript(subagentRunId)`. `host.open` throws for the thread id of a `harnessAgent` child. +- A child thread does not open. Open its root, and read the child with `session.agentTranscript({ subagentRunId })`. `host.open` throws for the thread id of a `harnessAgent` child. - `host.observe` also reads a child thread by its own thread id. It finds the root through the session index, so it needs `stores.sessions`. `memoryPersistence()` has it. ## Write to several threads in one append @@ -48,10 +48,12 @@ const root = await host.open(assistant, { threadId: 'agent-1' }) `session.append` adds records to the root thread. A record with `thread: ''` belongs to that thread of the same log. The whole append is all or nothing: ```ts group=harness-shared-logs -await root.append([ - { type: 'app.child_created', thread: 'task-1', task: 'research' }, - { type: 'app.child_linked', child: 'task-1' }, -]) +await root.append({ + records: [ + { type: 'app.child_created', thread: 'task-1', task: 'research' }, + { type: 'app.child_linked', child: 'task-1' }, + ], +}) ``` Here the child record and the link in the root land together, or not at all. @@ -85,9 +87,9 @@ const tracker = defineHarness({ durability: { reduce }, }) const tracked = await host.open(tracker, { threadId: 'agent-2' }) -await tracked.append([ - { type: 'app.child_created', thread: 'task-2', task: 'research' }, -]) +await tracked.append({ + records: [{ type: 'app.child_created', thread: 'task-2', task: 'research' }], +}) console.log((await tracked.logState())?.tasks) ``` diff --git a/docs/harness/shared-threads.md b/docs/harness/shared-threads.md index 2fdaed0a9a..5280e8f63b 100644 --- a/docs/harness/shared-threads.md +++ b/docs/harness/shared-threads.md @@ -98,9 +98,9 @@ export async function invite(userId: string, role: 'member' | 'viewer') { When `unshare` removes a user: -- The queued and running work that the user started on this host stops. The log keeps who cancelled it. -- The streams of the user on the thread close. A socket gets a `forbidden` error frame. -- The open handles of the user throw `AccessDeniedError`. +- The queued and running work that the user started stops, on every host. The log keeps who cancelled it. The schedules of the user on the thread are removed. +- The streams of the user on the thread close. A socket gets a `forbidden` error frame. A running `events()` read of a handle or a view ends with `AccessDeniedError`. +- The open handles and views of the user throw `AccessDeniedError`. - The signed media URLs of the user stop working. ## 3. Connect each person diff --git a/docs/harness/snapshots.md b/docs/harness/snapshots.md index 985bcdfc5d..bfa89ee4d4 100644 --- a/docs/harness/snapshots.md +++ b/docs/harness/snapshots.md @@ -45,6 +45,7 @@ const session = await host.open(coder, { threadId: 'fix-login' }) - `dataDir` is a folder for the snapshot repositories, an absolute path. Put it outside `root`. Each thread of each tenant gets its own repository in it. - The plugin needs the `git` command. `/undo` and `/redo` need git 2.26 or later. - Your project does not need to be a git repository, and the plugin never uses the `.git` folder of your project. +- With [`workspaceTools({ workspace: { bind } })`](./coding-workspaces) before it, each thread keeps its snapshots in its own workspace. Leave out `root`. ## 2. Undo and redo a turn @@ -99,13 +100,13 @@ for (const { record } of await log.read({ logId: logIdOf({ logId: 'fix-login' }) ```ts group=harness-snapshots const messages = await session.transcript() const firstAnswer = messages.find((message) => message.role === 'assistant') -if (firstAnswer?.id) await session.revert(firstAnswer.id) +if (firstAnswer?.id) await session.revert({ messageId: firstAnswer.id }) // Bring back the files and the messages. await session.unrevert() ``` -- A revert uses the last 500 steps of the thread. +- A revert uses the last 500 steps of the thread. The log snapshot keeps them, so a revert also works with [`logRecords: 'since-snapshot'`](./lifecycle#drop-the-records-before-a-snapshot). - A revert touches only the files that the tool calls after that message changed. Each file goes back to its state before the first of these calls. A file that did not exist then is removed. - Other files stay as they are. Your own edits between the hidden turns stay too. - `unrevert()` touches only the same files. It puts them back as they were when the revert started, with your edits. @@ -114,7 +115,7 @@ await session.unrevert() - The next prompt drops the hidden messages for good. Then `unrevert()` does not change the files. - The revert is saved, so `unrevert()` still works after a restart. -In your own UI, call `client.revert(messageId)` and `client.unrevert()`. The client needs `'undo'` in `expose.commands`. More about reverts: [Go back to an earlier message](./sessions#go-back-to-an-earlier-message). +In your own UI, call `client.revert({ messageId })` and `client.unrevert()`. The client needs `'undo'` in `expose.commands`. More about reverts: [Go back to an earlier message](./sessions#go-back-to-an-earlier-message). ## In a sandbox @@ -145,7 +146,12 @@ The undo point and the steps are in the session log. So when another server take ## Remove the snapshots of a thread -When you delete a thread, remove its snapshot repository too. Call `purgeSnapshots` with the options of the plugin and the thread: +[`host.sessions.delete`](./lifecycle#delete-a-thread-for-good) removes the snapshot repository of the thread for you. This needs two things: + +- The thread has an index entry. +- This host knows the harness: it opened a session of it before, or it has the harness in its [`harnesses`](./lifecycle#delete-a-thread-for-good) option. + +In other cases, call `purgeSnapshots` with the options of the plugin and the thread: ```ts group=harness-snapshots import { purgeSnapshots } from '@tanstack/ai-harness/plugins/coding' diff --git a/docs/harness/subagents.md b/docs/harness/subagents.md index 1da1b2a426..ec82d23add 100644 --- a/docs/harness/subagents.md +++ b/docs/harness/subagents.md @@ -249,6 +249,12 @@ For a follow-up, `receipt.operationId` is the id of the new run. While the run b A steer joins only a run of the same sender. A steer from another sender runs as a follow-up for that sender. As a result, no message runs with the credentials of another person. +Who sends: + +- `run.send` and `run.cancel` act as the user of the run, not as the user who opened the session. +- To send as another user, pass `principal`: `run.send('Add a title.', { principal })`. +- `session.agents.drafter.run(input, { principal })` and `start` run the agent as that user. + An agent written as plain code, with no `ctx.chat` call, makes no model call. It gets a steer only as a follow-up. To message a run from the browser, see [Send a message to a running agent](./connect#send-a-message-to-a-running-agent). @@ -411,7 +417,7 @@ Known limits: ## Show what a child wrote after a reload -Your UI shows each child's text and tool calls while they stream. Then the user reloads the page. The thread's transcript has only the agent tool call and its result, because each child keeps its own messages. `session.agentTranscript(subagentRunId)` gives you those messages back. +Your UI shows each child's text and tool calls while they stream. Then the user reloads the page. The thread's transcript has only the agent tool call and its result, because each child keeps its own messages. `session.agentTranscript({ subagentRunId })` gives you those messages back. Each agent tool result names its child with `subagentRunId`. Read the result, then ask for the child: @@ -444,7 +450,7 @@ for (const message of await session.transcript()) { if (!('subagentRunId' in result)) continue if (typeof result.subagentRunId !== 'string') continue - const child = await session.agentTranscript(result.subagentRunId) + const child = await session.agentTranscript({ subagentRunId: result.subagentRunId }) console.log(child.map((item) => item.content)) } ``` @@ -469,7 +475,7 @@ const client = createHarnessClient({ }) export async function childMessages(subagentRunId: string) { - return client.agentTranscript(subagentRunId) + return client.agentTranscript({ subagentRunId }) } ``` diff --git a/docs/harness/thread-settings.md b/docs/harness/thread-settings.md index 06f7135d61..f72cab14da 100644 --- a/docs/harness/thread-settings.md +++ b/docs/harness/thread-settings.md @@ -51,7 +51,7 @@ console.log(receipt.status) // 'accepted' console.log(session.settings()) // { model: 'strong', reasoning: 'high', instructions: 'Answer in French.' } ``` -The settings apply from the next turn. A field that you leave out stays as it is. Set a field to `null` to clear it: +The settings apply from the next turn. A field that you leave out stays as it is. `configure` merges your fields into the latest stored settings, also when another host changed them. Set a field to `null` to clear it: ```ts group=harness-thread-settings await session.configure({ settings: { instructions: null } }) @@ -70,7 +70,7 @@ The six fields: 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. -`configure` is an input, so the log keeps who changed what. Each change sends a `harness.settings.changed` event with the new `settings`. +`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. A client can change only the fields in `expose.settings` of the harness. See [Choose what clients can change](./connect#choose-what-clients-can-change). @@ -101,7 +101,7 @@ For `reasoning`, the `overrides` of the prompt win, then the stored setting, the - A `tools` list keeps only the tools with those names. `{ remove }` drops the tools with those names. Either way, the tools of the prompt `overrides` stay. - The harness applies `tools` before the plugins prepare the tool list (`prepareTools`). So a plugin that wraps tools sees only the tools that a thread keeps. -- `plugins: { remove }` leaves everything that those plugins give out of the thread: tools, prompts, middleware, the model pick, agents, subagents, commands, and the items that they add for other plugins. For example, a thread without `agents()` gets no subagent tools, no `/agent` command, and no permission rules of its agent profiles. +- `plugins: { remove }` leaves everything that those plugins give out of the thread: tools, prompts, middleware, the model pick, agents, subagents, commands, and the items that they add for other plugins. For example, a thread without `agents()` gets no subagent tools, no `/agent` command, and no permission rules of its agent profiles. The thread also cannot run an agent of a removed plugin by its name. - Plugins that run once per session stay mounted, but they give the thread nothing. - If a plugin that stays needs a capability of a removed plugin, the setting is rejected. diff --git a/docs/tools/server-tools.md b/docs/tools/server-tools.md index 9106cb0e00..e3d290d0c3 100644 --- a/docs/tools/server-tools.md +++ b/docs/tools/server-tools.md @@ -359,7 +359,7 @@ Malformed JSON arguments and input validation failures (Standard Schema or JSON The `inputSchema` checks the input just before the tool runs: -- The check runs after approval edits and after middleware `onBeforeToolCall`. An approval edit that does not fit the schema stops the resume with an `invalid-edited-args` error. A middleware change that does not fit becomes a tool error. +- The check runs after approval edits and after middleware `onBeforeToolCall`. An approval edit or a middleware change that does not fit the schema becomes a tool error. The resume goes on, the tool does not run, and the model gets the input error as the result of that call. - An async validator is awaited. - The tool gets the schema's output, so a transform such as `z.string().transform(Number)` applies once. diff --git a/examples/harness-cli/src/tui.tsx b/examples/harness-cli/src/tui.tsx index 53b8b89694..1137e3c419 100644 --- a/examples/harness-cli/src/tui.tsx +++ b/examples/harness-cli/src/tui.tsx @@ -104,7 +104,9 @@ function Screen({ view }: { view: SessionView }) { .answer(answerOf(text, question.schema)) .then((receipt) => { if (receipt.status === 'rejected') { - view.notice(`Answer again: ${receipt.reason ?? 'not accepted'}`) + view.notice({ + text: `Answer again: ${receipt.reason ?? 'not accepted'}`, + }) } }) } diff --git a/packages/ai-acp/src/agent/index.ts b/packages/ai-acp/src/agent/index.ts index 50398c0514..5acc1c63ec 100644 --- a/packages/ai-acp/src/agent/index.ts +++ b/packages/ai-acp/src/agent/index.ts @@ -16,7 +16,6 @@ import type { AgentApp, AgentContext, AgentConnection, - ClientCapabilities, ElicitationSchema, PermissionOption, SessionUpdate, @@ -230,6 +229,19 @@ function parseArgs(raw: string | undefined): unknown { } } +/** + * The tool call of an interrupt: its own `toolCallId`, or for an approval of + * `permissions()`, the `toolCallId` in its payload. Core gives such an + * approval no `toolCallId` of its own. + */ +function toolCallIdOf(interrupt: Interrupt) { + if (interrupt.toolCallId) return interrupt.toolCallId + const payload = interrupt.metadata?.[INTERRUPT_PAYLOAD_METADATA_KEY] + return isRecord(payload) && typeof payload.toolCallId === 'string' + ? payload.toolCallId + : undefined +} + /** The sign-in page of a sign-in interrupt, when it has one. */ function signInUrl(interrupt: Interrupt) { const payload = interrupt.metadata?.[INTERRUPT_PAYLOAD_METADATA_KEY] @@ -345,6 +357,11 @@ type WaitAnswer = * of the connection release the handles too. `session/resume` only checks the * session index: the session opens at its first prompt. * + * One app serves one connection: it keeps the session handles and prompt + * queues of that connection, and a second connection to it throws. For more + * connections, make one app for each, for example + * `new AcpServer({ createAgent: () => createAcpAgent(options) })`. + * * ACP v2 is a draft, so this API is experimental too. */ export function createAcpAgent

( @@ -442,12 +459,17 @@ export function createAcpAgent

( update: SessionUpdate, ) => client.notify('session/update', { sessionId, update }).catch(() => {}) - /** What the editor can show, from its `initialize`. */ - let capabilities: ClientCapabilities = {} - - /** True when the editor can answer `wait` (see `askEditor`). */ - const canAsk = (wait: Wait, url: string | undefined) => { - const elicitation = capabilities.elicitation + /** + * True when the editor can answer `wait` (see `askEditor`). What the editor + * can show comes from the `initialize` of its own connection. + */ + const canAsk = async ( + client: AgentContext, + wait: Wait, + url: string | undefined, + ) => { + const { request } = await client.initialized + const elicitation = request.capabilities?.elicitation switch (wait.kind) { case 'approval': return true @@ -483,7 +505,7 @@ export function createAcpAgent

( toolCallId?: string url?: string }): Promise => { - if (!canAsk(wait, url)) return { status: 'cancelled' } + if (!(await canAsk(client, wait, url))) return { status: 'cancelled' } if (wait.kind === 'approval') { const { outcome } = await client.request('session/request_permission', { sessionId, @@ -545,7 +567,10 @@ export function createAcpAgent

( wait: waitOfInterrupt({ interrupt, runId: sessionId }), url: signInUrl(interrupt), })) - if (items.some(({ wait, url }) => canAsk(wait, url))) { + const askable = await Promise.all( + items.map(({ wait, url }) => canAsk(client, wait, url)), + ) + if (askable.includes(true)) { await notify(client, sessionId, { sessionUpdate: 'state_update', state: 'requires_action', @@ -553,11 +578,12 @@ export function createAcpAgent

( } const resume: Array = [] for (const { interrupt, wait, url } of items) { + const toolCallId = toolCallIdOf(interrupt) const reply = await askEditor({ client, sessionId, wait, - ...(interrupt.toolCallId ? { toolCallId: interrupt.toolCallId } : {}), + ...(toolCallId ? { toolCallId } : {}), ...(url ? { url } : {}), }) if (reply.status === 'stop') return { cancelled: true } @@ -742,16 +768,30 @@ export function createAcpAgent

( ) } + /** + * True after the first connection. The SDK gives a request handler no + * public id of its connection, so the state above is for one connection + * only. The SDK way to serve many is one app for each connection. + */ + let connected = false + return ( agent() .onConnect((connection) => { + // A second connection would share the handles and queues of the + // first, so the SDK closes it with this error. + if (connected) { + throw new Error( + 'An ACP agent app serves one connection. Call createAcpAgent once for each connection.', + ) + } + connected = true // The handles of the sessions go with the connection. void connection.closed.then(() => Promise.all([...handles.keys()].map(release)), ) }) - .onRequest('initialize', ({ params }) => { - capabilities = params.capabilities ?? {} + .onRequest('initialize', () => { return { protocolVersion: PROTOCOL_VERSION, info: { name: harness.name, version: options.version ?? '0.0.0' }, diff --git a/packages/ai-acp/tests/agent.test.ts b/packages/ai-acp/tests/agent.test.ts index ebb7984f94..89c8d95cc5 100644 --- a/packages/ai-acp/tests/agent.test.ts +++ b/packages/ai-acp/tests/agent.test.ts @@ -249,12 +249,14 @@ describe('ACP v2 agent', () => { const host = createHarnessHost({ persistence: memoryPersistence() }) const updates: Array = [] const offered: Array> = [] + const subjects: Array = [] const editor = client() .onNotification('session/update', ({ params }) => { updates.push(params.update) }) .onRequest('session/request_permission', ({ params }) => { offered.push(params.options.map((option) => option.kind)) + subjects.push(params.subject) return { outcome: { outcome: 'selected', optionId: 'always' } } }) @@ -275,6 +277,10 @@ describe('ACP v2 agent', () => { }) expect(offered).toEqual([['allow_once', 'allow_always', 'reject_once']]) + // The request names the tool call that the editor already shows. + expect(subjects).toEqual([ + { type: 'tool_call', toolCall: { toolCallId: 'call_1' } }, + ]) expect(ran).toEqual(['bash']) expect(agentText(updates)).toContain('Listed.') await host.close() @@ -350,4 +356,80 @@ describe('ACP v2 agent', () => { expect(result?.content).toContain('Ada') await host.close() }) + + it('refuses a second connection to one app, and keeps the first', async () => { + const greet = durableTool( + toolDefinition({ + name: 'greet', + description: 'Ask the user for a name', + inputSchema: { type: 'object', properties: {} }, + }), + async (_args, { step }) => { + const answer = await step.ask({ + name: 'name', + message: 'Name?', + schema: { + type: 'object', + properties: { name: { type: 'string' } }, + required: ['name'], + }, + }) + return { greeted: answer } + }, + ) + const harness = defineHarness({ + name: 'test/acp-connections', + adapter: scripted([toolTurn('greet', '{}'), textTurn('Greeted.')]), + tools: [greet], + }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const app = createAcpAgent({ host, harness }) + const updates: Array = [] + const asked: Array = [] + const first = client() + .onNotification('session/update', ({ params }) => { + updates.push(params.update) + }) + .onRequest('elicitation/create', ({ params }) => { + asked.push(params) + return { action: 'accept', content: { name: 'Ada' } } + }) + // An editor that shows no elicitation, on its own connection. + const second = client() + + await first.connectWith(app, async (ctx) => { + await ctx.request('initialize', { + protocolVersion: 2, + info: { name: 'editor-a', version: '1.0.0' }, + capabilities: { elicitation: { form: {} } }, + }) + const { sessionId } = await ctx.request('session/new', { + cwd: '/tmp', + mcpServers: [], + }) + // A second editor on the same app would share its sessions, so the + // connection fails at once. + expect(() => + second.connectWith(app, async (other) => { + await other.request('initialize', { + protocolVersion: 2, + info: { name: 'editor-b', version: '1.0.0' }, + }) + }), + ).toThrow( + 'An ACP agent app serves one connection. Call createAcpAgent once for each connection.', + ) + await ctx.request('session/prompt', { + sessionId, + prompt: [{ type: 'text', text: 'greet me' }], + }) + await waitFor(() => expect(isIdle(updates)).toBe(true)) + }) + + // The first editor still gets its question: the second one did not + // change what the first one can show. + expect(asked).toHaveLength(1) + expect(agentText(updates)).toContain('Greeted.') + await host.close() + }) }) diff --git a/packages/ai-code-mode/src/harness.ts b/packages/ai-code-mode/src/harness.ts index 76594e921d..8e4fc4497c 100644 --- a/packages/ai-code-mode/src/harness.ts +++ b/packages/ai-code-mode/src/harness.ts @@ -7,8 +7,17 @@ import { } from '@tanstack/ai-harness' import { createCodeMode } from './create-code-mode' import type { AnyTool } from '@tanstack/ai' +import type { PermissionMode } from '@tanstack/ai-harness' import type { CodeModeTool, CodeModeToolConfig } from './types' +/** The permission modes of `permissions()`, for the `mode` setting. */ +const MODES: ReadonlyArray = [ + 'default', + 'plan', + 'acceptEdits', + 'bypass', +] + export interface CodeModePluginOptions extends Omit< CodeModeToolConfig, 'tools' @@ -24,7 +33,8 @@ export interface CodeModePluginOptions extends Omit< * `include` cannot move a tool with `needsApproval`, or a tool that a rule * asks for or denies: such a tool stays a normal tool call, with its * approval and its checks. Code mode warns once for each such tool that `include` - * picks. + * picks. The rules decide in the permission mode of the session at each + * turn, so in plan mode a tool that edits stays a normal tool call. */ include?: (tool: CodeModeTool) => boolean /** @@ -120,15 +130,20 @@ export function codeMode(options: CodeModePluginOptions) { // Ask permissions() when it is mounted: it knows all rules in its // order and its default. Read here, so it can come after code mode. const decide = ctx.getOptional(PermissionDecisionCapability) - const decisionOf = (tool: CodeModeTool, mode: 'default' | 'plan') => + const decisionOf = (tool: CodeModeTool, mode: PermissionMode) => decide?.(tool.name, mode) ?? decidePermission(rules, tool.name, mode) const safe = (tool: CodeModeTool) => !tool.needsApproval && decisionOf(tool, 'plan') === 'allow' + // The permission mode of the session now, read at each turn. In + // plan mode, a tool that edits must stay a tool call, where the + // rules deny it. + const value = ctx.config.get('mode') + const mode = MODES.find((each) => each === value) ?? 'default' // `include` widens the pick past plan mode, but a call inside the // isolate must not skip an approval, an `ask` rule, or a deny rule. const canInclude = (tool: CodeModeTool) => - !tool.needsApproval && decisionOf(tool, 'default') === 'allow' + !tool.needsApproval && decisionOf(tool, mode) === 'allow' const moves = (tool: CodeModeTool) => { // The permission checks run on tool calls of the model. A call // inside the isolate skips them, so such a tool stays a tool call. diff --git a/packages/ai-code-mode/tests/harness.test.ts b/packages/ai-code-mode/tests/harness.test.ts index 15ff48baad..a8b9ad4df6 100644 --- a/packages/ai-code-mode/tests/harness.test.ts +++ b/packages/ai-code-mode/tests/harness.test.ts @@ -477,6 +477,40 @@ describe('codeMode', () => { ]) }) + it('keeps a tool that include picks and that edits as a tool call in plan mode', async () => { + const { adapter, calls } = scripted([textTurn('ok'), textTurn('ok')]) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open( + defineHarness({ + name: 'test/code-mode-include-plan', + adapter, + tools: [tools.lookup], + plugins: () => [ + permissions({ + rules: [{ tool: 'lookup', decision: 'allow', kind: 'edit' }], + }), + codeMode({ + driver: fakeDriver().driver, + include: (tool) => tool.name === 'lookup', + }), + ], + }), + { threadId: 't' }, + ) + const namesOf = (call: number) => + calls[call].tools.map((tool: { name: string }) => tool.name) + await session.send('hi') + // In the default mode, the rule allows it, so it moves. + expect(namesOf(0)).toEqual(['execute_typescript']) + + await session.command({ name: 'mode', input: 'plan' }) + await session.send('plan it') + // Plan mode denies an edit, so a call inside the isolate must not run + // it. No tool moves, so code mode adds no tool. + expect(namesOf(1)).toEqual(['lookup']) + await host.close() + }) + it('keeps a tool that a rule of permissions() asks for as a tool call', async () => { const names = await firstToolNames({ tools: [ diff --git a/packages/ai-harness-cli/src/index.ts b/packages/ai-harness-cli/src/index.ts index 42aa3098ec..1bf08c8c6c 100644 --- a/packages/ai-harness-cli/src/index.ts +++ b/packages/ai-harness-cli/src/index.ts @@ -137,7 +137,7 @@ function attachingSource(session: HarnessSession) { snapshot: () => session.snapshot(), transcript: () => session.transcript(), describe: () => session.describe(), - mediaUrl: (id) => session.mediaUrl(id), + mediaUrl: (options) => session.mediaUrl(options), loadMedia: (options) => session.loadMedia(options), } return source diff --git a/packages/ai-harness-cli/tests/attach.test.ts b/packages/ai-harness-cli/tests/attach.test.ts index 26db146163..6d4fd9c72a 100644 --- a/packages/ai-harness-cli/tests/attach.test.ts +++ b/packages/ai-harness-cli/tests/attach.test.ts @@ -96,7 +96,7 @@ async function storedFiles(input: UserInput) { const ids = parts.flatMap((part) => mediaIdOf(part) ?? []) return Promise.all( ids.map(async (id) => ({ - name: (await session.getMedia(id))?.name, + name: (await session.getMedia({ id }))?.name, text: new TextDecoder().decode(await session.loadMedia({ id })), })), ) diff --git a/packages/ai-harness/src/agents.ts b/packages/ai-harness/src/agents.ts index b3984575c6..a8d245f929 100644 --- a/packages/ai-harness/src/agents.ts +++ b/packages/ai-harness/src/agents.ts @@ -16,7 +16,7 @@ import type { AgentStartOptions, RunArgs, } from './session/types' -import type { AgentRun } from './types' +import type { AgentRun, RunContext } from './types' import type { PluginState } from './plugins' import type { AnyHarness } from './define' @@ -118,6 +118,11 @@ export class AgentRegistry implements AgentRegistryView { return this.agents.get(name)?.agent } + /** Who added the agent `name`: `'the harness'` or a plugin name. */ + ownerOf(name: string): string | undefined { + return this.agents.get(name)?.owner + } + find(query: { produces: AgentProduces }): AnyAgent | undefined { return this.list().find((agent) => agent.produces === query.produces) } @@ -304,15 +309,32 @@ export type HarnessChildRun = ( */ const childRuns = new WeakMap() -/** @internal Give `context` the child hook `run`. Returns `context`. */ +/** The run context of the run of each `ctx.harness` that a session made. */ +const runContexts = new WeakMap() + +/** + * @internal Give `context` the child hook `run`, and the run context of the + * run that it belongs to. Returns `context`. + */ export function withHarnessChild( context: HarnessAgentContext, run: HarnessChildRun, + runContext: RunContext, ): HarnessAgentContext { childRuns.set(context, run) + runContexts.set(context, runContext) return context } +/** + * @internal The run context of the run of `context`: for an agent that runs + * as a tool of a turn, the run of that turn. `undefined` outside a run that + * a harness session started. + */ +export function runContextOf(context: HarnessAgentContext | undefined) { + return context ? runContexts.get(context) : undefined +} + /** * @internal The child hook of `context`, or `undefined` outside a run that * a harness session started. diff --git a/packages/ai-harness/src/client.ts b/packages/ai-harness/src/client.ts index 2b5a2551d6..c254f97f31 100644 --- a/packages/ai-harness/src/client.ts +++ b/packages/ai-harness/src/client.ts @@ -65,6 +65,14 @@ export interface HarnessClientOptions { * A read from the server sets the wait back to this value. */ reconnectDelayMs?: number + /** + * A connection of `events()` that gets no bytes for this long is dead, + * for example after a laptop sleeps or a NAT drops the mapping. The + * client closes it and connects again from its last cursor. Default + * 60 000 ms. Keep it larger than the `stream.heartbeatMs` of the server + * (default 20 000 ms), or the client reconnects in a loop. + */ + idleTimeoutMs?: number } type StartArgs = @@ -158,8 +166,13 @@ export interface HarnessClient { /** * Cancel an input that waits (see `snapshot().waitingInputs`). An input * that started is refused with `not_waiting`: use `cancel`. + * + * @example + * ```ts + * await client.cancelInput({ inputId }) + * ``` */ - cancelInput: (inputId: string) => Promise + cancelInput: (options: { inputId: string }) => Promise /** * Move an input that waits: `'steer'` joins the running turn, `'queue'` * runs it as its own turn later. @@ -204,9 +217,18 @@ export interface HarnessClient { }) => Promise /** * Add host records to the session log, as `session.append` does. The - * harness must list each record type in `expose.records`. + * harness must list each record type in `expose.records`. With `inputId`, + * a second append with the same id is a duplicate and writes nothing. + * + * @example + * ```ts + * await client.append({ records: [{ type: 'app.signal', text: 'Saved.' }] }) + * ``` */ - append: (records: ReadonlyArray) => Promise + append: (options: { + records: ReadonlyArray + inputId?: string + }) => Promise /** Change a session setting. */ setConfig: (change: { key: string; value: unknown }) => Promise /** @@ -231,8 +253,13 @@ export interface HarnessClient { /** * Go back to the message `messageId`, as `session.revert` does. The * harness must list `'undo'` in `expose.commands`. + * + * @example + * ```ts + * await client.revert({ messageId }) + * ``` */ - revert: (messageId: string) => Promise + revert: (options: { messageId: string }) => Promise /** End the revert that stands, as `session.unrevert` does. */ unrevert: () => Promise /** @@ -264,8 +291,15 @@ export interface HarnessClient { * The saved messages of a child agent run of the thread, by its * `subagentRunId`. Empty for an unknown id. A child with no messages has * one message with no content, with its card. See `session.agentTranscript`. + * + * @example + * ```ts + * const messages = await client.agentTranscript({ subagentRunId }) + * ``` */ - agentTranscript: (subagentRunId: string) => Promise> + agentTranscript: (options: { + subagentRunId: string + }) => Promise> /** * The commands, settings, and tools of the session. Only the commands in * `expose.commands` and the config keys in `expose.config`. @@ -278,17 +312,24 @@ export interface HarnessClient { * * @example * ```ts - * const record = await client.upload({ body: file, mimeType: file.type, name: file.name }) + * const record = await client.putMedia({ body: file, mimeType: file.type, name: file.name }) * ``` */ - upload: ( + putMedia: ( file: MediaUpload, ) => Promise /** * A signed URL for a media file, for ``, `

{ * ``` */ observability?: ObservabilityOptions + /** + * The harnesses whose threads this host may delete without an open first. + * `host.sessions.delete` ends a thread in the plugins of its harness, so + * they free what lives as long as the thread (an owned sandbox, a shadow + * repository). It finds the harness by the name in the index entry, among + * these and the harnesses that this host opened. A thread whose harness + * it does not know skips that step, with a warning. + * + * @example + * ```ts + * const host = createHarnessHost({ harnesses: [assistant] }) + * ``` + */ + harnesses?: ReadonlyArray } /** How the sessions of a host live. */ @@ -439,10 +455,12 @@ export interface HostSessions

{ }) => Promise /** * Remove the user `userId` from the members of a thread. Needs - * `membersOnly()`, and the `share` action. The work that the user started - * on the session in this host stops, and `host.revoke` runs for the user. - * The owner cannot be removed. Resolves to the changed entry, or - * `undefined` for a thread that has no entry. + * `membersOnly()`, and the `share` action. The running and queued work + * that the user started on the thread stops, on any host, their schedules + * do not run, and `host.revoke` runs for the user. When that work does + * not stop, it rejects after the member is removed: call `host.revoke` + * for the user to stop it. The owner cannot be removed. Resolves to the + * changed entry, or `undefined` for a thread that has no entry. * * @example * ```ts @@ -799,9 +817,14 @@ export interface HarnessHost

{ * Take the access of the user `principal` to a thread, after your app * changed its own access tables. `host.events()` yields a `revoked` * event, so the user's streams close. On a host with access, the open - * handles of that user on the thread throw `AccessDeniedError` from now - * on, and a new `host.open` asks `authorize` again. The `tenantId` of - * `principal` picks the tenant. Only `id` and `tenantId` are read. + * handles and `host.observe` views of that user on the thread throw + * `AccessDeniedError` from now on, their running `events()` reads end + * with it, and a new `host.open` asks `authorize` again. The running and + * queued work of the user on the thread is cancelled on any host, and + * their schedules do not run. Resolves when the cancels are sent, and + * rejects when they could not be sent: the access is gone already, so + * call it again to stop the work. The `tenantId` of `principal` picks + * the tenant. Only `id` and `tenantId` are read. * * @example * ```ts @@ -811,7 +834,7 @@ export interface HarnessHost

{ revoke: (options: { threadId: string principal: { id: string; tenantId?: string } - }) => void + }) => Promise /** * The user of `request`, from `access.identify`, or `null` when * `identify` refuses it. Network code other than the HTTP handler (an MCP @@ -1101,7 +1124,8 @@ export interface HostSweep { * Acquire the log of `orphan`, open its thread with `harness`, and take * its work over, as `host.resumePending` does for each log. Resolves * `null` when another host got the log. Rejects when the thread does not - * open: the log goes back, so a later sweep tries again. + * open: the log goes back with a wake of `OPEN_RETRY_MS`, so a sweep + * after that time tries again. */ takeOver: ( orphan: OrphanedLog, @@ -1214,6 +1238,20 @@ function forkedMembers( return { ownerId: forker.id, members: [owner, ...old, ...others] } } +/** + * The host records that the log fold of `harness` keeps: the `keepRecords` + * of its plugins, with the larger count for a type in two plugins. + */ +function keptRecordsOf(harness: AnyHarness) { + const keep = new Map() + for (const plugin of harness.plugins?.() ?? []) { + for (const [type, count] of Object.entries(plugin.keepRecords ?? {})) { + keep.set(type, Math.max(keep.get(type) ?? 0, count)) + } + } + return keep +} + /** * The messages of `messages` up to the last point where each tool call has * its result. A running turn can have a call with no result yet: it does @@ -1397,12 +1435,14 @@ export function createHarnessHost

( if (open) return open const project = harness.durability?.project const reduce = harness.durability?.reduce + const keep = keptRecordsOf(harness) const loaded = loadLogState({ store, logId, rootThread, ...(project ? { project } : {}), ...(reduce ? { reduce } : {}), + keep, }).then((state) => { const log = new SharedLog({ store, @@ -1415,6 +1455,7 @@ export function createHarnessHost

( eventBytes: retention.eventBytes, ...(project ? { project } : {}), ...(reduce ? { reduce } : {}), + keep, ...(options.log?.snapshot ? { snapshot: options.log.snapshot } : {}), logRecords: retention.logRecords, // The next open folds the log again. @@ -1431,6 +1472,15 @@ export function createHarnessHost

( return loaded } const sessions = new Map>() + /** + * The last harness definition that this host opened under each name, + * first from the `harnesses` option. A thread purge reads the harness + * name in the index entry, and mounts the plugins of that definition to + * end the thread (see `endThread`). + */ + const harnessesByName = new Map( + (options.harnesses ?? []).map((harness) => [harness.name, harness]), + ) /** The plugins with `lifetime: 'host'`, shared by the sessions of a harness. */ const hostPlugins = new HostPlugins() /** The sessions of `sessions` that finished opening, by key. */ @@ -1683,6 +1733,7 @@ export function createHarnessHost

( ): OpenChildSession => async (harness, { threadId, principal, child }) => { if (isClosed) throw hostClosed() + harnessesByName.set(harness.name, harness) const created: SessionImpl = new SessionImpl({ ...sessionDeps(ids, rootHarness, tenantId), harness, @@ -1721,6 +1772,7 @@ export function createHarnessHost

( tenantId: string | undefined, ) => { if (isClosed) return Promise.reject(hostClosed()) + harnessesByName.set(harness.name, harness) const key = sessionKey(harness.name, tenantId, threadId) const of = { tenantId, threadId } touch(key) @@ -1809,10 +1861,11 @@ export function createHarnessHost

( // Of two hosts that sweep at once, one gets the log. const epoch = held ?? (await ownership.acquire(storeLogId, name)) if (epoch === null) return null - // Only an ownership that this sweep acquired goes back here. - const giveBack = () => + // Only an ownership that this sweep acquired goes back here. `wakeIn` + // lists the log again after that time, so a later sweep tries again. + const giveBack = (wakeIn?: number) => held === undefined - ? ownership.release(storeLogId, epoch) + ? ownership.release(storeLogId, epoch, wakeIn) : Promise.resolve() // The store id of a log is its tenant and its root thread. const { tenantId, logId: threadId } = parseLogId(storeLogId) @@ -1838,9 +1891,10 @@ export function createHarnessHost

( return null } } catch (error) { - // The log goes back, so the next sweep tries again. + // The log goes back with a wake, so a sweep after the wait tries + // again. With no wake, no sweep lists the log again. if (isOwn) endSweep(key) - await giveBack().catch(() => {}) + await giveBack(OPEN_RETRY_MS).catch(() => {}) throw error } const thread: ResumedThread = { @@ -1858,8 +1912,29 @@ export function createHarnessHost

( // ponytail: one count per revoked user and thread, for the life of the // host. Drop the old ones if a host revokes millions. const revocations = new Map() + /** + * The reads that run now (`handle.events()`, a view of `host.observe`), + * by revoked key: a revoke ends them. + */ + const revokeListeners = new Map void>>() + /** Call `listener` at the next revoke of `key`. Returns the stop. */ + const onRevoke = (key: string, listener: () => void) => { + let listeners = revokeListeners.get(key) + if (!listeners) { + listeners = new Set() + revokeListeners.set(key, listeners) + } + const set = listeners + set.add(listener) + return () => { + set.delete(listener) + if (set.size === 0 && revokeListeners.get(key) === set) { + revokeListeners.delete(key) + } + } + } - /** Count a revoke, and tell the readers of `host.events()`. */ + /** Count a revoke, end the reads of the user, and tell `host.events()`. */ const revokeUser = ({ tenantId, threadId, @@ -1871,6 +1946,7 @@ export function createHarnessHost

( }) => { const key = revokedKey(tenantId, threadId, principalId) revocations.set(key, (revocations.get(key) ?? 0) + 1) + for (const listener of [...(revokeListeners.get(key) ?? [])]) listener() emit({ type: 'revoked', ...(tenantId !== undefined ? { tenantId } : {}), @@ -1997,6 +2073,7 @@ export function createHarnessHost

( const count = revocations.get(key) ?? 0 return { isRevoked: () => (revocations.get(key) ?? 0) !== count, + onRevoke: (listener) => onRevoke(key, listener), authorize: async (action, run, wait) => allow({ principal, @@ -2107,6 +2184,83 @@ export function createHarnessHost

( ) } + /** + * Stop the work of the user `userId` on a thread, on any host, after + * `unshare` or `revoke` took their access. The sessions open here cancel + * it at once. Then each input of the user that did not end gets a cancel + * through the input lane, so the owner of the log stops it, on any host. + * Each schedule of the user takes its input id in the lane, so it never + * runs: the store keeps each input id once. `principal` is who stops the + * work, and the log keeps it. A failure rejects: the access is gone + * already, so the caller can stop the work again. + */ + const stopWorkOf = async ({ + tenantId, + threadId, + userId, + principal, + }: { + tenantId: string | undefined + threadId: string + userId: string + principal: P | undefined + }) => { + try { + const open = await openSessionsOf(tenantId, threadId) + for (const session of open) { + await session.cancelWorkOf({ userId, principal }) + } + const entry = await index.get({ tenantId, threadId }) + // The lane needs the harness of the log. + if (entry?.harness === undefined) return + const harness = entry.harness + const logId = logIdOf({ tenantId, logId: threadId }) + const state = await loadLogState({ + store: logStore, + logId, + rootThread: threadId, + }) + const root = sessionOf(state, threadId) + /** The sessions here stopped the operations that they know. */ + const isKnownHere = (operationId: string) => + open.some((session) => session.operation(operationId) !== undefined) + const offer = ( + inputId: string, + record: Parameters[0]['record'], + ) => logStore.offer({ logId, harness, inputId, record }) + for (const logged of root.inputs.values()) { + const isTheirs = + logged.principal?.id === userId && WORK_OPS.has(logged.input.op) + const hasEnded = + logged.status === 'settled' || + logged.status === 'rejected' || + logged.status === 'joined' + if (!isTheirs || hasEnded) continue + const { operationId } = logged + if (operationId !== undefined && isKnownHere(operationId)) continue + const input: HarnessInput = + operationId === undefined + ? { op: 'cancelInput', inputId: logged.inputId } + : { op: 'cancel', operationId } + const inputId = createInputId() + await offer(inputId, offeredInputRecord({ inputId, input, principal })) + } + for (const [scheduleId, schedule] of root.thread.schedules) { + if (schedule.principal?.id !== userId || !schedule.input) continue + // The id that the schedule offers its input with when it fires. + await offer(`schedule:${scheduleId}`, { + type: 'harness.schedule.done', + scheduleId, + }) + } + } catch (error) { + throw new Error( + `The access of ${JSON.stringify(userId)} to thread ${JSON.stringify(threadId)} is gone, but their work did not stop: ${String(error)}. Call host.revoke again to stop it.`, + { cause: error }, + ) + } + } + /** The thread ids of the index entries with `threadId` as parent. */ const childrenOf = async (tenantId: string | undefined, threadId: string) => { if (!sessionStore) return [] @@ -2124,10 +2278,63 @@ export function createHarnessHost

( return children } + /** + * Tell the plugins of a thread that it ends for good (`ThreadEnds`), so + * they free what lives as long as the thread: an owned sandbox, a shadow + * repository. The sessions of the thread are closed already, so no turn + * runs. A short session with only the log and the session plugins runs + * the handlers, and closes again. A child thread lives in the log of its + * root, so the session reads that log. It needs the harness definition + * that the index entry names, from the `harnesses` option or an open on + * this host. Without it, it warns and runs nothing. With no entry, there + * is nothing to run. A failed handler only warns: the purge of the data + * goes on. + */ + const endThread = async (tenantId: string | undefined, threadId: string) => { + const entry = await index.get({ tenantId, threadId }) + const name = entry?.harness + if (!entry || name === undefined) return + const harness = harnessesByName.get(name) + if (!harness) { + console.warn( + `[@tanstack/ai-harness] Thread ${JSON.stringify(threadId)} is deleted, but its plugins did not end it: this host does not know the harness ${JSON.stringify(name)}. What lives as long as the thread (an owned sandbox, a shadow repository) is not freed. Pass the harness in the \`harnesses\` option of createHarnessHost, or open it on this host first.`, + ) + return + } + const root = await rootOf(entry) + // The harness of the root folds the log, as in `openChildIn`. + const rootHarness = harnessesByName.get(root.harness ?? name) ?? harness + const session = new SessionImpl({ + ...sessionDeps( + { + logId: logIdOf({ tenantId, logId: root.threadId }), + rootThread: root.threadId, + }, + rootHarness, + tenantId, + ), + harness, + threadId, + onEvent: () => {}, + onClose: () => {}, + }) + try { + await session.openToEnd() + await session.endThread() + } catch (error) { + console.warn( + `[@tanstack/ai-harness] A plugin did not end thread ${JSON.stringify(threadId)}: ${String(error)}`, + ) + } finally { + // As on a crash: the log gets no write of this short session. + await session.close({ recoverable: true }).catch(() => {}) + } + } + /** * `host.sessions.delete` after its check: close the open sessions of the - * thread, purge its children depth first, then delete its data and its - * entry. `seen` stops a loop of parent ids. + * thread, end it in its plugins, purge its children depth first, then + * delete its data and its entry. `seen` stops a loop of parent ids. */ const purge = async ( tenantId: string | undefined, @@ -2143,6 +2350,7 @@ export function createHarnessHost

( const session = await sessions.get(key)?.catch(() => undefined) if (session) await closeShared(key, session) } + await endThread(tenantId, threadId) const children = await childrenOf(tenantId, threadId) for (const child of children) await purge(tenantId, child, seen) const storeId = logIdOf({ tenantId, logId: threadId }) @@ -2274,8 +2482,9 @@ export function createHarnessHost

( if (isClosed) throw hostClosed() const tenantId = principal?.tenantId const entry = await index.get({ tenantId, threadId }) + let user: P | undefined if (access) { - const user = need(principal, 'host.observe') + user = need(principal, 'host.observe') // No entry is not found: the same answer as a refusal. if (!entry) throw new AccessDeniedError({ action: 'read', threadId }) // `allow` checks a child thread through its root thread. @@ -2285,6 +2494,9 @@ export function createHarnessHost

( // A child thread has its records in the log of its root, with its // thread on each. A thread with no entry is a root. const root = entry ? (await rootOf(entry)).threadId : threadId + // A revoke of the user on the thread (its root) ends the view: each + // later call throws, and a read that runs now stops. + const revoked = user && guardOf(user, root) const ids = { logId: logIdOf({ tenantId, logId: root }), rootThread: root, @@ -2303,7 +2515,11 @@ export function createHarnessHost

( }, check: () => { if (isClosed) throw hostClosed() + if (revoked?.isRevoked()) { + throw new AccessDeniedError({ action: 'read', threadId }) + } }, + ...(revoked ? { onRevoke: revoked.onRevoke } : {}), index, media, readLog: () => readLog(logStore, ids, harness, threadId), @@ -2472,12 +2688,21 @@ export function createHarnessHost

( await release(sourceKey) } }, - revoke: ({ threadId, principal }) => + revoke: async ({ threadId, principal }) => { + // At once, so the handles of the user refuse each call from now on. revokeUser({ tenantId: principal.tenantId, threadId, principalId: principal.id, - }), + }) + // No principal stops it: the app changed its own access tables. + await stopWorkOf({ + tenantId: principal.tenantId, + threadId, + userId: principal.id, + principal: undefined, + }) + }, hasAccess: access !== undefined, async identify({ request }) { if (!access) { @@ -2820,13 +3045,11 @@ export function createHarnessHost

( updatedAt: Date.now(), } }) - // After the write, so no new work of the user gets through. The log + // After the write, so no new work of the user gets through. At + // once, so the reads of the user end before the work stops. The log // keeps who cancelled it. - const open = await openSessionsOf(tenantId, threadId) - for (const session of open) { - await session.cancelWorkOf({ userId, principal: user }) - } revokeUser({ tenantId, threadId, principalId: userId }) + await stopWorkOf({ tenantId, threadId, userId, principal: user }) return changed }, }, diff --git a/packages/ai-harness/src/http.ts b/packages/ai-harness/src/http.ts index f36e975b31..11a0634556 100644 --- a/packages/ai-harness/src/http.ts +++ b/packages/ai-harness/src/http.ts @@ -208,11 +208,13 @@ function cursorResponse( // Not `request.signal`: some servers abort it once the body is read. const reader = new AbortController() watch?.(reader) - const cursors: Array = [] + // The stream encodes each event right after it pulls it, before the next + // pull, so the cursor of the last event is the only one it needs. + let cursor: string | undefined async function* chunks() { try { for await (const entry of read(reader.signal)) { - cursors.push(entry.cursor) + cursor = entry.cursor yield entry.event } } finally { @@ -222,9 +224,7 @@ function cursorResponse( } return new Response( withHeartbeat( - toServerSentEventsStream(chunks(), reader, (_chunk, index) => - cursors.at(index), - ), + toServerSentEventsStream(chunks(), reader, () => cursor), { heartbeatMs: stream.heartbeatMs }, ), { headers: SSE_HEADERS }, @@ -345,7 +345,7 @@ async function mediaResponse( view: ObservedSession, id: string, ) { - const record = await view.getMedia(id) + const record = await view.getMedia({ id }) if (!record) return json({ error: 'not found' }, 404) const asked = parseRangeHeader(request.headers.get('Range'), record.size) if (asked === 'unsatisfiable') { @@ -466,9 +466,18 @@ function watchRevoke

( onRevoke: () => void, ) { if (principal === undefined) return + const isRevoke = (event: HostEvent) => isRevokeOf(event, principal, threadId) + // Only the revokes of this principal, from the bus of its tenant: the + // filter runs before the queue, so other events cost this watch nothing, + // and the first revoke ends the watch, so its queue does not fill. + // A host that `createHarnessHost` did not make has `host.events()`. + const readTenant = tenantEventsOf(host) + const events = readTenant + ? readTenant({ tenantId: principal.tenantId, filter: isRevoke, signal }) + : host.events({ signal }) void (async () => { - for await (const event of host.events({ signal })) { - if (!isRevokeOf(event, principal, threadId)) continue + for await (const event of events) { + if (!isRevoke(event)) continue onRevoke() return } @@ -779,32 +788,49 @@ export function createHarnessHandler

( /** * The `host.events()` that `principal` may read. With access, a thread * needs the `read` action. The decision of each thread is kept for the - * stream, until a revoke or an index change of that thread. `accepts` + * stream, until a revoke or a delete of that thread. An index change of + * the thread drops only a decision that is not `true` (for example after + * a share), so the index changes of each turn read no store. `accepts` * reads no store, so the host event bus runs it before the queue. * `mayWatch` runs after the queue. */ const watchedEvents = (principal: P | undefined) => { const user = principal ? { principal } : {} const decisions = new Map>() + /** The threads whose kept decision is `true`. */ + const readable = new Set() + const forget = (threadId: string) => { + decisions.delete(threadId) + readable.delete(threadId) + } const mayRead = (threadId: string) => { const known = decisions.get(threadId) if (known) return known - const decision = host.sessions.get({ threadId, ...user }).then( - (entry) => entry !== undefined, - () => false, - ) + const decision: Promise = host.sessions + .get({ threadId, ...user }) + .then( + (entry) => { + // A revoke while the read ran dropped this decision. + const isKept = decisions.get(threadId) === decision + if (entry !== undefined && isKept) readable.add(threadId) + return entry !== undefined + }, + () => false, + ) decisions.set(threadId, decision) return decision } const accepts = (event: HostEvent) => { if (tenantOfHostEvent(event) !== principal?.tenantId) return false if (event.type === 'revoked') { - decisions.delete(event.threadId) + forget(event.threadId) // A user hears of their own revokes only. return event.principalId === principal?.id } - if (event.type === 'session' || event.type === 'session-deleted') { - decisions.delete(event.threadId) + if (event.type === 'session-deleted') forget(event.threadId) + // A user who may read the thread keeps that until a revoke. + if (event.type === 'session' && !readable.has(event.threadId)) { + forget(event.threadId) } return true } @@ -908,13 +934,18 @@ export function createHarnessHandler

( const { forwardedProps } = params // The span of the turn links the `traceparent` of the caller. const traceparent = request.headers.get('traceparent') - const operation = session.send(checked.parts, { + const sent = { ...ids, ...(Object.keys(forwardedProps).length > 0 ? { context: forwardedProps } : {}), ...(traceparent ? { traceparent } : {}), - }) + } + // Only the session class takes the run id. `host.open` gives one. + const operation = + session instanceof SessionImpl + ? session.sendForRun(checked.parts, sent) + : session.send(checked.parts, sent) // A turn that the host refused throws, and the answer is 403. const receipt = await turnReceipt(operation) if (receipt.status === 'rejected') { @@ -1052,7 +1083,7 @@ export function createHarnessHandler

( return json({ error: 'subagentRunId is required' }, 400) const view = await viewFor(principal, threadId) if (!view) return json({ error: 'not found' }, 404) - return json(await view.agentTranscript(subagentRunId)) + return json(await view.agentTranscript({ subagentRunId })) } if (request.method === 'GET' && route === 'snapshot') { @@ -1185,7 +1216,7 @@ export function createHarnessHandler

( const view = await viewFor(principal, threadId) if (!view) return json({ error: 'not found' }, 404) if (route === 'media') return mediaResponse(request, view, id) - if (!(await view.getMedia(id))) { + if (!(await view.getMedia({ id }))) { return json({ error: 'not found' }, 404) } const expiresAt = Date.now() + SIGNED_URL_TTL_MS @@ -1457,6 +1488,10 @@ export function handleHarnessSocket

( socket.addEventListener('close', () => reader.abort()) socket.addEventListener('error', () => reader.abort()) const frameLimit = maxBodyBytesOf(harness.media) + // The frames of the socket run one after another, as the stdio worker + // runs them: an input with media to store does not let a later input + // reach the session first. + let frames = Promise.resolve() socket.addEventListener('message', (message) => { const data = String(message.data) // A frame over `media.maxBodyBytes` is not parsed, so its error frame @@ -1476,7 +1511,7 @@ export function handleHarnessSocket

( // Read before the full parse, so a frame with a bad `input` still names // its request. const requestId = requestIdOf(data) ?? socketId - void (async () => { + frames = frames.then(async () => { try { const user = await who if (!user) return refuse('unauthorized', requestId) @@ -1568,6 +1603,6 @@ export function handleHarnessSocket

( // no session is open, so there is none to scrub. fail(code, errorMessage(scrubbedBy(holding, error)), requestId) } - })() + }) }) } diff --git a/packages/ai-harness/src/log.ts b/packages/ai-harness/src/log.ts index 7f094dd5a2..7789b4a7c7 100644 --- a/packages/ai-harness/src/log.ts +++ b/packages/ai-harness/src/log.ts @@ -93,6 +93,12 @@ export type HarnessRecord = } | { type: 'harness.input.joined'; inputId: string; into: string } | { type: 'harness.input.rejected'; inputId: string; reason: string } + /** + * The owner refused an offer from the input lane, and the log has another + * input with its id. It does not change that input. Only the host that + * offered it ends its wait with the refusal. + */ + | { type: 'harness.offer.rejected'; inputId: string; reason: string } | { type: 'harness.input.abort'; inputId: string } | { type: 'harness.turn.retry' @@ -124,13 +130,28 @@ export type HarnessRecord = value: unknown } | ({ type: 'harness.usage' } & UsageCall) - /** A revert that stands, or `null` when it ended. */ - | { type: 'harness.revert'; revert: RevertState | null } + /** + * A revert that stands, or `null` when it ended. `principal` is who + * reverted, on the record of `session.revert`. + */ + | { + type: 'harness.revert' + revert: RevertState | null + principal?: { id: string; tenantId?: string } + } /** * A background job started, or it ended. `backendId` is the id of its * process in the workspace backend, so a later host can stop it. + * `principal` is who moved a running call to the background + * (`session.background`). */ - | { type: 'harness.job'; jobId: string; ended: boolean; backendId?: string } + | { + type: 'harness.job' + jobId: string + ended: boolean + backendId?: string + principal?: { id: string; tenantId?: string } + } /** A note for the transcript. `noteId` is the id of its message. */ | { type: 'harness.note'; noteId: string; note: string } /** Thread state: settings, config, plugin state, and the rest. */ @@ -177,6 +198,7 @@ const HARNESS_RECORD_TYPES = new Set([ 'harness.input.applied', 'harness.input.joined', 'harness.input.rejected', + 'harness.offer.rejected', 'harness.input.abort', 'harness.turn.retry', 'harness.input.delivery', @@ -311,10 +333,56 @@ export interface LogState { jobs: Map /** Notes that are not in the transcript yet, by message id. */ notes: Map + /** + * The last host records of the types that the plugins keep (their + * `keepRecords`), oldest first. The snapshot keeps them, so a truncate of + * the log does not drop what `session.revert` reads. + */ + keptRecords: Array /** The thread state that the thread-state records fold to. */ thread: ThreadStateFold } +/** + * The host records that a fold keeps, by type, with how many of each it + * keeps. The `keepRecords` of the plugins give it. + */ +export type KeptRecords = ReadonlyMap + +/** The count of each type in a `keptRecords` list, made at its first use. */ +const keptCounts = new WeakMap, Map>() + +/** Keep `entry` in `state` when `keep` keeps its type. */ +function keepHostRecord( + state: LogState, + entry: LogEntry, + keep: KeptRecords | undefined, +) { + const { type } = entry.record + const limit = keep?.get(type) + if (limit === undefined) return + const kept = state.keptRecords + let counts = keptCounts.get(kept) + if (!counts) { + counts = new Map() + for (const { record } of kept) { + counts.set(record.type, (counts.get(record.type) ?? 0) + 1) + } + keptCounts.set(kept, counts) + } + kept.push(entry) + let count = (counts.get(type) ?? 0) + 1 + // ponytail: the splice is O(n), only when a type is full. A list for + // each type if many types are kept. + for (; count > limit; count--) { + kept.splice( + kept.findIndex((each) => each.record.type === type), + 1, + ) + } + counts.set(type, count) +} + /** * The cursor before the first event of run `operationId`: before its * first input record. A run that answers go on has more inputs, so the @@ -345,6 +413,7 @@ export function emptyLogState() { runUsage: new Map(), jobs: new Map(), notes: new Map(), + keptRecords: [], thread: emptyThreadState(), } return state @@ -477,10 +546,12 @@ export function foldEntry( state: LogState, entry: LogEntry, project?: ProjectRecord, + keep?: KeptRecords, ) { state.seq = entry.seq const { record } = entry if (!record.type.startsWith('harness.')) { + keepHostRecord(state, entry, keep) const next = project ? hostFold( entry.seq, @@ -495,6 +566,7 @@ export function foldEntry( if (!isHarnessRecord(record)) return switch (record.type) { case 'harness.event': + case 'harness.offer.rejected': return case 'harness.transcript': state.messages = [ @@ -750,6 +822,7 @@ interface FoldOptions { rootThread: string project?: ProjectRecord reduce?: ReduceOptions + keep?: KeptRecords } /** Fold one entry of a shared log. */ @@ -776,6 +849,7 @@ export function foldLogEntry( sessionOf(state, threadOf(record, options.rootThread)), entry, options.project, + options.keep, ) } @@ -916,6 +990,7 @@ function serialize(state: SharedLogState, versions: SnapshotVersions) { backendId === undefined ? jobId : [jobId, backendId], ), notes: [...session.notes.entries()], + keptRecords: session.keptRecords, thread: serializeThread(session.thread), }, ]), @@ -979,6 +1054,10 @@ function parseSession(value: unknown, seq: number) { ? (value.notes as Array<[string, string]>) : [], ), + // A state with no kept records gets an empty value. + keptRecords: Array.isArray(value.keptRecords) + ? (value.keptRecords as Array) + : [], thread, } return session @@ -1051,8 +1130,10 @@ export async function loadLogState(options: { rootThread: string project?: ProjectOptions reduce?: ReduceOptions + /** The host records to keep. See {@link KeptRecords}. */ + keep?: KeptRecords }) { - const { store, logId, rootThread, project, reduce } = options + const { store, logId, rootThread, project, reduce, keep } = options // A snapshot that does not load only makes the fold longer. const snapshot = await store.getSnapshot({ logId }).catch(() => null) const state = @@ -1062,6 +1143,7 @@ export async function loadLogState(options: { rootThread, ...(project ? { project: project.record } : {}), ...(reduce ? { reduce } : {}), + ...(keep ? { keep } : {}), } await readAll(store, logId, state, (entry) => foldLogEntry(state, entry, fold), @@ -1318,6 +1400,12 @@ export interface LogViewHooks { * it. Own events never come here: the session saw them at publish. */ onRemoteEvent?: (entry: SessionEvent) => void + /** + * This log folded records that another host wrote: on a notification, or + * when this host acquires the log. It comes before the `onRemoteEvent` + * calls, so a session reads the new fold first. + */ + onRemoteFold?: () => void /** * This host became the owner of the log, before its first append. Only * one session of the log gets it: the root session when it is open. @@ -1459,6 +1547,13 @@ export class SharedLog { */ private hasLeftWork = false private writing = false + /** + * The store notifications that came while this host appended. The store + * calls each instance once for each append, also for its own. A second + * call is another host's offer, so the write looks at the offers after + * its append. + */ + private callsInWrite = 0 private readonly unsubscribe: () => void private readonly stopLost: () => void private readonly stopWake: () => void @@ -1479,6 +1574,8 @@ export class SharedLog { eventBytes: number project?: ProjectOptions reduce?: ReduceOptions + /** The host records to keep. See {@link KeptRecords}. */ + keep?: KeptRecords /** When the owner writes a snapshot. */ snapshot?: SnapshotTiming /** Which records the log keeps after a snapshot. Default `'all'`. */ @@ -1504,6 +1601,7 @@ export class SharedLog { rootThread: options.rootThread, ...(options.project ? { project: options.project.record } : {}), ...(options.reduce ? { reduce: options.reduce } : {}), + ...(options.keep ? { keep: options.keep } : {}), } this.versions = versionsOf(options.project, options.reduce) this.tail = new EventRing({ @@ -1511,17 +1609,17 @@ export class SharedLog { from: options.state.seq, }) // Records from another writer join the fold. A call during this - // writer's own append is its own records. + // writer's own append can be its own records or another host's offer: + // the write looks at the offers after its append. this.unsubscribe = options.store.subscribe({ logId: options.logId, listener: () => { - if (this.writing) return - this.catchUpLater() - // The call can be a new offer. The owner applies it. A host that - // offered applies its own offers when no live host owns the log. - if (this.epoch !== undefined || this.offeredHere.size > 0) { - this.notifyOffers() + if (this.writing) { + this.callsInWrite += 1 + return } + this.catchUpLater() + this.onOffersChanged() }, }) this.stopLost = options.ownership.onLost(options.logId, (error) => @@ -1757,6 +1855,16 @@ export class SharedLog { await this.ownership.release(this.logId, epoch) } + /** + * A store notification can be a new offer. The owner applies it. A host + * that offered applies its own offers when no live host owns the log. + */ + private onOffersChanged() { + if (this.epoch !== undefined || this.offeredHere.size > 0) { + this.notifyOffers() + } + } + /** Fold the records of another writer, after the queued writes. */ private catchUpLater() { // ponytail: a read that fails leaves the fold behind until the next @@ -2081,6 +2189,7 @@ export class SharedLog { const epoch = await this.own() const seq = this.state.seq + 1 this.writing = true + this.callsInWrite = 0 try { await this.appendOnce({ seq, epoch, batch, consumes }) } catch (error) { @@ -2090,6 +2199,11 @@ export class SharedLog { throw error } finally { this.writing = false + // Only the owner appends, so a call is this append or an offer. + // Records of a new owner reach this host when its next renewal or + // write fails. A store that calls after the append calls outside of + // the write, and the listener looks at the offers then. + if (this.callsInWrite > 1) this.onOffersChanged() } batch.forEach((record, index) => this.apply({ seq: seq + index, record })) this.wake() @@ -2289,6 +2403,13 @@ export class SharedLog { this.lose(new OwnershipLostError(this.logId, this.epoch)) } this.wake() + for (const view of [...this.views.values()]) { + try { + view.hooks.onRemoteFold?.() + } catch { + // A hook never stops the fold of the log. + } + } // After the whole fold, so a hook reads the state after the events. for (const { thread, entry } of remote) { try { diff --git a/packages/ai-harness/src/media.ts b/packages/ai-harness/src/media.ts index 6783c0acdc..36cf4ced67 100644 --- a/packages/ai-harness/src/media.ts +++ b/packages/ai-harness/src/media.ts @@ -74,6 +74,17 @@ export type MediaStore = ReturnType const DEFAULT_MAX_BYTES = 100 * 1024 * 1024 const DATA_URL_MAX_BYTES = 1024 * 1024 const DEFAULT_ENCODED_BYTES = 32 * 1024 * 1024 +/** The base64 LRU of `createMediaStore`, one for each blob store. */ +interface EncodedCache { + /** The most characters it keeps. */ + cap: number + /** The characters it keeps now. */ + size: number + /** By `/`, the oldest first. */ + entries: Map +} +const encodedCaches = new WeakMap() + const ALL_KINDS: ReadonlyArray = [ 'image', 'audio', @@ -192,7 +203,10 @@ export function createMediaStore({ /** The tenant of the thread. Without it, the default tenant. */ tenantId?: string options?: MediaOptions - /** @internal How many characters of base64 to keep across turns. */ + /** + * @internal How many characters of base64 to keep across turns, for all + * the media stores of this blob store. The largest value asked for wins. + */ encodedBytes?: number }) { const { artifacts, blobs } = persistence.stores @@ -234,32 +248,41 @@ export function createMediaStore({ } // ponytail: an LRU of base64 text across turns, at most `encodedBytes` - // characters (default 32 MB). The key is the media id only: the bytes of an - // id never change, and this store sees one thread of one tenant. Above the - // cap, each turn encodes the oldest files again. Send adapters a URL when - // that gets slow. - const encodedCap = encodedBytes ?? DEFAULT_ENCODED_BYTES - const encoded = new Map() - let encodedSize = 0 + // characters (default 32 MB). The media stores of one blob store (all the + // sessions of a host) share it, so the cap is for the host, not for each + // open session. The largest `encodedBytes` asked for is the cap. The key is + // the store key of the thread and the media id: the bytes of an id never + // change. Above the cap, each turn encodes the oldest files again. Send + // adapters a URL when that gets slow. + const cap = encodedBytes ?? DEFAULT_ENCODED_BYTES + let encoded = encodedCaches.get(blobs) + if (!encoded) { + encoded = { cap, size: 0, entries: new Map() } + encodedCaches.set(blobs, encoded) + } + encoded.cap = Math.max(encoded.cap, cap) + const cache = encoded /** The base64 of the bytes of `id`. Call `get` first: a hit does not check the record. */ async function base64(id: string) { - const hit = encoded.get(id) + const key = `${storeThreadId}/${id}` + const hit = cache.entries.get(key) if (hit !== undefined) { // Delete and set again, so the Map order puts it last (newest). - encoded.delete(id) - encoded.set(id, hit) + cache.entries.delete(key) + cache.entries.set(key, hit) return hit } const value = toBase64(await load(id)) - if (value.length <= encodedCap) { - encoded.set(id, value) - encodedSize += value.length + // Another call stored it while this one read the bytes. + if (value.length <= cache.cap && !cache.entries.has(key)) { + cache.entries.set(key, value) + cache.size += value.length // The first keys of the Map are the oldest. - for (const [key, old] of encoded) { - if (encodedSize <= encodedCap) break - encoded.delete(key) - encodedSize -= old.length + for (const [oldKey, old] of cache.entries) { + if (cache.size <= cache.cap) break + cache.entries.delete(oldKey) + cache.size -= old.length } } return value diff --git a/packages/ai-harness/src/ownership.ts b/packages/ai-harness/src/ownership.ts index 26145c865d..6fb5868a23 100644 --- a/packages/ai-harness/src/ownership.ts +++ b/packages/ai-harness/src/ownership.ts @@ -4,6 +4,12 @@ import type { LogStore } from '@tanstack/ai-persistence' /** How long a host owns a log after each renewal, and how often it renews. */ export const OWNERSHIP = { ttlMs: 30_000, renewMs: 10_000 } +/** + * How long a sweep waits before it tries again a log whose thread did not + * open. The log goes back with this wake time. + */ +export const OPEN_RETRY_MS = 30_000 + /** * Ownership timing for a host, and its stable id. A missing timing field * uses {@link OWNERSHIP}. @@ -22,10 +28,53 @@ export interface OwnershipOptions { * store clock. Default 30 000. */ ttlMs?: number - /** How often the host renews every log it owns, in milliseconds. Default 10 000. */ + /** + * How often the host renews every log it owns, in milliseconds. It must be + * less than `ttlMs`. Default 10 000, or a third of `ttlMs` when you set + * only `ttlMs`. + */ renewMs?: number } +/** + * @internal For tests only. Timing with this key is not checked, so a test + * host can own a log for a short time and never renew it, like a process + * that stopped. + */ +export const UNCHECKED_TIMING = Symbol('harness.uncheckedTiming') + +/** + * The `ttlMs` and `renewMs` of `timing`, with the defaults. Throws a + * `RangeError` for a value that is not more than 0, and when `renewMs` is + * not less than `ttlMs`: then each ownership expires before its renewal, + * and other hosts take the logs of this host while it runs. + */ +function ownershipTiming(timing: OwnershipOptions = {}) { + const ttlMs = timing.ttlMs ?? OWNERSHIP.ttlMs + const renewMs = + timing.renewMs ?? + (timing.ttlMs === undefined + ? OWNERSHIP.renewMs + : Math.max(1, Math.floor(ttlMs / 3))) + if (UNCHECKED_TIMING in timing) return { ttlMs, renewMs } + for (const [name, value] of [ + ['ttlMs', ttlMs], + ['renewMs', renewMs], + ] as const) { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError( + `ownership.${name} must be a number more than 0, not ${String(value)}.`, + ) + } + } + if (renewMs >= ttlMs) { + throw new RangeError( + `ownership.renewMs (${renewMs}) must be less than ownership.ttlMs (${ttlMs}). Else the ownership of a log expires before its renewal, and other hosts take it.`, + ) + } + return { ttlMs, renewMs } +} + /** * A write that this host cannot do: another host owns the log, or took it * from this host. Nothing was written. This host still reads the log. @@ -90,8 +139,9 @@ export class HostOwnership { this.store = options.store this.ownerId = options.ownerId this.affinity = options.affinity - this.ttlMs = options.timing?.ttlMs ?? OWNERSHIP.ttlMs - this.renewMs = options.timing?.renewMs ?? OWNERSHIP.renewMs + const { ttlMs, renewMs } = ownershipTiming(options.timing) + this.ttlMs = ttlMs + this.renewMs = renewMs } /** The epoch of `logId` while this host owns it. */ @@ -137,13 +187,14 @@ export class HostOwnership { /** * Give `logId` up. With `epoch`, only that ownership: a later acquire * keeps the log. A log that this host does not own does nothing. The - * store gets the wake time of {@link setWake}, when the log has one. + * store gets `wakeIn` as the wake time, else the wake time of + * {@link setWake}, when the log has one. */ - release(logId: string, epoch = this.owned.get(logId)) { + release(logId: string, epoch = this.owned.get(logId), wakeIn?: number) { const isOwned = epoch !== undefined && this.owned.get(logId) === epoch if (!isOwned) return Promise.resolve() this.forget(logId) - const wakeInMs = this.wakes.get(logId)?.() + const wakeInMs = wakeIn ?? this.wakes.get(logId)?.() const released = this.store .release({ logId, diff --git a/packages/ai-harness/src/plugins.ts b/packages/ai-harness/src/plugins.ts index b400918970..9ff0b80f55 100644 --- a/packages/ai-harness/src/plugins.ts +++ b/packages/ai-harness/src/plugins.ts @@ -122,11 +122,13 @@ export interface PluginContributions { * `discoverTools`. Return the new list. Runs before prompts resolve, so a * prompt `text()` can describe the tools this returned. Code mode uses it to * move tools behind `execute_typescript`. `model` is the model id of the - * turn's adapter, so the list can differ for each model. + * turn's adapter, so the list can differ for each model. `run` is the run + * of the turn: its id and its user. */ prepareTools?: (turn: { tools: ReadonlyArray model: string + run: RunContext }) => ReadonlyArray | Promise> } @@ -409,6 +411,19 @@ interface PluginDefinitionBase { optionalRequires?: ReadonlyArray /** Agent outputs this plugin needs the session to have. */ needs?: { produces?: ReadonlyArray } + /** + * The host records of this plugin that the log fold keeps, by record + * type, with how many of each: the last ones. The fold snapshot keeps + * them, so they stay readable after a truncate of the log. The plugins + * of the harness that opens the log first (its root thread) decide. With + * one type in two plugins, the larger count wins. + * + * @example + * ```ts + * definePlugin({ name: 'acme/steps', keepRecords: { 'acme/steps:step': 100 } }) + * ``` + */ + keepRecords?: Readonly> } /** A plugin that is set up for each session (default) or for each turn. */ @@ -628,8 +643,9 @@ export interface MountedPlugins { * A copy without everything that the plugins in `names` contributed: tools, * prompts (also the `'context'` prompts), every kind of middleware, agents, subagents, commands, config, * adapter picks, discoverers, preparers, extension items, and capabilities. - * The copy is a snapshot: a later `ctx.agents.set` or `ctx.commands.set` - * changes this set only. It shares `dispose` with this set. It throws when + * The copy reads the commands and agents of this set at each use, so a + * later `ctx.agents.set` or `ctx.commands.set` of a kept plugin reaches + * it. It shares `dispose` with this set. It throws when * a kept plugin requires a capability that a removed plugin provides. */ withoutOwners: (names: ReadonlyArray) => MountedPlugins @@ -706,6 +722,7 @@ const NO_SESSION: PluginSessionApi = { send: unavailable('ctx.session'), note: unavailable('ctx.session'), transcript: unavailable('ctx.session'), + keptRecords: unavailable('ctx.session'), replaceTranscript: unavailable('ctx.session'), entry: unavailable('ctx.session'), updateEntry: unavailable('ctx.session'), @@ -759,15 +776,18 @@ export interface RevertFilesHandler { /** * Put back the files that the tool calls of `hidden` changed. Other files * stay as they are. `records` reads the host records of one type from the - * session log. Resolves to what `unrevert` needs (JSON), or `undefined` - * when no file changed. + * session log. `run` is a run of the revert in the thread of the session, + * so a workspace that is not open on this host opens again from the log. + * Resolves to what `unrevert` needs (JSON), or `undefined` when no file + * changed. */ revert: ( hidden: ReadonlyArray, records: (type: string) => Promise>>, + run: RunContext, ) => Promise - /** Put the files back as they were before `revert`. */ - unrevert: (saved: unknown) => Promise + /** Put the files back as they were before `revert`. `run` as in `revert`. */ + unrevert: (saved: unknown, run: RunContext) => Promise } /** @internal See {@link RevertFilesHandler}. */ @@ -852,6 +872,44 @@ export interface WorkspaceLogApi { stranded: (input: { operationId: string; hostId?: string }) => void } +/** + * @internal Tells first-party plugins that a thread ended for good: + * `host.sessions.delete` purges it. A session close is not the end of a + * thread (the next host reattaches it), so this is the only place to free + * what lives as long as the thread, like an owned sandbox or the shadow + * repository of the snapshots. A plugin adds its handler in its setup, also + * in the `session` part of a host plugin. The session provides it. The + * package does not export it. + */ +export const ThreadEnds = createCapability()( + 'tanstack/thread-ends', +) + +/** @internal See {@link ThreadEnds}. */ +export interface ThreadEndsApi { + /** Add `handler`. Gives a function that removes it. */ + add: (handler: ThreadEndHandler) => () => void +} + +/** + * @internal A handler of {@link ThreadEnds}. `run` is a run of the purge in + * the thread, for a `reattach` that needs one. + */ +export type ThreadEndHandler = (thread: { + tenantId: string | undefined + threadId: string + run: RunContext +}) => Promise + +/** + * @internal Takes the secrets of the session out of `text`, as the session + * does for tool results. A plugin that writes tool output to a file uses it. + * The session provides it. The package does not export it. + */ +export const SessionScrub = createCapability<(text: string) => string>()( + 'tanstack/session-scrub', +) + /** Capability values provided by plugins, keyed by handle. */ export class CapabilityValues { readonly context = { capabilities: new CapabilityRegistry() } @@ -962,9 +1020,14 @@ function withoutOwnersOf( mounted.generationMiddleware, owners.generationMiddleware, ), - commands: new Map( - [...mounted.commands].filter(([, entry]) => isKept(entry.owner)), - ), + // Commands and agents are read at each use: a kept plugin can change + // them later (`ctx.commands.set`, `ctx.agents.set`), and this thread + // must see that. + get commands() { + return new Map( + [...mounted.commands].filter(([, entry]) => isKept(entry.owner)), + ) + }, config: new Map( [...mounted.config].filter(([, entry]) => isKept(entry.owner)), ), @@ -972,8 +1035,12 @@ function withoutOwnersOf( adapters: keep(mounted.adapters, owners.adapters), discoverers: mounted.discoverers.filter((item) => isKept(item.owner)), preparers: mounted.preparers.filter((item) => isKept(item.owner)), - agents: keep(mounted.agents, owners.agents), - subagents: keep(mounted.subagents, owners.subagents), + get agents() { + return keep(mounted.agents, owners.agents) + }, + get subagents() { + return keep(mounted.subagents, owners.subagents) + }, owners: { plugins: owners.plugins.filter((plugin) => isKept(plugin.name)), tools: owners.tools.filter((entry) => isKept(entry.owner)), @@ -982,8 +1049,12 @@ function withoutOwnersOf( adapters: owners.adapters.filter(isKept), agentMiddleware: owners.agentMiddleware.filter(isKept), generationMiddleware: owners.generationMiddleware.filter(isKept), - agents: owners.agents.filter(isKept), - subagents: owners.subagents.filter(isKept), + get agents() { + return owners.agents.filter(isKept) + }, + get subagents() { + return owners.subagents.filter(isKept) + }, capabilities: owners.capabilities.filter((entry) => isKept(entry.owner)), }, capabilityBridge: capabilityBridgeOf(values), diff --git a/packages/ai-harness/src/protocol.ts b/packages/ai-harness/src/protocol.ts index 13b7dd9200..ce6dd77964 100644 --- a/packages/ai-harness/src/protocol.ts +++ b/packages/ai-harness/src/protocol.ts @@ -9,6 +9,7 @@ import type { AnyAgent } from './agents' import type { AnyHarness } from './define' import type { InlineMediaContext } from './inline-media' import type { HarnessSession, SessionSnapshot } from './session/index' +import type { AgentRunStartOptions } from './session/types' import type { Cursor, HarnessInput, @@ -395,9 +396,11 @@ export async function applyInput({ // It runs no new code, so it needs no `expose` entry, like `cancel`. It // stores no input, so there is no sender to keep. case 'background': - return session.background(input.toolCallId) + return session.background( + input.toolCallId !== undefined ? { toolCallId: input.toolCallId } : {}, + ) case 'cancelInput': - return session.cancelInput(input.inputId, user) + return session.cancelInput({ inputId: input.inputId, ...user }) case 'setDelivery': return session.setDelivery({ inputId: input.inputId, @@ -409,9 +412,12 @@ export async function applyInput({ if (!(harness.expose?.commands ?? []).includes(input.name)) { return notExposed } + // The client's id makes a resent command a duplicate, so it runs once. + const inputId = input.inputId ?? createInputId() const operation = session.command({ name: input.name, input: input.input, + inputId, ...user, }) // ponytail: a command has no receipt, so it is accepted at once. A @@ -420,11 +426,7 @@ export async function applyInput({ () => {}, () => {}, ) - return { - inputId: operation.id, - status: 'accepted', - operationId: operation.id, - } + return { inputId, status: 'accepted', operationId: operation.id } } case 'answer': return session.answer({ @@ -457,11 +459,16 @@ export async function applyInput({ (record) => types.includes(record.type) && !('thread' in record), ) if (!isExposed) return notExposed - const inputId = input.inputId ?? createInputId() + // The client's id makes a resent append a duplicate that writes + // nothing. try { - await session.append(input.records) - return { inputId, status: 'accepted' } + return await session.append({ + records: input.records, + ...(input.inputId !== undefined ? { inputId: input.inputId } : {}), + ...user, + }) } catch (error) { + const inputId = input.inputId ?? createInputId() return { inputId, status: 'rejected', reason: errorText(error) } } } @@ -481,7 +488,7 @@ export async function applyInput({ case 'unrevert': if (!(harness.expose?.commands ?? []).includes('undo')) return notExposed return input.op === 'revert' - ? session.revert(input.messageId) + ? session.revert({ messageId: input.messageId }) : session.unrevert() case 'agent': { const exposed = (harness.expose?.agents ?? []).includes(input.agent) @@ -495,15 +502,16 @@ export async function applyInput({ // The run is the client's: its credentials and keys are theirs. // ponytail: accepted at once, as a command. A run that the host // refuses fails with a `RUN_ERROR` event. - const operation = handle.start(input.input, { + // The client's id makes a resent start a duplicate, so the agent runs + // once. + const inputId = input.inputId ?? createInputId() + const options: AgentRunStartOptions = { wake: input.detached === true, + inputId, ...user, - }) - return { - inputId: operation.id, - status: 'accepted', - operationId: operation.id, } + const operation = handle.start(input.input, options) + return { inputId, status: 'accepted', operationId: operation.id } } case 'agentMessage': { const run = session.agentRun(input.operationId) diff --git a/packages/ai-harness/src/session/agents.ts b/packages/ai-harness/src/session/agents.ts index 6a57312a41..a90b5cf853 100644 --- a/packages/ai-harness/src/session/agents.ts +++ b/packages/ai-harness/src/session/agents.ts @@ -124,7 +124,8 @@ export abstract class SessionAgents< * command, a protocol input). `undefined` when no such agent exists. */ agent(name: string): DynamicAgentHandle | undefined { - if (!this.agentRegistry.get(name)) return undefined + // An agent of a plugin that this thread removed does not exist here. + if (!this.threadAgent(name)) return undefined return this.agentHandle(name) } @@ -141,7 +142,14 @@ export abstract class SessionAgents< const follower = new OperationImpl( 'agent', this.feed, - () => this.cancel({ operationId: target }), + () => { + // It acts as the user of the run, as a cancel of the run does. + const principal = this.principalOfRun(target) + return this.cancel({ + operationId: target, + ...(principal ? { principal } : {}), + }) + }, chain?.agent, ) void this.sendToAgent({ @@ -516,6 +524,7 @@ export abstract class SessionAgents< }, (inner, ctx) => this.runHarnessChild(inner, ctx, { operation, principal }), + run, ) return { harness } }) @@ -1094,7 +1103,14 @@ export abstract class SessionAgents< for (const item of this.writer.state.thread.tree.values()) { if (item.chainInputId === first.inputId) node = item } - return chainFields(first, node) + const fields = chainFields(first, node) + // The input keeps only a parent run of agent code. The tree keeps the + // turn that started the chain too, so a run of the chain on any host + // knows that turn. + const parent = node?.parentRunId + return parent !== undefined && fields.parentRunId === undefined + ? { ...fields, turnRunId: parent } + : fields } /** @@ -1165,10 +1181,17 @@ export abstract class SessionAgents< id?: string, startedCursor?: Cursor, ): AgentRunImpl { + // A call on the run itself acts as the user of the run, not as the + // principal that opened the session. A handle passes its own principal. + const user = (running: string) => { + const principal = this.principalOfRun(running) + return principal ? { principal } : {} + } const operation = new OperationImpl( 'agent', this.feed, - (running) => this.cancel({ operationId: running.id }), + (running) => + this.cancel({ operationId: running.id, ...user(running.id) }), agent, id, startedCursor, @@ -1179,9 +1202,18 @@ export abstract class SessionAgents< { send: ( message: UserInput, - options?: { mode?: 'steer' | 'followUp'; inputId?: string }, + options?: { + mode?: 'steer' | 'followUp' + inputId?: string + principal?: Principal + }, ) => - this.sendToAgent({ ...options, operationId: operation.id, message }), + this.sendToAgent({ + ...user(operation.id), + ...options, + operationId: operation.id, + message, + }), }, ) this.track(run) @@ -1466,7 +1498,13 @@ export abstract class SessionAgents< try { await this.runAgentOnce(operation, chain, start) } catch (error) { - // A store write threw. The run still ends. + // A store write threw. The run still ends. A run that had no receipt + // yet gets a refusal, so a reader of it does not wait. + operation.resolveReceipt({ + inputId: start.inputId, + status: 'rejected', + reason: error instanceof Error ? error.message : String(error), + }) this.failOperation(operation, error) } finally { // After a log failure, another host owns the thread. A closing session @@ -1581,6 +1619,11 @@ export abstract class SessionAgents< // An offered run runs as this operation on the owner. { runId: operation.id }, ) + // As a turn does: a refused, offered, or known input gets its receipt + // now. A new one gets it below, when the run starts or waits. + if (admission !== 'new') { + operation.resolveReceipt(this.duplicateReceipt(inputId, admission)) + } switch (admission) { case 'new': break @@ -1610,15 +1653,23 @@ export abstract class SessionAgents< // A top-level start waits for a slot of `maxConcurrent`. const isTopLevel = chain.parentRunId === undefined && chain.turnRunId === undefined + if (isTopLevel && !this.hasSlot()) { + operation.resolveReceipt({ + inputId, + status: 'queued', + operationId: operation.id, + }) + } if (isTopLevel && !(await this.takeSlot(operation, chain, inputId))) { return } } + // By name, an agent of a plugin that this thread removed is unknown. const agent: AnyAgent | undefined = - typeof target === 'string' ? this.agentRegistry.get(target) : target + typeof target === 'string' ? this.threadAgent(target) : target if (!agent) { this.releaseStart(chain) - this.reject(inputId, 'unknown_agent') + operation.resolveReceipt(this.reject(inputId, 'unknown_agent')) operation.fail('failed', new Error(`Unknown agent: ${name}`)) return } @@ -1633,7 +1684,7 @@ export abstract class SessionAgents< .map((issue) => issue.message) .join(', ')}` this.releaseStart(chain) - this.reject(inputId, 'invalid_input') + operation.resolveReceipt(this.reject(inputId, 'invalid_input')) operation.fail('failed', new Error(reason)) return } @@ -1641,6 +1692,12 @@ export abstract class SessionAgents< } operation.setStatus('running') + // A run that waited for a slot has its receipt already. + operation.resolveReceipt({ + inputId, + status: 'accepted', + operationId: operation.id, + }) // A run that goes on after its waits applied its input already. The // node of the run goes in the same append. if (!answers) { @@ -1763,6 +1820,7 @@ export abstract class SessionAgents< }, (inner, ctx) => this.runHarnessChild(inner, ctx, { operation, principal }), + run, ) const stream = runAgentStream( agent, @@ -2440,8 +2498,16 @@ export abstract class SessionAgents< if (queued.has(id)) continue this.pendingNotes.push({ id, role: 'assistant', content: note }) } - for (const [jobId, backendId] of [...writer.state.jobs]) { - if (this.jobs.has(jobId) || backendId !== undefined) continue + const lost = [...writer.state.jobs] + .filter( + ([jobId, backendId]) => + !this.jobs.has(jobId) && backendId === undefined, + ) + .map(([jobId]) => jobId) + // Only the owner notes them. While another live host owns the log, the + // job runs there. + if (lost.length > 0 && !(await writer.own())) return + for (const jobId of lost) { const id = createMessageId() const note = `Background job ${jobId} stopped when the host restarted.` this.pendingNotes.push({ id, role: 'assistant', content: note }) diff --git a/packages/ai-harness/src/session/base.ts b/packages/ai-harness/src/session/base.ts index 09468e6408..cd924e7e40 100644 --- a/packages/ai-harness/src/session/base.ts +++ b/packages/ai-harness/src/session/base.ts @@ -90,8 +90,9 @@ import type { RunContext, SessionEvent, ThreadSettings, - SendOptions, + SendForRunOptions, TurnInfo, + UserInput, } from '../types' import type { AgentRunOptions, @@ -365,7 +366,10 @@ export abstract class SessionBase { /** Background jobs that run on this host. Recovery skips them. */ protected readonly jobs = new Set() /** Running tool calls that support `detach`: id to the move. */ - protected readonly detachable = new Map void>() + protected readonly detachable = new Map< + string, + (principal: Principal | undefined) => void + >() protected activeTurn: OperationImpl | undefined /** The open skipped recovery: another host owned the thread then. */ protected recoveryDeferred = false @@ -835,6 +839,18 @@ export abstract class SessionBase { return cache.plugins } + /** + * The agent `name` of the session registry, as this thread has it: + * `undefined` when a plugin that this thread removed added it. + */ + protected threadAgent(name: string): AnyAgent | undefined { + const owner = this.agentRegistry.ownerOf(name) + if (owner === undefined || this.removedPlugins().has(owner)) { + return undefined + } + return this.agentRegistry.get(name) + } + /** Put each plugin's saved state in the snapshot before the first read. */ protected loadPluginState() { for (const [plugin, load] of this.stateLoaders) { @@ -1096,8 +1112,8 @@ export abstract class SessionBase { /** * @internal No input being stored, no queued or running turn, no waiting - * steer, and no running agent. The host closes an idle session that no - * handle holds. + * steer, no running agent, and no background job of this host. The host + * closes an idle session that no handle holds. */ isIdle(): boolean { // A follow-up that waits for its run keeps the thread busy. @@ -1106,6 +1122,9 @@ export abstract class SessionBase { ) if (hasFollowUps) return false return ( + // A background job of this host runs in its memory. A close stops it, + // and its end note is lost. + this.jobs.size === 0 && // A live wait (an `ask()`) waits in this host's memory: its answer // must come here, so it keeps the log. A wait that only the log keeps // does not: the session is idle, and an answer reaches any host. @@ -1175,9 +1194,10 @@ export abstract class SessionBase { } /** - * Resolves when the thread is idle: no input is being stored, and no turn, - * steer, agent run, or command runs or waits. A thread that waits for a - * human (an approval, a sign-in) is idle. When the thread is idle now, it + * Resolves when the thread is idle: no input is being stored, no turn, + * steer, agent run, or command runs or waits, and no background job of + * this host runs. A thread that waits for a human (an approval, a sign-in) + * is idle. When the thread is idle now, it * resolves at once. When `signal` aborts first, it rejects with the reason * of `signal`. * @@ -1311,12 +1331,18 @@ export abstract class SessionBase { * Track a background job of this host. The log also has its start and * end, so recovery can note a job that a crash stopped. The record goes * to `threadId`, the thread of the workspace that runs the job. + * `principal` is who moved the job to the background. */ protected logJob( - job: { jobId: string; backendId?: string; threadId?: string }, + job: { + jobId: string + backendId?: string + threadId?: string + principal?: Principal + }, ended: boolean, ) { - const { jobId, backendId, threadId } = job + const { jobId, backendId, threadId, principal } = job if (ended) this.jobs.delete(jobId) else this.jobs.add(jobId) const isOtherThread = threadId !== undefined && threadId !== this.threadId @@ -1325,10 +1351,13 @@ export abstract class SessionBase { jobId, ended, ...(!ended && backendId !== undefined ? { backendId } : {}), + ...storedPrincipal(principal), ...(isOtherThread ? { thread: threadId } : {}), } // No one waits for a job record. A failed log write fails the session. this.writer.append([record]).catch(() => undefined) + // The last job ended: the host can close the session now. + if (ended) queueMicrotask(() => this.checkIdle()) } // Implemented by a later layer of the class. @@ -1355,10 +1384,43 @@ export abstract class SessionBase { protected abstract armSchedules(options?: { isRetry?: boolean }): void /** @internal See `SessionRequests.resolveForRun`. */ abstract resolveForRun(options: ResolveForRunOptions): Promise + /** @internal See `SessionRequests.sendForRun`. */ + abstract sendForRun( + message: UserInput, + options?: SendForRunOptions, + ): Operation + /** See `SessionRequests.applyCancelInput`. */ + protected abstract applyCancelInput(options: { + inputId: string + principal?: Principal + controlId?: string + }): Promise + /** See `SessionRequests.applySetDelivery`. */ + protected abstract applySetDelivery(options: { + inputId: string + delivery: 'steer' | 'queue' + principal?: Principal + controlId?: string + }): Promise + /** See `SessionState.applyConfig`. */ + protected abstract applyConfig(options: { + key: string + value: unknown + principal?: Principal + controlId?: string + }): Promise + /** See `SessionState.applyAnswer`. */ + protected abstract applyAnswer(options: { + questionId: string + value: unknown + principal?: Principal + controlId?: string + ref?: string + }): Promise protected abstract recoverFromLog(writer: LogWriter): Promise protected abstract startTurn( input: HarnessInput & { op: 'prompt' | 'steer' | 'followUp' }, - options?: SendOptions & { wake?: boolean }, + options?: SendForRunOptions & { wake?: boolean }, ): Operation protected abstract runAgent( target: string | AnyAgent, diff --git a/packages/ai-harness/src/session/handle.ts b/packages/ai-harness/src/session/handle.ts index 17624c517a..645d957b90 100644 --- a/packages/ai-harness/src/session/handle.ts +++ b/packages/ai-harness/src/session/handle.ts @@ -25,6 +25,47 @@ export interface HandleGuard { ) => Promise /** `host.revoke` took the access of the principal of the handle. */ isRevoked: () => boolean + /** + * Call `listener` when `host.revoke` (or `unshare`) takes the access of + * the principal of the handle. Returns a function that stops the calls. + */ + onRevoke: (listener: () => void) => () => void +} + +/** + * @internal The items of `read`, until `signal` aborts or `onRevoke` calls + * its listener. A revoke ends the read with `AccessDeniedError`, so a read + * that started before the revoke stops too. The listener is added at the + * call, not at the first pull, so a revoke between them ends the read too. + * + * ponytail: an iterable that is never read keeps its listener until the + * revoke. + */ +export function untilRevoked({ + onRevoke, + threadId, + signal, + read, +}: { + onRevoke: HandleGuard['onRevoke'] + threadId: string + signal: AbortSignal | undefined + read: (signal: AbortSignal) => AsyncIterable +}): AsyncGenerator { + const revoked = new AbortController() + const stop = onRevoke(() => revoked.abort()) + return (async function* () { + try { + yield* read( + signal ? AbortSignal.any([signal, revoked.signal]) : revoked.signal, + ) + } finally { + stop() + } + if (revoked.signal.aborted) { + throw new AccessDeniedError({ action: 'read', threadId }) + } + })() } /** How a handle reaches the shared session of its thread. The host gives one. */ @@ -403,6 +444,44 @@ export function sessionHandle({ live(action) return run((target) => checkedOn(target, call)) } + /** The operations that `guarded` wrapped, so one operation gets one wrapper. */ + const wrapped = new WeakMap() + /** + * `operation`, with its `cancel` and `send` through this handle: they act + * as the principal of the handle, after `authorize` allows them. + */ + const guarded = >(operation: T): T => { + const known = wrapped.get(operation) + if (known) return known as T + const proxy = new Proxy(operation, { + get(target, key) { + if (key === 'cancel') { + return async () => cancel({ operationId: target.id }) + } + const value: unknown = Reflect.get(target, key) + if (typeof value !== 'function') return value + if (key === 'send') { + return async ( + message: UserInput, + options?: Omit, + ) => { + const sent = send(message, { ...options, to: target.id }) + const receipt = await sent.receipt + // A refused access check throws, as the calls of the handle do. + if (receipt.status === 'rejected') { + await Promise.resolve(sent).catch((error: unknown) => { + if (error instanceof AccessDeniedError) throw error + }) + } + return receipt + } + } + return value.bind(target) + }, + }) + wrapped.set(operation, proxy) + return proxy + } /** A call that stores an input and returns its operation. */ const storingOperation = >( kind: OperationKind, @@ -410,7 +489,11 @@ export function sessionHandle({ agent?: string, ) => { live('send') - return runOperation(kind, (target) => checkedOn(target, call), agent) + return runOperation( + kind, + (target) => guarded(checkedOn(target, call)), + agent, + ) } /** * A call that stores no input: it runs after `authorize` allows `action`. @@ -434,7 +517,10 @@ export function sessionHandle({ run: (input?: unknown, options?: AgentRunOptions) => storingOperation( 'agent', - (target) => target.agentHandle(name, principal).run(input, options), + (target) => + target + .agentHandle(name, actor(options?.principal)) + .run(input, options), name, ), start: (input?: unknown, options?: AgentStartOptions) => @@ -474,18 +560,48 @@ export function sessionHandle({ live('read') const target = open() const isHeld = target !== undefined && !token.isReleased - return isHeld ? target.events(options) : heldEvents(options) + const read = (next?: EventsOptions) => + isHeld ? target.events(next) : heldEvents(next) + if (!guard) return read(options) + // A revoke ends a read that runs now, not only the next call. + return untilRevoked({ + onRevoke: guard.onRevoke, + threadId, + signal: options?.signal, + read: (signal) => read({ ...options, signal }), + }) + } + const send = (message: UserInput, options?: SendOptions | AgentSendOptions) => + storingOperation(options?.to === undefined ? 'chat' : 'agent', (target) => + options?.to === undefined + ? target.send(message, as(options ?? {})) + : target.send(message, as(options)), + ) + const sendForRun = ( + message: UserInput, + options?: Parameters['sendForRun']>[1], + ) => + storingOperation('chat', (target) => + target.sendForRun(message, as(options ?? {})), + ) + const cancel = (options?: Parameters['cancel']>[0]) => + storing('cancel', (target) => target.cancel(as(options ?? {}))) + /** The operation `id` of the session, through this handle. */ + const operationOf = >( + find: (target: Session) => T | undefined, + ) => { + live('read') + const found = find(open() ?? last) + return found && guarded(found) } const methods = new Map([ ...Object.entries({ - send: (message: UserInput, options?: SendOptions | AgentSendOptions) => - storingOperation( - options?.to === undefined ? 'chat' : 'agent', - (target) => - options?.to === undefined - ? target.send(message, as(options ?? {})) - : target.send(message, as(options)), - ), + send, + sendForRun, + // A returned operation cancels, and an agent run takes messages, + // through this handle too. + operation: (id: string) => operationOf((target) => target.operation(id)), + agentRun: (id: string) => operationOf((target) => target.agentRun(id)), tool: (options: Parameters['tool']>[0]) => storingOperation('chat', (target) => target.tool(as(options))), continue: (options?: Parameters['continue']>[0]) => @@ -505,15 +621,9 @@ export function sessionHandle({ ) => storing('answer', (target) => target.resolveForRun(as(options))), answer: (options: Parameters['answer']>[0]) => storing('answer', (target) => target.answer(as(options))), - cancel: (options?: Parameters['cancel']>[0]) => - storing('cancel', (target) => target.cancel(as(options ?? {}))), - cancelInput: ( - inputId: string, - options?: Parameters['cancelInput']>[1], - ) => - storing('cancel', (target) => - target.cancelInput(inputId, as(options ?? {})), - ), + cancel, + cancelInput: (options: Parameters['cancelInput']>[0]) => + storing('cancel', (target) => target.cancelInput(as(options))), configure: (options: Parameters['configure']>[0]) => storing('configure', (target) => target.configure(as(options))), setConfig: (options: Parameters['setConfig']>[0]) => @@ -522,13 +632,13 @@ export function sessionHandle({ agent: (name: string) => (open() ?? last).agent(name) === undefined ? undefined : agentOf(name), // These store no input, so the handle asks `authorize` itself. - revert: (messageId: string) => - acting('send', (target) => target.revert(messageId)), + revert: (options: Parameters['revert']>[0]) => + acting('send', (target) => target.revert(as(options))), unrevert: () => acting('send', (target) => target.unrevert()), - background: (toolCallId?: string) => - acting('send', (target) => target.background(toolCallId)), - append: (records: Parameters['append']>[0]) => - acting('send', (target) => target.append(records)), + background: (options?: Parameters['background']>[0]) => + acting('send', (target) => target.background(as(options ?? {}))), + append: (options: Parameters['append']>[0]) => + acting('send', (target) => target.append(as(options))), putMedia: (upload: Parameters['putMedia']>[0]) => acting('send', (target) => target.putMedia(upload)), updateEntry: (update: Parameters['updateEntry']>[0]) => diff --git a/packages/ai-harness/src/session/harness-session.ts b/packages/ai-harness/src/session/harness-session.ts index 0819cad5ca..91c3e53b7a 100644 --- a/packages/ai-harness/src/session/harness-session.ts +++ b/packages/ai-harness/src/session/harness-session.ts @@ -59,12 +59,15 @@ export interface HarnessSession< | 'transferSummary' | 'onIdle' | 'resolveForRun' + | 'sendForRun' | 'checked' | 'cancelWorkOf' | 'agentHandle' | 'isIdle' | 'isClosing' | 'openWait' + | 'openToEnd' + | 'endThread' // A handle closes itself, not the session. | 'close' > { @@ -170,6 +173,41 @@ export class SessionImpl< } } + /** + * @internal For `host.sessions.delete`: open the log and the session + * plugins, and nothing else. No recovery, no index entry, no offers, and + * no schedules, so no work of the thread runs again before the purge. Call + * `endThread`, then close it. + */ + async openToEnd(): Promise { + await this.openLog() + await this.mountSessionPlugins() + this.loadSettings() + } + + /** + * @internal The thread ends for good: each `ThreadEnds` handler of the + * plugins runs, the newest first, so a plugin that needs the workspace of + * an older plugin runs before that workspace closes. Every handler runs. + * Then the first error, if any, is thrown. + */ + async endThread(): Promise { + const run = this.runContext({ + principal: undefined, + runId: `end:${this.threadId}`, + signal: this.lifetime.signal, + }) + const errors: Array = [] + for (const handler of [...this.threadEnds].reverse()) { + await handler({ + tenantId: this.tenantId, + threadId: this.threadId, + run, + }).catch((error: unknown) => errors.push(error)) + } + if (errors.length > 0) throw errors[0] + } + /** Also waits for the last append of the log, so a reopen reads it. */ override async close(options?: { recoverable?: boolean }): Promise { // The log keeps the schedules. The release of the log gives the store @@ -532,6 +570,9 @@ export class SessionImpl< operationId, event, }), + // Records of another host: the settings, the config, and the + // interrupted turn come from the new fold. + onRemoteFold: () => this.syncFromLog(), // This host became the owner. The event goes in the next append. onOwned: (change) => this.feed.publish( diff --git a/packages/ai-harness/src/session/helpers.ts b/packages/ai-harness/src/session/helpers.ts index f8907970ac..715cd1f8bb 100644 --- a/packages/ai-harness/src/session/helpers.ts +++ b/packages/ai-harness/src/session/helpers.ts @@ -29,7 +29,7 @@ import type { } from '../log-records' import type { MountedPlugins } from '../plugins' import type { - HarnessInput, + OfferedHarnessInput, MediaKind, OperationStatus, Principal, @@ -121,7 +121,7 @@ function namedOverrides(extras: TurnExtras) { export interface OfferedInputRecord extends LogRecord { type: 'harness.input' inputId: string - input: HarnessInput + input: OfferedHarnessInput principal?: { id: string; tenantId?: string } /** * The id that the owner runs the input with: the AG-UI run id of a turn, @@ -138,7 +138,7 @@ export interface OfferedInputRecord extends LogRecord { /** The offer record of `input`. See {@link OfferedInputRecord}. */ export function offeredInputRecord(offer: { inputId: string - input: HarnessInput + input: OfferedHarnessInput principal: Principal | undefined runId?: string turn?: TurnExtras diff --git a/packages/ai-harness/src/session/inputs.ts b/packages/ai-harness/src/session/inputs.ts index b832cd41be..8cf24ed7cc 100644 --- a/packages/ai-harness/src/session/inputs.ts +++ b/packages/ai-harness/src/session/inputs.ts @@ -28,6 +28,7 @@ import type { AnyHarness } from '../define' import type { ChatTurnResult, HarnessInput, + OfferedHarnessInput, InputSettlement, Principal, Receipt, @@ -59,7 +60,7 @@ interface AcceptOptions { * The input that goes to the owner when another host owns the log, if it * is not the input that this host logs. It throws to refuse the input. */ - offer?: () => Promise + offer?: () => Promise } /** The offer that the next `accept` applies. See `applyOffer`. */ @@ -240,14 +241,39 @@ export abstract class SessionInputs< * With `project` on the host, a record can change the model context. The * running turn sees the change at its next model call. * + * With `inputId`, the append is an input, as a `send` is: a second append + * with the same id is a duplicate and writes nothing, so a client can + * send it again after a lost answer. Without it, the append gets a new + * input id. `principal` is who appends. Default: the principal that + * opened the session. The receipt has the input id, as the log keeps it. + * * @example * ```ts - * await session.append([{ type: 'app.signal', text: 'The build failed.' }]) + * await session.append({ + * records: [{ type: 'app.signal', text: 'The build failed.' }], + * }) * ``` */ - async append(records: ReadonlyArray): Promise { + async append({ + records, + inputId = createInputId(), + principal, + }: { + records: ReadonlyArray + inputId?: string + principal?: Principal + }): Promise { checkHostRecords(records) - await this.writer.append(records) + const admission = await this.accept( + inputId, + { op: 'append', records: [...records] }, + principal ?? this.principal, + ) + if (admission !== 'new') return this.duplicateReceipt(inputId, admission) + // In the append of the applied record: a crash keeps both or neither. + await this.applied(inputId, 'session', records) + // Kept, so a duplicate gets the same receipt. + return this.keep({ inputId, status: 'accepted' }) } /** @@ -261,13 +287,24 @@ export abstract class SessionInputs< * wakes an idle session. Without `toolCallId`, it moves every running * call that supports it, such as `bash` and the agent tools. * With no such call, the receipt is rejected with `not_running`. + * `principal` is who moves it. Default: the principal that opened the + * session. The `harness.job` record of each moved call keeps it. A handle + * of `host.open` checks that it is the principal of the handle. The move + * stores no input. * * @example * ```ts * await session.background() + * await session.background({ toolCallId: 'call_1' }) * ``` */ - async background(toolCallId?: string): Promise { + async background({ + toolCallId, + principal, + }: { + toolCallId?: string + principal?: Principal + } = {}): Promise { const inputId = createInputId() const ids = toolCallId === undefined ? [...this.detachable.keys()] : [toolCallId] @@ -276,7 +313,7 @@ export abstract class SessionInputs< return { inputId, status: 'rejected', reason: 'not_running' } } for (const id of ids) this.detachable.delete(id) - for (const move of moves) move() + for (const move of moves) move(principal ?? this.principal) return { inputId, status: 'accepted' } } @@ -288,7 +325,9 @@ export abstract class SessionInputs< * @example * ```ts * for (const input of session.inputs()) { - * if (input.delivery === 'queue') await session.cancelInput(input.inputId) + * if (input.delivery === 'queue') { + * await session.cancelInput({ inputId: input.inputId }) + * } * } * ``` */ @@ -350,16 +389,33 @@ export abstract class SessionInputs< ) } signal?.throwIfAborted() + // A refused input ends too: the owner on another host writes only its + // rejected record, and `knownSettlement` throws for it. const settledEvent = (entry: SessionEvent) => entry.event.type === EventType.CUSTOM && - entry.event.name === HARNESS_EVENTS.inputSettled && + (entry.event.name === HARNESS_EVENTS.inputSettled || + entry.event.name === HARNESS_EVENTS.inputRejected) && isRecord(entry.event.value) && entry.event.value.inputId === inputId - for await (const _entry of this.feed.read({ + for await (const entry of this.feed.read({ from: this.feed.head(), filter: settledEvent, ...(signal ? { signal } : {}), })) { + // The owner refused the offer of this host: the log has another input + // with this id, and only this host ends with the refusal. + const value = entry.event.type === EventType.CUSTOM && entry.event.value + if ( + isRecord(value) && + value.offered === true && + this.offeredInputs.has(inputId) + ) { + throw new InputRejectedError({ + inputId, + status: 'rejected', + reason: typeof value.reason === 'string' ? value.reason : 'conflict', + }) + } const settlement = this.knownSettlement(inputId) if (settlement) return settlement } @@ -577,7 +633,9 @@ export abstract class SessionInputs< const consumes = offer ? [inputId] : [] if (known) { // The input ran before: its offer goes, and it does not run again. - if (offer) await this.writer.append([], { consumes }) + // An offer with another payload is refused, so its host stops waiting. + if (offer && known === 'conflict') await this.refuseOffer(inputId) + else if (offer) await this.writer.append([], { consumes }) return known } // A closing session runs no new work. Nothing is stored. An offer stays @@ -752,13 +810,23 @@ export abstract class SessionInputs< await done return } + // A prompt with a known id ends before `accept`. One with another + // payload is refused, so the host that offered it stops waiting. + const payload = inputKey(record.input, record.principal) + if (this.knownAdmission(inputId, payload) === 'conflict') { + await this.refuseOffer(inputId) + return + } } // `host.transfer` on a host that does not own the log offers records // here: the source end of the transfer, and the usage of its summary. // They go in the log as they are. A transfer that the log has already - // only drops its offer. + // only drops its offer. An unshare or a revoke offers the end of the + // schedules of the user, so they go from the list at once. const isLaneRecord = - record.type === 'harness.usage' || record.type === 'harness.transferred' + record.type === 'harness.usage' || + record.type === 'harness.transferred' || + record.type === 'harness.schedule.done' const isKnown = record.type === 'harness.transferred' && typeof record.transferId === 'string' && @@ -799,7 +867,7 @@ export abstract class SessionInputs< switch (input.op) { case 'prompt': ignore( - session.send(input.message, { + this.sendForRun(input.message, { ...sent, ...turn, ...(input.busy ? { busy: input.busy } : {}), @@ -868,11 +936,17 @@ export abstract class SessionInputs< ) return case 'cancelInput': - ignore(session.cancelInput(input.inputId, { inputId, ...user })) + ignore( + this.applyCancelInput({ + inputId: input.inputId, + controlId: inputId, + ...user, + }), + ) return case 'setDelivery': ignore( - session.setDelivery({ + this.applySetDelivery({ inputId: input.inputId, delivery: input.delivery, controlId: inputId, @@ -892,21 +966,21 @@ export abstract class SessionInputs< return case 'answer': ignore( - session.answer({ + this.applyAnswer({ questionId: input.questionId, value: input.value, ...(input.ref !== undefined ? { ref: input.ref } : {}), - inputId, + controlId: inputId, ...user, }), ) return case 'config': ignore( - session.setConfig({ + this.applyConfig({ key: input.key, value: input.value, - inputId, + controlId: inputId, ...user, }), ) @@ -923,10 +997,13 @@ export abstract class SessionInputs< }), ) return + case 'append': + // Only an append with an input id is an input that a host offers. + ignore(session.append({ records: input.records, inputId, ...user })) + return case 'revert': case 'unrevert': case 'background': - case 'append': // These run only where they are called. No host offers them. return } @@ -1002,6 +1079,28 @@ export abstract class SessionInputs< return receipt } + /** + * Refuse an offer whose id the log has with another payload. The record + * consumes the offer in the same append. The input in the log does not + * change: the event has `offered`, so only the host that offered the + * input ends its wait with the refusal. + */ + private async refuseOffer(inputId: string) { + const reason = 'conflict' + await this.writer.append( + [{ type: 'harness.offer.rejected', inputId, reason }], + { consumes: [inputId] }, + ) + this.feed.publish( + 'session', + customEvent(HARNESS_EVENTS.inputRejected, { + inputId, + reason, + offered: true, + }), + ) + } + /** * Record how inputs ended, all in one append: a turn and the inputs that * joined it settle together. Clients get a `harness.input.settled` event. @@ -1123,7 +1222,15 @@ export abstract class SessionInputs< } protected refuse(operation: OperationImpl, receipt: Receipt) { - this.answerTurn(operation, receipt) + // A conflict refuses a second payload. The id stays with the input that + // has it, so `settled()` of that input does not read this refusal. + const id = receipt.inputId + const isOtherInput = + receipt.status === 'rejected' && + receipt.reason === 'conflict' && + (this.admitted.has(id) || this.writer.state.inputs.has(id)) + if (isOtherInput) operation.resolveReceipt(receipt) + else this.answerTurn(operation, receipt) operation.fail('failed', new InputRejectedError(receipt)) } diff --git a/packages/ai-harness/src/session/observed-session.ts b/packages/ai-harness/src/session/observed-session.ts index b327bca03e..03e7d16157 100644 --- a/packages/ai-harness/src/session/observed-session.ts +++ b/packages/ai-harness/src/session/observed-session.ts @@ -2,6 +2,7 @@ import { loadChildTranscript } from '@tanstack/ai-persistence' import { runStartOf, visibleMessages } from '../log' import { snapshotWaits } from '../waits' import { createMediaStore } from '../media' +import { untilRevoked } from './handle' import type { ModelMessage } from '@tanstack/ai' import type { BlobRange, @@ -19,6 +20,7 @@ import type { } from '../define' import type { SessionIndexWriter } from '../host' import type { Cursor, MediaRecord, SessionEvent } from '../types' +import type { HandleGuard } from './handle' import type { SessionImpl } from './harness-session' import type { SessionSnapshot } from './types' @@ -75,9 +77,11 @@ export interface ObservedSession { * The saved messages of a child agent run of the thread, as * `session.agentTranscript()` gives them. Empty for an unknown id. */ - agentTranscript: (subagentRunId: string) => Promise> + agentTranscript: (options: { + subagentRunId: string + }) => Promise> /** The record of a media file of the thread, or `null` when it is not found. */ - getMedia: (id: string) => Promise + getMedia: (options: { id: string }) => Promise /** * The bytes of the media file `id`, or of one `range` of them. Throws a * `MediaError` with 404 when it is not found. @@ -108,8 +112,13 @@ export interface ObservedSources { tenantId: string | undefined /** The open session of the thread on this host, or `undefined`. */ live: () => SessionImpl | undefined - /** Throws when the host is closed. */ + /** Throws when the host is closed, and when a revoke took the access. */ check: () => void + /** + * With access: call `listener` when `host.revoke` (or `unshare`) takes the + * access of the user of the view. A read that runs then stops. + */ + onRevoke?: HandleGuard['onRevoke'] index: SessionIndexWriter media: MediaPersistence /** @@ -226,9 +235,19 @@ export function observedSession( } } + const { onRevoke } = sources const view: ObservedSession = { threadId, - events: (options) => events(options ?? {}), + // A revoke ends a read that runs now, not only the next call. + events: (options) => + onRevoke + ? untilRevoked({ + onRevoke, + threadId, + signal: options?.signal, + read: (signal) => events({ ...options, signal }), + }) + : events(options ?? {}), async transcript() { const session = open() if (session) return session.transcript() @@ -275,7 +294,7 @@ export function observedSession( } return snapshot }, - async agentTranscript(subagentRunId) { + async agentTranscript({ subagentRunId }) { check() const { reader } = await readLog() try { @@ -294,7 +313,7 @@ export function observedSession( reader.close() } }, - getMedia(id) { + getMedia({ id }) { check() return mediaStore.get(id) }, diff --git a/packages/ai-harness/src/session/requests.ts b/packages/ai-harness/src/session/requests.ts index 7ce1da9056..e5cfee2b16 100644 --- a/packages/ai-harness/src/session/requests.ts +++ b/packages/ai-harness/src/session/requests.ts @@ -28,6 +28,7 @@ import type { Receipt, ResetOptions, ResolveOptions, + SendForRunOptions, SendOptions, TurnOverrides, UserInput, @@ -127,6 +128,18 @@ export abstract class SessionRequests< options?: SendOptions | AgentSendOptions, ): Operation { if (options?.to !== undefined) return this.sendToRun(message, options) + return this.sendForRun(message, options) + } + + /** + * @internal `send` of a message to the session, with the AG-UI `runId` of + * a `POST run` request: the turn runs as it. The HTTP handler and an + * offered prompt call it. + */ + override sendForRun( + message: UserInput, + options?: SendForRunOptions, + ): Operation { const context = options?.context const mode = options?.mode ?? 'prompt' const input: HarnessInput = @@ -160,7 +173,7 @@ export abstract class SessionRequests< */ protected startTurn( input: HarnessInput & { op: 'prompt' | 'steer' | 'followUp' }, - options?: SendOptions & { wake?: boolean }, + options?: SendForRunOptions & { wake?: boolean }, ): Operation { const { message } = input const inputId = options?.inputId ?? createInputId() @@ -413,7 +426,9 @@ export abstract class SessionRequests< * * @example * ```ts - * await session.append([{ type: 'app.signal', text: 'The build failed.' }]) + * await session.append({ + * records: [{ type: 'app.signal', text: 'The build failed.' }], + * }) * await session.continue({ inputId: 'after-signal' }) * ``` */ @@ -781,15 +796,15 @@ export abstract class SessionRequests< /** * @internal Cancel the queued and running work that the user `userId` - * started: `host.sessions.unshare` stops it. `principal` is who cancels, - * and the log keeps it. + * started: `host.sessions.unshare` and `host.revoke` stop it. `principal` + * is who cancels, and the log keeps it. */ async cancelWorkOf({ userId, principal, }: { userId: string - principal: Principal + principal: Principal | undefined }): Promise { const theirs = [...this.operations.values()].filter( (operation) => @@ -797,7 +812,10 @@ export abstract class SessionRequests< this.startedBy(operation.id).principalId === userId, ) for (const operation of theirs) { - await this.cancel({ operationId: operation.id, principal }) + await this.cancel({ + operationId: operation.id, + ...(principal ? { principal } : {}), + }) } } @@ -811,22 +829,34 @@ export abstract class SessionRequests< * @example * ```ts * await session.send('Then write the tests.', { mode: 'followUp', inputId: 'tests' }) - * await session.cancelInput('tests') + * await session.cancelInput({ inputId: 'tests' }) * ``` */ - async cancelInput( - target: string, - options?: { - principal?: Principal - /** @internal The id of an offered input, so a replay is a duplicate. */ - inputId?: string - }, - ): Promise { - const inputId = options?.inputId ?? createInputId() + cancelInput(options: { + inputId: string + principal?: Principal + }): Promise { + return this.applyCancelInput(options) + } + + /** + * `cancelInput`, with `controlId`: the id of an offered input, so a replay + * is a duplicate. + */ + protected override async applyCancelInput({ + inputId: target, + principal, + controlId, + }: { + inputId: string + principal?: Principal + controlId?: string + }): Promise { + const inputId = controlId ?? createInputId() const admission = await this.accept( inputId, { op: 'cancelInput', inputId: target }, - options?.principal, + principal, ) if (admission !== 'new') return this.duplicateReceipt(inputId, admission) const taken = this.takeWaiting(target) ?? this.takeQueuedContinue(target) @@ -863,7 +893,19 @@ export abstract class SessionRequests< * await session.setDelivery({ inputId: 'style', delivery: 'steer' }) * ``` */ - async setDelivery({ + setDelivery(options: { + inputId: string + delivery: WaitingInput['delivery'] + principal?: Principal + }): Promise { + return this.applySetDelivery(options) + } + + /** + * `setDelivery`, with `controlId`: the id of an offered input, so a replay + * is a duplicate. + */ + protected override async applySetDelivery({ inputId: target, delivery, principal, @@ -872,7 +914,6 @@ export abstract class SessionRequests< inputId: string delivery: WaitingInput['delivery'] principal?: Principal - /** @internal The id of an offered input, so a replay is a duplicate. */ controlId?: string }): Promise { const inputId = controlId ?? createInputId() @@ -970,8 +1011,10 @@ export abstract class SessionRequests< /** * The session API of plugins. With `principal` (a command, a turn plugin), - * it acts for that user. Without it (a session plugin), its `principal` is - * the principal that opened the session. With `operationId` (a command, a + * it acts for that user. Without it (a session plugin), it acts for the + * owner of the session entry, else for the principal that opened the + * session. A `principal` option of `send` and `note` wins: a plugin passes + * the user whose work the turn continues. With `operationId` (a command, a * turn plugin), its questions belong to that operation. */ protected override pluginApi( @@ -979,13 +1022,11 @@ export abstract class SessionRequests< metadataSchema?: SchemaInput, operationId?: string, ): PluginSessionApi { - // ponytail: D9. Without `principal`, `send`, `note`, and `ask` follow the - // running turn: its sender, and its operation for a question. PR 7 - // rebuilds them on the run that calls. - const sender = () => principal ?? this.sender() // A session plugin acts for the owner of the entry, not for whoever // opened the session first on this host. The owner is known only after - // the session read its entry, so it is read at each call. + // the session read its entry, so it is read at each call. A turn of + // `send` or `note` never follows the sender of the running turn: that + // user did not start the work of the plugin. const owner = () => principal ?? this.owner ?? this.principal return { threadId: this.threadId, @@ -994,23 +1035,24 @@ export abstract class SessionRequests< }, snapshot: () => this.snapshot(), // A plugin turn always waits its turn. It cannot see a busy rejection. - send: (text) => + send: (text, options) => this.startTurn( { op: 'prompt', message: text, busy: 'queue' }, - { principal: sender() }, + { principal: options?.principal ?? owner() }, ), note: async (text, options) => { await this.addNote(text) if (options?.wake) { const receipt = await this.startTurn( { op: 'followUp', message: text }, - { principal: sender(), wake: true }, + { principal: options.principal ?? owner(), wake: true }, ).receipt if (receipt.status === 'rejected') throw new InputRejectedError(receipt) } }, transcript: () => this.transcript(), + keptRecords: async (type) => this.keptRecords(type), replaceTranscript: (messages) => this.messages.saveThread(this.threadId, messages), entry: () => this.index.get(this.indexKey), @@ -1147,7 +1189,15 @@ export abstract class SessionRequests< const operation = new OperationImpl( 'chat', this.feed, - (target) => this.cancel({ operationId: target.id }), + (target) => { + // A cancel on the turn itself acts as its sender, not as the + // principal that opened the session. A handle passes its own. + const principal = this.principalOfRun(target.id) + return this.cancel({ + operationId: target.id, + ...(principal ? { principal } : {}), + }) + }, undefined, id, startedCursor, diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index 7b2ea83d12..db5bcabde7 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -10,7 +10,7 @@ import { LogOwnedElsewhereError } from '../ownership' import { isRecord } from '../utils' import { jsonSchemaIssues } from '../json-schema' import { changedEntry } from '../session-entry' -import { stableJson, threadOf, visibleMessages } from '../log' +import { stableJson, visibleMessages } from '../log' import { isHumanTarget } from '../log-records' import { mediaIdOf, mediaOfMessage, mediaPart } from '../media-ref' import { @@ -19,7 +19,9 @@ import { RevertFiles, RevertStanding, SessionMetadata, + SessionScrub, SessionSignal, + ThreadEnds, WorkspaceLog, } from '../plugins' import { HARNESS_EVENTS } from '../types' @@ -55,6 +57,7 @@ import type { SessionIndexEntry, } from '@tanstack/ai-persistence' import type { RevertState } from '../log' +import type { ThreadEndHandler } from '../plugins' import type { HandoffRecord, RecordPrincipal, @@ -74,7 +77,7 @@ import type { SessionEntryChange, SessionEntryUpdate } from '../session-entry' import type { ChatTurnResult, ConfigureOptions, - HarnessInput, + OfferedHarnessInput, MediaRecord, MediaUpload, PendingQuestion, @@ -118,6 +121,8 @@ export abstract class SessionState< protected readonly waitEnds = new Map void>() /** The secret answers to `step.ask` waits that this host kept, by wait id. */ protected readonly keptAnswers = new Map() + /** The handlers that plugins added to `ThreadEnds`, oldest first. */ + protected readonly threadEnds = new Set() /** The stores that first-party plugins read as capabilities. */ protected storeCapabilities() { @@ -126,6 +131,13 @@ export abstract class SessionState< if (metadata) values.provide(SessionMetadata, metadata) values.provide(RevertStanding, () => this.reverted !== undefined) values.provide(SessionSignal, this.lifetime.signal) + values.provide(SessionScrub, (text) => this.scrub(text)) + values.provide(ThreadEnds, { + add: (handler) => { + this.threadEnds.add(handler) + return () => this.threadEnds.delete(handler) + }, + }) values.provide(BackgroundJobs, { started: (job) => this.logJob(job, false), ended: (job) => this.logJob(job, true), @@ -299,7 +311,7 @@ export abstract class SessionState< } /** The record of a media file of this thread, or `null` when it is not found. */ - getMedia(id: string) { + getMedia({ id }: { id: string }) { return this.mediaStore.get(id) } @@ -319,7 +331,7 @@ export abstract class SessionState< * has no server, so this is a data URL for a file up to 1 MB, and `{}` for * a bigger file (use `loadMedia`). */ - async mediaUrl(id: string) { + async mediaUrl({ id }: { id: string }) { const url = await this.mediaStore.dataUrl(id) const answer: { url?: string; expiresAt?: number } = url === undefined ? {} : { url } @@ -355,7 +367,7 @@ export abstract class SessionState< const copies = new Map() const copy = async (id: string) => { if (!copies.has(id)) { - const record = await source.getMedia(id) + const record = await source.getMedia({ id }) copies.set( id, record && @@ -554,17 +566,28 @@ export abstract class SessionState< * who changes it. Default: the principal that opened the session. The log * keeps it. */ - async setConfig({ + setConfig(options: { + key: string + value: unknown + principal?: Principal + }): Promise { + return this.applyConfig(options) + } + + /** + * `setConfig`, with `controlId`: the id of an offered input, so a replay + * is a duplicate. + */ + protected override async applyConfig({ key, value, - inputId = createInputId(), + controlId: inputId = createInputId(), principal, }: { key: string value: unknown - /** @internal The id of an offered input, so a replay is a duplicate. */ - inputId?: string principal?: Principal + controlId?: string }): Promise { const admission = await this.accept( inputId, @@ -633,16 +656,19 @@ export abstract class SessionState< principal, ) if (admission !== 'new') return this.duplicateReceipt(inputId, admission) + // The store took the input, so this host owns the log, and its fold has + // the settings that other hosts wrote. The change merges into them. + const current = this.settingsFromLog() let next: ThreadSettings try { - next = changeSettings(this.threadSettings, settings, this.settingNames()) + next = changeSettings(current, settings, this.settingNames()) this.checkRemovable(next) } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.reject(inputId, reason) return this.keep({ inputId, status: 'rejected', reason }) } - const movedFrom = this.threadSettings.cwd + const movedFrom = current.cwd this.threadSettings = next // Tell the model about a new folder, so it stops using the old paths. // A new thread gets no note: some providers refuse a conversation that @@ -759,7 +785,11 @@ export abstract class SessionState< * child of this thread too. Empty for an unknown id, and for a child of * another thread. */ - async agentTranscript(subagentRunId: string): Promise> { + async agentTranscript({ + subagentRunId, + }: { + subagentRunId: string + }): Promise> { return ( (await loadChildTranscript(this.engine, subagentRunId, this.threadId)) ?? [] @@ -800,9 +830,23 @@ export abstract class SessionState< * hidden messages for good. The revert state is saved, so it survives a * restart. Refused while the session is not idle, or another host runs * the thread (`'busy'`), and for a message that is not in the transcript - * (`'unknown_message'`). + * (`'unknown_message'`). `principal` is who reverts. Default: the + * principal that opened the session. The `harness.revert` record keeps + * it. A handle of `host.open` checks that it is the principal of the + * handle. + * + * @example + * ```ts + * await session.revert({ messageId: message.id }) + * ``` */ - async revert(messageId: string): Promise { + async revert({ + messageId, + principal, + }: { + messageId: string + principal?: Principal + }): Promise { const inputId = createInputId() if (!(await this.ownsIdleThread())) { return { inputId, status: 'rejected', reason: 'busy' } @@ -815,13 +859,15 @@ export abstract class SessionState< // A revert that stands ends first, so its files come back. await this.endRevert() const handler = this.sessionPlugins?.values.get(RevertFiles) - const files = await handler?.revert(messages.slice(at + 1), (type) => - this.hostRecords(type), + const files = await handler?.revert( + messages.slice(at + 1), + async (type) => this.keptRecords(type), + this.revertRun(principal ?? this.principal), + ) + await this.saveRevert( + { messageId, ...(files !== undefined ? { files } : {}) }, + principal ?? this.principal, ) - await this.saveRevert({ - messageId, - ...(files !== undefined ? { files } : {}), - }) return { inputId, status: 'accepted' } }) } @@ -848,11 +894,24 @@ export abstract class SessionState< if (reverted.files !== undefined) { await this.sessionPlugins?.values .get(RevertFiles) - ?.unrevert(reverted.files) + ?.unrevert(reverted.files, this.revertRun(this.principal)) } await this.saveRevert(undefined) } + /** + * A run for the files of a revert, in the thread of this session. It is + * part of no turn, so a workspace that no turn opened on this host (after + * a restart) opens again from the log. + */ + protected revertRun(principal: Principal | undefined) { + return this.runContext({ + principal, + runId: `revert:${this.threadId}`, + signal: this.lifetime.signal, + }) + } + /** Run `fn` while no new turn starts. A held turn starts after it. */ protected async holding(fn: () => Promise) { this.holdTurns++ @@ -886,11 +945,18 @@ export abstract class SessionState< await this.saveRevert(undefined) } - /** Keep the revert state in the log. */ - protected async saveRevert(revert: RevertState | undefined) { + /** Keep the revert state in the log, with who reverted. */ + protected async saveRevert( + revert: RevertState | undefined, + principal?: Principal, + ) { this.reverted = revert await this.writer.append([ - { type: 'harness.revert', revert: revert ?? null }, + { + type: 'harness.revert', + revert: revert ?? null, + ...storedPrincipal(principal), + }, ]) this.feed.publish( 'session', @@ -900,25 +966,15 @@ export abstract class SessionState< ) } - /** The host records of `type` of this thread, from the log, oldest first. */ - protected async hostRecords(type: string) { - const found: Array> = [] - let after = 0 - for (;;) { - const entries = await this.log.store.read({ - logId: this.storeLogId, - after, - limit: 256, - }) - for (const { seq, record } of entries) { - after = seq - const isOwn = threadOf(record, this.threadId) === this.threadId - if (isOwn && record.type === type) found.push(record) - } - // A store can return fewer than `limit` before the end: stop only at - // an empty page. - if (entries.length === 0) return found - } + /** + * The kept host records of `type` of this thread, oldest first: the last + * ones that the `keepRecords` of the plugins name. The log snapshot keeps + * them, so they stay after a truncate of the log. + */ + protected keptRecords(type: string): Array { + return this.writer.state.keptRecords.flatMap(({ record }) => + record.type === type ? [record] : [], + ) } /** The commands, settings, and tools of this session, for a UI. */ @@ -940,19 +996,30 @@ export abstract class SessionState< * wait, by its id. `principal` is who answers. Default: the principal that * opened the session. The log keeps it. */ - async answer({ + answer(options: { + questionId: string + value: unknown + principal?: Principal + }): Promise { + return this.applyAnswer(options) + } + + /** + * `answer`, with `controlId` (the id of an offered input, so a replay is a + * duplicate) and `ref` (a secret answer that another host saved, see + * `answerOffer`). + */ + protected override async applyAnswer({ questionId, value, - inputId = createInputId(), + controlId: inputId = createInputId(), principal, ref, }: { questionId: string value: unknown - /** @internal The id of an offered input, so a replay is a duplicate. */ - inputId?: string principal?: Principal - /** @internal A secret answer that another host saved. See `answerOffer`. */ + controlId?: string ref?: string }): Promise { // A secret (a key or a password) stays out of the log. A restart does @@ -1206,7 +1273,7 @@ export abstract class SessionState< value: unknown inputId: string sender: Principal | undefined - }): Promise { + }): Promise { // The wait of another host: read what that host wrote first. if (!this.questions.has(questionId)) await this.writer.catchUp() const logged = this.loggedWait(questionId) @@ -1715,22 +1782,44 @@ export abstract class SessionState< /** Read the stored settings of this thread from the log. */ protected loadSettings() { + this.threadSettings = this.settingsFromLog(this.threadSettings) + } + + /** The stored settings in the fold of the log, over `base`. */ + protected settingsFromLog(base: ThreadSettings = {}): ThreadSettings { const stored = this.writer.state.thread.settings const names = this.settingNames() + let settings = base for (const [key, value] of Object.entries(stored)) { try { - const next = changeSettings( - this.threadSettings, - { [key]: value }, - names, - ) + const next = changeSettings(settings, { [key]: value }, names) this.checkRemovable(next) - this.threadSettings = next + settings = next } catch { // A value the harness no longer takes (a removed model or plugin) // is dropped. } } + return settings + } + + /** + * Another host wrote to the log, and this log folded its records. Read + * the settings, the config values, and the interrupted turn from the fold + * again, so a turn and a change on this host use what that host wrote. + */ + protected syncFromLog() { + // While the plugins mount (an open or a reload), the open reads them. + if (!this.sessionPlugins) return + this.threadSettings = this.settingsFromLog() + this.configValues.clear() + for (const [key, value] of this.configSnapshot()) { + this.configValues.set(key, value) + } + const logged = this.writer.state.thread.interrupted ?? undefined + if (stableJson(logged ?? null) !== stableJson(this.interrupted ?? null)) { + this.interrupted = logged + } } /** Restore the interrupted turn that the log keeps for this thread. */ diff --git a/packages/ai-harness/src/session/turn-parts.ts b/packages/ai-harness/src/session/turn-parts.ts index dc88d3587e..2eb5e3f1e6 100644 --- a/packages/ai-harness/src/session/turn-parts.ts +++ b/packages/ai-harness/src/session/turn-parts.ts @@ -375,13 +375,19 @@ export abstract class SessionTurnParts< */ protected async prepareTools( preparers: MountedPlugins['preparers'], - turn: { tools: Array; model: string }, + turn: { tools: Array; model: string; run: RunContext }, operation: OperationImpl, ): Promise> { let prepared = turn.tools for (const { prepare, owner } of preparers) { try { - prepared = [...(await prepare({ tools: prepared, model: turn.model }))] + prepared = [ + ...(await prepare({ + tools: prepared, + model: turn.model, + run: turn.run, + })), + ] } catch (error) { this.warn(operation, owner, error) } @@ -438,8 +444,8 @@ export abstract class SessionTurnParts< context.runId = runId context.detach = (work) => new Promise((moved) => { - this.detachable.set(id, () => { - this.logJob({ jobId: id }, false) + this.detachable.set(id, (mover) => { + this.logJob({ jobId: id, principal: mover }, false) moved( `The job moved to the background. Its id is ${id}. You get a note when it ends.`, ) @@ -951,7 +957,7 @@ export abstract class SessionTurnParts< typeof content === 'string' ? [] : content.map(mediaIdOf).filter((id) => id !== undefined) - const records = await Promise.all(ids.map((id) => this.getMedia(id))) + const records = await Promise.all(ids.map((id) => this.getMedia({ id }))) const media = records.filter((record) => record !== null) const message: ModelMessage = { id: createMessageId(), diff --git a/packages/ai-harness/src/session/turn.ts b/packages/ai-harness/src/session/turn.ts index fe4b84da15..8c605ff6c0 100644 --- a/packages/ai-harness/src/session/turn.ts +++ b/packages/ai-harness/src/session/turn.ts @@ -732,6 +732,7 @@ export abstract class SessionTurn< turnToolNames, ), model: adapter.model, + run, }, operation, ) diff --git a/packages/ai-harness/src/types.ts b/packages/ai-harness/src/types.ts index 7eb8b70fc1..df15d21252 100644 --- a/packages/ai-harness/src/types.ts +++ b/packages/ai-harness/src/types.ts @@ -81,8 +81,6 @@ export type SendOptions = { ephemeral?: ReadonlyArray /** System prompts ahead of the harness prompts, from server code only. */ systemPreamble?: Array - /** @internal The AG-UI `runId` of a `POST run` request. The turn runs as it. */ - runId?: string /** * A W3C `traceparent` header value of the caller. The span of the turn gets * a link to it. A value that is not a valid `traceparent` is ignored. @@ -105,6 +103,13 @@ export type SendOptions = { | { mode: 'steer' | 'followUp'; busy?: never; as?: never } ) +/** + * @internal `SendOptions` with the AG-UI `runId` of a `POST run` request: the + * turn runs as it. The HTTP handler and an offered prompt pass it to + * `sendForRun`. The package index does not export it. + */ +export type SendForRunOptions = SendOptions & { runId?: string } + /** Options of `session.send` for a message to an agent run. See `send`. */ export interface AgentSendOptions { /** The operation id of the agent run. Any run of its chain works. */ @@ -291,16 +296,7 @@ export type HarnessInput = ( * `pendingQuestions` (a `step.ask` wait, a live MCP question), or a * `human` wait. */ - | { - op: 'answer' - questionId: string - value: unknown - /** - * @internal A secret answer that another host saved in the credential - * store, for the owner of the log. The owner reads it once. - */ - ref?: string - } + | { op: 'answer'; questionId: string; value: unknown } | { op: 'config'; key: string; value: unknown } /** Run one tool in `expose.tools` with no model. See `session.tool`. */ | { op: 'tool'; name: string; args?: unknown; meta?: Record } @@ -322,6 +318,16 @@ export type HarnessInput = ( } ) & { inputId?: string } +/** + * @internal An input that a host offers to the owner of the log. An answer + * can carry `ref`: a secret answer that another host saved in the + * credential store, for the owner of the log. The owner reads it once. The + * package index does not export it. + */ +export type OfferedHarnessInput = + | Exclude + | (Extract & { ref?: string }) + /** * An input that waits to run. `session.inputs()` lists them. Change one with * the `cancelInput` and `setDelivery` inputs. @@ -702,11 +708,17 @@ export interface AgentRun extends Operation { * runs as a follow-up. `mode: 'followUp'` runs the agent again after the * run ends, on the run's transcript with the message added, with its * first input and options. A run that ended starts again at once. The - * receipt names the run that gets the message. + * receipt names the run that gets the message. `principal` is who sends + * it. Default: the user of the run. On a handle of `host.open`, the + * principal of the handle, after its access check. */ send: ( message: UserInput, - options?: { mode?: 'steer' | 'followUp'; inputId?: string }, + options?: { + mode?: 'steer' | 'followUp' + inputId?: string + principal?: Principal + }, ) => Promise } @@ -954,7 +966,15 @@ export interface HarnessEventValues { 'harness.auth_required': AuthRequest 'harness.input.accepted': { inputId: string; op: HarnessInput['op'] } 'harness.input.applied': { inputId: string; operationId: string } - 'harness.input.rejected': { inputId: string; reason: string } + /** + * `offered` is true when the owner refused an offer from another host, and + * the log has another input with the id. That input is not refused. + */ + 'harness.input.rejected': { + inputId: string + reason: string + offered?: true + } 'harness.input.settled': InputSettlement 'harness.input.delivery': { inputId: string diff --git a/packages/ai-harness/src/view/index.ts b/packages/ai-harness/src/view/index.ts index 99da5eb4d4..fab18c1f59 100644 --- a/packages/ai-harness/src/view/index.ts +++ b/packages/ai-harness/src/view/index.ts @@ -149,7 +149,7 @@ export type ViewSendOptions = { to?: undefined } & ( /** The options of `view.send`. */ export interface ViewTextOptions { - /** Stored media files (from `upload`) that go with the text. */ + /** Stored media files (from `putMedia`) that go with the text. */ attachments?: ReadonlyArray /** * `'operator'`: the reply of an operator while a handoff to a person is @@ -184,14 +184,14 @@ export interface SessionViewSource { cancel: (options?: CancelOptions) => Promise // Both sources have these. Without one, its view action shows an error. continue?: () => unknown - cancelInput?: (inputId: string) => Promise + cancelInput?: (options: { inputId: string }) => Promise setDelivery?: (change: { inputId: string delivery: WaitingInput['delivery'] }) => Promise configure?: (options: ConfigureOptions) => Promise reset?: (options?: ResetOptions) => Promise - revert?: (messageId: string) => Promise + revert?: (options: { messageId: string }) => Promise unrevert?: () => Promise answer: (answer: { questionId: string; value: unknown }) => Promise command: (options: CommandOptions) => unknown @@ -205,14 +205,17 @@ export interface SessionViewSource { transcript: () => Promise> describe: () => SessionDescription | Promise /** A URL for a media file. Without it, media parts have no `url`. */ - mediaUrl?: (id: string) => Promise<{ url?: string; expiresAt?: number }> + mediaUrl?: (options: { + id: string + }) => Promise<{ url?: string; expiresAt?: number }> /** The bytes of a media file. Without it, `load()` rejects. */ loadMedia?: (options: { id: string }) => Promise - /** Store a file: `upload` on a client, `putMedia` on a session. */ - upload?: (file: MediaUpload) => Promise + /** Store a file. Without it, `view.putMedia` rejects. */ putMedia?: (file: MediaUpload) => Promise /** The saved messages of a child agent run. Without it, a reload shows no child parts. */ - agentTranscript?: (subagentRunId: string) => Promise> + agentTranscript?: (options: { + subagentRunId: string + }) => Promise> } /** The bytes of a file to store. */ @@ -253,7 +256,7 @@ export interface SessionView { ready: Promise /** * A `/command`, a prompt, or a steer while a turn runs. `attachments` are - * stored media files (from `upload`) that go with the text. Pass them as + * stored media files (from `putMedia`) that go with the text. Pass them as * a list, or as `{ attachments }`. A `/command` ignores them. Empty text * with no attachments does nothing. `{ as: 'operator' }` sends the reply * of an operator (see {@link ViewTextOptions}). @@ -279,10 +282,10 @@ export interface SessionView { * * @example * ```ts - * const record = await view.upload({ body: bytes, mimeType: 'image/png', name: 'cat.png' }) + * const record = await view.putMedia({ body: bytes, mimeType: 'image/png', name: 'cat.png' }) * ``` */ - upload: (file: MediaUpload) => Promise + putMedia: (file: MediaUpload) => Promise /** * Run the plugin command `name` with its `input`. * @@ -305,7 +308,7 @@ export interface SessionView { /** Stop the running turn, or the operation `operationId`. */ cancel: (options?: CancelOptions) => Promise /** Drop an input from `waitingInputs` before it runs. */ - cancelInput: (inputId: string) => Promise + cancelInput: (options: { inputId: string }) => Promise /** Move a waiting input: `'steer'` joins the running turn, `'queue'` waits for its own turn. */ setDelivery: (change: { inputId: string @@ -313,15 +316,46 @@ export interface SessionView { }) => Promise /** Start a fresh model context. The model sees `note` first. */ reset: (options?: ResetOptions) => Promise - /** Go back to the message `messageId`. `unrevert` undoes it. */ - revert: (messageId: string) => Promise + /** + * Go back to the message `messageId`. `unrevert` undoes it. + * + * @example + * ```ts + * await view.revert({ messageId: message.id }) + * ``` + */ + revert: (options: { messageId: string }) => Promise unrevert: () => Promise - approve: (id: string, options?: ApproveOptions) => void - reject: (id: string, payload?: unknown) => void + /** + * Say yes to the approval `id`, maybe with changed arguments or data. + * + * @example + * ```ts + * view.approve({ id: approval.id }) + * ``` + */ + approve: (options: { id: string } & ApproveOptions) => void + /** + * Say no to the approval `id`. `payload` goes to the tool's approval + * schema, when it has one. + * + * @example + * ```ts + * view.reject({ id: approval.id }) + * ``` + */ + reject: (options: { id: string; payload?: unknown }) => void approveAll: () => void rejectAll: () => void - /** Add a line of your own to the messages. */ - notice: (text: string) => void + /** + * Add a line of your own to the messages. + * + * @example + * ```ts + * view.notice({ text: 'Saved.' }) + * ``` + */ + notice: (options: { text: string }) => void /** * Fill the agent part `id` of a saved message with what that child wrote. * After a reload, a saved child shows only its card (name, status, result) @@ -573,7 +607,7 @@ export function createSessionView(source: SessionViewSource) { const fetchUrl = async (id: string) => { if (!source.mediaUrl) return try { - const { url, expiresAt } = await source.mediaUrl(id) + const { url, expiresAt } = await source.mediaUrl({ id }) const part = mediaParts.get(id) if (disposed || !part) return if (url !== undefined) mediaParts.set(id, { ...part, url }) @@ -755,7 +789,8 @@ export function createSessionView(source: SessionViewSource) { args: call?.args ?? bound?.originalArgs, ...(interrupt.message ? { message: interrupt.message } : {}), approve: (options) => decide(interrupt.id, approvedWith(options)), - reject: (payload) => decide(interrupt.id, rejectedWith(payload)), + reject: ({ payload } = {}) => + decide(interrupt.id, rejectedWith(payload)), } }, interrupt: (interrupt) => { @@ -891,7 +926,7 @@ export function createSessionView(source: SessionViewSource) { if (!source.agentTranscript || !isWaiting) return loadedAgents.add(id) try { - const messages = await source.agentTranscript(id) + const messages = await source.agentTranscript({ subagentRunId: id }) const parts = agentPartsFrom(messages) if (disposed) return // A child that failed before its first message has only its card. @@ -923,6 +958,38 @@ export function createSessionView(source: SessionViewSource) { } }) + /** + * After a gap: the events of a part of the log are gone. Load the snapshot + * and the transcript again, as `ready` does, while `read` waits. The + * stream goes on after the gap's `resumeAt`. Its events up to the new + * snapshot cursor replay only for the operations that run now, and the + * parts that the transcript has are dropped by id. + */ + const reload = async () => { + try { + // The snapshot before the transcript, as in `ready`. + const snapshot = await source.snapshot() + const transcript = await source.transcript() + if (disposed) return + commit({ ...get(), messages: messagesFromTranscript(transcript) }) + takeSnapshot(snapshot) + saved = transcriptIds(transcript) + held = [] + replayEnd = Number(snapshot.cursor) + replayOps = new Set(snapshot.activeOperations.map((item) => item.id)) + // The children that loaded before load again, a parent before its + // nested children. The events do not wait for them. + const loaded = [...loadedAgents] + loadedAgents.clear() + savedAgents = new Set(agentIdsIn(get().messages)) + void (async () => { + for (const id of loaded) await loadAgent(id) + })() + } catch (error) { + fail(error) + } + } + const fire = ( entry: SessionEvent, before: SessionViewState, @@ -980,12 +1047,8 @@ export function createSessionView(source: SessionViewSource) { event.name === HARNESS_EVENTS.commandsChanged || event.name === HARNESS_EVENTS.reloaded if (isDescriptionChange) refreshDescription() - // A gap: the events of a part of the log are gone, so the view loads - // the transcript (and the snapshot, see `changesSnapshot`) again. - const isReload = - event.name === HARNESS_EVENTS.revert || - event.name === HARNESS_EVENTS.gap - if (isReload) refreshTranscript() + // A gap loads the transcript again too, in `read` (see `reload`). + if (event.name === HARNESS_EVENTS.revert) refreshTranscript() } for (const signIn of after.signIns) if (!before.signIns.includes(signIn)) emit('signIn', signIn) @@ -1000,9 +1063,12 @@ export function createSessionView(source: SessionViewSource) { }) for await (const entry of entries) { if (disposed) return - // A gap is not an event of an operation, so no replay drops it. - if (isGap(entry)) apply(entry) - else for (const ready of replayed(entry)) apply(ready) + // A gap is not an event of an operation, so no replay drops it. No + // event applies until the reload ends. + if (isGap(entry)) { + apply(entry) + await reload() + } else for (const ready of replayed(entry)) apply(ready) } } catch (error) { fail(error) @@ -1200,9 +1266,8 @@ export function createSessionView(source: SessionViewSource) { alive() settle(source.continue ? source.continue() : missing('continue')) }, - upload: (file) => { + putMedia: (file) => { alive() - if (source.upload) return source.upload(file) if (source.putMedia) return source.putMedia(file) return Promise.reject(new Error('This view source cannot store media.')) }, @@ -1216,10 +1281,10 @@ export function createSessionView(source: SessionViewSource) { source.configure ? source.configure(options) : missing('configure'), ), cancel: (options) => act(() => source.cancel(options)), - cancelInput: (inputId) => + cancelInput: ({ inputId }) => act(() => source.cancelInput - ? source.cancelInput(inputId) + ? source.cancelInput({ inputId }) : missing('cancel inputs'), ), setDelivery: (change) => @@ -1230,15 +1295,21 @@ export function createSessionView(source: SessionViewSource) { ), reset: (options) => act(() => (source.reset ? source.reset(options) : missing('reset'))), - revert: (messageId) => - act(() => (source.revert ? source.revert(messageId) : missing('revert'))), + revert: ({ messageId }) => + act(() => + source.revert ? source.revert({ messageId }) : missing('revert'), + ), unrevert: () => act(() => (source.unrevert ? source.unrevert() : missing('unrevert'))), - approve: (id, options) => decide(id, approvedWith(options)), - reject: (id, payload) => decide(id, rejectedWith(payload)), + approve: ({ id, ...options }) => + decide( + id, + approvedWith(Object.keys(options).length > 0 ? options : undefined), + ), + reject: ({ id, payload }) => decide(id, rejectedWith(payload)), approveAll: () => decideAll(true), rejectAll: () => decideAll(false), - notice: (text) => { + notice: ({ text }) => { alive() commit(withNotice(get(), 'ui', text)) }, diff --git a/packages/ai-harness/src/view/types.ts b/packages/ai-harness/src/view/types.ts index 07f5a9f08c..a451b803a0 100644 --- a/packages/ai-harness/src/view/types.ts +++ b/packages/ai-harness/src/view/types.ts @@ -102,7 +102,7 @@ export interface Approval { message?: string approve: (options?: ApproveOptions) => void /** Say no. `payload` goes to the tool's approval schema, when it has one. */ - reject: (payload?: unknown) => void + reject: (options?: { payload?: unknown }) => void } /** diff --git a/packages/ai-harness/tests/access-work.test.ts b/packages/ai-harness/tests/access-work.test.ts new file mode 100644 index 0000000000..3978bd29c4 --- /dev/null +++ b/packages/ai-harness/tests/access-work.test.ts @@ -0,0 +1,385 @@ +import { describe, expect, it, vi } from 'vitest' +import { defineAgent } from '@tanstack/ai' +import { + memoryLogBackend, + memoryLogStore, + memoryPersistence, +} from '@tanstack/ai-persistence' +import { + AccessDeniedError, + createHarnessHost, + defineHarness, + definePlugin, + membersOnly, +} from '../src' +import { loadLogState, logIdOf, sessionOf } from '../src/log' +import { + mockAdapter, + storedThreadState, + text, + untilAborted, + wrapLogStore, +} from './helpers' +import type { LogStore } from '@tanstack/ai-persistence' +import type { AgentActions, Operation } from '../src' + +// Coverage runs in CI are slow, so polled checks wait up to 5 s. +const waitFor = ( + check: () => T | Promise, + options?: { timeout?: number; interval?: number }, +) => vi.waitFor(check, { timeout: 5_000, ...options }) + +interface User { + id: string + tenantId: string +} + +const olga: User = { id: 'olga', tenantId: 'acme' } +const bea: User = { id: 'bea', tenantId: 'acme' } +const vic: User = { id: 'vic', tenantId: 'acme' } + +const THREAD = 't1' +const NAME = 'test/access-work' + +/** An agent whose run goes on until it is cancelled. */ +const slow = defineAgent({ + name: 'slow', + description: 'Runs until it is cancelled', + run: (ctx) => ctx.chat({ adapter: mockAdapter(untilAborted()).adapter }), +}) + +/** + * Wait until `operation` runs. The receipt of an agent run is not awaited: + * the run answers through its result. + */ +const running = (operation: Operation) => + waitFor(() => expect(operation.status()).toBe('running')) + +/** The op of each input in the log of `THREAD`, and who sent it. */ +async function loggedInputs(log: LogStore, tenantId?: string) { + const state = await loadLogState({ + store: log, + logId: logIdOf({ tenantId, logId: THREAD }), + rootThread: THREAD, + }) + return [...sessionOf(state, THREAD).inputs.values()].map((logged) => ({ + op: logged.input.op, + by: logged.principal?.id, + })) +} + +/** A host with `membersOnly()` access, and a harness with the `slow` agent. */ +function accessHost() { + const persistence = memoryPersistence() + const host = createHarnessHost({ + persistence, + access: { identify: (): User | null => null, authorize: membersOnly() }, + }) + const harness = defineHarness({ + name: NAME, + adapter: mockAdapter(() => text('ok')).adapter, + agents: [slow], + }) + const open = (principal: User) => + host.open(harness, { threadId: THREAD, principal }) + const share = (userId: string, role: 'member' | 'viewer') => + host.sessions.share({ threadId: THREAD, userId, role, principal: olga }) + return { host, harness, open, share, log: persistence.stores.log } +} + +describe('who acts on an agent run', () => { + it('sends to and cancels a run as the user of the run, not as the first user of the session', async () => { + let agents: AgentActions | undefined + const starter = definePlugin({ + name: 'test/starter', + setup: (ctx) => { + agents = ctx.agents + }, + }) + const persistence = memoryPersistence() + const host = createHarnessHost({ persistence }) + const harness = defineHarness({ + name: NAME, + adapter: mockAdapter(() => text('ok')).adapter, + agents: [slow], + plugins: () => [starter], + }) + // Olga opens the session first. + await host.open(harness, { threadId: THREAD, principal: olga }) + if (!agents) throw new Error('No plugin context.') + const run = agents.start('slow', undefined, { principal: bea }) + await running(run) + + await run.send('Look at the tests first.') + await run.cancel() + + await expect(run).rejects.toThrow() + const inputs = await loggedInputs(persistence.stores.log, 'acme') + expect( + inputs.filter( + (input) => input.op === 'agentMessage' || input.op === 'cancel', + ), + ).toEqual([ + { op: 'agentMessage', by: 'bea' }, + { op: 'cancel', by: 'bea' }, + ]) + await host.close() + }) + + it('runs handle.agents..run as the principal that it passes, as start does', async () => { + const persistence = memoryPersistence() + const host = createHarnessHost({ persistence }) + const harness = defineHarness({ + name: NAME, + adapter: mockAdapter(() => text('ok')).adapter, + agents: [slow], + }) + const system = await host.open(harness, { + threadId: THREAD, + principal: { id: 'system' }, + }) + + const run = system.agents.slow.run(undefined, { principal: bea }) + await running(run) + + const inputs = await loggedInputs(persistence.stores.log) + expect(inputs.filter((input) => input.op === 'agent')).toEqual([ + { op: 'agent', by: 'bea' }, + ]) + await run.cancel() + await expect(run).rejects.toThrow() + await host.close() + }) +}) + +describe('operations that a handle returns', () => { + it('check access on cancel and send, as the calls of the handle do', async () => { + const { host, open, share } = accessHost() + const owner = await open(olga) + await share('bea', 'member') + await share('vic', 'viewer') + const member = await open(bea) + const viewer = await open(vic) + const run = owner.agents.slow.start() + await running(run) + + const seen = viewer.agentRun(run.id) + if (!seen) throw new Error('The viewer does not see the run.') + await expect(seen.cancel()).rejects.toBeInstanceOf(AccessDeniedError) + await expect(seen.send('Stop.')).rejects.toBeInstanceOf(AccessDeniedError) + // A member may cancel only the runs that the member started. + await expect(member.operation(run.id)?.cancel()).rejects.toBeInstanceOf( + AccessDeniedError, + ) + expect(run.status()).toBe('running') + + const own = member.agents.slow.start() + await running(own) + expect((await own.cancel()).status).toBe('accepted') + await expect(own).rejects.toThrow() + await run.cancel() + await expect(run).rejects.toThrow() + await host.close() + }) +}) + +describe('a user that loses access', () => { + it('ends a running events() read of a handle and of a view', async () => { + const { host, harness, open, share } = accessHost() + const owner = await open(olga) + await share('bea', 'member') + const member = await open(bea) + const view = await host.observe(harness, { + threadId: THREAD, + principal: bea, + }) + const readAll = async (events: AsyncIterable) => { + for await (const _event of events) { + // Read until the read ends. + } + } + const handleRead = readAll(member.events()) + const viewRead = readAll(view.events()) + await owner.send('Hi.') + + await host.sessions.unshare({ + threadId: THREAD, + userId: 'bea', + principal: olga, + }) + + await expect(handleRead).rejects.toBeInstanceOf(AccessDeniedError) + await expect(viewRead).rejects.toBeInstanceOf(AccessDeniedError) + await expect(view.transcript()).rejects.toBeInstanceOf(AccessDeniedError) + await host.close() + }) + + it('ends an events() read that is first pulled after the revoke', async () => { + const { host, open, share } = accessHost() + await open(olga) + await share('bea', 'member') + const member = await open(bea) + const events = member.events() + + await host.revoke({ threadId: THREAD, principal: bea }) + + const read = (async () => { + for await (const _event of events) { + // Read until the read ends. + } + })() + await expect(read).rejects.toBeInstanceOf(AccessDeniedError) + await host.close() + }) + + it('rejects host.revoke when the work of the user did not stop', async () => { + const persistence = memoryPersistence() + let isDown = false + const log = wrapLogStore(persistence.stores.log, { + offer: (input) => + isDown + ? Promise.reject(new Error('The store is down.')) + : persistence.stores.log.offer(input), + }) + const host = createHarnessHost({ + persistence: { ...persistence, stores: { ...persistence.stores, log } }, + access: { identify: (): User | null => null, authorize: membersOnly() }, + }) + const harness = defineHarness({ + name: NAME, + adapter: mockAdapter(() => text('ok')).adapter, + }) + await host.open(harness, { threadId: THREAD, principal: olga }) + await host.sessions.share({ + threadId: THREAD, + userId: 'bea', + role: 'member', + principal: olga, + }) + const member = await host.open(harness, { + threadId: THREAD, + principal: bea, + }) + await member.schedule({ + at: Date.now() + 3_600_000, + input: { op: 'prompt', message: 'Write the daily summary.' }, + }) + isDown = true + + await expect( + host.revoke({ threadId: THREAD, principal: bea }), + ).rejects.toThrow('their work did not stop') + // The access is gone already. + expect(() => member.transcript()).toThrow(AccessDeniedError) + await host.close() + }) + + it('has their running work cancelled by host.revoke', async () => { + const { host, open, share } = accessHost() + await open(olga) + await share('bea', 'member') + const member = await open(bea) + const run = member.agents.slow.start() + await running(run) + + await host.revoke({ threadId: THREAD, principal: bea }) + + await expect(run).rejects.toThrow() + expect(run.status()).toBe('cancelled') + await host.close() + }) + + it('has their work and their schedules stopped by an unshare on another host', async () => { + const backend = memoryLogBackend() + const shared = memoryPersistence() + const on = (log: LogStore) => ({ + ...shared, + stores: { ...shared.stores, log }, + }) + const access = { + identify: (): User | null => null, + authorize: membersOnly(), + } + const main = mockAdapter(() => text('ran')) + const harness = defineHarness({ + name: NAME, + adapter: main.adapter, + agents: [slow], + }) + const log = memoryLogStore({ backend }) + const hostA = createHarnessHost({ persistence: on(log), access }) + const hostB = createHarnessHost({ + persistence: on(memoryLogStore({ backend })), + access, + }) + await hostA.open(harness, { threadId: THREAD, principal: olga }) + await hostA.sessions.share({ + threadId: THREAD, + userId: 'bea', + role: 'member', + principal: olga, + }) + const member = await hostA.open(harness, { + threadId: THREAD, + principal: bea, + }) + const run = member.agents.slow.start() + await running(run) + const { scheduleId } = await member.schedule({ + at: Date.now() + 300, + input: { op: 'prompt', message: 'Write the daily summary.' }, + }) + + // Host B has no session of the thread open. + await hostB.sessions.unshare({ + threadId: THREAD, + userId: 'bea', + principal: olga, + }) + + await expect(run).rejects.toThrow() + expect(run.status()).toBe('cancelled') + // The time of the schedule passes, and its prompt does not run. + await waitFor(async () => + expect( + ( + await storedThreadState(log, { threadId: THREAD, tenantId: 'acme' }) + ).schedules.has(scheduleId), + ).toBe(false), + ) + expect(JSON.stringify(main.calls)).not.toContain('daily summary') + await hostA.close() + await hostB.close() + }) + + it('has their schedules removed at once by an unshare, not at their time', async () => { + const { host, open, share, log } = accessHost() + const owner = await open(olga) + await share('bea', 'member') + const member = await open(bea) + // A run of the owner keeps the log owned, so the owner applies offers. + const run = owner.agents.slow.start() + await running(run) + const { scheduleId } = await member.schedule({ + at: Date.now() + 3_600_000, + input: { op: 'prompt', message: 'Write the daily summary.' }, + }) + const schedules = async () => + (await storedThreadState(log, { threadId: THREAD, tenantId: 'acme' })) + .schedules + expect((await schedules()).has(scheduleId)).toBe(true) + + await host.sessions.unshare({ + threadId: THREAD, + userId: 'bea', + principal: olga, + }) + + await waitFor(async () => + expect((await schedules()).has(scheduleId)).toBe(false), + ) + await run.cancel() + await expect(run).rejects.toThrow() + await host.close() + }) +}) diff --git a/packages/ai-harness/tests/agent-group.test.ts b/packages/ai-harness/tests/agent-group.test.ts index 6d5104afb6..a710d1a210 100644 --- a/packages/ai-harness/tests/agent-group.test.ts +++ b/packages/ai-harness/tests/agent-group.test.ts @@ -249,7 +249,9 @@ async function stopAndRunAgain( const refuse = vi .spyOn(log, 'append') .mockRejectedValue(new Error('the host is gone')) - await expect(a.session.append([{ type: 'app.ping' }])).rejects.toThrow() + await expect( + a.session.append({ records: [{ type: 'app.ping' }] }), + ).rejects.toThrow() await expect(operation).rejects.toThrow() refuse.mockRestore() diff --git a/packages/ai-harness/tests/agent-limits.test.ts b/packages/ai-harness/tests/agent-limits.test.ts index 9b3df42a4d..8272a1f56e 100644 --- a/packages/ai-harness/tests/agent-limits.test.ts +++ b/packages/ai-harness/tests/agent-limits.test.ts @@ -61,7 +61,9 @@ async function stopHost( const refuse = vi .spyOn(log, 'append') .mockRejectedValue(new Error('the host is gone')) - await expect(session.append([{ type: 'app.ping' }])).rejects.toThrow() + await expect( + session.append({ records: [{ type: 'app.ping' }] }), + ).rejects.toThrow() await expect(running).rejects.toThrow() refuse.mockRestore() } diff --git a/packages/ai-harness/tests/agent-resume.test.ts b/packages/ai-harness/tests/agent-resume.test.ts index f8883a6970..b5e803c90e 100644 --- a/packages/ai-harness/tests/agent-resume.test.ts +++ b/packages/ai-harness/tests/agent-resume.test.ts @@ -94,7 +94,9 @@ async function stopDuringAgent(options: { const refuse = vi .spyOn(log, 'append') .mockRejectedValue(new Error('the host is gone')) - await expect(sessionA.append([{ type: 'app.ping' }])).rejects.toThrow() + await expect( + sessionA.append({ records: [{ type: 'app.ping' }] }), + ).rejects.toThrow() await expect(operation).rejects.toThrow() refuse.mockRestore() diff --git a/packages/ai-harness/tests/agent-runs.test.ts b/packages/ai-harness/tests/agent-runs.test.ts index ec2a2819bc..d39a680b7f 100644 --- a/packages/ai-harness/tests/agent-runs.test.ts +++ b/packages/ai-harness/tests/agent-runs.test.ts @@ -155,6 +155,48 @@ describe('the thread of an agent run', () => { }) }) +describe('the receipt of an agent run', () => { + it('resolves when the run starts, with the run as its operation', async () => { + const { host, session } = await open( + writing(mockAdapter([() => text('Done.')]).adapter), + ) + + const run = session.agents.writer.start() + + const receipt = await run.receipt + expect(receipt).toMatchObject({ status: 'accepted', operationId: run.id }) + expect(receipt.inputId).toEqual(expect.any(String)) + await run + await host.close() + }) + + it('resolves rejected for an input that the schema of the agent refuses', async () => { + const checked = defineAgent({ + name: 'checked', + description: 'Needs a topic', + inputSchema: z.object({ topic: z.string() }), + run: (ctx) => ctx.chat({ adapter: mockAdapter([]).adapter }), + }) + const { host, session } = await open( + defineHarness({ + name: 'test/agent-runs', + adapter: mockAdapter([]).adapter, + agents: [checked], + }), + ) + + const run = session.agent('checked')?.start({ topic: 7 }) + if (!run) throw new Error('No agent checked.') + + expect(await run.receipt).toMatchObject({ + status: 'rejected', + reason: 'invalid_input', + }) + await expect(run).rejects.toThrow('Input validation failed') + await host.close() + }) +}) + describe('follow-ups to an agent run', () => { it('runs the agent again after the run ended, on its transcript', async () => { const model = mockAdapter([ @@ -685,7 +727,9 @@ function durable() { const refuse = vi .spyOn(log, 'append') .mockRejectedValue(new Error('the host is gone')) - await expect(session.append([{ type: 'app.ping' }])).rejects.toThrow() + await expect( + session.append({ records: [{ type: 'app.ping' }] }), + ).rejects.toThrow() await expect(run).rejects.toThrow() refuse.mockRestore() } diff --git a/packages/ai-harness/tests/agents.test.ts b/packages/ai-harness/tests/agents.test.ts index 30d26aa744..28b5cff170 100644 --- a/packages/ai-harness/tests/agents.test.ts +++ b/packages/ai-harness/tests/agents.test.ts @@ -1,11 +1,11 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' -import { createHarnessHost, defineHarness } from '../src' +import { createHarnessHost, defineHarness, definePlugin } from '../src' import { permissions } from '../src/first-party' import { agents } from '../src/first-party/agents' import { splitFrontmatter } from '../src/first-party/files' @@ -56,6 +56,25 @@ function toolNames(call: { tools: Array<{ name: string }> }) { return call.tools.map((entry) => entry.name) } +/** The names of the tools a subagent got on one call, also when none. */ +function childTools(call: { tools?: Array<{ name: string }> }) { + return (call.tools ?? []).map((entry) => entry.name) +} + +/** A subagent with its own model, and every tool of the turn. */ +const helper: AgentProfile = { + name: 'helper', + description: 'Helps with a task', + mode: 'subagent', + model: 'child-model', +} + +// Coverage runs in CI are slow, so polled checks wait up to 5 s. +const waitFor = ( + check: () => T | Promise, + options?: { timeout?: number; interval?: number }, +) => vi.waitFor(check, { timeout: 5_000, ...options }) + /** The system prompts of one call, as one string. */ function promptsOf(call: { systemPrompts: unknown }) { return JSON.stringify(call.systemPrompts) @@ -406,6 +425,87 @@ describe('agents', () => { expect(toolNames(calls[0])).toEqual(['read_file', 'write_file']) await host.close() }) + it("gives a subagent the tools of the turn that started it, never another user's", async () => { + // Each turn finds one tool of its user, as a sign-in to a server does. + const secrets = definePlugin({ + name: 'test/secrets', + setup: () => ({ + discoverTools: (turn) => [tool(`secret_${turn.principal?.id}`)], + }), + }) + const lead = mockAdapter([ + () => toolCall('helper', { prompt: 'Help.' }), + () => text('Olga is done.'), + () => text('Bea is done.'), + ]) + const child = mockAdapter(() => text('Helped.')) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const harness = defineHarness({ + name: 'test/agents', + adapter: lead.adapter, + tools: [tool('read_file')], + plugins: () => [ + secrets, + agents({ adapter: () => child.adapter, agents: [helper] }), + ], + }) + const olga = await host.open(harness, { + threadId: 't', + principal: { id: 'olga' }, + }) + const bea = await host.open(harness, { + threadId: 't', + principal: { id: 'bea' }, + }) + + await olga.send('Get help.') + await bea.send('Hi.') + // Code starts the agent after the turn of bea. No turn started it. + await olga.agent('helper')?.start('Again.') + + expect(childTools(child.calls[0])).toEqual(['read_file', 'secret_olga']) + expect(childTools(child.calls[1])).toEqual([]) + await host.close() + }) + + it('fails a subagent run whose turn ran on another host with a clear error', async () => { + const persistence = memoryPersistence() + const lead = mockAdapter([ + () => toolCall('helper', { prompt: 'Help.', background: true }), + () => text('It runs.'), + ]) + const child = mockAdapter(() => text('Helped.')) + const harness = defineHarness({ + name: 'test/agents', + adapter: lead.adapter, + tools: [tool('read_file')], + plugins: () => [ + agents({ adapter: () => child.adapter, agents: [helper] }), + ], + }) + const first = createHarnessHost({ persistence }) + const session = await first.open(harness, { threadId: 't' }) + await session.send('Start a helper.') + await waitFor(() => + expect(session.agentRuns().map((run) => run.status)).toEqual([ + 'completed', + ]), + ) + const [started] = session.agentRuns() + await first.close() + + // Another host has no turn of this thread in its memory. + const second = createHarnessHost({ persistence }) + const again = await second.open(harness, { threadId: 't' }) + const run = again.agentRun(started?.operationId ?? '') + const receipt = await run?.send('Go on.', { mode: 'followUp' }) + + await expect(again.operation(receipt?.operationId ?? '')).rejects.toThrow( + 'only in the memory of the host that ran that turn', + ) + expect(child.calls).toHaveLength(1) + await second.close() + }) }) describe('agents from Markdown files', () => { diff --git a/packages/ai-harness/tests/background-tools.test.ts b/packages/ai-harness/tests/background-tools.test.ts index e81c865ec4..b09c8ce153 100644 --- a/packages/ai-harness/tests/background-tools.test.ts +++ b/packages/ai-harness/tests/background-tools.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { defineAgent, toolDefinition } from '@tanstack/ai' -import { memoryPersistence } from '@tanstack/ai-persistence' +import { + memoryLogBackend, + memoryLogStore, + memoryPersistence, +} from '@tanstack/ai-persistence' import { createHarnessHost, defineHarness } from '../src' import { after, @@ -87,6 +91,96 @@ describe('session.background', () => { await host.close() }) + it('keeps a session with a running job open after its last handle closes', async () => { + const finish = gate() + const woke = gate() + const child = mockAdapter(after(finish.opened, 'Found it')) + const writer = defineAgent({ + name: 'writer', + description: 'Writes notes', + run: (ctx) => ctx.chat({ adapter: child.adapter }), + }) + const model = mockAdapter(lead('writer', { prompt: 'Look around' }, woke)) + const harness = defineHarness({ + name: 'test/background', + adapter: model.adapter, + subagents: { agents: [writer] }, + }) + const host = createHarnessHost({ + persistence: memoryPersistence(), + lifecycle: { idleCloseMs: 0 }, + }) + const session = await host.open(harness, { threadId: 't' }) + + const turn = session.send('Look around for me') + await vi.waitFor(() => expect(child.calls).toHaveLength(1)) + expect(await session.background()).toMatchObject({ status: 'accepted' }) + await turn + // No handle holds the session now, but its job still runs. + await session.close() + expect(host.isOpen(harness, { threadId: 't' })).toBe(true) + + finish.open() + await woke.opened + const note = messageTexts(model.calls[2]).at(-1) ?? '' + expect(note.split('\n')[0]).toBe('Background job call-1 ended.') + // After the job and its note, the session is idle and closes. + await vi.waitFor( + () => expect(host.isOpen(harness, { threadId: 't' })).toBe(false), + { timeout: 5_000 }, + ) + await host.close() + }) + + it('opens on another host while the owner runs a job', async () => { + const backend = memoryLogBackend() + const finish = gate() + const woke = gate() + const child = mockAdapter(after(finish.opened, 'Found it')) + const writer = defineAgent({ + name: 'writer', + description: 'Writes notes', + run: (ctx) => ctx.chat({ adapter: child.adapter }), + }) + const model = mockAdapter(lead('writer', { prompt: 'Look around' }, woke)) + const harnessOf = (adapter: AnyTextAdapter) => + defineHarness({ + name: 'test/background', + adapter, + subagents: { agents: [writer] }, + }) + const hostOf = () => + createHarnessHost({ + persistence: { stores: { log: memoryLogStore({ backend }) } }, + }) + const owner = hostOf() + const session = await owner.open(harnessOf(model.adapter), { + threadId: 't', + }) + const turn = session.send('Look around for me') + await vi.waitFor(() => expect(child.calls).toHaveLength(1)) + expect(await session.background()).toMatchObject({ status: 'accepted' }) + await turn + + // The job runs on the owner, so the other host notes nothing. + const other = hostOf() + const reader = await other.open(harnessOf(mockAdapter([]).adapter), { + threadId: 't', + }) + expect( + (await reader.transcript()).some((message) => + String(message.content).includes('stopped when the host restarted'), + ), + ).toBe(false) + + finish.open() + await woke.opened + const note = messageTexts(model.calls[2]).at(-1) ?? '' + expect(note.split('\n')[0]).toBe('Background job call-1 ended.') + await owner.close() + await other.close() + }) + it('leaves a tool without support running', async () => { const finish = gate() const started = gate() diff --git a/packages/ai-harness/tests/client-media.test.ts b/packages/ai-harness/tests/client-media.test.ts index 4f3b698676..6ef6836c6c 100644 --- a/packages/ai-harness/tests/client-media.test.ts +++ b/packages/ai-harness/tests/client-media.test.ts @@ -49,7 +49,7 @@ describe('client media', () => { // A view on a bigger buffer: only the bytes of the view are sent. const bytes = new Uint8Array([0, 1, 2, 3, 9]).subarray(1, 4) - const stored = await client.upload({ + const stored = await client.putMedia({ body: bytes, name: 'cat photo.png', mimeType: 'image/png', @@ -71,7 +71,7 @@ describe('client media', () => { ) await expect( - client.upload({ + client.putMedia({ body: new Blob(['big']), name: 'big.mp4', mimeType: 'video/mp4', @@ -89,7 +89,7 @@ describe('client media', () => { ) await expect( - client.upload({ + client.putMedia({ body: new ArrayBuffer(3), name: 'a.png', mimeType: 'image/png', @@ -101,7 +101,7 @@ describe('client media', () => { const { client } = setup(() => Response.json({ id: 'm1' })) await expect( - client.upload({ + client.putMedia({ body: new Blob(['abc']), name: 'a.png', mimeType: 'image/png', @@ -117,7 +117,7 @@ describe('client media', () => { }), ) - expect(await client.mediaUrl('m1')).toEqual({ + expect(await client.mediaUrl({ id: 'm1' })).toEqual({ url: 'http://local/api/harness/media?threadId=thread-m&id=m1&exp=5000&sig=abc', expiresAt: 5000, }) @@ -133,7 +133,7 @@ describe('client media', () => { it('throws when the media-url answer has no signed path', async () => { const { client } = setup(() => Response.json({ expiresAt: 5000 })) - await expect(client.mediaUrl('m1')).rejects.toThrow( + await expect(client.mediaUrl({ id: 'm1' })).rejects.toThrow( 'Harness media-url failed: the answer has no signed path', ) }) diff --git a/packages/ai-harness/tests/client-parse.test.ts b/packages/ai-harness/tests/client-parse.test.ts index d6c8452c9f..a96caa4c70 100644 --- a/packages/ai-harness/tests/client-parse.test.ts +++ b/packages/ai-harness/tests/client-parse.test.ts @@ -27,12 +27,17 @@ function streamed(chunks: ReadonlyArray) { } /** A client on `fetch`, for the thread `t`. */ -function clientOn(fetch: typeof globalThis.fetch, reconnectDelayMs?: number) { +function clientOn( + fetch: typeof globalThis.fetch, + reconnectDelayMs?: number, + idleTimeoutMs?: number, +) { return createHarnessClient({ url: 'http://x/api/harness', threadId: 't', fetch, ...(reconnectDelayMs === undefined ? {} : { reconnectDelayMs }), + ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }), }) } @@ -146,6 +151,64 @@ describe('events() reconnects', () => { ]) }) + it('waits idleTimeoutMs before it connects again, for a server with a long heartbeat', async () => { + vi.useFakeTimers() + const urls: Array = [] + // The connection sends nothing. + const client = clientOn( + async (input) => { + urls.push(String(input)) + return new Response(new ReadableStream(), { status: 200 }) + }, + 1, + 120_000, + ) + const controller = new AbortController() + const next = client + .events({ from: '0', signal: controller.signal }) + [Symbol.asyncIterator]() + .next() + + await vi.advanceTimersByTimeAsync(61_000) + expect(urls).toHaveLength(1) + await vi.advanceTimersByTimeAsync(60_000) + expect(urls).toHaveLength(2) + controller.abort() + await next + }) + + it('connects again from its last cursor when a connection sends nothing for 60 s', async () => { + vi.useFakeTimers() + const encoder = new TextEncoder() + const urls: Array = [] + // The first connection sends one event, then nothing: a half-open + // connection. The next ones send one event each. + const client = clientOn(async (input) => { + urls.push(String(input)) + if (urls.length > 1) return streamed([sessionFrame(String(urls.length))]) + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sessionFrame('1'))) + }, + }) + return new Response(body, { status: 200 }) + }, 1) + const controller = new AbortController() + const iterator = client + .events({ from: '0', signal: controller.signal }) + [Symbol.asyncIterator]() + + expect((await iterator.next()).value?.cursor).toBe('1') + const next = iterator.next() + await vi.advanceTimersByTimeAsync(59_000) + expect(urls).toHaveLength(1) + await vi.advanceTimersByTimeAsync(1_100) + + expect((await next).value?.cursor).toBe('2') + expect(urls[1]).toContain('from=1') + controller.abort() + }) + it('stops the wait before a reconnect when the signal aborts', async () => { vi.useFakeTimers() const client = clientOn(async () => { @@ -224,7 +287,7 @@ describe('control inputs', () => { it('keeps the inputId of the input to change in cancelInput', async () => { const { host, client, bodies } = recorded() - await client.cancelInput('waiting-1').catch(() => undefined) + await client.cancelInput({ inputId: 'waiting-1' }).catch(() => undefined) await host.close() expect(inputIdOf(bodies[0])).toBe('waiting-1') diff --git a/packages/ai-harness/tests/client-reads.test.ts b/packages/ai-harness/tests/client-reads.test.ts index 212caadaeb..08fda1239d 100644 --- a/packages/ai-harness/tests/client-reads.test.ts +++ b/packages/ai-harness/tests/client-reads.test.ts @@ -117,15 +117,15 @@ describe('client reads and actions', () => { ) const [first] = await client.transcript() - const renamed = await client.renameSession({ + const renamed = await client.sessions.rename({ threadId: 'thread-r', title: 'Greeting', }) - const pinned = await client.updateSession({ + const pinned = await client.sessions.update({ threadId: 'thread-r', pinned: true, }) - const fork = await client.forkSession({ + const fork = await client.sessions.fork({ threadId: 'thread-r', at: { through: first?.id ?? '' }, }) @@ -192,13 +192,15 @@ describe('client reads and actions', () => { const statusOf = async (path: string) => (await handler(new Request(`http://local/api/harness/${path}`))).status - const transcript = await client.agentTranscript(childId) + const transcript = await client.agentTranscript({ subagentRunId: childId }) expect(transcript.map((message) => message.content)).toEqual([ 'Take notes', 'Note 1', ]) - expect(await client.agentTranscript('unknown')).toEqual([]) + expect(await client.agentTranscript({ subagentRunId: 'unknown' })).toEqual( + [], + ) expect(await statusOf('agent-transcript?threadId=thread-a')).toBe(400) expect( await statusOf( diff --git a/packages/ai-harness/tests/client.test.ts b/packages/ai-harness/tests/client.test.ts index 441208292f..bf6c2b7208 100644 --- a/packages/ai-harness/tests/client.test.ts +++ b/packages/ai-harness/tests/client.test.ts @@ -185,7 +185,9 @@ describe('createHarnessClient', () => { ).toContain('billing'), ) expect(calls).toHaveLength(0) - expect(await client.append([{ type: 'app.signal' }])).toMatchObject({ + expect( + await client.append({ records: [{ type: 'app.signal' }] }), + ).toMatchObject({ status: 'rejected', reason: 'not_exposed', }) diff --git a/packages/ai-harness/tests/coding-bash.test.ts b/packages/ai-harness/tests/coding-bash.test.ts index 9f028a0e94..99118e88c6 100644 --- a/packages/ai-harness/tests/coding-bash.test.ts +++ b/packages/ai-harness/tests/coding-bash.test.ts @@ -213,6 +213,23 @@ describe('bash', () => { expect(result).toBe(`${LONG_RESULT}\n[Full output saved to ${path}.]`) }) + it('saves the output that scrub gives, so the file keeps no secret', async () => { + const { env, files } = workspace({ + ...LONG, + stdout: 'key=sk-secret\n'.repeat(3000), + }) + await bash( + env, + { command: 'test' }, + { + spillDir: '.agent/bash', + scrub: (text) => text.replaceAll('sk-secret', '[redacted]'), + }, + ) + const [saved] = files.values() + expect(saved).toBe('key=[redacted]\n'.repeat(3000)) + }) + it('saves it with a POSIX path in a sandbox, also on a Windows host', async () => { const { env, files } = workspace(LONG, { shell: 'sh' }) await bash( diff --git a/packages/ai-harness/tests/coding-formatter.test.ts b/packages/ai-harness/tests/coding-formatter.test.ts index 2e6471cc32..0f7233edeb 100644 --- a/packages/ai-harness/tests/coding-formatter.test.ts +++ b/packages/ai-harness/tests/coding-formatter.test.ts @@ -4,7 +4,10 @@ import { describe, expect, it, vi } from 'vitest' import { memoryPersistence } from '@tanstack/ai-persistence' import { createHarnessHost, defineHarness, definePlugin } from '../src' import { workspaceTools } from '../src/first-party/coding' -import { permissions } from '../src/first-party/permissions' +import { + listSavedPermissions, + permissions, +} from '../src/first-party/permissions' import { FormatFailed, formatOnWrite, @@ -377,6 +380,48 @@ describe('formatter', () => { }) }) +describe('formatter with a workspace for each thread', () => { + it('runs in the workspace that bind gave the thread, also with permissions()', async () => { + // A Linux sandbox at /workspace, with a prettier config. + const { backend, runs } = project(prettier, ok, 'sh') + const { adapter } = mockAdapter([ + () => toolCall('write_file', { path: 'a.ts', content: 'let a=1' }, 'c1'), + () => text('done'), + ]) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open( + defineHarness({ + name: 'test/formatter-bind', + adapter, + plugins: () => [ + permissions({ rules: [{ tool: 'formatter:*', decision: 'allow' }] }), + workspaceTools({ + workspace: { + bind: async () => ({ ...backend, root: '/workspace' }), + }, + }), + formatter({}), + ], + }), + { threadId: 't' }, + ) + // Not bypass: the rules check the formatter of the call before it runs. + await session.command({ name: 'mode', input: 'acceptEdits' }) + + await session.send('write it') + expect(session.snapshot().pendingWaits).toEqual([]) + await host.close() + + expect(runs).toEqual([ + { + command: `npx --no-install prettier --write '/workspace/a.ts'`, + cwd: '/workspace', + timeoutMs: 20_000, + }, + ]) + }) +}) + describe('formatter events', () => { it('sends tanstack/formatter:rewrote when the formatter changed the written file', async () => { // The fake prettier puts spaces around `=`. @@ -431,12 +476,14 @@ describe('formatter with permissions()', () => { persistence = memoryPersistence(), files: Record = {}, rules: Array = [], + /** The start of the tool call ids. A thread needs new ids on each host. */ + ids = 'c', ) { const { backend, runs } = project(files) const { adapter, calls } = mockAdapter([ ...paths.map( (path, index) => () => - toolCall('write_file', { path, content: '' }, `c${index}`), + toolCall('write_file', { path, content: '' }, `${ids}${index}`), ), () => text('done'), ]) @@ -545,6 +592,75 @@ describe('formatter with permissions()', () => { await second.host.close() }) + it('asks again after an always answer when the formatter config changed', async () => { + const persistence = memoryPersistence() + const first = await openWith('acceptEdits', ['a.ts'], persistence, { + 'prettier.config.js': 'export default {}', + }) + await first.session.send('write it') + await answer(first.session, { answer: 'always' }) + expect(first.runs).toHaveLength(1) + await first.host.close() + + // The config now loads a plugin: its code runs with the formatter. + const second = await openWith( + 'acceptEdits', + ['b.ts'], + persistence, + { 'prettier.config.js': "export default { plugins: ['./evil.js'] }" }, + [], + 'second-', + ) + await second.session.send('write again') + const message = await answer(second.session, { answer: 'once' }) + expect(message).toContain('Allow write_file') + expect(second.runs.map((run) => run.command)).toEqual([ + `npx --no-install prettier --write ${quoted('b.ts')}`, + ]) + await second.host.close() + }) + + it('asks again after an always answer for a write of a config below the root', async () => { + const persistence = memoryPersistence() + const first = await openWith('acceptEdits', ['a.ts'], persistence, prettier) + await first.session.send('write it') + await answer(first.session, { answer: 'always' }) + await first.host.close() + + // Prettier loads the nested config, and runs its code. The root + // config did not change. + const second = await openWith( + 'acceptEdits', + ['src/prettier.config.js'], + persistence, + prettier, + [], + 'second-', + ) + await second.session.send('write the nested config') + const message = await answer(second.session, { answer: 'once' }) + expect(message).toContain('Allow write_file') + await second.host.close() + }) + + it('saves no rule for the formatter of a nested config on an always answer', async () => { + const persistence = memoryPersistence() + const { host, session } = await openWith( + 'acceptEdits', + ['src/prettier.config.js'], + persistence, + prettier, + ) + await session.send('write the nested config') + + await answer(session, { answer: 'always' }) + + const saved = await listSavedPermissions(persistence.stores, root) + expect(saved.map((rule) => rule.tool)).toContain('formatter:prettier') + expect(saved.some((rule) => rule.tool.includes('@unsaved:'))).toBe(false) + await host.close() + }) + it('runs with no question in bypass', async () => { const { host, session, runs } = await openWith('bypass', [ 'prettier.config.js', diff --git a/packages/ai-harness/tests/coding-jobs.test.ts b/packages/ai-harness/tests/coding-jobs.test.ts index 04edd76afb..be9b63be22 100644 --- a/packages/ai-harness/tests/coding-jobs.test.ts +++ b/packages/ai-harness/tests/coding-jobs.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { hostBackend } from '../src/first-party/coding/backend' import { bashTools } from '../src/first-party/coding/bash' import { createOutputTail } from '../src/first-party/coding/ring-buffer' +import { createWorkspaceTools } from '../src/first-party/coding/workspace' import type { ToolExecutionContext } from '@tanstack/ai' import type { PluginEvent } from '../src/extensions' import type { @@ -380,6 +381,39 @@ describe('bash job ids', () => { }) }) +describe('the note of a background job', () => { + it('wakes the session as the user of the run that started the job', async () => { + const running = runningJob() + const { env } = workspace({ spawn: () => running.job }) + const note = vi.fn(async () => undefined) + const { tools } = createWorkspaceTools( + { root, backend: env.backend, web: false }, + { note }, + ) + const bash = tools.find((tool) => tool.name === 'bash') + const run = { + logId: 'log', + threadId: 't', + runId: 'run-a', + principal: { id: 'user-a' }, + } + + await bash?.execute?.( + { command: 'npm test', background: true }, + { emitCustomEvent: () => undefined, context: { run } }, + ) + running.end(0) + await settle() + + // Another user's turn can run when the job ends. The wake turn is + // still the work of user-a. + expect(note).toHaveBeenCalledWith( + expect.stringContaining('Background job bash-1 ended.'), + { wake: true, principal: { id: 'user-a' } }, + ) + }) +}) + describe('hostBackend.killJob', () => { // Coverage runs in CI are slow, so polled checks wait up to 5 s. const waitFor = (check: () => unknown) => diff --git a/packages/ai-harness/tests/coding-snapshots.test.ts b/packages/ai-harness/tests/coding-snapshots.test.ts index 602aac39c9..c8184d8ed8 100644 --- a/packages/ai-harness/tests/coding-snapshots.test.ts +++ b/packages/ai-harness/tests/coding-snapshots.test.ts @@ -495,6 +495,87 @@ describe.skipIf(!hasGit)('snapshots', () => { }) }) +describe.skipIf(!hasGit)('snapshots with a workspace for each thread', () => { + it('keeps the snapshots in the workspace that bind gave the thread', async () => { + // The workspace of each thread is a folder of its own, not `root`. + const own = join(base, 'thread-a') + await mkdir(own) + await writeFile(join(own, 'a.txt'), 'one') + const { adapter } = mockAdapter([ + () => toolCall('write_file', { path: 'a.txt', content: 'two' }, 'w-1'), + () => text('Done.'), + ]) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open( + defineHarness({ + name: 'test/snapshots-bind', + adapter, + plugins: () => [ + workspaceTools({ + workspace: { bind: async () => ({ ...hostBackend, root: own }) }, + web: false, + }), + snapshots({ dataDir }), + ], + }), + { threadId: THREAD }, + ) + + await session.send('Write a.txt') + expect(await readFile(join(own, 'a.txt'), 'utf8')).toBe('two') + expect(await session.command({ name: 'undo' })).toBe( + 'Undid the last turn. Files restored: 1.', + ) + expect(await readFile(join(own, 'a.txt'), 'utf8')).toBe('one') + await host.close() + }) + + it('unreverts on a new host, which opens the workspace of the thread again from the log', async () => { + const own = join(base, 'thread-a') + await mkdir(own) + await writeFile(join(own, 'a.txt'), 'one') + const persistence = memoryPersistence() + const openOn = (replies: Array) => { + const host = createHarnessHost({ persistence }) + const harness = defineHarness({ + name: 'test/snapshots-bind', + adapter: mockAdapter(replies).adapter, + plugins: () => [ + workspaceTools({ + workspace: { bind: async () => ({ ...hostBackend, root: own }) }, + web: false, + }), + snapshots({ dataDir }), + ], + }) + return { host, opened: host.open(harness, { threadId: THREAD }) } + } + const first = openOn([ + () => toolCall('write_file', { path: 'a.txt', content: 'two' }, 'w-1'), + () => text('First.'), + () => toolCall('write_file', { path: 'a.txt', content: 'three' }, 'w-2'), + () => text('Second.'), + ]) + const session = await first.opened + await session.send('One') + await session.send('Two') + const answer = (await session.transcript()).find( + (message) => message.content === 'First.', + ) + await session.revert({ messageId: answer?.id ?? '' }) + expect(await readFile(join(own, 'a.txt'), 'utf8')).toBe('two') + await first.host.close() + + // No turn ran on this host, so the workspace of the thread is not open. + const second = openOn([]) + const again = await second.opened + + expect(await again.unrevert()).toMatchObject({ status: 'accepted' }) + expect(await readFile(join(own, 'a.txt'), 'utf8')).toBe('three') + await second.host.close() + }) +}) + describe.skipIf(!hasGit)('snapshots of many runs', () => { it("/undo of run A keeps the change that run B made during A's step", async () => { await put('a.txt', 'one') @@ -587,6 +668,27 @@ describe.skipIf(!hasGit)('snapshots of many runs', () => { purgeSnapshots({ root, dataDir }, { threadId: THREAD }), ).resolves.toBeUndefined() }) + + it('host.sessions.delete removes the repository of that thread, also when no session has it open', async () => { + const first = await open({ replies: [() => text('Hi.')] }) + await first.session.close() + const second = await first.host.open( + defineHarness({ + name: 'test/snapshots', + adapter: mockAdapter([]).adapter, + plugins: () => [snapshots({ root, dataDir })], + }), + { threadId: 't2' }, + ) + await second.close() + expect(await readdir(dataDir)).toHaveLength(2) + + await first.host.sessions.delete({ threadId: THREAD }) + + // The repository of the other thread stays. + expect(await readdir(dataDir)).toHaveLength(1) + await first.host.close() + }) }) /** Three turns. Each one changes the files once, with its own tool call. */ @@ -622,7 +724,9 @@ describe.skipIf(!hasGit)('session.revert', () => { const { transcript, firstId } = await runThreeTurns(session) expect(transcript).toHaveLength(12) - expect(await session.revert(firstId)).toMatchObject({ status: 'accepted' }) + expect(await session.revert({ messageId: firstId })).toMatchObject({ + status: 'accepted', + }) expect(await read('a.txt')).toBe('two') expect(await readdir(root)).toEqual(['a.txt']) @@ -634,7 +738,7 @@ describe.skipIf(!hasGit)('session.revert', () => { await put('a.txt', 'one') const { host, session } = await open(threeTurns()) const { transcript, firstId } = await runThreeTurns(session) - await session.revert(firstId) + await session.revert({ messageId: firstId }) expect(await session.unrevert()).toMatchObject({ status: 'accepted' }) @@ -652,7 +756,7 @@ describe.skipIf(!hasGit)('session.revert', () => { replies: [...turns.replies, () => text('Fourth.')], }) const { transcript, firstId } = await runThreeTurns(session) - await session.revert(firstId) + await session.revert({ messageId: firstId }) await session.send('Four') @@ -685,13 +789,15 @@ describe.skipIf(!hasGit)('session.revert', () => { const running = session.send('Again') await waitFor(() => expect(session.snapshot().status).toBe('running')) - expect(await session.revert(message?.id ?? '')).toMatchObject({ + expect( + await session.revert({ messageId: message?.id ?? '' }), + ).toMatchObject({ status: 'rejected', reason: 'busy', }) wait.open() await running - expect(await session.revert('no-such-id')).toMatchObject({ + expect(await session.revert({ messageId: 'no-such-id' })).toMatchObject({ status: 'rejected', reason: 'unknown_message', }) @@ -704,7 +810,7 @@ describe.skipIf(!hasGit)('session.revert', () => { const persistence = memoryPersistence() const first = await open({ ...threeTurns(), persistence }) const { transcript, firstId } = await runThreeTurns(first.session) - await first.session.revert(firstId) + await first.session.revert({ messageId: firstId }) await first.host.close() const second = await open({ replies: [], persistence }) @@ -732,7 +838,7 @@ describe.skipIf(!hasGit)('session.revert keeps the edits of the user', () => { (message) => message.content === 'First.', ) - await session.revert(first?.id ?? '') + await session.revert({ messageId: first?.id ?? '' }) expect(await read('a.txt')).toBe('two') expect(await read('c.txt')).toBe('mine') @@ -747,7 +853,7 @@ describe.skipIf(!hasGit)('session.revert keeps the edits of the user', () => { // The user edits a file that a hidden turn changed. await put('a.txt', 'mine') - await session.revert(firstId) + await session.revert({ messageId: firstId }) expect(await read('a.txt')).toBe('two') await session.unrevert() @@ -780,7 +886,7 @@ describe.skipIf(!hasGit)('session.revert keeps the edits of the user', () => { }) const { firstId } = await runThreeTurns(session) blocking = true - const reverting = session.revert(firstId) + const reverting = session.revert({ messageId: firstId }) await reached.opened const prompting = session.send('Four') @@ -811,7 +917,7 @@ describe.skipIf(!hasGit)('session.revert keeps the edits of the user', () => { replies: [...turns.replies, () => text('Fourth.')], }) const { firstId } = await runThreeTurns(session) - await session.revert(firstId) + await session.revert({ messageId: firstId }) await session.configure({ settings: { cwd: 'sub' } }) await session.send('Four') @@ -831,7 +937,7 @@ describe.skipIf(!hasGit)('session.revert keeps the edits of the user', () => { await mkdir(join(root, 'sub')) const { host, session } = await open(threeTurns()) const { transcript, firstId } = await runThreeTurns(session) - await session.revert(firstId) + await session.revert({ messageId: firstId }) await session.configure({ settings: { cwd: 'sub' } }) await session.unrevert() @@ -849,7 +955,7 @@ describe.skipIf(!hasGit)('session.revert keeps the edits of the user', () => { await put('a.txt', 'one') const { host, session } = await open(threeTurns()) const { transcript, firstId } = await runThreeTurns(session) - await session.revert(firstId) + await session.revert({ messageId: firstId }) const refused = 'A revert stands. Run unrevert, or send a prompt, first.' expect(await session.command({ name: 'undo' })).toBe(refused) @@ -946,7 +1052,7 @@ describe('snapshots with a fake git', () => { // The steps of call-1 to call-505 come after the first answer. Of them, // the revert uses the last 500: from call-6 on. - await session.revert(first.id) + await session.revert({ messageId: first.id }) const restores = commands.filter((command) => command.includes('--name-status'), diff --git a/packages/ai-harness/tests/durable-background.test.ts b/packages/ai-harness/tests/durable-background.test.ts index 8ed098f448..6801d1fcae 100644 --- a/packages/ai-harness/tests/durable-background.test.ts +++ b/packages/ai-harness/tests/durable-background.test.ts @@ -89,7 +89,7 @@ describe('background work after a crash', () => { ) const turn = crashed.send('Wait') await started.opened - expect(await crashed.background('call-1')).toMatchObject({ + expect(await crashed.background({ toolCallId: 'call-1' })).toMatchObject({ status: 'accepted', }) await turn diff --git a/packages/ai-harness/tests/durable-inputs.test.ts b/packages/ai-harness/tests/durable-inputs.test.ts index c02e5ff10c..361823df66 100644 --- a/packages/ai-harness/tests/durable-inputs.test.ts +++ b/packages/ai-harness/tests/durable-inputs.test.ts @@ -1060,7 +1060,7 @@ describe('waiting inputs', () => { { inputId: 'third', delivery: 'steer', message: 'third' }, { inputId: 'second', delivery: 'queue', message: 'second' }, ]) - expect(await session.cancelInput('second')).toMatchObject({ + expect(await session.cancelInput({ inputId: 'second' })).toMatchObject({ status: 'accepted', }) expect(await session.settled({ inputId: 'second' })).toMatchObject({ @@ -1093,7 +1093,7 @@ describe('waiting inputs', () => { const turn = session.send('first', { inputId: 'first' }) await waitFor(() => expect(calls).toHaveLength(1)) - expect(await session.cancelInput('first')).toMatchObject({ + expect(await session.cancelInput({ inputId: 'first' })).toMatchObject({ status: 'rejected', reason: 'not_waiting', }) @@ -1166,7 +1166,7 @@ describe('waiting inputs', () => { ), ) - expect(await session.cancelInput('c-1')).toMatchObject({ + expect(await session.cancelInput({ inputId: 'c-1' })).toMatchObject({ status: 'accepted', }) expect(await session.settled({ inputId: 'c-1' })).toMatchObject({ diff --git a/packages/ai-harness/tests/durable-session.test.ts b/packages/ai-harness/tests/durable-session.test.ts index 1d625876c8..ef5b4651c2 100644 --- a/packages/ai-harness/tests/durable-session.test.ts +++ b/packages/ai-harness/tests/durable-session.test.ts @@ -236,9 +236,9 @@ describe('durable host stores', () => { const durable = await createHarnessHost({ persistence: durablePersistence(), }).open(harness, { threadId: THREAD }) - await expect(durable.append([{ type: 'harness.event' }])).rejects.toThrow( - 'reserved for the harness', - ) + await expect( + durable.append({ records: [{ type: 'harness.event' }] }), + ).rejects.toThrow('reserved for the harness') }) }) @@ -308,7 +308,33 @@ describe('durable session log', () => { await next.close() }) - it('writes host records in the same batch as the waiting events, in order', async () => { + it('logs an append with no inputId as an input, with the id of its receipt', async () => { + const persistence = durablePersistence() + const host = createHarnessHost({ persistence }) + const session = await host.open( + defineHarness({ + name: 'test/append', + adapter: mockAdapter(() => text('ok')).adapter, + }), + { threadId: THREAD }, + ) + + const { inputId } = await session.append({ + records: [{ type: 'app.note' }], + }) + + const state = await loadLogState({ + store: persistence.stores.log, + logId: LOG, + rootThread: THREAD, + }) + expect(sessionOf(state, THREAD).inputs.get(inputId)?.input.op).toBe( + 'append', + ) + await host.close() + }) + + it('writes host records after the waiting events, in order', async () => { const inner = memoryLogStore() const batches: Array> = [] const log = wrapLogStore(inner, { @@ -340,14 +366,20 @@ describe('durable session log', () => { expect(JSON.stringify(batches)).toContain('TEXT_MESSAGE_START'), ) - await session.append([{ type: 'app.note', n: 1 }]) + await session.append({ records: [{ type: 'app.note', n: 1 }] }) - expect(batches.at(-1)).toEqual([ + // The input record of the append takes the waiting events with it, and + // the records come after them. + expect(batches).toContainEqual([ expect.objectContaining({ type: EventType.TEXT_MESSAGE_CONTENT, delta: 'partial', }), + expect.objectContaining({ type: 'harness.input' }), + ]) + expect(batches.at(-2)).toEqual([ { type: 'app.note', n: 1 }, + expect.objectContaining({ type: 'harness.input.applied' }), ]) release.open() await Promise.resolve(turn).catch(() => {}) @@ -361,9 +393,9 @@ describe('durable session log', () => { name: 'check', description: 'Check the build', }).server(async () => { - await session?.append([ - { type: 'app.signal', text: '[signal] tests failed' }, - ]) + await session?.append({ + records: [{ type: 'app.signal', text: '[signal] tests failed' }], + }) await session?.send('more', { busy: 'steer' }).receipt return 'checked' }) @@ -405,9 +437,9 @@ describe('durable session log', () => { name: 'check', description: 'Check the build', }).server(async () => { - await session?.append([ - { type: 'app.signal', text: '[signal] tests failed' }, - ]) + await session?.append({ + records: [{ type: 'app.signal', text: '[signal] tests failed' }], + }) return 'checked' }) const { adapter, calls } = mockAdapter([ @@ -430,9 +462,9 @@ describe('durable session log', () => { await session.send('one') // Between turns: the next turn starts with it. - await session.append([ - { type: 'app.signal', text: '[signal] build failed' }, - ]) + await session.append({ + records: [{ type: 'app.signal', text: '[signal] build failed' }], + }) await session.send('two') expect(messageTexts(calls[1])).toEqual([ @@ -486,9 +518,11 @@ describe('durable session log', () => { await session.send('q1') await session.send('q2') - await session.append([ - { type: 'app.compact', summary: 'Earlier: q1 and a1.', firstKept: 2 }, - ]) + await session.append({ + records: [ + { type: 'app.compact', summary: 'Earlier: q1 and a1.', firstKept: 2 }, + ], + }) await session.send('q3') expect(messageTexts(calls[2])).toEqual([ diff --git a/packages/ai-harness/tests/fork.test.ts b/packages/ai-harness/tests/fork.test.ts index fcaa70567e..1503bc1aea 100644 --- a/packages/ai-harness/tests/fork.test.ts +++ b/packages/ai-harness/tests/fork.test.ts @@ -222,14 +222,14 @@ describe('host.fork', () => { if (!id) throw new Error('The fork lost the media part.') expect(id).not.toBe(record.id) - expect(await fork.getMedia(id)).toMatchObject({ + expect(await fork.getMedia({ id })).toMatchObject({ threadId: FORK, name: 'cat.png', }) expect(await fork.loadMedia({ id })).toEqual(bytes) // Each thread sees only its own media. - expect(await fork.getMedia(record.id)).toBeNull() - expect(await source.getMedia(id)).toBeNull() + expect(await fork.getMedia({ id: record.id })).toBeNull() + expect(await source.getMedia({ id })).toBeNull() await host.close() }) }) diff --git a/packages/ai-harness/tests/gap.test.ts b/packages/ai-harness/tests/gap.test.ts index 40dd6dcb1e..aaac57d05a 100644 --- a/packages/ai-harness/tests/gap.test.ts +++ b/packages/ai-harness/tests/gap.test.ts @@ -4,7 +4,7 @@ import { memoryPersistence } from '@tanstack/ai-persistence' import { HARNESS_EVENTS, createHarnessHost, defineHarness } from '../src' import { SessionFeed, gapEvent } from '../src/feed' import { createSessionView } from '../src/view' -import { mockAdapter, text } from './helpers' +import { gate, mockAdapter, text } from './helpers' import type { StreamChunk } from '@tanstack/ai' import type { SessionEvent } from '../src/types' import type { SessionViewSource } from '../src/view' @@ -107,4 +107,56 @@ describe('the view on an evicted gap', () => { await session.close() await host.close() }) + + it('applies no backlog event twice after the transcript loads again', async () => { + const harness = defineHarness({ + name: 'test/gap-backlog', + adapter: mockAdapter([() => text('First.'), () => text('Second.')]) + .adapter, + }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open(harness, { threadId: 'gap-backlog' }) + await session.send('One.') + const before = session.snapshot().cursor + const transcript = vi.fn(() => session.transcript()) + const turnTwo = gate() + const reading = new AbortController() + const overrides: Partial = { + transcript, + // The stream sends a gap, then the backlog of the second turn. The + // backlog comes only after the transcript loaded again. + events: () => + (async function* () { + await turnTwo.opened + yield gapEvent(session.threadId, '0', before, 'evicted') + await waitFor(() => expect(transcript).toHaveBeenCalledTimes(2)) + await new Promise((resolve) => setTimeout(resolve, 20)) + yield* session.events({ from: before, signal: reading.signal }) + })(), + } + const source = new Proxy(session, { + get: (target, key) => { + if (key in overrides) return overrides[key as keyof SessionViewSource] + const value: unknown = Reflect.get(target, key, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + const view = createSessionView(source) + await view.ready + await session.send('Two.') + turnTwo.open() + + const replies = () => + view.store + .get() + .messages.filter((message) => message.role === 'assistant') + await waitFor(() => expect(JSON.stringify(replies())).toContain('Second.')) + // The backlog events had time to apply. + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(replies()).toHaveLength(2) + view.dispose() + reading.abort() + await session.close() + await host.close() + }) }) diff --git a/packages/ai-harness/tests/goal.test.ts b/packages/ai-harness/tests/goal.test.ts index 1f2468cde2..9dab807fcb 100644 --- a/packages/ai-harness/tests/goal.test.ts +++ b/packages/ai-harness/tests/goal.test.ts @@ -4,6 +4,7 @@ import { toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' import { createHarnessHost, defineHarness, definePlugin } from '../src' import { GoalMet, goal, selectGoal } from '../src/first-party' +import { loadLogState, logIdOf, sessionOf } from '../src/log' import { after, appendToLog, @@ -279,7 +280,7 @@ describe('goal', () => { await session.command({ name: 'goal', input: 'finish' }) await waitFor(() => expect(session.inputs()).toHaveLength(1)) const [waiting] = session.inputs() - if (waiting) await session.cancelInput(waiting.inputId) + if (waiting) await session.cancelInput({ inputId: waiting.inputId }) hold.open() await question await waitFor(() => expect(session.snapshot().status).toBe('idle')) @@ -520,3 +521,44 @@ describe('goal', () => { expect(selectGoal(state)).toBeNull() }) }) + +describe('goal with two users', () => { + it('runs each round as the user who set the goal, not as the owner of the thread', async () => { + const main = mockAdapter([ + () => text('Round one.'), + () => text('Round two.'), + ]) + const { judge } = scriptedJudge([ + { met: false, reason: 'Not yet.' }, + { met: true, reason: 'Done.' }, + ]) + const persistence = memoryPersistence() + const host = createHarnessHost({ persistence }) + const harness = defineHarness({ + name: 'test/goal', + adapter: main.adapter, + plugins: () => [goal({ judge })], + }) + // Olga opens the thread first, so she owns it. + await host.open(harness, { threadId: 't', principal: { id: 'olga' } }) + const bea = await host.open(harness, { + threadId: 't', + principal: { id: 'bea' }, + }) + + await bea.command({ name: 'goal', input: 'ship it' }) + await waitFor(() => expect(main.calls).toHaveLength(2)) + await bea.whenIdle() + + const state = await loadLogState({ + store: persistence.stores.log, + logId: logIdOf({ logId: 't' }), + rootThread: 't', + }) + const prompts = [...sessionOf(state, 't').inputs.values()] + .filter((logged) => logged.input.op === 'prompt') + .map((logged) => logged.principal?.id) + expect(prompts).toEqual(['bea', 'bea']) + await host.close() + }) +}) diff --git a/packages/ai-harness/tests/helpers.ts b/packages/ai-harness/tests/helpers.ts index e3b3d32437..703864ce86 100644 --- a/packages/ai-harness/tests/helpers.ts +++ b/packages/ai-harness/tests/helpers.ts @@ -1,5 +1,6 @@ import { EventType } from '@tanstack/ai' import { loadLogState, logIdOf, sessionOf } from '../src/log' +import { UNCHECKED_TIMING } from '../src/ownership' import type { AnyChatMiddleware, AnyTextAdapter, @@ -94,7 +95,11 @@ export async function storedRun( * 200 ms and never renews it in a test, like a process that stopped. After * `ownershipExpired()`, another host can take its logs and recover its work. */ -export const CRASHING_OWNERSHIP = { ttlMs: 200, renewMs: 60_000 } +export const CRASHING_OWNERSHIP = { + ttlMs: 200, + renewMs: 60_000, + [UNCHECKED_TIMING]: true, +} /** Wait until an ownership of {@link CRASHING_OWNERSHIP} expired. */ export const ownershipExpired = () => diff --git a/packages/ai-harness/tests/http-access.test.ts b/packages/ai-harness/tests/http-access.test.ts index ff59c1caac..b9598bffc0 100644 --- a/packages/ai-harness/tests/http-access.test.ts +++ b/packages/ai-harness/tests/http-access.test.ts @@ -202,6 +202,50 @@ describe('the handler on a host with access', () => { await host.close() }) + it('watches revokes on the bus of the tenant, not on the every-tenant host.events()', async () => { + const { host, call, prompt, share } = setup() + await prompt('olga', 't1') + await share('bea', 'member') + // The every-tenant reader starts with all statuses and gets every event + // of every tenant, so a watch of each stream must not use it. + const everyTenant = vi.spyOn(host, 'events') + const response = await call('bea', 'events?threadId=t1') + const reading = readSse(response, () => false) + + host.revoke({ threadId: 't1', principal: bea }) + + await expect(reading).resolves.toBeInstanceOf(Array) + expect(everyTenant).not.toHaveBeenCalled() + await host.close() + }) + + it('reads the access of a readable thread once, not on each index change', async () => { + const { host, call, prompt } = setup() + await prompt('olga', 't1') + const get = vi.spyOn(host.sessions, 'get') + const renames: Array> = [] + const reading = readSse( + await call('olga', 'host-events'), + (frame: { type: string } | HostEvent) => { + // The hello: the stream reads the bus now. + if (frame.type === 'harness.hello') { + for (const title of ['a', 'b', 'c']) { + renames.push( + host.sessions.rename({ threadId: 't1', title, principal: olga }), + ) + } + } + return 'entry' in frame && frame.entry.title === 'c' + }, + ) + + await reading + await Promise.all(renames) + const reads = get.mock.calls.filter(([read]) => read.threadId === 't1') + expect(reads).toHaveLength(1) + await host.close() + }) + it('never shows the threads of another tenant in host-events', async () => { const { host, call, prompt } = setup() const reading = readSse( diff --git a/packages/ai-harness/tests/input-lane.test.ts b/packages/ai-harness/tests/input-lane.test.ts index 59ac0f64d5..9136923281 100644 --- a/packages/ai-harness/tests/input-lane.test.ts +++ b/packages/ai-harness/tests/input-lane.test.ts @@ -5,6 +5,7 @@ import { memoryLogBackend, memoryLogStore } from '@tanstack/ai-persistence' import { memoryCredentialStore } from '@tanstack/ai/byok/server' import { credentialsFor } from '../src/auth' import { + InputRejectedError, createHarnessHost, defineCommand, defineHarness, @@ -31,7 +32,9 @@ import type { AnyHarness, HarnessHostOptions, HarnessSession, + Receipt, } from '../src' +import type { SessionImpl } from '../src/session/harness-session' // Coverage runs in CI are slow, so polled checks wait up to 5 s. const waitFor = ( @@ -174,6 +177,91 @@ describe('an input that reaches a host that does not own the log', () => { await other.host.close() }) + it('ends settled() with InputRejectedError when the owner refuses the offered input', async () => { + const { a, b } = twoStores() + const release = gate() + const owner = await hostOn( + a, + harnessWith(mockAdapter([after(release.opened, 'One.')]).adapter), + ) + const other = await hostOn(b, harnessWith(mockAdapter([]).adapter)) + + const turn = owner.session.send('Go.') + await turn.receipt + const refused = other.session.send('Now!', { + inputId: 'in-b', + busy: 'reject', + }) + refused.then( + () => {}, + () => {}, + ) + expect((await refused.receipt).status).toBe('queued') + await expect( + other.session.settled({ + inputId: 'in-b', + signal: AbortSignal.timeout(3_000), + }), + ).rejects.toBeInstanceOf(InputRejectedError) + + release.open() + await turn + await owner.host.close() + await other.host.close() + }) + + it('refuses an offer whose id the log has with another payload, on the host that offered it', async () => { + const { a, b } = twoStores() + // The owner does not read the lane until the test lets it. + let isLaneOpen = false + const slowLane = wrapLogStore(a, { + offered: (scope) => (isLaneOpen ? a.offered(scope) : Promise.resolve([])), + }) + const release = gate() + const ownerModel = mockAdapter([ + after(release.opened, 'One.'), + () => text('Two.'), + ]) + const owner = await hostOn(slowLane, harnessWith(ownerModel.adapter)) + const other = await hostOn(b, harnessWith(mockAdapter([]).adapter)) + + const turn = owner.session.send('Go.') + await turn.receipt + const offered = other.session.send('From B.', { inputId: 'in-x' }) + offered.then( + () => {}, + () => {}, + ) + expect((await offered.receipt).status).toBe('queued') + const settled = other.session.settled({ + inputId: 'in-x', + signal: AbortSignal.timeout(3_000), + }) + settled.catch(() => {}) + // The owner stores another input with the same id first. + const stored = owner.session.send('From A.', { inputId: 'in-x' }) + expect((await stored.receipt).status).toBe('queued') + isLaneOpen = true + await owner.session.recover() + + await expect(settled).rejects.toMatchObject({ + name: 'InputRejectedError', + receipt: { reason: 'conflict' }, + }) + await expect(Promise.resolve(offered)).rejects.toBeInstanceOf( + InputRejectedError, + ) + expect(await waiting(a)).toEqual([]) + // The input that the log has is not refused: it runs. + release.open() + expect((await owner.session.settled({ inputId: 'in-x' })).outcome).toBe( + 'completed', + ) + expect(ownerModel.calls).toHaveLength(2) + await owner.host.close() + await other.host.close() + }) + it('streams an offered turn under its run id on the host that sent it', async () => { const { a, b } = twoStores() const release = gate() @@ -187,7 +275,10 @@ describe('an input that reaches a host that does not own the log', () => { const first = owner.session.send('Go.') await first.receipt // A `POST run` on the other host: the turn runs on the owner as `run-2`. - const second = other.session.send('And then?', { runId: 'run-2' }) + const second = (other.session as unknown as SessionImpl).sendForRun( + 'And then?', + { runId: 'run-2' }, + ) expect(await second.receipt).toMatchObject({ status: 'queued' }) release.open() const texts: Array = [] @@ -266,6 +357,26 @@ describe('an input that reaches a host that does not own the log', () => { return { credentials, saved } } + /** + * An answer as the owner applies it from the lane: with the `ref` of a + * secret answer, and the input id of the offer. + */ + function laneAnswer( + session: HarnessSession, + options: { + questionId: string + value: unknown + ref: string + controlId: string + }, + ): Promise { + // `applyAnswer` is internal: the input lane calls it. + const internal = session as unknown as { + applyAnswer: (offered: typeof options) => Promise + } + return internal.applyAnswer(options) + } + /** The owner asks for the password. Resolves with its question id. */ async function ask(session: HarnessSession, log: LogStore) { const running = session.command({ name: 'ask' }) @@ -330,11 +441,11 @@ describe('an input that reaches a host that does not own the log', () => { // The same ref again, as the owner applies it from the lane, is // refused: the store no longer has it. - const replay = await owner.session.answer({ + const replay = await laneAnswer(owner.session, { questionId, value: '[secret]', - ref, - inputId: 'replay', + ref: ref!, + controlId: 'replay', }) expect(replay).toMatchObject({ status: 'rejected', @@ -345,11 +456,11 @@ describe('an input that reaches a host that does not own the log', () => { const scope = { userId: 'someone-else' } const credential = { type: 'api_key' as const, value: '"theirs"' } await credentials.set({ scope, id: theirs, credential }) - const wrongUser = await owner.session.answer({ + const wrongUser = await laneAnswer(owner.session, { questionId, value: '[secret]', ref: theirs, - inputId: 'wrong-user', + controlId: 'wrong-user', }) expect(wrongUser).toMatchObject({ reason: 'unknown_secret_answer' }) expect(await credentials.get({ scope, id: theirs })).toEqual(credential) @@ -423,6 +534,42 @@ describe('an input that reaches a host that does not own the log', () => { await other.host.close() }) + it('cancels the turn of the owner when the offer comes while the owner appends', async () => { + const { a, b } = twoStores() + let other: Awaited> | undefined + let cancelIn: string | undefined + // The offer of B lands while the owner's append runs, so the store + // tells the owner about it during its own write. + const ownerLog = wrapLogStore(a, { + append: async (batch) => { + const operationId = cancelIn + if (operationId !== undefined && other) { + cancelIn = undefined + const receipt = await other.session.cancel({ operationId }) + expect(receipt.status).toBe('queued') + } + return a.append(batch) + }, + }) + const ownerModel = mockAdapter([untilAborted()]) + const owner = await hostOn(ownerLog, harnessWith(ownerModel.adapter)) + const otherHost = await hostOn(b, harnessWith(mockAdapter([]).adapter)) + other = otherHost + + const turn = owner.session.send('Go.') + const { operationId } = await turn.receipt + await waitFor(() => expect(ownerModel.calls).toHaveLength(1)) + cancelIn = operationId + // A write of the owner while its turn runs, with no write after it. + await owner.session.configure({ settings: { instructions: 'Be brief.' } }) + expect(cancelIn).toBeUndefined() + + await expect(Promise.resolve(turn)).rejects.toThrow() + expect(turn.status()).toBe('cancelled') + await owner.host.close() + await otherHost.host.close() + }) + it('starts an agent on the owner, and the run of the other host ends with it', async () => { const { a, b } = twoStores() const ran: Array = [] @@ -473,7 +620,9 @@ describe('an input that reaches a host that does not own the log', () => { const other = await hostOn(b, harness) const [first] = await other.session.transcript() - expect(await other.session.revert(first?.id ?? '')).toMatchObject({ + expect( + await other.session.revert({ messageId: first?.id ?? '' }), + ).toMatchObject({ status: 'rejected', reason: 'busy', }) diff --git a/packages/ai-harness/tests/log-stores.test.ts b/packages/ai-harness/tests/log-stores.test.ts index 9d4028405a..92e6ba06a2 100644 --- a/packages/ai-harness/tests/log-stores.test.ts +++ b/packages/ai-harness/tests/log-stores.test.ts @@ -10,6 +10,7 @@ import { HostOwnership } from '../src/ownership' import { mockAdapter, storedThreadState, text, toolCall } from './helpers' import type { LogStore } from '@tanstack/ai-persistence' import type { Reply } from './helpers' +import type { SessionImpl } from '../src/session/harness-session' const THREAD = 't1' @@ -171,11 +172,16 @@ describe('runs and interrupts of a session', () => { it('refuses a client run id that a run in the log has, also on a new host', async () => { const log = memoryLogStore() const first = await openOn(log, [() => text('Hi.')]) - await first.session.send('Hi.', { runId: 'run-1' }) + await (first.session as unknown as SessionImpl).sendForRun('Hi.', { + runId: 'run-1', + }) await first.host.close() const next = await openOn(log, [() => text('never')]) - const again = next.session.send('Hi again.', { runId: 'run-1' }) + const again = (next.session as unknown as SessionImpl).sendForRun( + 'Hi again.', + { runId: 'run-1' }, + ) expect(await again.receipt).toMatchObject({ status: 'rejected', reason: 'conflict', @@ -195,12 +201,20 @@ describe('runs and interrupts of a session', () => { tenantId: 'globex', }) - expect((await acme.session.send('Hi.', { runId: 'run-1' })).text).toBe( - 'Hello, acme.', - ) - expect((await globex.session.send('Hi.', { runId: 'run-1' })).text).toBe( - 'Hello, globex.', - ) + expect( + ( + await (acme.session as unknown as SessionImpl).sendForRun('Hi.', { + runId: 'run-1', + }) + ).text, + ).toBe('Hello, acme.') + expect( + ( + await (globex.session as unknown as SessionImpl).sendForRun('Hi.', { + runId: 'run-1', + }) + ).text, + ).toBe('Hello, globex.') await acme.host.close() await globex.host.close() }) diff --git a/packages/ai-harness/tests/log.test.ts b/packages/ai-harness/tests/log.test.ts index ee053e6209..f88d72d9e0 100644 --- a/packages/ai-harness/tests/log.test.ts +++ b/packages/ai-harness/tests/log.test.ts @@ -12,8 +12,10 @@ import { SNAPSHOT_VERSION, SharedLog, commonPrefix, + emptyLogState, emptySharedLogState, engineMessageStore, + foldEntry, loadLogState, logIdOf, logMessageStore, @@ -21,7 +23,7 @@ import { sessionOf, } from '../src/log' import { HostOwnership, LogOwnedElsewhereError } from '../src/ownership' -import { wrapLogStore, writeAsOtherHost } from './helpers' +import { CRASHING_OWNERSHIP, wrapLogStore, writeAsOtherHost } from './helpers' import { HARNESS_EVENTS } from '../src/types' import type { ModelMessage, StreamChunk } from '@tanstack/ai' import type { LogRecord, LogSnapshot, LogStore } from '@tanstack/ai-persistence' @@ -353,6 +355,27 @@ describe('fold robustness', () => { }) }) +describe('kept host records', () => { + it('keeps the last records of each type that keep names, and no other', () => { + const state = emptyLogState() + const keep = new Map([['app.step', 2]]) + const records = [ + { type: 'app.step', n: 1 }, + { type: 'app.other' }, + { type: 'app.step', n: 2 }, + { type: 'app.step', n: 3 }, + ] + records.forEach((record, index) => + foldEntry(state, { seq: index + 1, record }, undefined, keep), + ) + + expect(state.keptRecords.map((entry) => entry.record)).toEqual([ + { type: 'app.step', n: 2 }, + { type: 'app.step', n: 3 }, + ]) + }) +}) + describe('tool call records', () => { const result = (toolCallId: string): LogRecord => ({ type: 'harness.tool.result', @@ -685,7 +708,10 @@ describe('write failures', () => { // the test: a host that pauses. const first = new SharedLog({ store, - ownership: hostOwnership(store, { ttlMs: 20, renewMs: 60_000 }), + ownership: hostOwnership(store, { + ...CRASHING_OWNERSHIP, + ttlMs: 20, + }), harness: HARNESS, logId: LOG, rootThread: THREAD, @@ -1777,7 +1803,10 @@ describe('a log shared by sessions', () => { const onIdle = vi.fn() const log = new SharedLog({ store, - ownership: hostOwnership(store, { ttlMs: 20, renewMs: 60_000 }), + ownership: hostOwnership(store, { + ...CRASHING_OWNERSHIP, + ttlMs: 20, + }), harness: HARNESS, logId: SHARED, rootThread: 'log-1', diff --git a/packages/ai-harness/tests/media.test.ts b/packages/ai-harness/tests/media.test.ts index 8c51e399a8..f965ea7fe7 100644 --- a/packages/ai-harness/tests/media.test.ts +++ b/packages/ai-harness/tests/media.test.ts @@ -266,6 +266,65 @@ describe('createMediaStore', () => { expect(await store.dataUrl(a.id)).toBe('data:image/png;base64,YWFhYWE=') }) + it('keeps one base64 cap for all the threads of a blob store', async () => { + const persistence = memoryPersistence() + const blobGet = vi.spyOn(persistence.stores.blobs, 'get') + // Each 5-byte file is 8 characters of base64: two fit in 16, for both + // threads together. + const one = createMediaStore({ + persistence, + threadId: 't1', + encodedBytes: 16, + }) + const two = createMediaStore({ + persistence, + threadId: 't2', + encodedBytes: 16, + }) + const put = (store: typeof one, body: string) => + store.put(body, { mimeType: 'image/png', name: `${body}.png` }) + const a = await put(one, 'aaaaa') + const b = await put(two, 'bbbbb') + const c = await put(one, 'ccccc') + const reads = async (store: typeof one, id: string) => { + const before = blobGet.mock.calls.length + await store.dataUrl(id) + return blobGet.mock.calls.length - before + } + + expect(await reads(one, a.id)).toBe(1) + expect(await reads(two, b.id)).toBe(1) + // `c` pushes out `a`, the oldest, though `a` is of the other thread. + expect(await reads(one, c.id)).toBe(1) + expect(await reads(two, b.id)).toBe(0) + expect(await reads(one, a.id)).toBe(1) + }) + + it('takes the largest base64 cap that a store of the blob store asks for', async () => { + const persistence = memoryPersistence() + const blobGet = vi.spyOn(persistence.stores.blobs, 'get') + const one = createMediaStore({ + persistence, + threadId: 't1', + encodedBytes: 16, + }) + // A later store asks for more: three files of 8 characters fit in 24. + createMediaStore({ persistence, threadId: 't2', encodedBytes: 24 }) + const put = (body: string) => + one.put(body, { mimeType: 'image/png', name: `${body}.png` }) + const ids = [ + (await put('aaaaa')).id, + (await put('bbbbb')).id, + (await put('ccccc')).id, + ] + for (const id of ids) await one.dataUrl(id) + const before = blobGet.mock.calls.length + + for (const id of ids) await one.dataUrl(id) + + expect(blobGet.mock.calls.length).toBe(before) + }) + it('makes a data URL up to 1 MB only', async () => { const { store } = setup() const small = await store.put(encoder.encode('hello'), { diff --git a/packages/ai-harness/tests/ownership.test.ts b/packages/ai-harness/tests/ownership.test.ts index 212a718c76..67311269e2 100644 --- a/packages/ai-harness/tests/ownership.test.ts +++ b/packages/ai-harness/tests/ownership.test.ts @@ -99,9 +99,9 @@ describe('ownership of a log by two hosts', () => { inputId: 'in-b', status: 'queued', }) - await expect(b.session.append([{ type: 'app.from-b' }])).rejects.toThrow( - 'Another host owns the log', - ) + await expect( + b.session.append({ records: [{ type: 'app.from-b' }] }), + ).rejects.toThrow('Another host owns the log') // B still reads: it gets the answers of A. release.open() @@ -366,7 +366,7 @@ describe('renewals', () => { const ownership = new HostOwnership({ store: log, ownerId: 'host-1', - timing: { ttlMs: 20, renewMs: 40 }, + timing: { ...CRASHING_OWNERSHIP, ttlMs: 20, renewMs: 40 }, }) const lost = vi.fn() ownership.onLost(LOG, lost) @@ -450,3 +450,40 @@ describe('the run context', () => { await host.close() }) }) + +describe('ownership timing', () => { + it('renews at a third of ttlMs when only ttlMs is set', async () => { + const intervals = vi.spyOn(globalThis, 'setInterval') + const ownership = new HostOwnership({ + store: memoryLogStore(), + ownerId: 'host-1', + timing: { ttlMs: 6_000 }, + }) + await ownership.acquire(LOG, 'test/ownership') + + expect(intervals.mock.calls.map(([, ms]) => ms)).toContain(2_000) + await ownership.close() + intervals.mockRestore() + }) + + it('refuses a renewMs that is not less than ttlMs', () => { + expect(() => + createHarnessHost({ + persistence: memoryPersistence(), + ownership: { ttlMs: 5_000, renewMs: 5_000 }, + }), + ).toThrow(RangeError) + expect(() => + createHarnessHost({ + persistence: memoryPersistence(), + ownership: { renewMs: 30_000 }, + }), + ).toThrow('must be less than ownership.ttlMs') + expect(() => + createHarnessHost({ + persistence: memoryPersistence(), + ownership: { ttlMs: 0 }, + }), + ).toThrow('more than 0') + }) +}) diff --git a/packages/ai-harness/tests/permissions.test.ts b/packages/ai-harness/tests/permissions.test.ts index 800c65604b..631eec1f68 100644 --- a/packages/ai-harness/tests/permissions.test.ts +++ b/packages/ai-harness/tests/permissions.test.ts @@ -369,6 +369,43 @@ describe('permissions()', () => { await second.host.close() }) + it('decides on the final arguments: a middleware before it cannot rewrite a call past a deny', async () => { + const tools = fakeTools() + // It runs before permissions(), and its onBeforeToolCall wins first. + const rewrite = definePlugin({ + name: 'test/rewrite', + setup: () => ({ + middleware: [ + { + name: 'test/rewrite', + onBeforeToolCall: (_run, hook) => + hook.toolName === 'read_file' + ? { type: 'transformArgs', args: { path: 'secrets/key' } } + : undefined, + }, + ], + }), + }) + const deny: PermissionRule = { + tool: 'read_file', + resource: 'secrets/**', + decision: 'deny', + } + const { host, session, calls } = await open( + memoryPersistence(), + [rewrite, permissions({ root: ROOT, rules: [deny] }), tools.plugin], + [() => toolCall('read_file', { path: 'src/a.ts' }, 'c1')], + ) + + await session.send('Read a file.') + + expect(tools.ran).toEqual([]) + expect(JSON.stringify(calls[1].messages)).toContain( + 'A permission rule denies this tool.', + ) + await host.close() + }) + it('keeps an always answer for the session when there is no metadata store', async () => { const { stores } = memoryPersistence() const tools = fakeTools() diff --git a/packages/ai-harness/tests/plugin-owners.test.ts b/packages/ai-harness/tests/plugin-owners.test.ts index f33694fb2a..f553e49028 100644 --- a/packages/ai-harness/tests/plugin-owners.test.ts +++ b/packages/ai-harness/tests/plugin-owners.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it, vi } from 'vitest' +import { memoryPersistence } from '@tanstack/ai-persistence' import { createCapability, defineAgent, toolDefinition } from '@tanstack/ai' import { AgentRegistry } from '../src/agents' import { defineCommand } from '../src/commands' import { configOption } from '../src/config' import { createExtensionPoint, createPluginEvent } from '../src/extensions' import { definePlugin, mountPlugins } from '../src/plugins' +import { createHarnessHost, defineHarness } from '../src' +import { mockAdapter, text } from './helpers' import type { MountedPlugins, PluginSetupContext } from '../src/plugins' const env = () => ({ @@ -136,6 +139,29 @@ describe('MountedPlugins.withoutOwners', () => { ]) }) + it('reads the commands and agents that a kept plugin sets after the copy', async () => { + let saved: PluginSetupContext | undefined + const late = definePlugin({ + name: 'late', + setup: (ctx) => { + saved = ctx + }, + }) + const mounted = await mountPlugins([late, contributor('b').plugin], env()) + const kept = mounted.withoutOwners(['b']) + + saved?.commands.set( + 'deploy', + defineCommand({ description: 'Deploy', run: () => 'deployed' }), + ) + saved?.agents.set(agentNamed('late-subagent'), { subagent: true }) + + expect(contents(kept).commands).toEqual(['deploy']) + expect(contents(kept).subagents).toEqual(['late-subagent']) + saved?.commands.delete('deploy') + expect(contents(kept).commands).toEqual([]) + }) + it('refuses to remove a plugin whose capability a kept plugin requires', async () => { const a = contributor('a') const reader = definePlugin({ @@ -177,3 +203,36 @@ describe('ctx.on', () => { expect(added.filter((item) => !removed.includes(item))).toEqual([]) }) }) + +describe('a thread that removes a plugin', () => { + it('does not run an agent of that plugin by name', async () => { + const worker = defineAgent({ + name: 'worker', + description: 'Works', + run: async () => 'worked', + }) + const plugin = definePlugin({ + name: 'test/workers', + setup: () => ({ agents: [worker] }), + }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open( + defineHarness({ + name: 'test/plugin-owners', + adapter: mockAdapter(() => text('ok')).adapter, + plugins: () => [plugin], + }), + { threadId: 't' }, + ) + const byName = session.agent('worker') + expect(await byName?.run()).toBe('worked') + + await session.configure({ + settings: { plugins: { remove: ['test/workers'] } }, + }) + + expect(session.agent('worker')).toBeUndefined() + await expect(byName?.start()).rejects.toThrow('Unknown agent: worker') + await host.close() + }) +}) diff --git a/packages/ai-harness/tests/protocol.test.ts b/packages/ai-harness/tests/protocol.test.ts index 966afd9a38..6e694c180d 100644 --- a/packages/ai-harness/tests/protocol.test.ts +++ b/packages/ai-harness/tests/protocol.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { defineAgent } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' -import { createHarnessHost, defineHarness } from '../src' +import { + createHarnessHost, + defineCommand, + defineHarness, + definePlugin, + logIdOf, +} from '../src' import { applyInput, createHarnessHandler, @@ -24,6 +30,7 @@ import { negotiateVersion, requestIdOf, } from '../src/protocol' +import type { Receipt } from '../src' import type { HostFrame } from '../src/server' import type { Reply } from './helpers' @@ -227,6 +234,88 @@ describe('HTTP handler', () => { await host.close() }) + it('runs a resent command or agent start with the same inputId once', async () => { + const runs = { command: 0, agent: 0 } + const counter = defineAgent({ + name: 'counter', + description: 'Counts its runs', + run: async () => ++runs.agent, + }) + const harness = defineHarness({ + name: 'test/protocol-resend', + adapter: mockAdapter([]).adapter, + agents: [counter], + plugins: () => [ + definePlugin({ + name: 'test/count', + setup: () => ({ + commands: { + count: defineCommand({ + description: 'Counts its runs', + run: () => ++runs.command, + }), + }, + }), + }), + ], + expose: { agents: ['counter'], commands: ['count'] }, + }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open(harness, { threadId: 'resend' }) + + // The operation of a receipt ends, also as a duplicate. + const settled = (receipt: Receipt) => + receipt.operationId === undefined + ? undefined + : session.operation(receipt.operationId)?.then( + () => {}, + () => {}, + ) + const command = { op: 'command', name: 'count', inputId: 'c-1' } as const + const first = await applyInput({ harness, session, input: command }) + expect(first).toMatchObject({ inputId: 'c-1', status: 'accepted' }) + await vi.waitFor(() => expect(runs.command).toBe(1), { timeout: 5000 }) + await settled(await applyInput({ harness, session, input: command })) + + const agent = { op: 'agent', agent: 'counter', inputId: 'a-1' } as const + expect(await applyInput({ harness, session, input: agent })).toMatchObject({ + inputId: 'a-1', + status: 'accepted', + }) + await vi.waitFor(() => expect(runs.agent).toBe(1), { timeout: 5000 }) + await settled(await applyInput({ harness, session, input: agent })) + + expect(runs).toEqual({ command: 1, agent: 1 }) + await host.close() + }) + + it('writes the records of a resent append with the same inputId once', async () => { + const persistence = memoryPersistence() + const harness = defineHarness({ + name: 'test/protocol-append', + adapter: mockAdapter([]).adapter, + expose: { records: ['app.signal'] }, + }) + const host = createHarnessHost({ persistence }) + const session = await host.open(harness, { threadId: 'append' }) + const input = { + op: 'append' as const, + records: [{ type: 'app.signal', text: 'The build failed.' }], + inputId: 'ap-1', + } + + const first = await applyInput({ harness, session, input }) + const again = await applyInput({ harness, session, input }) + + expect(first).toEqual({ inputId: 'ap-1', status: 'accepted' }) + expect(again).toEqual(first) + const signals = ( + await persistence.stores.log.read({ logId: logIdOf({ logId: 'append' }) }) + ).filter(({ record }) => record.type === 'app.signal') + expect(signals).toHaveLength(1) + await host.close() + }) + it('runs exposed agents from a client and refuses the others', async () => { const { handler, host } = setup() const control = controlOf(handler, 'user-1-a') @@ -502,6 +591,53 @@ describe('WebSocket', () => { }) }) +describe('WebSocket frame order', () => { + it('applies the inputs of one socket in the order they came', async () => { + const { host, harness } = setup() + const { socket, sent, receive } = fakeSocket() + handleHarnessSocket({ + host, + harness, + socket, + request: new Request('http://x/api/harness/socket', { headers: auth }), + }) + receive({ type: 'harness.subscribe', threadId: 'user-1-order' }) + await vi.waitFor( + () => + expect(sent.some((frame) => frame.type === 'harness.hello')).toBe(true), + { timeout: 5000 }, + ) + + // The first input stores its image before it reaches the session. The + // second is text only, so it is faster on its own. + const image = { + type: 'image', + source: { type: 'data', value: 'aGVsbG8=', mimeType: 'image/png' }, + } + receive({ + type: 'harness.input', + requestId: 'first', + input: { op: 'prompt', inputId: 'first', message: [image] }, + }) + receive({ + type: 'harness.input', + requestId: 'second', + input: { op: 'prompt', inputId: 'second', message: 'B' }, + }) + + const receipts = () => + sent.flatMap((frame) => + frame.type === 'harness.receipt' ? [frame.requestId] : [], + ) + await vi.waitFor(() => expect(receipts()).toHaveLength(2), { + timeout: 5000, + }) + expect(receipts()).toEqual(['first', 'second']) + socket.close() + await host.close() + }) +}) + describe('parseControlFrame', () => { it('rejects unknown frames', () => { expect(() => parseControlFrame('{"type":"nope"}')).toThrow('unknown type') diff --git a/packages/ai-harness/tests/redaction.test.ts b/packages/ai-harness/tests/redaction.test.ts index 3e1e115727..0961ffc350 100644 --- a/packages/ai-harness/tests/redaction.test.ts +++ b/packages/ai-harness/tests/redaction.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { memoryCredentialStore } from '@tanstack/ai/byok/server' import { describe, expect, it } from 'vitest' import { z } from 'zod' @@ -15,10 +18,13 @@ import { import { Redactor, redactMiddleware } from '../src/auth' import { createHarnessHandler } from '../src/server' import { scrubbedBy } from '../src/session/harness-session' +import { hostBackend } from '../src/first-party/coding/backend' +import { workspaceTools } from '../src/first-party/coding/workspace' import { mockAdapter, text, toolCall } from './helpers' import type { AnyTool, ChatMiddlewareConfig } from '@tanstack/ai' import type { Reply } from './helpers' -import type { HarnessRunContext } from '../src' +import type { WorkspaceBackend } from '../src/first-party/coding/backend' +import type { HarnessPlugin, HarnessRunContext } from '../src' const THREAD = 't1' const LOG = logIdOf({ logId: THREAD }) @@ -38,10 +44,12 @@ const leaked = createPluginEvent<{ key: string }>('test/leaked') async function open({ fail, tools = [], + plugins = [], replies = [() => toolCall('leak', {}), () => text('done')], }: { fail: boolean tools?: Array + plugins?: Array replies?: Array }) { const credentials = memoryCredentialStore() @@ -80,7 +88,7 @@ async function open({ name: 'test/redaction', adapter: model.adapter, tools, - plugins: () => [leaky], + plugins: () => [leaky, ...plugins], }) const session = await host.open(harness, { threadId: THREAD, @@ -130,6 +138,46 @@ describe('the redaction sink of a session', () => { ) }) +describe('a bash spill', () => { + it('keeps no key of the session in the file of the full output', async () => { + const root = await mkdtemp(join(tmpdir(), 'tanstack-spill-')) + try { + // Long output with the key: the model gets only its end, and the + // file gets all of it. + const backend: WorkspaceBackend = { + ...hostBackend, + exec: async () => ({ + exitCode: 0, + stdout: `${KEY}\n`.repeat(3000), + stderr: '', + }), + } + const { host, session } = await open({ + fail: false, + plugins: [ + workspaceTools({ root, backend, spillDir: 'spill', web: false }), + ], + replies: [ + () => toolCall('leak', {}, 'c0'), + () => toolCall('bash', { command: 'print-key' }, 'c1'), + () => text('done'), + ], + }) + + await session.send('Leak it twice.') + + const [file] = await readdir(join(root, 'spill')) + if (file === undefined) throw new Error('No spill file.') + const saved = await readFile(join(root, 'spill', file), 'utf8') + expect(saved).toContain('[redacted]') + expect(saved).not.toContain(KEY_PART) + await host.close() + } finally { + await rm(root, { recursive: true, force: true }) + } + }) +}) + describe('the edges of a session', () => { it.each([ { fail: false, what: 'result' }, diff --git a/packages/ai-harness/tests/resume-pending.test.ts b/packages/ai-harness/tests/resume-pending.test.ts index e46a01e8f0..4d988c100f 100644 --- a/packages/ai-harness/tests/resume-pending.test.ts +++ b/packages/ai-harness/tests/resume-pending.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { defineAgent } from '@tanstack/ai' -import { UNOWNED_EPOCH, memoryLogStore } from '@tanstack/ai-persistence' +import { + UNOWNED_EPOCH, + memoryLogBackend, + memoryLogStore, +} from '@tanstack/ai-persistence' import { createHarnessHost, defineHarness, definePlugin, logIdOf } from '../src' +import { OPEN_RETRY_MS } from '../src/ownership' import { after, canOtherHostTake, @@ -357,7 +362,10 @@ describe('host.resumePending', () => { }) it('goes on after a thread that fails to open, and names the threads it opened', async () => { - const log = memoryLogStore() + let skew = 0 + const log = memoryLogStore({ + backend: memoryLogBackend({ now: () => Date.now() + skew }), + }) // The failing thread expired first, so it comes first. await stopOwner(log, logIdOf({ logId: 't-bad' })) await seedStoppedTurn(log) @@ -393,6 +401,15 @@ describe('host.resumePending', () => { }), ) expect(calls).toHaveLength(1) + // The log of the thread that failed goes back with a wake, so a later + // sweep lists it again. + const listed = async () => + (await log.listOrphaned({ limit: 10, harnesses: [NAME] })).map( + (orphan) => orphan.logId, + ) + expect(await listed()).not.toContain(logIdOf({ logId: 't-bad' })) + skew = OPEN_RETRY_MS + expect(await listed()).toContain(logIdOf({ logId: 't-bad' })) await host.close() }) diff --git a/packages/ai-harness/tests/retention.test.ts b/packages/ai-harness/tests/retention.test.ts index 7f70304654..f461b6d7af 100644 --- a/packages/ai-harness/tests/retention.test.ts +++ b/packages/ai-harness/tests/retention.test.ts @@ -4,6 +4,7 @@ import { memoryPersistence } from '@tanstack/ai-persistence' import { createHarnessHost, defineHarness } from '../src' import { mockAdapter, text } from './helpers' import type { HarnessPersistence, RetentionLimits } from '../src' +import type { SessionImpl } from '../src/session/harness-session' /** * A session with `retention`. The model answers `done` to every call, and @@ -109,7 +110,9 @@ describe('retention', () => { settledOperations: 2, settledAgentRuns: 1, }) - await session.send('Hi', { runId: 'run-0' }) + await (session as unknown as SessionImpl).sendForRun('Hi', { + runId: 'run-0', + }) for (let index = 0; index < 2; index += 1) await session.send('Hi') expect(session.operation('run-0')).toBe(undefined) // Both windows keep 3 operations. @@ -117,7 +120,9 @@ describe('retention', () => { expect(privateSetSize(session, 'retiredIds')).toBe(3) // The log keeps every run, so the id stays taken. - const reused = session.send('Hi', { runId: 'run-0' }) + const reused = (session as unknown as SessionImpl).sendForRun('Hi', { + runId: 'run-0', + }) expect(await reused.receipt).toMatchObject({ status: 'rejected', reason: 'conflict', diff --git a/packages/ai-harness/tests/session-media.test.ts b/packages/ai-harness/tests/session-media.test.ts index a39321a0a8..0b271b8256 100644 --- a/packages/ai-harness/tests/session-media.test.ts +++ b/packages/ai-harness/tests/session-media.test.ts @@ -149,7 +149,7 @@ describe('session media store', () => { }) expect(record).toEqual(storedImage()) - expect(await session.getMedia(record.id)).toEqual(record) + expect(await session.getMedia({ id: record.id })).toEqual(record) expect(decoder.decode(await session.loadMedia({ id: record.id }))).toBe( 'hello', ) @@ -201,7 +201,7 @@ describe('session media store', () => { name: 'a.png', }) - expect(await other.getMedia(record.id)).toBeNull() + expect(await other.getMedia({ id: record.id })).toBeNull() await host.close() }) @@ -213,10 +213,10 @@ describe('session media store', () => { name: 'cat.png', }) - expect(await session.mediaUrl(record.id)).toEqual({ + expect(await session.mediaUrl({ id: record.id })).toEqual({ url: 'data:image/png;base64,aGVsbG8=', }) - expect(await session.mediaUrl('nope')).toStrictEqual({}) + expect(await session.mediaUrl({ id: 'nope' })).toStrictEqual({}) await host.close() }) }) diff --git a/packages/ai-harness/tests/session-plugins.test.ts b/packages/ai-harness/tests/session-plugins.test.ts index 8caf491c0b..36de9ecc2a 100644 --- a/packages/ai-harness/tests/session-plugins.test.ts +++ b/packages/ai-harness/tests/session-plugins.test.ts @@ -17,8 +17,16 @@ import { } from '../src' import { compact, modelPicker, todos, usage } from '../src/first-party' import { fileCommands, projectInstructions } from '../src/first-party/node' -import { appendToLog, gate, mockAdapter, text, toolCall } from './helpers' -import type { PluginState, SessionEvent } from '../src' +import { loadLogState, logIdOf, sessionOf } from '../src/log' +import { + after, + appendToLog, + gate, + mockAdapter, + text, + toolCall, +} from './helpers' +import type { PluginSessionApi, PluginState, SessionEvent } from '../src' // Coverage runs in CI are slow, so polled checks wait up to 5 s. const waitFor = ( @@ -878,3 +886,66 @@ describe('session events', () => { await host.close() }) }) + +describe('the turns that a session plugin starts', () => { + it('run as the owner, or as the principal that the plugin passes, not as the sender of the running turn', async () => { + let api: PluginSessionApi | undefined + const notes = definePlugin({ + name: 'test/notes', + setup: (ctx) => { + api = ctx.session + }, + }) + const release = gate() + const { adapter, calls } = mockAdapter([ + after(release.opened, 'Bea is done.'), + () => text('One.'), + () => text('Two.'), + () => text('Three.'), + ]) + const persistence = memoryPersistence() + const host = createHarnessHost({ persistence }) + const harness = defineHarness({ + name: 'test/notes', + adapter, + plugins: () => [notes], + }) + // Olga opens the thread first, so she owns it. + await host.open(harness, { threadId: 't', principal: { id: 'olga' } }) + const bea = await host.open(harness, { + threadId: 't', + principal: { id: 'bea' }, + }) + const turn = bea.send('Start a job.') + await waitFor(() => expect(turn.status()).toBe('running')) + if (!api) throw new Error('No plugin context.') + + // A job of olga ends while the turn of bea runs. + await api.note('The job of olga ended.', { wake: true }) + await api.note('The job of carl ended.', { + wake: true, + principal: { id: 'carl' }, + }) + api.send('Go on, carl.', { principal: { id: 'carl' } }) + release.open() + await turn + await waitFor(() => expect(calls).toHaveLength(4)) + + const state = await loadLogState({ + store: persistence.stores.log, + logId: logIdOf({ logId: 't' }), + rootThread: 't', + }) + const senders = [...sessionOf(state, 't').inputs.values()].map((logged) => [ + logged.input.op, + logged.principal?.id, + ]) + expect(senders).toEqual([ + ['prompt', 'bea'], + ['followUp', 'olga'], + ['followUp', 'carl'], + ['prompt', 'carl'], + ]) + await host.close() + }) +}) diff --git a/packages/ai-harness/tests/session-surface.test.ts b/packages/ai-harness/tests/session-surface.test.ts index 1a44b3263c..c4942dcd81 100644 --- a/packages/ai-harness/tests/session-surface.test.ts +++ b/packages/ai-harness/tests/session-surface.test.ts @@ -91,6 +91,27 @@ describe('the HarnessSession type', () => { runId: 'run-1', } void answers + // @ts-expect-error the handler sends a `POST run` as its run id. + void session.sendForRun + const sent: Parameters[1] = { + // @ts-expect-error `send` takes no run id. + runId: 'run-1', + } + void sent + const config: Parameters[0] = { + key: 'model', + value: 'fast', + // @ts-expect-error only an offered input has its own control id. + inputId: 'in-1', + } + void config + const answer: Parameters[0] = { + questionId: 'q-1', + value: 'yes', + // @ts-expect-error only an offered answer has a secret ref. + ref: 'harness:answer:q-1:in-1', + } + void answer await host.close() }) }) diff --git a/packages/ai-harness/tests/shared-log.test.ts b/packages/ai-harness/tests/shared-log.test.ts index e5ccc05bfd..ec38d81632 100644 --- a/packages/ai-harness/tests/shared-log.test.ts +++ b/packages/ai-harness/tests/shared-log.test.ts @@ -58,7 +58,7 @@ describe('the log of a session tree', () => { { type: 'app.child_linked', child: 'task-1' }, ] - await root.append(pair) + await root.append({ records: pair }) const types = async () => (await inner.read({ logId: STORE_LOG })).map((entry) => entry.record.type) expect(await types()).toEqual(['app.child_created', 'app.child_linked']) @@ -68,7 +68,7 @@ describe('the log of a session tree', () => { log.append = async () => { throw new Error('store down') } - await expect(root.append(pair)).rejects.toThrow('store down') + await expect(root.append({ records: pair })).rejects.toThrow('store down') log.append = original expect(await types()).toEqual(['app.child_created', 'app.child_linked']) await host.close().catch(() => {}) @@ -79,11 +79,17 @@ describe('the log of a session tree', () => { const host = hostFor(persistence) const root = await host.open(tracked, { threadId: ROOT }) - await root.append([{ type: 'app.state_write', name: 'plan', value: 'a' }]) - await root.append([ - { type: 'app.state_write', thread: 'task-1', name: 'plan', value: 'b' }, - ]) - await root.append([{ type: 'app.state_write', name: 'done', value: true }]) + await root.append({ + records: [{ type: 'app.state_write', name: 'plan', value: 'a' }], + }) + await root.append({ + records: [ + { type: 'app.state_write', thread: 'task-1', name: 'plan', value: 'b' }, + ], + }) + await root.append({ + records: [{ type: 'app.state_write', name: 'done', value: true }], + }) const folded = { plan: 'b', done: true } expect(await root.logState()).toEqual(folded) @@ -104,7 +110,9 @@ describe('the log of a session tree', () => { it('gives undefined from session.logState() for a harness without reduce', async () => { const host = hostFor(durablePersistence()) const root = await host.open(plain, { threadId: ROOT }) - await root.append([{ type: 'app.state_write', name: 'plan', value: 'a' }]) + await root.append({ + records: [{ type: 'app.state_write', name: 'plan', value: 'a' }], + }) expect(await root.logState()).toBeUndefined() expectTypeOf(root.logState).returns.resolves.toEqualTypeOf() @@ -126,10 +134,12 @@ describe('the log of a session tree', () => { }, }) const root = await host.open(harness, { threadId: ROOT }) - await root.append([ - { type: 'app.signal', thread: 'task-1', text: 'for the child' }, - { type: 'app.signal', text: 'for the root' }, - ]) + await root.append({ + records: [ + { type: 'app.signal', thread: 'task-1', text: 'for the child' }, + { type: 'app.signal', text: 'for the root' }, + ], + }) expect((await root.transcript()).map((m) => m.content)).toEqual([ 'for the root', @@ -141,7 +151,7 @@ describe('the log of a session tree', () => { const persistence = durablePersistence() const host = hostFor(persistence) const root = await host.open(plain, { threadId: ROOT }) - await root.append([{ type: 'app.signal' }]) + await root.append({ records: [{ type: 'app.signal' }] }) // The session has no work, so the host gave the log up. Another host // takes it and writes. @@ -154,7 +164,7 @@ describe('the log of a session tree', () => { expect(host.isOpen(plain, { threadId: ROOT })).toBe(true) // The next write acquires the log again, after the record of the other // host. - await root.append([{ type: 'app.root' }]) + await root.append({ records: [{ type: 'app.root' }] }) const records = await persistence.stores.log.read({ logId: STORE_LOG }) const types = records .map((entry) => entry.record.type) @@ -168,7 +178,7 @@ describe('the log of a session tree', () => { const root = await host.open(plain, { threadId: ROOT }) await expect( - root.append([{ type: 'app.signal', thread: 7 }]), + root.append({ records: [{ type: 'app.signal', thread: 7 }] }), ).rejects.toThrow('thread') await host.close() }) diff --git a/packages/ai-harness/tests/snapshots.test.ts b/packages/ai-harness/tests/snapshots.test.ts index 325b284e0c..99be8126bb 100644 --- a/packages/ai-harness/tests/snapshots.test.ts +++ b/packages/ai-harness/tests/snapshots.test.ts @@ -1,13 +1,25 @@ import { describe, expect, it, vi } from 'vitest' import { EventType } from '@tanstack/ai' -import { memoryPersistence } from '@tanstack/ai-persistence' -import { HARNESS_EVENTS, createHarnessHost, defineHarness } from '../src' +import { UNOWNED_EPOCH, memoryPersistence } from '@tanstack/ai-persistence' +import { + HARNESS_EVENTS, + createHarnessHost, + defineHarness, + definePlugin, +} from '../src' +import { RevertFiles } from '../src/plugins' import { createHarnessHandler } from '../src/server' import { createHarnessClient } from '../src/client' import { createSessionView } from '../src/view' import { logIdOf } from '../src/log' import { mockAdapter, text, wrapLogStore } from './helpers' -import type { HarnessHost, HarnessHostOptions, SessionEvent } from '../src' +import type { + AnyHarness, + HarnessHost, + HarnessHostOptions, + PluginSessionApi, + SessionEvent, +} from '../src' // Coverage runs in CI are slow, so polled checks wait up to 5 s. const waitFor = ( @@ -25,16 +37,16 @@ const harness = defineHarness({ }) /** One turn on `THREAD`, then the session closes. */ -async function chat(host: HarnessHost) { - const session = await host.open(harness, { threadId: THREAD }) +async function chat(host: HarnessHost, target: AnyHarness = harness) { + const session = await host.open(target, { threadId: THREAD }) await session.send('Hello.') await session.close() } /** Write one turn on a host with `options`, which then stops. */ -async function seed(options: HarnessHostOptions) { +async function seed(options: HarnessHostOptions, target?: AnyHarness) { const host = createHarnessHost(options) - await chat(host) + await chat(host, target) await host.close() } @@ -135,4 +147,111 @@ describe('log snapshots on a host', () => { view.dispose() await host.close() }) + + it('gives a plugin its kept records after a truncate, oldest first', async () => { + const persistence = memoryPersistence() + const records = [ + { type: 'acme/steps:step', n: 1 }, + { type: 'acme/other', n: 2 }, + { type: 'acme/steps:step', n: 3 }, + ] + await persistence.stores.log.append({ + logId: LOG, + seq: 1, + epoch: UNOWNED_EPOCH, + records, + }) + let session: PluginSessionApi | undefined + const steps = definePlugin({ + name: 'acme/steps', + keepRecords: { 'acme/steps:step': 10 }, + setup: (ctx) => { + session = ctx.session + }, + }) + const withSteps = defineHarness({ + name: 'test/snapshots', + adapter: mockAdapter(() => text('Hello back.')).adapter, + plugins: () => [steps], + }) + const options: HarnessHostOptions = { + persistence, + log: { snapshot: { afterBytes: 1 } }, + lifecycle: { retention: { logRecords: 'since-snapshot' } }, + } + await seed(options, withSteps) + await expect( + persistence.stores.log.read({ logId: LOG, after: 0 }), + ).rejects.toThrow() + + const host = createHarnessHost(options) + const handle = await host.open(withSteps, { threadId: THREAD }) + + expect(await session?.keptRecords('acme/steps:step')).toEqual([ + records[0], + records[2], + ]) + await handle.close() + await host.close() + }) + + it('reverts after a truncate with the file steps from before the cut', async () => { + const persistence = memoryPersistence() + const step = { + type: 'tanstack/snapshots:step', + from: 'a', + to: 'b', + files: ['notes.md'], + toolCallIds: ['call-1'], + } + // A step record of the snapshots plugin, before the first turn. + await persistence.stores.log.append({ + logId: LOG, + seq: 1, + epoch: UNOWNED_EPOCH, + records: [step], + }) + let steps: Array> = [] + const files = definePlugin({ + name: 'test/files', + provides: [RevertFiles], + // The fold keeps the steps, so the snapshot has them. + keepRecords: { [step.type]: 10 }, + setup: ({ provide }) => + provide(RevertFiles, { + revert: async (_hidden, records) => { + steps = await records(step.type) + return undefined + }, + unrevert: async () => {}, + }), + }) + const withFiles = defineHarness({ + name: 'test/snapshots', + adapter: mockAdapter(() => text('Hello back.')).adapter, + plugins: () => [files], + }) + const options: HarnessHostOptions = { + persistence, + log: { snapshot: { afterBytes: 1 } }, + lifecycle: { retention: { logRecords: 'since-snapshot' } }, + } + await seed(options, withFiles) + const snapshot = await snapshotOf(persistence) + if (!snapshot) throw new Error('The first host wrote a snapshot.') + await expect( + persistence.stores.log.read({ logId: LOG, after: 0 }), + ).rejects.toThrow() + + const host = createHarnessHost(options) + const session = await host.open(withFiles, { threadId: THREAD }) + const [first] = await session.transcript() + + expect(await session.revert({ messageId: first?.id ?? '' })).toMatchObject({ + status: 'accepted', + }) + expect(steps).toEqual([step]) + await session.close() + await host.close() + }) }) diff --git a/packages/ai-harness/tests/subagent-children.test.ts b/packages/ai-harness/tests/subagent-children.test.ts index bae8b2e45f..cc0b855532 100644 --- a/packages/ai-harness/tests/subagent-children.test.ts +++ b/packages/ai-harness/tests/subagent-children.test.ts @@ -190,14 +190,16 @@ describe('subagent children of a harness session', () => { // A fork copies the agent result, but the child stays a child of `t`. const fork = await host.fork(harness, { threadId: 't', newThreadId: 'f' }) - const transcript = await session.agentTranscript(id) + const transcript = await session.agentTranscript({ subagentRunId: id }) expect( transcript.map((message) => [message.role, message.content]), ).toEqual([ ['user', 'Take notes'], ['assistant', 'Note 1'], ]) - expect(await session.agentTranscript('unknown')).toEqual([]) + expect(await session.agentTranscript({ subagentRunId: 'unknown' })).toEqual( + [], + ) const other = await host.open( defineHarness({ name: 'test/subagent-children', @@ -207,8 +209,8 @@ describe('subagent children of a harness session', () => { threadId: 'u', }, ) - expect(await other.agentTranscript(id)).toEqual([]) - expect(await fork.agentTranscript(id)).toEqual([]) + expect(await other.agentTranscript({ subagentRunId: id })).toEqual([]) + expect(await fork.agentTranscript({ subagentRunId: id })).toEqual([]) await host.close() }) @@ -287,10 +289,9 @@ describe('subagent children of a harness session', () => { const rootTranscript = contents(await rootView.transcript()) expect(rootTranscript).toContain('Notes, please') expect(rootTranscript).not.toContain('Note 1') - expect(contents(await rootView.agentTranscript(id))).toEqual([ - 'Take notes', - 'Note 1', - ]) + expect( + contents(await rootView.agentTranscript({ subagentRunId: id })), + ).toEqual(['Take notes', 'Note 1']) await next.close() }) }) diff --git a/packages/ai-harness/tests/thread-settings.test.ts b/packages/ai-harness/tests/thread-settings.test.ts index 26c9abcad5..7d4d66e0b7 100644 --- a/packages/ai-harness/tests/thread-settings.test.ts +++ b/packages/ai-harness/tests/thread-settings.test.ts @@ -1,15 +1,20 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { EventType, toolDefinition } from '@tanstack/ai' -import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' +import { + memoryLogBackend, + memoryLogStore, + memoryPersistence, +} from '@tanstack/ai-persistence' import { HARNESS_EVENTS, configOption, createHarnessHost, defineHarness, definePlugin, + logIdOf, } from '../src' -import { mockAdapter, text } from './helpers' +import { canOtherHostTake, mockAdapter, text } from './helpers' import type { AnyTool, ModelMessage } from '@tanstack/ai' import type { HarnessConfig, HarnessPersistence } from '../src' import type { Reply } from './helpers' @@ -375,3 +380,48 @@ describe('plugin config next to the settings', () => { await host.close() }) }) + +describe('thread settings on two hosts', () => { + it('keeps the change of the other host, and a turn uses it', async () => { + const backend = memoryLogBackend() + const model = mockAdapter(() => text('ok')) + const harness = defineHarness({ + name: 'test/settings', + adapter: model.adapter, + }) + const hostOn = () => + createHarnessHost({ + persistence: { stores: { log: memoryLogStore({ backend }) } }, + }) + const hostA = hostOn() + const hostB = hostOn() + const a = await hostA.open(harness, { threadId: THREAD }) + const b = await hostB.open(harness, { threadId: THREAD }) + + await a.configure({ settings: { reasoning: 'high' } }) + // A gives the log back, so B owns it for its change. + const store = memoryLogStore({ backend }) + await vi.waitFor( + async () => + expect(await canOtherHostTake(store, logIdOf({ logId: THREAD }))).toBe( + true, + ), + { timeout: 5_000 }, + ) + expect( + await b.configure({ settings: { instructions: 'Answer in French.' } }), + ).toMatchObject({ status: 'accepted' }) + + // Each host reads the change of the other one from the log. + const both = { reasoning: 'high', instructions: 'Answer in French.' } + for (const session of [a, b]) { + await vi.waitFor(() => expect(session.settings()).toEqual(both), { + timeout: 5_000, + }) + } + await b.send('Hello.') + expect(model.calls.map((call) => call.reasoning?.level)).toEqual(['high']) + await hostA.close() + await hostB.close() + }) +}) diff --git a/packages/ai-harness/tests/transfer.test.ts b/packages/ai-harness/tests/transfer.test.ts index 7994265181..8710d8c66d 100644 --- a/packages/ai-harness/tests/transfer.test.ts +++ b/packages/ai-harness/tests/transfer.test.ts @@ -317,12 +317,12 @@ describe('host.transfer to a new thread', () => { const id = parts.map(mediaIdOf).find((value) => value !== undefined) if (!id) throw new Error('The transfer lost the media part.') expect(id).not.toBe(record.id) - expect(await target.getMedia(id)).toMatchObject({ + expect(await target.getMedia({ id })).toMatchObject({ threadId, name: 'cat.png', }) expect(await target.loadMedia({ id })).toEqual(bytes) - expect(await target.getMedia(record.id)).toBeNull() + expect(await target.getMedia({ id: record.id })).toBeNull() // Nothing else moved. expect(target.settings()).toEqual({}) expect(target.config().tone?.value).toBe('plain') diff --git a/packages/ai-harness/tests/turn-control.test.ts b/packages/ai-harness/tests/turn-control.test.ts index 9a14a231a3..66f55eab41 100644 --- a/packages/ai-harness/tests/turn-control.test.ts +++ b/packages/ai-harness/tests/turn-control.test.ts @@ -171,9 +171,11 @@ describe('turn.onModelError', () => { turn: { onModelError: async ({ session: current, error, retries }) => { if (retries > 0 || error.code !== CONTEXT_OVERFLOW_CODE) return - await current.append([ - { type: 'app.compaction', summary: 'summary of the chat' }, - ]) + await current.append({ + records: [ + { type: 'app.compaction', summary: 'summary of the chat' }, + ], + }) return 'retry' }, }, diff --git a/packages/ai-harness/tests/view-client-tools.test.ts b/packages/ai-harness/tests/view-client-tools.test.ts index fab422d77c..e073250b90 100644 --- a/packages/ai-harness/tests/view-client-tools.test.ts +++ b/packages/ai-harness/tests/view-client-tools.test.ts @@ -126,7 +126,7 @@ describe('session view client tools', () => { approval?.approve() // A client tool id is not an approval, so a yes does not answer it. - view.approve(call?.id ?? '') + view.approve({ id: call?.id ?? '' }) expect(resolve).not.toHaveBeenCalled() call?.resolve({ opened: true }) call?.resolve({ opened: false }) diff --git a/packages/ai-harness/tests/view-media.test.ts b/packages/ai-harness/tests/view-media.test.ts index a60825afb7..3264da07fc 100644 --- a/packages/ai-harness/tests/view-media.test.ts +++ b/packages/ai-harness/tests/view-media.test.ts @@ -154,7 +154,7 @@ async function openView(over: Partial = {}) { /** A source whose URLs expire an hour after each call, and the ids it was asked for. */ function expiringUrls() { const asked: Array = [] - const mediaUrl = async (id: string) => { + const mediaUrl = async ({ id }: { id: string }) => { asked.push(id) return { url: `https://media.test/${id}?v=${asked.length}`, diff --git a/packages/ai-harness/tests/view-paths.test.ts b/packages/ai-harness/tests/view-paths.test.ts index 69cfa2b34a..afc67ac12b 100644 --- a/packages/ai-harness/tests/view-paths.test.ts +++ b/packages/ai-harness/tests/view-paths.test.ts @@ -568,10 +568,10 @@ describe('session view approvals', () => { ) const { view, stream, inputs } = await openView({ snapshot }) - view.approve('late') + view.approve({ id: 'late' }) stream.push(runFinished()) await waitFor(() => expect(view.store.get().approvals).toHaveLength(2)) - view.approve('a') + view.approve({ id: 'a' }) // `late` still waits for its own answer. expect(inputs).toEqual([]) @@ -848,11 +848,11 @@ describe('session view inputs', () => { await view.followUp('then the tests') await view.continue() await view.cancel({ operationId: 'op-2' }) - await view.cancelInput('in-1') + await view.cancelInput({ inputId: 'in-1' }) await view.setDelivery({ inputId: 'in-2', delivery: 'steer' }) await view.configure({ settings: { model: 'strong' } }) await view.reset({ note: 'Start over.' }) - await view.revert('m1') + await view.revert({ messageId: 'm1' }) await view.unrevert() await drain() @@ -860,11 +860,11 @@ describe('session view inputs', () => { ['followUp', 'then the tests', { mode: 'followUp' }], ['continue'], ['cancel', { operationId: 'op-2' }], - ['cancelInput', 'in-1'], + ['cancelInput', { inputId: 'in-1' }], ['setDelivery', { inputId: 'in-2', delivery: 'steer' }], ['configure', { settings: { model: 'strong' } }], ['reset', { note: 'Start over.' }], - ['revert', 'm1'], + ['revert', { messageId: 'm1' }], ['unrevert'], ]) expect(view.store.get().messages).toEqual([ @@ -902,14 +902,16 @@ describe('session view inputs', () => { const { view } = await openView({ putMedia }) const bare = await openView() - expect(await view.upload({ body: new Uint8Array([1]), ...info })).toBe(file) + expect(await view.putMedia({ body: new Uint8Array([1]), ...info })).toBe( + file, + ) expect(putMedia).toHaveBeenCalledWith({ body: new Uint8Array([1]), name: 'a.png', mimeType: 'image/png', }) await expect( - bare.view.upload({ body: new Uint8Array([1]), ...info }), + bare.view.putMedia({ body: new Uint8Array([1]), ...info }), ).rejects.toThrow('This view source cannot store media.') }) }) @@ -965,7 +967,7 @@ describe('session view interrupts', () => { snapshot.mockReturnValueOnce(stopped) const { view, inputs } = await openView({ snapshot }) - view.reject('ap-1', { reason: 'wrong file' }) + view.reject({ id: 'ap-1', payload: { reason: 'wrong file' } }) view.store.get().interrupts[0]?.cancel() expect(inputs).toEqual([ @@ -1232,7 +1234,7 @@ describe('session view reload', () => { started('c1', 's1'), started('c2', 's2'), ], - agentTranscript: async (id) => [ + agentTranscript: async ({ subagentRunId: id }) => [ { role: 'assistant', content: id === 's1' ? 'All done.' : '', @@ -1347,7 +1349,7 @@ describe('session view reload', () => { content: JSON.stringify({ subagentRunId: 's1', result: 'Note 1' }), }, ], - agentTranscript: async (id) => { + agentTranscript: async ({ subagentRunId: id }) => { asked.push(id) return [ { role: 'user', content: 'Notes' }, @@ -1487,7 +1489,7 @@ describe('session view dispose', () => { action: string act: (view: SessionView) => unknown }> = [ - { action: 'notice', act: (view) => view.notice('hi') }, + { action: 'notice', act: (view) => view.notice({ text: 'hi' }) }, { action: 'command', act: (view) => view.command({ name: 'ping' }) }, { action: 'setConfig', diff --git a/packages/ai-harness/tests/view.test.ts b/packages/ai-harness/tests/view.test.ts index f915c3737f..826b20bd97 100644 --- a/packages/ai-harness/tests/view.test.ts +++ b/packages/ai-harness/tests/view.test.ts @@ -262,7 +262,7 @@ describe('createSessionView', () => { const second = await open([], persistence) const view = createSessionView(second.session) - view.notice('Welcome back.') + view.notice({ text: 'Welcome back.' }) await view.ready expect(view.store.get().messages).toEqual([ @@ -303,7 +303,7 @@ describe('createSessionView', () => { expect(view.store.get()).toEqual({ ...before, connection: 'closed' }) expect(snapshot).not.toHaveBeenCalled() await expect(view.send('hi')).rejects.toThrow('disposed') - expect(() => view.approve('x')).toThrow('disposed') + expect(() => view.approve({ id: 'x' })).toThrow('disposed') await host.close() }) @@ -489,7 +489,7 @@ describe('the agents of a session view', () => { expect(view.store.get().agents).toMatchObject([ { id: run.id, status: 'waiting' }, ]) - view.approve(wait.waitId) + view.approve({ id: wait.waitId }) await expect(run).resolves.toBe('Done.') expect(removed).toEqual(['old.log']) await waitFor(() => expect(view.store.get().waits).toEqual([])) diff --git a/packages/ai-harness/tests/workspace-tools.test.ts b/packages/ai-harness/tests/workspace-tools.test.ts index 8c361781bf..74ec2f69fd 100644 --- a/packages/ai-harness/tests/workspace-tools.test.ts +++ b/packages/ai-harness/tests/workspace-tools.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os' import * as nodePath from 'node:path' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { memoryPersistence } from '@tanstack/ai-persistence' -import { createHarnessHost, defineHarness, definePlugin } from '../src' +import { + createHarnessHost, + defineHarness, + definePlugin, + harnessAgent, +} from '../src' import { permissions } from '../src/first-party/permissions' import { hostBackend } from '../src/first-party/coding/backend' import { @@ -1236,7 +1241,7 @@ describe('a workspace for each thread', () => { ) } - it('closes the workspace that bind gave when the session closes', async () => { + it('closes the workspace that bind gave as moved when the session closes, so the thread can reattach it', async () => { const { workspace } = threadWorkspaces() const closed: Array = [] const closing: WorkspaceBinder = { @@ -1258,7 +1263,153 @@ describe('a workspace for each thread', () => { await host.close() - expect(closed).toEqual(['closed']) + // A session close does not end the thread: a sandbox stays. + expect(closed).toEqual(['moved']) + }) + + it('destroys the sandbox of a thread that host.sessions.delete purges, also when no session has the thread open', async () => { + const { workspace } = threadWorkspaces() + const closed: Array = [] + const reattached: Array = [] + const sandbox = async (input: Parameters[0]) => ({ + ...(await workspace.bind(input)), + binding: () => ({ + kind: 'sandbox' as const, + provider: 'test', + sandboxId: `sb-${input.run.threadId}`, + }), + close: async ({ reason }: { reason: 'closed' | 'moved' }) => { + closed.push(reason) + }, + }) + const owned: WorkspaceBinder = { + bind: sandbox, + reattach: async ({ run, binding }) => { + reattached.push(binding.kind === 'sandbox' ? binding.sandboxId : '') + return sandbox({ run }) + }, + } + const { adapter } = mockAdapter([ + () => toolCall('read_file', { path: 'mine.txt' }, 'c0'), + () => text('done'), + ]) + const host = createHarnessHost({ + persistence: memoryPersistence(), + lifecycle: { idleCloseMs: 0 }, + }) + const session = await openThread(host, owned, adapter, 'a') + await session.send('read') + await session.close() + await waitFor(() => expect(closed).toEqual(['moved'])) + + await host.sessions.delete({ threadId: 'a' }) + + // The purge reattached the sandbox from the log, then destroyed it. + expect(reattached).toEqual(['sb-a']) + expect(closed).toEqual(['moved', 'closed']) + await host.close() + }) + + it('destroys the sandbox on a host that never opened the harness, when its harnesses option has it, and warns when not', async () => { + const { workspace } = threadWorkspaces() + const closed: Array = [] + const sandbox = async (input: Parameters[0]) => ({ + ...(await workspace.bind(input)), + binding: () => ({ + kind: 'sandbox' as const, + provider: 'test', + sandboxId: `sb-${input.run.threadId}`, + }), + close: async ({ reason }: { reason: 'closed' | 'moved' }) => { + closed.push(`${input.run.threadId}:${reason}`) + }, + }) + const owned: WorkspaceBinder = { + bind: sandbox, + reattach: async ({ run }) => sandbox({ run }), + } + const { adapter } = mockAdapter([ + () => toolCall('read_file', { path: 'mine.txt' }, 'c0'), + () => text('done'), + () => toolCall('read_file', { path: 'mine.txt' }, 'c1'), + () => text('done'), + ]) + const harness = defineHarness({ + name: 'test/workspace-delete', + adapter, + plugins: () => [workspaceTools({ workspace: owned, web: false })], + }) + const persistence = memoryPersistence() + const first = createHarnessHost({ persistence }) + for (const threadId of ['a', 'b']) { + const session = await first.open(harness, { threadId }) + await session.send('read') + } + await first.close() + closed.length = 0 + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + // A new host, after a deploy: it never opened the harness. + const knows = createHarnessHost({ persistence, harnesses: [harness] }) + await knows.sessions.delete({ threadId: 'a' }) + const blind = createHarnessHost({ persistence }) + await blind.sessions.delete({ threadId: 'b' }) + + expect(closed).toEqual(['a:closed']) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('does not know the harness'), + ) + warn.mockRestore() + await knows.close() + await blind.close() + }) + + it('destroys the sandbox of a harness child thread when host.sessions.delete purges its root', async () => { + const { workspace } = threadWorkspaces() + const closed: Array = [] + const sandbox = async (input: Parameters[0]) => ({ + ...(await workspace.bind(input)), + binding: () => ({ + kind: 'sandbox' as const, + provider: 'test', + sandboxId: `sb-${input.run.threadId}`, + }), + close: async ({ reason }: { reason: 'closed' | 'moved' }) => { + closed.push(reason) + }, + }) + const child = defineHarness({ + name: 'test/workspace-child', + adapter: mockAdapter([ + () => toolCall('read_file', { path: 'mine.txt' }, 'c0'), + () => text('Child done.'), + ]).adapter, + plugins: () => [ + workspaceTools({ + workspace: { bind: sandbox, reattach: ({ run }) => sandbox({ run }) }, + web: false, + }), + ], + }) + const agent = harnessAgent(child) + const lead = defineHarness({ + name: 'test/workspace-lead', + adapter: mockAdapter([ + () => toolCall(agent.name, { prompt: 'Read it.' }), + () => text('Done.'), + ]).adapter, + subagents: { agents: [agent] }, + }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open(lead, { threadId: 't' }) + await session.send('Go.') + await waitFor(() => expect(closed).toEqual(['moved'])) + + await host.sessions.delete({ threadId: 't' }) + + // The child lives in the log of its root: the purge reads it there. + expect(closed).toEqual(['moved', 'closed']) + await host.close() }) it('keeps a thread in its root: no path through .. or a link reaches another root', async () => { diff --git a/packages/ai-mcp/src/connector.ts b/packages/ai-mcp/src/connector.ts index 52570614dc..de920c569c 100644 --- a/packages/ai-mcp/src/connector.ts +++ b/packages/ai-mcp/src/connector.ts @@ -227,6 +227,10 @@ interface Connection { connected: boolean client?: MCPClient tools?: ReadonlyArray + /** The server sent `tools/list_changed`: the next turn lists again. */ + stale?: boolean + /** Wraps a tool list of `client` for the session. */ + wrap?: (tools: ReadonlyArray) => ReadonlyArray } /** @@ -240,6 +244,8 @@ interface Connection { * - After sign-in, the next turn has the server's tools, named * `_` by default. `toolName` can change the names. * Tools that can change data ask for approval. + * - When the connection closes, the next turn connects again. When the + * server sends `tools/list_changed`, the next turn lists its tools again. * * @example * ```ts @@ -313,8 +319,10 @@ export function mcpConnector(options: McpConnectorOptions) { : undefined) ?? 'http://127.0.0.1/callback' // The tool calls of this client that run now, newest last. const calling: Array = [] + const current = connection + let opened: MCPClient | undefined try { - connection.client = await connectTransport( + opened = await connectTransport( { transport: { type: 'http', @@ -333,9 +341,20 @@ export function mcpConnector(options: McpConnectorOptions) { options.needsApproval ?? ((tool) => tool.annotations?.readOnlyHint !== true), clientOptions: harnessClientOptions, + // A dropped connection: the next turn connects again. + onClose: () => { + if (current.client !== opened) return + current.client = undefined + current.tools = undefined + }, + onToolListChanged: () => { + if (current.client === opened) current.stale = true + }, }, elicitFor(calling), ) + connection.client = opened + connection.stale = false } catch (error) { // The server wants a new sign-in: ask the user to run /connect. // Other failures, for example a server that is down, stay @@ -348,7 +367,7 @@ export function mcpConnector(options: McpConnectorOptions) { ctx.session.authRequired({ connector: id }) throw error.cause } - const client = connection.client + const client = opened // A failed tool list closes the client: the next turn connects // again, and must not leave this one open. const listed = await client @@ -358,14 +377,22 @@ export function mcpConnector(options: McpConnectorOptions) { await client.close().catch(() => {}) throw error }) - connection.tools = askForInput( - trackCaller( + const wrap = (found: ReadonlyArray) => + askForInput(trackCaller(found, ctx.session, calling), ctx.session) + connection.wrap = wrap + connection.tools = wrap(listed as ReadonlyArray) + } else if (connection.stale && connection.client) { + // After `tools/list_changed`: list the tools again. On a + // failure, the old list stays, and the next turn tries again. + connection.stale = false + const client = connection.client + const listed = await client.tools().catch(() => undefined) + if (listed === undefined) connection.stale = true + else if (connection.client === client && connection.wrap) { + connection.tools = connection.wrap( listed as ReadonlyArray, - ctx.session, - calling, - ), - ctx.session, - ) + ) + } } return connection.tools }, diff --git a/packages/ai-mcp/src/harness-plugin.ts b/packages/ai-mcp/src/harness-plugin.ts index 7db14784fc..b514da23cb 100644 --- a/packages/ai-mcp/src/harness-plugin.ts +++ b/packages/ai-mcp/src/harness-plugin.ts @@ -88,7 +88,12 @@ export type McpServerStatus = { * - When the connection of a server closes, the server is `reconnecting`. * The plugin connects again after 0.5 s, 1 s, 2 s, and so on (at most * 30 s), until it is connected or its session or host closes. The next - * turn gets the tools of the new connection. + * turn gets the tools of the new connection. A server that fails its + * first connect is `failed`, and connects again the same way. + * - A spec 2025 server that asks for input (`elicitation/create`) does not + * name the tool call that asks. The user of the call is asked only when + * all the calls of that server that run now belong to one run. Else the + * request is declined, so a question never goes to another user. * - When a server sends `tools/list_changed`, the next turn lists its tools * again. * - When two servers give a tool with the same name, the model gets the @@ -123,9 +128,8 @@ export function mcp(options: { setup: (host) => { // The tool calls of a shared server that run now, newest last. const calling: Array = [] - // ponytail: a spec 2025 request of a shared server goes to the newest - // running call. Two sessions that call one stdio server at the same - // time can get the question of the other one. + // A spec 2025 request of a shared server goes to the run that calls, + // only when one run calls. Else it is declined (see `elicitFor`). const askCaller = elicitFor(calling) const shared = new Map() for (const [name, server] of servers) { @@ -281,11 +285,13 @@ async function sessionPart( type SharedServer = ReturnType /** - * Connect one server in `resources`. Resolves to the error of a server that - * fails its first connect, so it never rejects. + * Connect one server in `resources`. It never rejects: an error that stops + * the setup itself resolves to that error. * - * When the connection closes later, the status is `reconnecting` and the - * server connects again with backoff, until `resources` is disposed. After + * When the first connect fails, the status is `failed` with the error, and + * the server connects again with backoff. When the connection closes later, + * the status is `reconnecting`, with the same backoff. Both go on until a + * connection is open or `resources` is disposed. After * `tools/list_changed`, the next `tools()` lists the tools again. */ function openServer( @@ -412,7 +418,20 @@ function openServer( return resources .acquire( async () => { - await open() + try { + await open() + } catch (error) { + if (signal.aborted) throw error + // A server that is not up yet (a slow `npx`, a short outage) gets + // its tools at a later turn. + setStatus({ + name, + status: 'failed', + error: errorText(error), + toolCount: 0, + }) + void reconnect() + } return link }, () => client?.close(), @@ -487,7 +506,7 @@ export interface Caller { /** * Puts each call of `tools` in `calling` while it runs, with its session - * and its run, so a spec 2025 elicitation goes to the run that calls. + * and its run, so a spec 2025 elicitation can find the run that calls. */ export function trackCaller( tools: ReadonlyArray, @@ -554,14 +573,24 @@ export function askForInput( } /** - * Answers the `elicitation/create` requests of a spec 2025 server: it asks - * for the newest call of `calling`, as a live wait of its run. With no - * call, it declines: only a tool call can wait for the user. + * Answers the `elicitation/create` requests of a spec 2025 server. The + * request does not name the tool call that it belongs to. So it asks only + * when the calls of `calling` all belong to one run of one session, as a + * live wait of that run. With no call, or with calls of more than one run + * (two users or tenants can share a server), it declines: a question must + * never go to the user of another run. */ export function elicitFor(calling: ReadonlyArray): OnElicit { return async (params) => { - const caller = calling.at(-1) + const [caller] = calling if (!caller) return { action: 'decline' } + const isOneRun = calling.every( + (other) => + other.session === caller.session && + other.run !== undefined && + other.run.runId === caller.run?.runId, + ) + if (!isOneRun) return { action: 'decline' } return elicitResult(await ask(caller.session, params, caller.run)) } } diff --git a/packages/ai-mcp/src/harness.ts b/packages/ai-mcp/src/harness.ts index 4b922908c1..4f9f8b9419 100644 --- a/packages/ai-mcp/src/harness.ts +++ b/packages/ai-mcp/src/harness.ts @@ -265,6 +265,18 @@ export async function createHarnessMcpServer

( // question, or a wait of a background agent run). `answer` or `resolve` // continues it. const waiting = new Map>() + /** + * Keep `operation` for its wait `waitId`. The entry goes when the + * operation settles, also when the wait ended another way: answered by + * another client, expired, or cancelled. + */ + const keepWaiting = (waitId: string, operation: Operation) => { + waiting.set(waitId, operation) + const forget = () => { + if (waiting.get(waitId) === operation) waiting.delete(waitId) + } + void operation.then(forget, forget) + } // The user of each request, by the token in its context. A token that // this server did not make is in no map, so a context cannot name a user. const callers = new WeakMap() @@ -379,7 +391,7 @@ export async function createHarnessMcpServer

( if (!outcome.done) { const snapshot = session.snapshot() for (const wait of snapshot.pendingWaits ?? []) { - if (wait.runId === operation.id) waiting.set(wait.waitId, operation) + if (wait.runId === operation.id) keepWaiting(wait.waitId, operation) } return { status: 'waiting', ...pending(snapshot, operation.id) } } @@ -576,6 +588,9 @@ export async function createHarnessMcpServer

( }).server( onSession(async (session, args, context) => { const questionId = textArg(args, 'questionId') + // Read before the answer: the work can settle at once after it, and + // then its entry is gone. + const operation = waiting.get(questionId) const receipt = await session.answer({ questionId, value: isRecord(args) ? args.value : undefined, @@ -585,7 +600,6 @@ export async function createHarnessMcpServer

( `The harness refused the answer: ${receipt.reason ?? 'no reason'}.`, ) } - const operation = waiting.get(questionId) waiting.delete(questionId) // A question that no tool call waits on: nothing to continue. if (operation === undefined) { @@ -766,7 +780,7 @@ export async function createHarnessMcpServer

( try { // A read: no session opens. const view = await observe(threadId, ctx.context) - const record = await view.getMedia(id) + const record = await view.getMedia({ id }) if (record === null) { throw new Error(`Media ${id} was not found in thread ${threadId}.`) } diff --git a/packages/ai-mcp/tests/connector-reconnect.test.ts b/packages/ai-mcp/tests/connector-reconnect.test.ts new file mode 100644 index 0000000000..8f0f2ee197 --- /dev/null +++ b/packages/ai-mcp/tests/connector-reconnect.test.ts @@ -0,0 +1,97 @@ +import { memoryCredentialStore } from '@tanstack/ai/byok/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { mcpConnector } from '../src/connector' +import { recorder } from './connector-helpers' +import type { AnyTool } from '@tanstack/ai' +import type { MCPClientOptions } from '../src/types' + +// Each connect of the connector gets a fake client. The test keeps the +// options of each connect, so it can close a connection or change its +// tool list like a server does. +const connects = vi.hoisted(() => ({ + options: [] as Array, + tools: [] as Array>, +})) +vi.mock('../src/client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + connectTransport: async (options: unknown) => { + const index = connects.options.length + connects.options.push(options) + connects.tools.push(['echo']) + return { + tools: async () => + (connects.tools[index] ?? []).map( + (name): AnyTool => ({ + name: `demo_${name}`, + description: name, + execute: async () => name, + }), + ), + close: async () => {}, + } + }, + } +}) + +const cleanups: Array<() => unknown> = [] +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup() + connects.options.length = 0 + connects.tools.length = 0 +}) + +/** The callbacks that connect `index` gave the client. */ +function eventsOf(index: number) { + const options = connects.options[index] as Pick< + MCPClientOptions, + 'onClose' | 'onToolListChanged' + > + return options +} + +describe('mcpConnector connection events', () => { + it('lists the tools again after tools/list_changed, and connects again after a close', async () => { + const credentials = memoryCredentialStore() + await credentials.set({ + scope: { threadId: 't', userId: 'user-1' }, + id: 'demo', + credential: { type: 'oauth', accessToken: 'access-1' }, + }) + const host = createHarnessHost({ + persistence: memoryPersistence(), + credentials, + }) + cleanups.push(() => host.close()) + const model = recorder() + const session = await host.open( + defineHarness({ + name: 'test/connector-events', + adapter: model.adapter, + plugins: () => [ + mcpConnector({ id: 'demo', label: 'Demo', url: 'http://mcp.test' }), + ], + }), + { threadId: 't', principal: { id: 'user-1' } }, + ) + + await session.send('one') + expect(model.toolNames(0)).toEqual(['demo_echo']) + + // The server adds a tool and says so. + connects.tools[0] = ['echo', 'time'] + eventsOf(0).onToolListChanged?.() + await session.send('two') + expect(model.toolNames(1)).toEqual(['demo_echo', 'demo_time']) + expect(connects.options).toHaveLength(1) + + // The connection drops: the next turn connects again. + eventsOf(0).onClose?.() + await session.send('three') + expect(connects.options).toHaveLength(2) + expect(model.toolNames(2)).toEqual(['demo_echo']) + }) +}) diff --git a/packages/ai-mcp/tests/harness-elicit.test.ts b/packages/ai-mcp/tests/harness-elicit.test.ts index 140ed4439b..23f1f3a7b2 100644 --- a/packages/ai-mcp/tests/harness-elicit.test.ts +++ b/packages/ai-mcp/tests/harness-elicit.test.ts @@ -56,7 +56,7 @@ describe('an MCP elicitation is a live wait of the run that calls', () => { }) }) - it('asks a spec 2025 elicitation as a wait of the newest running call', async () => { + it('asks a spec 2025 elicitation as a wait of the run of the running call', async () => { const { session, ask } = askingSession({ city: 'Paris' }) const calling: Parameters[2] = [] const onElicit = elicitFor(calling) @@ -90,6 +90,36 @@ describe('an MCP elicitation is a live wait of the run that calls', () => { expect(ask).not.toHaveBeenCalled() }) + it('declines a spec 2025 elicitation while calls of two runs share the server, and asks nobody', async () => { + const first = askingSession({ city: 'Paris' }) + const second = askingSession({ city: 'Rome' }) + const calling: Parameters[2] = [ + { session: first.session, run: runOf('op-user-a') }, + { session: second.session, run: runOf('op-user-b') }, + ] + + await expect(elicitFor(calling)(form as never)).resolves.toEqual({ + action: 'decline', + }) + expect(first.ask).not.toHaveBeenCalled() + expect(second.ask).not.toHaveBeenCalled() + }) + + it('asks a spec 2025 elicitation when every running call is of one run', async () => { + const { session, ask } = askingSession({ city: 'Paris' }) + const run = runOf('op-chat-1') + const calling: Parameters[2] = [ + { session, run }, + { session, run }, + ] + + await expect(elicitFor(calling)(form as never)).resolves.toEqual({ + action: 'accept', + content: { city: 'Paris' }, + }) + expect(ask).toHaveBeenCalledTimes(1) + }) + it('declines a spec 2025 elicitation that comes when no call runs', async () => { const onElicit = elicitFor([]) await expect(onElicit(form as never)).resolves.toEqual({ diff --git a/packages/ai-mcp/tests/harness-reconnect.test.ts b/packages/ai-mcp/tests/harness-reconnect.test.ts index d16cf8d062..a3063ee055 100644 --- a/packages/ai-mcp/tests/harness-reconnect.test.ts +++ b/packages/ai-mcp/tests/harness-reconnect.test.ts @@ -161,6 +161,24 @@ describe('mcp reconnect', () => { ) }) + it('connects again after a failed first connect, and gives its tools at a later turn', async () => { + refuse = 1 + const { sessions, model } = await openSessions(1) + const [session] = sessions + if (!session) throw new Error('a session expected') + expect(await statusOf(session)).toMatch(/^local: failed: /) + + // The first wait is 0.5 s at most. + await vi.waitFor( + async () => + expect(await statusOf(session)).toBe('local: connected (1 tool)'), + { timeout: 5_000 }, + ) + expect(children).toHaveLength(2) + await session.send('which server?') + expect(lastSeen(model)).toContain('child:2') + }) + it('stops trying when the host closes', async () => { const { host, sessions } = await openSessions(1) await sessions[0]?.send('hi') diff --git a/packages/ai-mcp/tests/harness.test.ts b/packages/ai-mcp/tests/harness.test.ts index 81bcd7862d..bb3ed89a0b 100644 --- a/packages/ai-mcp/tests/harness.test.ts +++ b/packages/ai-mcp/tests/harness.test.ts @@ -1318,6 +1318,36 @@ describe('createHarnessMcpServer', () => { expect(answered.content).toEqual([{ type: 'text', text: 'Confirmed.' }]) }) + it('forgets a waiting question when another client answers it', async () => { + // The server keeps the waiting work in a Map that it does not export. + // Find it: the Map that gets the question id with an operation. + const sets = vi.spyOn(Map.prototype, 'set') + const { server, session } = await harnessServer([]) + const { client } = await connect(server) + const waiting = await client.callTool({ + name: 'command_confirm', + arguments: {}, + }) + const [question] = pendingShape.parse(waiting.structuredContent).questions + if (question === undefined) throw new Error('The command asked nothing') + const index = sets.mock.calls.findIndex( + ([key, value]) => + key === question.id && + typeof value === 'object' && + value !== null && + 'stream' in value, + ) + const map: unknown = sets.mock.contexts[index] + sets.mockRestore() + if (!(map instanceof Map)) throw new Error('No waiting map found') + expect(map.has(question.id)).toBe(true) + + // Another client answers, so the MCP `answer` tool never runs. + const live = await session() + await live.answer({ questionId: question.id, value: true }) + await waitFor(() => expect(map.has(question.id)).toBe(false)) + }) + it('returns the result of its own turn when another operation asks a question', async () => { const held = heldTurn(textTurn('Done.')) const { server, session } = await harnessServer([held]) diff --git a/testing/e2e/src/routes/api.coding-tools.ts b/testing/e2e/src/routes/api.coding-tools.ts index 4e5b59db06..c81c9b24bc 100644 --- a/testing/e2e/src/routes/api.coding-tools.ts +++ b/testing/e2e/src/routes/api.coding-tools.ts @@ -103,7 +103,7 @@ async function* revertTurns( await writeFile(join(root, 'other.txt'), 'mine\n') yield* session.send('[coding-revert-2] run test').stream() const [prompt] = await session.transcript() - if (prompt?.id) await session.revert(prompt.id) + if (prompt?.id) await session.revert({ messageId: prompt.id }) } finally { await host.close() await rm(dataDir, { recursive: true, force: true }) From f634e4fce777c37b6514ba994711cc7bc6b61a95 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 19 Oct 2026 11:12:13 +0200 Subject: [PATCH 10/15] refactor(ai-harness): mounted plugins keep each contribution list with its owner, not in parallel owner arrays --- packages/ai-harness/src/plugins.ts | 233 +++++++++++++---------------- 1 file changed, 107 insertions(+), 126 deletions(-) diff --git a/packages/ai-harness/src/plugins.ts b/packages/ai-harness/src/plugins.ts index 9ff0b80f55..c5c297664d 100644 --- a/packages/ai-harness/src/plugins.ts +++ b/packages/ai-harness/src/plugins.ts @@ -564,10 +564,29 @@ interface Owned { owner: string } -/** Agents, with the owner of each agent at the same index. */ -interface AgentList { - items: Array - owners: Array +/** The contributions that `withoutOwners` filters, each with its owner. */ +interface OwnedLists { + /** The `'system'` and the `'context'` prompts. */ + prompts: Array> + middleware: Array> + agentMiddleware: Array> + generationMiddleware: Array> + adapters: Array>> + /** Root agents. `ctx.agents.set` and `delete` change them after setup. */ + agents: Array> + subagents: Array> +} + +/** The values of `list`, without the owners. */ +const valuesIn = (list: ReadonlyArray>) => + list.map((entry) => entry.value) + +/** The `'system'` prompts of `prompts`, then the `'context'` prompts. */ +function splitPrompts(prompts: ReadonlyArray>) { + return { + system: prompts.filter((entry) => entry.value.placement !== 'context'), + context: prompts.filter((entry) => entry.value.placement === 'context'), + } } /** Everything a set of mounted plugins contributes, plus its cleanup. */ @@ -606,23 +625,8 @@ export interface MountedPlugins { provides: Array }> tools: Array<{ name: string; owner: string }> - /** - * The owner of each prompt. The items of `prompts` come first, at the - * same index, then the items of `contextPrompts`. - */ + /** The owner of each `'system'` prompt, then of each `'context'` prompt. */ prompts: Array<{ id: string; owner: string }> - /** The owner of each item of `middleware`, at the same index. */ - middleware: Array - /** The owner of each item of `adapters`, at the same index. */ - adapters: Array - /** The owner of each item of `agentMiddleware`, at the same index. */ - agentMiddleware: Array - /** The owner of each item of `generationMiddleware`, at the same index. */ - generationMiddleware: Array - /** The owner of each item of `agents`, at the same index. */ - agents: Array - /** The owner of each item of `subagents`, at the same index. */ - subagents: Array /** The plugin that provided each capability of `values`. */ capabilities: Array<{ name: string; owner: string }> } @@ -964,6 +968,7 @@ interface MountRecord { plugins: ReadonlyArray /** The plugin that provided each capability. */ capabilityOwners: ReadonlyMap + owned: OwnedLists } /** `mounted` without what the plugins in `names` contributed. */ @@ -975,9 +980,7 @@ function withoutOwnersOf( const removed = new Set(names) const isKept = (owner: string | undefined) => owner === undefined || !removed.has(owner) - /** The items of `items` whose owner, at the same index, is kept. */ - const keep = (items: ReadonlyArray, owners: ReadonlyArray) => - items.filter((_item, index) => isKept(owners[index])) + const isKeptEntry = (entry: { owner: string }) => isKept(entry.owner) const plugins = record.plugins.filter((plugin) => isKept(plugin.name)) for (const plugin of plugins) { for (const handle of plugin.requires ?? []) { @@ -995,73 +998,65 @@ function withoutOwnersOf( const values = mounted.values.without(removedHandles) const { owners } = mounted const keptTools = new Set( - owners.tools - .filter((entry) => isKept(entry.owner)) - .map((entry) => entry.name), + owners.tools.filter(isKeptEntry).map((entry) => entry.name), ) const extensions = new Map>() for (const [point, items] of mounted.extensions) { - extensions.set( - point, - items.filter((item) => isKept(item.owner)), - ) + extensions.set(point, items.filter(isKeptEntry)) + } + const { owned } = record + const kept: OwnedLists = { + prompts: owned.prompts.filter(isKeptEntry), + middleware: owned.middleware.filter(isKeptEntry), + agentMiddleware: owned.agentMiddleware.filter(isKeptEntry), + generationMiddleware: owned.generationMiddleware.filter(isKeptEntry), + adapters: owned.adapters.filter(isKeptEntry), + // Agents are read at each use: a kept plugin can change them later + // (`ctx.agents.set`), and this thread must see that. + get agents() { + return owned.agents.filter(isKeptEntry) + }, + get subagents() { + return owned.subagents.filter(isKeptEntry) + }, } const next: MountedPlugins = { tools: mounted.tools.filter((tool) => keptTools.has(tool.name)), - // `owners.prompts` has the owners of `prompts` first, at the same index. - prompts: keep( - mounted.prompts, - owners.prompts.map((entry) => entry.owner), - ), - contextPrompts: mounted.contextPrompts.filter((item) => isKept(item.owner)), - middleware: keep(mounted.middleware, owners.middleware), - agentMiddleware: keep(mounted.agentMiddleware, owners.agentMiddleware), - generationMiddleware: keep( - mounted.generationMiddleware, - owners.generationMiddleware, - ), - // Commands and agents are read at each use: a kept plugin can change - // them later (`ctx.commands.set`, `ctx.agents.set`), and this thread - // must see that. + prompts: splitPrompts(kept.prompts).system.map((entry) => entry.value.text), + contextPrompts: mounted.contextPrompts.filter(isKeptEntry), + middleware: valuesIn(kept.middleware), + agentMiddleware: valuesIn(kept.agentMiddleware), + generationMiddleware: valuesIn(kept.generationMiddleware), + // Commands are read at each use, as agents are (`ctx.commands.set`). get commands() { return new Map( - [...mounted.commands].filter(([, entry]) => isKept(entry.owner)), + [...mounted.commands].filter(([, entry]) => isKeptEntry(entry)), ) }, config: new Map( - [...mounted.config].filter(([, entry]) => isKept(entry.owner)), + [...mounted.config].filter(([, entry]) => isKeptEntry(entry)), ), extensions, - adapters: keep(mounted.adapters, owners.adapters), - discoverers: mounted.discoverers.filter((item) => isKept(item.owner)), - preparers: mounted.preparers.filter((item) => isKept(item.owner)), + adapters: valuesIn(kept.adapters), + discoverers: mounted.discoverers.filter(isKeptEntry), + preparers: mounted.preparers.filter(isKeptEntry), get agents() { - return keep(mounted.agents, owners.agents) + return valuesIn(kept.agents) }, get subagents() { - return keep(mounted.subagents, owners.subagents) + return valuesIn(kept.subagents) }, owners: { plugins: owners.plugins.filter((plugin) => isKept(plugin.name)), - tools: owners.tools.filter((entry) => isKept(entry.owner)), - prompts: owners.prompts.filter((entry) => isKept(entry.owner)), - middleware: owners.middleware.filter(isKept), - adapters: owners.adapters.filter(isKept), - agentMiddleware: owners.agentMiddleware.filter(isKept), - generationMiddleware: owners.generationMiddleware.filter(isKept), - get agents() { - return owners.agents.filter(isKept) - }, - get subagents() { - return owners.subagents.filter(isKept) - }, - capabilities: owners.capabilities.filter((entry) => isKept(entry.owner)), + tools: owners.tools.filter(isKeptEntry), + prompts: owners.prompts.filter(isKeptEntry), + capabilities: owners.capabilities.filter(isKeptEntry), }, capabilityBridge: capabilityBridgeOf(values), values, dispose: mounted.dispose, withoutOwners: (more) => - withoutOwnersOf(next, { ...record, plugins }, more), + withoutOwnersOf(next, { ...record, plugins, owned: kept }, more), } return next } @@ -1159,14 +1154,16 @@ export async function mountPlugins( value: tool, owner: 'the harness', })) - const prompts: Array> = [] - const middleware: Array = [] - const middlewareOwners: Array = [] - const adapterOwners: Array = [] - const agentMiddleware: Array = [] - const agentMiddlewareOwners: Array = [] - const generationMiddleware: Array = [] - const generationMiddlewareOwners: Array = [] + const owned: OwnedLists = { + prompts: [], + middleware: [], + agentMiddleware: [], + generationMiddleware: [], + adapters: [], + agents: [], + subagents: [], + } + const { prompts, agents: roots, subagents: subs } = owned const capabilityOwners = new Map() const commands = new Map() const config = new Map() @@ -1174,18 +1171,14 @@ export async function mountPlugins( for (const [point, items] of env.inheritedExtensions ?? []) { extensions.set(point, [...items]) } - const adapters: MountedPlugins['adapters'] = [] const discoverers: MountedPlugins['discoverers'] = [] const preparers: MountedPlugins['preparers'] = [] - // Each list keeps the owner of each agent at the same index. - const roots: AgentList = { items: [], owners: [] } - const subs: AgentList = { items: [], owners: [] } const services = env.services ?? NO_SERVICES - const remove = (name: string, list: AgentList) => { - const at = list.items.findIndex((item) => item.name === name) - if (at < 0) return - list.items.splice(at, 1) - list.owners.splice(at, 1) + const indexIn = (name: string, list: Array>) => + list.findIndex((entry) => entry.value.name === name) + const remove = (name: string, list: Array>) => { + const at = indexIn(name, list) + if (at >= 0) list.splice(at, 1) } // `ctx.commands.ready`: resolves after the last plugin is set up. A failed // mount leaves it pending, so no plugin acts on a session that never opens. @@ -1272,10 +1265,11 @@ export async function mountPlugins( env.registry.set(agent, plugin.name) const list = options?.subagent ? subs : roots remove(agent.name, options?.subagent ? roots : subs) - const at = list.items.findIndex((item) => item.name === agent.name) - const index = at < 0 ? list.items.length : at - list.items.splice(index, 1, agent) - list.owners.splice(index, 1, plugin.name) + const at = indexIn(agent.name, list) + list.splice(at < 0 ? list.length : at, 1, { + value: agent, + owner: plugin.name, + }) }, delete: (name) => { env.registry.delete(name, plugin.name) @@ -1403,18 +1397,14 @@ export async function mountPlugins( } prompts.push({ value: section, owner: plugin.name }) }) - for (const item of contributions.middleware ?? []) { - middleware.push(item) - middlewareOwners.push(plugin.name) - } - for (const item of contributions.agentMiddleware ?? []) { - agentMiddleware.push(item) - agentMiddlewareOwners.push(plugin.name) - } - for (const item of contributions.generationMiddleware ?? []) { - generationMiddleware.push(item) - generationMiddlewareOwners.push(plugin.name) - } + const own = (value: T) => ({ value, owner: plugin.name }) + owned.middleware.push(...(contributions.middleware ?? []).map(own)) + owned.agentMiddleware.push( + ...(contributions.agentMiddleware ?? []).map(own), + ) + owned.generationMiddleware.push( + ...(contributions.generationMiddleware ?? []).map(own), + ) // The session reads commands and config only from its session plugins. const isSessionOnly = Object.keys(contributions.commands ?? {}).length > 0 || @@ -1450,10 +1440,8 @@ export async function mountPlugins( } items.push({ value: item.value, owner: plugin.name }) } - if (contributions.adapter) { - adapters.push(contributions.adapter) - adapterOwners.push(plugin.name) - } + if (contributions.adapter) + owned.adapters.push(own(contributions.adapter)) if (contributions.discoverTools) { discoverers.push({ discover: contributions.discoverTools, @@ -1468,13 +1456,11 @@ export async function mountPlugins( } for (const agent of contributions.agents ?? []) { env.registry.add(agent, plugin.name) - roots.items.push(agent) - roots.owners.push(plugin.name) + roots.push(own(agent)) } for (const agent of contributions.subagents ?? []) { env.registry.add(agent, plugin.name) - subs.items.push(agent) - subs.owners.push(plugin.name) + subs.push(own(agent)) } } } @@ -1503,13 +1489,9 @@ export async function mountPlugins( settled = true markReady() let disposed: Promise | undefined - const record: MountRecord = { plugins, capabilityOwners } - const systemPrompts = prompts.filter( - (entry) => entry.value.placement !== 'context', - ) - const contextPrompts = prompts.filter( - (entry) => entry.value.placement === 'context', - ) + const record: MountRecord = { plugins, capabilityOwners, owned } + const { system: systemPrompts, context: contextPrompts } = + splitPrompts(prompts) const mounted: MountedPlugins = { tools: tools .filter((entry) => entry.owner !== 'the harness') @@ -1519,17 +1501,22 @@ export async function mountPlugins( text: entry.value.text, owner: entry.owner, })), - middleware, - agentMiddleware, - generationMiddleware, + middleware: valuesIn(owned.middleware), + agentMiddleware: valuesIn(owned.agentMiddleware), + generationMiddleware: valuesIn(owned.generationMiddleware), commands, config, extensions, - adapters, + adapters: valuesIn(owned.adapters), discoverers, preparers, - agents: roots.items, - subagents: subs.items, + // Read at each use: `ctx.agents.set` and `delete` change them later. + get agents() { + return valuesIn(roots) + }, + get subagents() { + return valuesIn(subs) + }, owners: { plugins: plugins.map((plugin) => ({ name: plugin.name, @@ -1549,12 +1536,6 @@ export async function mountPlugins( id: entry.value.id, owner: entry.owner, })), - middleware: middlewareOwners, - adapters: adapterOwners, - agentMiddleware: agentMiddlewareOwners, - generationMiddleware: generationMiddlewareOwners, - agents: roots.owners, - subagents: subs.owners, capabilities: [...capabilityOwners].map(([handle, owner]) => ({ name: handle.capabilityName, owner, From 8ec1658ac46a14f12675ab2b7dd3e856c507c96c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 19 Oct 2026 11:13:54 +0200 Subject: [PATCH 11/15] fix(ai-harness): a plugins remove setting is rejected when a kept turn plugin or a harness middleware needs the removed capability --- docs/harness/thread-settings.md | 2 +- packages/ai-harness/src/session/state.ts | 34 ++++++++++++++++-- .../ai-harness/tests/thread-settings.test.ts | 36 ++++++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/harness/thread-settings.md b/docs/harness/thread-settings.md index f72cab14da..aa05330a2a 100644 --- a/docs/harness/thread-settings.md +++ b/docs/harness/thread-settings.md @@ -103,7 +103,7 @@ For `reasoning`, the `overrides` of the prompt win, then the stored setting, the - The harness applies `tools` before the plugins prepare the tool list (`prepareTools`). So a plugin that wraps tools sees only the tools that a thread keeps. - `plugins: { remove }` leaves everything that those plugins give out of the thread: tools, prompts, middleware, the model pick, agents, subagents, commands, and the items that they add for other plugins. For example, a thread without `agents()` gets no subagent tools, no `/agent` command, and no permission rules of its agent profiles. The thread also cannot run an agent of a removed plugin by its name. - Plugins that run once per session stay mounted, but they give the thread nothing. -- If a plugin that stays needs a capability of a removed plugin, the setting is rejected. +- If a plugin that stays (of any lifetime) or a harness `middleware` entry needs a capability of a removed plugin, the setting is rejected. ## The working folder diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index db5bcabde7..cc774dbcdf 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -700,11 +700,39 @@ export abstract class SessionState< /** * Throws when the plugins that `settings` removes provide a capability - * that a kept session plugin requires. + * that a kept plugin (of any lifetime) or a harness middleware requires. */ protected checkRemovable(settings: ThreadSettings) { - const remove = settings.plugins?.remove ?? [] - if (remove.length > 0) this.sessionPlugins?.withoutOwners(remove) + const removed = new Set(settings.plugins?.remove ?? []) + if (removed.size === 0) return + const owners = new Map( + this.plugins.flatMap((plugin) => + (plugin.provides ?? []).map((handle) => [handle, plugin.name] as const), + ), + ) + const needs = [ + ...this.plugins + .filter((plugin) => !removed.has(plugin.name)) + .map((plugin) => ({ + who: `plugin ${plugin.name}`, + requires: plugin.requires, + })), + ...(this.harness.middleware ?? []).map((middleware) => ({ + who: middleware.name + ? `harness middleware ${middleware.name}` + : 'a harness middleware', + requires: middleware.requires, + })), + ] + for (const { who, requires } of needs) { + for (const handle of requires ?? []) { + const owner = owners.get(handle) + if (owner === undefined || !removed.has(owner)) continue + throw new Error( + `Cannot remove plugin ${owner}: ${who} requires its capability "${handle.capabilityName}".`, + ) + } + } } /** The model and plugin names that a setting can use. */ diff --git a/packages/ai-harness/tests/thread-settings.test.ts b/packages/ai-harness/tests/thread-settings.test.ts index 7d4d66e0b7..5e5f5e27fb 100644 --- a/packages/ai-harness/tests/thread-settings.test.ts +++ b/packages/ai-harness/tests/thread-settings.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' -import { EventType, toolDefinition } from '@tanstack/ai' +import { EventType, createCapability, toolDefinition } from '@tanstack/ai' import { memoryLogBackend, memoryLogStore, @@ -263,6 +263,40 @@ describe('thread settings', () => { await host.close() }) + it('refuses to remove a plugin whose capability a turn plugin or a harness middleware requires', async () => { + const Notes = createCapability()('acme/notes-capability') + const { adapter } = mockAdapter(() => text('ok')) + const { host, session } = await open({ + adapter, + middleware: [{ name: 'audit', requires: [Notes] }], + plugins: () => [ + definePlugin({ + name: 'acme/notes', + provides: [Notes], + setup: (ctx) => void ctx.provide(Notes, 'notes'), + }), + definePlugin({ + name: 'acme/search', + lifetime: 'turn', + requires: [Notes], + }), + ], + }) + const remove = (names: Array) => + session.configure({ settings: { plugins: { remove: names } } }) + + expect(await remove(['acme/notes'])).toMatchObject({ + status: 'rejected', + reason: expect.stringContaining('plugin acme/search requires'), + }) + expect(await remove(['acme/notes', 'acme/search'])).toMatchObject({ + status: 'rejected', + reason: expect.stringContaining('harness middleware audit requires'), + }) + expect(session.settings()).toEqual({}) + await host.close() + }) + it('keeps the settings after a restart', async () => { const persistence = durablePersistence() const { base, strong } = models([() => text('strong')]) From a5fa03a9e13b2857e4df2289d65d2882844286f8 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 19 Oct 2026 11:16:00 +0200 Subject: [PATCH 12/15] fix(ai-harness): client media checks every part before it stores any, refuses provider file handles unless media.allowProviderFiles, and the frame limit docs name the server maxPayload --- docs/config.json | 2 +- docs/harness/connect.md | 2 +- docs/harness/media.md | 21 ++-- packages/ai-harness/src/http.ts | 9 +- packages/ai-harness/src/inline-media.ts | 100 +++++++++++------- packages/ai-harness/src/media.ts | 15 ++- .../ai-harness/tests/inline-media.test.ts | 31 ++++++ 7 files changed, 130 insertions(+), 50 deletions(-) diff --git a/docs/config.json b/docs/config.json index 59f674170d..0a99c5cf4d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -933,7 +933,7 @@ "label": "Send and show media", "to": "harness/media", "addedAt": "2026-10-13", - "updatedAt": "2026-10-18" + "updatedAt": "2026-10-19" }, { "label": "Connect clients", diff --git a/docs/harness/connect.md b/docs/harness/connect.md index fe136501a2..5902ecb071 100644 --- a/docs/harness/connect.md +++ b/docs/harness/connect.md @@ -466,7 +466,7 @@ The client sends `{ type: 'harness.subscribe', threadId }` first, and the host a - A user that `identify` refuses gets an `unauthorized` error frame, and the socket closes. - The socket handles the frames of one connection in the order they came. So a prompt with a large image reaches the session before the frame after it. - A user who cannot read the thread, or who loses access later, gets a `forbidden` error frame, and the socket closes. -- A frame over `media.maxBodyBytes` (default 32 MiB) gets an error frame, and the socket closes with code `1009`. +- A frame over `media.maxBodyBytes` (default 32 MiB) gets an error frame, and the socket closes with code `1009`. Your WebSocket server reads the whole frame before this check, so the check does not cap memory. Set the frame limit of your server too, for example `maxPayload` of `ws`. - On a host with no `access`, pass `openAccess`, the same as for `createHarnessHandler`. - Pass `socket: true` to `createHarnessHandler` too. Then `GET capabilities` says `transport.websocket: true`, so clients know that they can use the socket. The default is `false`. diff --git a/docs/harness/media.md b/docs/harness/media.md index 8be96f5bdb..a41d9486d2 100644 --- a/docs/harness/media.md +++ b/docs/harness/media.md @@ -206,11 +206,12 @@ On the server, the harness does this for each part: - Base64 bytes (a `data` source, or a base64 `data:` URL) go to the media store. The message keeps a `harness-media:` reference, the same as a file from `putMedia`. `maxBytes`, `kinds`, and the model check apply. - An `http:` or `https:` URL is refused. Set `externalUrls` to allow it. +- A provider file handle (`source: { type: 'file' }`) is refused. The adapter reads the file with your provider key, so a client that knows the file id of another user could make the model read it. Set `allowProviderFiles: true` to allow it. - A URL with another scheme, for example `file:`, is always refused. `POST run` (the route of `useChat`) checks the parts of the last user message the same way. -Two options control this: +Three options control this: ```ts group=harness-media-inline import { defineHarness } from '@tanstack/ai-harness' @@ -221,26 +222,28 @@ export const assistant = defineHarness({ adapter: openaiText('gpt-6.1-sol'), media: { externalUrls: (url) => url.hostname === 'cdn.acme.com', + allowProviderFiles: false, maxBodyBytes: 8 * 1024 * 1024, }, }) ``` - `externalUrls`: `true` allows every `http:` and `https:` URL. A function decides for each URL. Default `false`. -- `maxBodyBytes`: the largest request body or socket frame, in bytes. Default 32 MiB. A bigger body answers `413`. A bigger frame closes the socket. +- `allowProviderFiles`: `true` allows provider file handles. Default `false`. +- `maxBodyBytes`: the largest request body or socket frame, in bytes. Default 32 MiB. A bigger body answers `413`. A bigger frame closes the socket. The socket checks a frame only after your WebSocket server read it, so also set the frame limit of your server, for example `maxPayload` of `ws`. If the harness refuses a part, no turn starts. The receipt is `rejected`, and its `reason` tells you why: -| `reason` | Why | -| ----------------------- | ----------------------------------------------------- | -| `media_too_large` | The bytes are bigger than `maxBytes`. | -| `media_not_allowed` | The kind is not in `kinds`, or the type has no kind. | -| `media_invalid` | The bytes are not base64. | -| `media_url_not_allowed` | `externalUrls` refuses the URL, or the scheme is bad. | +| `reason` | Why | +| ----------------------- | -------------------------------------------------------------------------------------------------------------- | +| `media_too_large` | The bytes are bigger than `maxBytes`. | +| `media_not_allowed` | The kind is not in `kinds`, or the type has no kind. | +| `media_invalid` | The bytes are not base64. | +| `media_url_not_allowed` | `externalUrls` refuses the URL, the scheme is bad, or it is a file handle and `allowProviderFiles` is not set. | `POST run` has no receipt. It answers with the `reason` as `error`, and with `413` for `media_too_large`, `415` for `media_not_allowed`, or `400` for the other two. -Server code that calls `session.send` directly is not checked. The checks are for inputs from a client. +Server code that calls `session.send` directly is not checked, so it can send file handles. The checks are for inputs from a client. ## Keep media across restarts diff --git a/packages/ai-harness/src/http.ts b/packages/ai-harness/src/http.ts index 11a0634556..1d417d2b50 100644 --- a/packages/ai-harness/src/http.ts +++ b/packages/ai-harness/src/http.ts @@ -1356,6 +1356,12 @@ const errorMessage = (error: unknown) => * * `stream` sets the values of the socket (see `HarnessStreamOptions`). * + * A frame over `media.maxBodyBytes` gets an error frame, and the socket + * closes with 1009. Your WebSocket server has read the whole frame by then, + * so this check saves only the parse. The memory cap is the frame limit of + * your server, for example `maxPayload` of `ws`. Set it to + * `media.maxBodyBytes` or lower. + * * Throws at the start for a host without `access` and without `openAccess`, * for a host with both, and for a `stream` value that is not a finite number * above 0. @@ -1495,7 +1501,8 @@ export function handleHarnessSocket

( socket.addEventListener('message', (message) => { const data = String(message.data) // A frame over `media.maxBodyBytes` is not parsed, so its error frame - // names the socket. The socket closes with 1009 (message too big). + // names the socket. The socket closes with 1009 (message too big). The + // server read the frame already: its own frame limit caps the memory. if (isOverLimit(data, frameLimit)) { closeWith( { diff --git a/packages/ai-harness/src/inline-media.ts b/packages/ai-harness/src/inline-media.ts index 23ae14a2f9..5c6f368eae 100644 --- a/packages/ai-harness/src/inline-media.ts +++ b/packages/ai-harness/src/inline-media.ts @@ -18,8 +18,9 @@ export const DEFAULT_MAX_BODY_BYTES = 32 * 1024 * 1024 * - `media_not_allowed`: a MIME type with no media kind, or a kind not in * `media.kinds`. * - `media_invalid`: the data is not base64. - * - `media_url_not_allowed`: a URL that `media.externalUrls` refuses, or a - * URL scheme other than `http:`, `https:`, `data:`, and `harness-media:`. + * - `media_url_not_allowed`: a URL that `media.externalUrls` refuses, a + * URL scheme other than `http:`, `https:`, `data:`, and `harness-media:`, + * or a provider file handle without `media.allowProviderFiles`. */ export type InlineMediaRefusal = | 'media_too_large' @@ -36,6 +37,11 @@ export interface InlineMediaContext { type MediaContentPart = Exclude +/** A checked part, with the bytes that it must store, if any. */ +type CheckedPart = + | { part: ContentPart; upload?: undefined } + | { part: MediaContentPart; upload: MediaUpload } + const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/ // `data:[;params];base64,`. Only base64 data URLs carry bytes. const DATA_URL = /^data:([^,]*?);base64,(.*)$/s @@ -71,13 +77,12 @@ function isUrlAllowed(url: URL, options: MediaOptions | undefined) { return typeof allow === 'function' ? allow(url) : allow } -/** `part` with its bytes in the store, as a `harness-media:` reference. */ -async function storePart( +/** `part`, checked, with the bytes it must store, or why it was refused. */ +function checkBytes( part: MediaContentPart, data: { value: string; mimeType: string }, - context: InlineMediaContext, + options: MediaOptions | undefined, ) { - const { options } = context const kind = kindOf(data.mimeType) const isKindAllowed = kind !== undefined && (options?.kinds ?? [kind]).includes(kind) @@ -87,40 +92,38 @@ async function storePart( if (isTooLarge) return refuse('media_too_large') const bytes = fromBase64(data.value) if (bytes === undefined) return refuse('media_invalid') - try { - const record = await context.putMedia({ - body: bytes, - mimeType: data.mimeType, - name: kind, - }) - // Other fields, like provider metadata, stay on the part. - const stored: ContentPart = { ...part, ...mediaPart(record) } - return stored - } catch (error) { - if (!(error instanceof MediaError)) throw error - return refuse( - error.status === 413 ? 'media_too_large' : 'media_not_allowed', - ) + const checked: CheckedPart = { + part, + upload: { body: bytes, mimeType: data.mimeType, name: kind }, } + return checked } -/** One part, checked and stored, or the reason it was refused. */ -async function checkPart(part: ContentPart, context: InlineMediaContext) { - if (part.type === 'text') return part +/** One part, checked, or the reason it was refused. Stores nothing. */ +function checkPart( + part: ContentPart, + options: MediaOptions | undefined, +): CheckedPart | { refused: InlineMediaRefusal } { + if (part.type === 'text') return { part } const { source } = part - if (source.type === 'data') return storePart(part, source, context) - if (source.type !== 'url') return part - if (source.value.startsWith(MEDIA_URL_PREFIX)) return part + if (source.type === 'data') return checkBytes(part, source, options) + // A provider file handle is read with the server's provider key. + if (source.type === 'file') { + return options?.allowProviderFiles === true + ? { part } + : refuse('media_url_not_allowed') + } + if (source.value.startsWith(MEDIA_URL_PREFIX)) return { part } const dataUrl = DATA_URL.exec(source.value) if (dataUrl) { const [, mimeType = '', value = ''] = dataUrl - return storePart(part, { value, mimeType }, context) + return checkBytes(part, { value, mimeType }, options) } const url = URL.canParse(source.value) ? new URL(source.value) : undefined - if (url === undefined || !isUrlAllowed(url, context.options)) { + if (url === undefined || !isUrlAllowed(url, options)) { return refuse('media_url_not_allowed') } - return part + return { part } } /** @@ -129,12 +132,13 @@ async function checkPart(part: ContentPart, context: InlineMediaContext) { * with `putMedia`, and the part becomes a `harness-media:` reference. So the * bytes are checked against `media.kinds` and `media.maxBytes`, and the model * check of `harness-media:` parts runs on them. An `http:` or `https:` URL - * stays only when `media.externalUrls` allows it. Other URL schemes are - * refused. Text, `harness-media:` parts, and provider file handles stay as - * they are. + * stays only when `media.externalUrls` allows it. A provider file handle + * stays only when `media.allowProviderFiles` is `true`. Other URL schemes are + * refused. Text and `harness-media:` parts stay as they are. * * Resolves to `{ parts }`, or to `{ refused }` with the reason of the first - * part that was refused. Parts stored before a refusal stay in the store. + * part that was refused. Every part is checked before any part is stored, so + * a refused input stores nothing. * * @example * ```ts @@ -150,18 +154,40 @@ export async function storeInlineMedia( parts: ReadonlyArray, context: InlineMediaContext, ) { - const checked: Array = [] + const pending: Array = [] for (const part of parts) { - const result = await checkPart(part, context) + const result = checkPart(part, context.options) if ('refused' in result) return result - checked.push(result) + pending.push(result) + } + const checked: Array = [] + for (const entry of pending) { + if (entry.upload === undefined) { + checked.push(entry.part) + continue + } + try { + const record = await context.putMedia(entry.upload) + // Other fields, like provider metadata, stay on the part. + const stored: ContentPart = { ...entry.part, ...mediaPart(record) } + checked.push(stored) + } catch (error) { + // ponytail: the store refuses only what the checks above let through + // when its own limits differ (its 100 MB default `maxBytes`). Parts + // stored before such a refusal stay in the store. + if (!(error instanceof MediaError)) throw error + return refuse( + error.status === 413 ? 'media_too_large' : 'media_not_allowed', + ) + } } return { parts: checked } } /** * The largest request body or socket frame, in bytes, that a transport - * reads: `media.maxBodyBytes`, or 32 MiB. + * reads: `media.maxBodyBytes`, or 32 MiB. A socket checks a frame after the + * WebSocket server read it, so this saves only the parse. * * @example * ```ts diff --git a/packages/ai-harness/src/media.ts b/packages/ai-harness/src/media.ts index 36cf4ced67..a5b2c5edd1 100644 --- a/packages/ai-harness/src/media.ts +++ b/packages/ai-harness/src/media.ts @@ -45,7 +45,20 @@ export interface MediaOptions { * input is refused. A function decides for each URL. */ externalUrls?: boolean | ((url: URL) => boolean) - /** The largest request body or socket frame, in bytes. Default 32 MiB. */ + /** + * Whether a client input can send a content part with a provider file + * handle (`source.type: 'file'`). The adapter reads that file with the + * server's provider key, so a client that knows another user's file id + * could make the model read it. Default `false`: the input is refused. + * Server code can always send file handles. + */ + allowProviderFiles?: boolean + /** + * The largest request body or socket frame, in bytes. Default 32 MiB. A + * body stops being read at the limit. A socket frame is checked only after + * the WebSocket server read all of it, so set the frame limit of the server + * (for example `maxPayload` of `ws`) too. + */ maxBodyBytes?: number } diff --git a/packages/ai-harness/tests/inline-media.test.ts b/packages/ai-harness/tests/inline-media.test.ts index a32a9e923e..1cff8a3d1f 100644 --- a/packages/ai-harness/tests/inline-media.test.ts +++ b/packages/ai-harness/tests/inline-media.test.ts @@ -132,6 +132,37 @@ describe('storeInlineMedia', () => { refused: 'media_url_not_allowed', }) }) + + it('stores nothing when a later part is refused', async () => { + const stored: Array = [] + const checked = await storeInlineMedia( + [inlineImage(), urlImage('https://example.com/cat.png')], + { + options: undefined, + putMedia: async (upload) => { + stored.push(upload.mimeType) + throw new Error('not reached') + }, + }, + ) + + expect(checked).toEqual({ refused: 'media_url_not_allowed' }) + expect(stored).toEqual([]) + }) + + it('refuses a provider file handle unless media.allowProviderFiles is set', async () => { + const file: ContentPart = { + type: 'document', + source: { type: 'file', value: 'file-abc' }, + } + + expect(await storeWith().check([file])).toEqual({ + refused: 'media_url_not_allowed', + }) + expect(await storeWith({ allowProviderFiles: true }).check([file])).toEqual( + { parts: [file] }, + ) + }) }) describe('readBodyText', () => { From 2c8d58b4d73df4096be28058cdc60de6824d506d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 19 Oct 2026 11:19:16 +0200 Subject: [PATCH 13/15] fix(ai-harness): a retried client input with inline media reuses the stored media, so it stores nothing again and is a duplicate, not a conflict --- packages/ai-harness/src/http.ts | 20 ++++--- packages/ai-harness/src/protocol.ts | 57 +++++++++++++++---- .../ai-harness/src/session/harness-session.ts | 1 + packages/ai-harness/src/session/inputs.ts | 13 +++++ .../ai-harness/tests/inline-media.test.ts | 25 ++++++++ 5 files changed, 99 insertions(+), 17 deletions(-) diff --git a/packages/ai-harness/src/http.ts b/packages/ai-harness/src/http.ts index 1d417d2b50..4b21e535de 100644 --- a/packages/ai-harness/src/http.ts +++ b/packages/ai-harness/src/http.ts @@ -24,6 +24,7 @@ import { parseControlFrame, parseHarnessInput, requestIdOf, + retriedMessage, turnReceipt, } from './protocol' import { socketSender, sseStream, streamValues, withHeartbeat } from './sse' @@ -917,13 +918,18 @@ export function createHarnessHandler

( return json({ error: 'no user message' }, 400) } // Inline bytes go to the media store, and the message keeps a - // `harness-media:` reference, as `POST control` does. - const checked = Array.isArray(message) - ? await storeInlineMedia(message, { - options: harness.media, - putMedia: (upload) => session.putMedia(upload), - }) - : { parts: message } + // `harness-media:` reference, as `POST control` does. A retried run + // stores no media again. + const retry = retriedMessage(session, params.runId, message) + const checked = + retry !== undefined + ? { parts: retry } + : Array.isArray(message) + ? await storeInlineMedia(message, { + options: harness.media, + putMedia: (upload) => session.putMedia(upload), + }) + : { parts: message } if ('refused' in checked) { return json( { error: checked.refused }, diff --git a/packages/ai-harness/src/protocol.ts b/packages/ai-harness/src/protocol.ts index ce6dd77964..708c6f8c27 100644 --- a/packages/ai-harness/src/protocol.ts +++ b/packages/ai-harness/src/protocol.ts @@ -4,6 +4,8 @@ import { parseTraceparent } from './observability' import { isRecord } from './utils' import { createInputId, errorText } from './session/helpers' import { storeInlineMedia } from './inline-media' +import { SessionImpl } from './session/harness-session' +import { mediaIdOf } from './media-ref' import type { ModelMessage, StreamChunk } from '@tanstack/ai' import type { AnyAgent } from './agents' import type { AnyHarness } from './define' @@ -308,7 +310,9 @@ export function parseControlFrame(data: string): ControlFrame { * `storeInlineMedia` does: inline bytes go to the media store and the part * becomes a `harness-media:` reference. An external URL needs * `media.externalUrls`. A refused part gives a rejected receipt with the - * reason, for example `media_too_large`. + * reason, for example `media_too_large`. An input with an `inputId` that the + * session has already sends the stored message again, so a retry stores no + * media. * * @example * ```ts @@ -359,11 +363,22 @@ export async function applyInput({ options: harness.media, putMedia: (upload) => session.putMedia(upload), } + /** `message` with its inline media stored. A retry stores none again. */ + const checkedMessage = async (message: UserInput) => { + const retry = retriedMessage(session, input.inputId, message) + if (retry !== undefined) return { content: retry, isRetry: true } + if (typeof message === 'string') return { content: message } + const checked = await storeInlineMedia(message, media) + return 'refused' in checked ? checked : { content: checked.parts } + } switch (input.op) { case 'prompt': { - const message = await checkedMessage(input.message, media) + const message = await checkedMessage(input.message) if ('refused' in message) return refused(message.refused) - const ephemeral = await checkedEphemeral(input.ephemeral, media) + // A retry drops its ephemeral messages, as `session.send` does. + const ephemeral = message.isRetry + ? { options: {} } + : await checkedEphemeral(input.ephemeral, media) if ('refused' in ephemeral) return refused(ephemeral.refused) return turnReceipt( session.send(message.content, { @@ -383,7 +398,7 @@ export async function applyInput({ } case 'steer': case 'followUp': { - const message = await checkedMessage(input.message, media) + const message = await checkedMessage(input.message) if ('refused' in message) return refused(message.refused) return turnReceipt( session.send(message.content, { ...sent, ...traced, mode: input.op }), @@ -531,7 +546,7 @@ export async function applyInput({ reason: 'not_exposed', } } - const message = await checkedMessage(input.message, media) + const message = await checkedMessage(input.message) if ('refused' in message) return refused(message.refused) return turnReceipt( session.send(message.content, { @@ -544,11 +559,33 @@ export async function applyInput({ } } -/** `message` with its inline media stored, or why a part was refused. */ -async function checkedMessage(message: UserInput, media: InlineMediaContext) { - if (typeof message === 'string') return { content: message } - const checked = await storeInlineMedia(message, media) - return 'refused' in checked ? checked : { content: checked.parts } +/** + * `message` as a retry of the input `inputId` that `session` has in its log, + * or `undefined` for a new input. A part at the place of a stored + * `harness-media:` part, of the same type, becomes the stored part. So a + * retry stores no media again, and the duplicate check of the session + * matches. A retry that differs in another way gets `conflict`. Its parts are + * not checked or stored: the session refuses it before it runs. + */ +// ponytail: a retry with other inline bytes of the same type matches too. +// The first input stands. +export function retriedMessage( + session: HarnessSession, + inputId: string | undefined, + message: UserInput, +): UserInput | undefined { + if (inputId === undefined || !(session instanceof SessionImpl)) { + return undefined + } + const known = session.knownMessage(inputId) + if (known === undefined) return undefined + if (typeof message === 'string' || typeof known === 'string') return message + return message.map((part, index) => { + const stored = known[index] + const isStoredMedia = + stored?.type === part.type && mediaIdOf(stored) !== undefined + return isStoredMedia ? stored : part + }) } /** diff --git a/packages/ai-harness/src/session/harness-session.ts b/packages/ai-harness/src/session/harness-session.ts index 91c3e53b7a..4f2b01ba52 100644 --- a/packages/ai-harness/src/session/harness-session.ts +++ b/packages/ai-harness/src/session/harness-session.ts @@ -61,6 +61,7 @@ export interface HarnessSession< | 'resolveForRun' | 'sendForRun' | 'checked' + | 'knownMessage' | 'cancelWorkOf' | 'agentHandle' | 'isIdle' diff --git a/packages/ai-harness/src/session/inputs.ts b/packages/ai-harness/src/session/inputs.ts index 8cf24ed7cc..4b91957bd0 100644 --- a/packages/ai-harness/src/session/inputs.ts +++ b/packages/ai-harness/src/session/inputs.ts @@ -33,6 +33,7 @@ import type { Principal, Receipt, SessionEvent, + UserInput, WaitingInput, } from '../types' import type { OfferedInputRecord, TurnExtras } from './helpers' @@ -121,6 +122,18 @@ export abstract class SessionInputs< } } + /** + * @internal The message of the input `inputId` in the log, or `undefined`. + * A client that sends an input again sends this message, so its inline + * media is not stored again and the duplicate check matches. + */ + // ponytail: reads the log only. A retry that comes while the first copy + // is still being stored stores its media again. + knownMessage(inputId: string): UserInput | undefined { + const input = this.writer.state.inputs.get(inputId)?.input + return input && 'message' in input ? input.message : undefined + } + /** * The run that `input` answers or cancels, and who started it, when the * session knows. Other inputs target no run. diff --git a/packages/ai-harness/tests/inline-media.test.ts b/packages/ai-harness/tests/inline-media.test.ts index 1cff8a3d1f..7dcc500024 100644 --- a/packages/ai-harness/tests/inline-media.test.ts +++ b/packages/ai-harness/tests/inline-media.test.ts @@ -216,6 +216,31 @@ describe('applyInput with media from a client', () => { await host.close() }) + it('treats a retried prompt with inline media as the same input', async () => { + const { harness, host, session, calls } = await open() + const input = { + op: 'prompt' as const, + inputId: 'in-1', + message: [inlineImage()], + } + + const first = await applyInput({ harness, session, input }) + await session.settled({ inputId: 'in-1' }) + const again = await applyInput({ harness, session, input }) + + // Stored again, the media id would differ, and the retry would be a + // conflict. + expect(first.status).toBe('accepted') + expect(again).toEqual(first) + expect(calls).toHaveLength(1) + const other = { ...input, message: [inlineImage(), inlineImage()] } + expect(await applyInput({ harness, session, input: other })).toMatchObject({ + status: 'rejected', + reason: 'conflict', + }) + await host.close() + }) + it('rejects a prompt with an external URL, and no turn runs', async () => { const { harness, host, session, calls } = await open() From cc1eae9abe901b0e6648244744f65c206de58e3d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 19 Oct 2026 11:19:34 +0200 Subject: [PATCH 14/15] docs(ai-harness): routed root-agent turns take steers and late joins, only session.tool() turns do not --- docs/harness/turn-control.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/harness/turn-control.md b/docs/harness/turn-control.md index 26fd401eb0..c486d3cedb 100644 --- a/docs/harness/turn-control.md +++ b/docs/harness/turn-control.md @@ -191,10 +191,9 @@ The join stops at the first message that: That message and the ones after it wait. `canJoin` runs again before each model call and at each final answer, so it can run more than once for one message. Keep it a quick check with no side effects. What still waits when the turn ends runs as its own turn, before other queued turns. A cancelled one settles `aborted` and does not run. -Two kinds of turns take no messages. A message that arrives while one runs waits, and then runs as its own turn: +A `session.tool()` turn takes no messages. It runs no model, so nothing could answer them. A message that arrives while it runs waits, and then runs as its own turn. See [Run a tool without a model](./tool-runs). -- A `session.tool()` turn. It runs no model, so nothing could answer the message. See [Run a tool without a model](./tool-runs). -- A turn that `routing` sends to root agents. +A turn that `routing` sends to root agents takes messages, the same as the main model. The root agents get them at each model call. A joined message runs with the credentials of the sender of the turn. So without `turn.canJoin`, a waiting message joins the running turn only when the same person sent both. A message from another person waits and runs as its own turn after the running one, as its sender. Joins are a prefix, so it also holds back the messages that wait after it. Set `canJoin` to choose your own rule. Compare `principal` with `turnPrincipal` to keep one person's message out of another person's turn. See [Keep other people out of a running turn](./shared-threads#5-keep-other-people-out-of-a-running-turn). From b5e10b66ae8e5a66cb1cbe62d43d670c1133a7cf Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Mon, 19 Oct 2026 11:22:16 +0200 Subject: [PATCH 15/15] fix(ai-harness-cli): --serve signs media URLs with --media-key or HARNESS_MEDIA_KEY, or a random key, never with the bearer token --- .changeset/harness-cli-handler-options.md | 2 +- .changeset/harness-turn-and-body-limits.md | 6 +++-- docs/config.json | 2 +- docs/harness/cli.md | 2 +- packages/ai-harness-cli/src/args.ts | 6 +++++ packages/ai-harness-cli/src/index.ts | 2 ++ packages/ai-harness-cli/src/serve.ts | 27 +++++++++++++--------- packages/ai-harness-cli/tests/cli.test.ts | 26 +++++++++++++++++++++ 8 files changed, 57 insertions(+), 16 deletions(-) diff --git a/.changeset/harness-cli-handler-options.md b/.changeset/harness-cli-handler-options.md index a53abd427c..bd1a0fc1f0 100644 --- a/.changeset/harness-cli-handler-options.md +++ b/.changeset/harness-cli-handler-options.md @@ -2,4 +2,4 @@ '@tanstack/ai-harness-cli': patch --- -`--serve` passes `media: { secret }` to the harness handler. 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. +`--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-turn-and-body-limits.md b/.changeset/harness-turn-and-body-limits.md index c8b61dfe1a..c3a8dc216c 100644 --- a/.changeset/harness-turn-and-body-limits.md +++ b/.changeset/harness-turn-and-body-limits.md @@ -6,7 +6,9 @@ Fix how a turn takes steers, settings, and plugin removals, and limit the size o - 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 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 without a capability that it needs is rejected. +- `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. +- `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/docs/config.json b/docs/config.json index 0a99c5cf4d..e6959b7a8e 100644 --- a/docs/config.json +++ b/docs/config.json @@ -957,7 +957,7 @@ "label": "Run in the terminal", "to": "harness/cli", "addedAt": "2026-10-13", - "updatedAt": "2026-10-18" + "updatedAt": "2026-10-19" }, { "label": "Deploy a harness", diff --git a/docs/harness/cli.md b/docs/harness/cli.md index 6940502762..1af11fbfeb 100644 --- a/docs/harness/cli.md +++ b/docs/harness/cli.md @@ -117,7 +117,7 @@ console.log(await exit) // 143 `--serve` is the quick way to put a harness on HTTP, so it makes the trust decisions for you: one bearer token, one user, every thread. Anyone with the token acts as that one user. They can open or drive any thread on the host, including the tools it runs (shell commands, file edits) on the machine the CLI runs on. Treat the token like a shell login. - It binds to `127.0.0.1:8787`, reachable only from the same machine. Change the bind with `--host` and `--port`. `--host 0.0.0.0` puts it on the network, so do that only behind TLS and a proxy you trust. -- The CLI signs media links with the token, so rotating the token stops any link a turn already handed out. +- The CLI signs media links with its own key, never with the token. By default it makes a new random key at each start, so media links stop working after a restart. To keep them working, pass `--media-key` or set `HARNESS_MEDIA_KEY`, with a key of at least 32 bytes. Change the key to stop every link a turn already handed out. Need per-user access, your own sign-in check, or a rule for which threads each user can open? Use [`createHarnessHandler`](./connect) on a host with [`access`](./access) instead. The host reads the user of each request and checks each action, so no thread is open by accident. diff --git a/packages/ai-harness-cli/src/args.ts b/packages/ai-harness-cli/src/args.ts index 7abfb143d9..0fc59a389c 100644 --- a/packages/ai-harness-cli/src/args.ts +++ b/packages/ai-harness-cli/src/args.ts @@ -11,6 +11,7 @@ export interface CliArgs { port: number hostname: string token?: string + mediaKey?: string thread: string mediaDir?: string } @@ -31,6 +32,7 @@ Options: --port Port for --serve (default 8787) --host Host name for --serve (default 127.0.0.1) --token Bearer token for --serve (default: HARNESS_TOKEN, or a new random token) + --media-key Key that signs media URLs of --serve, 32+ bytes (default: HARNESS_MEDIA_KEY, or a new random key at each start) --thread Conversation id (default: main) --media-dir

Save the media a turn makes here (default: ./-media) -h, --help Show this help @@ -52,6 +54,7 @@ export function parseCliArgs(argv: ReadonlyArray): CliArgs { port: { type: 'string' }, host: { type: 'string' }, token: { type: 'string' }, + 'media-key': { type: 'string' }, thread: { type: 'string' }, 'media-dir': { type: 'string' }, }, @@ -76,6 +79,9 @@ export function parseCliArgs(argv: ReadonlyArray): CliArgs { port, hostname: values.host ?? '127.0.0.1', ...(values.token !== undefined ? { token: values.token } : {}), + ...(values['media-key'] !== undefined + ? { mediaKey: values['media-key'] } + : {}), thread: values.thread ?? 'main', ...(values['media-dir'] !== undefined ? { mediaDir: values['media-dir'] } diff --git a/packages/ai-harness-cli/src/index.ts b/packages/ai-harness-cli/src/index.ts index 1bf08c8c6c..b36e0fa40f 100644 --- a/packages/ai-harness-cli/src/index.ts +++ b/packages/ai-harness-cli/src/index.ts @@ -261,12 +261,14 @@ export async function runCli

( if (args.serve) { const token = args.token ?? env.HARNESS_TOKEN ?? createToken() + const mediaKey = args.mediaKey ?? env.HARNESS_MEDIA_KEY const server = await serve({ host, harness, port: args.port, hostname: args.hostname, token, + ...(mediaKey !== undefined ? { mediaKey } : {}), threadId: args.thread, approvals, ...(principal ? { principal } : {}), diff --git a/packages/ai-harness-cli/src/serve.ts b/packages/ai-harness-cli/src/serve.ts index 71c58d8932..ba6ea3f932 100644 --- a/packages/ai-harness-cli/src/serve.ts +++ b/packages/ai-harness-cli/src/serve.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' -import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import { randomBytes, timingSafeEqual } from 'node:crypto' import { createHarnessHandler } from '@tanstack/ai-harness/server' import { loadOptional } from './optional' import type { IncomingMessage, ServerResponse } from 'node:http' @@ -23,6 +23,13 @@ export interface ServeOptions

{ * media URL needs no token: its signature is its login. */ token: string + /** + * The key that signs media URLs, at least 32 bytes (UTF-8). Default: a new + * random key at each start, so a signed URL stops working after a restart. + * It is never made from `token`: a signed URL is given out without the + * token, and must not let anyone guess it. + */ + mediaKey?: string /** The conversation of an MCP tool call that names none. Default `'main'`. */ threadId?: string /** How `/mcp` handles tool calls that need approval. Default `'ask'`. */ @@ -134,22 +141,20 @@ export async function serve

( const isAllowed = (request: Request) => sameToken(request.headers.get('authorization'), options.token) const withAccess = host.hasAccess - // ponytail: a key from the token signs media URLs, so they work as long - // as the token does. The hash gives the 32 bytes that signing needs, also - // for a short token. - const media = { - secret: createHash('sha256') - .update(`tanstack-ai-harness-cli media:${options.token}`) - .digest('hex'), - } + // Without `mediaKey`, the handler signs with a new random key, so signed + // media URLs stop working after a restart. + const media = + options.mediaKey !== undefined + ? { media: { secret: options.mediaKey } } + : {} const handler = withAccess - ? createHarnessHandler({ host, harness, media }) + ? createHarnessHandler({ host, harness, ...media }) : createHarnessHandler({ host, harness, // One principal holds the token, so it can use every thread. openAccess: principal ? { principal } : true, - media, + ...media, }) const mcp = await mcpServer(options, withAccess) const route = (request: Request) => { diff --git a/packages/ai-harness-cli/tests/cli.test.ts b/packages/ai-harness-cli/tests/cli.test.ts index 540684d151..09a7fe64b2 100644 --- a/packages/ai-harness-cli/tests/cli.test.ts +++ b/packages/ai-harness-cli/tests/cli.test.ts @@ -749,6 +749,32 @@ describe('serve mode', () => { } }) + it('signs media URLs with --media-key, never with the token', async () => { + expect(parseCliArgs(['--serve', '--media-key', 'k'])).toMatchObject({ + mediaKey: 'k', + }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const start = (mediaKey?: string) => + serve({ + host, + harness: defineHarness({ + name: 'test/serve-key', + adapter: scripted([]).adapter, + }), + port: 0, + hostname: '127.0.0.1', + token: 'secret', + ...(mediaKey !== undefined ? { mediaKey } : {}), + }) + + // The key is checked: a short one is refused at the start. + await expect(start('short')).rejects.toThrow('Media signing key 0 is 5') + // A short token is fine: it does not sign anything. + const server = await start() + await server.close() + await host.close() + }) + it('reads the user of each request with identify on a host with access, behind the token', async () => { const host = accessHost() const server = await serve({