From 9847e358892ab643b0d87dd1cd71660ceb4927e9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 08:06:37 -0700 Subject: [PATCH] feat: add client tool abort signal --- .../content/docs/chat/api/api-docs.json | 32 +- ...07-08-client-tools-m2-abort-signal-plan.md | 354 ++++++++++++++++++ .../client-tools/client-tool-executor.spec.ts | 79 ++++ .../lib/client-tools/client-tool-executor.ts | 28 +- .../chat/src/lib/client-tools/execute.spec.ts | 14 + libs/chat/src/lib/client-tools/execute.ts | 7 +- libs/chat/src/lib/client-tools/index.ts | 2 +- libs/chat/src/lib/client-tools/tool-def.ts | 13 +- libs/chat/src/lib/client-tools/tools.ts | 18 +- .../src/lib/client-tools/tools.type-spec.ts | 3 +- libs/chat/src/public-api.ts | 2 +- 11 files changed, 530 insertions(+), 22 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-08-client-tools-m2-abort-signal-plan.md diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 4c7c76a84..ed7475a6b 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -6305,7 +6305,7 @@ }, { "name": "handler", - "type": "(args: any) => unknown", + "type": "(args: any, context: FunctionToolHandlerContext) => unknown", "description": "", "optional": false }, @@ -6976,7 +6976,7 @@ }, { "name": "handler", - "type": "(args: StandardSchemaInferOutput) => R | Promise", + "type": "(args: StandardSchemaInferOutput, context: FunctionToolHandlerContext) => R | Promise", "description": "", "optional": false }, @@ -6995,6 +6995,20 @@ ], "examples": [] }, + { + "name": "FunctionToolHandlerContext", + "kind": "interface", + "description": "Runtime context passed to browser-executed function tool handlers.", + "properties": [ + { + "name": "signal", + "type": "AbortSignal", + "description": "Aborts when the client tool execution should stop without resolving.", + "optional": false + } + ], + "examples": [] + }, { "name": "Message", "kind": "interface", @@ -8144,7 +8158,7 @@ "name": "action", "kind": "function", "description": "Declare an async function tool the model can call; its resolved return value\nbecomes the tool result shipped back to the model.", - "signature": "action(description: string, schema: S, handler: (args: StandardSchemaInferOutput) => R | Promise): FunctionToolDef", + "signature": "action(description: string, schema: S, handler: (args: StandardSchemaInferOutput, context: FunctionToolHandlerContext) => R | Promise): FunctionToolDef", "params": [ { "name": "description", @@ -8160,8 +8174,8 @@ }, { "name": "handler", - "type": "(args: StandardSchemaInferOutput) => R | Promise", - "description": "Runs in the browser when the model calls the tool; its return\n type `R` is carried on the resulting FunctionToolDef.", + "type": "(args: StandardSchemaInferOutput, context: FunctionToolHandlerContext) => R | Promise", + "description": "Runs in the browser when the model calls the tool. The second\n argument carries an `AbortSignal`; its return type `R` is carried on the\n resulting FunctionToolDef.", "optional": false } ], @@ -8500,7 +8514,7 @@ "name": "executeFunctionTool", "kind": "function", "description": "Validate args, run the handler, and normalize the outcome to a ClientToolResult.", - "signature": "executeFunctionTool(def: AnyFunctionToolDef, rawArgs: unknown): Promise", + "signature": "executeFunctionTool(def: AnyFunctionToolDef, rawArgs: unknown, context: FunctionToolHandlerContext): Promise", "params": [ { "name": "def", @@ -8513,6 +8527,12 @@ "type": "unknown", "description": "", "optional": false + }, + { + "name": "context", + "type": "FunctionToolHandlerContext", + "description": "", + "optional": true } ], "returns": { diff --git a/docs/superpowers/plans/2026-07-08-client-tools-m2-abort-signal-plan.md b/docs/superpowers/plans/2026-07-08-client-tools-m2-abort-signal-plan.md new file mode 100644 index 000000000..89848835d --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-client-tools-m2-abort-signal-plan.md @@ -0,0 +1,354 @@ +# Client Tool Abort Signal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an `AbortSignal` to function client-tool handler context and prevent aborted executions from resolving or starting a continuation. + +**Architecture:** Keep the change in `@threadplane/chat`, where client-tool declaration and execution already live. `action()` and `FunctionToolDef` gain an additive second handler argument, `executeFunctionTool()` passes a context through, and `startClientToolExecutor()` owns one `AbortController` per in-flight function-tool call. Abort is triggered by `agent.stop()` and Angular injector teardown; resolved results are ignored when the signal is aborted. + +**Tech Stack:** Angular signals/effects, Vitest, Zod v4 Standard Schema, Nx, generated API docs. + +--- + +## Source Of Truth + +- Spec: `docs/superpowers/specs/2026-07-07-client-tool-continuation-architecture-design.md` §5b and milestone M2. +- Contract: `handler(args, { signal })` is additive; existing one-argument handlers keep working. +- Abort behavior: an aborted handler must not call `resolve()` and therefore must not start a continuation run. +- Scope: M2 only. Do not implement `settle()`, batching, durable dedup, or max-turns in this phase. + +## File Structure + +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` + - Add `FunctionToolHandlerContext`. + - Add the second handler parameter to `FunctionToolDef` and `AnyFunctionToolDef`. +- Modify: `libs/chat/src/lib/client-tools/tools.ts` + - Accept the additive handler context in `action()`. + - Update API docs comments. +- Modify: `libs/chat/src/lib/client-tools/execute.ts` + - Accept optional handler context. + - Pass `{ signal }` to the handler after argument validation. +- Modify: `libs/chat/src/lib/client-tools/client-tool-executor.ts` + - Create one `AbortController` per in-flight function tool. + - Abort in-flight tools on `agent.stop()` and injector teardown. + - Suppress `cap.resolve()` when a controller has been aborted. +- Modify: `libs/chat/src/lib/client-tools/index.ts` + - Re-export the new context type. +- Modify: `libs/chat/src/public-api.ts` + - Re-export the new context type. +- Modify: `apps/website/content/docs/chat/api/api-docs.json` + - Regenerate after public API change. +- Test: `libs/chat/src/lib/client-tools/execute.spec.ts` + - Verify `executeFunctionTool()` passes `signal` to the handler. +- Test: `libs/chat/src/lib/client-tools/client-tool-executor.spec.ts` + - Verify executor passes a non-aborted signal. + - Verify `stop()` aborts in-flight work and suppresses `resolve()`. + - Verify injector teardown aborts in-flight work. +- Test: `libs/chat/src/lib/client-tools/tools.type-spec.ts` + - Verify inferred handler context type while preserving existing argument and return inference. + +--- + +### Task 1: Handler Context Type And Direct Execution + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` +- Modify: `libs/chat/src/lib/client-tools/tools.ts` +- Modify: `libs/chat/src/lib/client-tools/execute.ts` +- Modify: `libs/chat/src/lib/client-tools/index.ts` +- Modify: `libs/chat/src/public-api.ts` +- Test: `libs/chat/src/lib/client-tools/execute.spec.ts` +- Test: `libs/chat/src/lib/client-tools/tools.type-spec.ts` + +- [x] **Step 1: Write failing direct-execution test** + +Add to `execute.spec.ts`: + +```ts +it('passes the handler context signal to the handler', async () => { + const controller = new AbortController(); + const handler = vi.fn(async (_args: { city: string }, context: { signal: AbortSignal }) => { + expect(context.signal).toBe(controller.signal); + return 'ok'; + }); + const def = action('ctx', z.object({ city: z.string() }), handler); + + const result = await executeFunctionTool(def, { city: 'SF' }, { signal: controller.signal }); + + expect(result).toEqual({ ok: true, value: 'ok' }); + expect(handler).toHaveBeenCalledOnce(); +}); +``` + +- [x] **Step 2: Write failing type assertion** + +Update `tools.type-spec.ts`: + +```ts +import type { FunctionToolDef, ClientToolDef, FunctionToolHandlerContext } from './tool-def'; + +type _ctxInfer = Expect[1], FunctionToolHandlerContext>>; +``` + +Expected: the focused chat type/test run fails because the second handler parameter does not exist yet. + +- [x] **Step 3: Run focused tests to verify red** + +Run from `libs/chat`: + +```bash +npx vitest run src/lib/client-tools/execute.spec.ts src/lib/client-tools/tools.type-spec.ts --config vite.config.mts +``` + +Expected: FAIL because handler context is not typed/passed. + +- [x] **Step 4: Implement minimal context plumbing** + +Add `FunctionToolHandlerContext` in `tool-def.ts`: + +```ts +export interface FunctionToolHandlerContext { + readonly signal: AbortSignal; +} +``` + +Update `FunctionToolDef`, `AnyFunctionToolDef`, and `action()` handler types to accept `(args, context)`. + +Update `executeFunctionTool()` to accept an optional context: + +```ts +const defaultSignal = new AbortController().signal; + +export async function executeFunctionTool( + def: AnyFunctionToolDef, + rawArgs: unknown, + context: FunctionToolHandlerContext = { signal: defaultSignal }, +): Promise { + // validate first, then call: + const value = await def.handler((v as { value: unknown }).value as never, context); +} +``` + +Export `FunctionToolHandlerContext` from `libs/chat/src/lib/client-tools/index.ts` and `libs/chat/src/public-api.ts`. + +- [x] **Step 5: Run focused tests to verify green** + +Run from `libs/chat`: + +```bash +npx vitest run src/lib/client-tools/execute.spec.ts src/lib/client-tools/tools.type-spec.ts --config vite.config.mts +``` + +Expected: PASS. + +--- + +### Task 2: Executor Abort Semantics + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/client-tool-executor.ts` +- Test: `libs/chat/src/lib/client-tools/client-tool-executor.spec.ts` + +- [x] **Step 1: Write failing signal delivery test** + +Add to `client-tool-executor.spec.ts`: + +```ts +it('passes a non-aborted signal to function tool handlers', async () => { + const seen: AbortSignal[] = []; + const registry = tools({ + read: action('read', z.object({}), async (_args, context) => { + seen.push(context.signal); + return 'done'; + }), + }); + const { pending, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry); + }); + + pending.set([{ id: 's1', name: 'read', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(seen).toHaveLength(1); + expect(seen[0].aborted).toBe(false); +}); +``` + +- [x] **Step 2: Write failing stop-abort test** + +Add to `client-tool-executor.spec.ts`: + +```ts +it('aborts in-flight function tools on stop and does not resolve them', async () => { + let complete!: (value: string) => void; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const seen: AbortSignal[] = []; + const registry = tools({ + slow: action('slow', z.object({}), async (_args, context) => { + seen.push(context.signal); + return completion; + }), + }); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry); + }); + + pending.set([{ id: 'slow-1', name: 'slow', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + await agent.stop(); + expect(seen[0].aborted).toBe(true); + + complete('late result'); + await drainMicrotasks(); + + expect(resolve).not.toHaveBeenCalled(); +}); +``` + +- [x] **Step 3: Write failing injector-teardown test** + +Add to `client-tool-executor.spec.ts`: + +```ts +it('aborts in-flight function tools when the injection context is destroyed', async () => { + const seen: AbortSignal[] = []; + const registry = tools({ + slow: action('slow', z.object({}), async (_args, context) => { + seen.push(context.signal); + return new Promise(() => undefined); + }), + }); + const { pending, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry); + }); + + pending.set([{ id: 'slow-2', name: 'slow', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + TestBed.resetTestingModule(); + + expect(seen[0].aborted).toBe(true); +}); +``` + +- [x] **Step 4: Run executor tests to verify red** + +Run from `libs/chat`: + +```bash +npx vitest run src/lib/client-tools/client-tool-executor.spec.ts --config vite.config.mts +``` + +Expected: FAIL because executor does not pass context or abort in-flight work. + +- [x] **Step 5: Implement minimal executor abort behavior** + +In `client-tool-executor.ts`: + +```ts +const destroyRef = inject(DestroyRef); +const inFlight = new Map(); + +const abortAll = (): void => { + for (const controller of inFlight.values()) { + controller.abort(); + } +}; + +const originalStop = agent.stop.bind(agent); +agent.stop = async () => { + abortAll(); + await originalStop(); +}; + +destroyRef.onDestroy(abortAll); +``` + +For each function tool call, create a controller, pass `{ signal: controller.signal }`, and resolve only if `!controller.signal.aborted`. + +- [x] **Step 6: Run executor tests to verify green** + +Run from `libs/chat`: + +```bash +npx vitest run src/lib/client-tools/client-tool-executor.spec.ts --config vite.config.mts +``` + +Expected: PASS. + +--- + +### Task 3: Public API Docs And Verification + +**Files:** +- Modify: `apps/website/content/docs/chat/api/api-docs.json` + +- [x] **Step 1: Regenerate API docs** + +Run: + +```bash +npm run generate-api-docs +``` + +Expected: generated chat API docs include `FunctionToolHandlerContext`. + +- [x] **Step 2: Run project tests** + +Run: + +```bash +npx nx test chat +``` + +Expected: PASS. + +- [x] **Step 3: Run focused adapter smoke tests** + +Run: + +```bash +npx nx test ag-ui +npx nx test langgraph +``` + +Expected: PASS, proving the additive handler type did not break adapter test suites. + +- [x] **Step 4: Run lint and count errors** + +Run: + +```bash +npx nx lint chat 2>&1 | tee /tmp/threadplane-chat-m2-lint.log; grep -cE ' error ' /tmp/threadplane-chat-m2-lint.log +npx nx lint ag-ui 2>&1 | tee /tmp/threadplane-ag-ui-m2-lint.log; grep -cE ' error ' /tmp/threadplane-ag-ui-m2-lint.log +npx nx lint langgraph 2>&1 | tee /tmp/threadplane-langgraph-m2-lint.log; grep -cE ' error ' /tmp/threadplane-langgraph-m2-lint.log +``` + +Expected: each grep count is `0`. + +- [x] **Step 5: Diff audit** + +Run: + +```bash +git diff --check +git diff --name-only +(git diff --name-only; git ls-files --others --exclude-standard) | rg -v '^docs/superpowers/' | xargs rg -n "hashbrown|copilotkit|chatgpt|claude" || true +``` + +Expected: no whitespace errors; changed files match M2 scope; forbidden external names are absent from changed code and generated docs except spec/plan markdown where allowed. diff --git a/libs/chat/src/lib/client-tools/client-tool-executor.spec.ts b/libs/chat/src/lib/client-tools/client-tool-executor.spec.ts index 35611dcd6..cc73196c6 100644 --- a/libs/chat/src/lib/client-tools/client-tool-executor.spec.ts +++ b/libs/chat/src/lib/client-tools/client-tool-executor.spec.ts @@ -168,6 +168,85 @@ describe('startClientToolExecutor()', () => { expect(resolve).toHaveBeenCalledOnce(); }); + it('passes a non-aborted signal to function tool handlers', async () => { + const seen: AbortSignal[] = []; + const registry = tools({ + read: action('read', z.object({}), async (_args, context) => { + seen.push(context.signal); + return 'done'; + }), + }); + const { pending, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry); + }); + + pending.set([{ id: 's1', name: 'read', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(seen).toHaveLength(1); + expect(seen[0].aborted).toBe(false); + }); + + it('aborts in-flight function tools on stop and does not resolve them', async () => { + let complete!: (value: string) => void; + const completion = new Promise((resolve) => { + complete = resolve; + }); + const seen: AbortSignal[] = []; + const registry = tools({ + slow: action('slow', z.object({}), async (_args, context) => { + seen.push(context.signal); + return completion; + }), + }); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry); + }); + + pending.set([{ id: 'slow-1', name: 'slow', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + await agent.stop(); + expect(seen[0].aborted).toBe(true); + + complete('late result'); + await drainMicrotasks(); + + expect(resolve).not.toHaveBeenCalled(); + }); + + it('aborts in-flight function tools when the injection context is destroyed', async () => { + const seen: AbortSignal[] = []; + const registry = tools({ + slow: action('slow', z.object({}), async (_args, context) => { + seen.push(context.signal); + return new Promise(() => undefined); + }), + }); + const { pending, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry); + }); + + pending.set([{ id: 'slow-2', name: 'slow', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + TestBed.resetTestingModule(); + + expect(seen[0].aborted).toBe(true); + }); + it('ignores view/ask tool calls (only function tools are auto-executed)', async () => { const { pending, resolve, capability } = makeFakeCapability(); const agent = makeFakeAgent(capability); diff --git a/libs/chat/src/lib/client-tools/client-tool-executor.ts b/libs/chat/src/lib/client-tools/client-tool-executor.ts index 6cc83ad7a..763637406 100644 --- a/libs/chat/src/lib/client-tools/client-tool-executor.ts +++ b/libs/chat/src/lib/client-tools/client-tool-executor.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import { effect } from '@angular/core'; +import { DestroyRef, effect, inject } from '@angular/core'; import type { Agent } from '../agent'; import type { ClientToolRegistry } from './tool-def'; import { executeFunctionTool } from './execute'; @@ -13,7 +13,21 @@ import { executeFunctionTool } from './execute'; export function startClientToolExecutor(agent: Agent, registry: ClientToolRegistry): void { const cap = agent.clientTools; if (!cap) return; - const inFlight = new Set(); + const destroyRef = inject(DestroyRef); + const inFlight = new Map(); + const abortAll = (): void => { + for (const controller of inFlight.values()) { + controller.abort(); + } + }; + + const originalStop = agent.stop.bind(agent); + agent.stop = async (): Promise => { + abortAll(); + await originalStop(); + }; + destroyRef.onDestroy(abortAll); + effect(() => { for (const tc of cap.pending()) { const def = registry[tc.name]; @@ -24,9 +38,13 @@ export function startClientToolExecutor(agent: Agent, registry: ClientToolRegist // calls that have a result or were resolved; `inFlight` prevents a // double-dispatch within a render cycle. if (inFlight.has(tc.id)) continue; - inFlight.add(tc.id); - void executeFunctionTool(def, tc.args).then((result) => { - cap.resolve(tc.id, result); + const controller = new AbortController(); + inFlight.set(tc.id, controller); + void executeFunctionTool(def, tc.args, { signal: controller.signal }).then((result) => { + if (!controller.signal.aborted) { + cap.resolve(tc.id, result); + } + }).finally(() => { inFlight.delete(tc.id); }); } diff --git a/libs/chat/src/lib/client-tools/execute.spec.ts b/libs/chat/src/lib/client-tools/execute.spec.ts index 0947d7c29..9e0deca1f 100644 --- a/libs/chat/src/lib/client-tools/execute.spec.ts +++ b/libs/chat/src/lib/client-tools/execute.spec.ts @@ -54,4 +54,18 @@ describe('executeFunctionTool()', () => { } expect(handlerSpy).not.toHaveBeenCalled(); }); + + it('passes the handler context signal to the handler', async () => { + const controller = new AbortController(); + const handler = vi.fn(async (_args: { city: string }, context: { signal: AbortSignal }) => { + expect(context.signal).toBe(controller.signal); + return 'ok'; + }); + const def = action('ctx', z.object({ city: z.string() }), handler); + + const result = await executeFunctionTool(def, { city: 'SF' }, { signal: controller.signal }); + + expect(result).toEqual({ ok: true, value: 'ok' }); + expect(handler).toHaveBeenCalledOnce(); + }); }); diff --git a/libs/chat/src/lib/client-tools/execute.ts b/libs/chat/src/lib/client-tools/execute.ts index 14f592c67..a73afa15e 100644 --- a/libs/chat/src/lib/client-tools/execute.ts +++ b/libs/chat/src/lib/client-tools/execute.ts @@ -1,8 +1,10 @@ // SPDX-License-Identifier: MIT import type { StandardSchemaV1 } from '@threadplane/render'; -import type { AnyFunctionToolDef } from './tool-def'; +import type { AnyFunctionToolDef, FunctionToolHandlerContext } from './tool-def'; import type { ClientToolResult } from './client-tools-capability'; +const defaultSignal = new AbortController().signal; + /** Validate raw model args against a Standard Schema. */ export async function validateArgs( schema: StandardSchemaV1, @@ -22,11 +24,12 @@ export async function validateArgs( export async function executeFunctionTool( def: AnyFunctionToolDef, rawArgs: unknown, + context: FunctionToolHandlerContext = { signal: defaultSignal }, ): Promise { const v = await validateArgs(def.schema, rawArgs); if (!v.ok) return { ok: false, error: `invalid arguments: ${(v as { error: string }).error}` }; try { - const value = await def.handler((v as { value: unknown }).value as never); + const value = await def.handler((v as { value: unknown }).value as never, context); return { ok: true, value }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; diff --git a/libs/chat/src/lib/client-tools/index.ts b/libs/chat/src/lib/client-tools/index.ts index 57436b5b3..3a8479e78 100644 --- a/libs/chat/src/lib/client-tools/index.ts +++ b/libs/chat/src/lib/client-tools/index.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT export { action, view, ask, tools } from './tools'; export { deriveJsonSchema } from './to-json-schema'; -export type { ClientToolDef, FunctionToolDef, AnyFunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry } from './tool-def'; +export type { ClientToolDef, FunctionToolDef, FunctionToolHandlerContext, AnyFunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry } from './tool-def'; export type { ClientToolSpec } from './to-json-schema'; export type { ClientToolsCapability, ClientToolResult } from './client-tools-capability'; export { validateArgs, executeFunctionTool } from './execute'; diff --git a/libs/chat/src/lib/client-tools/tool-def.ts b/libs/chat/src/lib/client-tools/tool-def.ts index c1d513786..f7d290005 100644 --- a/libs/chat/src/lib/client-tools/tool-def.ts +++ b/libs/chat/src/lib/client-tools/tool-def.ts @@ -4,13 +4,22 @@ import type { StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOut export type { StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput }; +/** Runtime context passed to browser-executed function tool handlers. */ +export interface FunctionToolHandlerContext { + /** Aborts when the client tool execution should stop without resolving. */ + readonly signal: AbortSignal; +} + /** Precise authored function tool — what `action()` returns. Carries the schema * `S` and the handler's resolved return type `R`. */ export interface FunctionToolDef { readonly kind: 'function'; readonly description: string; readonly schema: S; - readonly handler: (args: StandardSchemaInferOutput) => R | Promise; + readonly handler: ( + args: StandardSchemaInferOutput, + context: FunctionToolHandlerContext, + ) => R | Promise; } /** Bivariant union member used only for registry storage/iteration. The handler @@ -23,7 +32,7 @@ export interface AnyFunctionToolDef { readonly description: string; readonly schema: StandardSchemaV1; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariance escape hatch; see note above - readonly handler: (args: any) => unknown | Promise; + readonly handler: (args: any, context: FunctionToolHandlerContext) => unknown | Promise; } export interface ViewToolDef { diff --git a/libs/chat/src/lib/client-tools/tools.ts b/libs/chat/src/lib/client-tools/tools.ts index 0cbc24e6a..823004bb4 100644 --- a/libs/chat/src/lib/client-tools/tools.ts +++ b/libs/chat/src/lib/client-tools/tools.ts @@ -1,7 +1,13 @@ // SPDX-License-Identifier: MIT import type { Type } from '@angular/core'; import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/render'; -import type { ClientToolDef, FunctionToolDef, ViewToolDef, AskToolDef } from './tool-def'; +import type { + AskToolDef, + ClientToolDef, + FunctionToolDef, + FunctionToolHandlerContext, + ViewToolDef, +} from './tool-def'; import type { AcceptComponent } from './component-inputs'; export type { ViewProps } from './component-inputs'; @@ -12,8 +18,9 @@ export type { ViewProps } from './component-inputs'; * @param description Natural-language description the model sees. * @param schema Standard Schema (e.g. a Zod object) for the arguments; the * handler's argument type is inferred from it. - * @param handler Runs in the browser when the model calls the tool; its return - * type `R` is carried on the resulting {@link FunctionToolDef}. + * @param handler Runs in the browser when the model calls the tool. The second + * argument carries an `AbortSignal`; its return type `R` is carried on the + * resulting {@link FunctionToolDef}. * @returns A {@link FunctionToolDef} for inclusion in {@link tools}. * @example * ```ts @@ -24,7 +31,10 @@ export type { ViewProps } from './component-inputs'; export function action( description: string, schema: S, - handler: (args: StandardSchemaInferOutput) => R | Promise, + handler: ( + args: StandardSchemaInferOutput, + context: FunctionToolHandlerContext, + ) => R | Promise, ): FunctionToolDef { return { kind: 'function', description, schema, handler }; } diff --git a/libs/chat/src/lib/client-tools/tools.type-spec.ts b/libs/chat/src/lib/client-tools/tools.type-spec.ts index 2719738c6..366615168 100644 --- a/libs/chat/src/lib/client-tools/tools.type-spec.ts +++ b/libs/chat/src/lib/client-tools/tools.type-spec.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import type { Equal, Expect } from '../../testing/type-assert'; -import type { FunctionToolDef, ClientToolDef } from './tool-def'; +import type { FunctionToolDef, ClientToolDef, FunctionToolHandlerContext } from './tool-def'; import { action, tools } from './tools'; import { z } from 'zod/v4'; @@ -9,6 +9,7 @@ const moveSchema = z.object({ fromDay: z.number(), placeId: z.string() }); // action() infers handler arg from the schema output and carries the return type R. const moveAction = action('Move a stop', moveSchema, (a) => a.fromDay + 1); type _argInfer = Expect[0], { fromDay: number; placeId: string }>>; +type _ctxInfer = Expect[1], FunctionToolHandlerContext>>; type _retInfer = Expect>>; // A precise FunctionToolDef must be assignable into the bivariant union. diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 8f8596e8f..2bf45fed9 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -230,7 +230,7 @@ export { isPathRef, isLiteralString, isLiteralNumber, isLiteralBoolean } from '@ // Client tools (declaration API — tools/action/view/ask + JSON-schema derivation) export { tools, action, view, ask } from './lib/client-tools/tools'; export { deriveJsonSchema } from './lib/client-tools/to-json-schema'; -export type { ClientToolDef, AnyFunctionToolDef, FunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry, StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput } from './lib/client-tools/tool-def'; +export type { ClientToolDef, AnyFunctionToolDef, FunctionToolDef, FunctionToolHandlerContext, ViewToolDef, AskToolDef, ClientToolRegistry, StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput } from './lib/client-tools/tool-def'; export type { ViewProps } from './lib/client-tools/component-inputs'; /** Inferred argument type for a schema (alias of StandardSchemaInferOutput). */ export type ToolArgs = import('./lib/client-tools/tool-def').StandardSchemaInferOutput;