diff --git a/.changeset/abort-memory-hindsight-tools.md b/.changeset/abort-memory-hindsight-tools.md new file mode 100644 index 0000000000..bbf1b646e5 --- /dev/null +++ b/.changeset/abort-memory-hindsight-tools.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-memory': minor +--- + +The `hindsight_retain`, `hindsight_recall`, and `hindsight_reflect` tools now give the abort signal of the call to the Hindsight client. diff --git a/.changeset/ai-client-abort-signal-agents.md b/.changeset/ai-client-abort-signal-agents.md new file mode 100644 index 0000000000..f3cac932b8 --- /dev/null +++ b/.changeset/ai-client-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-client': minor +--- + +`createMcpAppBridge().callTool(input, { signal })` passes the signal to the call request. diff --git a/.changeset/ai-code-mode-abort-signal-agents.md b/.changeset/ai-code-mode-abort-signal-agents.md new file mode 100644 index 0000000000..fad03dddda --- /dev/null +++ b/.changeset/ai-code-mode-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-code-mode': minor +--- + +`IsolateContext.execute(code, options?)` takes an optional `{ signal }`. `execute_typescript` passes the abort signal of the tool call, so a driver can stop the run. diff --git a/.changeset/ai-computer-playwright-action-signal.md b/.changeset/ai-computer-playwright-action-signal.md new file mode 100644 index 0000000000..c020ba3e07 --- /dev/null +++ b/.changeset/ai-computer-playwright-action-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-computer-playwright': minor +--- + +`screenshot`, `zoom`, and `navigate` reject when their signal aborts. Playwright takes no signal, so the page call continues in the background. The factory closes the new browser context when `create` aborts during the start URL. diff --git a/.changeset/ai-computer-sandbox-abort-signal-agents.md b/.changeset/ai-computer-sandbox-abort-signal-agents.md new file mode 100644 index 0000000000..388770ac59 --- /dev/null +++ b/.changeset/ai-computer-sandbox-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-computer-sandbox': minor +--- + +Pass the `create()` signal to `provider.create()`. diff --git a/.changeset/ai-computer-sandbox-action-signal.md b/.changeset/ai-computer-sandbox-action-signal.md new file mode 100644 index 0000000000..5180794293 --- /dev/null +++ b/.changeset/ai-computer-sandbox-action-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-computer-sandbox': minor +--- + +The driver passes the signal of each action to `sandbox.process.exec`, so an abort stops the xdotool or screenshot command. `createSandboxComputerDriver({ signal })` stops the setup commands, and `sandboxComputer` passes the `create` signal to it. diff --git a/.changeset/ai-computer-use-abort-signal-agents.md b/.changeset/ai-computer-use-abort-signal-agents.md new file mode 100644 index 0000000000..8097a358a7 --- /dev/null +++ b/.changeset/ai-computer-use-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-computer-use': minor +--- + +`ComputerDriverFactory.create({ threadId, signal })` gets the abort signal of the tool call that needs the machine. diff --git a/.changeset/ai-computer-use-action-signal.md b/.changeset/ai-computer-use-action-signal.md new file mode 100644 index 0000000000..1c7882f347 --- /dev/null +++ b/.changeset/ai-computer-use-action-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-computer-use': minor +--- + +Each `ComputerDriver` action takes an optional `signal`, for example `move(x, y, { signal })` and `click(x, y, { button, count, signal })`. The computer tools pass the abort signal of the tool call to each action. An abort also stops the `wait` and `hold_key` waits and the wait before the screenshot. `keyUp` and `mouseUp` get no signal, so held keys and buttons are released after an abort. diff --git a/.changeset/ai-harness-abort-signal-agents.md b/.changeset/ai-harness-abort-signal-agents.md new file mode 100644 index 0000000000..9e5e244c35 --- /dev/null +++ b/.changeset/ai-harness-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-harness': minor +--- + +Each request method of `createHarnessClient` takes an optional last argument `{ signal }`: the control inputs, `snapshot`, `transcript`, `agentTranscript`, `describe`, `putMedia`, `mediaUrl`, `loadMedia`, and `sessions.*`. diff --git a/.changeset/ai-isolate-cloudflare-abort-signal-agents.md b/.changeset/ai-isolate-cloudflare-abort-signal-agents.md new file mode 100644 index 0000000000..4f3c13857e --- /dev/null +++ b/.changeset/ai-isolate-cloudflare-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-isolate-cloudflare': minor +--- + +Forward the `execute()` signal to the worker request. diff --git a/.changeset/ai-isolate-daytona-abort-signal.md b/.changeset/ai-isolate-daytona-abort-signal.md new file mode 100644 index 0000000000..8375979580 --- /dev/null +++ b/.changeset/ai-isolate-daytona-abort-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-isolate-daytona': minor +--- + +When the `execute()` signal aborts, `execute()` returns an `AbortError` failure and starts no more tool rounds. The Daytona SDK cannot cancel a `codeRun`, so a run that already started continues in the sandbox until its own timeout. diff --git a/.changeset/ai-isolate-e2b-abort-signal-agents.md b/.changeset/ai-isolate-e2b-abort-signal-agents.md new file mode 100644 index 0000000000..81de234349 --- /dev/null +++ b/.changeset/ai-isolate-e2b-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-isolate-e2b': minor +--- + +Kill the sandbox process group when the `execute()` signal aborts. The run returns an `AbortError` failure. diff --git a/.changeset/ai-isolate-node-abort-signal-agents.md b/.changeset/ai-isolate-node-abort-signal-agents.md new file mode 100644 index 0000000000..9c8c84b763 --- /dev/null +++ b/.changeset/ai-isolate-node-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-isolate-node': minor +--- + +Stop a running script when the `execute()` signal aborts. The run returns an `AbortError` failure. diff --git a/.changeset/ai-isolate-quickjs-abort-signal.md b/.changeset/ai-isolate-quickjs-abort-signal.md new file mode 100644 index 0000000000..242236aec3 --- /dev/null +++ b/.changeset/ai-isolate-quickjs-abort-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-isolate-quickjs': minor +--- + +Stop a run when the `execute()` signal aborts. The run returns an `AbortError` failure, and the context is disposed, the same as after a timeout. diff --git a/.changeset/ai-isolate-quickjs-bun-abort-signal.md b/.changeset/ai-isolate-quickjs-bun-abort-signal.md new file mode 100644 index 0000000000..077161443a --- /dev/null +++ b/.changeset/ai-isolate-quickjs-bun-abort-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-isolate-quickjs-bun': minor +--- + +Stop a run when the `execute()` signal aborts. The run returns an `AbortError` failure. The context stays usable. diff --git a/.changeset/ai-mcp-abort-signal-agents.md b/.changeset/ai-mcp-abort-signal-agents.md new file mode 100644 index 0000000000..f60b8cb9d3 --- /dev/null +++ b/.changeset/ai-mcp-abort-signal-agents.md @@ -0,0 +1,10 @@ +--- +'@tanstack/ai-mcp': minor +--- + +New optional signals. Old calls work the same. + +- `createMCPClient({ signal })` cancels the connect handshake. +- `tools({ signal })`, `resources({ signal })`, `readResource(uri, { signal })`, `resourceTemplates({ signal })`, `prompts({ signal })`, and `getPrompt(name, args, { signal })` send the signal to the MCP SDK. +- The lazy tool list in `callTool` uses the signal of the call. +- The handler from `createMcpAppCallHandler` takes `(req, { signal })` and passes it to the connect, the tool list, and the tool call. diff --git a/.changeset/ai-memory-abort-signal-agents.md b/.changeset/ai-memory-abort-signal-agents.md new file mode 100644 index 0000000000..2975acbc5c --- /dev/null +++ b/.changeset/ai-memory-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-memory': minor +--- + +`MemoryAdapter.recall(scope, query, { signal })` and `Embedder.embed(text, { signal })` take an optional signal. `memoryMiddleware` passes the chat run signal. `inMemory()`, `redis()`, and `mem0()` forward it. diff --git a/.changeset/ai-memory-provider-recall-signal.md b/.changeset/ai-memory-provider-recall-signal.md new file mode 100644 index 0000000000..95dd4a9f96 --- /dev/null +++ b/.changeset/ai-memory-provider-recall-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-memory': minor +--- + +The Hindsight adapter passes the `recall()` signal to `client.recall`. The Honcho adapter stops waiting when the signal aborts. The Honcho SDK takes no signal, so its request continues in the background. diff --git a/.changeset/ai-sandbox-abort-signal-agents.md b/.changeset/ai-sandbox-abort-signal-agents.md new file mode 100644 index 0000000000..6c0053c899 --- /dev/null +++ b/.changeset/ai-sandbox-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox': minor +--- + +New optional signals on the sandbox contract: `git.clone({ signal })`, `git.push(dir, { signal })`, `git.pull(dir, { signal })`, `snapshot(label, { signal })`, `fork({ signal })`, and `ports.connect(port, { signal })`. The exec-backed git passes the signal to each git command. diff --git a/.changeset/ai-sandbox-blaxel-abort-signal-agents.md b/.changeset/ai-sandbox-blaxel-abort-signal-agents.md new file mode 100644 index 0000000000..4dbf765c4e --- /dev/null +++ b/.changeset/ai-sandbox-blaxel-abort-signal-agents.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-blaxel': minor +--- + +Pass the `git.push` and `git.pull` signal to the git command. diff --git a/.changeset/ai-sandbox-boxd-k11-signal.md b/.changeset/ai-sandbox-boxd-k11-signal.md new file mode 100644 index 0000000000..aa39005b08 --- /dev/null +++ b/.changeset/ai-sandbox-boxd-k11-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-boxd': minor +--- + +When the `snapshot` signal aborts, `snapshot` stops polling for the capture. When the `fork` signal aborts, `fork` stops waiting for the new machine and deletes it. The boxd SDK takes no signal, so a started capture continues in boxd. diff --git a/.changeset/ai-sandbox-docker-k11-signal.md b/.changeset/ai-sandbox-docker-k11-signal.md new file mode 100644 index 0000000000..baf7ff2b4e --- /dev/null +++ b/.changeset/ai-sandbox-docker-k11-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-docker': minor +--- + +`snapshot(label, { signal })`, `fork({ signal })`, and `ports.connect(port, { signal })` pass the signal to dockerode as `abortSignal`, which aborts the Docker API request. diff --git a/.changeset/ai-sandbox-e2b-k11-signal.md b/.changeset/ai-sandbox-e2b-k11-signal.md new file mode 100644 index 0000000000..34f9caf8ac --- /dev/null +++ b/.changeset/ai-sandbox-e2b-k11-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-e2b': minor +--- + +`snapshot(label, { signal })` and `fork({ signal })` pass the signal to the E2B SDK, which aborts the request. diff --git a/.changeset/ai-sandbox-sprites-k11-signal.md b/.changeset/ai-sandbox-sprites-k11-signal.md new file mode 100644 index 0000000000..50443f5f67 --- /dev/null +++ b/.changeset/ai-sandbox-sprites-k11-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox-sprites': minor +--- + +`snapshot(label, { signal })` passes the signal to the checkpoint request. diff --git a/.changeset/ai-sandbox-tool-bridge-call-signal.md b/.changeset/ai-sandbox-tool-bridge-call-signal.md new file mode 100644 index 0000000000..4fca7e55e6 --- /dev/null +++ b/.changeset/ai-sandbox-tool-bridge-call-signal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-sandbox': minor +--- + +`ToolBridgeCore.callTool(name, args, { signal })` takes an optional signal for one call. The tool gets a signal that aborts when this signal or the bridge `signal` aborts. The host MCP bridge passes the request signal, so a closed `tools/call` request stops the bridged tool. diff --git a/docs/code-mode/code-mode-isolates.md b/docs/code-mode/code-mode-isolates.md index 22bdeeac00..0cad92f975 100644 --- a/docs/code-mode/code-mode-isolates.md +++ b/docs/code-mode/code-mode-isolates.md @@ -388,7 +388,10 @@ interface IsolateConfig { } interface IsolateContext { - execute(code: string): Promise> + execute( + code: string, + options?: { signal?: AbortSignal }, + ): Promise> dispose(): Promise } @@ -399,3 +402,9 @@ interface ExecutionResult { error?: NormalizedError } ``` + +Code Mode passes the abort signal of the tool call as `options.signal`. When it aborts, stop the run and return a failure. The built-in drivers do this: + +- The Node, Cloudflare, E2B, and QuickJS drivers stop the run at once. After an abort, the Node and QuickJS contexts are disposed, the same as after a timeout. +- The QuickJS on Bun driver stops the run between jobs. The context stays usable. +- The Daytona driver returns at once and starts no more tool rounds. The Daytona SDK cannot cancel a `codeRun`, so a run that started continues in the sandbox until its `timeout`. diff --git a/docs/computer-use/custom-driver.md b/docs/computer-use/custom-driver.md index 4f6f3bd65d..8481fcec48 100644 --- a/docs/computer-use/custom-driver.md +++ b/docs/computer-use/custom-driver.md @@ -107,6 +107,8 @@ Coordinates are in pixels, in `display` space. Key names are `KeyboardEvent.key` | `press(keys)` | Presses one combo, like `['Control', 's']`. | | `type(text)` | Types text as a user does. | +Each action also takes an optional `signal`, the abort signal of the tool call. It is the last argument, for example `move(x, y, { signal })`. `click` gets it in its options: `click(x, y, { button, count, signal })`. Pass it to your requests, so an action stops when the run stops. `keyUp` and `mouseUp` get no signal, because they release what an aborted action held down. + ### Add the optional methods Four methods are optional: @@ -191,7 +193,7 @@ The factory has these members: | `display` | The screen size of every machine. Each driver from `create` has this `display`. | | `environment` | The kind of every machine. | | `capabilities` | `{ zoom, cursorPosition, navigate }`: the optional methods that every driver from `create` has. The tool is built before any machine exists, so the factory tells it. | -| `create({ threadId })` | Makes the driver of one thread. If it throws, the model gets the error. The next model turn calls `create` again. | +| `create({ threadId, signal })` | Makes the driver of one thread. `signal` is the abort signal of the tool call that needs the machine. Pass it to your setup requests. If `create` throws, the model gets the error. The next model turn calls `create` again. | | `close()` | Optional. Frees what the threads share. `computerUse.close()` calls it after it closes every thread. | When a thread closes or goes idle, `createComputerUse` calls `close()` on the driver of that thread. diff --git a/docs/config.json b/docs/config.json index 0fa0528cb7..c72f59343a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -632,7 +632,7 @@ "label": "Manual MCP", "to": "tools/mcp-manual", "addedAt": "2026-06-05", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-02" }, { "label": "MCP Type Generation", @@ -660,7 +660,7 @@ "label": "MCP Apps", "to": "mcp/apps", "addedAt": "2026-06-24", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-02" }, { "label": "Serve Tools over HTTP", @@ -1025,7 +1025,7 @@ "label": "Code Mode Isolate Drivers", "to": "code-mode/code-mode-isolates", "addedAt": "2026-04-15", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-02" }, { "label": "Lazy Tools", @@ -1061,7 +1061,7 @@ "label": "Custom Driver", "to": "computer-use/custom-driver", "addedAt": "2026-10-20", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-02" } ], "tab": "guides" @@ -1229,7 +1229,7 @@ "label": "Sandbox and extend the coding tools", "to": "harness/coding-tools-backends", "addedAt": "2026-10-13", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-02" }, { "label": "Run the coding tools for many threads", @@ -1360,7 +1360,7 @@ "label": "Providers", "to": "sandbox/providers", "addedAt": "2026-06-29", - "updatedAt": "2026-10-29" + "updatedAt": "2026-11-02" }, { "label": "Harnesses", @@ -1556,7 +1556,7 @@ "label": "Adapters", "to": "memory/adapters", "addedAt": "2026-08-04", - "updatedAt": "2026-10-22" + "updatedAt": "2026-11-02" }, { "label": "Custom Adapter", diff --git a/docs/harness/coding-tools-backends.md b/docs/harness/coding-tools-backends.md index 6d579fc546..88e20fb22f 100644 --- a/docs/harness/coding-tools-backends.md +++ b/docs/harness/coding-tools-backends.md @@ -62,6 +62,8 @@ export const sandboxed = defineHarness({ To give the agent the project folder on your machine, [copy it into the container](#copy-your-project-into-the-container). +To stop a slow clone, pass `signal`: `sandbox.git.clone({ url, signal })`. `git.push(dir, { signal })` and `git.pull(dir, { signal })` take it too. When the signal aborts, it stops the git command on every provider except Daytona. Daytona runs git through its own API, which takes no signal, so the git command runs to the end. + 3. When you are done, stop the sandbox with `await sandbox.destroy()`. Every provider works. Get the handle from `create()`, or from `resume()` for a sandbox that runs already. See [Providers](../sandbox/providers). To create a sandbox for each thread, and resume it on another server, see [Run the coding tools for many threads](./coding-workspaces). diff --git a/docs/harness/connect.md b/docs/harness/connect.md index 6e61421531..19578c29b8 100644 --- a/docs/harness/connect.md +++ b/docs/harness/connect.md @@ -188,6 +188,19 @@ Without `from`, `events()` starts at the newest event, so it sends no old events - To read every event from the start of the session, pass `from: '0'`. Do this when you rebuild a transcript from the events. - If the snapshot answer has no `cursor` (the URL is not a harness handler), `events()` throws and does not try again. +Each request method of the client takes an optional last argument `{ signal }`. When the signal aborts, the request stops and the call rejects: + +```ts group=harness-connect-client +const controller = new AbortController() +setTimeout(() => controller.abort(), 5000) + +const snapshot = await client.snapshot({ signal: controller.signal }) +await client.send('Summarize my open tickets.', undefined, { + signal: controller.signal, +}) +console.log(snapshot.cursor) +``` + To upload files and show the media that your agents make, read [Send and show media](./media). ## Send a message to a running agent diff --git a/docs/mcp/apps.md b/docs/mcp/apps.md index 40f6326e20..94135d750d 100644 --- a/docs/mcp/apps.md +++ b/docs/mcp/apps.md @@ -211,7 +211,8 @@ export const Route = createFileRoute('/api/mcp-apps/call')({ POST: async ({ request }) => { // body: { threadId, serverId, toolName, args?, messageId? } const body = await request.json() - const result = await handler(body) + // The signal stops the connect and the tool call if the widget leaves. + const result = await handler(body, { signal: request.signal }) return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' }, }) diff --git a/docs/memory/adapters.md b/docs/memory/adapters.md index 5cdcd3fb39..2bd07a490a 100644 --- a/docs/memory/adapters.md +++ b/docs/memory/adapters.md @@ -15,7 +15,7 @@ keywords: - options --- -Each memory adapter implements `recall` and `save`. Swap one for another in `memoryMiddleware`. +Each memory adapter implements `recall` and `save`. Swap one for another in `memoryMiddleware`. `memoryMiddleware` calls `recall(scope, query, { signal })` with the abort signal of the chat run. The built-in adapters pass it to their requests when the backend client can take it. | Adapter | Import | Use it for | Extra install | | --- | --- | --- | --- | @@ -64,7 +64,7 @@ const memory = inMemory({ ``` - **`extract`** returns `ExtractedFact[]` (`{ text, kind?, importance?, metadata? }`), or `undefined` to save nothing extra. Put an LLM-based fact extractor here. The adapter does not depend on a model. -- **`embedder`** embeds the query on `recall` and the stored text on `save`. With no embedder, the score uses word overlap, recency, and importance only. +- **`embedder`** embeds the query on `recall` and the stored text on `save`. With no embedder, the score uses word overlap, recency, and importance only. On `recall`, `embed(text, { signal })` gets the abort signal of the chat run. Pass it to your embedding request, so a stopped run does not wait for it. ## `inMemory()` @@ -162,7 +162,7 @@ const memory = mem0({ ## `honcho()` -Honcho stores the turn as messages in a session. `recall` returns one written answer about the user, built by Honcho, with no separate fragments. +Honcho stores the turn as messages in a session. `recall` returns one written answer about the user, built by Honcho, with no separate fragments. When the chat run aborts, `recall` stops waiting and returns an empty prompt. The Honcho SDK takes no abort signal, so the Honcho request continues on the server. | Option | Type | Default | Purpose | | --- | --- | --- | --- | diff --git a/docs/sandbox/providers.md b/docs/sandbox/providers.md index 587b99b1a9..d52ad66b08 100644 --- a/docs/sandbox/providers.md +++ b/docs/sandbox/providers.md @@ -186,6 +186,27 @@ const text = await handle.fs.read('/workspace/README.md', { `bootstrapWorkspace(handle, workspace, { signal })` gives its signal to each file call that it makes. A bring-your-own provider must reject a call when the signal is already aborted. +### Stop a snapshot, fork, or port call + +`snapshot(label, { signal })`, `fork({ signal })`, and `ports.connect(port, { signal })` take a signal too: + +```ts +import { e2bSandbox } from '@tanstack/ai-sandbox-e2b' + +const handle = await e2bSandbox().create({}) +const controller = new AbortController() + +const ref = await handle.snapshot?.('after-setup', { + signal: controller.signal, +}) +``` + +- E2B gives the signal to `snapshot` and `fork`. +- Docker gives the signal to `snapshot`, `fork`, and `ports.connect`. +- Sprites gives the signal to `snapshot`. +- boxd stops waiting for the snapshot or the fork. An aborted fork is deleted. +- The other bundled providers ignore the signal for these calls. Their SDKs take no signal. + ## Advanced Each provider has its own options and limits: idle timers, ports, network rules, non-root users, and snapshot behavior. Read them in [Providers (Advanced)](./providers-advanced) before you go to production. diff --git a/docs/tools/mcp-manual.md b/docs/tools/mcp-manual.md index 2eb72cabcc..b0dbc52097 100644 --- a/docs/tools/mcp-manual.md +++ b/docs/tools/mcp-manual.md @@ -328,6 +328,27 @@ const stream = chat({ controller.abort() ``` +Outside a chat run, pass `signal` to a client call yourself. Each of these calls takes it: + +```ts +import { createMCPClient } from '@tanstack/ai-mcp' + +const controller = new AbortController() +const { signal } = controller + +const mcp = await createMCPClient({ + transport: { type: 'http', url: process.env.MCP_URL! }, + signal, // cancels the connect only +}) + +const tools = await mcp.tools({ signal }) +const resources = await mcp.resources({ signal }) +const prompts = await mcp.prompts({ signal }) +console.log(tools.length, resources.length, prompts.length) +``` + +`readResource(uri, { signal })`, `resourceTemplates({ signal })`, and `getPrompt(name, args, { signal })` take it too. The client sends the signal to the MCP SDK, which sends `notifications/cancelled` to the server. + ## Full server and client example This TanStack Start route connects to two MCP servers and streams the response to the browser. diff --git a/packages/ai-client/src/mcp-app-bridge.ts b/packages/ai-client/src/mcp-app-bridge.ts index 7de435c653..531748b9bb 100644 --- a/packages/ai-client/src/mcp-app-bridge.ts +++ b/packages/ai-client/src/mcp-app-bridge.ts @@ -12,17 +12,20 @@ export interface CreateMcpAppBridgeOptions { } export interface McpAppBridge { - callTool: (input: { - serverId?: string - toolName: string - args?: Record - /** - * Reserved — forwarded to the call handler for correlation purposes but - * not consumed by the handler. Accepted on the wire; the handler does not - * read it (mirrors the `meta` convention on `UIResourcePart`). - */ - messageId?: string - }) => Promise + callTool: ( + input: { + serverId?: string + toolName: string + args?: Record + /** + * Reserved — forwarded to the call handler for correlation purposes but + * not consumed by the handler. Accepted on the wire; the handler does not + * read it (mirrors the `meta` convention on `UIResourcePart`). + */ + messageId?: string + }, + options?: { signal?: AbortSignal }, + ) => Promise sendPrompt: (text: string) => Promise openLink: (url: string) => { isError: boolean } } @@ -61,8 +64,9 @@ export function createMcpAppBridge( const doFetch = fetchImpl ?? fetch return { - async callTool(input) { + async callTool(input, callOptions) { const response = await doFetch(callEndpoint, { + ...(callOptions?.signal && { signal: callOptions.signal }), method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ diff --git a/packages/ai-client/tests/mcp-app-bridge-signal.test.ts b/packages/ai-client/tests/mcp-app-bridge-signal.test.ts new file mode 100644 index 0000000000..1b4356f091 --- /dev/null +++ b/packages/ai-client/tests/mcp-app-bridge-signal.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { createMcpAppBridge } from '../src/mcp-app-bridge' + +/** Waits until the request signal aborts; fails at once without a signal. */ +function hangingFetch() { + return vi.fn( + (_input: RequestInfo | URL, init?: RequestInit): Promise => { + const signal = init?.signal + if (!signal) return Promise.reject(new Error('request had no signal')) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true, + }) + }) + }, + ) +} + +describe('createMcpAppBridge callTool signal', () => { + it('passes the signal to the call request and rejects on abort', async () => { + const fetchImpl = hangingFetch() + const bridge = createMcpAppBridge({ + threadId: 't1', + callEndpoint: '/api/mcp-call', + chat: { sendMessage: async () => {} }, + fetchImpl, + }) + const controller = new AbortController() + const pending = bridge.callTool( + { toolName: 'place_order' }, + { signal: controller.signal }, + ) + expect(fetchImpl.mock.calls[0]![1]?.signal).toBe(controller.signal) + controller.abort() + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) diff --git a/packages/ai-code-mode/src/create-code-mode-tool.ts b/packages/ai-code-mode/src/create-code-mode-tool.ts index 898569f0ce..6dbc2685f0 100644 --- a/packages/ai-code-mode/src/create-code-mode-tool.ts +++ b/packages/ai-code-mode/src/create-code-mode-tool.ts @@ -336,7 +336,9 @@ export function createCodeModeTool( } // Step 5: Execute the code in the sandbox - const executionResult = await isolateContext.execute(strippedCode) + const executionResult = runSignal + ? await isolateContext.execute(strippedCode, { signal: runSignal }) + : await isolateContext.execute(strippedCode) // Emit console logs as custom events if (executionResult.logs && executionResult.logs.length > 0) { @@ -378,15 +380,24 @@ export function createCodeModeTool( return finish( { success: false, - error: executionResult.error - ? { - message: executionResult.error.message, - name: executionResult.error.name, - ...(executionResult.error.stack !== undefined && { - stack: executionResult.error.stack, - }), - } - : { message: 'Unknown execution error', name: 'UnknownError' }, + // An abort also disposes the context, so the driver can report + // a DisposedError first. Report the abort. + error: + runSignal?.aborted && + executionResult.error?.name === 'DisposedError' + ? { message: 'Execution was aborted.', name: 'AbortError' } + : executionResult.error + ? { + message: executionResult.error.message, + name: executionResult.error.name, + ...(executionResult.error.stack !== undefined && { + stack: executionResult.error.stack, + }), + } + : { + message: 'Unknown execution error', + name: 'UnknownError', + }, logs: executionResult.logs, }, 'execute', diff --git a/packages/ai-code-mode/src/types.ts b/packages/ai-code-mode/src/types.ts index f69f072303..63b50d79fd 100644 --- a/packages/ai-code-mode/src/types.ts +++ b/packages/ai-code-mode/src/types.ts @@ -47,9 +47,13 @@ export interface IsolateConfig { */ export interface IsolateContext { /** - * Execute generated code and return results + * Execute generated code and return results. Drivers stop the run when + * `options.signal` aborts, if their runtime can. */ - execute: (code: string) => Promise> + execute: ( + code: string, + options?: { signal?: AbortSignal }, + ) => Promise> /** * Clean up sandbox resources diff --git a/packages/ai-code-mode/tests/create-code-mode-tool.test.ts b/packages/ai-code-mode/tests/create-code-mode-tool.test.ts index 126f48bc2e..9e4181f223 100644 --- a/packages/ai-code-mode/tests/create-code-mode-tool.test.ts +++ b/packages/ai-code-mode/tests/create-code-mode-tool.test.ts @@ -729,7 +729,11 @@ describe('createCodeModeTool run abort', () => { // A call that waits for an approval stops with the program. expect(programSignal?.aborted).toBe(true) expect(dispose).toHaveBeenCalled() - expect((await running).success).toBe(false) + // The model sees the abort, not the dispose. + expect(await running).toMatchObject({ + success: false, + error: { name: 'AbortError' }, + }) }) it('does not run the code when the run aborts while the context is made', async () => { diff --git a/packages/ai-code-mode/tests/execute-signal.test.ts b/packages/ai-code-mode/tests/execute-signal.test.ts new file mode 100644 index 0000000000..d39dc84335 --- /dev/null +++ b/packages/ai-code-mode/tests/execute-signal.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { toolDefinition } from '@tanstack/ai' +import { createCodeModeTool } from '../src/create-code-mode-tool' +import type { IsolateContext, IsolateDriver } from '../src/types' + +function setup() { + const context: IsolateContext = { + execute: vi.fn().mockResolvedValue({ success: true, value: 1, logs: [] }), + dispose: vi.fn().mockResolvedValue(undefined), + } + const driver: IsolateDriver = { + createContext: vi.fn().mockResolvedValue(context), + } + const tool = createCodeModeTool({ + driver, + tools: [ + toolDefinition({ + name: 'lookup', + description: 'Look up a value', + inputSchema: z.object({ q: z.string() }), + }).server(async () => 'x'), + ], + }) + return { tool, context } +} + +describe('execute_typescript passes the tool signal to the isolate', () => { + it('forwards context.abortSignal to execute()', async () => { + const { tool, context } = setup() + const controller = new AbortController() + await tool.execute!( + { typescriptCode: 'return 1' }, + { abortSignal: controller.signal, emitCustomEvent() {} }, + ) + expect(context.execute).toHaveBeenCalledWith(expect.any(String), { + signal: controller.signal, + }) + }) + + it('calls execute() with the code only when there is no signal', async () => { + const { tool, context } = setup() + await tool.execute!({ typescriptCode: 'return 1' }) + expect(vi.mocked(context.execute).mock.calls[0]).toHaveLength(1) + }) +}) diff --git a/packages/ai-computer-playwright/src/driver.ts b/packages/ai-computer-playwright/src/driver.ts index 305bcfaea8..bd9d51d192 100644 --- a/packages/ai-computer-playwright/src/driver.ts +++ b/packages/ai-computer-playwright/src/driver.ts @@ -69,6 +69,30 @@ export interface PlaywrightComputerOptions { launch?: LaunchOptions } +/** + * Playwright takes no abort signal. Reject when `signal` aborts, so the + * caller stops waiting. The page call itself goes on until it ends. + */ +function untilAborted(work: Promise, signal: AbortSignal | undefined) { + if (!signal) return work + return new Promise((resolve, reject) => { + const onAbort = () => reject(signal.reason) + // Handle `work` first, so a late rejection is never unhandled. + work.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error) + }, + ) + if (signal.aborted) return onAbort() + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + /** * Create a computer driver that controls one Playwright page. * @@ -98,7 +122,8 @@ export function createPlaywrightComputerDriver({ const driver: ComputerDriver = { display, environment: 'browser', - screenshot: () => page.screenshot({ type: 'png' }), + screenshot: (options) => + untilAborted(page.screenshot({ type: 'png' }), options?.signal), async click(x, y, { button, count }) { if (button === 'back' || button === 'forward') return history(button) await page.mouse.click(x, y, { button, clickCount: count }) @@ -121,9 +146,13 @@ export function createPlaywrightComputerDriver({ keyUp: (key) => page.keyboard.up(key), press: (keys) => page.keyboard.press(keys.join('+')), type: (text) => page.keyboard.type(text), - zoom: (region) => page.screenshot({ type: 'png', clip: region }), - navigate: async (url) => { - await page.goto(url) + zoom: (region, options) => + untilAborted( + page.screenshot({ type: 'png', clip: region }), + options?.signal, + ), + navigate: async (url, options) => { + await untilAborted(page.goto(url), options?.signal) }, } return driver @@ -170,12 +199,15 @@ export function playwrightComputer(options: PlaywrightComputerOptions = {}) { display, environment: 'browser', capabilities: { zoom: true, cursorPosition: false, navigate: true }, - async create() { + async create({ signal }) { const shared = await getBrowser() const context = await shared.newContext({ viewport: display }) try { + signal?.throwIfAborted() const page = await context.newPage() - if (startUrl !== undefined) await page.goto(startUrl) + if (startUrl !== undefined) { + await untilAborted(page.goto(startUrl), signal) + } return { ...createPlaywrightComputerDriver({ page }), close: () => context.close(), diff --git a/packages/ai-computer-playwright/tests/driver.test.ts b/packages/ai-computer-playwright/tests/driver.test.ts index c31f72c99d..81a07e5bcf 100644 --- a/packages/ai-computer-playwright/tests/driver.test.ts +++ b/packages/ai-computer-playwright/tests/driver.test.ts @@ -330,3 +330,42 @@ describe('playwrightComputer', () => { expect(contexts).toHaveLength(1) }) }) + +describe('abort signal', () => { + it('rejects navigate when the signal aborts while goto hangs', async () => { + const { page } = createFakePage() + page.goto = () => new Promise(() => {}) + const driver = createPlaywrightComputerDriver({ page }) + const controller = new AbortController() + const navigation = driver.navigate!('https://tanstack.com', { + signal: controller.signal, + }) + controller.abort(new Error('stopped')) + await expect(navigation).rejects.toThrow('stopped') + }) + + it('rejects screenshot when the signal aborts while it hangs', async () => { + const { page } = createFakePage() + page.screenshot = () => new Promise(() => {}) + const driver = createPlaywrightComputerDriver({ page }) + const controller = new AbortController() + const shot = driver.screenshot({ signal: controller.signal }) + controller.abort(new Error('stopped')) + await expect(shot).rejects.toThrow('stopped') + }) + + it('closes the context when create aborts during the start URL', async () => { + const { browser, contexts } = createFakeBrowser() + const controller = new AbortController() + const factory = playwrightComputer({ + browser, + startUrl: 'https://tanstack.com', + }) + controller.abort(new Error('stopped')) + await expect( + factory.create({ threadId: 'a', signal: controller.signal }), + ).rejects.toThrow('stopped') + expect(contexts[0]?.closed).toBe(true) + expect(contexts[0]?.calls).toEqual([]) + }) +}) diff --git a/packages/ai-computer-sandbox/src/driver.ts b/packages/ai-computer-sandbox/src/driver.ts index 1c4dcafbba..5207b68f6e 100644 --- a/packages/ai-computer-sandbox/src/driver.ts +++ b/packages/ai-computer-sandbox/src/driver.ts @@ -16,6 +16,8 @@ export interface SandboxComputerDriverOptions { * `{ width: 1024, height: 768 }`. */ display?: { width: number; height: number } + /** Stops the setup commands when it aborts. */ + signal?: AbortSignal } /** Options for {@link sandboxComputer}. */ @@ -102,9 +104,13 @@ function moveTo(x: number, y: number) { export async function createSandboxComputerDriver({ sandbox, display: size = DEFAULT_DISPLAY, + signal: setupSignal, }: SandboxComputerDriverOptions) { - async function run(command: string) { - const result = await sandbox.process.exec(command) + /** Runs `command`. `signal` stops it. Without one, exec gets no options. */ + async function run(command: string, signal?: AbortSignal) { + const result = await (signal + ? sandbox.process.exec(command, { signal }) + : sandbox.process.exec(command)) if (result.exitCode !== 0) { throw new Error( `Sandbox command failed with exit code ${result.exitCode}: ${command}\n${result.stderr}`, @@ -112,13 +118,15 @@ export async function createSandboxComputerDriver({ } return result.stdout } - const xdotool = (args: string) => run(`${X} xdotool ${args}`) + const xdotool = (args: string, signal?: AbortSignal) => + run(`${X} xdotool ${args}`, signal) // Prints each missing tool. `@import` means no scrot, but ImageMagick // `import` takes the screenshots, like on Amazon Linux. const found = ( await run( 'for t in xdotool xdpyinfo; do command -v $t >/dev/null || echo $t; done; command -v scrot >/dev/null || { command -v import >/dev/null && echo @import || echo scrot; }', + setupSignal, ) ) .trim() @@ -134,7 +142,10 @@ export async function createSandboxComputerDriver({ ? 'import -window root' : 'scrot --pointer' - const running = await sandbox.process.exec(`${X} xdpyinfo >/dev/null 2>&1`) + const probe = `${X} xdpyinfo >/dev/null 2>&1` + const running = await (setupSignal + ? sandbox.process.exec(probe, { signal: setupSignal }) + : sandbox.process.exec(probe)) if (running.exitCode !== 0) { // Output goes to /dev/null, so no provider buffer fills up. await sandbox.process.spawn( @@ -142,6 +153,7 @@ export async function createSandboxComputerDriver({ ) await run( `for i in $(seq 50); do ${X} xdpyinfo >/dev/null 2>&1 && exit 0; sleep 0.2; done; echo 'Xvfb did not start' >&2; exit 1`, + setupSignal, ) // ponytail: no wait for the session. Early screenshots can show an empty // screen, and the model takes another one. @@ -150,7 +162,7 @@ export async function createSandboxComputerDriver({ ) } - const info = await run(`${X} xdpyinfo`) + const info = await run(`${X} xdpyinfo`, setupSignal) const match = /dimensions:\s+(\d+)x(\d+)/.exec(info) if (!match) { throw new Error('Could not read the screen size from xdpyinfo.') @@ -159,30 +171,32 @@ export async function createSandboxComputerDriver({ const driver = { display: { width: Number(match[1]), height: Number(match[2]) }, environment: 'linux', - async screenshot() { + async screenshot(options?: { signal?: AbortSignal }) { const file = `/tmp/${generateId('tanstack-screenshot')}.png` // One round trip: capture, print as base64, delete. Not `status`: zsh // (the Daytona shell) makes it read-only. const base64 = await run( `${X} ${capture} ${file} && base64 -w0 ${file}; code=$?; rm -f ${file}; exit $code`, + options?.signal, ) return base64ToUint8Array(base64.trim()) }, - async click(x, y, { button, count }) { + async click(x, y, { button, count, signal }) { await xdotool( `${moveTo(x, y)} click --repeat ${int(count)} ${BUTTONS[button]}`, + signal, ) }, - async move(x, y) { - await xdotool(moveTo(x, y)) + async move(x, y, options?: { signal?: AbortSignal }) { + await xdotool(moveTo(x, y), options?.signal) }, - async mouseDown(button) { - await xdotool(`mousedown ${BUTTONS[button]}`) + async mouseDown(button, options?: { signal?: AbortSignal }) { + await xdotool(`mousedown ${BUTTONS[button]}`, options?.signal) }, async mouseUp(button) { await xdotool(`mouseup ${BUTTONS[button]}`) }, - async scroll(x, y, deltaX, deltaY) { + async scroll(x, y, deltaX, deltaY, options?: { signal?: AbortSignal }) { // xdotool buttons 4 and 5 scroll up and down, 6 and 7 left and right. let args = moveTo(x, y) if (deltaY !== 0) { @@ -191,18 +205,21 @@ export async function createSandboxComputerDriver({ if (deltaX !== 0) { args += ` click --repeat ${scrollClicks(deltaX)} ${deltaX > 0 ? 7 : 6}` } - await xdotool(args) + await xdotool(args, options?.signal) }, - async keyDown(key) { - await xdotool(`keydown -- ${quote(toXdotoolKey(key))}`) + async keyDown(key, options?: { signal?: AbortSignal }) { + await xdotool(`keydown -- ${quote(toXdotoolKey(key))}`, options?.signal) }, async keyUp(key) { await xdotool(`keyup -- ${quote(toXdotoolKey(key))}`) }, - async press(keys) { - await xdotool(`key -- ${quote(keys.map(toXdotoolKey).join('+'))}`) + async press(keys, options?: { signal?: AbortSignal }) { + await xdotool( + `key -- ${quote(keys.map(toXdotoolKey).join('+'))}`, + options?.signal, + ) }, - async type(text) { + async type(text, options?: { signal?: AbortSignal }) { if (text === '') return const chunks: Array = [] for (let i = 0; i < text.length; i += TYPE_CHUNK) { @@ -215,10 +232,11 @@ export async function createSandboxComputerDriver({ `${X} xdotool type --delay ${TYPE_DELAY_MS} -- ${quote(chunk)}`, ) .join(' && '), + options?.signal, ) }, - async cursorPosition() { - const out = await xdotool('getmouselocation') + async cursorPosition(options?: { signal?: AbortSignal }) { + const out = await xdotool('getmouselocation', options?.signal) const position = /x:(\d+)\s+y:(\d+)/.exec(out) if (!position) { throw new Error(`Could not read the cursor position: ${out}`) @@ -253,10 +271,14 @@ export function sandboxComputer({ display, environment: 'linux', capabilities: { zoom: false, cursorPosition: true, navigate: false }, - async create() { - const sandbox = await provider.create({}) + async create({ signal }) { + const sandbox = await provider.create(signal ? { signal } : {}) try { - const driver = await createSandboxComputerDriver({ sandbox, display }) + const driver = await createSandboxComputerDriver({ + sandbox, + display, + ...(signal && { signal }), + }) const { width, height } = driver.display if (width !== display.width || height !== display.height) { throw new Error( diff --git a/packages/ai-computer-sandbox/tests/driver.test.ts b/packages/ai-computer-sandbox/tests/driver.test.ts index f656121d49..a14f0a8a35 100644 --- a/packages/ai-computer-sandbox/tests/driver.test.ts +++ b/packages/ai-computer-sandbox/tests/driver.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { createSandboxComputerDriver, sandboxComputer } from '../src/index' import type { ExecResult, + ProcessOptions, SandboxHandle, SandboxProvider, } from '@tanstack/ai-sandbox' @@ -25,21 +26,23 @@ function fakeSandbox({ running = true, missing = [], size }: FakeOptions = {}) { const calls: Array = [] let started = running const ok = (stdout = ''): ExecResult => ({ stdout, stderr: '', exitCode: 0 }) - const exec = vi.fn(async (command: string): Promise => { - calls.push(`exec ${command}`) - if (command.startsWith('for t in')) return ok(missing.join('\n')) - if (command.endsWith('xdpyinfo >/dev/null 2>&1')) { - return started ? ok() : { stdout: '', stderr: '', exitCode: 1 } - } - if (command.endsWith('xdpyinfo')) { - return ok(size ? XDPYINFO.replace('1024x768', size) : XDPYINFO) - } - if (command.includes('base64 -w0')) return ok(`${PNG_BASE64}\n`) - if (command.endsWith('getmouselocation')) { - return ok('x:12 y:34 screen:0 window:1\n') - } - return ok() - }) + const exec = vi.fn( + async (command: string, _options?: ProcessOptions): Promise => { + calls.push(`exec ${command}`) + if (command.startsWith('for t in')) return ok(missing.join('\n')) + if (command.endsWith('xdpyinfo >/dev/null 2>&1')) { + return started ? ok() : { stdout: '', stderr: '', exitCode: 1 } + } + if (command.endsWith('xdpyinfo')) { + return ok(size ? XDPYINFO.replace('1024x768', size) : XDPYINFO) + } + if (command.includes('base64 -w0')) return ok(`${PNG_BASE64}\n`) + if (command.endsWith('getmouselocation')) { + return ok('x:12 y:34 screen:0 window:1\n') + } + return ok() + }, + ) const spawn = vi.fn(async (command: string) => { calls.push(`spawn ${command}`) if (command.startsWith('Xvfb')) started = true @@ -284,3 +287,55 @@ describe('sandboxComputer', () => { error.mockRestore() }) }) + +describe('sandboxComputer create signal', () => { + it('passes the signal to provider.create, and {} without one', async () => { + const provider = fakeProvider([fakeSandbox(), fakeSandbox()]) + const factory = sandboxComputer({ provider }) + const controller = new AbortController() + await factory.create({ threadId: 'a', signal: controller.signal }) + await factory.create({ threadId: 'b' }) + expect(vi.mocked(provider.create).mock.calls).toEqual([ + [{ signal: controller.signal }], + [{}], + ]) + }) +}) + +describe('action signals', () => { + it('passes the action signal to exec, and none to the releases', async () => { + const fake = fakeSandbox() + const driver = await createSandboxComputerDriver({ sandbox: fake.sandbox }) + fake.exec.mockClear() + const signal = new AbortController().signal + await driver.move(1, 2, { signal }) + await driver.click(1, 2, { button: 'left', count: 1, signal }) + await driver.type('hi', { signal }) + await driver.keyUp('a') + await driver.mouseUp('left') + expect(fake.exec.mock.calls.map((call) => call[1])).toEqual([ + { signal }, + { signal }, + { signal }, + undefined, + undefined, + ]) + }) + + it('passes the setup signal to the setup commands', async () => { + const fake = fakeSandbox() + const signal = new AbortController().signal + await createSandboxComputerDriver({ sandbox: fake.sandbox, signal }) + expect(fake.exec.mock.calls.length).toBeGreaterThan(0) + for (const call of fake.exec.mock.calls) { + expect(call[1]).toEqual({ signal }) + } + }) + + it('sends exec no options without a signal', async () => { + const fake = fakeSandbox() + const driver = await createSandboxComputerDriver({ sandbox: fake.sandbox }) + await driver.move(1, 2) + expect(fake.exec.mock.calls.every((call) => call.length === 1)).toBe(true) + }) +}) diff --git a/packages/ai-computer-use/src/actions.ts b/packages/ai-computer-use/src/actions.ts index 84ad1bd900..6185c30879 100644 --- a/packages/ai-computer-use/src/actions.ts +++ b/packages/ai-computer-use/src/actions.ts @@ -35,6 +35,68 @@ export function sleep(ms: number, signal?: AbortSignal) { }) } +/** + * The driver with `signal` on every action. Each action first throws when + * the signal has aborted. `keyUp` and `mouseUp` run without the signal, so + * an aborted action still releases what it held down. While a mouse button + * is down, `move` also runs without the signal, so a drag that started always + * ends at its last point. Without a signal, this returns `driver` as it is. + */ +export function withSignal( + driver: ComputerDriver, + signal: AbortSignal | undefined, +): ComputerDriver { + if (!signal) return driver + const options = { signal } + const { zoom, cursorPosition, navigate } = driver + let buttonDown = false + return { + display: driver.display, + environment: driver.environment, + screenshot: () => (signal.throwIfAborted(), driver.screenshot(options)), + click: (x, y, click) => ( + signal.throwIfAborted(), + driver.click(x, y, { ...click, signal }) + ), + move: (x, y) => + buttonDown + ? driver.move(x, y) + : (signal.throwIfAborted(), driver.move(x, y, options)), + mouseDown: (button) => ( + signal.throwIfAborted(), + (buttonDown = true), + driver.mouseDown(button, options) + ), + mouseUp: (button) => ((buttonDown = false), driver.mouseUp(button)), + scroll: (x, y, deltaX, deltaY) => ( + signal.throwIfAborted(), + driver.scroll(x, y, deltaX, deltaY, options) + ), + keyDown: (key) => (signal.throwIfAborted(), driver.keyDown(key, options)), + keyUp: (key) => driver.keyUp(key), + press: (keys) => (signal.throwIfAborted(), driver.press(keys, options)), + type: (text) => (signal.throwIfAborted(), driver.type(text, options)), + ...(zoom && { + zoom: (region) => ( + signal.throwIfAborted(), + zoom.call(driver, region, options) + ), + }), + ...(cursorPosition && { + cursorPosition: () => ( + signal.throwIfAborted(), + cursorPosition.call(driver, options) + ), + }), + ...(navigate && { + navigate: (url) => ( + signal.throwIfAborted(), + navigate.call(driver, url, options) + ), + }), + } +} + function invalid(key: string, expected: string) { return new Error(`Invalid computer action: "${key}" must be ${expected}.`) } @@ -416,6 +478,8 @@ async function runOpenAIAction( * * @param driver - The driver that controls the computer. * @param input - The raw tool input from the model. + * @param signal - The abort signal of the tool call. It stops the waits. + * Wrap the driver with {@link withSignal} to give it to each action too. */ export async function runComputerAction( driver: ComputerDriver, diff --git a/packages/ai-computer-use/src/create-computer-use.ts b/packages/ai-computer-use/src/create-computer-use.ts index 2c5ece98b4..630d1c343d 100644 --- a/packages/ai-computer-use/src/create-computer-use.ts +++ b/packages/ai-computer-use/src/create-computer-use.ts @@ -1,7 +1,7 @@ import { brandProviderTool, toolDefinition } from '@tanstack/ai' import { arrayBufferToBase64 } from '@tanstack/ai-utils' import { z } from 'zod' -import { runComputerAction, sleep } from './actions' +import { runComputerAction, sleep, withSignal } from './actions' import { computerSafetyCheck, safetyCheckSchema } from './safety-check' import type { ChatMiddleware, @@ -239,10 +239,10 @@ export function createComputerUse({ await machine?.close?.() } - function machineOf(thread: ThreadState) { + function machineOf(thread: ThreadState, signal?: AbortSignal) { if (!('create' in driver)) return driver thread.driver ??= driver - .create({ threadId: thread.id }) + .create({ threadId: thread.id, ...(signal && { signal }) }) .catch((error: unknown) => { // The next call of the thread calls create again. thread.driver = undefined @@ -326,14 +326,11 @@ export function createComputerUse({ 'Refused: this computer action has safety checks that nobody approved. Add the `interrupt` from createComputerUse to chat({ interrupts }) and the `middleware` to chat({ middleware }).', ) } - const machine = await machineOf(thread) - const result = await runComputerAction( - machine, - input, - context?.abortSignal, - ) + const signal = context?.abortSignal + const machine = withSignal(await machineOf(thread, signal), signal) + const result = await runComputerAction(machine, input, signal) if (result === undefined) { - await sleep(screenshotDelay, context?.abortSignal) + await sleep(screenshotDelay, signal) return imageResult(await machine.screenshot()) } return result.type === 'image' ? imageResult(result.bytes) : result.text @@ -348,14 +345,15 @@ export function createComputerUse({ 'Refused: navigate needs a full URL that starts with https:// or http://, like https://tanstack.com.', ) } - const machine = await machineOf(thread) + const signal = context?.abortSignal + const machine = withSignal(await machineOf(thread, signal), signal) if (machine.navigate === undefined) { throw new Error('The computer driver has no navigate method.') } // The call can abort while its machine starts. context?.abortSignal?.throwIfAborted() await machine.navigate(url) - await sleep(screenshotDelay, context?.abortSignal) + await sleep(screenshotDelay, signal) return imageResult(await machine.screenshot()) }), ) diff --git a/packages/ai-computer-use/src/types.ts b/packages/ai-computer-use/src/types.ts index 0aecbc2e3c..5891c54faf 100644 --- a/packages/ai-computer-use/src/types.ts +++ b/packages/ai-computer-use/src/types.ts @@ -1,6 +1,11 @@ /** A mouse button a driver can press. */ export type MouseButton = 'left' | 'right' | 'middle' | 'back' | 'forward' +/** The options of one driver action. `signal` is the tool call's abort signal. */ +interface ActionOptions { + signal?: AbortSignal +} + /** The kind of computer a driver controls. */ export type ComputerEnvironment = 'browser' | 'linux' | 'mac' | 'windows' @@ -9,6 +14,10 @@ export type ComputerEnvironment = 'browser' | 'linux' | 'mac' | 'windows' * * Coordinates are in pixels in `display` space. Key names are * `KeyboardEvent.key` names, for example `'Enter'`, `'Control'`, or `'a'`. + * + * Each action gets the optional `signal` of the tool call. Stop the action + * when it aborts, if the machine can. `keyUp` and `mouseUp` get no signal, + * because they release what an aborted action held down. */ export interface ComputerDriver { /** The screen size in pixels. Screenshots have this size. */ @@ -16,17 +25,17 @@ export interface ComputerDriver { /** The kind of computer the driver controls. */ environment: ComputerEnvironment /** Take a screenshot. Returns PNG bytes. */ - screenshot: () => Promise + screenshot: (options?: ActionOptions) => Promise /** Click `count` times at the point with the given button. */ click: ( x: number, y: number, - options: { button: MouseButton; count: number }, + options: { button: MouseButton; count: number; signal?: AbortSignal }, ) => Promise /** Move the mouse to the point. */ - move: (x: number, y: number) => Promise + move: (x: number, y: number, options?: ActionOptions) => Promise /** Press and hold a mouse button at the current mouse position. */ - mouseDown: (button: MouseButton) => Promise + mouseDown: (button: MouseButton, options?: ActionOptions) => Promise /** Release a mouse button at the current mouse position. */ mouseUp: (button: MouseButton) => Promise /** Scroll at the point. The deltas are in pixels. */ @@ -35,29 +44,35 @@ export interface ComputerDriver { y: number, deltaX: number, deltaY: number, + options?: ActionOptions, ) => Promise /** Press and hold one key. */ - keyDown: (key: string) => Promise + keyDown: (key: string, options?: ActionOptions) => Promise /** Release one key. */ keyUp: (key: string) => Promise /** Press one key combo, like `['Control', 's']`. */ - press: (keys: Array) => Promise + press: (keys: Array, options?: ActionOptions) => Promise /** Type text as a user does. */ - type: (text: string) => Promise + type: (text: string, options?: ActionOptions) => Promise /** Take a screenshot of one region at full size. Returns PNG bytes. */ - zoom?: (region: { - x: number - y: number - width: number - height: number - }) => Promise + zoom?: ( + region: { + x: number + y: number + width: number + height: number + }, + options?: ActionOptions, + ) => Promise /** Get the current mouse position. */ - cursorPosition?: () => Promise<{ x: number; y: number }> + cursorPosition?: ( + options?: ActionOptions, + ) => Promise<{ x: number; y: number }> /** * Go to a URL. When the driver has this method, `createComputerUse` adds * the `navigate` tool. */ - navigate?: (url: string) => Promise + navigate?: (url: string, options?: ActionOptions) => Promise /** * Free the machine. When a factory created the driver, `createComputerUse` * calls this when the thread closes or goes idle. A driver that the app @@ -79,8 +94,14 @@ export interface ComputerDriverFactory { environment: ComputerEnvironment /** The optional methods that every created driver has. */ capabilities: { zoom: boolean; cursorPosition: boolean; navigate: boolean } - /** Make the machine for one thread. */ - create: (context: { threadId: string }) => Promise + /** + * Make the machine for one thread. `signal` is the abort signal of the + * tool call that needs the machine. Stop the setup when it aborts. + */ + create: (context: { + threadId: string + signal?: AbortSignal + }) => Promise /** * Free what the threads share, like the browser. * `createComputerUse(...).close()` with no thread id calls this. diff --git a/packages/ai-computer-use/tests/actions.test.ts b/packages/ai-computer-use/tests/actions.test.ts index ca54e26c86..e52a659c0b 100644 --- a/packages/ai-computer-use/tests/actions.test.ts +++ b/packages/ai-computer-use/tests/actions.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { runComputerAction } from '../src/actions' +import { runComputerAction, withSignal } from '../src/actions' import type { ComputerDriver } from '../src/types' type Call = [string, ...Array] @@ -503,6 +503,55 @@ it('throws for an input with no action', async () => { await expect(run({ foo: 1 })).rejects.toThrow('"action"') }) +describe('abort signal', () => { + it('gives the signal to each driver action, but not to the releases', async () => { + const { driver, calls } = createDriver() + const signal = new AbortController().signal + await runComputerAction( + withSignal(driver, signal), + { action: 'left_click', coordinate: [1, 2], text: 'shift' }, + signal, + ) + expect(calls).toEqual([ + ['keyDown', 'Shift', { signal }], + ['click', 1, 2, { button: 'left', count: 1, signal }], + ['keyUp', 'Shift'], + ]) + }) + + it('stops a wait and releases the held key when the signal aborts', async () => { + const { driver, calls } = createDriver() + const controller = new AbortController() + const run = runComputerAction( + withSignal(driver, controller.signal), + { action: 'hold_key', text: 'a', duration: 300 }, + controller.signal, + ) + await Promise.resolve() + controller.abort(new Error('stop')) + await expect(run).rejects.toThrow('stop') + expect(calls.map(([name]) => name)).toEqual(['keyDown', 'keyUp']) + }) + + it('runs no driver action when the signal is already aborted', async () => { + const { driver, calls } = createDriver() + const signal = AbortSignal.abort() + await expect( + runComputerAction( + withSignal(driver, signal), + { action: 'mouse_move', coordinate: [1, 2] }, + signal, + ), + ).rejects.toThrow() + expect(calls).toEqual([]) + }) + + it('returns the driver as it is without a signal', () => { + const { driver } = createDriver() + expect(withSignal(driver, undefined)).toBe(driver) + }) +}) + describe('an abort during a compound action', () => { /** * A driver that records its calls. Call number `nth` of `method` aborts @@ -601,6 +650,31 @@ describe('an abort during a compound action', () => { ]) }) + it('finishes a drag through withSignal that aborts between two moves', async () => { + const { driver, calls, signal } = abortingDuring('move', 2) + await runComputerAction( + withSignal(driver, signal), + { + action: { + type: 'drag', + path: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + { x: 2, y: 2 }, + ], + }, + }, + signal, + ) + expect(calls).toEqual([ + ['move', 0, 0, { signal }], + ['mouseDown', 'left', { signal }], + ['move', 1, 1], + ['move', 2, 2], + ['mouseUp', 'left'], + ]) + }) + it('finishes a drag that aborts between two moves', async () => { const { driver, calls, signal } = abortingDuring('move', 2) await runComputerAction( diff --git a/packages/ai-computer-use/tests/create-computer-use.test.ts b/packages/ai-computer-use/tests/create-computer-use.test.ts index f79c3e2ffc..99424e21d9 100644 --- a/packages/ai-computer-use/tests/create-computer-use.test.ts +++ b/packages/ai-computer-use/tests/create-computer-use.test.ts @@ -932,6 +932,77 @@ describe('createComputerUse navigate', () => { }) }) +describe('driver factory signal', () => { + it('passes the abort signal of the tool call to create()', async () => { + const signals: Array = [] + const factory: ComputerDriverFactory = { + display: { width: 1280, height: 800 }, + environment: 'linux', + capabilities: { zoom: false, cursorPosition: false, navigate: false }, + async create({ signal }) { + signals.push(signal) + return createDriver().driver + }, + } + const [computerTool] = createComputerUse({ + driver: factory, + screenshotDelay: 0, + }).tools + const controller = new AbortController() + await computerTool.execute!(CLICK, { + toolCallId: 'call_1', + threadId: THREAD, + abortSignal: controller.signal, + emitCustomEvent() {}, + }) + expect(signals).toEqual([controller.signal]) + }) +}) + +describe('tool call signal', () => { + it('stops the screenshot wait and takes no screenshot when the call aborts', async () => { + const { driver, calls } = createDriver() + const [computerTool] = createComputerUse({ + driver, + screenshotDelay: 60_000, + }).tools + const controller = new AbortController() + const result = computerTool.execute!(CLICK, { + toolCallId: 'call_1', + threadId: THREAD, + abortSignal: controller.signal, + emitCustomEvent() {}, + }) + await vi.waitFor(() => expect(calls).toContain('click:end')) + controller.abort(new Error('stopped')) + await expect(result).rejects.toThrow('stopped') + expect(calls).not.toContain('screenshot') + }) + + it('gives the signal to navigate', async () => { + const { driver } = createDriver({ navigate: true }) + const navigate = vi.fn(async () => {}) + driver.navigate = navigate + const [, navigateTool] = createComputerUse({ + driver, + screenshotDelay: 0, + }).tools + const controller = new AbortController() + await navigateTool!.execute!( + { url: 'https://tanstack.com' }, + { + toolCallId: 'call_nav', + threadId: THREAD, + abortSignal: controller.signal, + emitCustomEvent() {}, + }, + ) + expect(navigate).toHaveBeenCalledWith('https://tanstack.com', { + signal: controller.signal, + }) + }) +}) + describe('the abort signal of a computer action', () => { /** Run the computer tool as `chat()` does, with `signal`. */ function executeWith( diff --git a/packages/ai-harness/src/client.ts b/packages/ai-harness/src/client.ts index ca74ca18ca..8535979a55 100644 --- a/packages/ai-harness/src/client.ts +++ b/packages/ai-harness/src/client.ts @@ -167,15 +167,22 @@ export interface HarnessClient { * `as: 'operator'` is the reply of an operator while a handoff to a * person is open: no model runs. The sender needs the `operator` action. */ - send: (message: UserInput, options?: ClientSendOptions) => Promise + send: ( + message: UserInput, + options?: ClientSendOptions, + request?: { signal?: AbortSignal }, + ) => Promise /** * Start a turn from the stored transcript, with no new message. See * `session.continueWithoutMessage`. */ - continueWithoutMessage: (options?: { - inputId?: string - ephemeral?: ReadonlyArray - }) => Promise + continueWithoutMessage: ( + options?: { + inputId?: string + ephemeral?: ReadonlyArray + }, + request?: { signal?: AbortSignal }, + ) => Promise /** * Answer the waits of `snapshot().pendingWaits` that have an * `interrupt`, as `session.resolve` does. The waits of the last turn (the @@ -188,7 +195,10 @@ export interface HarnessClient { * await client.resolve({ resume: [{ interruptId, status: 'resolved' }] }) * ``` */ - resolve: (options: ResolveOptions) => Promise + resolve: ( + options: ResolveOptions, + request?: { signal?: AbortSignal }, + ) => Promise /** * Stop the operation `operationId`, or the running turn without it, as * `session.cancel` does. @@ -198,7 +208,10 @@ export interface HarnessClient { * await client.cancel({ operationId }) * ``` */ - cancel: (options?: CancelOptions) => Promise + cancel: ( + options?: CancelOptions, + request?: { signal?: AbortSignal }, + ) => Promise /** * Cancel an input that waits (see `snapshot().waitingInputs`). An input * that started is refused with `not_waiting`: use `cancel`. @@ -208,15 +221,21 @@ export interface HarnessClient { * await client.cancelInput({ inputId }) * ``` */ - cancelInput: (options: { inputId: string }) => Promise + cancelInput: ( + options: { inputId: string }, + request?: { signal?: AbortSignal }, + ) => Promise /** * Move an input that waits: `'steer'` joins the running turn, `'queue'` * runs it as its own turn later. */ - changeInputDelivery: (change: { - inputId: string - delivery: WaitingInput['delivery'] - }) => Promise + changeInputDelivery: ( + change: { + inputId: string + delivery: WaitingInput['delivery'] + }, + request?: { signal?: AbortSignal }, + ) => Promise agents: ClientAgentHandles /** * Answer a wait of `snapshot().pendingWaits` that no turn holds, as @@ -224,7 +243,10 @@ export interface HarnessClient { * or an agent run, an MCP question, or a `human` wait. The waits of the * stopped turn take `resolve`. */ - answer: (answer: { questionId: string; value: unknown }) => Promise + answer: ( + answer: { questionId: string; value: unknown }, + request?: { signal?: AbortSignal }, + ) => Promise /** * Run the plugin command `name`. Its result arrives as a * `harness.command.result` event. @@ -234,7 +256,10 @@ export interface HarnessClient { * await client.runCommand({ name: 'mode', input: 'plan' }) * ``` */ - runCommand: (options: CommandOptions) => Promise + runCommand: ( + options: CommandOptions, + request?: { signal?: AbortSignal }, + ) => Promise /** * Run a harness tool with no model turn, as `session.runTool` does. The * harness must list it in `expose.tools`. `args` is typed from the @@ -245,12 +270,15 @@ export interface HarnessClient { * await client.runTool({ name: 'sync_inbox', args: { folder: 'INBOX' } }) * ``` */ - runTool: ['name']>(options: { - name: TName - args?: HarnessToolInput - meta?: Record - inputId?: string - }) => Promise + runTool: ['name']>( + options: { + name: TName + args?: HarnessToolInput + meta?: Record + inputId?: string + }, + request?: { signal?: AbortSignal }, + ) => Promise /** * Add host records to the session log, as `session.appendRecords` does. The * harness must list each record type in `expose.records`. With `inputId`, @@ -261,15 +289,21 @@ export interface HarnessClient { * await client.appendRecords({ records: [{ type: 'app.signal', text: 'Saved.' }] }) * ``` */ - appendRecords: (options: { - records: ReadonlyArray - inputId?: string - }) => Promise + appendRecords: ( + options: { + records: ReadonlyArray + inputId?: string + }, + request?: { signal?: AbortSignal }, + ) => Promise /** Change a session setting. */ - setPluginSetting: (change: { - key: string - value: unknown - }) => Promise + setPluginSetting: ( + change: { + key: string + value: unknown + }, + request?: { signal?: AbortSignal }, + ) => Promise /** * Change the stored settings of the thread. See `session.updateThreadSettings`. * @@ -278,7 +312,10 @@ export interface HarnessClient { * await client.updateThreadSettings({ settings: { model: 'strong' } }) * ``` */ - updateThreadSettings: (options: ConfigureOptions) => Promise + updateThreadSettings: ( + options: ConfigureOptions, + request?: { signal?: AbortSignal }, + ) => Promise /** * Start a fresh model context from the next turn. The model sees `note` * first, when you give one. The transcript keeps every message. @@ -288,7 +325,10 @@ export interface HarnessClient { * await client.reset({ note: 'The user wants a new plan.' }) * ``` */ - reset: (options?: ResetOptions) => Promise + reset: ( + options?: ResetOptions, + request?: { signal?: AbortSignal }, + ) => Promise /** * Go back to the message `messageId`, as `session.revert` does. The * harness must list `'undo'` in `expose.commands`. @@ -298,9 +338,12 @@ export interface HarnessClient { * await client.revert({ messageId }) * ``` */ - revert: (options: { messageId: string }) => Promise + revert: ( + options: { messageId: string }, + request?: { signal?: AbortSignal }, + ) => Promise /** End the revert that stands, as `session.unrevert` does. */ - unrevert: () => Promise + unrevert: (request?: { signal?: AbortSignal }) => Promise /** * The session events from `from` (exclusive). Without `from`, the events * start at the cursor of the snapshot: only new events come. Pass @@ -323,9 +366,11 @@ export interface HarnessClient { * again to reconnect: the current statuses come first again. */ hostEvents: (options?: { signal?: AbortSignal }) => AsyncIterable - snapshot: () => Promise + snapshot: (request?: { signal?: AbortSignal }) => Promise /** The saved messages of the thread. */ - transcript: () => Promise> + transcript: (request?: { + signal?: AbortSignal + }) => Promise> /** * The saved messages of a child agent run of the thread, by its * `subagentRunId` or by its `runId`. Empty for an unknown id. A child with @@ -339,12 +384,13 @@ export interface HarnessClient { */ agentTranscript: ( run: { subagentRunId: string } | { runId: string }, + request?: { signal?: AbortSignal }, ) => Promise> /** * The commands, settings, and tools of the session. Only the commands in * `expose.commands` and the config keys in `expose.config`. */ - describe: () => Promise + describe: (request?: { signal?: AbortSignal }) => Promise /** * 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 @@ -357,6 +403,7 @@ export interface HarnessClient { */ putMedia: ( file: MediaUpload, + request?: { signal?: AbortSignal }, ) => Promise /** * A signed URL for a media file, for ``, `