From 0ca98b7b269d0460a23a88db9d1d0ea400a2896f Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 10:24:05 -0700 Subject: [PATCH] feat: add client tool execution claims --- .../content/docs/chat/api/api-docs.json | 232 ++++++++++++- ...ent-tools-m35-claim-before-execute-plan.md | 324 ++++++++++++++++++ .../client-tool-execution-guard.spec.ts | 44 +++ .../client-tool-execution-guard.ts | 56 +++ .../client-tools/client-tool-executor.spec.ts | 206 +++++++++++ .../lib/client-tools/client-tool-executor.ts | 94 ++++- .../client-tools-coordinator.spec.ts | 45 +++ .../client-tools/client-tools-coordinator.ts | 13 +- libs/chat/src/lib/client-tools/index.ts | 16 +- libs/chat/src/lib/client-tools/tool-def.ts | 8 + libs/chat/src/lib/client-tools/tools.ts | 6 +- .../chat/chat.component.client-tools.spec.ts | 55 +++ .../lib/compositions/chat/chat.component.ts | 6 +- libs/chat/src/public-api.ts | 14 +- 14 files changed, 1104 insertions(+), 15 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-08-client-tools-m35-claim-before-execute-plan.md create mode 100644 libs/chat/src/lib/client-tools/client-tool-execution-guard.spec.ts create mode 100644 libs/chat/src/lib/client-tools/client-tool-execution-guard.ts diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index ed7475a6b..7c02a0cd0 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1821,6 +1821,12 @@ "description": "", "optional": false }, + { + "name": "clientToolExecutionGuard", + "type": "InputSignal", + "description": "", + "optional": false + }, { "name": "clientTools", "type": "InputSignal> | undefined>", @@ -6309,6 +6315,12 @@ "description": "", "optional": false }, + { + "name": "idempotent", + "type": "boolean", + "description": "", + "optional": true + }, { "name": "kind", "type": "\"function\"", @@ -6688,6 +6700,134 @@ ], "examples": [] }, + { + "name": "ClientToolExecutionGuard", + "kind": "interface", + "description": "Opt-in guard configuration for claim-before-execute client tools.", + "properties": [ + { + "name": "store", + "type": "ClientToolExecutionStore", + "description": "", + "optional": false + }, + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "ClientToolExecutionKey", + "kind": "interface", + "description": "Durable identity for one client-tool execution on one thread.", + "properties": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "toolCallId", + "type": "string", + "description": "", + "optional": false + } + ], + "examples": [] + }, + { + "name": "ClientToolExecutionOptions", + "kind": "interface", + "description": "Execution policy options for browser-executed function tools.", + "properties": [ + { + "name": "idempotent", + "type": "boolean", + "description": "True when the handler may safely re-run and should skip durable pre-claims.", + "optional": true + } + ], + "examples": [] + }, + { + "name": "ClientToolExecutionStore", + "kind": "interface", + "description": "Structural store contract for guarded browser client-tool execution.", + "properties": [], + "methods": [ + { + "name": "claim", + "signature": "claim(key: ClientToolExecutionKey): Promise<\"claimed\" | ClientToolExecutionRecord>", + "description": "Atomically claim a tool-call execution. Returns prior state when present.", + "params": [ + { + "name": "key", + "type": "ClientToolExecutionKey", + "description": "", + "optional": false + } + ] + }, + { + "name": "lookup", + "signature": "lookup(threadId: string, toolCallIds: readonly string[]): Promise>", + "description": "Lookup prior execution states for pending tool calls on a thread.", + "params": [ + { + "name": "threadId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "toolCallIds", + "type": "readonly string[]", + "description": "", + "optional": false + } + ] + }, + { + "name": "record", + "signature": "record(key: ClientToolExecutionKey, result: ClientToolResult): Promise", + "description": "Record the final client-tool result for a claimed execution.", + "params": [ + { + "name": "key", + "type": "ClientToolExecutionKey", + "description": "", + "optional": false + }, + { + "name": "result", + "type": "ClientToolResult", + "description": "", + "optional": false + } + ] + } + ], + "examples": [] + }, + { + "name": "ClientToolExecutorOptions", + "kind": "interface", + "description": "Options for wiring automatic browser function-tool execution.", + "properties": [ + { + "name": "executionGuard", + "type": "ClientToolExecutionGuard", + "description": "", + "optional": true + } + ], + "examples": [] + }, { "name": "ClientToolsCapability", "kind": "interface", @@ -6980,6 +7120,12 @@ "description": "", "optional": false }, + { + "name": "idempotent", + "type": "boolean", + "description": "", + "optional": true + }, { "name": "kind", "type": "\"function\"", @@ -7979,6 +8125,13 @@ "signature": "AnyFunctionToolDef | ViewToolDef | AskToolDef", "examples": [] }, + { + "name": "ClientToolExecutionRecord", + "kind": "type", + "description": "Prior durable execution state returned by a client-tool execution store.", + "signature": "object | object | object", + "examples": [] + }, { "name": "ClientToolRegistry", "kind": "type", @@ -8158,7 +8311,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, context: FunctionToolHandlerContext) => R | Promise): FunctionToolDef", + "signature": "action(description: string, schema: S, handler: (args: StandardSchemaInferOutput, context: FunctionToolHandlerContext) => R | Promise, options: ClientToolExecutionOptions): FunctionToolDef", "params": [ { "name": "description", @@ -8177,6 +8330,12 @@ "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 + }, + { + "name": "options", + "type": "ClientToolExecutionOptions", + "description": "Execution policy options, including `idempotent: true` to skip\n durable pre-execution claims when a guard is configured.", + "optional": true } ], "returns": { @@ -8302,6 +8461,31 @@ }, "examples": [] }, + { + "name": "clientToolGuardFailureResult", + "kind": "function", + "description": "Default fail-closed result when the execution guard itself cannot be reached.", + "signature": "clientToolGuardFailureResult(toolCallId: string, error: unknown): ClientToolResult", + "params": [ + { + "name": "toolCallId", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "error", + "type": "unknown", + "description": "", + "optional": false + } + ], + "returns": { + "type": "ClientToolResult", + "description": "" + }, + "examples": [] + }, { "name": "createA2uiSurfaceStore", "kind": "function", @@ -8391,6 +8575,25 @@ }, "examples": [] }, + { + "name": "defaultInterruptedClientToolResult", + "kind": "function", + "description": "Default fail-closed result for a stale in-progress client-tool execution.", + "signature": "defaultInterruptedClientToolResult(toolCallId: string): ClientToolResult", + "params": [ + { + "name": "toolCallId", + "type": "string", + "description": "", + "optional": false + } + ], + "returns": { + "type": "ClientToolResult", + "description": "" + }, + "examples": [] + }, { "name": "deriveDomain", "kind": "function", @@ -8966,11 +9169,30 @@ }, "examples": [] }, + { + "name": "shouldClaimBeforeExecute", + "kind": "function", + "description": "Return whether this function tool should claim before browser execution.", + "signature": "shouldClaimBeforeExecute(def: AnyFunctionToolDef): boolean", + "params": [ + { + "name": "def", + "type": "AnyFunctionToolDef", + "description": "", + "optional": false + } + ], + "returns": { + "type": "boolean", + "description": "" + }, + "examples": [] + }, { "name": "startClientToolExecutor", "kind": "function", "description": "Watches the agent's pending client tool calls and auto-runs FUNCTION tools,\nresolving each with its result. View/ask (component) tools are handled by the\nrendering layer, not here. No-op if the agent lacks the clientTools\ncapability. MUST be called in an injection context (sets up an effect).", - "signature": "startClientToolExecutor(agent: Agent<>, registry: ClientToolRegistry): void", + "signature": "startClientToolExecutor(agent: Agent<>, registry: ClientToolRegistry, options: ClientToolExecutorOptions): void", "params": [ { "name": "agent", @@ -8983,6 +9205,12 @@ "type": "ClientToolRegistry", "description": "", "optional": false + }, + { + "name": "options", + "type": "ClientToolExecutorOptions", + "description": "", + "optional": true } ], "returns": { diff --git a/docs/superpowers/plans/2026-07-08-client-tools-m35-claim-before-execute-plan.md b/docs/superpowers/plans/2026-07-08-client-tools-m35-claim-before-execute-plan.md new file mode 100644 index 000000000..2c3d0e6f9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-client-tools-m35-claim-before-execute-plan.md @@ -0,0 +1,324 @@ +# Client Tool Claim Before Execute M3.5 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add opt-in Tier-2 claim-before-execute for browser function tools so guarded, non-idempotent handlers claim durably before running and fail closed on stale in-progress executions. + +**Architecture:** Keep the runtime-neutral `Agent` contract intact and keep browser packages decoupled from `@threadplane/middleware`. `@threadplane/chat` owns a structural execution-guard boundary (`threadId` + store with `claim`/`record`/`lookup`), the function executor consults it only when supplied, and `action(..., { idempotent: true })` opts a handler out of the pre-execution round trip. Existing apps with no guard continue on the current fast path. + +**Tech Stack:** TypeScript, Angular signals/effects, Vitest, Nx, generated API docs. + +--- + +## Source Of Truth + +- Spec: `docs/superpowers/specs/2026-07-07-client-tool-continuation-architecture-design.md` §7c, §7d, §7e, and milestone M3.5. +- M3 foundation: `@threadplane/middleware/langgraph` exports a compatible store interface and Postgres implementation. M3.5 must not import middleware from chat; the API stays structural so apps can bridge to a backend endpoint or reuse compatible types server-side. +- Tier 2 applies to browser-executed function tools. `view()` and `ask()` remain outside this PR because they do not run automatic browser side-effect handlers. +- No guard configured means Tier 0/Tier 1 behavior remains exactly as today. +- With a guard configured, function tools are fail-closed by default. `idempotent: true` opts out and uses the existing no-claim execution path. + +## File Structure + +- Create: `libs/chat/src/lib/client-tools/client-tool-execution-guard.ts` + - Structural guard/store types, compatible with the M3 middleware store shape. + - `defaultInterruptedClientToolResult()` and `clientToolGuardFailureResult()` helpers. + - `shouldClaimBeforeExecute()` helper for function tool idempotency policy. +- Test: `libs/chat/src/lib/client-tools/client-tool-execution-guard.spec.ts` + - Pure policy/result helper tests. +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` + - Add `ClientToolExecutionOptions` with `idempotent?: boolean`. + - Add `idempotent?: boolean` to function tool definitions. +- Modify: `libs/chat/src/lib/client-tools/tools.ts` + - Add an optional fourth `options` parameter to `action()`. +- Modify: `libs/chat/src/lib/client-tools/client-tool-executor.ts` + - Add optional `ClientToolExecutionGuard`. + - Claim before executing guarded function tools. + - Record successful or normalized error results before resolving. + - Skip handler and resolve prior `done` results. + - Skip handler, record an interrupted error, and resolve it for stale `executing`/unresolved `failed` records. + - On claim/record failure, fail closed without running the handler. +- Test: `libs/chat/src/lib/client-tools/client-tool-executor.spec.ts` + - Extend existing executor tests with M3.5 claim behavior. +- Modify: `libs/chat/src/lib/client-tools/client-tools-coordinator.ts` + - Accept optional coordinator options and pass the execution guard to the executor. +- Test: `libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts` + - Verify the coordinator forwards the guard and keeps unguarded behavior unchanged. +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + - Add `clientToolExecutionGuard` input. + - Include the guard in coordinator memoization. +- Test: `libs/chat/src/lib/compositions/chat/chat.component.client-tools.spec.ts` + - Verify the chat composition wires the guard into function-tool execution. +- Modify: `libs/chat/src/lib/client-tools/index.ts` + - Export new public guard and option types/helpers. +- Modify: `apps/website/content/docs/chat/api/api-docs.json` + - Regenerate after public exports. + +--- + +### Task 1: Guard Boundary And Idempotency Metadata + +**Files:** +- Create: `libs/chat/src/lib/client-tools/client-tool-execution-guard.spec.ts` +- Create: `libs/chat/src/lib/client-tools/client-tool-execution-guard.ts` +- 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/index.ts` + +- [x] **Step 1: Write failing guard/type tests** + +Cover: +- `action(description, schema, handler, { idempotent: true })` preserves `idempotent: true`. +- `action(description, schema, handler)` leaves `idempotent` undefined. +- `shouldClaimBeforeExecute()` returns true for function tools unless `idempotent === true`. +- `defaultInterruptedClientToolResult()` returns an `ok:false` result mentioning the tool call id. +- `clientToolGuardFailureResult()` returns an `ok:false` result mentioning guard failure. + +- [x] **Step 2: Run guard/type tests to verify red** + +Run: + +```bash +npx vitest run src/lib/client-tools/client-tool-execution-guard.spec.ts --config vite.config.mts +``` + +Expected: FAIL because the guard module/options do not exist. + +- [x] **Step 3: Implement guard boundary and action options** + +Add: + +```ts +export interface ClientToolExecutionOptions { + readonly idempotent?: boolean; +} + +export interface ClientToolExecutionKey { + readonly threadId: string; + readonly toolCallId: string; +} + +export type ClientToolExecutionRecord = + | { readonly status: 'executing' } + | { readonly status: 'done'; readonly result: ClientToolResult } + | { readonly status: 'failed'; readonly result?: ClientToolResult }; + +export interface ClientToolExecutionStore { + claim(key: ClientToolExecutionKey): Promise<'claimed' | ClientToolExecutionRecord>; + record(key: ClientToolExecutionKey, result: ClientToolResult): Promise; + lookup(threadId: string, toolCallIds: readonly string[]): Promise>; +} + +export interface ClientToolExecutionGuard { + readonly threadId: string; + readonly store: ClientToolExecutionStore; +} +``` + +Add `idempotent?: boolean` to function tool definitions and copy options through `action()`. + +- [x] **Step 4: Run guard/type tests to verify green** + +Run: + +```bash +npx vitest run src/lib/client-tools/client-tool-execution-guard.spec.ts --config vite.config.mts +``` + +Expected: PASS. + +--- + +### Task 2: Function Executor Claim-Before-Execute + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/client-tool-executor.spec.ts` +- Modify: `libs/chat/src/lib/client-tools/client-tool-executor.ts` + +- [x] **Step 1: Write failing executor tests** + +Add focused tests: +- With no guard, existing execution path does not call any store. +- With guard and a non-idempotent action, `store.claim()` runs before the handler. +- On `'claimed'`, the handler runs, `store.record()` records the normalized result before `cap.resolve()`. +- On prior `{ status: 'done', result }`, the handler is not called and the stored result is resolved. +- On prior `{ status: 'executing' }`, the handler is not called, an interrupted error result is recorded, and that result is resolved. +- On `{ status: 'failed' }` without a result, the same fail-closed interrupted path is used. +- On guard claim failure, the handler is not called and a guard-failure error is resolved. +- On `idempotent: true`, the guard is bypassed and the handler runs on the existing path. +- If `agent.stop()` aborts before a delayed claim resolves, the handler is not called and no result is resolved. + +- [x] **Step 2: Run executor tests to verify red** + +Run: + +```bash +npx vitest run src/lib/client-tools/client-tool-executor.spec.ts --config vite.config.mts +``` + +Expected: FAIL for missing options/guard behavior. + +- [x] **Step 3: Implement minimal guarded execution** + +Use one internal async helper per pending tool call: + +```ts +if (!guard || !shouldClaimBeforeExecute(def)) { + return executeAndResolveToday(); +} + +const key = { threadId: guard.threadId, toolCallId: tc.id }; +const claim = await guard.store.claim(key); +if (signal.aborted) return; +if (claim === 'claimed') { + const result = await executeFunctionTool(def, tc.args, { signal }); + if (signal.aborted) return; + await guard.store.record(key, result); + if (!signal.aborted) cap.resolve(tc.id, result); + return; +} +if (claim.status === 'done') { + cap.resolve(tc.id, claim.result); + return; +} +const result = claim.status === 'failed' && claim.result + ? claim.result + : defaultInterruptedClientToolResult(tc.id); +await guard.store.record(key, result); +if (!signal.aborted) cap.resolve(tc.id, result); +``` + +On claim/record errors, fail closed with `clientToolGuardFailureResult()` and do not run the handler. + +- [x] **Step 4: Run executor tests to verify green** + +Run: + +```bash +npx vitest run src/lib/client-tools/client-tool-executor.spec.ts --config vite.config.mts +``` + +Expected: PASS. + +--- + +### Task 3: Coordinator And Chat Component Wiring + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts` +- Modify: `libs/chat/src/lib/client-tools/client-tools-coordinator.ts` +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.client-tools.spec.ts` +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + +- [x] **Step 1: Write failing coordinator/component tests** + +Cover: +- `createClientToolsCoordinator(registry, { executionGuard })` passes the guard to function-tool execution. +- Existing `createClientToolsCoordinator(registry)` still auto-executes unguarded actions. +- `ChatComponent` accepts a `clientToolExecutionGuard` input and guarded function tools claim before handler execution. + +- [x] **Step 2: Run focused tests to verify red** + +Run: + +```bash +npx vitest run src/lib/client-tools/client-tools-coordinator.spec.ts src/lib/compositions/chat/chat.component.client-tools.spec.ts --config vite.config.mts +``` + +Expected: FAIL for missing coordinator options/component input. + +- [x] **Step 3: Implement wiring** + +Add: + +```ts +export interface ClientToolsCoordinatorOptions { + readonly executionGuard?: ClientToolExecutionGuard; +} +``` + +Pass `options.executionGuard` into `startClientToolExecutor()`. Add: + +```ts +readonly clientToolExecutionGuard = input(undefined); +``` + +and include it when creating the coordinator. + +- [x] **Step 4: Run focused tests to verify green** + +Run: + +```bash +npx vitest run src/lib/client-tools/client-tools-coordinator.spec.ts src/lib/compositions/chat/chat.component.client-tools.spec.ts --config vite.config.mts +``` + +Expected: PASS. + +--- + +### Task 4: API Docs, Verification, And PR + +**Files:** +- Modify: `apps/website/content/docs/chat/api/api-docs.json` +- Optional Modify: `libs/chat/README.md` only if an existing client-tools section is present and can be updated narrowly. + +- [x] **Step 1: Regenerate API docs** + +Run: + +```bash +npm run generate-api-docs +``` + +Expected: chat API docs include the new public guard/options exports. + +- [x] **Step 2: Run focused chat verification** + +Run: + +```bash +npx nx test chat +npx nx lint chat +npx nx build chat +``` + +Expected: tests, lint, and build complete without errors. + +- [x] **Step 3: Run release-path guard** + +Run: + +```bash +npx nx run-many -t build --projects=chat,langgraph,ag-ui,render,a2ui,licensing,telemetry --configuration=production +node scripts/check-dx-coverage.mjs +``` + +Expected: library build guard and DX coverage pass. + +- [x] **Step 4: Diff audit** + +Run: + +```bash +git diff --check +(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 non-plan files contain none of the forbidden external names. + +- [ ] **Step 5: Commit and open PR** + +Commit: + +```bash +git add docs/superpowers/plans/2026-07-08-client-tools-m35-claim-before-execute-plan.md \ + libs/chat/src/lib/client-tools \ + libs/chat/src/lib/compositions/chat/chat.component.ts \ + libs/chat/src/lib/compositions/chat/chat.component.client-tools.spec.ts \ + apps/website/content/docs/chat/api/api-docs.json +git commit -m "feat: add client tool execution claims" +git push -u origin blove/client-tools-m35-claim-before-execute +``` + +Open a PR for M3.5 only. Enable auto-merge after checks are running, then merge on green before continuing to M4. diff --git a/libs/chat/src/lib/client-tools/client-tool-execution-guard.spec.ts b/libs/chat/src/lib/client-tools/client-tool-execution-guard.spec.ts new file mode 100644 index 000000000..2cef64336 --- /dev/null +++ b/libs/chat/src/lib/client-tools/client-tool-execution-guard.spec.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { z } from 'zod/v4'; +import { + clientToolGuardFailureResult, + defaultInterruptedClientToolResult, + shouldClaimBeforeExecute, +} from './client-tool-execution-guard'; +import { action } from './tools'; + +describe('client tool execution guard helpers', () => { + it('marks action tools idempotent when requested', () => { + const tool = action('Read cached data', z.object({}), async () => 'cached', { + idempotent: true, + }); + + expect(tool.idempotent).toBe(true); + expect(shouldClaimBeforeExecute(tool)).toBe(false); + }); + + it('claims before executing function tools by default', () => { + const tool = action('Charge a card', z.object({}), async () => 'charged'); + + expect(tool.idempotent).toBeUndefined(); + expect(shouldClaimBeforeExecute(tool)).toBe(true); + }); + + it('creates an interrupted error result for stale executions', () => { + const result = defaultInterruptedClientToolResult('call-1'); + + expect(result.ok).toBe(false); + expect(result.error).toContain('call-1'); + expect(result.error).toContain('interrupted'); + }); + + it('creates a guard failure error result', () => { + const result = clientToolGuardFailureResult('call-2', new Error('store unavailable')); + + expect(result.ok).toBe(false); + expect(result.error).toContain('call-2'); + expect(result.error).toContain('guard failed'); + expect(result.error).toContain('store unavailable'); + }); +}); diff --git a/libs/chat/src/lib/client-tools/client-tool-execution-guard.ts b/libs/chat/src/lib/client-tools/client-tool-execution-guard.ts new file mode 100644 index 000000000..94d8f42d3 --- /dev/null +++ b/libs/chat/src/lib/client-tools/client-tool-execution-guard.ts @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +import type { ClientToolResult } from './client-tools-capability'; +import type { AnyFunctionToolDef } from './tool-def'; + +/** Durable identity for one client-tool execution on one thread. */ +export interface ClientToolExecutionKey { + readonly threadId: string; + readonly toolCallId: string; +} + +/** Prior durable execution state returned by a client-tool execution store. */ +export type ClientToolExecutionRecord = + | { readonly status: 'executing' } + | { readonly status: 'done'; readonly result: ClientToolResult } + | { readonly status: 'failed'; readonly result?: ClientToolResult }; + +/** Structural store contract for guarded browser client-tool execution. */ +export interface ClientToolExecutionStore { + /** Atomically claim a tool-call execution. Returns prior state when present. */ + claim(key: ClientToolExecutionKey): Promise<'claimed' | ClientToolExecutionRecord>; + /** Record the final client-tool result for a claimed execution. */ + record(key: ClientToolExecutionKey, result: ClientToolResult): Promise; + /** Lookup prior execution states for pending tool calls on a thread. */ + lookup( + threadId: string, + toolCallIds: readonly string[], + ): Promise>; +} + +/** Opt-in guard configuration for claim-before-execute client tools. */ +export interface ClientToolExecutionGuard { + readonly threadId: string; + readonly store: ClientToolExecutionStore; +} + +/** Return whether this function tool should claim before browser execution. */ +export function shouldClaimBeforeExecute(def: AnyFunctionToolDef): boolean { + return def.idempotent !== true; +} + +/** Default fail-closed result for a stale in-progress client-tool execution. */ +export function defaultInterruptedClientToolResult(toolCallId: string): ClientToolResult { + return { + ok: false, + error: `client tool execution interrupted before completion: ${toolCallId}`, + }; +} + +/** Default fail-closed result when the execution guard itself cannot be reached. */ +export function clientToolGuardFailureResult(toolCallId: string, error: unknown): ClientToolResult { + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + error: `client tool execution guard failed for ${toolCallId}: ${message}`, + }; +} 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 cc73196c6..e9746bea6 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 @@ -6,6 +6,11 @@ import { z } from 'zod/v4'; import { action, view, tools } from './tools'; import { startClientToolExecutor } from './client-tool-executor'; import type { ClientToolsCapability, ClientToolResult } from './client-tools-capability'; +import type { + ClientToolExecutionGuard, + ClientToolExecutionRecord, + ClientToolExecutionStore, +} from './client-tool-execution-guard'; import type { Agent } from '../agent/agent'; import type { ToolCall } from '../agent/tool-call'; @@ -51,6 +56,24 @@ function makeFakeAgent(capability: ClientToolsCapability): Agent { }; } +function makeGuardStore( + claimResult: 'claimed' | ClientToolExecutionRecord = 'claimed', +): ClientToolExecutionStore & { + claim: ReturnType[0]], Promise<'claimed' | ClientToolExecutionRecord>>>; + record: ReturnType, Promise>>; + lookup: ReturnType, Promise>>>; +} { + return { + claim: vi.fn(async () => claimResult), + record: vi.fn(async () => undefined), + lookup: vi.fn(async () => ({})), + }; +} + +function makeGuard(store: ClientToolExecutionStore): ClientToolExecutionGuard { + return { threadId: 'thread-1', store }; +} + // ── registry ────────────────────────────────────────────────────────────────── const weatherRegistry = tools({ @@ -247,6 +270,189 @@ describe('startClientToolExecutor()', () => { expect(seen[0].aborted).toBe(true); }); + it('claims before executing guarded function tools and records before resolving', async () => { + const order: string[] = []; + const handler = vi.fn(async () => { + order.push('handler'); + return 'charged'; + }); + const registry = tools({ + charge: action('Charge a card', z.object({}), handler), + }); + const store = makeGuardStore('claimed'); + store.claim.mockImplementation(async () => { + order.push('claim'); + return 'claimed'; + }); + store.record.mockImplementation(async () => { + order.push('record'); + }); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry, { executionGuard: makeGuard(store) }); + }); + + pending.set([{ id: 'charge-1', name: 'charge', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(8); + + expect(store.claim).toHaveBeenCalledWith({ threadId: 'thread-1', toolCallId: 'charge-1' }); + expect(store.record).toHaveBeenCalledWith( + { threadId: 'thread-1', toolCallId: 'charge-1' }, + { ok: true, value: 'charged' }, + ); + expect(resolve).toHaveBeenCalledWith('charge-1', { ok: true, value: 'charged' }); + expect(order).toEqual(['claim', 'handler', 'record']); + }); + + it('replays a prior done result without re-running the handler', async () => { + const handler = vi.fn(async () => 'new-value'); + const registry = tools({ + charge: action('Charge a card', z.object({}), handler), + }); + const prior: ClientToolResult = { ok: true, value: 'stored-value' }; + const store = makeGuardStore({ status: 'done', result: prior }); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry, { executionGuard: makeGuard(store) }); + }); + + pending.set([{ id: 'charge-2', name: 'charge', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(handler).not.toHaveBeenCalled(); + expect(store.record).not.toHaveBeenCalled(); + expect(resolve).toHaveBeenCalledWith('charge-2', prior); + }); + + it('fails closed for stale executing records', async () => { + const handler = vi.fn(async () => 'new-value'); + const registry = tools({ + charge: action('Charge a card', z.object({}), handler), + }); + const store = makeGuardStore({ status: 'executing' }); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry, { executionGuard: makeGuard(store) }); + }); + + pending.set([{ id: 'charge-3', name: 'charge', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(handler).not.toHaveBeenCalled(); + expect(store.record).toHaveBeenCalledOnce(); + const result = store.record.mock.calls[0][1]; + expect(result.ok).toBe(false); + expect((result as { error: string }).error).toContain('charge-3'); + expect(resolve).toHaveBeenCalledWith('charge-3', result); + }); + + it('fails closed for failed records without a stored result', async () => { + const handler = vi.fn(async () => 'new-value'); + const registry = tools({ + charge: action('Charge a card', z.object({}), handler), + }); + const store = makeGuardStore({ status: 'failed' }); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry, { executionGuard: makeGuard(store) }); + }); + + pending.set([{ id: 'charge-4', name: 'charge', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(handler).not.toHaveBeenCalled(); + expect(store.record).toHaveBeenCalledOnce(); + expect(resolve.mock.calls[0][1].ok).toBe(false); + }); + + it('fails closed when the guard claim fails', async () => { + const handler = vi.fn(async () => 'new-value'); + const registry = tools({ + charge: action('Charge a card', z.object({}), handler), + }); + const store = makeGuardStore('claimed'); + store.claim.mockRejectedValue(new Error('store unavailable')); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry, { executionGuard: makeGuard(store) }); + }); + + pending.set([{ id: 'charge-5', name: 'charge', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(handler).not.toHaveBeenCalled(); + expect(store.record).not.toHaveBeenCalled(); + expect(resolve.mock.calls[0][1].ok).toBe(false); + expect((resolve.mock.calls[0][1] as { error: string }).error).toContain('guard failed'); + }); + + it('bypasses the guard for idempotent function tools', async () => { + const handler = vi.fn(async () => 'cached'); + const registry = tools({ + read: action('Read cached data', z.object({}), handler, { idempotent: true }), + }); + const store = makeGuardStore('claimed'); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry, { executionGuard: makeGuard(store) }); + }); + + pending.set([{ id: 'read-1', name: 'read', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(store.claim).not.toHaveBeenCalled(); + expect(handler).toHaveBeenCalledOnce(); + expect(resolve).toHaveBeenCalledWith('read-1', { ok: true, value: 'cached' }); + }); + + it('does not execute or resolve when stopped before a delayed claim resolves', async () => { + let resolveClaim!: (value: 'claimed') => void; + const claim = new Promise<'claimed'>((resolve) => { + resolveClaim = resolve; + }); + const handler = vi.fn(async () => 'late'); + const registry = tools({ + charge: action('Charge a card', z.object({}), handler), + }); + const store = makeGuardStore('claimed'); + store.claim.mockReturnValue(claim); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + + TestBed.runInInjectionContext(() => { + startClientToolExecutor(agent, registry, { executionGuard: makeGuard(store) }); + }); + + pending.set([{ id: 'charge-6', name: 'charge', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + await agent.stop(); + resolveClaim('claimed'); + await drainMicrotasks(); + + expect(handler).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + }); + 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 763637406..29f0b8e05 100644 --- a/libs/chat/src/lib/client-tools/client-tool-executor.ts +++ b/libs/chat/src/lib/client-tools/client-tool-executor.ts @@ -1,8 +1,22 @@ // SPDX-License-Identifier: MIT import { DestroyRef, effect, inject } from '@angular/core'; import type { Agent } from '../agent'; -import type { ClientToolRegistry } from './tool-def'; +import type { ClientToolRegistry, AnyFunctionToolDef } from './tool-def'; +import type { ClientToolsCapability, ClientToolResult } from './client-tools-capability'; import { executeFunctionTool } from './execute'; +import { + clientToolGuardFailureResult, + defaultInterruptedClientToolResult, + shouldClaimBeforeExecute, + type ClientToolExecutionGuard, + type ClientToolExecutionKey, + type ClientToolExecutionRecord, +} from './client-tool-execution-guard'; + +/** Options for wiring automatic browser function-tool execution. */ +export interface ClientToolExecutorOptions { + readonly executionGuard?: ClientToolExecutionGuard; +} /** * Watches the agent's pending client tool calls and auto-runs FUNCTION tools, @@ -10,7 +24,11 @@ import { executeFunctionTool } from './execute'; * rendering layer, not here. No-op if the agent lacks the clientTools * capability. MUST be called in an injection context (sets up an effect). */ -export function startClientToolExecutor(agent: Agent, registry: ClientToolRegistry): void { +export function startClientToolExecutor( + agent: Agent, + registry: ClientToolRegistry, + options: ClientToolExecutorOptions = {}, +): void { const cap = agent.clientTools; if (!cap) return; const destroyRef = inject(DestroyRef); @@ -40,13 +58,77 @@ export function startClientToolExecutor(agent: Agent, registry: ClientToolRegist if (inFlight.has(tc.id)) continue; 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); - } + void runFunctionTool({ + cap, + def, + rawArgs: tc.args, + toolCallId: tc.id, + controller, + executionGuard: options.executionGuard, }).finally(() => { inFlight.delete(tc.id); }); } }); } + +async function runFunctionTool(input: { + readonly cap: ClientToolsCapability; + readonly def: AnyFunctionToolDef; + readonly rawArgs: unknown; + readonly toolCallId: string; + readonly controller: AbortController; + readonly executionGuard?: ClientToolExecutionGuard; +}): Promise { + const { cap, def, rawArgs, toolCallId, controller, executionGuard } = input; + const signal = controller.signal; + if (!executionGuard || !shouldClaimBeforeExecute(def)) { + const result = await executeFunctionTool(def, rawArgs, { signal }); + if (!signal.aborted) cap.resolve(toolCallId, result); + return; + } + + const key = { threadId: executionGuard.threadId, toolCallId }; + let claim: 'claimed' | ClientToolExecutionRecord; + try { + claim = await executionGuard.store.claim(key); + } catch (err) { + if (!signal.aborted) cap.resolve(toolCallId, clientToolGuardFailureResult(toolCallId, err)); + return; + } + if (signal.aborted) return; + + if (claim === 'claimed') { + const result = await executeFunctionTool(def, rawArgs, { signal }); + if (signal.aborted) return; + await recordOrResolveGuardFailure(executionGuard, key, result, cap, toolCallId, signal); + return; + } + + if (claim.status === 'done') { + cap.resolve(toolCallId, claim.result); + return; + } + + const result = claim.status === 'failed' && claim.result + ? claim.result + : defaultInterruptedClientToolResult(toolCallId); + await recordOrResolveGuardFailure(executionGuard, key, result, cap, toolCallId, signal); +} + +async function recordOrResolveGuardFailure( + executionGuard: ClientToolExecutionGuard, + key: ClientToolExecutionKey, + result: ClientToolResult, + cap: ClientToolsCapability, + toolCallId: string, + signal: AbortSignal, +): Promise { + try { + await executionGuard.store.record(key, result); + } catch (err) { + if (!signal.aborted) cap.resolve(toolCallId, clientToolGuardFailureResult(toolCallId, err)); + return; + } + if (!signal.aborted) cap.resolve(toolCallId, result); +} diff --git a/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts b/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts index daf6100ea..75f324237 100644 --- a/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts +++ b/libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts @@ -6,6 +6,11 @@ import { z } from 'zod/v4'; import { action, view, ask, tools } from './tools'; import { toClientToolSpecs, createClientToolsCoordinator } from './client-tools-coordinator'; import type { ClientToolsCapability, ClientToolResult } from './client-tools-capability'; +import type { + ClientToolExecutionGuard, + ClientToolExecutionRecord, + ClientToolExecutionStore, +} from './client-tool-execution-guard'; import type { Agent } from '../agent/agent'; import type { ToolCall } from '../agent/tool-call'; @@ -53,6 +58,22 @@ function makeFakeAgent(capability: ClientToolsCapability | undefined): Agent { }; } +function makeGuardStore(): ClientToolExecutionStore & { + claim: ReturnType[0]], Promise<'claimed' | ClientToolExecutionRecord>>>; + record: ReturnType, Promise>>; + lookup: ReturnType, Promise>>>; +} { + return { + claim: vi.fn(async () => 'claimed'), + record: vi.fn(async () => undefined), + lookup: vi.fn(async () => ({})), + }; +} + +function makeGuard(store: ClientToolExecutionStore): ClientToolExecutionGuard { + return { threadId: 'thread-1', store }; +} + // ── registry ────────────────────────────────────────────────────────────────── const testRegistry = tools({ @@ -196,6 +217,30 @@ describe('createClientToolsCoordinator()', () => { expect(resolve).toHaveBeenCalledWith('f1', { ok: true, value: { temp: 72, city: 'SF' } }); }); + it('passes an execution guard to function-tool execution', async () => { + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + const store = makeGuardStore(); + const coordinator = createClientToolsCoordinator(testRegistry, { + executionGuard: makeGuard(store), + }); + + TestBed.runInInjectionContext(() => { + coordinator.connect(agent); + }); + + pending.set([{ id: 'f2', name: 'get_weather', args: { city: 'SF' }, status: 'running' }]); + TestBed.flushEffects(); + await drainMicrotasks(8); + + expect(store.claim).toHaveBeenCalledWith({ threadId: 'thread-1', toolCallId: 'f2' }); + expect(store.record).toHaveBeenCalledWith( + { threadId: 'thread-1', toolCallId: 'f2' }, + { ok: true, value: { temp: 72, city: 'SF' } }, + ); + expect(resolve).toHaveBeenCalledWith('f2', { ok: true, value: { temp: 72, city: 'SF' } }); + }); + it('handleRenderEvent() resolves pending ask tool call by elementKey (tool name)', () => { const { pending, resolve, capability } = makeFakeCapability(); const agent = makeFakeAgent(capability); diff --git a/libs/chat/src/lib/client-tools/client-tools-coordinator.ts b/libs/chat/src/lib/client-tools/client-tools-coordinator.ts index e92da4a24..86c66b8de 100644 --- a/libs/chat/src/lib/client-tools/client-tools-coordinator.ts +++ b/libs/chat/src/lib/client-tools/client-tools-coordinator.ts @@ -4,6 +4,7 @@ import { views, type ViewRegistry } from '@threadplane/render'; import type { RenderEvent, RenderViewEntry } from '@threadplane/render'; import type { Agent, ToolCall } from '../agent'; import type { ClientToolRegistry, ClientToolDef } from './tool-def'; +import type { ClientToolExecutionGuard } from './client-tool-execution-guard'; import type { ClientToolSpec } from './to-json-schema'; import { deriveJsonSchema } from './to-json-schema'; import { startClientToolExecutor } from './client-tool-executor'; @@ -19,6 +20,11 @@ export interface ClientToolsCoordinator { handleRenderEvent(agent: Agent, event: RenderEvent): void; } +/** Options for creating a client-tools coordinator. */ +export interface ClientToolsCoordinatorOptions { + readonly executionGuard?: ClientToolExecutionGuard; +} + /** Build the catalog spec list shipped to the model. */ export function toClientToolSpecs(registry: ClientToolRegistry): ClientToolSpec[] { return Object.entries(registry).map(([name, def]) => ({ @@ -42,7 +48,10 @@ function viewComponents(registry: ClientToolRegistry): Record(); @@ -52,7 +61,7 @@ export function createClientToolsCoordinator(registry: ClientToolRegistry): Clie const cap = agent.clientTools; if (!cap) return; cap.setCatalog(toClientToolSpecs(registry)); - startClientToolExecutor(agent, registry); // function tools + startClientToolExecutor(agent, registry, { executionGuard: options.executionGuard }); // function tools // Auto-ack `view` tools: they render but produce no user value. effect(() => { for (const tc of cap.pending()) { diff --git a/libs/chat/src/lib/client-tools/index.ts b/libs/chat/src/lib/client-tools/index.ts index 3a8479e78..64770d88e 100644 --- a/libs/chat/src/lib/client-tools/index.ts +++ b/libs/chat/src/lib/client-tools/index.ts @@ -1,12 +1,24 @@ // SPDX-License-Identifier: MIT export { action, view, ask, tools } from './tools'; export { deriveJsonSchema } from './to-json-schema'; -export type { ClientToolDef, FunctionToolDef, FunctionToolHandlerContext, AnyFunctionToolDef, ViewToolDef, AskToolDef, ClientToolRegistry } from './tool-def'; +export type { ClientToolDef, ClientToolExecutionOptions, 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'; export { selectPendingClientToolCalls } from './select-pending-client-tool-calls'; export type { SelectPendingClientToolCallsInput } from './select-pending-client-tool-calls'; export { startClientToolExecutor } from './client-tool-executor'; +export type { ClientToolExecutorOptions } from './client-tool-executor'; export { createClientToolsCoordinator, toClientToolSpecs } from './client-tools-coordinator'; -export type { ClientToolsCoordinator } from './client-tools-coordinator'; +export type { ClientToolsCoordinator, ClientToolsCoordinatorOptions } from './client-tools-coordinator'; +export { + clientToolGuardFailureResult, + defaultInterruptedClientToolResult, + shouldClaimBeforeExecute, +} from './client-tool-execution-guard'; +export type { + ClientToolExecutionGuard, + ClientToolExecutionKey, + ClientToolExecutionRecord, + ClientToolExecutionStore, +} from './client-tool-execution-guard'; diff --git a/libs/chat/src/lib/client-tools/tool-def.ts b/libs/chat/src/lib/client-tools/tool-def.ts index f7d290005..8fb89aaa3 100644 --- a/libs/chat/src/lib/client-tools/tool-def.ts +++ b/libs/chat/src/lib/client-tools/tool-def.ts @@ -10,12 +10,19 @@ export interface FunctionToolHandlerContext { readonly signal: AbortSignal; } +/** Execution policy options for browser-executed function tools. */ +export interface ClientToolExecutionOptions { + /** True when the handler may safely re-run and should skip durable pre-claims. */ + readonly idempotent?: boolean; +} + /** 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 idempotent?: boolean; readonly handler: ( args: StandardSchemaInferOutput, context: FunctionToolHandlerContext, @@ -31,6 +38,7 @@ export interface AnyFunctionToolDef { readonly kind: 'function'; readonly description: string; readonly schema: StandardSchemaV1; + readonly idempotent?: boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bivariance escape hatch; see note above readonly handler: (args: any, context: FunctionToolHandlerContext) => unknown | Promise; } diff --git a/libs/chat/src/lib/client-tools/tools.ts b/libs/chat/src/lib/client-tools/tools.ts index 823004bb4..2c218a49b 100644 --- a/libs/chat/src/lib/client-tools/tools.ts +++ b/libs/chat/src/lib/client-tools/tools.ts @@ -4,6 +4,7 @@ import type { StandardSchemaV1, StandardSchemaInferOutput } from '@threadplane/r import type { AskToolDef, ClientToolDef, + ClientToolExecutionOptions, FunctionToolDef, FunctionToolHandlerContext, ViewToolDef, @@ -21,6 +22,8 @@ export type { ViewProps } from './component-inputs'; * @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}. + * @param options Execution policy options, including `idempotent: true` to skip + * durable pre-execution claims when a guard is configured. * @returns A {@link FunctionToolDef} for inclusion in {@link tools}. * @example * ```ts @@ -35,8 +38,9 @@ export function action( args: StandardSchemaInferOutput, context: FunctionToolHandlerContext, ) => R | Promise, + options: ClientToolExecutionOptions = {}, ): FunctionToolDef { - return { kind: 'function', description, schema, handler }; + return { kind: 'function', description, schema, handler, ...options }; } /** diff --git a/libs/chat/src/lib/compositions/chat/chat.component.client-tools.spec.ts b/libs/chat/src/lib/compositions/chat/chat.component.client-tools.spec.ts index 014d527bd..405ab18ab 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.client-tools.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.client-tools.spec.ts @@ -7,6 +7,11 @@ import { ChatComponent } from './chat.component'; import { mockAgent, type MockAgent } from '../../testing/mock-agent'; import { tools, action, view, ask } from '../../client-tools/tools'; import type { ClientToolsCapability, ClientToolResult } from '../../client-tools/client-tools-capability'; +import type { + ClientToolExecutionGuard, + ClientToolExecutionRecord, + ClientToolExecutionStore, +} from '../../client-tools/client-tool-execution-guard'; import type { ClientToolSpec } from '../../client-tools/to-json-schema'; import type { ToolCall } from '../../agent/tool-call'; @@ -48,6 +53,22 @@ function makeFakeCapability(): FakeCap { return { pending, resolve, setCatalog, capability }; } +function makeGuardStore(): ClientToolExecutionStore & { + claim: ReturnType[0]], Promise<'claimed' | ClientToolExecutionRecord>>>; + record: ReturnType, Promise>>; + lookup: ReturnType, Promise>>>; +} { + return { + claim: vi.fn(async () => 'claimed'), + record: vi.fn(async () => undefined), + lookup: vi.fn(async () => ({})), + }; +} + +function makeGuard(store: ClientToolExecutionStore): ClientToolExecutionGuard { + return { threadId: 'thread-1', store }; +} + /** A mockAgent augmented with a clientTools capability. */ function agentWithClientTools(cap: ClientToolsCapability): MockAgent { const agent = mockAgent({ messages: [] }); @@ -167,6 +188,40 @@ describe('ChatComponent — client-tools wiring', () => { void comp; }); + it('wires clientToolExecutionGuard into function-tool execution', async () => { + const cap = makeFakeCapability(); + const store = makeGuardStore(); + let comp!: ChatComponent; + runInInjectionContext(injector, () => { + comp = new ChatComponent(); + setSignalInput(comp.clientTools, clientToolRegistry); + setSignalInput(comp.clientToolExecutionGuard, makeGuard(store)); + setSignalInput(comp.agent, agentWithClientTools(cap.capability)); + TestBed.flushEffects(); + }); + await drainMicrotasks(); + + cap.pending.set([ + { id: 'guarded-action', name: 'get_weather', args: { city: 'SF' }, status: 'running' }, + ]); + TestBed.flushEffects(); + await drainMicrotasks(8); + + expect(store.claim).toHaveBeenCalledWith({ + threadId: 'thread-1', + toolCallId: 'guarded-action', + }); + expect(store.record).toHaveBeenCalledWith( + { threadId: 'thread-1', toolCallId: 'guarded-action' }, + { ok: true, value: { temp: 70, city: 'SF' } }, + ); + expect(cap.resolve).toHaveBeenCalledWith('guarded-action', { + ok: true, + value: { temp: 70, city: 'SF' }, + }); + void comp; + }); + it('routes a RenderResultEvent through onClientToolEvent to resolve a pending ask', async () => { const cap = makeFakeCapability(); let comp!: ChatComponent; diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 99265d293..b5813f910 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -13,6 +13,7 @@ import type { A2uiActionMessage } from '@threadplane/a2ui'; import type { StateStore } from '@json-render/core'; import { toRenderRegistry, signalStateStore, withViews } from '@threadplane/render'; import type { ClientToolRegistry } from '../../client-tools/tool-def'; +import type { ClientToolExecutionGuard } from '../../client-tools/client-tool-execution-guard'; import { createClientToolsCoordinator } from '../../client-tools/client-tools-coordinator'; import { ChatWindowComponent } from '../../primitives/chat-window/chat-window.component'; import { ChatMessageListComponent } from '../../primitives/chat-message-list/chat-message-list.component'; @@ -370,6 +371,7 @@ export class ChatComponent { 'generate_json_render_spec', 'render_spec', ]); + readonly clientToolExecutionGuard = input(undefined); readonly showWelcome = computed(() => { if (this.welcomeDisabled()) return false; @@ -403,7 +405,9 @@ export class ChatComponent { */ private readonly coordinator = computed(() => { const reg = this.clientTools(); - return reg ? createClientToolsCoordinator(reg) : undefined; + return reg ? createClientToolsCoordinator(reg, { + executionGuard: this.clientToolExecutionGuard(), + }) : undefined; }); /** diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 2bf45fed9..a5903c4f2 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, FunctionToolHandlerContext, ViewToolDef, AskToolDef, ClientToolRegistry, StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput } from './lib/client-tools/tool-def'; +export type { ClientToolDef, ClientToolExecutionOptions, 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; @@ -240,6 +240,18 @@ export { selectPendingClientToolCalls } from './lib/client-tools/select-pending- export type { SelectPendingClientToolCallsInput } from './lib/client-tools/select-pending-client-tool-calls'; export { validateArgs, executeFunctionTool } from './lib/client-tools/execute'; export { startClientToolExecutor } from './lib/client-tools/client-tool-executor'; +export type { ClientToolExecutorOptions } from './lib/client-tools/client-tool-executor'; +export { + clientToolGuardFailureResult, + defaultInterruptedClientToolResult, + shouldClaimBeforeExecute, +} from './lib/client-tools/client-tool-execution-guard'; +export type { + ClientToolExecutionGuard, + ClientToolExecutionKey, + ClientToolExecutionRecord, + ClientToolExecutionStore, +} from './lib/client-tools/client-tool-execution-guard'; // createClientToolsCoordinator: internal — provideChat wires it; not public. export { toClientToolSpecs } from './lib/client-tools/client-tools-coordinator'; export type { ClientToolsCoordinator } from './lib/client-tools/client-tools-coordinator';