From 50299e02cd112db92a0cce75c5f18fd3f667c841 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 16 Oct 2026 17:47:20 +0200 Subject: [PATCH 01/11] chore(ai-acp): pin @agentclientprotocol/sdk to ~1.8.0 --- .changeset/harness-acp-agent.md | 2 +- packages/ai-acp/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/harness-acp-agent.md b/.changeset/harness-acp-agent.md index f860ff5c0a..8034ce646c 100644 --- a/.changeset/harness-acp-agent.md +++ b/.changeset/harness-acp-agent.md @@ -2,4 +2,4 @@ '@tanstack/ai-acp': minor --- -`@tanstack/ai-acp/agent` adds `createAcpAgent` and `serveAcp`, which serve a harness as an ACP v2 agent (experimental, like the draft protocol). Tool approvals become permission requests, cancel cancels the running turn, and image, audio, and embedded file blocks go to the media store of the session. `session/list` lists the sessions of the harness from the session index of the host. `@tanstack/ai-acp` now uses `@agentclientprotocol/sdk` 1.5. The existing ACP client adapters keep working. +`@tanstack/ai-acp/agent` adds `createAcpAgent` and `serveAcp`, which serve a harness as an ACP v2 agent (experimental, like the draft protocol). Tool approvals become permission requests, cancel cancels the running turn, and image, audio, and embedded file blocks go to the media store of the session. `session/list` lists the sessions of the harness from the session index of the host. `@tanstack/ai-acp` now uses `@agentclientprotocol/sdk` 1.8, pinned to `~1.8.0` because the agent uses its experimental v2 entry point. The existing ACP client adapters keep working. diff --git a/packages/ai-acp/package.json b/packages/ai-acp/package.json index 4893a07c80..47a45e5bb8 100644 --- a/packages/ai-acp/package.json +++ b/packages/ai-acp/package.json @@ -48,7 +48,7 @@ "test:types": "tsc" }, "dependencies": { - "@agentclientprotocol/sdk": "^1.5.0" + "@agentclientprotocol/sdk": "~1.8.0" }, "peerDependencies": { "@tanstack/ai": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 375d0bbadb..0b90d12af8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2083,7 +2083,7 @@ importers: packages/ai-acp: dependencies: '@agentclientprotocol/sdk': - specifier: ^1.5.0 + specifier: ~1.8.0 version: 1.8.0(zod@4.6.5) devDependencies: '@tanstack/ai': @@ -3567,7 +3567,7 @@ importers: devDependencies: '@google/genai': specifier: ^2.10.0 - version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)) + version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)) '@tanstack/ai': specifier: workspace:* version: link:../ai From f617668b2d73e80fe331ebe9253d8d6e6f05257f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 16 Oct 2026 17:48:52 +0200 Subject: [PATCH 02/11] chore: keep the lockfile peer resolution unchanged for the ACP pin --- pnpm-lock.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b90d12af8..e185116708 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3567,7 +3567,7 @@ importers: devDependencies: '@google/genai': specifier: ^2.10.0 - version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)) + version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)) '@tanstack/ai': specifier: workspace:* version: link:../ai From 27c7da07cc302fc339b621a6112a4a7e37a58a21 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 16 Oct 2026 18:07:24 +0200 Subject: [PATCH 03/11] refactor(ai-harness): drop the 'run' lifetime, type schema-less command input as unknown, use core metadata keys --- packages/ai-harness/src/commands.ts | 7 +- packages/ai-harness/src/plugins.ts | 3 +- packages/ai-harness/src/session/state.ts | 4 +- packages/ai-harness/src/view/index.ts | 3 +- packages/ai-harness/src/view/reduce.ts | 8 +- .../ai-harness/tests/input-sender.test.ts | 22 +++-- .../ai-harness/tests/session-plugins.test.ts | 32 ++++++ packages/ai-harness/tests/session.test.ts | 98 +++++++++---------- packages/ai-harness/tests/types.test-d.ts | 2 +- 9 files changed, 109 insertions(+), 70 deletions(-) diff --git a/packages/ai-harness/src/commands.ts b/packages/ai-harness/src/commands.ts index 40b670672b..3c1b62bb82 100644 --- a/packages/ai-harness/src/commands.ts +++ b/packages/ai-harness/src/commands.ts @@ -134,10 +134,13 @@ export interface CommandDefinition< TSchema extends SchemaInput | undefined = SchemaInput | undefined, > { description: string - /** The input shape, checked before `run`. */ + /** + * The input shape, checked before `run`. Without one, `run` gets the input + * as sent, typed `unknown`: narrow it before use. + */ input?: TSchema run: ( - input: TSchema extends SchemaInput ? InferSchemaType : undefined, + input: TSchema extends SchemaInput ? InferSchemaType : unknown, ctx: CommandContext, ) => unknown } diff --git a/packages/ai-harness/src/plugins.ts b/packages/ai-harness/src/plugins.ts index d8a94d3f58..b22d764caa 100644 --- a/packages/ai-harness/src/plugins.ts +++ b/packages/ai-harness/src/plugins.ts @@ -34,9 +34,8 @@ import type { MetadataOf } from './session-entry' * - `session` (default): from session open to session close. Resources such * as an index, a watcher, or an LSP server stay up across turns. * - `turn`: set up for each chat turn, disposed when that turn ends. - * - `run`: deprecated, the old name of `turn`. It works the same. */ -export type PluginLifetime = 'session' | 'turn' | 'run' +export type PluginLifetime = 'session' | 'turn' /** * A prompt a plugin contributes. `id` must be unique in the session. A diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index f46212a24a..297d076fb8 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -1,5 +1,6 @@ import { EventType, + INTERRUPT_BINDING_METADATA_KEY, convertSchemaToJsonSchema, validateWithStandardSchema, } from '@tanstack/ai' @@ -751,7 +752,8 @@ export abstract class SessionState< typeof payload.toolCallId === 'string' || metadata?.kind === 'approval' || metadata?.kind === 'client_tool' || - (metadata !== undefined && 'tanstack:interruptBinding' in metadata) || + (metadata !== undefined && + INTERRUPT_BINDING_METADATA_KEY in metadata) || typeof payload.id !== 'string' || typeof payload.reason !== 'string' || typeof payload.message !== 'string' diff --git a/packages/ai-harness/src/view/index.ts b/packages/ai-harness/src/view/index.ts index f25add29fd..1af3695a14 100644 --- a/packages/ai-harness/src/view/index.ts +++ b/packages/ai-harness/src/view/index.ts @@ -2,6 +2,7 @@ // ../types, ../media-ref, and ./reduce. Everything else is a type. import { EventType, + INTERRUPT_PAYLOAD_METADATA_KEY, readUnopenedInterruptBinding, wireSubagentInfo, } from '@tanstack/ai' @@ -574,7 +575,7 @@ export function createSessionView(source: SessionViewSource) { }, interrupt: (interrupt) => { const open = () => get().interrupts - const payload = interrupt.metadata?.['tanstack:interruptPayload'] + const payload = interrupt.metadata?.[INTERRUPT_PAYLOAD_METADATA_KEY] const { message, toolCallId, subagentRunId, responseSchema } = interrupt const item: ViewInterrupt = { id: interrupt.id, diff --git a/packages/ai-harness/src/view/reduce.ts b/packages/ai-harness/src/view/reduce.ts index f5a59bed89..8dbfd6b913 100644 --- a/packages/ai-harness/src/view/reduce.ts +++ b/packages/ai-harness/src/view/reduce.ts @@ -1,4 +1,8 @@ -import { EventType, readUnopenedInterruptBinding } from '@tanstack/ai' +import { + EventType, + INTERRUPT_PAYLOAD_METADATA_KEY, + readUnopenedInterruptBinding, +} from '@tanstack/ai' import { isMediaRecord, mediaIdOf, mediaOfMessage } from '../media-ref' import { HARNESS_EVENTS } from '../types' import type { Interrupt, ModelMessage, StreamChunk } from '@tanstack/ai' @@ -680,7 +684,7 @@ const isGeneric = (interrupt: Interrupt) => */ function signInOf(interrupt: Interrupt): SignIn | undefined { if (interrupt.reason !== 'auth_required') return undefined - const payload = recordOf(interrupt.metadata?.['tanstack:interruptPayload']) + const payload = recordOf(interrupt.metadata?.[INTERRUPT_PAYLOAD_METADATA_KEY]) const request = recordOf(payload.request) if (typeof request.connector !== 'string') return undefined return { diff --git a/packages/ai-harness/tests/input-sender.test.ts b/packages/ai-harness/tests/input-sender.test.ts index b8169f0c06..9e773542cb 100644 --- a/packages/ai-harness/tests/input-sender.test.ts +++ b/packages/ai-harness/tests/input-sender.test.ts @@ -433,18 +433,22 @@ describe('the sender of each input', () => { }) describe('recovery keeps the sender and the context', () => { - /** A run plugin that keeps the turn it is set up for, and a tool that reports. */ + /** A turn plugin that keeps the turn it is set up for, and a tool that reports. */ function recorder() { const turns: Array = [] const { plugin, seen } = whoami() - const run = definePlugin({ + const perTurn = definePlugin({ name: 'test/turns', - lifetime: 'run', + lifetime: 'turn', setup: (ctx) => { turns.push(ctx.turn) }, }) - return { plugins: (): Array => [plugin, run], turns, seen } + return { + plugins: (): Array => [plugin, perTurn], + turns, + seen, + } } const sent = { @@ -561,12 +565,12 @@ describe('recovery keeps the sender and the context', () => { }) describe('the turn where the model is picked', () => { - it('gives run plugins and adapter() the message, the context, and the sender', async () => { + it('gives turn plugins and adapter() the message, the context, and the sender', async () => { const setups: Array = [] const picks: Array = [] - const runPicker = definePlugin({ - name: 'test/run-picker', - lifetime: 'run', + const turnPicker = definePlugin({ + name: 'test/turn-picker', + lifetime: 'turn', setup: (ctx) => { setups.push(ctx.turn) return { @@ -590,7 +594,7 @@ describe('the turn where the model is picked', () => { defineHarness({ name: 'test/picker-turn', adapter, - plugins: () => [sessionPlugin, runPicker], + plugins: () => [sessionPlugin, turnPicker], }), { threadId: THREAD, principal: alice }, ) diff --git a/packages/ai-harness/tests/session-plugins.test.ts b/packages/ai-harness/tests/session-plugins.test.ts index e80e75e384..5d6d4d5fa6 100644 --- a/packages/ai-harness/tests/session-plugins.test.ts +++ b/packages/ai-harness/tests/session-plugins.test.ts @@ -167,6 +167,38 @@ describe('commands', () => { await release.opened await waiting.cancel() await waiting.then( + + it('gives a command without a schema the input as sent, typed unknown', async () => { + // Defined on their own, with no contextual type from a plugin's `setup`. + const peek = defineCommand({ + description: 'Reads a field without a check', + // @ts-expect-error Without a schema, narrow the input before use. + run: (input) => input.foo, + }) + const shout = defineCommand({ + description: 'Shouts the text it gets', + run: (input) => + typeof input === 'string' ? input.toUpperCase() : 'no text', + }) + const raw = definePlugin({ + name: 'test/raw-commands', + setup: () => ({ commands: { peek, shout } }), + }) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open( + defineHarness({ + name: 'test/raw-cmds', + adapter: mockAdapter([]).adapter, + plugins: () => [raw], + }), + { threadId: 't' }, + ) + + expect(await session.command('peek', { foo: 'bar' })).toBe('bar') + expect(await session.command('shout', 'hi')).toBe('HI') + expect(await session.command('shout', { text: 'hi' })).toBe('no text') + await host.close() + }) () => undefined, () => undefined, ) diff --git a/packages/ai-harness/tests/session.test.ts b/packages/ai-harness/tests/session.test.ts index 65d3b17d47..49b0006f53 100644 --- a/packages/ai-harness/tests/session.test.ts +++ b/packages/ai-harness/tests/session.test.ts @@ -623,59 +623,53 @@ describe('plugins', () => { await host.close() }) - // 'run' is the old name of 'turn', and still works. - it.each(['turn', 'run'] as const)( - 'keeps session plugins across turns and sets up %s plugins per turn', - async (lifetime) => { - const { host } = setup() - const log: Array = [] - const sessionPlugin = definePlugin({ - name: 'test/session', - setup: async ({ resources }) => { - await resources.acquire( - () => log.push('session:open'), - () => log.push('session:close'), - ) - return { prompts: ['Session prompt.'] } - }, - }) - const runPlugin = definePlugin({ - name: 'test/run', - lifetime, - setup: async ({ resources }) => { - await resources.acquire( - () => log.push('run:open'), - () => log.push('run:close'), - ) - }, - }) - const { adapter, calls } = mockAdapter([() => text('a'), () => text('b')]) - const session = await host.open( - defineHarness({ - name: 'test/plugins', - adapter, - plugins: () => [sessionPlugin, runPlugin], - }), - { threadId: 't1' }, - ) + it('keeps session plugins across turns and sets up turn plugins per turn', async () => { + const { host } = setup() + const log: Array = [] + const sessionPlugin = definePlugin({ + name: 'test/session', + setup: async ({ resources }) => { + await resources.acquire( + () => log.push('session:open'), + () => log.push('session:close'), + ) + return { prompts: ['Session prompt.'] } + }, + }) + const runPlugin = definePlugin({ + name: 'test/run', + lifetime: 'turn', + setup: async ({ resources }) => { + await resources.acquire( + () => log.push('run:open'), + () => log.push('run:close'), + ) + }, + }) + const { adapter, calls } = mockAdapter([() => text('a'), () => text('b')]) + const session = await host.open( + defineHarness({ + name: 'test/plugins', + adapter, + plugins: () => [sessionPlugin, runPlugin], + }), + { threadId: 't1' }, + ) - await session.send('one') - await session.send('two') - await session.close() - - expect(log).toEqual([ - 'session:open', - 'run:open', - 'run:close', - 'run:open', - 'run:close', - 'session:close', - ]) - expect(JSON.stringify(calls[0].systemPrompts)).toContain( - 'Session prompt.', - ) - }, - ) + await session.send('one') + await session.send('two') + await session.close() + + expect(log).toEqual([ + 'session:open', + 'run:open', + 'run:close', + 'run:open', + 'run:close', + 'session:close', + ]) + expect(JSON.stringify(calls[0].systemPrompts)).toContain('Session prompt.') + }) it('runs a harness without an adapter on the model a plugin picks', async () => { const { host } = setup() diff --git a/packages/ai-harness/tests/types.test-d.ts b/packages/ai-harness/tests/types.test-d.ts index b0bcd6596c..6052ef5a5c 100644 --- a/packages/ai-harness/tests/types.test-d.ts +++ b/packages/ai-harness/tests/types.test-d.ts @@ -94,7 +94,7 @@ it('takes a harness without an adapter, and turn plugins', () => { expectTypeOf(picked.adapter).toEqualTypeOf< AnyTextAdapter | KeyedAdapter | undefined >() - expectTypeOf().toEqualTypeOf<'session' | 'turn' | 'run'>() + expectTypeOf().toEqualTypeOf<'session' | 'turn'>() }) it('takes attempt and time limits on the harness', () => { From d03a973345233676150faef273761a0304751115 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 16 Oct 2026 18:12:21 +0200 Subject: [PATCH 04/11] refactor(ai-harness): subpath entry points for server, oauth, media, and Node plugins --- packages/ai-acp/src/agent/index.ts | 8 +- packages/ai-acp/tests/agent-media.test.ts | 10 +-- packages/ai-harness-cli/src/attach.ts | 2 +- packages/ai-harness-cli/src/print.ts | 3 +- packages/ai-harness-cli/src/serve.ts | 2 +- packages/ai-harness-cli/tests/attach.test.ts | 7 +- packages/ai-harness/package.json | 16 ++++ packages/ai-harness/src/first-party/index.ts | 9 +-- packages/ai-harness/src/first-party/node.ts | 6 ++ packages/ai-harness/src/http.ts | 3 +- packages/ai-harness/src/index.ts | 46 +---------- packages/ai-harness/src/media-entry.ts | 14 ++++ packages/ai-harness/src/oauth.ts | 8 +- packages/ai-harness/src/server.ts | 18 +++++ packages/ai-harness/src/utils.ts | 7 ++ packages/ai-harness/tests/append-op.test.ts | 8 +- packages/ai-harness/tests/auth-wait.test.ts | 5 +- packages/ai-harness/tests/auth.test.ts | 15 ++-- packages/ai-harness/tests/build.test.ts | 8 +- .../ai-harness/tests/client-reads.test.ts | 2 +- packages/ai-harness/tests/client.test.ts | 3 +- packages/ai-harness/tests/edge-safety.test.ts | 80 ++++++++++++++++++- .../ai-harness/tests/entry-points.test.ts | 73 +++++++++++++++++ packages/ai-harness/tests/first-party.test.ts | 3 +- packages/ai-harness/tests/fork.test.ts | 3 +- packages/ai-harness/tests/http-media.test.ts | 14 ++-- .../ai-harness/tests/instructions.test.ts | 2 +- packages/ai-harness/tests/media-ref.test.ts | 7 +- packages/ai-harness/tests/oauth-edges.test.ts | 12 +-- .../ai-harness/tests/oauth-loopback.test.ts | 2 +- packages/ai-harness/tests/protocol.test.ts | 7 +- .../ai-harness/tests/remote-worker.test.ts | 8 +- .../ai-harness/tests/run-route-hosts.test.ts | 3 +- packages/ai-harness/tests/run-route.test.ts | 8 +- .../ai-harness/tests/session-media.test.ts | 11 +-- .../ai-harness/tests/session-plugins.test.ts | 22 ++--- .../ai-harness/tests/system-preamble.test.ts | 8 +- packages/ai-harness/tests/tool-op.test.ts | 8 +- .../ai-harness/tests/transport-edges.test.ts | 7 +- packages/ai-harness/tests/types.test-d.ts | 2 +- .../tests/view-client-tools.test.ts | 3 +- packages/ai-harness/tests/view.test.ts | 2 +- packages/ai-harness/vite.config.ts | 4 + packages/ai-mcp/src/connector.ts | 2 +- packages/ai-mcp/src/harness.ts | 7 +- .../e2e/src/routes/api.harness-first-party.ts | 3 +- .../e2e/src/routes/api.harness-protocol.$.ts | 2 +- 47 files changed, 312 insertions(+), 191 deletions(-) create mode 100644 packages/ai-harness/src/first-party/node.ts create mode 100644 packages/ai-harness/src/media-entry.ts create mode 100644 packages/ai-harness/src/server.ts create mode 100644 packages/ai-harness/tests/entry-points.test.ts diff --git a/packages/ai-acp/src/agent/index.ts b/packages/ai-acp/src/agent/index.ts index cc43fdde84..31e07b9964 100644 --- a/packages/ai-acp/src/agent/index.ts +++ b/packages/ai-acp/src/agent/index.ts @@ -6,12 +6,8 @@ import { agent, ndJsonStream, } from '@agentclientprotocol/sdk/experimental/v2' -import { - InputRejectedError, - MediaError, - kindOf, - mediaPart, -} from '@tanstack/ai-harness' +import { InputRejectedError } from '@tanstack/ai-harness' +import { MediaError, kindOf, mediaPart } from '@tanstack/ai-harness/media' import type { AgentApp, AgentContext, diff --git a/packages/ai-acp/tests/agent-media.test.ts b/packages/ai-acp/tests/agent-media.test.ts index fe9df9b4ba..7e8b66f881 100644 --- a/packages/ai-acp/tests/agent-media.test.ts +++ b/packages/ai-acp/tests/agent-media.test.ts @@ -2,11 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { EventType } from '@tanstack/ai' import { client } from '@agentclientprotocol/sdk/experimental/v2' import { memoryPersistence } from '@tanstack/ai-persistence' -import { - createHarnessHost, - defineHarness, - mediaOfMessage, -} from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { mediaOfMessage } from '@tanstack/ai-harness/media' import { createAcpAgent } from '../src/agent' import type { AnyTextAdapter, StreamChunk } from '@tanstack/ai' import type { @@ -14,7 +11,8 @@ import type { ContentBlock, InitializeResponse, } from '@agentclientprotocol/sdk/experimental/v2' -import type { MediaOptions, MediaRecord } from '@tanstack/ai-harness' +import type { MediaRecord } from '@tanstack/ai-harness' +import type { MediaOptions } from '@tanstack/ai-harness/media' const now = () => Date.now() diff --git a/packages/ai-harness-cli/src/attach.ts b/packages/ai-harness-cli/src/attach.ts index 880689a407..f4cfec99ce 100644 --- a/packages/ai-harness-cli/src/attach.ts +++ b/packages/ai-harness-cli/src/attach.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' -import { mediaPart, mimeTypeOf } from '@tanstack/ai-harness' +import { mediaPart, mimeTypeOf } from '@tanstack/ai-harness/media' import type { ContentPart } from '@tanstack/ai' import type { HarnessSession, diff --git a/packages/ai-harness-cli/src/print.ts b/packages/ai-harness-cli/src/print.ts index 9265a931b7..9051008fd6 100644 --- a/packages/ai-harness-cli/src/print.ts +++ b/packages/ai-harness-cli/src/print.ts @@ -1,5 +1,6 @@ import { EventType } from '@tanstack/ai' -import { HARNESS_EVENTS, isMediaRecord } from '@tanstack/ai-harness' +import { HARNESS_EVENTS } from '@tanstack/ai-harness' +import { isMediaRecord } from '@tanstack/ai-harness/media' import { attach, saveMediaLine } from './attach' import type { HarnessSession } from '@tanstack/ai-harness' diff --git a/packages/ai-harness-cli/src/serve.ts b/packages/ai-harness-cli/src/serve.ts index ed8c574847..de1654b117 100644 --- a/packages/ai-harness-cli/src/serve.ts +++ b/packages/ai-harness-cli/src/serve.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' import { randomBytes, timingSafeEqual } from 'node:crypto' -import { createHarnessHandler } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { loadOptional } from './optional' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ReadableStream as NodeReadableStream } from 'node:stream/web' diff --git a/packages/ai-harness-cli/tests/attach.test.ts b/packages/ai-harness-cli/tests/attach.test.ts index 1f0aaea15b..b221ee60e0 100644 --- a/packages/ai-harness-cli/tests/attach.test.ts +++ b/packages/ai-harness-cli/tests/attach.test.ts @@ -10,11 +10,8 @@ import { it, vi, } from 'vitest' -import { - createHarnessHost, - defineHarness, - mediaIdOf, -} from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { mediaIdOf } from '@tanstack/ai-harness/media' import { memoryPersistence } from '@tanstack/ai-persistence' import { attach, defaultMediaDir, saveMedia } from '../src/attach' import type { AnyTextAdapter } from '@tanstack/ai' diff --git a/packages/ai-harness/package.json b/packages/ai-harness/package.json index 51920da03c..9940b03528 100644 --- a/packages/ai-harness/package.json +++ b/packages/ai-harness/package.json @@ -30,6 +30,18 @@ "types": "./dist/esm/index.d.ts", "import": "./dist/esm/index.js" }, + "./server": { + "types": "./dist/esm/server.d.ts", + "import": "./dist/esm/server.js" + }, + "./oauth": { + "types": "./dist/esm/oauth.d.ts", + "import": "./dist/esm/oauth.js" + }, + "./media": { + "types": "./dist/esm/media-entry.d.ts", + "import": "./dist/esm/media-entry.js" + }, "./client": { "types": "./dist/esm/client.d.ts", "import": "./dist/esm/client.js" @@ -50,6 +62,10 @@ "types": "./dist/esm/first-party/index.d.ts", "import": "./dist/esm/first-party/index.js" }, + "./plugins/node": { + "types": "./dist/esm/first-party/node.d.ts", + "import": "./dist/esm/first-party/node.js" + }, "./plugins/coding": { "types": "./dist/esm/first-party/coding/index.d.ts", "import": "./dist/esm/first-party/coding/index.js" diff --git a/packages/ai-harness/src/first-party/index.ts b/packages/ai-harness/src/first-party/index.ts index 0370211b18..6ba9ea8d37 100644 --- a/packages/ai-harness/src/first-party/index.ts +++ b/packages/ai-harness/src/first-party/index.ts @@ -1,5 +1,6 @@ -// First-party plugins. This entry is Node only: some plugins use the file -// system and the shell. +// First-party plugins. This entry is edge-safe: it loads no Node module. The +// plugins that use the file system are in the `./plugins/node` entry +// (`./node`). export { modelPicker } from './model-picker' export { PERMISSION_MODES, @@ -22,7 +23,6 @@ export type { export { globToRegExp } from './glob' export { formatTodos, todos } from './todos' export type { Todo } from './todos' -export { fileCommands, projectInstructions } from './files' export { compact, usage } from './session-tools' export { providerKeys } from './provider-keys' export type { @@ -32,8 +32,5 @@ export type { } from './provider-keys' export { GoalMet, goal, selectGoal } from './goal' export type { Goal } from './goal' -export { agents, builtInAgents } from './agents' -export type { AgentProfile } from './agents' export { question } from './question' export { TitleFailed, title } from './title' -export { boundToolOutput } from './bound-output' diff --git a/packages/ai-harness/src/first-party/node.ts b/packages/ai-harness/src/first-party/node.ts new file mode 100644 index 0000000000..bbe920b2ce --- /dev/null +++ b/packages/ai-harness/src/first-party/node.ts @@ -0,0 +1,6 @@ +// The `@tanstack/ai-harness/plugins/node` entry: first-party plugins that use +// the file system. Node only. +export { agents, builtInAgents } from './agents' +export type { AgentProfile } from './agents' +export { boundToolOutput } from './bound-output' +export { fileCommands, projectInstructions } from './files' diff --git a/packages/ai-harness/src/http.ts b/packages/ai-harness/src/http.ts index 5f544e1155..eb5dc4c8c6 100644 --- a/packages/ai-harness/src/http.ts +++ b/packages/ai-harness/src/http.ts @@ -11,7 +11,6 @@ import { import { parseRangeHeader, resolveBlobRange } from '@tanstack/ai-persistence' import { durableStoresOf, sessionOfTurn } from './host' import { MediaError } from './media' -import { base64url } from './oauth' import { HARNESS_PROTOCOL_VERSION, applyInput, @@ -20,7 +19,7 @@ import { parseControlFrame, parseHarnessInput, } from './protocol' -import { isRecord } from './utils' +import { base64url, isRecord } from './utils' import { SessionMetadataError } from './session-entry' import type { ModelMessage, diff --git a/packages/ai-harness/src/index.ts b/packages/ai-harness/src/index.ts index 7408a32e03..9c3ce47580 100644 --- a/packages/ai-harness/src/index.ts +++ b/packages/ai-harness/src/index.ts @@ -51,8 +51,8 @@ export type { } from './plugins' export { createExtensionPoint, createPluginEvent } from './extensions' -// Portable (no Node imports), so edge-safe plugins such as code mode can read -// the permission rules and resources without the Node-only `./plugins` entry. +// Portable (no Node imports), so plugins such as code mode can read the +// permission rules and resources from the root. export { PermissionDecisionCapability, PermissionResources, @@ -176,51 +176,9 @@ export type { WaitingInput, } from './types' -export { - MEDIA_URL_PREFIX, - isMediaRecord, - kindOf, - mediaIdOf, - mediaOfMessage, - mediaPart, - mimeTypeOf, -} from './media-ref' - -export { MediaError } from './media' -export type { MediaOptions } from './media' - -export { - HARNESS_PROTOCOL_VERSION, - applyInput, - capabilitiesOf, - parseControlFrame, - parseHarnessInput, -} from './protocol' -export type { ControlFrame, HostFrame } from './protocol' - -export { createHarnessHandler, handleHarnessSocket } from './http' -export type { - Authorize, - CanAccess, - HarnessHandlerOptions, - HarnessSocketOptions, -} from './http' - export { harnessText } from './harness-text' export type { HarnessTextOptions, RemoteHarness } from './harness-text' -export { - buildAuthorizationUrl, - createPkce, - deviceLogin, - exchangeCode, - isExpired, - loopbackLogin, - refreshCredential, - startLoopbackReceiver, -} from './oauth' -export type { OAuthConfig } from './oauth' - export { oauthConnector } from './connectors' export type { OAuthConnectorOptions } from './connectors' diff --git a/packages/ai-harness/src/media-entry.ts b/packages/ai-harness/src/media-entry.ts new file mode 100644 index 0000000000..85fd44aabc --- /dev/null +++ b/packages/ai-harness/src/media-entry.ts @@ -0,0 +1,14 @@ +// The `@tanstack/ai-harness/media` entry: helpers that read and build media +// references, and the media error and options. +export { + MEDIA_URL_PREFIX, + isMediaRecord, + kindOf, + mediaIdOf, + mediaOfMessage, + mediaPart, + mimeTypeOf, +} from './media-ref' + +export { MediaError } from './media' +export type { MediaOptions } from './media' diff --git a/packages/ai-harness/src/oauth.ts b/packages/ai-harness/src/oauth.ts index 19d8b31aec..cccf37f653 100644 --- a/packages/ai-harness/src/oauth.ts +++ b/packages/ai-harness/src/oauth.ts @@ -1,3 +1,4 @@ +import { base64url } from './utils' import type { Credential } from '@tanstack/ai/byok' /** An OAuth 2 app: where to send the user, and where to get tokens. */ @@ -16,13 +17,6 @@ type Fetch = typeof fetch const encoder = new TextEncoder() -/** Base64url (RFC 4648 section 5) without padding. */ -export function base64url(bytes: Uint8Array): string { - let binary = '' - for (const byte of bytes) binary += String.fromCharCode(byte) - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - function randomString(bytes = 32): string { return base64url(crypto.getRandomValues(new Uint8Array(bytes))) } diff --git a/packages/ai-harness/src/server.ts b/packages/ai-harness/src/server.ts new file mode 100644 index 0000000000..c0235d3d35 --- /dev/null +++ b/packages/ai-harness/src/server.ts @@ -0,0 +1,18 @@ +// The `@tanstack/ai-harness/server` entry: the HTTP and WebSocket transport, +// and the protocol helpers that a custom transport uses. +export { createHarnessHandler, handleHarnessSocket } from './http' +export type { + Authorize, + CanAccess, + HarnessHandlerOptions, + HarnessSocketOptions, +} from './http' + +export { + HARNESS_PROTOCOL_VERSION, + applyInput, + capabilitiesOf, + parseControlFrame, + parseHarnessInput, +} from './protocol' +export type { ControlFrame, HostFrame } from './protocol' diff --git a/packages/ai-harness/src/utils.ts b/packages/ai-harness/src/utils.ts index 533bad5e0d..ccf92bac3a 100644 --- a/packages/ai-harness/src/utils.ts +++ b/packages/ai-harness/src/utils.ts @@ -1,3 +1,10 @@ export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } + +/** Base64url (RFC 4648 section 5) without padding. */ +export function base64url(bytes: Uint8Array): string { + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} diff --git a/packages/ai-harness/tests/append-op.test.ts b/packages/ai-harness/tests/append-op.test.ts index b14dea7d9c..5cdfcd1445 100644 --- a/packages/ai-harness/tests/append-op.test.ts +++ b/packages/ai-harness/tests/append-op.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest' import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' -import { - applyInput, - createHarnessHost, - defineHarness, - parseHarnessInput, -} from '../src' +import { createHarnessHost, defineHarness } from '../src' +import { applyInput, parseHarnessInput } from '../src/server' import { messageTexts, mockAdapter, text } from './helpers' import type { ProjectOptions } from '../src' diff --git a/packages/ai-harness/tests/auth-wait.test.ts b/packages/ai-harness/tests/auth-wait.test.ts index 264bfa0023..eccf3afe19 100644 --- a/packages/ai-harness/tests/auth-wait.test.ts +++ b/packages/ai-harness/tests/auth-wait.test.ts @@ -5,15 +5,16 @@ import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' import { AuthRequiredError, HARNESS_EVENTS, - createHarnessHandler, createHarnessHost, defineCommand, defineHarness, definePlugin, oauthConnector, } from '../src' +import { createHarnessHandler } from '../src/server' import { after, gate, mockAdapter, text, toolCall } from './helpers' -import type { HarnessSession, OAuthConfig } from '../src' +import type { HarnessSession } from '../src' +import type { OAuthConfig } from '../src/oauth' import type { Reply } from './helpers' const oauth: OAuthConfig = { diff --git a/packages/ai-harness/tests/auth.test.ts b/packages/ai-harness/tests/auth.test.ts index 5891acd7a8..08184b3025 100644 --- a/packages/ai-harness/tests/auth.test.ts +++ b/packages/ai-harness/tests/auth.test.ts @@ -4,21 +4,24 @@ import { EventType, defineAgent, toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' import { HARNESS_EVENTS, - buildAuthorizationUrl, createHarnessHost, - createPkce, defineCommand, defineHarness, definePlugin, + oauthConnector, + scrubSecrets, +} from '../src' +import { + buildAuthorizationUrl, + createPkce, deviceLogin, exchangeCode, loopbackLogin, - oauthConnector, refreshCredential, - scrubSecrets, -} from '../src' +} from '../src/oauth' import { mockAdapter, text, toolCall } from './helpers' -import type { OAuthConfig, SessionEvent } from '../src' +import type { SessionEvent } from '../src' +import type { OAuthConfig } from '../src/oauth' const oauth: OAuthConfig = { authorizationUrl: 'https://auth.example/authorize', diff --git a/packages/ai-harness/tests/build.test.ts b/packages/ai-harness/tests/build.test.ts index cbdffc91e9..184a803c02 100644 --- a/packages/ai-harness/tests/build.test.ts +++ b/packages/ai-harness/tests/build.test.ts @@ -6,12 +6,8 @@ import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' import { EventType, chat } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' -import { - createHarnessHandler, - createHarnessHost, - defineHarness, - harnessText, -} from '../src' +import { createHarnessHost, defineHarness, harnessText } from '../src' +import { createHarnessHandler } from '../src/server' import { artifactText, buildHarness, readManifest } from '../src/build' import { runHarnessWorker } from '../src/worker' import { mockAdapter, text } from './helpers' diff --git a/packages/ai-harness/tests/client-reads.test.ts b/packages/ai-harness/tests/client-reads.test.ts index 27a124e780..1356cd991d 100644 --- a/packages/ai-harness/tests/client-reads.test.ts +++ b/packages/ai-harness/tests/client-reads.test.ts @@ -3,12 +3,12 @@ import { defineAgent } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' import { configOption, - createHarnessHandler, createHarnessHost, defineCommand, defineHarness, definePlugin, } from '../src' +import { createHarnessHandler } from '../src/server' import { createHarnessClient } from '../src/client' import { mockAdapter, text, toolCall } from './helpers' diff --git a/packages/ai-harness/tests/client.test.ts b/packages/ai-harness/tests/client.test.ts index 54ab37199e..89051ea81b 100644 --- a/packages/ai-harness/tests/client.test.ts +++ b/packages/ai-harness/tests/client.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { defineAgent, toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' -import { createHarnessHandler, createHarnessHost, defineHarness } from '../src' +import { createHarnessHost, defineHarness } from '../src' +import { createHarnessHandler } from '../src/server' import { createHarnessClient } from '../src/client' import { gate, messageTexts, mockAdapter, text } from './helpers' import type { SessionEvent } from '../src' diff --git a/packages/ai-harness/tests/edge-safety.test.ts b/packages/ai-harness/tests/edge-safety.test.ts index c69fcc0549..2dca47b1e6 100644 --- a/packages/ai-harness/tests/edge-safety.test.ts +++ b/packages/ai-harness/tests/edge-safety.test.ts @@ -18,8 +18,9 @@ const srcDir = resolve(here, '..', 'src') /** * Node-only entry points, relative to `src`. A path that ends with `/` allows * the whole folder. `build.ts` and `worker.ts` are the Node-only `./build` - * and `./worker` entries. `first-party/coding/` is the Node-only - * `plugins/coding` entry. + * and `./worker` entries. `first-party/files.ts` and + * `first-party/bound-output.ts` are behind the Node-only `plugins/node` entry. + * `first-party/coding/` is the Node-only `plugins/coding` entry. */ const ALLOWED: Array = [ 'build.ts', @@ -29,11 +30,24 @@ const ALLOWED: Array = [ 'first-party/coding/', ] +/** The edge-safe entry points, relative to `src`. */ +const EDGE_ENTRIES: Array = [ + 'index.ts', + 'client.ts', + 'view/index.ts', + 'server.ts', + 'media-entry.ts', + 'oauth.ts', + 'first-party/index.ts', +] + const isAllowed = (file: string) => ALLOWED.some((entry) => entry.endsWith('/') ? file.startsWith(entry) : file === entry, ) +const toPosix = (file: string) => relative(srcDir, file).split(sep).join('/') + const NODE_BUILTINS = [ 'fs', 'path', @@ -83,10 +97,44 @@ function stripComments(text: string): string { const importsNode = (source: string) => NODE_IMPORT.test(stripComments(source)) +// A relative `import … from`, `export … from`, or `import '…'` that loads with +// the module. `import type`, `export type`, and a dynamic `import('…')` do not +// match: they load nothing when the module loads. +const STATIC_RELATIVE_IMPORT = + /(?:^|\n)\s*(?:(?:import|export)\s+(?!type\s)[^'"]*?\bfrom\s*|import\s*)['"](\.{1,2}\/[^'"]+)['"]/g + +/** The relative specifiers that `source` loads statically. */ +const staticRelativeImports = (source: string) => + [...stripComments(source).matchAll(STATIC_RELATIVE_IMPORT)].flatMap( + (match) => match[1] ?? [], + ) + +/** The `src` file behind `specifier`, imported from `fromFile`. */ +function resolveImport(fromFile: string, specifier: string) { + const base = resolve(dirname(fromFile), specifier) + const file = [`${base}.ts`, join(base, 'index.ts')].find(existsSync) + if (file === undefined) { + throw new Error(`cannot resolve '${specifier}' from ${toPosix(fromFile)}`) + } + return file +} + +/** Every `src` file that `entry` loads through static relative imports. */ +function staticGraphOf(entry: string) { + const seen = new Set() + const queue = [join(srcDir, entry)] + // The loop also visits the files that it adds to `queue`. + for (const file of queue) { + if (seen.has(file)) continue + seen.add(file) + const specifiers = staticRelativeImports(readFileSync(file, 'utf8')) + queue.push(...specifiers.map((specifier) => resolveImport(file, specifier))) + } + return [...seen].map(toPosix) +} + describe('edge safety of the harness core', () => { it('imports no Node-only module outside the Node entry points', () => { - const toPosix = (file: string) => - relative(srcDir, file).split(sep).join('/') const files = collectTsFiles(srcDir) expect(files.length).toBeGreaterThan(0) @@ -102,6 +150,15 @@ describe('edge safety of the harness core', () => { const missing = ALLOWED.filter((file) => !existsSync(join(srcDir, file))) expect(missing).toEqual([]) }) + + it('loads no Node entry point from an edge-safe entry point', () => { + const reached = EDGE_ENTRIES.flatMap((entry) => + staticGraphOf(entry) + .filter(isAllowed) + .map((file) => `${entry} -> ${file}`), + ) + expect(reached).toEqual([]) + }) }) describe('edge safety check', () => { @@ -129,6 +186,21 @@ describe('edge safety check', () => { expect(importsNode(`const s = "a//b"; import fs from 'fs'`)).toBe(true) }) + it('follows only the relative imports that load with the module', () => { + const source = [ + `import { a } from './a'`, + `import {\n b,\n} from '../b'`, + `export { c } from './c'`, + `import './d'`, + `import type { E } from './e'`, + `export type { F } from './f'`, + `import { chat } from '@tanstack/ai'`, + `// import { g } from './g'`, + `async function h() { await import('./h') }`, + ].join('\n') + expect(staticRelativeImports(source)).toEqual(['./a', '../b', './c', './d']) + }) + it('ignores packages whose names start like a built-in', () => { expect(importsNode(`import { x } from 'events-polyfill'`)).toBe(false) expect(importsNode(`import { chat } from '@tanstack/ai'`)).toBe(false) diff --git a/packages/ai-harness/tests/entry-points.test.ts b/packages/ai-harness/tests/entry-points.test.ts new file mode 100644 index 0000000000..9c84a56799 --- /dev/null +++ b/packages/ai-harness/tests/entry-points.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import * as root from '../src/index' +import * as plugins from '../src/first-party/index' +import * as pluginsNode from '../src/first-party/node' +import * as media from '../src/media-entry' +import * as oauth from '../src/oauth' +import * as server from '../src/server' + +// The runtime names that each subpath holds. Types are not listed here: the +// tests that import them from a subpath fail `tsc` when one is missing. +const SERVER = [ + 'HARNESS_PROTOCOL_VERSION', + 'applyInput', + 'capabilitiesOf', + 'createHarnessHandler', + 'handleHarnessSocket', + 'parseControlFrame', + 'parseHarnessInput', +] +const OAUTH = [ + 'buildAuthorizationUrl', + 'createPkce', + 'deviceLogin', + 'exchangeCode', + 'isExpired', + 'loopbackLogin', + 'refreshCredential', + 'startLoopbackReceiver', +] +const MEDIA = [ + 'MEDIA_URL_PREFIX', + 'MediaError', + 'isMediaRecord', + 'kindOf', + 'mediaIdOf', + 'mediaOfMessage', + 'mediaPart', + 'mimeTypeOf', +] +const PLUGINS_NODE = [ + 'agents', + 'boundToolOutput', + 'builtInAgents', + 'fileCommands', + 'projectInstructions', +] + +/** The names in `names` that `module` exports. */ +const exportedFrom = (module: object, names: Array) => + names.filter((name) => name in module) + +describe('subpath entry points', () => { + it.each([ + ['server', server, SERVER], + ['oauth', oauth, OAUTH], + ['media', media, MEDIA], + ['plugins/node', pluginsNode, PLUGINS_NODE], + ])('./%s exports exactly its names', (_subpath, module, names) => { + expect(Object.keys(module).sort()).toEqual(names) + }) + + it('the root does not export a name that moved to a subpath', () => { + expect(exportedFrom(root, [...SERVER, ...OAUTH, ...MEDIA])).toEqual([]) + }) + + it('the root keeps oauthConnector, because it builds a plugin', () => { + expect(exportedFrom(root, ['oauthConnector'])).toEqual(['oauthConnector']) + }) + + it('./plugins does not export the Node plugins', () => { + expect(exportedFrom(plugins, PLUGINS_NODE)).toEqual([]) + }) +}) diff --git a/packages/ai-harness/tests/first-party.test.ts b/packages/ai-harness/tests/first-party.test.ts index e213680260..87f073bfab 100644 --- a/packages/ai-harness/tests/first-party.test.ts +++ b/packages/ai-harness/tests/first-party.test.ts @@ -7,14 +7,13 @@ import { createHarnessHost, defineHarness } from '../src' import { compact, decidePermission, - fileCommands, globToRegExp, modelPicker, permissions, - projectInstructions, todos, usage, } from '../src/first-party' +import { fileCommands, projectInstructions } from '../src/first-party/node' import { workspaceTools } from '../src/first-party/coding' import { messageTexts, mockAdapter, text, toolCall } from './helpers' import type { HarnessPlugin } from '../src' diff --git a/packages/ai-harness/tests/fork.test.ts b/packages/ai-harness/tests/fork.test.ts index 4164109977..6b85271777 100644 --- a/packages/ai-harness/tests/fork.test.ts +++ b/packages/ai-harness/tests/fork.test.ts @@ -7,9 +7,8 @@ import { createHarnessHost, defineHarness, definePlugin, - mediaIdOf, - mediaPart, } from '../src' +import { mediaIdOf, mediaPart } from '../src/media-entry' import { messageTexts, mockAdapter, text, toolCall } from './helpers' import type { ModelMessage } from '@tanstack/ai' import type { HarnessPersistence } from '../src' diff --git a/packages/ai-harness/tests/http-media.test.ts b/packages/ai-harness/tests/http-media.test.ts index ac280484d8..16cc40742b 100644 --- a/packages/ai-harness/tests/http-media.test.ts +++ b/packages/ai-harness/tests/http-media.test.ts @@ -1,15 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' -import { - createHarnessHandler, - createHarnessHost, - defineHarness, - isMediaRecord, - mediaPart, -} from '../src' +import { createHarnessHost, defineHarness } from '../src' +import { createHarnessHandler } from '../src/server' +import { isMediaRecord, mediaPart } from '../src/media-entry' import { mockAdapter, text } from './helpers' import type { ContentPart } from '@tanstack/ai' -import type { Authorize, MediaOptions, MediaRecord } from '../src' +import type { MediaRecord } from '../src' +import type { Authorize } from '../src/server' +import type { MediaOptions } from '../src/media-entry' const BASE = 'http://x/api/harness' const HOUR = 60 * 60 * 1000 diff --git a/packages/ai-harness/tests/instructions.test.ts b/packages/ai-harness/tests/instructions.test.ts index 235b3871c0..e093707967 100644 --- a/packages/ai-harness/tests/instructions.test.ts +++ b/packages/ai-harness/tests/instructions.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { memoryPersistence } from '@tanstack/ai-persistence' import { createHarnessHost, defineHarness } from '../src' -import { projectInstructions } from '../src/first-party' +import { projectInstructions } from '../src/first-party/node' import { workspaceTools } from '../src/first-party/coding' import { mockAdapter, text, toolCall } from './helpers' import type { AnyTextAdapter, ModelMessage } from '@tanstack/ai' diff --git a/packages/ai-harness/tests/media-ref.test.ts b/packages/ai-harness/tests/media-ref.test.ts index 48e77d22da..adc9c4401c 100644 --- a/packages/ai-harness/tests/media-ref.test.ts +++ b/packages/ai-harness/tests/media-ref.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { kindOf, mediaIdOf, mediaOfMessage, mediaPart } from '../src' +import { + kindOf, + mediaIdOf, + mediaOfMessage, + mediaPart, +} from '../src/media-entry' import type { ModelMessage } from '@tanstack/ai' import type { MediaKind, MediaRecord } from '../src' diff --git a/packages/ai-harness/tests/oauth-edges.test.ts b/packages/ai-harness/tests/oauth-edges.test.ts index 39c3ddd012..0a6bd795ec 100644 --- a/packages/ai-harness/tests/oauth-edges.test.ts +++ b/packages/ai-harness/tests/oauth-edges.test.ts @@ -4,19 +4,21 @@ import { toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' import { AuthRequiredError, - buildAuthorizationUrl, createHarnessHost, defineHarness, + oauthConnector, + scrubSecrets, +} from '../src' +import { + buildAuthorizationUrl, deviceLogin, exchangeCode, isExpired, loopbackLogin, - oauthConnector, refreshCredential, - scrubSecrets, -} from '../src' +} from '../src/oauth' import { mockAdapter, text, toolCall } from './helpers' -import type { OAuthConfig } from '../src' +import type { OAuthConfig } from '../src/oauth' const oauth: OAuthConfig = { authorizationUrl: 'https://auth.example/authorize', diff --git a/packages/ai-harness/tests/oauth-loopback.test.ts b/packages/ai-harness/tests/oauth-loopback.test.ts index 23b4e03dbe..ef4670d907 100644 --- a/packages/ai-harness/tests/oauth-loopback.test.ts +++ b/packages/ai-harness/tests/oauth-loopback.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { startLoopbackReceiver } from '../src' +import { startLoopbackReceiver } from '../src/oauth' describe('startLoopbackReceiver', () => { it('returns the code for the matching state and ignores other paths', async () => { diff --git a/packages/ai-harness/tests/protocol.test.ts b/packages/ai-harness/tests/protocol.test.ts index 7532aa3e8c..b44ec5fe53 100644 --- a/packages/ai-harness/tests/protocol.test.ts +++ b/packages/ai-harness/tests/protocol.test.ts @@ -2,17 +2,16 @@ 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 { createHarnessHandler, - createHarnessHost, - defineHarness, handleHarnessSocket, parseControlFrame, parseHarnessInput, -} from '../src' +} from '../src/server' import { after, gate, mockAdapter, text, wakeMessages } from './helpers' import type { WebSocketLike } from '@tanstack/ai' -import type { HostFrame } from '../src' +import type { HostFrame } from '../src/server' import type { Reply } from './helpers' const pricer = defineAgent({ diff --git a/packages/ai-harness/tests/remote-worker.test.ts b/packages/ai-harness/tests/remote-worker.test.ts index 7e174b66ed..1898d41b76 100644 --- a/packages/ai-harness/tests/remote-worker.test.ts +++ b/packages/ai-harness/tests/remote-worker.test.ts @@ -2,12 +2,8 @@ import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import { EventType, chat } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' -import { - createHarnessHandler, - createHarnessHost, - defineHarness, - harnessText, -} from '../src' +import { createHarnessHost, defineHarness, harnessText } from '../src' +import { createHarnessHandler } from '../src/server' import { runHarnessWorker } from '../src/worker' import { mockAdapter, text } from './helpers' import type { StreamChunk } from '@tanstack/ai' diff --git a/packages/ai-harness/tests/run-route-hosts.test.ts b/packages/ai-harness/tests/run-route-hosts.test.ts index 8bf86d4b59..f18fea2302 100644 --- a/packages/ai-harness/tests/run-route-hosts.test.ts +++ b/packages/ai-harness/tests/run-route-hosts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' -import { createHarnessHandler, createHarnessHost, defineHarness } from '../src' +import { createHarnessHost, defineHarness } from '../src' +import { createHarnessHandler } from '../src/server' import { gate, mockAdapter, text } from './helpers' import type { Reply } from './helpers' diff --git a/packages/ai-harness/tests/run-route.test.ts b/packages/ai-harness/tests/run-route.test.ts index 4266c06d99..1a8388d31f 100644 --- a/packages/ai-harness/tests/run-route.test.ts +++ b/packages/ai-harness/tests/run-route.test.ts @@ -3,12 +3,8 @@ import { z } from 'zod' import { readInterruptBinding, toolDefinition } from '@tanstack/ai' import { fakeText } from '@tanstack/ai/testing' import { memoryPersistence } from '@tanstack/ai-persistence' -import { - createHarnessHandler, - createHarnessHost, - defineHarness, - definePlugin, -} from '../src' +import { createHarnessHost, defineHarness, definePlugin } from '../src' +import { createHarnessHandler } from '../src/server' import { after, gate, mockAdapter, text, toolCall } from './helpers' import type { Interrupt } from '@tanstack/ai' import type { FakeResponse } from '@tanstack/ai/testing' diff --git a/packages/ai-harness/tests/session-media.test.ts b/packages/ai-harness/tests/session-media.test.ts index 361d54e6ba..2531ea41be 100644 --- a/packages/ai-harness/tests/session-media.test.ts +++ b/packages/ai-harness/tests/session-media.test.ts @@ -1,13 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { EventType, defineAgent } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' -import { - HARNESS_EVENTS, - MediaError, - createHarnessHost, - defineHarness, - mediaPart, -} from '../src' +import { HARNESS_EVENTS, createHarnessHost, defineHarness } from '../src' +import { MediaError, mediaPart } from '../src/media-entry' import { mockAdapter, text, toolCall } from './helpers' import type { AnyTextAdapter, @@ -19,11 +14,11 @@ import type { AnyHarness, HarnessPersistence, MediaKind, - MediaOptions, MediaRecord, SessionEvent, UserInput, } from '../src' +import type { MediaOptions } from '../src/media-entry' const encoder = new TextEncoder() const decoder = new TextDecoder() diff --git a/packages/ai-harness/tests/session-plugins.test.ts b/packages/ai-harness/tests/session-plugins.test.ts index 5d6d4d5fa6..97e300eefe 100644 --- a/packages/ai-harness/tests/session-plugins.test.ts +++ b/packages/ai-harness/tests/session-plugins.test.ts @@ -14,14 +14,8 @@ import { defineHarness, definePlugin, } from '../src' -import { - compact, - fileCommands, - modelPicker, - projectInstructions, - todos, - usage, -} from '../src/first-party' +import { compact, modelPicker, todos, usage } from '../src/first-party' +import { fileCommands, projectInstructions } from '../src/first-party/node' import { gate, mockAdapter, text, toolCall } from './helpers' import type { PluginState, SessionEvent } from '../src' @@ -167,6 +161,12 @@ describe('commands', () => { await release.opened await waiting.cancel() await waiting.then( + () => undefined, + () => undefined, + ) + expect(waiting.status()).toBe('cancelled') + await host.close() + }) it('gives a command without a schema the input as sent, typed unknown', async () => { // Defined on their own, with no contextual type from a plugin's `setup`. @@ -198,12 +198,6 @@ describe('commands', () => { expect(await session.command('shout', 'hi')).toBe('HI') expect(await session.command('shout', { text: 'hi' })).toBe('no text') await host.close() - }) - () => undefined, - () => undefined, - ) - expect(waiting.status()).toBe('cancelled') - await host.close() }) }) diff --git a/packages/ai-harness/tests/system-preamble.test.ts b/packages/ai-harness/tests/system-preamble.test.ts index 341a2af92b..dc65e40459 100644 --- a/packages/ai-harness/tests/system-preamble.test.ts +++ b/packages/ai-harness/tests/system-preamble.test.ts @@ -2,12 +2,8 @@ import { describe, expect, it } from 'vitest' import { z } from 'zod' import { toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' -import { - applyInput, - createHarnessHost, - defineHarness, - parseHarnessInput, -} from '../src' +import { createHarnessHost, defineHarness } from '../src' +import { applyInput, parseHarnessInput } from '../src/server' import { mockAdapter, text, toolCall } from './helpers' /** A tool that records the execution context it was called with. */ diff --git a/packages/ai-harness/tests/tool-op.test.ts b/packages/ai-harness/tests/tool-op.test.ts index 66954820ef..cbb9de5550 100644 --- a/packages/ai-harness/tests/tool-op.test.ts +++ b/packages/ai-harness/tests/tool-op.test.ts @@ -2,12 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' -import { - applyInput, - capabilitiesOf, - createHarnessHost, - defineHarness, -} from '../src' +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 type { HarnessPlugin } from '../src' diff --git a/packages/ai-harness/tests/transport-edges.test.ts b/packages/ai-harness/tests/transport-edges.test.ts index 573831a198..d69092663e 100644 --- a/packages/ai-harness/tests/transport-edges.test.ts +++ b/packages/ai-harness/tests/transport-edges.test.ts @@ -2,20 +2,19 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { defineAgent, toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' +import { createHarnessHost, defineHarness } from '../src' import { applyInput, capabilitiesOf, createHarnessHandler, - createHarnessHost, - defineHarness, handleHarnessSocket, parseControlFrame, parseHarnessInput, -} from '../src' +} from '../src/server' import { createHarnessClient } from '../src/client' import { gate, mockAdapter, text, toolCall, untilAborted } from './helpers' import type { WebSocketLike } from '@tanstack/ai' -import type { HostFrame } from '../src' +import type { HostFrame } from '../src/server' const pricer = defineAgent({ name: 'pricer', diff --git a/packages/ai-harness/tests/types.test-d.ts b/packages/ai-harness/tests/types.test-d.ts index 6052ef5a5c..74b47f64c9 100644 --- a/packages/ai-harness/tests/types.test-d.ts +++ b/packages/ai-harness/tests/types.test-d.ts @@ -2,12 +2,12 @@ import { expectTypeOf, it } from 'vitest' import { z } from 'zod' import { defineAgent } from '@tanstack/ai' import { - createHarnessHandler, createHarnessHost, defineHarness, definePlugin, oauthConnector, } from '../src' +import { createHarnessHandler } from '../src/server' import { mockAdapter } from './helpers' import type { AnyTextAdapter, KeyedAdapter } from '@tanstack/ai' import type { SessionIndexEntry } from '@tanstack/ai-persistence' diff --git a/packages/ai-harness/tests/view-client-tools.test.ts b/packages/ai-harness/tests/view-client-tools.test.ts index 9522b91806..4680f7e84d 100644 --- a/packages/ai-harness/tests/view-client-tools.test.ts +++ b/packages/ai-harness/tests/view-client-tools.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' import { z } from 'zod' -import { createHarnessHandler, createHarnessHost, defineHarness } from '../src' +import { createHarnessHost, defineHarness } from '../src' +import { createHarnessHandler } from '../src/server' import { createHarnessClient } from '../src/client' import { createSessionView } from '../src/view' import { mockAdapter, text, toolCall } from './helpers' diff --git a/packages/ai-harness/tests/view.test.ts b/packages/ai-harness/tests/view.test.ts index 0529263725..f8efbadb1d 100644 --- a/packages/ai-harness/tests/view.test.ts +++ b/packages/ai-harness/tests/view.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it, vi } from 'vitest' import { defineAgent, toolDefinition } from '@tanstack/ai' import { memoryPersistence } from '@tanstack/ai-persistence' import { - createHarnessHandler, createHarnessHost, createPluginEvent, defineCommand, defineHarness, definePlugin, } from '../src' +import { createHarnessHandler } from '../src/server' import { createHarnessClient } from '../src/client' import { createSessionView } from '../src/view' import { after, gate, mockAdapter, text, toolCall } from './helpers' diff --git a/packages/ai-harness/vite.config.ts b/packages/ai-harness/vite.config.ts index 52524e5591..bd8fed4bb1 100644 --- a/packages/ai-harness/vite.config.ts +++ b/packages/ai-harness/vite.config.ts @@ -30,11 +30,15 @@ export default mergeConfig( tanstackViteConfig({ entry: [ './src/index.ts', + './src/server.ts', + './src/oauth.ts', + './src/media-entry.ts', './src/client.ts', './src/view/index.ts', './src/build.ts', './src/worker.ts', './src/first-party/index.ts', + './src/first-party/node.ts', './src/first-party/coding/index.ts', ], srcDir: './src', diff --git a/packages/ai-mcp/src/connector.ts b/packages/ai-mcp/src/connector.ts index 879e6a49a9..4a80a62d2f 100644 --- a/packages/ai-mcp/src/connector.ts +++ b/packages/ai-mcp/src/connector.ts @@ -4,8 +4,8 @@ import { AuthRequiredError, defineCommand, definePlugin, - startLoopbackReceiver, } from '@tanstack/ai-harness' +import { startLoopbackReceiver } from '@tanstack/ai-harness/oauth' import { connectTransport } from './client' import { MCPConnectionError } from './errors' import { askForInput, elicitFor, harnessClientOptions } from './harness-plugin' diff --git a/packages/ai-mcp/src/harness.ts b/packages/ai-mcp/src/harness.ts index 1cef5db9a1..82314c80fc 100644 --- a/packages/ai-mcp/src/harness.ts +++ b/packages/ai-mcp/src/harness.ts @@ -4,15 +4,14 @@ import { readUnopenedInterruptBinding, toolDefinition, } from '@tanstack/ai' +import { HARNESS_EVENTS } from '@tanstack/ai-harness' import { - HARNESS_EVENTS, - applyInput, isMediaRecord, kindOf, mediaPart, mimeTypeOf, - parseHarnessInput, -} from '@tanstack/ai-harness' +} from '@tanstack/ai-harness/media' +import { applyInput, parseHarnessInput } from '@tanstack/ai-harness/server' // The inferred return type names `MCPHandleOptions`. Without an import of // `./server/index`, the .d.ts emit writes `./server.js`, which does not resolve. import { createMCPServer, resourceDefinition } from './server/index' diff --git a/testing/e2e/src/routes/api.harness-first-party.ts b/testing/e2e/src/routes/api.harness-first-party.ts index 6c685733c4..bad390726e 100644 --- a/testing/e2e/src/routes/api.harness-first-party.ts +++ b/testing/e2e/src/routes/api.harness-first-party.ts @@ -13,14 +13,13 @@ import { } from '@tanstack/ai-harness' import { TitleFailed, - agents, goal, permissions, - projectInstructions, providerKeys, selectGoal, title, } from '@tanstack/ai-harness/plugins' +import { agents, projectInstructions } from '@tanstack/ai-harness/plugins/node' import { createOpenaiChat } from '@tanstack/ai-openai' import { openaiByok } from '@tanstack/ai-openai/byok' import { memoryPersistence } from '@tanstack/ai-persistence' diff --git a/testing/e2e/src/routes/api.harness-protocol.$.ts b/testing/e2e/src/routes/api.harness-protocol.$.ts index ecb2c676ca..9da500a40c 100644 --- a/testing/e2e/src/routes/api.harness-protocol.$.ts +++ b/testing/e2e/src/routes/api.harness-protocol.$.ts @@ -5,12 +5,12 @@ import { type FinishContext, retryTransientErrors, configOption, - createHarnessHandler, createHarnessHost, defineCommand, defineHarness, definePlugin, } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { mcp } from '@tanstack/ai-mcp/harness' import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' import { z } from 'zod' From bb9768adf289041e369c3410f69f56b5bcd0fb4a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 16 Oct 2026 18:32:03 +0200 Subject: [PATCH 05/11] refactor(ai-harness): HarnessSession as a type over a private class, whenIdle, and harnessOf --- packages/ai-harness/src/agents.ts | 32 +++- packages/ai-harness/src/host.ts | 152 ++++++++++-------- packages/ai-harness/src/index.ts | 3 +- packages/ai-harness/src/session/base.ts | 30 ++++ .../ai-harness/src/session/harness-session.ts | 18 ++- packages/ai-harness/src/session/index.ts | 2 +- packages/ai-harness/src/session/state.ts | 4 +- packages/ai-harness/src/session/types.ts | 14 +- .../ai-harness/tests/agent-resume.test.ts | 4 +- packages/ai-harness/tests/agent-runs.test.ts | 8 +- .../ai-harness/tests/session-surface.test.ts | 107 ++++++++++++ 11 files changed, 283 insertions(+), 91 deletions(-) create mode 100644 packages/ai-harness/tests/session-surface.test.ts diff --git a/packages/ai-harness/src/agents.ts b/packages/ai-harness/src/agents.ts index 681d717c2d..f294f55f5d 100644 --- a/packages/ai-harness/src/agents.ts +++ b/packages/ai-harness/src/agents.ts @@ -160,16 +160,42 @@ export const NO_SESSION_CONTEXT: HarnessAgentContext = { }, } +/** + * The harness context of an agent run: `ctx.harness`. Use it in the `run` of + * an agent that only a harness session runs. + * + * @param ctx The context of the agent run. + * @throws TypeError when `ctx` has no `harness`: a plain `chat()` started + * the run, not a harness session. + * + * @example + * ```ts + * const lead = defineAgent({ + * name: 'lead', + * description: 'Starts a helper', + * run: async (ctx) => await harnessOf(ctx).agents.start('helper'), + * }) + * ``` + */ +export function harnessOf(ctx: { harness?: HarnessAgentContext }) { + if (ctx.harness === undefined) { + throw new TypeError( + 'harnessOf: this agent runs outside a harness session. ctx.harness exists only in an agent run that a harness session started.', + ) + } + return ctx.harness +} + declare module '@tanstack/ai' { /** * `ctx.harness` in the `run` of an agent: `step` and `agents`. A harness * session gives them to each agent run it starts from code (a session * agent, `ctx.harness.agents.start`, a background child), and to the * children those runs start through `ctx.chat({ subagents })`. A run - * that a plain `chat()` starts outside a harness has no `harness` at - * run time: such an agent reads `ctx.harness` only inside a harness. + * that a plain `chat()` starts has no `harness`. Read it with + * `harnessOf(ctx)`, which throws when it is missing. */ interface SubagentContextExtension { - harness: HarnessAgentContext + harness?: HarnessAgentContext } } diff --git a/packages/ai-harness/src/host.ts b/packages/ai-harness/src/host.ts index 9f2558e6ca..ff278ea41a 100644 --- a/packages/ai-harness/src/host.ts +++ b/packages/ai-harness/src/host.ts @@ -4,7 +4,7 @@ import { memoryPersistence } from '@tanstack/ai-persistence' import type { CredentialStore } from '@tanstack/ai/byok' import { SharedLog, loadLogState } from './log' import { LEASE } from './resume' -import { HarnessSession } from './session/index' +import { SessionImpl } from './session/harness-session' import { HARNESS_EVENTS } from './types' import { isRecord } from './utils' import { changedEntry } from './session-entry' @@ -30,6 +30,7 @@ import type { AnyHarness, SessionMetadataOf } from './define' import type { SessionEntryChange, SessionEntryUpdate } from './session-entry' import type { ProjectOptions, ReduceOptions } from './log' import type { LeaseOptions } from './resume' +import type { HarnessSession } from './session/index' import type { ForkPoint, Principal } from './types' type SharedStores = { @@ -676,7 +677,7 @@ export function createHarnessHost( logs.set(logId, loaded) return loaded } - const sessions = new Map>() + const sessions = new Map>() /** * The keys of sessions that only a `resumePending` sweep opened. With * `close: 'whenIdle'`, the sweep closes only these. An open by the app @@ -687,75 +688,84 @@ export function createHarnessHost( const closing = new Map>() const hostId = `host-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}` - const host: HarnessHost = { - open(harness, { threadId, principal, logId: openLogId, promptCache }) { - const key = `${harness.name}\u0000${threadId}` - let session = sessions.get(key) - // The app opened a thread that a sweep opened: it stays open. - if (session) sweptOnly.delete(key) - if (!session) { - const logId = openLogId ?? threadId - const created = new HarnessSession({ - harness, - threadId, - logId, - persistence, - inbox, - credentials, - media, - hostId, - index, - ...(logStore - ? { - log: { - store: logStore, - ...(options.project ? { project: options.project } : {}), - open: async (sessionThread, onFailure) => { - let log = await sharedLog(logStore, logId) - // The log closed while this open waited, and the host - // dropped it. - while (!log.isOpen) log = await sharedLog(logStore, logId) - return log.view(sessionThread, onFailure) - }, + /** + * `host.open`. It gives the session class, so the host can call the + * members that the `HarnessSession` type does not have. + */ + const openSession = ( + harness: THarness, + { threadId, principal, logId: openLogId, promptCache }: OpenSessionOptions, + ) => { + const key = `${harness.name}\u0000${threadId}` + let session = sessions.get(key) + // The app opened a thread that a sweep opened: it stays open. + if (session) sweptOnly.delete(key) + if (!session) { + const logId = openLogId ?? threadId + const created = new SessionImpl({ + harness, + threadId, + logId, + persistence, + inbox, + credentials, + media, + hostId, + index, + ...(logStore + ? { + log: { + store: logStore, + ...(options.project ? { project: options.project } : {}), + open: async (sessionThread, onFailure) => { + let log = await sharedLog(logStore, logId) + // The log closed while this open waited, and the host + // dropped it. + while (!log.isOpen) log = await sharedLog(logStore, logId) + return log.view(sessionThread, onFailure) }, - } - : {}), - ...(options.lease ? { lease: options.lease } : {}), - ...(principal ? { principal } : {}), - ...(promptCache ? { promptCache } : {}), - onEvent: (event) => { - const status = statusAfter(created, event) - if (status) setStatus(key, threadId, status) + }, + } + : {}), + ...(options.lease ? { lease: options.lease } : {}), + ...(principal ? { principal } : {}), + ...(promptCache ? { promptCache } : {}), + onEvent: (event) => { + const status = statusAfter(created, event) + if (status) setStatus(key, threadId, status) + }, + onClose: () => { + // A sweep may have put a new session under this key already. It + // opens only after this one closed, so its status is not set yet. + if (sessions.get(key) === session) sessions.delete(key) + statuses.delete(key) + }, + }) + // A swept session under this key may still close: it lets go of + // the thread first. + const closed = closing.get(key) ?? Promise.resolve() + session = closed + .then(() => created.open()) + .then( + () => { + // A recovered turn can run already. Else the new session is + // idle. + if (!statuses.has(key)) setStatus(key, threadId, 'idle') + return created }, - onClose: () => { - // A sweep may have put a new session under this key already. It - // opens only after this one closed, so its status is not set yet. - if (sessions.get(key) === session) sessions.delete(key) + (error: unknown) => { + sessions.delete(key) statuses.delete(key) + throw error }, - }) - // A swept session under this key may still close: it lets go of - // the thread first. - const closed = closing.get(key) ?? Promise.resolve() - session = closed - .then(() => created.open()) - .then( - () => { - // A recovered turn can run already. Else the new session is - // idle. - if (!statuses.has(key)) setStatus(key, threadId, 'idle') - return created - }, - (error: unknown) => { - sessions.delete(key) - statuses.delete(key) - throw error - }, - ) - sessions.set(key, session) - } - return session as Promise> - }, + ) + sessions.set(key, session) + } + return session as Promise> + } + + const host: HarnessHost = { + open: openSession, isOpen(harness, { threadId }) { return sessions.has(`${harness.name}\u0000${threadId}`) }, @@ -765,7 +775,7 @@ export function createHarnessHost( } // A source that only this fork opens closes again at the end. const wasOpen = host.isOpen(harness, { threadId }) - const source = await host.open(harness, { + const source = await openSession(harness, { threadId, ...(logId ? { logId } : {}), }) @@ -791,7 +801,7 @@ export function createHarnessHost( `Thread ${JSON.stringify(newThreadId)} is not empty. Fork into a new thread.`, ) } - const fork = await host.open(harness, { + const fork = await openSession(harness, { threadId: newThreadId, ...(principal ? { principal } : {}), }) @@ -847,9 +857,9 @@ export function createHarnessHost( // that the app has open stays open. const isOwn = close === 'whenIdle' && !sessions.has(key) if (isOwn) sweptOnly.add(key) - let session: HarnessSession + let session: SessionImpl try { - session = await host.open(harness, { threadId }) + session = await openSession(harness, { threadId }) } catch (error) { // One thread that cannot open must not stop the sweep. The claim // goes back, so the next sweep tries again. diff --git a/packages/ai-harness/src/index.ts b/packages/ai-harness/src/index.ts index 9c3ce47580..f3abcbef66 100644 --- a/packages/ai-harness/src/index.ts +++ b/packages/ai-harness/src/index.ts @@ -84,6 +84,7 @@ export type { export { AuthRequiredError, scrubSecrets } from './auth' export type { CredentialsAccess } from './auth' +export { harnessOf } from './agents' export type { AgentInputOf, AgentRegistryView, @@ -114,8 +115,8 @@ export type { SessionIndexStore, } from '@tanstack/ai-persistence' -export { HarnessSession } from './session/index' export type { + HarnessSession, AgentHandle, AgentHandles, AgentRunInfo, diff --git a/packages/ai-harness/src/session/base.ts b/packages/ai-harness/src/session/base.ts index c9ec860e05..9d6322e5fa 100644 --- a/packages/ai-harness/src/session/base.ts +++ b/packages/ai-harness/src/session/base.ts @@ -1040,6 +1040,36 @@ 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 at once. When `signal` aborts first, it rejects with the reason + * of `signal`. + * + * @example + * ```ts + * session.send('Write a haiku about the sea.') + * await session.whenIdle({ signal: AbortSignal.timeout(60_000) }) + * ``` + */ + whenIdle({ signal }: { signal?: AbortSignal } = {}) { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(signal.reason) + if (this.isIdle()) return resolve() + const onAbort = () => { + stop() + reject(signal?.reason) + } + const stop = this.onIdle(() => { + stop() + signal?.removeEventListener('abort', onAbort) + resolve() + }) + signal?.addEventListener('abort', onAbort, { once: true }) + }) + } + protected warn( operation: OperationImpl | OperationImpl, plugin: string, diff --git a/packages/ai-harness/src/session/harness-session.ts b/packages/ai-harness/src/session/harness-session.ts index 05eb724123..507e1e8305 100644 --- a/packages/ai-harness/src/session/harness-session.ts +++ b/packages/ai-harness/src/session/harness-session.ts @@ -35,10 +35,22 @@ import type { WaitingSteer } from './types' * A live harness session: one conversation (`threadId`) with its plugins, * operations, inbox, and event stream. Open one with `host.open()`. */ -export class HarnessSession< +export interface HarnessSession< + THarness extends AnyHarness = AnyHarness, +> extends Omit< + SessionImpl, + // The public members that only the host calls. + 'open' | 'adoptFork' | 'onIdle' +> {} + +/** + * The session class. The package does not export it: apps get the + * {@link HarnessSession} type from `host.open()`. + */ +export class SessionImpl< THarness extends AnyHarness = AnyHarness, > extends SessionTurn { - /** This session, typed as the full class, for the hooks of each layer. */ + /** This session, typed as the public type, for the hooks of each layer. */ protected override self(): HarnessSession { return this } @@ -383,7 +395,7 @@ export class HarnessSession< const recover = this.harness.durability?.recover if (!recover) return decision const answer = await recover({ - session: this, + session: this.self(), input: { inputId: input.inputId, input: input.input, diff --git a/packages/ai-harness/src/session/index.ts b/packages/ai-harness/src/session/index.ts index 7f2ced8b79..84ac81623e 100644 --- a/packages/ai-harness/src/session/index.ts +++ b/packages/ai-harness/src/session/index.ts @@ -1,4 +1,4 @@ -export { HarnessSession } from './harness-session' +export type { HarnessSession } from './harness-session' export { DEFAULT_SUBAGENT_LIMITS, acceptedKinds } from './helpers' export type { AgentRunOptions, diff --git a/packages/ai-harness/src/session/state.ts b/packages/ai-harness/src/session/state.ts index 297d076fb8..2c3ac5750f 100644 --- a/packages/ai-harness/src/session/state.ts +++ b/packages/ai-harness/src/session/state.ts @@ -63,7 +63,7 @@ import type { InterruptedTurn, } from './types' import type { SettingNames } from './helpers' -import type { HarnessSession } from './harness-session' +import type { HarnessSession, SessionImpl } from './harness-session' /** * A layer of {@link HarnessSession}: config, thread settings, revert, @@ -192,7 +192,7 @@ export abstract class SessionState< * settings and plugin config of `source`. `host.fork` calls it. */ async adoptFork( - source: HarnessSession, + source: SessionImpl, messages: ReadonlyArray, ): Promise { // A media id belongs to one thread, so each file gets a copy here. diff --git a/packages/ai-harness/src/session/types.ts b/packages/ai-harness/src/session/types.ts index 19991b250c..75268fda0f 100644 --- a/packages/ai-harness/src/session/types.ts +++ b/packages/ai-harness/src/session/types.ts @@ -25,7 +25,7 @@ import type { SessionUsage } from '../usage' import type { AgentInputOf, AgentResultOf, AnyAgent } from '../agents' import type { AnyHarness, HarnessAgentsOf } from '../define' import type { HarnessPersistence, SessionIndexWriter } from '../host' -import type { MountedPlugins } from '../plugins' +import type { PluginLifetime } from '../plugins' import type { AgentRun, ChatTurnResult, @@ -163,9 +163,15 @@ export interface SessionSnapshot { /** The resolved plugin plan of a session, for debugging and tooling. */ export interface SessionInspection { - plugins: MountedPlugins['owners']['plugins'] - tools: MountedPlugins['owners']['tools'] - prompts: MountedPlugins['owners']['prompts'] + /** Each plugin, in mount order, with the capability names it uses. */ + plugins: Array<{ + name: string + lifetime: PluginLifetime + requires: Array + provides: Array + }> + tools: Array<{ name: string; owner: string }> + prompts: Array<{ id: string; owner: string }> commands: Array<{ name: string; owner: string }> config: Array<{ key: string; owner: string }> extensionPoints: Record> diff --git a/packages/ai-harness/tests/agent-resume.test.ts b/packages/ai-harness/tests/agent-resume.test.ts index 9221a0f750..0ac63787e9 100644 --- a/packages/ai-harness/tests/agent-resume.test.ts +++ b/packages/ai-harness/tests/agent-resume.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import { defineAgent, toolDefinition } from '@tanstack/ai' import { memoryLogStore, memoryPersistence } from '@tanstack/ai-persistence' -import { createHarnessHost, defineHarness } from '../src' +import { createHarnessHost, defineHarness, harnessOf } from '../src' import { messageTexts, mockAdapter, @@ -47,7 +47,7 @@ async function stopDuringAgent(options: { name: 'worker', description: 'Works', run: async (ctx) => { - await ctx.harness.step.do('reserve', () => { + await harnessOf(ctx).step.do('reserve', () => { effects.push('reserve') return 'R-1' }) diff --git a/packages/ai-harness/tests/agent-runs.test.ts b/packages/ai-harness/tests/agent-runs.test.ts index e271a357e7..25c7d91883 100644 --- a/packages/ai-harness/tests/agent-runs.test.ts +++ b/packages/ai-harness/tests/agent-runs.test.ts @@ -6,7 +6,7 @@ import { memoryLogStore, memoryPersistence, } from '@tanstack/ai-persistence' -import { createHarnessHost, defineHarness } from '../src' +import { createHarnessHost, defineHarness, harnessOf } from '../src' import { after, gate, @@ -434,7 +434,7 @@ describe('agents that agent code starts', () => { name: 'lead', description: 'Starts a helper', run: async (ctx) => { - const child = ctx.harness.agents.start('helper') + const child = harnessOf(ctx).agents.start('helper') childId = child.id return await child }, @@ -474,7 +474,7 @@ describe('agents that agent code starts', () => { description: 'Starts a helper', run: async (ctx) => { try { - ctx.harness.agents.start('helper') + harnessOf(ctx).agents.start('helper') return 'started' } catch (error) { return String(error) @@ -517,7 +517,7 @@ describe('agents that agent code starts', () => { name: 'lead', description: 'Starts a waiter, then waits', run: (ctx) => { - void ctx.harness.agents.start('waiter') + void harnessOf(ctx).agents.start('waiter') return waitForAbort('lead', ctx.abortSignal) }, }) diff --git a/packages/ai-harness/tests/session-surface.test.ts b/packages/ai-harness/tests/session-surface.test.ts new file mode 100644 index 0000000000..9c6df5b051 --- /dev/null +++ b/packages/ai-harness/tests/session-surface.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { defineAgent } from '@tanstack/ai' +import { memoryPersistence } from '@tanstack/ai-persistence' +import { createHarnessHost, defineHarness, harnessOf } from '../src' +import { after, gate, mockAdapter } from './helpers' +import type { Reply } from './helpers' + +/** Open a session whose model calls answer with `replies`. */ +async function open(replies: Array = []) { + const { adapter } = mockAdapter(replies) + const host = createHarnessHost({ persistence: memoryPersistence() }) + const session = await host.open( + defineHarness({ name: 'test/session-surface', adapter }), + { threadId: 't1' }, + ) + return { host, session } +} + +/** A session with one turn that runs until `release` opens. */ +async function openBusy() { + const release = gate() + const { host, session } = await open([after(release.opened, 'Done.')]) + const turn = session.send('Hello.') + await turn.receipt + return { host, session, turn, release } +} + +describe('session.whenIdle', () => { + it('resolves on an idle session', async () => { + const { host, session } = await open() + + await expect(session.whenIdle()).resolves.toBeUndefined() + await host.close() + }) + + it('resolves after the running turn settles', async () => { + const { host, session, turn, release } = await openBusy() + + const statusWhenIdle = session.whenIdle().then(() => turn.status()) + release.open() + + expect(await statusWhenIdle).toBe('completed') + await host.close() + }) + + it('rejects at once with the reason of a signal that aborted before the call', async () => { + const { host, session, turn, release } = await openBusy() + const reason = new Error('Stop waiting.') + + // The turn waits for `release`, so a wait for idle would never end. + await expect( + session.whenIdle({ signal: AbortSignal.abort(reason) }), + ).rejects.toBe(reason) + release.open() + await turn + await host.close() + }) + + it('rejects with the reason when the signal aborts during the wait', async () => { + const { host, session, turn, release } = await openBusy() + const controller = new AbortController() + const reason = new Error('Stop waiting.') + + const waiting = session.whenIdle({ signal: controller.signal }) + controller.abort(reason) + + await expect(waiting).rejects.toBe(reason) + release.open() + await turn + await host.close() + }) +}) + +describe('the HarnessSession type', () => { + it('does not have the members that only the host calls', async () => { + const { host, session } = await open() + + // @ts-expect-error `host.open` opens the session. + void session.open + // @ts-expect-error `host.fork` adopts the fork. + void session.adoptFork + // @ts-expect-error `session.whenIdle` is the public wait. + void session.onIdle + await host.close() + }) +}) + +describe('harnessOf', () => { + it('throws a TypeError that names harnessOf outside a harness run', () => { + const outside = () => harnessOf({}) + + expect(outside).toThrow(TypeError) + expect(outside).toThrow(/^harnessOf: /) + }) + + it('types ctx.harness as possibly undefined in agent code', () => { + // A type pin: this agent never runs. + defineAgent({ + name: 'pin', + description: 'Reads ctx.harness without harnessOf', + run: async (ctx) => { + // @ts-expect-error a plain chat() run has no ctx.harness. + await ctx.harness.step.do('step', async () => 1) + }, + }) + }) +}) From 4f52e543cea3376f785151a032d0714a384dc200 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 16 Oct 2026 18:54:24 +0200 Subject: [PATCH 06/11] refactor(ai-harness): group host options per concern, and keep project and reduce on the harness --- packages/ai-harness-cli/src/serve.ts | 2 +- packages/ai-harness/src/define.ts | 102 ++++++++++++--- packages/ai-harness/src/host.ts | 118 +++++++++++------- packages/ai-harness/src/http.ts | 29 +++-- packages/ai-harness/tests/append-op.test.ts | 2 +- packages/ai-harness/tests/auth-wait.test.ts | 2 +- .../ai-harness/tests/durable-session.test.ts | 38 ++++-- packages/ai-harness/tests/forwarding.test.ts | 2 +- packages/ai-harness/tests/http-media.test.ts | 21 ++-- packages/ai-harness/tests/leases.test.ts | 4 +- .../ai-harness/tests/run-route-hosts.test.ts | 4 +- packages/ai-harness/tests/shared-log.test.ts | 77 ++++++++---- .../ai-harness/tests/subagent-router.test.ts | 6 +- .../ai-harness/tests/turn-control.test.ts | 5 +- packages/ai-harness/tests/types.test-d.ts | 19 ++- .../e2e/src/routes/api.harness-protocol.$.ts | 19 +-- 16 files changed, 311 insertions(+), 139 deletions(-) diff --git a/packages/ai-harness-cli/src/serve.ts b/packages/ai-harness-cli/src/serve.ts index de1654b117..0a709c9f49 100644 --- a/packages/ai-harness-cli/src/serve.ts +++ b/packages/ai-harness-cli/src/serve.ts @@ -104,7 +104,7 @@ export async function serve( // One principal holds the token, so it can use every thread. canAccess: () => true, // ponytail: the token signs media URLs, so they work as long as it does. - mediaSecret: options.token, + media: { secret: options.token }, }) const mcp = await mcpServer(options) const route = (request: Request) => { diff --git a/packages/ai-harness/src/define.ts b/packages/ai-harness/src/define.ts index 1fb3a0a787..52a9314efe 100644 --- a/packages/ai-harness/src/define.ts +++ b/packages/ai-harness/src/define.ts @@ -16,6 +16,7 @@ import type { SystemPrompt, } from '@tanstack/ai' import type { AnyAgent } from './agents' +import type { ProjectOptions, ReduceOptions } from './log' import type { MediaOptions } from './media' import type { HarnessPlugin, PluginMetadataCheck } from './plugins' import type { HarnessSession } from './session/index' @@ -87,10 +88,14 @@ export interface HarnessRouting extends Pick< } /** - * Limits for one input on a durable host (a host with `stores.log`). A host - * without a log reads only `interruptedToolResult` and `truncatedToolResult`. + * Limits for one input on a durable host (a host with `stores.log`), and how + * that host folds your own log records. A host without a log reads only + * `interruptedToolResult` and `truncatedToolResult`. */ -export interface HarnessDurability { +export interface HarnessDurability< + TMetadataSchema = any, + TLogState = undefined, +> { /** * Total attempts for one input, the first run included. Each recovery after * a crash is one more attempt. When no attempt is left, the input fails @@ -144,6 +149,18 @@ export interface HarnessDurability { * off. Continue exactly where it stopped, without repeating it.'` */ continueCutOff?: boolean | { note?: string | ReadonlyArray } + /** + * How a durable host folds your own log records into the model context of + * each session. + */ + project?: ProjectOptions + /** + * A fold of every host record of a log, across all its sessions, in log + * order. Read it with `host.logState({ harness, logId })`. When sessions of + * several harnesses share one log, the harness of the first session that + * opens the log folds it (`project` too). + */ + reduce?: ReduceOptions } /** @@ -156,6 +173,7 @@ export interface HarnessConfig< TSubagents extends ReadonlyArray = ReadonlyArray, TMetadataSchema = undefined, TTools extends ReadonlyArray = ReadonlyArray, + TLogState = undefined, > { /** A stable name, for example `'acme/studio'`. */ name: string @@ -246,8 +264,11 @@ export interface HarnessConfig< metadataSchema?: TMetadataSchema /** What a `prompt` does while a chat turn runs. Default `'queue'`. */ busy?: BusyPolicy - /** Attempt and time limits for each input on a durable host. */ - durability?: HarnessDurability + /** + * Attempt and time limits for each input on a durable host, and how that + * host folds your own log records (`project` and `reduce`). + */ + durability?: HarnessDurability /** Hooks that control a chat turn: retries, joins, and the end of a turn. */ turn?: HarnessTurnOptions /** @@ -298,8 +319,8 @@ export interface HarnessConfig< * example a webhook that adds `{ type: 'app.signal', text }`. An input * with any other type, or with a `thread` field, is refused with * `not_exposed`. Server code that calls `session.append()` can add every - * type. Only a durable host (with `stores.log`) keeps records, and its - * `project` decides what the model sees of them. + * type. Only a durable host (with `stores.log`) keeps records, and + * `durability.project` decides what the model sees of them. */ records?: ReadonlyArray } @@ -314,8 +335,16 @@ export type HarnessDefinition< TSubagents extends ReadonlyArray = ReadonlyArray, TMetadataSchema = undefined, TTools extends ReadonlyArray = ReadonlyArray, + TLogState = undefined, > = Readonly< - HarnessConfig + HarnessConfig< + TAdapter, + TAgents, + TSubagents, + TMetadataSchema, + TTools, + TLogState + > > & { readonly kind: typeof HARNESS_KIND readonly version: 1 @@ -327,32 +356,60 @@ export type AnyHarness = HarnessDefinition< any, any, any, - ReadonlyArray + ReadonlyArray, + any > /** A session whose harness has the metadata schema `TMetadataSchema`. */ export type SessionWithMetadata = HarnessSession< - HarnessDefinition + HarnessDefinition, any> > +// `durability.reduce` makes the log state type invariant, so each pattern +// below takes any log state (the last `any`). + /** The type of the session index `metadata` of a harness. */ export type SessionMetadataOf = - THarness extends HarnessDefinition + THarness extends HarnessDefinition< + any, + any, + any, + infer TMetadataSchema, + ReadonlyArray, + any + > ? MetadataOf : Record /** Every agent a harness registers, as a union. */ export type HarnessAgentsOf = - THarness extends HarnessDefinition + THarness extends HarnessDefinition< + any, + infer TAgents, + infer TSubagents, + any, + ReadonlyArray, + any + > ? TAgents[number] | TSubagents[number] : never /** The `tools` of a harness, as a union. */ export type HarnessToolsOf = - THarness extends HarnessDefinition + THarness extends HarnessDefinition ? TTools[number] : never +/** + * The state of the `durability.reduce` fold of a harness, as + * `host.logState` returns it. `undefined` for a harness without `reduce`. + */ +export type HarnessLogStateOf = THarness extends { + durability?: { reduce?: ReduceOptions } +} + ? TLogState + : undefined + /** * The input of the harness tool `TName`, from its input schema. `unknown` * for a tool that is not in `tools`, for example a plugin tool. @@ -393,9 +450,24 @@ export function defineHarness< const TSubagents extends ReadonlyArray = readonly [], TMetadataSchema extends SchemaInput | undefined = undefined, const TTools extends ReadonlyArray = readonly [], + TLogState = undefined, >( - config: HarnessConfig, -): HarnessDefinition { + config: HarnessConfig< + TAdapter, + TAgents, + TSubagents, + TMetadataSchema, + TTools, + TLogState + >, +): HarnessDefinition< + TAdapter, + TAgents, + TSubagents, + TMetadataSchema, + TTools, + TLogState +> { if (config.name.trim() === '') { throw new Error('defineHarness requires a non-empty name') } diff --git a/packages/ai-harness/src/host.ts b/packages/ai-harness/src/host.ts index ff278ea41a..f291de55a5 100644 --- a/packages/ai-harness/src/host.ts +++ b/packages/ai-harness/src/host.ts @@ -26,9 +26,9 @@ import type { SessionIndexPage, SessionIndexStore, } from '@tanstack/ai-persistence' -import type { AnyHarness, SessionMetadataOf } from './define' +import type { AnyHarness, HarnessLogStateOf, SessionMetadataOf } from './define' import type { SessionEntryChange, SessionEntryUpdate } from './session-entry' -import type { ProjectOptions, ReduceOptions } from './log' +import type { ReduceOptions } from './log' import type { LeaseOptions } from './resume' import type { HarnessSession } from './session/index' import type { ForkPoint, Principal } from './types' @@ -85,7 +85,7 @@ export type HarnessPersistence = SharedStores > -export interface HarnessHostOptions { +export interface HarnessHostOptions { /** Where sessions keep their state. Default: in memory, lost on restart. */ persistence?: HarnessPersistence /** @@ -95,24 +95,23 @@ export interface HarnessHostOptions { */ credentials?: CredentialStore /** - * How a durable host folds your own log records into the model context. - * Only a host with `stores.log` reads it. + * How a durable host (`stores.log`) appends to the log. Leave it out for + * the defaults. */ - project?: ProjectOptions - /** - * A fold of every host record of a log, across all its sessions, in log - * order. Read it with `host.logState(logId)`. Only a host with - * `stores.log` reads it. - */ - reduce?: ReduceOptions + log?: { + /** + * How long the host merges streamed text before it appends it, in + * milliseconds. Boundaries (a message start or end, a tool call, a run + * event) append at once. Default 100. + */ + coalesceMs?: number + } /** - * How long a durable host merges streamed text before it appends it to the - * log, in milliseconds. Boundaries (a message start or end, a tool call, a - * run event) append at once. Default 100. + * How this host holds the runs it owns: how long a run lease lasts + * (default 30000 ms), and how often the host renews it (default 10000 ms). + * Leave it out for the defaults. */ - coalesceMs?: number - /** How long a run lease lasts, and how often the host renews it. */ - lease?: LeaseOptions + ownership?: LeaseOptions } export interface OpenSessionOptions { @@ -285,7 +284,7 @@ export interface ResumePendingOptions { } /** Runs sessions for one or more harnesses in this process. */ -export interface HarnessHost { +export interface HarnessHost { /** * Open a session, or return the live one for this harness and thread. * Session plugins are set up here, so a plugin error rejects the promise. @@ -363,10 +362,20 @@ export interface HarnessHost { */ close: (options?: { recoverable?: boolean }) => Promise /** - * The `reduce` fold of a log that has an open session in this host, or - * `undefined`. + * The `durability.reduce` fold of `harness` for a log that has an open + * session in this host. `undefined` when no session has the log open, or + * when the reduce of another harness folded the log (the harness of its + * first session folds a shared log). + * + * @example + * ```ts + * const state = host.logState({ harness: assistant, logId: 'agent-1' }) + * ``` */ - logState: (logId: string) => TLogState | undefined + logState: (options: { + harness: THarness + logId: string + }) => HarnessLogStateOf | undefined /** The session index: list, rename, delete, and fork sessions. */ sessions: HostSessions /** @@ -575,9 +584,7 @@ function checkDurableStores(persistence: HarnessPersistence) { * const turn = await session.send('Write a haiku about the sea.') * ``` */ -export function createHarnessHost( - options: HarnessHostOptions = {}, -): HarnessHost { +export function createHarnessHost(options: HarnessHostOptions = {}) { if (!options.persistence && !warned) { warned = true console.warn( @@ -641,36 +648,45 @@ export function createHarnessHost( emit(event) } const index = sessionIndex(sessionStore, emit) - const coalesceMs = options.coalesceMs ?? 100 + const coalesceMs = options.log?.coalesceMs ?? 100 /** The shared log of each log id with an open session, by log id. */ - const logs = new Map>>() - /** The resolved shared logs, for `logState`. */ - const resolved = new Map>() - const sharedLog = (store: LogStore, logId: string) => { + const logs = new Map>() + /** The resolved shared logs, and the reduce that folds each one, for `logState`. */ + const resolved = new Map< + string, + { log: SharedLog; reduce: ReduceOptions | undefined } + >() + /** + * The shared log of `logId`. The harness of the session that opens it + * first gives the `project` and `reduce` that fold it. + */ + const sharedLog = (store: LogStore, logId: string, harness: AnyHarness) => { const open = logs.get(logId) if (open) return open + const project = harness.durability?.project + const reduce = harness.durability?.reduce const loaded = loadLogState({ store, logId, ...(metadata ? { metadata } : {}), - ...(options.project ? { project: options.project } : {}), - ...(options.reduce ? { reduce: options.reduce } : {}), + ...(project ? { project } : {}), + ...(reduce ? { reduce } : {}), }).then((state) => { - const log = new SharedLog({ + const log = new SharedLog({ store, logId, state, coalesceMs, - ...(options.project ? { project: options.project } : {}), - ...(options.reduce ? { reduce: options.reduce } : {}), + ...(project ? { project } : {}), + ...(reduce ? { reduce } : {}), ...(metadata ? { metadata } : {}), // The next open folds the log again. onIdle: () => { if (logs.get(logId) === loaded) logs.delete(logId) - if (resolved.get(logId) === log) resolved.delete(logId) + if (resolved.get(logId)?.log === log) resolved.delete(logId) }, }) - resolved.set(logId, log) + resolved.set(logId, { log, reduce }) return log }) loaded.catch(() => logs.delete(logId)) @@ -716,18 +732,22 @@ export function createHarnessHost( ? { log: { store: logStore, - ...(options.project ? { project: options.project } : {}), + ...(harness.durability?.project + ? { project: harness.durability.project } + : {}), open: async (sessionThread, onFailure) => { - let log = await sharedLog(logStore, logId) + let log = await sharedLog(logStore, logId, harness) // The log closed while this open waited, and the host // dropped it. - while (!log.isOpen) log = await sharedLog(logStore, logId) + while (!log.isOpen) { + log = await sharedLog(logStore, logId, harness) + } return log.view(sessionThread, onFailure) }, }, } : {}), - ...(options.lease ? { lease: options.lease } : {}), + ...(options.ownership ? { lease: options.ownership } : {}), ...(principal ? { principal } : {}), ...(promptCache ? { promptCache } : {}), onEvent: (event) => { @@ -764,7 +784,7 @@ export function createHarnessHost( return session as Promise> } - const host: HarnessHost = { + const host: HarnessHost = { open: openSession, isOpen(harness, { threadId }) { return sessions.has(`${harness.name}\u0000${threadId}`) @@ -823,7 +843,7 @@ export function createHarnessHost( async resumePending({ harnesses, close = 'whenIdle', limit = 100 }) { const claims = persistence.stores.workClaims if (!claims) throw new Error('resumePending needs stores.workClaims.') - const ttlMs = options.lease?.ttlMs ?? LEASE.ttlMs + const ttlMs = options.ownership?.ttlMs ?? LEASE.ttlMs const byName = new Map( harnesses.map((harness) => [harness.name, harness]), ) @@ -935,7 +955,17 @@ export function createHarnessHost( .map((entry) => entry.value.close(closeOptions)), ) }, - logState: (logId) => resolved.get(logId)?.state.reduced, + logState: ({ harness, logId }) => { + const folded = resolved.get(logId) + // The reduce of another harness folded this log: its state has + // another type. + if (folded?.reduce !== harness.durability?.reduce) return undefined + // The reduce of `harness` folded the state, so it has that type. A + // map of logs cannot carry a state type per log. + return folded?.log.state.reduced as + | HarnessLogStateOf + | undefined + }, sessions: { list: async (listOptions) => (await sessionStore?.list({ diff --git a/packages/ai-harness/src/http.ts b/packages/ai-harness/src/http.ts index eb5dc4c8c6..6238c0cf78 100644 --- a/packages/ai-harness/src/http.ts +++ b/packages/ai-harness/src/http.ts @@ -60,13 +60,18 @@ export interface HarnessHandlerOptions { */ canAccess: CanAccess /** - * The secret that signs media URLs (HMAC SHA-256). Use the same value on - * every server behind one URL. Required on a durable host (`stores.log`), - * where another instance can serve the next request. Without a log store, - * the default is a random key per handler, so signed URLs stop working - * after a restart. + * How the handler signs media URLs. Required on a durable host + * (`stores.log`), where another instance can serve the next request. + * Without a log store, leave it out to sign with a random key per handler, + * so signed URLs stop working after a restart. */ - mediaSecret?: string + media?: { + /** + * The secret that signs media URLs (HMAC SHA-256). Use the same value on + * every server behind one URL. + */ + secret: string + } } /** How long a signed media URL works. */ @@ -343,11 +348,11 @@ async function mediaResponse( export function createHarnessHandler( options: HarnessHandlerOptions, ): (request: Request) => Promise { - const { host, harness, authorize, canAccess } = options + const { host, harness, authorize, canAccess, media } = options const durable = durableStoresOf(host) - if (options.mediaSecret === undefined && durable) { + if (media === undefined && durable) { throw new Error( - 'createHarnessHandler needs mediaSecret on a durable host (stores.log): another instance must verify the media URLs this one signs. Set the same secret on every instance.', + 'createHarnessHandler needs media.secret on a durable host (stores.log): another instance must verify the media URLs this one signs. Set the same secret on every instance.', ) } @@ -356,12 +361,12 @@ export function createHarnessHandler( return host.open(harness, { threadId, principal }) } - // ponytail: without `mediaSecret` (only allowed without a log store), a + // ponytail: without `media.secret` (only allowed without a log store), a // random key per handler, so signed URLs stop working after a restart. const secret = - options.mediaSecret === undefined + media === undefined ? crypto.getRandomValues(new Uint8Array(32)) - : encoder.encode(options.mediaSecret) + : encoder.encode(media.secret) let key: Promise | undefined const mediaKey = () => (key ??= crypto.subtle.importKey('raw', secret, HMAC, false, [ diff --git a/packages/ai-harness/tests/append-op.test.ts b/packages/ai-harness/tests/append-op.test.ts index 5cdfcd1445..98d0f6f603 100644 --- a/packages/ai-harness/tests/append-op.test.ts +++ b/packages/ai-harness/tests/append-op.test.ts @@ -20,11 +20,11 @@ function setup() { name: 'test/append', adapter, expose: { records: ['app.signal'] }, + durability: { project }, }) const { runs, metadata } = memoryPersistence().stores const host = createHarnessHost({ persistence: { stores: { log: memoryLogStore(), runs, metadata } }, - project, }) return { harness, host, calls } } diff --git a/packages/ai-harness/tests/auth-wait.test.ts b/packages/ai-harness/tests/auth-wait.test.ts index eccf3afe19..253def3bbc 100644 --- a/packages/ai-harness/tests/auth-wait.test.ts +++ b/packages/ai-harness/tests/auth-wait.test.ts @@ -467,7 +467,7 @@ describe('sign-ins in a shared thread', () => { harness, canAccess: () => true, authorize: () => bob, - mediaSecret: 'test-secret', + media: { secret: 'test-secret' }, }) const session = await host.open(harness, { threadId: 't', diff --git a/packages/ai-harness/tests/durable-session.test.ts b/packages/ai-harness/tests/durable-session.test.ts index 29cfaab498..6d9c45246a 100644 --- a/packages/ai-harness/tests/durable-session.test.ts +++ b/packages/ai-harness/tests/durable-session.test.ts @@ -269,7 +269,7 @@ describe('durable session log', () => { streamsThenWaits('m-live', ['Hello, ', 'wor'], release.opened), ]) const harness = defineHarness({ name: 'test/crash-mid-text', adapter }) - const first = createHarnessHost({ persistence, coalesceMs: 5 }) + const first = createHarnessHost({ persistence, log: { coalesceMs: 5 } }) const session = await first.open(harness, { threadId: THREAD }) const turn = session.send('say hello') @@ -321,7 +321,7 @@ describe('durable session log', () => { ]) const host = createHarnessHost({ persistence: durablePersistence(log), - coalesceMs: 60_000, + log: { coalesceMs: 60_000 }, }) const session = await host.open( defineHarness({ name: 'test/batch', adapter }), @@ -363,13 +363,14 @@ describe('durable session log', () => { () => toolCall('check', {}), () => text('done'), ]) - const host = createHarnessHost({ persistence, project }) + const host = createHarnessHost({ persistence }) session = await host.open( defineHarness({ name: 'test/project', adapter, tools: [check], middleware: [hideSecret], + durability: { project }, }), { threadId: THREAD }, ) @@ -404,9 +405,14 @@ describe('durable session log', () => { () => toolCall('check', {}), () => text('second answer'), ]) - const host = createHarnessHost({ persistence, project }) + const host = createHarnessHost({ persistence }) session = await host.open( - defineHarness({ name: 'test/project', adapter, tools: [check] }), + defineHarness({ + name: 'test/project', + adapter, + tools: [check], + durability: { project }, + }), { threadId: THREAD }, ) @@ -453,9 +459,9 @@ describe('durable session log', () => { () => text('a2'), () => text('a3'), ]) - const host = createHarnessHost({ persistence, project }) + const host = createHarnessHost({ persistence }) const session = await host.open( - defineHarness({ name: 'test/compact', adapter }), + defineHarness({ name: 'test/compact', adapter, durability: { project } }), { threadId: THREAD }, ) await session.send('q1') @@ -604,12 +610,13 @@ describe('LogRecordsCapability', () => { ]) }, } - const host = createHarnessHost({ persistence, project }) + const host = createHarnessHost({ persistence }) const session = await host.open( defineHarness({ name: 'test/log-records', adapter, middleware: [signal], + durability: { project }, }), { threadId: THREAD }, ) @@ -653,13 +660,14 @@ describe('LogRecordsCapability', () => { ]) }, } - const host = createHarnessHost({ persistence, project }) + const host = createHarnessHost({ persistence }) const session = await host.open( defineHarness({ name: 'test/log-records-tool-phase', adapter, tools: [lookup], middleware: [signal], + durability: { project }, }), { threadId: THREAD }, ) @@ -704,12 +712,13 @@ describe('LogRecordsCapability', () => { await getLogRecords(ctx).append([{ type: 'app.note' }]) }, } - const host = createHarnessHost({ persistence, project }) + const host = createHarnessHost({ persistence }) const session = await host.open( defineHarness({ name: 'test/log-records-note', adapter, middleware: [note], + durability: { project }, }), { threadId: THREAD }, ) @@ -811,12 +820,13 @@ describe('compaction-style host records from a middleware', () => { await getLogRecords(ctx).append([compactRecord]) }, } - const host = createHarnessHost({ persistence, project }) + const host = createHarnessHost({ persistence }) const session = await host.open( defineHarness({ name: 'test/after-turn-record', adapter, middleware: [afterTurn], + durability: { project }, }), { threadId: THREAD }, ) @@ -871,12 +881,13 @@ describe('compaction-style host records from a middleware', () => { await getLogRecords(ctx).append([compactRecord]) }, } - const host = createHarnessHost({ persistence, project }) + const host = createHarnessHost({ persistence }) const session = await host.open( defineHarness({ name: 'test/before-call-record', adapter, middleware: [beforeCall], + durability: { project }, }), { threadId: THREAD }, ) @@ -930,13 +941,14 @@ describe('compaction-style host records from a middleware', () => { ]) }, } - const host = createHarnessHost({ persistence, project }) + const host = createHarnessHost({ persistence }) const session = await host.open( defineHarness({ name: 'test/tool-phase-record', adapter, tools: [lookup], middleware: [clearFirst], + durability: { project }, }), { threadId: THREAD }, ) diff --git a/packages/ai-harness/tests/forwarding.test.ts b/packages/ai-harness/tests/forwarding.test.ts index 08a912eeb0..402007e39e 100644 --- a/packages/ai-harness/tests/forwarding.test.ts +++ b/packages/ai-harness/tests/forwarding.test.ts @@ -524,7 +524,7 @@ describe('an input that reaches a host that does not own the thread', () => { // The owner stores its input, then stops before it writes its run lease. const runs = persistence.stores.runs const owner = await createHarnessHost({ - lease: { ttlMs: 200, renewMs: 50 }, + ownership: { ttlMs: 200, renewMs: 50 }, persistence: { stores: { ...persistence.stores, diff --git a/packages/ai-harness/tests/http-media.test.ts b/packages/ai-harness/tests/http-media.test.ts index 16cc40742b..ed6c516656 100644 --- a/packages/ai-harness/tests/http-media.test.ts +++ b/packages/ai-harness/tests/http-media.test.ts @@ -22,7 +22,7 @@ const allowUser: Authorize = (request) => * A handler for user-1, who can open only threads that start with `user-1`. * `handlerWith` makes another handler on the same host. */ -function setup(options: { media?: MediaOptions; mediaSecret?: string } = {}) { +function setup(options: { media?: MediaOptions; secret?: string } = {}) { const { adapter, calls } = mockAdapter(() => text('ok')) const harness = defineHarness({ name: 'test/http-media', @@ -30,17 +30,20 @@ function setup(options: { media?: MediaOptions; mediaSecret?: string } = {}) { media: options.media, }) const host = createHarnessHost({ persistence: memoryPersistence() }) + /** `secret` signs the media URLs. Without it, a random key per handler. */ const handlerWith = ( - handlerOptions: { authorize?: Authorize; mediaSecret?: string } = {}, + handlerOptions: { authorize?: Authorize; secret?: string } = {}, ) => createHarnessHandler({ host, harness, authorize: handlerOptions.authorize ?? allowUser, canAccess: (principal, threadId) => threadId.startsWith(principal.id), - mediaSecret: handlerOptions.mediaSecret, + ...(handlerOptions.secret + ? { media: { secret: handlerOptions.secret } } + : {}), }) - const handler = handlerWith({ mediaSecret: options.mediaSecret }) + const handler = handlerWith({ secret: options.secret }) const call = (path: string, init: RequestInit = {}) => handler( new Request(`${BASE}/${path}`, { @@ -125,7 +128,7 @@ afterEach(() => { }) describe('media upload and signed URLs', () => { - it('needs mediaSecret on a durable host, where another instance verifies the URL', () => { + it('needs media.secret on a durable host, where another instance verifies the URL', () => { const { adapter } = mockAdapter(() => text('ok')) const harness = defineHarness({ name: 'test/http-media-durable', adapter }) const host = createHarnessHost({ @@ -142,9 +145,9 @@ describe('media upload and signed URLs', () => { authorize: allowUser, canAccess: () => true, } - expect(() => createHarnessHandler(options)).toThrow(/mediaSecret/) + expect(() => createHarnessHandler(options)).toThrow(/media\.secret/) expect(() => - createHarnessHandler({ ...options, mediaSecret: 'shared' }), + createHarnessHandler({ ...options, media: { secret: 'shared' } }), ).not.toThrow() }) @@ -179,13 +182,13 @@ describe('media upload and signed URLs', () => { it('serves a signed URL through a handler whose authorize refuses everything', async () => { const { host, upload, signedPath, fetchSigned, handlerWith } = setup({ - mediaSecret: 'shared', + secret: 'shared', }) const record = await upload('user-1-a') const { path } = await signedPath(record) const refuseAll = handlerWith({ authorize: () => null, - mediaSecret: 'shared', + secret: 'shared', }) const signed = await fetchSigned(path, refuseAll) diff --git a/packages/ai-harness/tests/leases.test.ts b/packages/ai-harness/tests/leases.test.ts index 65bca56c78..5ece139fa4 100644 --- a/packages/ai-harness/tests/leases.test.ts +++ b/packages/ai-harness/tests/leases.test.ts @@ -73,10 +73,10 @@ async function openLeased( persistence: ReturnType, replies: Array, tools: Array = [], - lease?: { renewMs?: number; ttlMs?: number }, + ownership?: { renewMs?: number; ttlMs?: number }, ) { const { adapter, calls } = mockAdapter(replies) - const host = createHarnessHost({ persistence, lease }) + const host = createHarnessHost({ persistence, ownership }) const session = await host.open( defineHarness({ name: 'test/leases', adapter, tools }), { threadId: THREAD }, diff --git a/packages/ai-harness/tests/run-route-hosts.test.ts b/packages/ai-harness/tests/run-route-hosts.test.ts index f18fea2302..b7ee45beb7 100644 --- a/packages/ai-harness/tests/run-route-hosts.test.ts +++ b/packages/ai-harness/tests/run-route-hosts.test.ts @@ -25,7 +25,7 @@ function twoHosts() { const { adapter, calls } = mockAdapter([reply]) const harness = defineHarness({ name: 'test/run-hosts', adapter }) const handlerOf = () => { - const host = createHarnessHost({ persistence, coalesceMs: 0 }) + const host = createHarnessHost({ persistence, log: { coalesceMs: 0 } }) const handler = createHarnessHandler({ host, harness, @@ -33,7 +33,7 @@ function twoHosts() { id: request.headers.get('x-user') ?? 'user-1', }), canAccess: (principal, threadId) => threadId.startsWith(principal.id), - mediaSecret: 'shared', + media: { secret: 'shared' }, }) const call = (path: string, init: RequestInit = {}) => handler(new Request(`http://h.test/api/${path}`, init)) diff --git a/packages/ai-harness/tests/shared-log.test.ts b/packages/ai-harness/tests/shared-log.test.ts index 0734a52f5b..f6862f0694 100644 --- a/packages/ai-harness/tests/shared-log.test.ts +++ b/packages/ai-harness/tests/shared-log.test.ts @@ -14,7 +14,7 @@ import { wrapLogStore, } from './helpers' import type { LogRecord, LogStore } from '@tanstack/ai-persistence' -import type { HarnessHost, ReduceOptions } from '../src' +import type { ReduceOptions } from '../src' import type { Reply } from './helpers' const LOG = 'agent-1' @@ -36,14 +36,26 @@ const reduce: ReduceOptions> = { } function hostFor(persistence: Durable) { - return createHarnessHost({ persistence, reduce }) + return createHarnessHost({ persistence }) } +/** A harness whose `reduce` folds the logs it opens. */ const harnessWith = (replies: Array) => { const { adapter, calls } = mockAdapter(replies) - return { harness: defineHarness({ name: 'test/shared', adapter }), calls } + return { + harness: defineHarness({ + name: 'test/shared', + adapter, + durability: { reduce }, + }), + calls, + } } +/** A harness with no `reduce`. */ +const plainHarness = () => + defineHarness({ name: 'test/shared', adapter: mockAdapter([]).adapter }) + const expireLease = (persistence: Durable, runId: string) => persistence.stores.runs.update(runId, { leaseExpiresAt: Date.now() - 1 }) @@ -134,44 +146,61 @@ describe('sessions that share one log', () => { await host.close().catch(() => {}) }) - it('folds state writes from two sessions in log order, also after a rebuild', async () => { + it('folds state writes from two sessions in log order, with the reduce of the first harness, also after a rebuild', async () => { const persistence = durablePersistence() const host = hostFor(persistence) - const root = await host.open(harnessWith([]).harness, { threadId: LOG }) - const child = await host.open(harnessWith([]).harness, { - threadId: 'task-1', - logId: LOG, - }) + const tracked = harnessWith([]).harness + const plain = plainHarness() + const root = await host.open(tracked, { threadId: LOG }) + // The harness of the root opened the log first, so its reduce folds the + // records of this child too. + const child = await host.open(plain, { threadId: 'task-1', logId: LOG }) await root.append([{ type: 'app.state_write', name: 'plan', value: 'a' }]) await child.append([{ type: 'app.state_write', name: 'plan', value: 'b' }]) await root.append([{ type: 'app.state_write', name: 'done', value: true }]) - expect(host.logState(LOG)).toEqual({ plan: 'b', done: true }) - expectTypeOf(host).toExtend() + expect(host.logState({ harness: tracked, logId: LOG })).toEqual({ + plan: 'b', + done: true, + }) + // The reduce of `plain` did not fold this log. + expect(host.logState({ harness: plain, logId: LOG })).toBeUndefined() + expectTypeOf(host.logState({ harness: tracked, logId: LOG })).toEqualTypeOf< + Record | undefined + >() expectTypeOf( - createHarnessHost({ persistence: durablePersistence() }).logState('x'), + host.logState({ harness: plain, logId: LOG }), ).toEqualTypeOf() await host.close() const next = hostFor(persistence) - await next.open(harnessWith([]).harness, { threadId: 'task-1', logId: LOG }) - expect(next.logState(LOG)).toEqual({ plan: 'b', done: true }) - expect(next.logState('another-log')).toBeUndefined() + await next.open(tracked, { threadId: 'task-1', logId: LOG }) + expect(next.logState({ harness: tracked, logId: LOG })).toEqual({ + plan: 'b', + done: true, + }) + expect( + next.logState({ harness: tracked, logId: 'another-log' }), + ).toBeUndefined() await next.close() }) it('gives project only the records of its own session', async () => { - const host = createHarnessHost({ - persistence: durablePersistence(), - project: { - record: ({ messages, record }) => - record.type === 'app.signal' && typeof record.text === 'string' - ? [...messages, { role: 'user', content: record.text }] - : undefined, + const host = createHarnessHost({ persistence: durablePersistence() }) + const harness = defineHarness({ + name: 'test/shared', + adapter: mockAdapter([]).adapter, + durability: { + project: { + record: ({ messages, record }) => + record.type === 'app.signal' && typeof record.text === 'string' + ? [...messages, { role: 'user', content: record.text }] + : undefined, + }, }, }) - const root = await host.open(harnessWith([]).harness, { threadId: LOG }) - const child = await host.open(harnessWith([]).harness, { + const root = await host.open(harness, { threadId: LOG }) + const child = await host.open(harness, { threadId: 'task-1', logId: LOG, }) diff --git a/packages/ai-harness/tests/subagent-router.test.ts b/packages/ai-harness/tests/subagent-router.test.ts index 86f6c93813..a0c5e8922d 100644 --- a/packages/ai-harness/tests/subagent-router.test.ts +++ b/packages/ai-harness/tests/subagent-router.test.ts @@ -60,7 +60,7 @@ async function openRouted(options: { agent: AnyAgent pick: SubagentRouterPick replies?: Array - lease?: { renewMs?: number; ttlMs?: number } + ownership?: { renewMs?: number; ttlMs?: number } tools?: ReadonlyArray }) { const { stores } = memoryPersistence() @@ -72,7 +72,7 @@ async function openRouted(options: { const routerSaw: Array> = [] const host = createHarnessHost({ persistence, - ...(options.lease ? { lease: options.lease } : {}), + ...(options.ownership ? { ownership: options.ownership } : {}), }) const session = await host.open( defineHarness({ @@ -193,7 +193,7 @@ describe.each([ durable, agent, pick: 'writer', - lease: { renewMs: 5 }, + ownership: { renewMs: 5 }, }) const turn = session.send('go') await turn diff --git a/packages/ai-harness/tests/turn-control.test.ts b/packages/ai-harness/tests/turn-control.test.ts index afa890c208..b0cc52d0c2 100644 --- a/packages/ai-harness/tests/turn-control.test.ts +++ b/packages/ai-harness/tests/turn-control.test.ts @@ -107,7 +107,6 @@ async function open(options: { persistence: { stores: { log, runs, metadata, interrupts }, }, - project, }) : createHarnessHost({ persistence }) const session = await host.open( @@ -116,6 +115,8 @@ async function open(options: { adapter, tools: options.tools, turn: options.turn, + // Only the durable host reads it. + durability: { project }, }), { threadId: THREAD }, ) @@ -852,7 +853,6 @@ describe('turn.beforeFinish', () => { const { runs } = memoryPersistence().stores const host = createHarnessHost({ persistence: { stores: { log: memoryLogStore(), runs } }, - project: signalProject, }) let cycles = 0 const session = await host.open( @@ -867,6 +867,7 @@ describe('turn.beforeFinish', () => { : undefined }, }, + durability: { project: signalProject }, }), { threadId: THREAD }, ) diff --git a/packages/ai-harness/tests/types.test-d.ts b/packages/ai-harness/tests/types.test-d.ts index 74b47f64c9..00dacb6c98 100644 --- a/packages/ai-harness/tests/types.test-d.ts +++ b/packages/ai-harness/tests/types.test-d.ts @@ -18,8 +18,10 @@ import type { HarnessSession, Operation, PluginLifetime, + ProjectOptions, Receipt, RecoverHook, + ReduceOptions, SessionMetadataOf, } from '../src' import type { InputSettlement } from '../src/types' @@ -97,7 +99,7 @@ it('takes a harness without an adapter, and turn plugins', () => { expectTypeOf().toEqualTypeOf<'session' | 'turn'>() }) -it('takes attempt and time limits on the harness', () => { +it('takes attempt and time limits, and the log folds, on the harness', () => { expectTypeOf().toEqualTypeOf< | { maxAttempts?: number @@ -108,9 +110,24 @@ it('takes attempt and time limits on the harness', () => { | string | ((call: { toolCallId: string; toolName: string }) => string) continueCutOff?: boolean | { note?: string | ReadonlyArray } + project?: ProjectOptions + reduce?: ReduceOptions } | undefined >() + // The state type of `host.logState` comes from `initial` and `record`. + const counted = defineHarness({ + name: 'test/reduce', + durability: { + reduce: { + initial: { count: 0 }, + record: ({ state }) => ({ count: state.count + 1 }), + }, + }, + }) + expectTypeOf( + createHarnessHost().logState({ harness: counted, logId: 'log-1' }), + ).toEqualTypeOf<{ count: number } | undefined>() }) it('gives a started agent run send()', () => { diff --git a/testing/e2e/src/routes/api.harness-protocol.$.ts b/testing/e2e/src/routes/api.harness-protocol.$.ts index 9da500a40c..7b7e1dcb9f 100644 --- a/testing/e2e/src/routes/api.harness-protocol.$.ts +++ b/testing/e2e/src/routes/api.harness-protocol.$.ts @@ -3,6 +3,7 @@ import { defineAgent } from '@tanstack/ai' import { createOpenaiChat } from '@tanstack/ai-openai' import { type FinishContext, + type ProjectOptions, retryTransientErrors, configOption, createHarnessHost, @@ -36,18 +37,19 @@ import { createTextAdapter } from '@/lib/providers' const host = createHarnessHost({ persistence: memoryPersistence() }) // A durable host: a session log and run leases. A request picks it with the // `x-harness-durable: 1` header. -// An `app.signal` record that a client appends becomes a user message. const durableHost = createHarnessHost({ persistence: { stores: { log: memoryLogStore(), runs: memoryPersistence().stores.runs }, }, - project: { - record: ({ messages, record }) => - record.type === 'app.signal' && typeof record.text === 'string' - ? [...messages, { role: 'user', content: record.text }] - : [...messages], - }, }) +// On the durable host, an `app.signal` record that a client appends becomes +// a user message. The host without a log does not read it. +const signalProject: ProjectOptions = { + record: ({ messages, record }) => + record.type === 'app.signal' && typeof record.text === 'string' + ? [...messages, { role: 'user', content: record.text }] + : [...messages], +} // Two users, so a test can share one thread between them. const authorize = (req: Request) => { @@ -159,6 +161,7 @@ function harnessFor(request: Request) { const harness = defineHarness({ name: 'e2e/protocol', adapter, + durability: { project: signalProject }, // App data in the session index entry: the context items a user pinned // in the composer. Each write over `POST sessions` checks it. metadataSchema: z.object({ chips: z.array(z.string()) }), @@ -300,7 +303,7 @@ function handlerFor(request: Request) { canAccess: () => true, // Each request makes a new handler, so a fixed secret keeps a signed URL // working on the next request. - mediaSecret: 'e2e-media-secret', + media: { secret: 'e2e-media-secret' }, }) } From 8d7f1799af3b0bb901cab39a0fddb47929080199 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 16 Oct 2026 19:11:28 +0200 Subject: [PATCH 07/11] docs(ai-harness): one-object samples, subpath imports, grouped options, and the import guide --- .changeset/ai-harness-core.md | 4 +-- .changeset/ai-harness-plugins.md | 7 +++-- .changeset/ai-harness-transport.md | 6 ++-- .changeset/harness-cli-handler-options.md | 2 +- .changeset/harness-secure-defaults.md | 2 +- .changeset/harness-tool-ops.md | 2 +- docs/config.json | 3 +- docs/harness/agents.md | 13 +++++---- docs/harness/coding-agent.md | 11 ++++--- docs/harness/coding-agents.md | 2 +- docs/harness/coding-tools-backends.md | 2 +- docs/harness/coding-tools.md | 6 ++-- docs/harness/compaction.md | 6 ++-- docs/harness/connect.md | 14 +++++---- docs/harness/custom-ui.md | 8 ++++-- docs/harness/durable-sessions.md | 8 +++--- docs/harness/fork-and-reset.md | 15 +++++----- docs/harness/goal.md | 6 ++-- docs/harness/inputs.md | 8 +++--- docs/harness/media.md | 14 +++++---- docs/harness/overview.md | 35 +++++++++++++++++++++-- docs/harness/permissions.md | 10 +++---- docs/harness/plugins.md | 6 ++-- docs/harness/provider-keys.md | 5 ++-- docs/harness/session-log.md | 31 +++++++++++--------- docs/harness/sessions.md | 5 ++-- docs/harness/shared-logs.md | 25 +++++++++------- docs/harness/shared-threads.md | 11 +++---- docs/harness/snapshots.md | 6 ++-- docs/harness/subagents.md | 20 +++++++------ docs/harness/thread-settings.md | 18 ++++++------ docs/harness/tool-runs.md | 18 ++++++------ docs/harness/turn-control.md | 4 +-- docs/harness/usage.md | 7 +++-- packages/ai-harness/README.md | 2 +- 35 files changed, 198 insertions(+), 144 deletions(-) diff --git a/.changeset/ai-harness-core.md b/.changeset/ai-harness-core.md index 4519636ee5..3025e93c7e 100644 --- a/.changeset/ai-harness-core.md +++ b/.changeset/ai-harness-core.md @@ -5,9 +5,9 @@ Add `@tanstack/ai-harness`: build an agent harness on TanStack AI. A harness keeps one conversation open across many turns. - `defineHarness()` names an agent: its adapter, tools, system prompts, middleware, agents, subagents, routing, plugins, turn hooks, and durability options. -- `createHarnessHost()` opens sessions. 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()` opens sessions. 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. - `definePlugin()` adds tools, prompts, middleware, agents, commands, config options, state, and extension points to a session or to one turn. -- 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) and `agents.start()` (a child that counts against the subagent budget). +- Agents run from code with `session.agents..run()` and `.start()`. In agent code, `harnessOf(ctx)` gives `step.do()` (a finished step returns its stored value when the run runs again) and `agents.start()` (a child that counts against the subagent budget). - With `stores.log` from `@tanstack/ai-persistence`, a session is durable: inputs, tool results, and steps are in the log, and the next host recovers a turn that a crash stopped. `durableTool()` runs side effects once. `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. - `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. diff --git a/.changeset/ai-harness-plugins.md b/.changeset/ai-harness-plugins.md index f0812eb769..7dd3112006 100644 --- a/.changeset/ai-harness-plugins.md +++ b/.changeset/ai-harness-plugins.md @@ -2,10 +2,11 @@ '@tanstack/ai-harness': minor --- -Add first-party plugins in `@tanstack/ai-harness/plugins` (a Node entry): +Add first-party plugins. `@tanstack/ai-harness/plugins` loads no Node module. The plugins that read files are in the Node entry `@tanstack/ai-harness/plugins/node`. - `permissions()`: rules for tools, paths, and commands, the `default`, `plan`, `acceptEdits`, and `bypass` modes, questions answered with once, always, or reject, saved "always" rules with `/permissions` and `/permissions forget `, and `listSavedPermissions` / `deleteSavedPermission` for server code. The rules apply in subagent runs too. -- `agents()`: agent profiles with their own prompt, tools, model, rules, and step limit, in code or in Markdown files, `/agent` to switch the primary agent, and the built-in `build`, `plan`, `general`, and `explore` agents. -- `question()`, `todos()`, `title()`, `modelPicker()`, `projectInstructions()`, `fileCommands()`, `compact()`, `usage()` (with your own prices for calls that have no provider cost), and `boundToolOutput()`. +- `agents()` (in `plugins/node`): agent profiles with their own prompt, tools, model, rules, and step limit, in code or in Markdown files, `/agent` to switch the primary agent, and the built-in `build`, `plan`, `general`, and `explore` agents. +- `question()`, `todos()`, `title()`, `modelPicker()`, `compact()`, and `usage()` (with your own prices for calls that have no provider cost). +- `projectInstructions()`, `fileCommands()`, and `boundToolOutput()`, in `plugins/node`. `PermissionRules`, `PermissionResources`, `PermissionDecisionCapability`, and `decidePermission` are also exported from the package root. They load no Node module, so an edge-safe plugin can read the permission rules. diff --git a/.changeset/ai-harness-transport.md b/.changeset/ai-harness-transport.md index 9f0d8ebd40..7115dcda27 100644 --- a/.changeset/ai-harness-transport.md +++ b/.changeset/ai-harness-transport.md @@ -4,8 +4,8 @@ Serve a harness session to clients. -- `createHarnessHandler()` serves a session over HTTP. `POST run` is standard AG-UI, so `useChat` with `fetchServerSentEvents` works on a harness, with approvals, client tools, and a reload that joins the running turn. `POST control`, `GET events`, `GET transcript`, `GET agent-transcript` (the messages of a child agent run), `GET snapshot`, `GET describe`, `sessions`, and the media routes cover the rest. -- `handleHarnessSocket()` serves the same session over a WebSocket. +- `createHarnessHandler()` from `@tanstack/ai-harness/server` serves a session over HTTP. `POST run` is standard AG-UI, so `useChat` with `fetchServerSentEvents` works on a harness, with approvals, client tools, and a reload that joins the running turn. `POST control`, `GET events`, `GET transcript`, `GET agent-transcript` (the messages of a child agent run), `GET snapshot`, `GET describe`, `sessions`, and the media routes cover the rest. +- `handleHarnessSocket()`, from the same entry, serves the same session over a WebSocket. - `expose` on `defineHarness()` names the agents, settings, config keys, and commands that a client can use. Any other input of those kinds gets `rejected` with `not_exposed`. - `createHarnessClient()` from `@tanstack/ai-harness/client` is a typed, browser-safe client. It sends inputs, reads the session, and streams events that resume from a cursor. -- `oauthConnector()` and the OAuth helpers (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per sender. `token({ wait: true })` stops the turn with an `auth_required` interrupt until the user signs in. +- `oauthConnector()` and the OAuth helpers from `@tanstack/ai-harness/oauth` (`loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more) keep a credential per sender. `token({ wait: true })` stops the turn with an `auth_required` interrupt until the user signs in. diff --git a/.changeset/harness-cli-handler-options.md b/.changeset/harness-cli-handler-options.md index c2f77f3e99..fa70df563f 100644 --- a/.changeset/harness-cli-handler-options.md +++ b/.changeset/harness-cli-handler-options.md @@ -2,4 +2,4 @@ '@tanstack/ai-harness-cli': patch --- -`--serve` passes `canAccess` and `mediaSecret` to the harness handler, which needs both. +`--serve` passes `canAccess` and `media: { secret }` to the harness handler, which needs both. diff --git a/.changeset/harness-secure-defaults.md b/.changeset/harness-secure-defaults.md index d8f7ec8847..1cd5b98d5f 100644 --- a/.changeset/harness-secure-defaults.md +++ b/.changeset/harness-secure-defaults.md @@ -5,5 +5,5 @@ Make the transport defaults safe for a server with many instances. - `createHarnessHandler` and `handleHarnessSocket` need `canAccess`. To let every principal use every thread, pass `canAccess: () => true`. -- On a durable host (`stores.log`), `createHarnessHandler` throws without `mediaSecret`, so a media URL that one instance signs works on every instance. +- On a durable host (`stores.log`), `createHarnessHandler` throws without `media: { secret }`, so a media URL that one instance signs works on every instance. - `oauthConnector` needs `login`. Use `'device'` on a server, and `'loopback'` only for a harness on the user's own machine, such as a CLI. diff --git a/.changeset/harness-tool-ops.md b/.changeset/harness-tool-ops.md index 0e8d098ef3..bdcdaa50bc 100644 --- a/.changeset/harness-tool-ops.md +++ b/.changeset/harness-tool-ops.md @@ -6,7 +6,7 @@ Run a harness tool with no model turn, add host records from a client, and add a per-run system preamble. -- `session.tool(name, args?, options?)` runs one harness tool with no model +- `session.tool({ name, args?, meta?, inputId?, principal? })` runs one harness tool with no model turn, for example from a schedule or a webhook. The call goes through the same middleware, input check, and `permissions()` rules as a model call, and the call and its result join the transcript. A tool that asks for approval diff --git a/docs/config.json b/docs/config.json index 5eded52077..57dfafcd41 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1010,7 +1010,8 @@ { "label": "Share one log between sessions", "to": "harness/shared-logs", - "addedAt": "2026-10-13" + "addedAt": "2026-10-13", + "updatedAt": "2026-10-16" }, { "label": "Write a plugin", diff --git a/docs/harness/agents.md b/docs/harness/agents.md index 315b6b7355..78ec75b5cc 100644 --- a/docs/harness/agents.md +++ b/docs/harness/agents.md @@ -21,7 +21,8 @@ import { readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import { toolDefinition } from '@tanstack/ai' import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' -import { agents, permissions } from '@tanstack/ai-harness/plugins' +import { permissions } from '@tanstack/ai-harness/plugins' +import { agents } from '@tanstack/ai-harness/plugins/node' import { anthropicText } from '@tanstack/ai-anthropic' import { memoryPersistence } from '@tanstack/ai-persistence' import { z } from 'zod' @@ -61,20 +62,20 @@ const coder = defineHarness({ ```ts group=harness-agents const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(coder, { threadId: 'dark-mode' }) +const session = await host.open({ harness: coder, threadId: 'dark-mode' }) -await session.command('agent', 'plan') +await session.command({ name: 'agent', input: 'plan' }) const plan = await session.send('How do I add a dark mode?') console.log(plan.text) -await session.command('agent', 'build') +await session.command({ name: 'agent', input: 'build' }) await session.send('Do step 1.') ``` 1. The `plan` agent reads the code and gives numbered steps. It has no tool that changes a file. 2. The `build` agent changes the files, after your answer to each [permission question](./permissions). -- `session.command('agent')` with no input shows the current agent and the list: `Agent: build. Agents: build, plan.` +- `session.command({ name: 'agent' })` with no input shows the current agent and the list: `Agent: build. Agents: build, plan.` - A switch applies at the next turn. The same switch is the `agent` setting. `session.describe().config` lists its options for a menu: @@ -115,7 +116,7 @@ Give `agents()` a profile. This one reviews the changes with a stronger model: ```ts group=harness-agents import type { AnyTextAdapter } from '@tanstack/ai' -import type { AgentProfile } from '@tanstack/ai-harness/plugins' +import type { AgentProfile } from '@tanstack/ai-harness/plugins/node' const review: AgentProfile = { name: 'review', diff --git a/docs/harness/coding-agent.md b/docs/harness/coding-agent.md index a8bffe5f11..78fe27246e 100644 --- a/docs/harness/coding-agent.md +++ b/docs/harness/coding-agent.md @@ -13,7 +13,7 @@ keywords: - undo --- -You want your own coding agent in the terminal. It reads and edits files, and runs commands after you approve them. When a turn goes wrong, you undo it. The first-party plugins in `@tanstack/ai-harness/plugins` and `@tanstack/ai-harness/plugins/coding` give you those parts. You pick the model and the rules. +You want your own coding agent in the terminal. It reads and edits files, and runs commands after you approve them. When a turn goes wrong, you undo it. The first-party plugins in `@tanstack/ai-harness/plugins`, `@tanstack/ai-harness/plugins/node`, and `@tanstack/ai-harness/plugins/coding` give you those parts. You pick the model and the rules. ## 1. Define the agent @@ -28,14 +28,13 @@ import { } from '@tanstack/ai-harness' import { compact, - fileCommands, modelPicker, permissions, - projectInstructions, title, todos, usage, } from '@tanstack/ai-harness/plugins' +import { fileCommands, projectInstructions } from '@tanstack/ai-harness/plugins/node' import { snapshots, workspaceTools } from '@tanstack/ai-harness/plugins/coding' import { openaiText } from '@tanstack/ai-openai' import { memoryPersistence } from '@tanstack/ai-persistence' @@ -64,7 +63,7 @@ const coder = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(coder, { threadId: 'main' }) +const session = await host.open({ harness: coder, threadId: 'main' }) const terminal = createInterface({ input: process.stdin, output: process.stdout, @@ -92,7 +91,7 @@ while (line !== 'exit') { if (line.startsWith('/')) { // `/undo`, `/model fast`, `/mode plan`, and the other plugin commands. const [name = '', ...args] = line.slice(1).split(' ') - console.log(await session.command(name, args.join(' ') || undefined)) + console.log(await session.command({ name, input: args.join(' ') || undefined })) } else { const turn = session.send(line) turn.onEvent('TEXT_MESSAGE_CONTENT', ({ event }) => { @@ -112,7 +111,7 @@ terminal.close() await host.close() ``` -Both plugin entries run only in Node. The coding tools use the file system and the shell of the machine. A line that starts with `/` runs a command of a plugin. Any other line is a prompt. +The `plugins/node` and `plugins/coding` entries run only in Node. The coding tools use the file system and the shell of the machine. A line that starts with `/` runs a command of a plugin. Any other line is a prompt. ## 2. Run it diff --git a/docs/harness/coding-agents.md b/docs/harness/coding-agents.md index 125577e14c..0711c75003 100644 --- a/docs/harness/coding-agents.md +++ b/docs/harness/coding-agents.md @@ -81,7 +81,7 @@ import { createHarnessHost } from '@tanstack/ai-harness' import { memoryPersistence } from '@tanstack/ai-persistence' const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(lead, { threadId: 'date-parser' }) +const session = await host.open({ harness: lead, threadId: 'date-parser' }) const turn = await session.send( 'Have claude_code add a test for the date parser, then have codex fix what fails.', diff --git a/docs/harness/coding-tools-backends.md b/docs/harness/coding-tools-backends.md index 1641fe93e3..04ffd2ae84 100644 --- a/docs/harness/coding-tools-backends.md +++ b/docs/harness/coding-tools-backends.md @@ -246,7 +246,7 @@ A formatter runs code from the project, like `prettier.config.js`. The model can One tool result with a full log or a big JSON file fills the context of the model. `boundToolOutput()` cuts every tool result that is too long: ```ts group=harness-coding-tools-backends -import { boundToolOutput } from '@tanstack/ai-harness/plugins' +import { boundToolOutput } from '@tanstack/ai-harness/plugins/node' export const bounded = defineHarness({ name: 'acme/bounded-coder', diff --git a/docs/harness/coding-tools.md b/docs/harness/coding-tools.md index 577d2a0a99..c9b942f23c 100644 --- a/docs/harness/coding-tools.md +++ b/docs/harness/coding-tools.md @@ -70,7 +70,7 @@ const coder = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(coder, { threadId: 'main' }) +const session = await host.open({ harness: coder, threadId: 'main' }) const terminal = createInterface({ input: process.stdin, output: process.stdout, @@ -284,8 +284,8 @@ const repos = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(repos, { threadId: 'fix-login' }) -await session.configure({ cwd: 'web-app' }) +const session = await host.open({ harness: repos, threadId: 'fix-login' }) +await session.configure({ settings: { cwd: 'web-app' } }) ``` - Relative paths start in `/srv/repos/web-app`, and `bash` runs there. diff --git a/docs/harness/compaction.md b/docs/harness/compaction.md index aca25bbc1c..5bf570766f 100644 --- a/docs/harness/compaction.md +++ b/docs/harness/compaction.md @@ -21,7 +21,7 @@ Set `durable: true`. Compaction then writes a `tanstack.compaction` record into 1. Create the compaction middleware with `durable: true`. 2. Add it to `middleware` in `defineHarness`. -3. Give the host `projectCompaction` in its `project` option. +3. Give the harness `projectCompaction` in its `durability.project` option. ```ts group=harness-compaction import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' @@ -50,12 +50,12 @@ const assistant = defineHarness({ name: 'acme/assistant', adapter: openaiText('gpt-6.1-sol'), middleware: [compaction], + durability: { project: { version: 'v1', record: projectCompaction } }, }) const { runs, metadata } = memoryPersistence().stores const host = createHarnessHost({ persistence: { stores: { log: memoryLogStore(), runs, metadata } }, - project: { version: 'v1', record: projectCompaction }, }) ``` @@ -95,7 +95,7 @@ Sometimes a summary does not apply: ## Fold your own records too -If your host already folds records of its own, call `projectCompaction` first: +If your harness already folds records of its own, call `projectCompaction` first: ```ts group=harness-compaction import type { ProjectOptions } from '@tanstack/ai-harness' diff --git a/docs/harness/connect.md b/docs/harness/connect.md index 8d6b23afe1..937bc88ac4 100644 --- a/docs/harness/connect.md +++ b/docs/harness/connect.md @@ -18,7 +18,8 @@ Your harness runs on a server, but the people who use it are in a browser or ano Mount one fetch handler on a route. `authorize` is required, so no endpoint is open by accident. ```ts group=harness-connect -import { defineHarness, createHarnessHandler, createHarnessHost } from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' @@ -42,7 +43,7 @@ export const handler = createHarnessHandler({ `canAccess` is required. It decides which threads each principal can use. To let every principal use every thread, pass `canAccess: () => true`. -On a durable host (`stores.log`), also set `mediaSecret`. It signs media URLs, so every instance must use the same value. Without a log store, the default is a random key, and signed URLs stop working after a restart. +On a durable host (`stores.log`), also set `media: { secret }`. The secret signs media URLs, so every instance must use the same value. Without a log store, the default is a random key, and signed URLs stop working after a restart. The handler answers these paths under your route: @@ -127,7 +128,8 @@ A background agent runs for minutes, and the user wants to correct it from the p ```ts group=harness-connect-agents import { defineAgent } from '@tanstack/ai' -import { createHarnessHandler, createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' @@ -309,7 +311,7 @@ The join has these rules: For one connection that carries events and inputs, authorize the upgrade, then hand the socket to `handleHarnessSocket`: ```ts group=harness-connect -import { handleHarnessSocket } from '@tanstack/ai-harness' +import { handleHarnessSocket } from '@tanstack/ai-harness/server' import type { WebSocketLike } from '@tanstack/ai' export function onUpgrade(socket: WebSocketLike) { @@ -334,7 +336,7 @@ import { chat } from '@tanstack/ai' import { harnessText } from '@tanstack/ai-harness' const stream = chat({ - adapter: harnessText(assistant, { host }), + adapter: harnessText({ harness: assistant, host }), messages: [{ role: 'user', content: 'Fix the failing test.' }], threadId: 'outer-thread', }) @@ -342,7 +344,7 @@ const stream = chat({ Each outer thread gets its own inner session, so the harness keeps its own history. The inner session gets every content part of the last user message, so images and files go through too. -If a plugin picks the model of the harness, the harness has no adapter to read its input kinds from. Pass them with `harnessText(assistant, { host, inputModalities: ['text', 'image'] })`. +If a plugin picks the model of the harness, the harness has no adapter to read its input kinds from. Pass them with `harnessText({ harness: assistant, host, inputModalities: ['text', 'image'] })`. ## What you have now diff --git a/docs/harness/custom-ui.md b/docs/harness/custom-ui.md index d262a8d1da..8020d52c32 100644 --- a/docs/harness/custom-ui.md +++ b/docs/harness/custom-ui.md @@ -59,7 +59,7 @@ const assistant = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(assistant, { threadId: 'thread-1' }) +const session = await host.open({ harness: assistant, threadId: 'thread-1' }) const view = createSessionView(session) await view.ready @@ -395,7 +395,8 @@ To send files with a message, give their records to `view.send`. This code sends ```ts group=harness-custom-ui import { readFile } from 'node:fs/promises' -const screenshot = await session.putMedia(await readFile('./bug.png'), { +const screenshot = await session.putMedia({ + body: await readFile('./bug.png'), mimeType: 'image/png', name: 'bug.png', }) @@ -446,7 +447,8 @@ Your harness runs on a server, and your UI is a web page. Give `createSessionVie Serve the session with `createHarnessHandler`, and mount the handler on `/api/harness`: ```ts group=harness-custom-ui-server -import { createHarnessHandler, createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' diff --git a/docs/harness/durable-sessions.md b/docs/harness/durable-sessions.md index d18ea03936..805b68afeb 100644 --- a/docs/harness/durable-sessions.md +++ b/docs/harness/durable-sessions.md @@ -143,7 +143,7 @@ The session claims its thread for its host when the thread gets work: - A queued turn that the session recovers. - A background agent run. -The session renews the claim on the lease timer: every `lease.renewMs`, for `lease.ttlMs`. See [One host drives a thread](#one-host-drives-a-thread) to set them. +The session renews the claim on the lease timer: every `ownership.renewMs`, for `ownership.ttlMs`. See [One host drives a thread](#one-host-drives-a-thread) to set them. The session gives the claim back when the thread is idle: no queued or running turn, no waiting steer, and no running agent. A thread that waits for a human (an approval or a sign-in) is idle, so a sweep never takes it. @@ -341,12 +341,12 @@ When the owner stops, its claim expires. Then the next host that opens the threa Without `workClaims`, a host cannot see the owner. When a second host writes to the thread, the first host's session stops, and its running turn fails with a clear error. -The lease decides when a stopped host counts as gone. Change its timing on the host: +The lease decides when a stopped host counts as gone. Change its timing with the `ownership` option of the host: ```ts group=harness-durable const leasedHost = createHarnessHost({ persistence: { stores: { log: memoryLogStore(), runs } }, - lease: { ttlMs: 60_000, renewMs: 20_000 }, + ownership: { ttlMs: 60_000, renewMs: 20_000 }, }) ``` @@ -374,7 +374,7 @@ Your job queue or submission table can already have leases. Give the host a `Lea - `release`: the host gives the lease back when the attempt ends. It also gets `ownerId` and `expiresAt`. - `isAlive`: after a crash, the host asks if another host still runs the attempt. -A host with `leases` does not need `runs`. If you give both, `leases` decides. The `lease` option sets the timing, as in the section above. +A host with `leases` does not need `runs`. If you give both, `leases` decides. The `ownership` option sets the timing, as in the section above. ```ts group=harness-durable import type { LeaseStore, TurnLease, TurnLeaseKey } from '@tanstack/ai-persistence' diff --git a/docs/harness/fork-and-reset.md b/docs/harness/fork-and-reset.md index f17ae9dcc6..79205483bd 100644 --- a/docs/harness/fork-and-reset.md +++ b/docs/harness/fork-and-reset.md @@ -31,7 +31,8 @@ export const assistant = defineHarness({ const host = createHarnessHost({ persistence: memoryPersistence() }) export async function branch(threadId: string, messageId: string) { - const fork = await host.fork(assistant, { + const fork = await host.fork({ + harness: assistant, threadId, newThreadId: `${threadId}-branch-${messageId}`, at: messageId, @@ -66,7 +67,7 @@ export async function editAndResend(threadId: string, messageId: string, text: s threadId, at: { before: messageId }, }) - const fork = await host.open(assistant, { threadId: entry.threadId }) + const fork = await host.open({ harness: assistant, threadId: entry.threadId }) await fork.send(text) return entry.threadId } @@ -79,14 +80,14 @@ export async function editAndResend(threadId: string, messageId: string, text: s ## Reset with a handoff note -`session.reset(note)` starts a fresh model context. From the next turn, the model sees only the note and what comes after the reset: +`session.reset({ note })` starts a fresh model context. From the next turn, the model sees only the note and what comes after the reset: ```ts group=harness-fork-and-reset export async function startFresh(threadId: string) { - const session = await host.open(assistant, { threadId }) - return session.reset( - 'Summary: the user moves the billing service to the new region. The DNS change is done. The database move is next.', - ) + const session = await host.open({ harness: assistant, threadId }) + return session.reset({ + note: 'Summary: the user moves the billing service to the new region. The DNS change is done. The database move is next.', + }) } ``` diff --git a/docs/harness/goal.md b/docs/harness/goal.md index 045d37c0c6..19e96e6c38 100644 --- a/docs/harness/goal.md +++ b/docs/harness/goal.md @@ -30,7 +30,7 @@ export const coder = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(coder, { threadId: 'fix-tests' }) +const session = await host.open({ harness: coder, threadId: 'fix-tests' }) ``` `judge` is the model that decides if the goal is met. It can be a smaller model than the main model, or the same model. @@ -38,7 +38,7 @@ const session = await host.open(coder, { threadId: 'fix-tests' }) ## 2. Give it a goal ```ts group=harness-goal -await session.command('goal', 'all tests pass') +await session.command({ name: 'goal', input: 'all tests pass' }) ``` The first turn starts. Each new turn tells the model the reason of the last check: @@ -47,7 +47,7 @@ The first turn starts. Each new turn tells the model the reason of the last chec Keep working on the goal: all tests pass. Last check: The date parser test still fails. ``` -Run `session.command('goal')` to see the result: +Run `session.command({ name: 'goal' })` to see the result: ```text Goal: all tests pass diff --git a/docs/harness/inputs.md b/docs/harness/inputs.md index 386a240f66..d621b1ed92 100644 --- a/docs/harness/inputs.md +++ b/docs/harness/inputs.md @@ -35,7 +35,7 @@ const host = createHarnessHost({ stores: { log: memoryLogStore(), runs: memoryPersistence().stores.runs }, }, }) -const session = await host.open(assistant, { threadId: 'user-1-thread' }) +const session = await host.open({ harness: assistant, threadId: 'user-1-thread' }) const turn = session.send('Summarize the report.', { inputId: 'req-42' }) const receipt = await turn.receipt @@ -185,7 +185,7 @@ A message that arrives while the model writes its final answer still gets an ans Waiting `steer` inputs join before each model call, in the order they arrived. They join as a prefix: the join stops at the first input that has an abort request, or that `turn.canJoin` refuses. The inputs after it wait. - The `operationId` in the receipt of a `busy: 'steer'` or `mode: 'steer'` message is the running turn. A cancel with that id cancels the turn. -- A `busy: 'steer'` message also has its own operation, `joined`. To stop it before it joins, call `joined.cancel()` or `session.cancel(joined.id)`. It does not join, and it settles `aborted` when the running turn ends. +- A `busy: 'steer'` message also has its own operation, `joined`. To stop it before it joins, call `joined.cancel()` or `session.cancel({ operationId: joined.id })`. It does not join, and it settles `aborted` when the running turn ends. - The session rejects a cancel that comes after the join started, with `reason: 'not_running'`. - A refused or late input runs as its own turn after the running one. @@ -215,7 +215,7 @@ await migration - `delivery: 'steer'`: the input joins the running turn at the next model call. `'queue'`: it runs as its own turn, after the turns before it. - A cancelled input never runs, also after a restart. It settles `aborted`. - On a durable host, the log keeps a new delivery, and a restart obeys it. Without a log, the delivery stays in memory. -- If the input started, or joins the running turn now, the receipt has `status: 'rejected'` and `reason: 'not_waiting'`. To stop it, cancel its operation with `session.cancel(operationId)`. +- If the input started, or joins the running turn now, the receipt has `status: 'rejected'` and `reason: 'not_waiting'`. To stop it, cancel its operation with `session.cancel({ operationId })`. A client reads the waiting inputs from the snapshot, and sends the same two inputs: @@ -308,7 +308,7 @@ const records = defineHarness({ adapter: openaiText('gpt-6.1-sol'), tools: [openRecord], }) -const recordSession = await host.open(records, { threadId: 'user-1-records' }) +const recordSession = await host.open({ harness: records, threadId: 'user-1-records' }) await recordSession.send('Summarize the record I have open.', { context: { recordId: 'inv-42' }, diff --git a/docs/harness/media.md b/docs/harness/media.md index d12e77e3f9..af84b57a0f 100644 --- a/docs/harness/media.md +++ b/docs/harness/media.md @@ -21,7 +21,8 @@ A session saves a file with `putMedia`. `mediaPart(record)` makes the content pa ```ts group=harness-media-code import { readFile } from 'node:fs/promises' -import { createHarnessHost, defineHarness, mediaPart } from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { mediaPart } from '@tanstack/ai-harness/media' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' @@ -31,9 +32,10 @@ const assistant = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(assistant, { threadId: 'thread-1' }) +const session = await host.open({ harness: assistant, threadId: 'thread-1' }) -const scan = await session.putMedia(await readFile('./receipt.png'), { +const scan = await session.putMedia({ + body: await readFile('./receipt.png'), mimeType: 'image/png', name: 'receipt.png', }) @@ -50,7 +52,7 @@ console.log(turn.text) The session also has: - `getMedia(id)`: the record, or `null` when the thread has no file with this id. -- `loadMedia(id, range?)`: the bytes, or one range of them. +- `loadMedia({ id, range })`: the bytes, or one range of them. `range` is optional. - `mediaUrl(id)`: `{ url }`, with a data URL for a file up to 1 MB. A bigger file gets `{}`. Use `loadMedia` for it, and serve the bytes from your own route. ## Media that agents make @@ -107,7 +109,7 @@ import { createHarnessHost } from '@tanstack/ai-harness' import { memoryPersistence } from '@tanstack/ai-persistence' const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(studio, { threadId: 'thread-1' }) +const session = await host.open({ harness: studio, threadId: 'thread-1' }) const turn = session.send('Draw a lighthouse at night.') turn.onMedia(({ media }) => { @@ -128,7 +130,7 @@ A media record has: - `source`: `'user'` for a file that a user sent, and `'generated'` for a file that an agent made. - `runId` and `subagentRunId`: the turn and the child agent run that made the file. -At the end of a turn, the harness adds its records to the last assistant message, in `metadata.harness.media`. So the media comes back after a restart. Read the records with `mediaOfMessage(message)`. A failed or cancelled turn keeps its media in the store only. +At the end of a turn, the harness adds its records to the last assistant message, in `metadata.harness.media`. So the media comes back after a restart. Read the records with `mediaOfMessage(message)` from `@tanstack/ai-harness/media`. A failed or cancelled turn keeps its media in the store only. - If the harness cannot save a file, the agent still gets its result. The turn publishes a `harness.plugin.warning` event. - An agent that you run from code (`session.agents`, or `ctx.agents` in a plugin) also saves its media and publishes the event. This media is not added to the transcript. diff --git a/docs/harness/overview.md b/docs/harness/overview.md index 42ab38fabe..3e7dd64f9b 100644 --- a/docs/harness/overview.md +++ b/docs/harness/overview.md @@ -31,6 +31,36 @@ octane: @tanstack/ai @tanstack/ai-harness @tanstack/ai-persistence @tanstack/ai- +## Which import to use + +Import each name from the entry that holds it. Only four entries load Node modules: `plugins/node`, `plugins/coding`, `build`, and `worker`. All other entries also run on an edge runtime. + +For your app and its routes: + +| Import | Holds | +| --- | --- | +| `@tanstack/ai-harness` | `defineHarness`, `createHarnessHost`, `harnessOf`, `definePlugin`, `durableTool`, `harnessText`, `oauthConnector`, and the session and agent types. | +| `@tanstack/ai-harness/server` | `createHarnessHandler` and `handleHarnessSocket`. For your own transport: `applyInput`, `parseHarnessInput`, `parseControlFrame`, `capabilitiesOf`, and `HARNESS_PROTOCOL_VERSION`. | +| `@tanstack/ai-harness/client` | `createHarnessClient`, a typed client for a browser or another process. | +| `@tanstack/ai-harness/view` | `createSessionView`, the live state of a session for a UI. | + +For plugins: + +| Import | Holds | +| --- | --- | +| `@tanstack/ai-harness/plugins` | The first-party plugins that load no Node module: `permissions`, `modelPicker`, `todos`, `goal`, `providerKeys`, and more. | +| `@tanstack/ai-harness/plugins/node` | The first-party plugins that read files. Node only: `agents`, `builtInAgents`, `fileCommands`, `projectInstructions`, and `boundToolOutput`. | +| `@tanstack/ai-harness/plugins/coding` | The coding tools. Node only: `workspaceTools`, `snapshots`, and `formatter`. | + +For helpers and builds: + +| Import | Holds | +| --- | --- | +| `@tanstack/ai-harness/oauth` | The OAuth helpers: `loopbackLogin`, `deviceLogin`, `exchangeCode`, `refreshCredential`, and more. | +| `@tanstack/ai-harness/media` | The media helpers: `mediaPart`, `mediaOfMessage`, `MediaError`, and more. | +| `@tanstack/ai-harness/build` | `buildHarness` and `artifactText`, to ship a harness as a worker. Node only. | +| `@tanstack/ai-harness/worker` | `runHarnessWorker`, which runs a harness on stdin and stdout. Node only. | + ## 1. Define the harness `defineHarness` takes the `chat()` options you know (`adapter`, `systemPrompts`, `tools`, `middleware`, `subagents`), plus a `name`. Importing it starts nothing. @@ -69,7 +99,7 @@ import { createHarnessHost } from '@tanstack/ai-harness' import { memoryPersistence } from '@tanstack/ai-persistence' const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(assistant, { threadId: 'thread-1' }) +const session = await host.open({ harness: assistant, threadId: 'thread-1' }) ``` Opening the same `threadId` again returns the live session. @@ -192,7 +222,8 @@ export const longCache = defineHarness({ To change it for one session, pass `promptCache` to `host.open`. The session value wins over the harness value: ```ts group=harness-first -const support = await host.open(assistant, { +const support = await host.open({ + harness: assistant, threadId: 'thread-2', promptCache: { key: 'acme-support' }, }) diff --git a/docs/harness/permissions.md b/docs/harness/permissions.md index d2a59313c4..affb913883 100644 --- a/docs/harness/permissions.md +++ b/docs/harness/permissions.md @@ -50,7 +50,7 @@ export const notes = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(notes, { threadId: 'notes-1' }) +const session = await host.open({ harness: notes, threadId: 'notes-1' }) ``` The rule makes each `save_note` call wait for the user. @@ -87,10 +87,10 @@ Switch the mode from server code: ```ts group=harness-permissions await session.setConfig({ key: 'mode', value: 'plan' }) // Or run the command. It returns 'Mode: plan.' -await session.command('mode', 'plan') +await session.command({ name: 'mode', input: 'plan' }) ``` -`session.command('mode')` with no input shows the current mode. In `acceptEdits` mode, an edit outside `root` or of a `.env` file still asks. +`session.command({ name: 'mode' })` with no input shows the current mode. In `acceptEdits` mode, an edit outside `root` or of a `.env` file still asks. ### Keep the mode on the server @@ -251,7 +251,7 @@ A call that asks waits for one of three answers: ### See and forget saved answers -1. Run `session.command('permissions')`. The command lists the saved rules of the project: +1. Run `session.command({ name: 'permissions' })`. The command lists the saved rules of the project: ```text Saved rules: @@ -259,7 +259,7 @@ Saved rules: 2. read_file .env ``` -2. Run `session.command('permissions', 'forget 2')` to delete rule 2. The open session asks again at the next `read_file` call for `.env`. +2. Run `session.command({ name: 'permissions', input: 'forget 2' })` to delete rule 2. The open session asks again at the next `read_file` call for `.env`. A client can run the command only when `expose.commands` has `permissions`. diff --git a/docs/harness/plugins.md b/docs/harness/plugins.md index e0b9907427..196d052b8d 100644 --- a/docs/harness/plugins.md +++ b/docs/harness/plugins.md @@ -80,7 +80,7 @@ export const greeter = definePlugin({ }) ``` -- `session.command('greet', { name: 'Ada' })` runs the command. A Standard Schema input (zod here) is checked, and the command gets the parsed value. A JSON Schema input is not checked, the same as a JSON Schema tool input. +- `session.command({ name: 'greet', input: { name: 'Ada' } })` runs the command. A Standard Schema input (zod here) is checked, and the command gets the parsed value. A JSON Schema input is not checked, the same as a JSON Schema tool input. - `session.setConfig({ key: 'tone', value: 'warm' })` changes the setting. A value the option does not accept is rejected. - `ask` waits for `session.answer({ questionId, value })`. The question goes to `session.onQuestion` listeners, and it stays in `session.snapshot().pendingQuestions` until someone answers. @@ -368,7 +368,7 @@ import { watch } from 'node:fs' import { createHarnessHost } from '@tanstack/ai-harness' const host = createHarnessHost() -const session = await host.open(assistant, { threadId: 'thread-1' }) +const session = await host.open({ harness: assistant, threadId: 'thread-1' }) // One session. await session.reload() @@ -402,4 +402,4 @@ If a `setup` throws, the session keeps working with no plugins. The promise reje - Plugins that share services, lists, and events without knowing each other. - Commands, agents, and whole plugins that change while the session runs. -Next: add the first-party plugins from `@tanstack/ai-harness/plugins`. [Ask before risky tool calls](./permissions) and [Switch between agent profiles](./agents) are two of them. +Next: add the first-party plugins from `@tanstack/ai-harness/plugins` and `@tanstack/ai-harness/plugins/node`. [Ask before risky tool calls](./permissions) and [Switch between agent profiles](./agents) are two of them. diff --git a/docs/harness/provider-keys.md b/docs/harness/provider-keys.md index 399851f599..4d93404f63 100644 --- a/docs/harness/provider-keys.md +++ b/docs/harness/provider-keys.md @@ -70,7 +70,8 @@ export const assistant = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(assistant, { +const session = await host.open({ + harness: assistant, threadId: 'ada-chat', principal: { id: 'ada' }, }) @@ -93,7 +94,7 @@ const stopAsking = session.onEvent('CUSTOM', async ({ event }) => { if (question) await session.answer({ questionId: question.questionId, value: 'sk-proj-...' }) }) -console.log(await session.command('connect:openai')) // Connected to OpenAI (key ...abcd). +console.log(await session.command({ name: 'connect:openai' })) // Connected to OpenAI (key ...abcd). stopAsking() ``` diff --git a/docs/harness/session-log.md b/docs/harness/session-log.md index 95fb95cee8..0412a92b96 100644 --- a/docs/harness/session-log.md +++ b/docs/harness/session-log.md @@ -31,7 +31,7 @@ const assistant = defineHarness({ const host = createHarnessHost({ persistence: { stores: { log, runs: memoryPersistence().stores.runs } }, }) -const session = await host.open(assistant, { threadId: 'thread-1' }) +const session = await host.open({ harness: assistant, threadId: 'thread-1' }) await session.append([{ type: 'app.signal', text: 'The build failed.' }]) ``` @@ -43,7 +43,7 @@ await session.append([{ type: 'app.signal', text: 'The build failed.' }]) ## Fold records into the model context -By default, the model does not see your records. Give the host a `project` function that folds a record into the messages. It gets one object, `{ messages, record }`, and returns the new list, or `undefined` for no change: +By default, the model does not see your records. Set `durability.project` on the harness to fold a record into the messages. Its `record` function gets one object, `{ messages, record }`, and returns the new list, or `undefined` for no change: ```ts group=harness-session-log import type { ProjectOptions } from '@tanstack/ai-harness' @@ -58,9 +58,10 @@ const project: ProjectOptions = { }, } -const projectedHost = createHarnessHost({ - persistence: { stores: { log, runs: memoryPersistence().stores.runs } }, - project, +export const projected = defineHarness({ + name: 'acme/projected', + adapter: openaiText('gpt-6.1-sol'), + durability: { project }, }) ``` @@ -72,29 +73,33 @@ const projectedHost = createHarnessHost({ ## Let a client add records -A webhook or a browser knows something that the model needs: the build failed, or a payment arrived. Let it add a record to the thread, and the next turn reads the record through `project`. +A webhook or a browser knows something that the model needs: the build failed, or a payment arrived. Let it add a record to the thread, and the next turn reads the record through `durability.project`. -1. On the server, list the record types in `expose.records`. Serve the harness from the host that has `project`: +1. On the server, list the record types in `expose.records`, and set `durability.project` on the same harness: ```ts group=harness-session-log -import { createHarnessHandler } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' export const watcher = defineHarness({ name: 'acme/build-watcher', adapter: openaiText('gpt-6.1-sol'), expose: { records: ['app.signal'] }, + durability: { project }, }) +// A durable host signs media URLs with the same secret on every instance. +const mediaSecret = process.env.HARNESS_MEDIA_SECRET +if (!mediaSecret) throw new Error('Set HARNESS_MEDIA_SECRET.') + export const handler = createHarnessHandler({ - host: projectedHost, + host, harness: watcher, authorize: (request) => request.headers.get('authorization') === `Bearer ${process.env.HARNESS_TOKEN}` ? { id: 'ci-bot' } : null, canAccess: () => true, - // A durable host signs media URLs with the same secret on every instance. - mediaSecret: process.env.HARNESS_MEDIA_SECRET, + media: { secret: mediaSecret }, }) ``` @@ -140,12 +145,12 @@ console.log(await messages.loadThread('thread-1')) ## Tune how text streams into the log -A durable host merges streamed text and tool call arguments for `coalesceMs` (default 100) before it appends them. A message start or end, a tool call start or end, and your own records append at once. +A durable host merges streamed text and tool call arguments for `log.coalesceMs` milliseconds (default 100) before it appends them. A message start or end, a tool call start or end, and your own records append at once. ```ts group=harness-session-log const smoothHost = createHarnessHost({ persistence: { stores: { log, runs: memoryPersistence().stores.runs } }, - coalesceMs: 250, + log: { coalesceMs: 250 }, }) ``` diff --git a/docs/harness/sessions.md b/docs/harness/sessions.md index 5b5b014010..fa0f901cc2 100644 --- a/docs/harness/sessions.md +++ b/docs/harness/sessions.md @@ -35,7 +35,8 @@ export const assistant = defineHarness({ const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(assistant, { +const session = await host.open({ + harness: assistant, threadId: 'chat-1', principal: { id: 'ada' }, }) @@ -115,7 +116,7 @@ const forked = await host.sessions.fork({ threadId: 'chat-1', at: { before: first.id }, }) -const fork = await host.open(assistant, { threadId: forked.threadId }) +const fork = await host.open({ harness: assistant, threadId: forked.threadId }) await fork.send('Plan a trip to Porto.') ``` diff --git a/docs/harness/shared-logs.md b/docs/harness/shared-logs.md index 868f790455..96e31a021d 100644 --- a/docs/harness/shared-logs.md +++ b/docs/harness/shared-logs.md @@ -34,8 +34,9 @@ const host = createHarnessHost({ persistence: { stores: { log, runs, metadata } }, }) -const parent = await host.open(assistant, { threadId: 'agent-1' }) -const child = await host.open(assistant, { +const parent = await host.open({ harness: assistant, threadId: 'agent-1' }) +const child = await host.open({ + harness: assistant, threadId: 'task-1', logId: 'agent-1', }) @@ -66,7 +67,7 @@ Here the child record and the link in the parent land together, or not at all. ## Keep state for the whole log -Give the host a `reduce` option. It folds every host record of the log, from all sessions, in log order. Then `host.logState` reads the result: +Give the harness a `durability.reduce` option. It folds every host record of the log, from all sessions, in log order. Then `host.logState` reads the result: ```ts group=harness-shared-logs import type { ReduceOptions } from '@tanstack/ai-harness' @@ -84,23 +85,25 @@ const reduce: ReduceOptions = { : state, } -const trackedHost = createHarnessHost({ - persistence: { stores: { log, runs, metadata } }, - reduce, +const tracker = defineHarness({ + name: 'acme/tracker', + adapter: openaiText('gpt-6.1-sol'), + durability: { reduce }, }) -const tracked = await trackedHost.open(assistant, { threadId: 'agent-2' }) +const tracked = await host.open({ harness: tracker, threadId: 'agent-2' }) await tracked.append([ { type: 'app.child_created', thread: 'task-2', task: 'research' }, ]) -console.log(trackedHost.logState('agent-2')?.tasks) +console.log(host.logState({ harness: tracker, logId: 'agent-2' })?.tasks) ``` - `record` must be pure. It returns the next state, or the same state for no change. -- `host.logState(logId)` works for a log that has an open session in this host. Otherwise it returns `undefined`. +- `host.logState({ harness, logId })` works for a log that has an open session in this host. Otherwise it returns `undefined`. - The type of the state flows from `initial` and `record`. Here `logState` returns `Children | undefined`. +- If sessions of several harnesses share one log, the harness of the first session that opens the log folds it. For the other harnesses, `logState` returns `undefined`. - Change `version` when you change `record`. A fold checkpoint with another version is ignored. -- `project` still works. It folds a host record into the messages of its own session only. +- `durability.project` also works with `reduce`. It folds a host record into the messages of its own session only. ## One writer per log @@ -124,5 +127,5 @@ console.log(await messages.loadThread('task-1')) - Many sessions in one durable log, each with its own transcript. - Appends that reach several sessions and land all together or not at all. -- One state for the whole log, from `reduce`, that you read with `host.logState`. +- One state for the whole log, from `durability.reduce`, that you read with `host.logState`. - A clear rule for conflicts: one writer, and a fresh fold on the next open. diff --git a/docs/harness/shared-threads.md b/docs/harness/shared-threads.md index 6da9613c88..1c1d5ede7f 100644 --- a/docs/harness/shared-threads.md +++ b/docs/harness/shared-threads.md @@ -19,11 +19,8 @@ Several people write in one thread: a team in one chat, or a customer and a supp `authorize` decides who sends each request. Each message runs as the principal that it returns. If your users belong to organizations, add `tenantId`: ```ts group=harness-shared-threads -import { - createHarnessHandler, - createHarnessHost, - defineHarness, -} from '@tanstack/ai-harness' +import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' +import { createHarnessHandler } from '@tanstack/ai-harness/server' import { memoryPersistence } from '@tanstack/ai-persistence' import { openaiText } from '@tanstack/ai-openai' @@ -88,11 +85,11 @@ Bob sends his message while the turn of Ada runs. His message waits. Then it run On the server, give the sender to `send` yourself. `resolve` and `command` take `principal` too. Without it, the message runs as the person who opened the session: ```ts group=harness-shared-threads -const session = await host.open(assistant, { threadId: 'team-thread' }) +const session = await host.open({ harness: assistant, threadId: 'team-thread' }) const ada = { id: 'ada', tenantId: 'acme' } session.send('Summarize the open issues.', { principal: ada }) -session.command('connect:github', undefined, { principal: ada }) +session.command({ name: 'connect:github', principal: ada }) ``` A command acts for the person who runs it. In its `run` context, `session.principal` is that person, and `session.send` runs as that person. diff --git a/docs/harness/snapshots.md b/docs/harness/snapshots.md index e2533874ad..11b4c311b4 100644 --- a/docs/harness/snapshots.md +++ b/docs/harness/snapshots.md @@ -41,7 +41,7 @@ const log = memoryLogStore() const host = createHarnessHost({ persistence: { stores: { log, runs: memoryPersistence().stores.runs } }, }) -const session = await host.open(coder, { threadId: 'fix-login' }) +const session = await host.open({ harness: coder, threadId: 'fix-login' }) ``` - `root` is the workspace folder, an absolute path. @@ -52,9 +52,9 @@ const session = await host.open(coder, { threadId: 'fix-login' }) ## 2. Undo and redo a turn 1. Send a prompt that changes files: `await session.send('Fix the login form.')`. -2. Run `await session.command('undo')`. It returns the number of files that it put back, for example `Undid the last turn. Files restored: 3.` +2. Run `await session.command({ name: 'undo' })`. It returns the number of files that it put back, for example `Undid the last turn. Files restored: 3.` 3. Look at the files that the turn changed: they are as they were before the turn. The turn is gone from the conversation too. -4. To bring back the files and the turn, run `await session.command('redo')`. It returns `Redid the last turn.` +4. To bring back the files and the turn, run `await session.command({ name: 'redo' })`. It returns `Redid the last turn.` In a terminal app, the user types `/undo` and `/redo`. See [Build a coding agent](./coding-agent). diff --git a/docs/harness/subagents.md b/docs/harness/subagents.md index 8d637a4f03..f15af9c955 100644 --- a/docs/harness/subagents.md +++ b/docs/harness/subagents.md @@ -75,6 +75,8 @@ The harness saves the images, audio, and video that these agents make. See [Medi A background agent can work for a long time. When its host stops, for example in a deploy, the next host fails the run. Start it with `resume: true`, and the next host runs it again instead: ```ts group=harness-subagents +import { harnessOf } from '@tanstack/ai-harness' + // Your own services. async function startExport(customer: string) { return `export-${customer}` @@ -88,7 +90,7 @@ const migrator = defineAgent({ description: 'Moves the data of one customer to the new region', inputSchema: z.object({ customer: z.string() }), run: async (ctx) => { - const { step } = ctx.harness + const { step } = harnessOf(ctx) // Each side effect is a step. A finished step does not run again. const exportId = await step.do('export', () => startExport(ctx.input.customer)) await step.do('import', () => importInto('eu-west', exportId)) @@ -119,10 +121,10 @@ export const migrations = definePlugin({ ``` 1. Give the host a session log (`stores.log`). See [Durable sessions](./durable-sessions). Without a log, `start` with `resume: true` throws. -2. Put each side effect in `ctx.harness.step.do(name, fn)`. The agent code runs again from its start, and a finished step returns its stored value. +2. Put each side effect in `harnessOf(ctx).step.do(name, fn)`. The agent code runs again from its start, and a finished step returns its stored value. 3. Give each step a name that is the same on every run, and return a JSON value from `fn`. -`ctx.harness` is on the context of every agent run that a session starts. Outside such a run, `step.do` runs `fn` each time. +`harnessOf(ctx)` returns the harness context of the run. Every agent run that a session starts has one. In an agent that the model calls as a tool, `step.do` runs `fn` each time. A run that a plain `chat()` starts has no harness context, so `harnessOf` throws a `TypeError`. What the next host does: @@ -156,7 +158,7 @@ export const studio = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(studio, { threadId: 'user-1-thread' }) +const session = await host.open({ harness: studio, threadId: 'user-1-thread' }) const run = session.agents.drafter.start() @@ -212,14 +214,16 @@ A run and all of its follow-ups keep their messages in one thread: `:< ### Start agents from agent code -An agent can start other agents in the background with `ctx.harness.agents.start(agent, input, options)`. It returns a run that you can await, message, and cancel: +An agent can start other agents in the background with `harnessOf(ctx).agents.start(agent, input, options)`. It returns a run that you can await, message, and cancel: ```ts group=harness-subagents-messages +import { harnessOf } from '@tanstack/ai-harness' + const editor = defineAgent({ name: 'editor', description: 'Starts two drafts and joins them', run: async (ctx) => { - const { agents } = ctx.harness + const { agents } = harnessOf(ctx) const intro = agents.start(drafter) const outro = agents.start(drafter) await intro.send('Keep it under 100 words.') @@ -326,7 +330,7 @@ const studio = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(studio, { threadId: 'user-1-thread' }) +const session = await host.open({ harness: studio, threadId: 'user-1-thread' }) await session.send('Write a short intro, then have the writer edit it.') for (const message of await session.transcript()) { @@ -455,7 +459,7 @@ const reviewer = defineHarness({ export const lead = defineHarness({ name: 'acme/lead', adapter: openaiText('gpt-6.1-sol'), - subagents: { agents: [harnessAgent(reviewer)] }, + subagents: { agents: [harnessAgent({ harness: reviewer })] }, }) ``` diff --git a/docs/harness/thread-settings.md b/docs/harness/thread-settings.md index 9de666b134..8c1aa8f9da 100644 --- a/docs/harness/thread-settings.md +++ b/docs/harness/thread-settings.md @@ -38,12 +38,14 @@ const host = createHarnessHost({ persistence: memoryPersistence() }) ## 2. Store the settings of a thread ```ts group=harness-thread-settings -const session = await host.open(assistant, { threadId: 'review-thread' }) +const session = await host.open({ harness: assistant, threadId: 'review-thread' }) const receipt = await session.configure({ - model: 'strong', - reasoning: 'high', - instructions: 'Answer in French.', + settings: { + model: 'strong', + reasoning: 'high', + instructions: 'Answer in French.', + }, }) console.log(receipt.status) // 'accepted' console.log(session.settings()) // { model: 'strong', reasoning: 'high', instructions: 'Answer in French.' } @@ -52,7 +54,7 @@ console.log(session.settings()) // { model: 'strong', reasoning: 'high', instruc The settings apply from the next turn. A field that you leave out stays as it is. Set a field to `null` to clear it: ```ts group=harness-thread-settings -await session.configure({ instructions: null }) +await session.configure({ settings: { instructions: null } }) ``` The six fields: @@ -81,7 +83,7 @@ const { models, settings } = session.describe() console.log(models) // ['fast', 'strong'] console.log(settings.model) // 'strong' -await session.configure({ model: 'fast' }) +await session.configure({ settings: { model: 'fast' } }) ``` ## What wins @@ -107,8 +109,8 @@ For `reasoning`, the `overrides` of the prompt win, then the stored setting, the `cwd` is the working folder of the thread, for the plugins whose tools work in a folder. A plugin reads it at each call with `ctx.session.settings().cwd`, so a change applies from the next turn: ```ts group=harness-thread-settings -const task = await host.open(assistant, { threadId: 'fix-login' }) -await task.configure({ cwd: 'web-app' }) +const task = await host.open({ harness: assistant, threadId: 'fix-login' }) +await task.configure({ settings: { cwd: 'web-app' } }) console.log(task.settings().cwd) // 'web-app' ``` diff --git a/docs/harness/tool-runs.md b/docs/harness/tool-runs.md index 8895278d43..961a8922da 100644 --- a/docs/harness/tool-runs.md +++ b/docs/harness/tool-runs.md @@ -17,7 +17,7 @@ A cron job or a webhook needs to run one tool, not hold a conversation. You want ## Run a tool from server code -Call `session.tool(name, args?)`. A schedule or a webhook handler calls it. +Call `session.tool({ name, args })`. A schedule or a webhook handler calls it. ```ts group=harness-tool-runs import { createHarnessHost, defineHarness } from '@tanstack/ai-harness' @@ -40,10 +40,10 @@ const dashboard = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(dashboard, { threadId: 'ops-thread' }) +const session = await host.open({ harness: dashboard, threadId: 'ops-thread' }) // From a cron job, every hour: -const run = session.tool('fetchStats', { window: '1h' }) +const run = session.tool({ name: 'fetchStats', args: { window: '1h' } }) const { text } = await run ``` @@ -53,18 +53,18 @@ const { text } = await run - While an approval waits, `session.tool` is refused with `pending_interrupts`. Answer the approval first. - Pass `inputId` to make the run idempotent, as [`send`](./inputs) does. -TypeScript types `args` from the input schema of a harness tool. `session.tool('fetchStats', { window: 1 })` does not compile. A plugin tool takes any `args`, and its input schema checks them when the tool runs. +TypeScript types `args` from the input schema of a harness tool. `session.tool({ name: 'fetchStats', args: { window: 1 } })` does not compile. A plugin tool takes any `args`, and its input schema checks them when the tool runs. ## Tag a run with its trigger Pass `meta` to record why the tool ran, for example the schedule or the webhook event. The harness sends it as a `tanstack.injection` custom event with the tool call id. ```ts group=harness-tool-runs -const fromWebhook = session.tool( - 'fetchStats', - { window: '24h' }, - { meta: { trigger: 'webhook', deliveryId: 'whk-99' } }, -) +const fromWebhook = session.tool({ + name: 'fetchStats', + args: { window: '24h' }, + meta: { trigger: 'webhook', deliveryId: 'whk-99' }, +}) await fromWebhook ``` diff --git a/docs/harness/turn-control.md b/docs/harness/turn-control.md index e121275f88..ea07bbafa0 100644 --- a/docs/harness/turn-control.md +++ b/docs/harness/turn-control.md @@ -186,7 +186,7 @@ A message with `busy: 'steer'` waits while a turn runs. Before each model call, The join stops at the first message that: -- has an abort request (`session.cancel(operationId)` on it), or +- has an abort request (`session.cancel({ operationId })` on it), or - `turn.canJoin` refuses. That message and the ones after it wait. `canJoin` runs again before each model call and at each final answer, so it can run more than once for one message. Keep it a quick check with no side effects. What still waits when the turn ends runs as its own turn, before other queued turns. A cancelled one settles `aborted` and does not run. @@ -282,7 +282,7 @@ const readDiff = toolDefinition({ }).server(async () => 'diff --git a/src/cart.ts b/src/cart.ts') const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(resilient, { threadId: 'thread-1' }) +const session = await host.open({ harness: resilient, threadId: 'thread-1' }) const review = await session.send('Review the open pull request.', { overrides: { diff --git a/docs/harness/usage.md b/docs/harness/usage.md index f206d819e2..7e39cb7deb 100644 --- a/docs/harness/usage.md +++ b/docs/harness/usage.md @@ -27,7 +27,8 @@ export const assistant = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(assistant, { +const session = await host.open({ + harness: assistant, threadId: 'team-thread', principal: { id: 'ada' }, }) @@ -90,7 +91,7 @@ Read the totals of the sender before you send a prompt. This route refuses a pro const BUDGET_USD = 5 export async function send(threadId: string, userId: string, text: string) { - const thread = await host.open(assistant, { threadId }) + const thread = await host.open({ harness: assistant, threadId }) const spent = thread.usage().bySender[userId]?.cost ?? 0 if (spent >= BUDGET_USD) { return { error: 'You used your budget for this thread.' } @@ -140,7 +141,7 @@ export const priced = defineHarness({ }) ``` -`session.command('usage')` reads `session.usage()` and shows the total. When the thread used more than one model, it also shows a line for each model: +`session.command({ name: 'usage' })` reads `session.usage()` and shows the total. When the thread used more than one model, it also shows a line for each model: ```text 3 model calls, 41200 input tokens (30000 cache read, 0 cache write), 2100 output tokens, 43300 total. Cost: $0.0432. diff --git a/packages/ai-harness/README.md b/packages/ai-harness/README.md index bb7562a637..ddccf8f14d 100644 --- a/packages/ai-harness/README.md +++ b/packages/ai-harness/README.md @@ -53,7 +53,7 @@ const assistant = defineHarness({ }) const host = createHarnessHost({ persistence: memoryPersistence() }) -const session = await host.open(assistant, { threadId: 'thread-1' }) +const session = await host.open({ harness: assistant, threadId: 'thread-1' }) const turn = await session.send('Hello!') console.log(turn.text) From ca411d0c836373be64dc8ebfa3fe728045fe32d0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 16 Oct 2026 19:55:45 +0200 Subject: [PATCH 08/11] refactor(ai-harness): one-object parameters for the host, session, client, view, and helpers --- packages/ai-acp/src/agent/index.ts | 14 +- packages/ai-acp/tests/agent-handlers.test.ts | 12 +- packages/ai-acp/tests/agent-media.test.ts | 6 +- packages/ai-code-mode/tests/harness.test.ts | 75 ++-- packages/ai-harness-cli/src/attach.ts | 5 +- packages/ai-harness-cli/src/commands.ts | 2 +- packages/ai-harness-cli/src/index.ts | 11 +- packages/ai-harness-cli/src/session-view.ts | 2 +- packages/ai-harness-cli/tests/attach.test.ts | 16 +- .../ai-harness-cli/tests/child-view.test.ts | 8 +- .../ai-harness-cli/tests/commands.test.ts | 8 +- packages/ai-harness/src/build.ts | 2 +- packages/ai-harness/src/client.ts | 130 +++++-- packages/ai-harness/src/harness-agent.ts | 28 +- packages/ai-harness/src/harness-text.ts | 25 +- packages/ai-harness/src/host.ts | 63 ++-- packages/ai-harness/src/http.ts | 34 +- packages/ai-harness/src/plugins.ts | 2 +- packages/ai-harness/src/protocol.ts | 44 ++- packages/ai-harness/src/session/agents.ts | 4 +- packages/ai-harness/src/session/base.ts | 19 +- .../ai-harness/src/session/harness-session.ts | 4 +- packages/ai-harness/src/session/requests.ts | 122 ++++--- packages/ai-harness/src/session/state.ts | 31 +- packages/ai-harness/src/session/turn.ts | 31 +- packages/ai-harness/src/session/types.ts | 8 + packages/ai-harness/src/types.ts | 42 +++ packages/ai-harness/src/view/index.ts | 90 +++-- packages/ai-harness/src/worker.ts | 17 +- .../ai-harness/tests/agent-middleware.test.ts | 6 +- .../ai-harness/tests/agent-resume.test.ts | 20 +- packages/ai-harness/tests/agent-runs.test.ts | 38 +- packages/ai-harness/tests/agents.test.ts | 36 +- packages/ai-harness/tests/append-op.test.ts | 24 +- packages/ai-harness/tests/auth-wait.test.ts | 77 ++-- packages/ai-harness/tests/auth.test.ts | 35 +- .../ai-harness/tests/background-tools.test.ts | 8 +- .../ai-harness/tests/bound-output.test.ts | 2 +- packages/ai-harness/tests/build.test.ts | 14 +- .../ai-harness/tests/client-media.test.ts | 16 +- .../ai-harness/tests/client-reads.test.ts | 2 +- packages/ai-harness/tests/client.test.ts | 21 +- .../ai-harness/tests/coding-backend.test.ts | 8 +- .../ai-harness/tests/coding-formatter.test.ts | 26 +- .../ai-harness/tests/coding-snapshots.test.ts | 42 +-- packages/ai-harness/tests/define.test.ts | 8 +- .../ai-harness/tests/discover-tools.test.ts | 16 +- .../tests/durable-background.test.ts | 32 +- .../ai-harness/tests/durable-inputs.test.ts | 104 +++--- .../ai-harness/tests/durable-joins.test.ts | 30 +- .../ai-harness/tests/durable-session.test.ts | 124 +++---- .../ai-harness/tests/durable-tools.test.ts | 40 +-- packages/ai-harness/tests/extensions.test.ts | 82 +++-- packages/ai-harness/tests/first-party.test.ts | 30 +- packages/ai-harness/tests/fork.test.ts | 48 +-- packages/ai-harness/tests/forwarding.test.ts | 109 +++--- packages/ai-harness/tests/goal.test.ts | 50 +-- .../tests/harness-text-media.test.ts | 14 +- .../ai-harness/tests/harness-text.test.ts | 20 +- .../ai-harness/tests/input-sender.test.ts | 127 ++++--- .../ai-harness/tests/instructions.test.ts | 8 +- .../tests/interrupt-resolve.test.ts | 134 +++---- .../tests/interrupt-restart.test.ts | 231 +++++++----- packages/ai-harness/tests/leases.test.ts | 54 +-- .../ai-harness/tests/live-commands.test.ts | 20 +- .../tests/mid-conversation-changes.test.ts | 13 +- packages/ai-harness/tests/oauth-edges.test.ts | 18 +- packages/ai-harness/tests/on-event.test.ts | 10 +- packages/ai-harness/tests/permissions.test.ts | 42 +-- .../ai-harness/tests/plugin-agents.test.ts | 12 +- packages/ai-harness/tests/plugin-apis.test.ts | 20 +- .../ai-harness/tests/plugin-subagents.test.ts | 16 +- .../ai-harness/tests/prepare-tools.test.ts | 8 +- .../ai-harness/tests/prompt-cache.test.ts | 26 +- packages/ai-harness/tests/protocol.test.ts | 4 +- .../ai-harness/tests/provider-keys.test.ts | 31 +- packages/ai-harness/tests/question.test.ts | 18 +- packages/ai-harness/tests/reload.test.ts | 24 +- .../ai-harness/tests/remote-worker.test.ts | 14 +- .../tests/replay-adapter-parity.test.ts | 80 +++-- packages/ai-harness/tests/reset.test.ts | 55 +-- .../ai-harness/tests/resume-edges.test.ts | 38 +- packages/ai-harness/tests/resume.test.ts | 50 +-- packages/ai-harness/tests/routing.test.ts | 14 +- packages/ai-harness/tests/run-route.test.ts | 21 +- packages/ai-harness/tests/send.test.ts | 8 +- .../ai-harness/tests/session-hooks.test.ts | 12 +- .../ai-harness/tests/session-media.test.ts | 36 +- .../ai-harness/tests/session-plugins.test.ts | 243 +++++++------ .../ai-harness/tests/session-reads.test.ts | 14 +- .../ai-harness/tests/session-surface.test.ts | 18 +- packages/ai-harness/tests/session.test.ts | 330 ++++++++++-------- .../ai-harness/tests/sessions-index.test.ts | 74 ++-- packages/ai-harness/tests/shared-log.test.ts | 76 ++-- packages/ai-harness/tests/status-feed.test.ts | 12 +- .../tests/subagent-children.test.ts | 15 +- .../ai-harness/tests/subagent-router.test.ts | 14 +- packages/ai-harness/tests/subagents.test.ts | 28 +- .../ai-harness/tests/system-preamble.test.ts | 12 +- .../ai-harness/tests/thread-settings.test.ts | 84 +++-- packages/ai-harness/tests/title-usage.test.ts | 18 +- packages/ai-harness/tests/tool-op.test.ts | 78 +++-- .../ai-harness/tests/transport-edges.test.ts | 84 +++-- .../ai-harness/tests/turn-control.test.ts | 56 +-- .../ai-harness/tests/turn-overrides.test.ts | 12 +- packages/ai-harness/tests/usage.test.ts | 42 ++- .../tests/view-client-tools.test.ts | 34 +- packages/ai-harness/tests/view-media.test.ts | 4 +- packages/ai-harness/tests/view-paths.test.ts | 32 +- packages/ai-harness/tests/view.test.ts | 40 ++- packages/ai-harness/tests/work-claims.test.ts | 34 +- .../ai-harness/tests/workspace-tools.test.ts | 11 +- packages/ai-mcp/src/harness.ts | 46 ++- packages/ai-mcp/tests/connector-edges.test.ts | 61 ++-- packages/ai-mcp/tests/connector.test.ts | 46 +-- packages/ai-mcp/tests/harness-plugin.test.ts | 15 +- packages/ai-mcp/tests/harness.test.ts | 4 +- .../ai-sandbox/tests/coding-agents.test.ts | 8 +- .../tests/workspace-backend.test.ts | 8 +- packages/ai-skills/tests/harness.test.ts | 18 +- testing/e2e/src/routes/api.coding-tools.ts | 26 +- .../e2e/src/routes/api.harness-continue.ts | 8 +- .../e2e/src/routes/api.harness-first-party.ts | 70 ++-- .../e2e/src/routes/api.harness-forwarding.ts | 4 +- .../e2e/src/routes/api.harness-protocol.$.ts | 2 +- .../e2e/src/routes/api.harness-retry-after.ts | 8 +- 126 files changed, 2723 insertions(+), 2047 deletions(-) diff --git a/packages/ai-acp/src/agent/index.ts b/packages/ai-acp/src/agent/index.ts index 31e07b9964..f0cb49c45e 100644 --- a/packages/ai-acp/src/agent/index.ts +++ b/packages/ai-acp/src/agent/index.ts @@ -75,7 +75,9 @@ async function storedPart( `The data of ${info.name} is not base64.`, ) } - return mediaPart(await session.putMedia(Buffer.from(data, 'base64'), info)) + return mediaPart( + await session.putMedia({ body: Buffer.from(data, 'base64'), ...info }), + ) } /** One ACP prompt block as a content part, or `undefined` to skip it. */ @@ -247,7 +249,7 @@ export function createAcpAgent(options: AcpAgentOptions): AgentApp { // The host gives back the live session of a thread, or opens it again after // it closed, so the agent keeps no session objects. const live = (sessionId: string) => - host.open(harness, { threadId: sessionId }) + host.open({ harness, threadId: sessionId }) /** The session of an id that this agent or the session index knows. */ const known = async (sessionId: string) => { @@ -308,7 +310,7 @@ export function createAcpAgent(options: AcpAgentOptions): AgentApp { payload: 'optionId' in outcome && outcome.optionId === 'allow', }) } - const receipt = await session.resolve(resume) + const receipt = await session.resolve({ resume }) // For example `busy`: a later prompt runs already. if (receipt.status === 'rejected') throw new InputRejectedError(receipt) return { @@ -327,12 +329,12 @@ export function createAcpAgent(options: AcpAgentOptions): AgentApp { const closeInterrupts = async (session: HarnessSession) => { const open = session.snapshot().pendingInterrupts if (open.length === 0) return true - const receipt = await session.resolve( - open.map((interrupt) => ({ + const receipt = await session.resolve({ + resume: open.map((interrupt) => ({ interruptId: interrupt.id, status: 'cancelled' as const, })), - ) + }) if (receipt.status === 'rejected') throw new InputRejectedError(receipt) // ponytail: the reply of the model to the cancel stays out of the client. const turn = receipt.operationId diff --git a/packages/ai-acp/tests/agent-handlers.test.ts b/packages/ai-acp/tests/agent-handlers.test.ts index 3c30efb84b..76854f7d58 100644 --- a/packages/ai-acp/tests/agent-handlers.test.ts +++ b/packages/ai-acp/tests/agent-handlers.test.ts @@ -361,9 +361,9 @@ describe('ACP agent handlers', () => { const { harness, host, editor } = setup() close = () => host.close() const other = defineHarness({ name: 'test/other', adapter: model() }) - await host.open(other, { threadId: 'other-thread' }) + await host.open({ harness: other, threadId: 'other-thread' }) for (let index = 0; index < 50; index++) { - await host.open(harness, { threadId: `old-${index}` }) + await host.open({ harness, threadId: `old-${index}` }) } await editor.connectWith( createAcpAgent({ host, harness, cwd: '/work' }), @@ -611,7 +611,7 @@ describe('ACP agent handlers', () => { sessionId, prompt: [{ type: 'text', text: 'hello' }], }) - const session = await host.open(harness, { threadId: sessionId }) + const session = await host.open({ harness, threadId: sessionId }) await vi.waitFor(() => expect(session.snapshot().status).toBe('running')) await ctx.notify('session/cancel', { sessionId }) await vi.waitFor(() => @@ -646,7 +646,7 @@ describe('ACP agent handlers', () => { cwd: '/tmp', mcpServers: [], }) - const session = await host.open(harness, { threadId: sessionId }) + const session = await host.open({ harness, threadId: sessionId }) vi.spyOn(session, 'resolve').mockResolvedValue({ inputId: 'input-1', status: 'rejected', @@ -700,8 +700,8 @@ describe('ACP agent handlers', () => { const { harness, host, editor, idle, running, textOf } = setup() close = () => host.close() // Sessions from before: the session index knows them. - await host.open(harness, { threadId: 'kept-session' }) - await host.open(harness, { threadId: 'slow-session' }) + await host.open({ harness, threadId: 'kept-session' }) + await host.open({ harness, threadId: 'slow-session' }) await editor.connectWith(createAcpAgent({ host, harness }), async (ctx) => { await ctx.request('initialize', { protocolVersion: 2, diff --git a/packages/ai-acp/tests/agent-media.test.ts b/packages/ai-acp/tests/agent-media.test.ts index 7e8b66f881..44614aa95e 100644 --- a/packages/ai-acp/tests/agent-media.test.ts +++ b/packages/ai-acp/tests/agent-media.test.ts @@ -125,7 +125,7 @@ async function savedPrompt(prompt: Array) { await ctx.request('session/prompt', { sessionId, prompt }) return { sessionId } }) - const session = await host.open(harness, { threadId: sent.sessionId }) + const session = await host.open({ harness, threadId: sent.sessionId }) // The prompt goes to the harness after the response, so wait for it. const user = await vi.waitFor(async () => { const [first] = await session.transcript() @@ -205,7 +205,7 @@ describe('ACP agent media', () => { }, }, ]) - expect(await session.loadMedia(record.id)).toEqual( + expect(await session.loadMedia({ id: record.id })).toEqual( new Uint8Array([1, 2, 3, 4]), ) }, @@ -259,7 +259,7 @@ describe('ACP agent media', () => { return sessionId }) - const session = await host.open(harness, { threadId }) + const session = await host.open({ harness, threadId }) expect(await session.transcript()).toEqual([]) }) }) diff --git a/packages/ai-code-mode/tests/harness.test.ts b/packages/ai-code-mode/tests/harness.test.ts index ed11a2a565..8858479ffc 100644 --- a/packages/ai-code-mode/tests/harness.test.ts +++ b/packages/ai-code-mode/tests/harness.test.ts @@ -202,15 +202,15 @@ async function firstToolNames(options: { }) { const { adapter, calls } = scripted([textTurn('ok')]) const host = createHarnessHost({ persistence: memoryPersistence() }) - const session = await host.open( - defineHarness({ + const session = await host.open({ + harness: defineHarness({ name: 'test/code-mode-marked', adapter, tools: options.tools, plugins: () => options.plugins, }), - { threadId: 't' }, - ) + threadId: 't', + }) await session.send('hi') await host.close() return calls[0].tools.map((tool: { name: string }) => tool.name) @@ -224,15 +224,15 @@ describe('codeMode', () => { textTurn('done'), ]) const host = createHarnessHost({ persistence: memoryPersistence() }) - const session = await host.open( - defineHarness({ + const session = await host.open({ + harness: defineHarness({ name: 'test/code-mode', adapter, tools: [tools.lookup, tools.remove, tools.askUser], plugins: () => [writes, notion, codeMode({ driver })], }), - { threadId: 't' }, - ) + threadId: 't', + }) const turn = await session.send('look it up') expect(turn.text).toBe('done') @@ -259,8 +259,8 @@ describe('codeMode', () => { const { driver } = fakeDriver() const { adapter, calls } = scripted([textTurn('one'), textTurn('two')]) const host = createHarnessHost({ persistence: memoryPersistence() }) - const picked = await host.open( - defineHarness({ + const picked = await host.open({ + harness: defineHarness({ name: 'test/code-mode-include', adapter, tools: [tools.lookup, tools.remove], @@ -268,23 +268,23 @@ describe('codeMode', () => { codeMode({ driver, include: (tool) => tool.name === 'remove' }), ], }), - { threadId: 'picked' }, - ) + threadId: 'picked', + }) await picked.send('hi') expect(calls[0].tools.map((tool: { name: string }) => tool.name)).toEqual([ 'lookup', 'execute_typescript', ]) - const none = await host.open( - defineHarness({ + const none = await host.open({ + harness: defineHarness({ name: 'test/code-mode-none', adapter, tools: [tools.remove], plugins: () => [codeMode({ driver })], }), - { threadId: 'none' }, - ) + threadId: 'none', + }) await none.send('hi') expect(calls[1].tools.map((tool: { name: string }) => tool.name)).toEqual([ 'remove', @@ -302,15 +302,15 @@ describe('codeMode', () => { textTurn('found it'), ]) const host = createHarnessHost({ persistence: memoryPersistence() }) - const session = await host.open( - defineHarness({ + const session = await host.open({ + harness: defineHarness({ name: 'test/code-mode-lazy', adapter, tools: [tools.lookup, tools.remove], plugins: () => [codeMode({ driver, lazy: true })], }), - { threadId: 't' }, - ) + threadId: 't', + }) expect((await session.send('what can you call?')).text).toBe('found it') expect(calls[0].tools.map((tool: { name: string }) => tool.name)).toEqual([ @@ -351,8 +351,8 @@ describe('codeMode', () => { }).server(async () => reply) const { adapter, calls } = scripted([codeTurn('return 1'), textTurn('ok')]) const host = createHarnessHost({ persistence: memoryPersistence() }) - const session = await host.open( - defineHarness({ + const session = await host.open({ + harness: defineHarness({ name: 'test/code-mode-names', adapter, tools: [ @@ -362,8 +362,8 @@ describe('codeMode', () => { ], plugins: () => [codeMode({ driver })], }), - { threadId: 't' }, - ) + threadId: 't', + }) await session.send('search') // web_search clashes with web-search after the rename, so it stays direct. expect(calls[0].tools.map((tool: { name: string }) => tool.name)).toEqual([ @@ -486,8 +486,8 @@ describe('codeMode', () => { const { driver, seen } = fakeDriver() const { adapter } = scripted([codeTurn('return 1'), textTurn('done')]) const host = createHarnessHost({ persistence: memoryPersistence() }) - const session = await host.open( - defineHarness({ + const session = await host.open({ + harness: defineHarness({ name: 'test/code-mode-allow', adapter, tools: [tools.lookup], @@ -503,9 +503,10 @@ describe('codeMode', () => { codeMode({ driver }), ], }), - { threadId: 't' }, - ) - if (mode === 'plan') await session.command('mode', 'plan') + threadId: 't', + }) + if (mode === 'plan') + await session.command({ name: 'mode', input: 'plan' }) expect((await session.send('look it up')).text).toBe('done') // The program ran: a question would wait, and plan mode would deny it. expect(seen).toHaveLength(1) @@ -517,8 +518,8 @@ describe('codeMode', () => { const { driver, seen } = fakeDriver() const { adapter } = scripted([codeTurn('return 1'), textTurn('done')]) const host = createHarnessHost({ persistence: memoryPersistence() }) - const session = await host.open( - defineHarness({ + const session = await host.open({ + harness: defineHarness({ name: 'test/code-mode-user-rule', adapter, tools: [tools.lookup], @@ -530,8 +531,8 @@ describe('codeMode', () => { codeMode({ driver }), ], }), - { threadId: 't' }, - ) + threadId: 't', + }) const turn = session.send('look it up') await vi.waitFor(() => expect(session.snapshot().pendingQuestions).toHaveLength(1), @@ -553,8 +554,8 @@ describe('codeMode', () => { const { adapter, calls } = scripted([textTurn('one'), textTurn('two')]) let isBroken = false const host = createHarnessHost({ persistence: memoryPersistence() }) - const session = await host.open( - defineHarness({ + const session = await host.open({ + harness: defineHarness({ name: 'test/code-mode-failed-pick', adapter, tools: [tools.lookup], @@ -568,8 +569,8 @@ describe('codeMode', () => { }), ], }), - { threadId: 't' }, - ) + threadId: 't', + }) await session.send('first') expect(JSON.stringify(calls[0].systemPrompts)).toContain('external_lookup') diff --git a/packages/ai-harness-cli/src/attach.ts b/packages/ai-harness-cli/src/attach.ts index f4cfec99ce..c576fcd855 100644 --- a/packages/ai-harness-cli/src/attach.ts +++ b/packages/ai-harness-cli/src/attach.ts @@ -73,7 +73,8 @@ export async function attach( if (file === undefined) continue rest += text.slice(end, token.index) end = token.index + token[0].length - const record = await session.putMedia(file.bytes, { + const record = await session.putMedia({ + body: file.bytes, mimeType: file.mimeType, name: file.name, }) @@ -109,7 +110,7 @@ export async function saveMedia( `Cannot save ${JSON.stringify(name)}: it is not a plain file name.`, ) } - const bytes = await session.loadMedia(record.id) + const bytes = await session.loadMedia({ id: record.id }) await mkdir(dir, { recursive: true }) const dot = name.lastIndexOf('.') const stem = dot > 0 ? name.slice(0, dot) : name diff --git a/packages/ai-harness-cli/src/commands.ts b/packages/ai-harness-cli/src/commands.ts index 6d34b7d0d0..ddd90d332d 100644 --- a/packages/ai-harness-cli/src/commands.ts +++ b/packages/ai-harness-cli/src/commands.ts @@ -56,7 +56,7 @@ async function runCommand( ? show(outcome.value) : `/${name} failed: ${outcome.error instanceof Error ? outcome.error.message : String(outcome.error)}` const settled = Promise.resolve( - session.command(name, input).then( + session.command({ name, input }).then( (value) => ({ ok: true as const, value }), (error: unknown) => ({ ok: false as const, error }), ), diff --git a/packages/ai-harness-cli/src/index.ts b/packages/ai-harness-cli/src/index.ts index 190ba420bd..4c2315dac6 100644 --- a/packages/ai-harness-cli/src/index.ts +++ b/packages/ai-harness-cli/src/index.ts @@ -93,17 +93,17 @@ function attachingSource(session: HarnessSession) { operation.then(undefined, () => {}) return operation.receipt }), - resolve: (resume) => session.resolve(resume), - cancel: (operationId) => session.cancel(operationId), + resolve: (options) => session.resolve(options), + cancel: (options) => session.cancel(options), answer: (answer) => session.answer(answer), - command: (name, input) => session.command(name, input), + command: (options) => session.command(options), setConfig: (change) => session.setConfig(change), events: (options) => session.events(options), snapshot: () => session.snapshot(), transcript: () => session.transcript(), describe: () => session.describe(), mediaUrl: (id) => session.mediaUrl(id), - loadMedia: (id) => session.loadMedia(id), + loadMedia: (options) => session.loadMedia(options), } return source } @@ -249,7 +249,8 @@ export async function runCli( return EXIT.ok } - const session = await host.open(harness, { + const session = await host.open({ + harness, threadId: args.thread, ...(principal ? { principal } : {}), }) diff --git a/packages/ai-harness-cli/src/session-view.ts b/packages/ai-harness-cli/src/session-view.ts index 29dea42704..937b1504a2 100644 --- a/packages/ai-harness-cli/src/session-view.ts +++ b/packages/ai-harness-cli/src/session-view.ts @@ -13,7 +13,7 @@ export function resolveAll( status: 'resolved', payload: approved, })) - return session.resolve(resume) + return session.resolve({ resume }) } /** Open an http(s) URL in the default browser. No shell is involved. */ diff --git a/packages/ai-harness-cli/tests/attach.test.ts b/packages/ai-harness-cli/tests/attach.test.ts index b221ee60e0..69cca78c6a 100644 --- a/packages/ai-harness-cli/tests/attach.test.ts +++ b/packages/ai-harness-cli/tests/attach.test.ts @@ -73,10 +73,10 @@ beforeAll(async () => { await writeFile(join(dir, 'notes.xyz'), 'plain') await mkdir(join(dir, 'folder.png')) host = createHarnessHost({ persistence: memoryPersistence() }) - session = await host.open( - defineHarness({ name: 'test/attach', adapter: idleModel() }), - { threadId: 't' }, - ) + session = await host.open({ + harness: defineHarness({ name: 'test/attach', adapter: idleModel() }), + threadId: 't', + }) }) afterAll(async () => { @@ -95,7 +95,7 @@ async function storedFiles(input: UserInput) { return Promise.all( ids.map(async (id) => ({ name: (await session.getMedia(id))?.name, - text: new TextDecoder().decode(await session.loadMedia(id)), + text: new TextDecoder().decode(await session.loadMedia({ id })), })), ) } @@ -158,7 +158,8 @@ describe('attach', () => { describe('saveMedia', () => { it('makes the folder and adds a number instead of overwriting a file', async () => { - const record = await session.putMedia(new TextEncoder().encode('hello'), { + const record = await session.putMedia({ + body: new TextEncoder().encode('hello'), mimeType: 'image/png', name: 'cat.png', }) @@ -181,7 +182,8 @@ describe('saveMedia', () => { it.each(['../escaped.png', 'sub/escaped.png', 'sub\\escaped.png', '..'])( 'refuses the name %s', async (name) => { - const record = await session.putMedia(new TextEncoder().encode('x'), { + const record = await session.putMedia({ + body: new TextEncoder().encode('x'), mimeType: 'image/png', name, }) diff --git a/packages/ai-harness-cli/tests/child-view.test.ts b/packages/ai-harness-cli/tests/child-view.test.ts index f06771b9fa..9a7b373957 100644 --- a/packages/ai-harness-cli/tests/child-view.test.ts +++ b/packages/ai-harness-cli/tests/child-view.test.ts @@ -111,14 +111,14 @@ describe('line mode with a child agent', () => { say('lead done', 'l1'), ]) const host = createHarnessHost({ persistence: memoryPersistence() }) - const session = await host.open( - defineHarness({ + const session = await host.open({ + harness: defineHarness({ name: 'test/child-lines', adapter: lead, subagents: { agents: [helper] }, }), - { threadId: 't' }, - ) + threadId: 't', + }) const input = new PassThrough() let out = '' // This turn makes no media, so nothing is written to the media folder. diff --git a/packages/ai-harness-cli/tests/commands.test.ts b/packages/ai-harness-cli/tests/commands.test.ts index e1d9c650cf..b8454f18a0 100644 --- a/packages/ai-harness-cli/tests/commands.test.ts +++ b/packages/ai-harness-cli/tests/commands.test.ts @@ -139,14 +139,14 @@ const counter = defineAgent({ async function open(plugins = true) { const host = createHarnessHost({ persistence: memoryPersistence() }) - const session = await host.open( - defineHarness({ + const session = await host.open({ + harness: defineHarness({ name: 'test/cli-commands', adapter: model(), ...(plugins ? { agents: [counter], plugins: () => [tools] } : {}), }), - { threadId: 't' }, - ) + threadId: 't', + }) const say = async (line: string) => { const result = await handleLine(session, line) return result.type === 'notice' ? result.text : result.type diff --git a/packages/ai-harness/src/build.ts b/packages/ai-harness/src/build.ts index 7210fb7453..faed278d60 100644 --- a/packages/ai-harness/src/build.ts +++ b/packages/ai-harness/src/build.ts @@ -125,7 +125,7 @@ export async function buildHarness(options: BuildHarnessOptions): Promise<{ [ `import * as entry from ${JSON.stringify(entryPath)}`, `import { runHarnessWorker } from ${JSON.stringify(workerModule)}`, - `await runHarnessWorker(entry[${JSON.stringify(exportName)}])`, + `await runHarnessWorker({ harness: entry[${JSON.stringify(exportName)}] })`, ].join('\n'), ) await writeFile( diff --git a/packages/ai-harness/src/client.ts b/packages/ai-harness/src/client.ts index f260b1b218..4e973ffdb8 100644 --- a/packages/ai-harness/src/client.ts +++ b/packages/ai-harness/src/client.ts @@ -2,7 +2,7 @@ // package, plus `media-ref`, which has type-only imports itself. So no server // code (adapters, stores, secrets) reaches a client. import { isMediaRecord } from './media-ref' -import type { ModelMessage, RunAgentResumeItem } from '@tanstack/ai' +import type { ModelMessage } from '@tanstack/ai' import type { LogRecord, SessionIndexEntry, @@ -21,13 +21,18 @@ import type { SessionDescription, SessionSnapshot } from './session/index' import type { SessionEntryChange } from './session-entry' import type { BusyPolicy, + CancelOptions, + CommandOptions, + ConfigureOptions, Cursor, ForkPoint, HarnessInput, MediaRecord, + MediaUpload, Receipt, + ResetOptions, + ResolveOptions, SessionEvent, - ThreadSettingsChange, UserInput, WaitingInput, } from './types' @@ -105,8 +110,25 @@ export interface HarnessClient { inputId?: string ephemeral?: ReadonlyArray }) => Promise - resolve: (resume: Array) => Promise - cancel: (operationId?: string) => Promise + /** + * Answer the interrupts of the last turn, as `session.resolve` does. + * + * @example + * ```ts + * await client.resolve({ resume: [{ interruptId, status: 'resolved' }] }) + * ``` + */ + resolve: (options: ResolveOptions) => Promise + /** + * Stop the operation `operationId`, or the running turn without it, as + * `session.cancel` does. + * + * @example + * ```ts + * await client.cancel({ operationId }) + * ``` + */ + cancel: (options?: CancelOptions) => Promise /** * Cancel an input that waits (see `snapshot().waitingInputs`). An input * that started is refused with `not_waiting`: use `cancel`. @@ -123,18 +145,32 @@ export interface HarnessClient { agents: ClientAgentHandles /** Answer a question from a command or a plugin. */ answer: (answer: { questionId: string; value: unknown }) => Promise - /** Run a plugin command. Its result arrives as a `harness.command.result` event. */ - command: (name: string, input?: unknown) => Promise + /** + * Run the plugin command `name`. Its result arrives as a + * `harness.command.result` event. + * + * @example + * ```ts + * await client.command({ name: 'mode', input: 'plan' }) + * ``` + */ + command: (options: CommandOptions) => Promise /** * Run a harness tool with no model turn, as `session.tool` does. The * harness must list it in `expose.tools`. `args` is typed from the * tool's input schema. + * + * @example + * ```ts + * await client.tool({ name: 'sync_inbox', args: { folder: 'INBOX' } }) + * ``` */ - tool: ['name']>( - name: TName, - args?: HarnessToolInput, - options?: { inputId?: string; meta?: Record }, - ) => Promise + tool: ['name']>(options: { + name: TName + args?: HarnessToolInput + meta?: Record + inputId?: string + }) => Promise /** * Add host records to the session log, as `session.append` does. The * harness must list each record type in `expose.records`. @@ -142,13 +178,25 @@ export interface HarnessClient { append: (records: ReadonlyArray) => Promise /** Change a session setting. */ setConfig: (change: { key: string; value: unknown }) => Promise - /** Change the stored settings of the thread. See `session.configure`. */ - configure: (settings: ThreadSettingsChange) => Promise + /** + * Change the stored settings of the thread. See `session.configure`. + * + * @example + * ```ts + * await client.configure({ settings: { model: 'strong' } }) + * ``` + */ + configure: (options: ConfigureOptions) => Promise /** * Start a fresh model context from the next turn. The model sees `note` * first, when you give one. The transcript keeps every message. + * + * @example + * ```ts + * await client.reset({ note: 'The user wants a new plan.' }) + * ``` */ - reset: (note?: string, options?: { inputId?: string }) => Promise + reset: (options?: ResetOptions) => Promise /** * Go back to the message `messageId`, as `session.revert` does. The * harness must list `'undo'` in `expose.commands`. @@ -192,18 +240,29 @@ export interface HarnessClient { * Store a file in the media store of the thread. Send the record in a * prompt with `mediaPart(record)`. Throws when the handler refuses the * file, for example `413` for a file over the size limit. + * + * @example + * ```ts + * const record = await client.upload({ body: file, mimeType: file.type, name: file.name }) + * ``` */ upload: ( - body: Blob | ArrayBuffer | Uint8Array, - info: { name: string; mimeType: string }, + file: MediaUpload, ) => Promise /** * A signed URL for a media file, for ``, `