From 59c61fe71464c4d42dc90ef49b17e7214a63defb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 8 Jul 2026 15:37:01 -0700 Subject: [PATCH] feat: add client tool continuation policy --- .../content/docs/chat/api/api-docs.json | 134 ++++++ .../angular/e2e/client-tools.spec.ts | 13 + .../angular/e2e/fixtures/client-tools.json | 4 + .../angular/src/app/client-tools.component.ts | 32 ++ .../src/app/confirm-booking.component.ts | 13 +- .../angular/src/app/weather-card.component.ts | 11 +- .../python/prompts/client-tools.md | 5 + .../angular/e2e/client-tools.spec.ts | 13 + .../angular/e2e/fixtures/client-tools.json | 4 + .../angular/src/app/client-tools.component.ts | 32 ++ .../src/app/confirm-booking.component.ts | 13 +- .../angular/src/app/weather-card.component.ts | 11 +- .../python/prompts/client-tools.md | 5 + ...lient-tools-m5-continuation-policy-plan.md | 423 ++++++++++++++++++ .../lib/client-tools/client-tool-executor.ts | 2 + .../client-tools-coordinator.spec.ts | 154 +++++++ .../client-tools/client-tools-coordinator.ts | 74 ++- libs/chat/src/lib/client-tools/index.ts | 5 + libs/chat/src/lib/client-tools/tool-def.ts | 35 ++ .../lib/client-tools/view-ask.type-spec.ts | 12 +- .../chat/chat.component.client-tools.spec.ts | 40 ++ .../lib/compositions/chat/chat.component.ts | 14 + .../chat-tool-views.component.spec.ts | 39 +- .../chat-tool-views.component.ts | 16 +- libs/chat/src/public-api.ts | 20 +- 25 files changed, 1088 insertions(+), 36 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-08-client-tools-m5-continuation-policy-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 71667e296..f078e159f 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1821,6 +1821,18 @@ "description": "", "optional": false }, + { + "name": "clientToolContinuationLimit", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "clientToolContinuationPolicy", + "type": "InputSignal", + "description": "", + "optional": false + }, { "name": "clientToolExecutionGuard", "type": "InputSignal", @@ -6712,6 +6724,38 @@ ], "examples": [] }, + { + "name": "ClientToolContinuationLimitEvent", + "kind": "interface", + "description": "Diagnostic emitted when client-tool continuation would exceed the configured max turn count.", + "properties": [ + { + "name": "attemptedTurn", + "type": "number", + "description": "", + "optional": false + }, + { + "name": "maxTurns", + "type": "number", + "description": "", + "optional": false + }, + { + "name": "toolCallIds", + "type": "readonly string[]", + "description": "", + "optional": false + }, + { + "name": "toolNames", + "type": "readonly string[]", + "description": "", + "optional": false + } + ], + "examples": [] + }, { "name": "ClientToolContinuationOptions", "kind": "interface", @@ -6726,6 +6770,26 @@ ], "examples": [] }, + { + "name": "ClientToolContinuationPolicy", + "kind": "interface", + "description": "Policy for automatic client-tool continuation.", + "properties": [ + { + "name": "maxTurns", + "type": "number", + "description": "Maximum consecutive client-tool continuation groups per user turn. Default: 10. Use 0 for unlimited.", + "optional": true + }, + { + "name": "onLimit", + "type": "(event: ClientToolContinuationLimitEvent) => void", + "description": "Called when the max-turn guard stops a continuation group.", + "optional": true + } + ], + "examples": [] + }, { "name": "ClientToolExecutionGuard", "kind": "interface", @@ -6862,6 +6926,62 @@ "type": "(toolCall: ToolCall, result: ClientToolResult) => void", "description": "", "optional": true + }, + { + "name": "shouldExecuteToolCall", + "type": "(toolCall: ToolCall) => boolean", + "description": "", + "optional": true + } + ], + "examples": [] + }, + { + "name": "ClientToolLifecycle", + "kind": "interface", + "description": "", + "properties": [ + { + "name": "error", + "type": "unknown", + "description": "", + "optional": true + }, + { + "name": "hasResult", + "type": "boolean", + "description": "", + "optional": false + }, + { + "name": "id", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "name", + "type": "string", + "description": "", + "optional": false + }, + { + "name": "phase", + "type": "ClientToolLifecyclePhase", + "description": "", + "optional": false + }, + { + "name": "result", + "type": "unknown", + "description": "", + "optional": true + }, + { + "name": "status", + "type": "ToolCallStatus", + "description": "", + "optional": false } ], "examples": [] @@ -8201,6 +8321,13 @@ "signature": "object | object | object", "examples": [] }, + { + "name": "ClientToolLifecyclePhase", + "kind": "type", + "description": "", + "signature": "\"running\" | \"complete\" | \"error\"", + "examples": [] + }, { "name": "ClientToolRegistry", "kind": "type", @@ -8215,6 +8342,13 @@ "signature": "object | object", "examples": [] }, + { + "name": "ClientToolViewProps", + "kind": "type", + "description": "", + "signature": "StandardSchemaInferOutput & { clientTool?: ClientToolLifecycle; status?: ToolCallStatus }", + "examples": [] + }, { "name": "ContentBlock", "kind": "type", diff --git a/cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts b/cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts index 70d11cf27..ddd91006f 100644 --- a/cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts +++ b/cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts @@ -18,6 +18,19 @@ test('client-tools: view tool renders the model-filled component', async ({ page await expect(page.getByText("Here's the weather card for Tokyo")).toBeVisible({ timeout: 30000 }); }); +test('client-tools: terminal view tool renders without a follow-up assistant run', async ({ page }) => { + await page.goto('/'); + const input = page.getByRole('textbox', { name: /message|prompt/i }); + await input.fill('Show me a quiet weather snapshot for Oslo'); + await page.getByRole('button', { name: /send message/i }).click(); + + const card = page.locator('app-weather-card'); + await expect(card).toBeVisible({ timeout: 30000 }); + await expect(card).toContainText('Oslo'); + await expect(page.getByText(/quiet weather snapshot/i)).toHaveCount(1); + await expect(page.getByText(/Here's the weather card for Oslo/i)).toHaveCount(0); +}); + // ask tool (HITL): confirm_booking renders, the user confirms, the emitted value resumes the run. // This test does NOT use submitAndWaitForResponse because that helper awaits a finalized assistant // message — but the first run ends with only a tool call, so the helper would block until the user diff --git a/cockpit/ag-ui/client-tools/angular/e2e/fixtures/client-tools.json b/cockpit/ag-ui/client-tools/angular/e2e/fixtures/client-tools.json index 336353848..17c8a28e7 100644 --- a/cockpit/ag-ui/client-tools/angular/e2e/fixtures/client-tools.json +++ b/cockpit/ag-ui/client-tools/angular/e2e/fixtures/client-tools.json @@ -16,6 +16,10 @@ "match": { "userMessage": "Show me a weather card for Tokyo" }, "response": { "toolCalls": [ { "name": "weather_card", "arguments": { "location": "Tokyo", "temperatureF": 72, "conditions": "Clear", "humidity": 50, "windMph": 6 } } ] } }, + { + "match": { "userMessage": "Show me a quiet weather snapshot for Oslo" }, + "response": { "toolCalls": [ { "name": "weather_snapshot", "arguments": { "location": "Oslo", "temperatureF": 41, "conditions": "Cloudy", "humidity": 64, "windMph": 9 } } ] } + }, { "match": { "userMessage": "Book a table for two at 7pm", "hasToolResult": true }, "response": { "content": "Your booking is confirmed — see you at 7pm." } diff --git a/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts index 58724d47a..42e050e93 100644 --- a/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts +++ b/cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts @@ -27,11 +27,25 @@ const clientTools = tools({ z.object({ location: z.string() }), async ({ location }) => ({ location, temperatureF: 68, conditions: 'Sunny', humidity: 55, windMph: 8 }), ), + slow_status_check: action( + 'Run a cancellable local status check. Use when the user asks to test stopping a slow browser tool.', + z.object({ label: z.string().default('status check') }), + async ({ label }, { signal }) => { + await waitForAbortableDelay(3000, signal); + return { label, status: 'complete' }; + }, + ), weather_card: view( 'Display a weather card for a location with the given readings.', weatherCardSchema, WeatherCardComponent, ), + weather_snapshot: view( + 'Display a weather card as a terminal snapshot without asking the assistant to summarize afterwards.', + weatherCardSchema, + WeatherCardComponent, + { followUp: false }, + ), confirm_booking: ask( 'Ask the user to confirm a booking before finalizing it.', confirmBookingSchema, @@ -39,6 +53,24 @@ const clientTools = tools({ ), }); +function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + const timeout = window.setTimeout(resolve, ms); + signal.addEventListener( + 'abort', + () => { + window.clearTimeout(timeout); + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true }, + ); + }); +} + @Component({ selector: 'app-client-tools', standalone: true, diff --git a/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts index 573cbbe3c..fb408747f 100644 --- a/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts +++ b/cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, input } from '@angular/core'; -import type { ViewProps } from '@threadplane/chat'; +import type { ClientToolViewProps } from '@threadplane/chat'; import { injectRenderHost } from '@threadplane/render'; import { confirmBookingSchema } from './schemas'; @@ -16,12 +16,12 @@ import { confirmBookingSchema } from './schemas'; * `confirmed()` is defined we render a FROZEN line with no buttons; the live * interactive card only shows while `confirmed()` is still undefined. * - * Input types for schema-derived props are anchored to `ViewProps` — a schema change is a compile error here. */ /** Props this component receives from the `confirm_booking` schema. */ -type ConfirmBookingProps = ViewProps; +type ConfirmBookingProps = ClientToolViewProps; @Component({ selector: 'app-confirm-booking', @@ -29,7 +29,7 @@ type ConfirmBookingProps = ViewProps; changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (confirmed() === undefined) { -
+

{{ summary() }}

@@ -37,11 +37,11 @@ type ConfirmBookingProps = ViewProps;
} @else if (confirmed() === true) { -
+

Booking confirmed ✓

} @else { -
+

Booking cancelled

} @@ -61,6 +61,7 @@ export class ConfirmBookingComponent { readonly summary = input(); /** Spread back onto props after the ask resolves (undefined while interactive). */ readonly confirmed = input(undefined); + readonly clientTool = input(); private readonly host = injectRenderHost(); protected respond(confirmed: boolean): void { this.host.result({ confirmed }); diff --git a/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts b/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts index 60f047bfa..1f6721fa4 100644 --- a/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts +++ b/cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; -import type { ViewProps } from '@threadplane/chat'; +import type { ClientToolViewProps } from '@threadplane/chat'; import { weatherCardSchema } from './schemas'; /** @@ -10,12 +10,12 @@ import { weatherCardSchema } from './schemas'; * `status` of 'running' | 'complete'. Renders a loading affordance until the * result arrives. * - * Input types are derived from {@link weatherCardSchema} via `ViewProps` so + * Input types are derived from {@link weatherCardSchema} via `ClientToolViewProps` so * that a schema change is a compile error here under `strict: true`. */ /** Props this component receives from the `weather_card` schema. */ -type WeatherCardProps = ViewProps; +type WeatherCardProps = ClientToolViewProps; @Component({ selector: 'app-weather-card', @@ -59,7 +59,8 @@ export class WeatherCardComponent { readonly humidity = input(); readonly windMph = input(); /** Extra input not in the schema: injected by the framework for rendering state. */ - readonly status = input<'running' | 'complete'>(); + readonly status = input(); + readonly clientTool = input(); - readonly pending = computed(() => this.status() !== 'complete' || this.temperatureF() === undefined); + readonly pending = computed(() => this.clientTool()?.phase !== 'complete' || this.temperatureF() === undefined); } diff --git a/cockpit/ag-ui/client-tools/python/prompts/client-tools.md b/cockpit/ag-ui/client-tools/python/prompts/client-tools.md index 76971eab9..5f76e93e4 100644 --- a/cockpit/ag-ui/client-tools/python/prompts/client-tools.md +++ b/cockpit/ag-ui/client-tools/python/prompts/client-tools.md @@ -8,6 +8,11 @@ three client tools — call the right one and do not answer in prose first: - When the user asks to *show* or *display* a weather card, call `weather_card` with the location and plausible readings (temperatureF, conditions, humidity, windMph). After it renders, briefly confirm. +- When the user asks for a quiet or terminal weather snapshot, call + `weather_snapshot` with the location and plausible readings. Do not summarize + afterwards; the rendered card is the final response. +- When the user asks to test stopping a slow browser tool, call + `slow_status_check` with a short label. - When the user asks to book or reserve something, call `confirm_booking` with a one-line `summary` of what they're booking. After the user responds, confirm if they accepted or acknowledge if they cancelled. diff --git a/cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts b/cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts index 70d11cf27..ddd91006f 100644 --- a/cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts +++ b/cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts @@ -18,6 +18,19 @@ test('client-tools: view tool renders the model-filled component', async ({ page await expect(page.getByText("Here's the weather card for Tokyo")).toBeVisible({ timeout: 30000 }); }); +test('client-tools: terminal view tool renders without a follow-up assistant run', async ({ page }) => { + await page.goto('/'); + const input = page.getByRole('textbox', { name: /message|prompt/i }); + await input.fill('Show me a quiet weather snapshot for Oslo'); + await page.getByRole('button', { name: /send message/i }).click(); + + const card = page.locator('app-weather-card'); + await expect(card).toBeVisible({ timeout: 30000 }); + await expect(card).toContainText('Oslo'); + await expect(page.getByText(/quiet weather snapshot/i)).toHaveCount(1); + await expect(page.getByText(/Here's the weather card for Oslo/i)).toHaveCount(0); +}); + // ask tool (HITL): confirm_booking renders, the user confirms, the emitted value resumes the run. // This test does NOT use submitAndWaitForResponse because that helper awaits a finalized assistant // message — but the first run ends with only a tool call, so the helper would block until the user diff --git a/cockpit/langgraph/client-tools/angular/e2e/fixtures/client-tools.json b/cockpit/langgraph/client-tools/angular/e2e/fixtures/client-tools.json index 336353848..17c8a28e7 100644 --- a/cockpit/langgraph/client-tools/angular/e2e/fixtures/client-tools.json +++ b/cockpit/langgraph/client-tools/angular/e2e/fixtures/client-tools.json @@ -16,6 +16,10 @@ "match": { "userMessage": "Show me a weather card for Tokyo" }, "response": { "toolCalls": [ { "name": "weather_card", "arguments": { "location": "Tokyo", "temperatureF": 72, "conditions": "Clear", "humidity": 50, "windMph": 6 } } ] } }, + { + "match": { "userMessage": "Show me a quiet weather snapshot for Oslo" }, + "response": { "toolCalls": [ { "name": "weather_snapshot", "arguments": { "location": "Oslo", "temperatureF": 41, "conditions": "Cloudy", "humidity": 64, "windMph": 9 } } ] } + }, { "match": { "userMessage": "Book a table for two at 7pm", "hasToolResult": true }, "response": { "content": "Your booking is confirmed — see you at 7pm." } diff --git a/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts b/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts index 112d96e61..63fd5f31f 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts @@ -29,11 +29,25 @@ const clientTools = tools({ z.object({ location: z.string() }), async ({ location }) => ({ location, temperatureF: 68, conditions: 'Sunny', humidity: 55, windMph: 8 }), ), + slow_status_check: action( + 'Run a cancellable local status check. Use when the user asks to test stopping a slow browser tool.', + z.object({ label: z.string().default('status check') }), + async ({ label }, { signal }) => { + await waitForAbortableDelay(3000, signal); + return { label, status: 'complete' }; + }, + ), weather_card: view( 'Display a weather card for a location with the given readings.', weatherCardSchema, WeatherCardComponent, ), + weather_snapshot: view( + 'Display a weather card as a terminal snapshot without asking the assistant to summarize afterwards.', + weatherCardSchema, + WeatherCardComponent, + { followUp: false }, + ), confirm_booking: ask( 'Ask the user to confirm a booking before finalizing it.', confirmBookingSchema, @@ -41,6 +55,24 @@ const clientTools = tools({ ), }); +function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + const timeout = window.setTimeout(resolve, ms); + signal.addEventListener( + 'abort', + () => { + window.clearTimeout(timeout); + reject(new DOMException('Aborted', 'AbortError')); + }, + { once: true }, + ); + }); +} + @Component({ selector: 'app-client-tools', standalone: true, diff --git a/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts b/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts index 8067b3238..c8f75decd 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/confirm-booking.component.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, input } from '@angular/core'; -import type { ViewProps } from '@threadplane/chat'; +import type { ClientToolViewProps } from '@threadplane/chat'; import { injectRenderHost } from '@threadplane/render'; import { confirmBookingSchema } from './schemas'; @@ -16,12 +16,12 @@ import { confirmBookingSchema } from './schemas'; * `confirmed()` is defined we render a FROZEN line with no buttons; the live * interactive card only shows while `confirmed()` is still undefined. * - * Input types for schema-derived props are anchored to `ViewProps` — a schema change is a compile error here. */ /** Props this component receives from the `confirm_booking` schema. */ -type ConfirmBookingProps = ViewProps; +type ConfirmBookingProps = ClientToolViewProps; @Component({ selector: 'app-confirm-booking', @@ -29,7 +29,7 @@ type ConfirmBookingProps = ViewProps; changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (confirmed() === undefined) { -
+

{{ summary() }}

@@ -37,11 +37,11 @@ type ConfirmBookingProps = ViewProps;
} @else if (confirmed() === true) { -
+

Booking confirmed ✓

} @else { -
+

Booking cancelled

} @@ -60,6 +60,7 @@ export class ConfirmBookingComponent { readonly summary = input(); /** Spread back onto props after the ask resolves (undefined while interactive). */ readonly confirmed = input(undefined); + readonly clientTool = input(); private readonly host = injectRenderHost(); protected respond(confirmed: boolean): void { this.host.result({ confirmed }); diff --git a/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts b/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts index fee109be7..30f6352fe 100644 --- a/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts +++ b/cockpit/langgraph/client-tools/angular/src/app/weather-card.component.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; -import type { ViewProps } from '@threadplane/chat'; +import type { ClientToolViewProps } from '@threadplane/chat'; import { weatherCardSchema } from './schemas'; /** @@ -10,12 +10,12 @@ import { weatherCardSchema } from './schemas'; * `status` of 'running' | 'complete'. Renders a loading affordance until the * result arrives. * - * Input types are derived from {@link weatherCardSchema} via `ViewProps` so + * Input types are derived from {@link weatherCardSchema} via `ClientToolViewProps` so * that a schema change is a compile error here under `strict: true`. */ /** Props this component receives from the `weather_card` schema. */ -type WeatherCardProps = ViewProps; +type WeatherCardProps = ClientToolViewProps; @Component({ selector: 'app-weather-card', @@ -59,7 +59,8 @@ export class WeatherCardComponent { readonly humidity = input(); readonly windMph = input(); /** Extra input not in the schema: injected by the framework for rendering state. */ - readonly status = input<'running' | 'complete'>(); + readonly status = input(); + readonly clientTool = input(); - readonly pending = computed(() => this.status() !== 'complete' || this.temperatureF() === undefined); + readonly pending = computed(() => this.clientTool()?.phase !== 'complete' || this.temperatureF() === undefined); } diff --git a/cockpit/langgraph/client-tools/python/prompts/client-tools.md b/cockpit/langgraph/client-tools/python/prompts/client-tools.md index 00c242e55..4fa5e0ce3 100644 --- a/cockpit/langgraph/client-tools/python/prompts/client-tools.md +++ b/cockpit/langgraph/client-tools/python/prompts/client-tools.md @@ -8,6 +8,11 @@ three client tools — call the right one and do not answer in prose first: - When the user asks to *show* or *display* a weather card, call `weather_card` with the location and plausible readings (temperatureF, conditions, humidity, windMph). After it renders, briefly confirm. +- When the user asks for a quiet or terminal weather snapshot, call + `weather_snapshot` with the location and plausible readings. Do not summarize + afterwards; the rendered card is the final response. +- When the user asks to test stopping a slow browser tool, call + `slow_status_check` with a short label. - When the user asks to book or reserve something, call `confirm_booking` with a one-line `summary` of what they're booking. After the user responds, confirm if they accepted or acknowledge if they cancelled. diff --git a/docs/superpowers/plans/2026-07-08-client-tools-m5-continuation-policy-plan.md b/docs/superpowers/plans/2026-07-08-client-tools-m5-continuation-policy-plan.md new file mode 100644 index 000000000..316e07f81 --- /dev/null +++ b/docs/superpowers/plans/2026-07-08-client-tools-m5-continuation-policy-plan.md @@ -0,0 +1,423 @@ +# Client Tools M5 Continuation Policy 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 a configurable client-tool continuation guard, surface a typed lifecycle object to `view`/`ask` components, and update the cockpit client-tools demo to exercise aborted and terminal client tools. + +**Architecture:** Keep orchestration in `@threadplane/chat`: the coordinator owns continuation accounting, max-turn diagnostics, and group settlement while adapters remain thin `pending`/`settle`/`resolve` transports. View/ask lifecycle is surfaced as additive props on the synthetic render spec so existing components that only read schema fields and `status` keep working. Cockpit changes stay in the existing client-tools demos and use current public APIs (`AbortSignal`, `followUp:false`, durable guard wiring where already available). + +**Tech Stack:** Angular signals/effects, Vitest, Playwright cockpit examples, Nx, generated API docs. No new dependencies. + +--- + +## Source Of Truth + +- Spec: `docs/superpowers/specs/2026-07-07-client-tool-continuation-architecture-design.md` §5e and milestone M5. +- Prior lifecycle design: `docs/superpowers/specs/2026-06-17-client-tools-view-ask-streaming-lifecycle-design.md`. +- Preserved defaults: current automatic function/view/ask settlement continues to work; the new guard only stops runaway continuation rounds. +- Resolved decisions not reopened: `settle()`, batch-per-group, fail-closed crash policy, max-turns default 10, backend-authoritative dedup. + +## File Structure + +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` + - Add exported continuation policy and lifecycle types. +- Modify: `libs/chat/src/lib/client-tools/client-tools-coordinator.ts` + - Accept continuation policy options. + - Count consecutive client-tool continuation groups and enforce `maxTurns` default 10. + - Emit/log a typed diagnostic when the guard is hit. +- Modify: `libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.ts` + - Add an additive `clientTool` lifecycle prop while preserving existing `status`. +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` + - Add a `clientToolContinuationPolicy` input and `clientToolContinuationLimit` output. + - Pass policy into the coordinator. +- Modify: `libs/chat/src/lib/client-tools/index.ts` + - Export new public policy/lifecycle types. +- Modify: `libs/chat/src/public-api.ts` + - Export new public policy/lifecycle types. +- Modify: `apps/website/content/docs/chat/api/api-docs.json` + - Regenerate after public API changes. +- Modify: `cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts` + - Add terminal (`followUp:false`) and abort-aware tools to the existing cockpit client-tools demo. +- Modify: `cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts` + - Mirror the same demo coverage for the LangGraph cockpit client-tools demo. +- Test: `libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts` + - Guard/policy behavior and diagnostics. +- Test: `libs/chat/src/lib/compositions/chat/chat.component.client-tools.spec.ts` + - Chat input/output wiring for the policy and limit diagnostic. +- Test: `libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.spec.ts` + - Lifecycle prop shape passed to rendered view/ask components. +- Test: `libs/chat/src/lib/client-tools/tools.type-spec.ts` or `view-ask.type-spec.ts` + - Public lifecycle helper type remains usable with schema-derived props. +- Test: cockpit e2e specs for AG-UI and LangGraph client-tools if existing fixture scripts support these prompts. + +--- + +### Task 1: Continuation Policy Types And Guard + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` +- Modify: `libs/chat/src/lib/client-tools/client-tools-coordinator.ts` +- Test: `libs/chat/src/lib/client-tools/client-tools-coordinator.spec.ts` + +- [x] **Step 1: Write failing coordinator tests** + +Add tests proving: + +```ts +it('stops settling pending tools after the max continuation turn count is hit', async () => { + const warn = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const onLimit = vi.fn(); + const registry = tools({ + loop: action('loop', z.object({}), async () => 'again'), + }); + const { pending, resolve, capability } = makeFakeCapability(); + const coordinator = createClientToolsCoordinator(registry, { + continuationPolicy: { maxTurns: 2, onLimit }, + }); + + TestBed.runInInjectionContext(() => coordinator.connect(makeFakeAgent(capability))); + + pending.set([{ id: 'c1', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + pending.set([{ id: 'c2', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + pending.set([{ id: 'c3', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(resolve).toHaveBeenCalledTimes(2); + expect(onLimit).toHaveBeenCalledWith(expect.objectContaining({ + maxTurns: 2, + attemptedTurn: 3, + toolCallIds: ['c3'], + })); + expect(warn).toHaveBeenCalledOnce(); +}); +``` + +Also add tests that: +- omitted policy uses `maxTurns: 10`; +- `maxTurns: 0` disables the guard only when explicitly set; +- the turn count resets only when a new user turn appears in `agent.messages()`, not merely when `pending()` becomes empty between continuation runs. + +- [x] **Step 2: Run focused tests to verify RED** + +Run: + +```bash +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static --testFile=src/lib/client-tools/client-tools-coordinator.spec.ts +``` + +Expected: FAIL because coordinator options do not include a continuation policy and no max-turn guard exists. + +- [x] **Step 3: Implement minimal policy types and guard** + +Add types in `tool-def.ts`: + +```ts +export interface ClientToolContinuationLimitEvent { + readonly maxTurns: number; + readonly attemptedTurn: number; + readonly toolCallIds: readonly string[]; + readonly toolNames: readonly string[]; +} + +export interface ClientToolContinuationPolicy { + readonly maxTurns?: number; + readonly onLimit?: (event: ClientToolContinuationLimitEvent) => void; +} +``` + +In the coordinator: +- add `continuationPolicy?: ClientToolContinuationPolicy` to `ClientToolsCoordinatorOptions`; +- default `maxTurns` to 10; +- treat explicit `maxTurns: 0` as no limit; +- increment once per new pending group before any settlement; +- key the counter by the latest human-message identity/index visible on `agent.messages()` so a new user turn resets the consecutive continuation count; +- when the attempted turn is above the limit, call `console.error(...)`, call `onLimit(event)`, and skip settlement so no continuation run starts; +- keep group batching semantics unchanged below the limit. + +- [x] **Step 4: Run focused tests to verify GREEN** + +Run: + +```bash +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static --testFile=src/lib/client-tools/client-tools-coordinator.spec.ts +``` + +Expected: PASS. + +--- + +### Task 2: Chat Component Policy Wiring + +**Files:** +- Modify: `libs/chat/src/lib/compositions/chat/chat.component.ts` +- Test: `libs/chat/src/lib/compositions/chat/chat.component.client-tools.spec.ts` + +- [x] **Step 1: Write failing chat wiring tests** + +Add tests proving: + +```ts +it('passes clientToolContinuationPolicy into the coordinator and emits limit diagnostics', async () => { + const cap = makeFakeCapability(); + const limitEvents: unknown[] = []; + let comp!: ChatComponent; + runInInjectionContext(injector, () => { + comp = new ChatComponent(); + setSignalInput(comp.clientTools, clientToolRegistry); + setSignalInput(comp.clientToolContinuationPolicy, { maxTurns: 1 }); + comp.clientToolContinuationLimit.subscribe((event) => limitEvents.push(event)); + setSignalInput(comp.agent, agentWithClientTools(cap.capability)); + TestBed.flushEffects(); + }); + await drainMicrotasks(); + + cap.pending.set([{ id: 'first', name: 'get_weather', args: { city: 'SF' }, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + cap.pending.set([{ id: 'second', name: 'get_weather', args: { city: 'LA' }, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(limitEvents).toHaveLength(1); +}); +``` + +- [x] **Step 2: Run chat component spec to verify RED** + +Run: + +```bash +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static --testFile=src/lib/compositions/chat/chat.component.client-tools.spec.ts +``` + +Expected: FAIL because the input/output do not exist. + +- [x] **Step 3: Add input/output wiring** + +In `ChatComponent`: +- add `clientToolContinuationPolicy = input(undefined)`; +- add `clientToolContinuationLimit = output()`; +- pass `{ ...policy, onLimit: event => { policy?.onLimit?.(event); this.clientToolContinuationLimit.emit(event); } }` into `createClientToolsCoordinator`. + +- [x] **Step 4: Run chat component spec to verify GREEN** + +Run: + +```bash +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static --testFile=src/lib/compositions/chat/chat.component.client-tools.spec.ts +``` + +Expected: PASS. + +--- + +### Task 3: Typed View/Ask Lifecycle Props + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/tool-def.ts` +- Modify: `libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.ts` +- Test: `libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.spec.ts` +- Test: `libs/chat/src/lib/client-tools/view-ask.type-spec.ts` + +- [x] **Step 1: Write failing lifecycle prop tests** + +Add a test component with a typed lifecycle input: + +```ts +class TestWeatherCardComponent { + readonly clientTool = input(undefined); +} +``` + +Assert a running call receives: + +```ts +expect(component.clientTool()).toEqual({ + id: 'c1', + name: 'weather_card', + status: 'running', + phase: 'running', + hasResult: false, +}); +``` + +Assert an error call receives `phase: 'error'`, `hasResult: true`, and the error value. + +- [x] **Step 2: Write failing type assertion** + +In `view-ask.type-spec.ts`, add: + +```ts +type Inputs = ClientToolViewProps; +const _status: ToolCallStatus | undefined = ({} as Inputs).status; +const _clientTool: ClientToolLifecycle | undefined = ({} as Inputs).clientTool; +``` + +- [x] **Step 3: Run focused tests to verify RED** + +Run: + +```bash +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static --testFile=src/lib/primitives/chat-tool-views/chat-tool-views.component.spec.ts +NX_DAEMON=false npx nx run chat:type-tests --skip-nx-cache --outputStyle=static +``` + +Expected: FAIL because `clientTool` lifecycle props and public types do not exist. + +- [x] **Step 4: Implement lifecycle model** + +Add exported types: + +```ts +export type ClientToolLifecyclePhase = 'running' | 'complete' | 'error'; + +export interface ClientToolLifecycle { + readonly id: string; + readonly name: string; + readonly status: ToolCallStatus; + readonly phase: ClientToolLifecyclePhase; + readonly hasResult: boolean; + readonly result?: unknown; + readonly error?: unknown; +} + +export type ClientToolViewProps = + StandardSchemaInferOutput & { + readonly status?: ToolCallStatus; + readonly clientTool?: ClientToolLifecycle; + }; +``` + +In `ChatToolViewsComponent`, update `toToolViewSpec(tc)` to add `clientTool: toClientToolLifecycle(tc)` while preserving `status`. + +- [x] **Step 5: Run focused tests to verify GREEN** + +Run: + +```bash +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static --testFile=src/lib/primitives/chat-tool-views/chat-tool-views.component.spec.ts +NX_DAEMON=false npx nx run chat:type-tests --skip-nx-cache --outputStyle=static +``` + +Expected: PASS. + +--- + +### Task 4: Cockpit Client-Tools Demo Coverage + +**Files:** +- Modify: `cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts` +- Modify: `cockpit/langgraph/client-tools/angular/src/app/client-tools.component.ts` +- Test: existing cockpit client-tools e2e specs if fixture scripts cover deterministic prompts. + +- [x] **Step 1: Add failing cockpit assertions where feasible** + +If the current scripted cockpit fixtures can be extended deterministically, add tests that: +- prompt an abort-aware long client action, click stop, and assert the result is not continued; +- prompt a terminal view/action declared with `{ followUp: false }` and assert no follow-up summary is required to see the terminal UI. + +If fixtures cannot deterministically exercise these paths, document that in the PR body and rely on unit coverage plus local build. + +- [x] **Step 2: Run relevant cockpit test/build before implementation** + +Run the smallest available target for the edited cockpit projects, for example: + +```bash +NX_DAEMON=false npx nx test cockpit-ag-ui-client-tools-angular --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx test cockpit-langgraph-client-tools-angular --skip-nx-cache --outputStyle=static +``` + +Expected: either FAIL for the new assertions or report no test target; record the actual target names before proceeding. + +- [x] **Step 3: Update demo tools** + +In both client-tools demos: +- add an abort-aware function tool whose handler observes `context.signal` and exits without resolving when stopped; +- add a terminal tool (`followUp:false`) that renders or returns a visible terminal result; +- update component inputs to optionally read `clientTool` lifecycle where useful, without changing the demo layout into explanatory text. + +- [x] **Step 4: Run cockpit verification** + +Run the smallest relevant cockpit targets found in `project.json`, plus build if no unit target exists: + +```bash +NX_DAEMON=false npx nx build cockpit-ag-ui-client-tools-angular --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx build cockpit-langgraph-client-tools-angular --skip-nx-cache --outputStyle=static +``` + +Expected: PASS. + +--- + +### Task 5: Public Exports, API Docs, And Full Verification + +**Files:** +- Modify: `libs/chat/src/lib/client-tools/index.ts` +- Modify: `libs/chat/src/public-api.ts` +- Regenerate: `apps/website/content/docs/chat/api/api-docs.json` + +- [x] **Step 1: Export public API changes** + +Export: +- `ClientToolContinuationPolicy` +- `ClientToolContinuationLimitEvent` +- `ClientToolLifecycle` +- `ClientToolLifecyclePhase` +- `ClientToolViewProps` + +- [x] **Step 2: Regenerate API docs** + +Run: + +```bash +npm run generate-api-docs +``` + +Expected: succeeds and updates chat API docs. + +- [x] **Step 3: Run focused verification** + +Run: + +```bash +NX_DAEMON=false npx nx test chat --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx run chat:type-tests --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx lint chat --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx build chat --skip-nx-cache --outputStyle=static +``` + +Expected: PASS, with lint warnings allowed only if the command exits 0 and reports no errors. + +- [x] **Step 4: Run affected package/cockpit verification** + +Run: + +```bash +NX_DAEMON=false npx nx test ag-ui --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx test langgraph --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx build cockpit-ag-ui-client-tools-angular --skip-nx-cache --outputStyle=static +NX_DAEMON=false npx nx build cockpit-langgraph-client-tools-angular --skip-nx-cache --outputStyle=static +git diff --check +``` + +Expected: PASS. + +- [x] **Step 5: Review constraints before PR** + +Check: + +```bash +git diff --name-only origin/main +(git diff origin/main --name-only; git ls-files --others --exclude-standard) | rg -v '^docs/superpowers/' | xargs rg -n 'hashbrown|copilotkit|chatgpt|claude' || true +``` + +Expected: +- no out-of-scope M6 bridge work; +- no new dependencies; +- no forbidden external-framework references outside `docs/superpowers`; +- public API docs regenerated for new exports. 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 4c2ca554f..61d2bc07c 100644 --- a/libs/chat/src/lib/client-tools/client-tool-executor.ts +++ b/libs/chat/src/lib/client-tools/client-tool-executor.ts @@ -18,6 +18,7 @@ import { export interface ClientToolExecutorOptions { readonly executionGuard?: ClientToolExecutionGuard; readonly settleToolCall?: (toolCall: ToolCall, result: ClientToolResult) => void; + readonly shouldExecuteToolCall?: (toolCall: ToolCall) => boolean; } /** @@ -58,6 +59,7 @@ export function startClientToolExecutor( // calls that have a result or were resolved; `inFlight` prevents a // double-dispatch within a render cycle. if (inFlight.has(tc.id)) continue; + if (options.shouldExecuteToolCall && !options.shouldExecuteToolCall(tc)) continue; const controller = new AbortController(); inFlight.set(tc.id, controller); void runFunctionTool({ 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 ba21e6ae8..33901282e 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 @@ -12,6 +12,7 @@ import type { ClientToolExecutionStore, } from './client-tool-execution-guard'; import type { Agent } from '../agent/agent'; +import type { Message } from '../agent/message'; import type { ToolCall } from '../agent/tool-call'; // ── helpers ────────────────────────────────────────────────────────────────── @@ -367,6 +368,159 @@ describe('createClientToolsCoordinator()', () => { expect(resolve).toHaveBeenCalledWith('f2', { ok: true, value: { temp: 72, city: 'SF' } }); }); + it('stops settling pending tools after the configured max continuation turn count is hit', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const onLimit = vi.fn(); + const handler = vi.fn(async () => 'again'); + const registry = tools({ + loop: action('Loop', z.object({}), handler), + }); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + const coordinator = createClientToolsCoordinator(registry, { + continuationPolicy: { maxTurns: 2, onLimit }, + }); + + TestBed.runInInjectionContext(() => { + coordinator.connect(agent); + }); + + pending.set([{ id: 'c1', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + pending.set([{ id: 'c2', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + pending.set([{ id: 'c3', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(resolve).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledTimes(2); + expect(onLimit).toHaveBeenCalledOnce(); + expect(onLimit).toHaveBeenCalledWith({ + maxTurns: 2, + attemptedTurn: 3, + toolCallIds: ['c3'], + toolNames: ['loop'], + }); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); + }); + + it('uses a default max of 10 continuation turns when no policy is provided', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const registry = tools({ + loop: action('Loop', z.object({}), async () => 'again'), + }); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + const coordinator = createClientToolsCoordinator(registry); + + TestBed.runInInjectionContext(() => { + coordinator.connect(agent); + }); + + for (let i = 1; i <= 11; i++) { + pending.set([{ id: `c${i}`, name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + } + + expect(resolve).toHaveBeenCalledTimes(10); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); + }); + + it('treats maxTurns 0 as an explicit unlimited continuation policy', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const registry = tools({ + loop: action('Loop', z.object({}), async () => 'again'), + }); + const { pending, resolve, capability } = makeFakeCapability(); + const agent = makeFakeAgent(capability); + const coordinator = createClientToolsCoordinator(registry, { + continuationPolicy: { maxTurns: 0 }, + }); + + TestBed.runInInjectionContext(() => { + coordinator.connect(agent); + }); + + for (let i = 1; i <= 12; i++) { + pending.set([{ id: `c${i}`, name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + } + + expect(resolve).toHaveBeenCalledTimes(12); + expect(error).not.toHaveBeenCalled(); + error.mockRestore(); + }); + + it('resets the continuation turn count when a new user turn appears', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const registry = tools({ + loop: action('Loop', z.object({}), async () => 'again'), + }); + const { pending, resolve, capability } = makeFakeCapability(); + const messages = signal([{ id: 'u1', role: 'user', content: 'start' }]); + const agent: Agent = { ...makeFakeAgent(capability), messages }; + const coordinator = createClientToolsCoordinator(registry, { + continuationPolicy: { maxTurns: 1 }, + }); + + TestBed.runInInjectionContext(() => { + coordinator.connect(agent); + }); + + pending.set([{ id: 'c1', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + messages.set([{ id: 'u1', role: 'user', content: 'start' }, { id: 'u2', role: 'user', content: 'next' }]); + pending.set([{ id: 'c2', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(resolve).toHaveBeenCalledTimes(2); + expect(error).not.toHaveBeenCalled(); + error.mockRestore(); + }); + + it('does not reset the continuation turn count only because pending tools become empty', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const registry = tools({ + loop: action('Loop', z.object({}), async () => 'again'), + }); + const { pending, resolve, capability } = makeFakeCapability(); + const messages = signal([{ id: 'u1', role: 'user', content: 'start' }]); + const agent: Agent = { ...makeFakeAgent(capability), messages }; + const coordinator = createClientToolsCoordinator(registry, { + continuationPolicy: { maxTurns: 1 }, + }); + + TestBed.runInInjectionContext(() => { + coordinator.connect(agent); + }); + + pending.set([{ id: 'c1', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + pending.set([]); + TestBed.flushEffects(); + await drainMicrotasks(); + + pending.set([{ id: 'c2', name: 'loop', args: {}, status: 'complete' }]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); + }); + 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 17c24fd9f..9c86b2977 100644 --- a/libs/chat/src/lib/client-tools/client-tools-coordinator.ts +++ b/libs/chat/src/lib/client-tools/client-tools-coordinator.ts @@ -3,7 +3,12 @@ import { effect } from '@angular/core'; 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 { + ClientToolRegistry, + ClientToolDef, + ClientToolContinuationLimitEvent, + ClientToolContinuationPolicy, +} from './tool-def'; import type { ClientToolExecutionGuard } from './client-tool-execution-guard'; import type { ClientToolSpec } from './to-json-schema'; import { deriveJsonSchema } from './to-json-schema'; @@ -24,14 +29,18 @@ export interface ClientToolsCoordinator { /** Options for creating a client-tools coordinator. */ export interface ClientToolsCoordinatorOptions { readonly executionGuard?: ClientToolExecutionGuard; + readonly continuationPolicy?: ClientToolContinuationPolicy; } interface PendingToolGroup { readonly ids: ReadonlySet; readonly hasFollowUp: boolean; readonly settledIds: Set; + readonly allowed: boolean; } +const DEFAULT_MAX_CONTINUATION_TURNS = 10; + /** Build the catalog spec list shipped to the model. */ export function toClientToolSpecs(registry: ClientToolRegistry): ClientToolSpec[] { return Object.entries(registry).map(([name, def]) => ({ @@ -62,29 +71,75 @@ export function createClientToolsCoordinator( const viewRegistry = views(viewComponents(registry)); const ackedViews = new Set(); let currentGroup: PendingToolGroup | undefined; + let currentUserTurnKey = ''; + let continuationTurns = 0; function toolWantsFollowUp(tc: ToolCall): boolean { return registry[tc.name]?.followUp !== false; } - function createGroup(calls: readonly ToolCall[]): PendingToolGroup { + function latestUserTurnKey(agent: Agent): string { + const messages = agent.messages(); + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i] as { id?: string; role?: string }; + if (msg.role === 'user' || msg.role === 'human') return msg.id ?? `index:${i}`; + } + return 'no-user-turn'; + } + + function continuationLimit(calls: readonly ToolCall[]): ClientToolContinuationLimitEvent | undefined { + const maxTurns = options.continuationPolicy?.maxTurns ?? DEFAULT_MAX_CONTINUATION_TURNS; + if (maxTurns === 0) return undefined; + const attemptedTurn = continuationTurns + 1; + if (attemptedTurn <= maxTurns) { + continuationTurns = attemptedTurn; + return undefined; + } + return { + maxTurns, + attemptedTurn, + toolCallIds: calls.map((tc) => tc.id), + toolNames: calls.map((tc) => tc.name), + }; + } + + function emitContinuationLimit(event: ClientToolContinuationLimitEvent): void { + console.error( + `Client tool continuation stopped after ${event.maxTurns} turn(s); pending tool calls: ${event.toolCallIds.join(', ')}`, + ); + options.continuationPolicy?.onLimit?.(event); + } + + function createGroup(agent: Agent, calls: readonly ToolCall[]): PendingToolGroup { + const userTurnKey = latestUserTurnKey(agent); + if (userTurnKey !== currentUserTurnKey) { + currentUserTurnKey = userTurnKey; + continuationTurns = 0; + } + const limit = continuationLimit(calls); + if (limit) emitContinuationLimit(limit); return { ids: new Set(calls.map((tc) => tc.id)), hasFollowUp: calls.some(toolWantsFollowUp), settledIds: new Set(), + allowed: !limit, }; } - function groupFor(cap: ClientToolsCapability, tc: ToolCall): PendingToolGroup { + function groupFor(agent: Agent, cap: ClientToolsCapability, tc: ToolCall): PendingToolGroup { if (currentGroup?.ids.has(tc.id)) return currentGroup; const pending = cap.pending(); const calls = pending.some((pendingCall) => pendingCall.id === tc.id) ? pending : [tc]; - currentGroup = createGroup(calls); + currentGroup = createGroup(agent, calls); return currentGroup; } + function shouldHandleClientToolCall(agent: Agent, cap: ClientToolsCapability, tc: ToolCall): boolean { + return groupFor(agent, cap, tc).allowed; + } + function warnMissingSettle(tc: ToolCall): void { console.warn( `Client tool "${tc.name}" requested batched or terminal settlement, but the agent capability does not implement settle(); falling back to resolve().`, @@ -93,10 +148,12 @@ export function createClientToolsCoordinator( function settleClientToolCall( cap: ClientToolsCapability, + agent: Agent, tc: ToolCall, result: ClientToolResult, ): void { - const group = groupFor(cap, tc); + const group = groupFor(agent, cap, tc); + if (!group.allowed) return; if (group.settledIds.has(tc.id)) return; group.settledIds.add(tc.id); @@ -126,7 +183,8 @@ export function createClientToolsCoordinator( cap.setCatalog(toClientToolSpecs(registry)); startClientToolExecutor(agent, registry, { executionGuard: options.executionGuard, - settleToolCall: (tc, result) => settleClientToolCall(cap, tc, result), + shouldExecuteToolCall: (tc) => shouldHandleClientToolCall(agent, cap, tc), + settleToolCall: (tc, result) => settleClientToolCall(cap, agent, tc, result), }); // function tools // Auto-ack `view` tools: they render but produce no user value. effect(() => { @@ -135,7 +193,7 @@ export function createClientToolsCoordinator( if (!def || def.kind !== 'view') continue; if (ackedViews.has(tc.id)) continue; ackedViews.add(tc.id); - settleClientToolCall(cap, tc, { ok: true, value: { shown: true } }); + settleClientToolCall(cap, agent, tc, { ok: true, value: { shown: true } }); } }); }, @@ -149,7 +207,7 @@ export function createClientToolsCoordinator( const pending = cap.pending().find( (tc: ToolCall) => tc.name === name && registry[tc.name]?.kind === 'ask', ); - if (pending) settleClientToolCall(cap, pending, { ok: true, value: event.value }); + if (pending) settleClientToolCall(cap, agent, pending, { ok: true, value: event.value }); }, }; } diff --git a/libs/chat/src/lib/client-tools/index.ts b/libs/chat/src/lib/client-tools/index.ts index 6090f49f3..7102ec026 100644 --- a/libs/chat/src/lib/client-tools/index.ts +++ b/libs/chat/src/lib/client-tools/index.ts @@ -3,6 +3,11 @@ export { action, view, ask, tools } from './tools'; export { deriveJsonSchema } from './to-json-schema'; export type { ClientToolContinuationOptions, + ClientToolContinuationLimitEvent, + ClientToolContinuationPolicy, + ClientToolLifecycle, + ClientToolLifecyclePhase, + ClientToolViewProps, ClientToolDef, ClientToolExecutionOptions, FunctionToolDef, diff --git a/libs/chat/src/lib/client-tools/tool-def.ts b/libs/chat/src/lib/client-tools/tool-def.ts index 411632a32..495ba7a8c 100644 --- a/libs/chat/src/lib/client-tools/tool-def.ts +++ b/libs/chat/src/lib/client-tools/tool-def.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import type { Type } from '@angular/core'; import type { StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput } from '@threadplane/render'; +import type { ToolCallStatus } from '../agent/tool-call'; export type { StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput }; @@ -22,6 +23,40 @@ export interface ClientToolExecutionOptions extends ClientToolContinuationOption readonly idempotent?: boolean; } +/** Diagnostic emitted when client-tool continuation would exceed the configured max turn count. */ +export interface ClientToolContinuationLimitEvent { + readonly maxTurns: number; + readonly attemptedTurn: number; + readonly toolCallIds: readonly string[]; + readonly toolNames: readonly string[]; +} + +/** Policy for automatic client-tool continuation. */ +export interface ClientToolContinuationPolicy { + /** Maximum consecutive client-tool continuation groups per user turn. Default: 10. Use 0 for unlimited. */ + readonly maxTurns?: number; + /** Called when the max-turn guard stops a continuation group. */ + readonly onLimit?: (event: ClientToolContinuationLimitEvent) => void; +} + +export type ClientToolLifecyclePhase = 'running' | 'complete' | 'error'; + +export interface ClientToolLifecycle { + readonly id: string; + readonly name: string; + readonly status: ToolCallStatus; + readonly phase: ClientToolLifecyclePhase; + readonly hasResult: boolean; + readonly result?: unknown; + readonly error?: unknown; +} + +export type ClientToolViewProps = + StandardSchemaInferOutput & { + readonly status?: ToolCallStatus; + readonly clientTool?: ClientToolLifecycle; + }; + /** Precise authored function tool — what `action()` returns. Carries the schema * `S` and the handler's resolved return type `R`. */ export interface FunctionToolDef { diff --git a/libs/chat/src/lib/client-tools/view-ask.type-spec.ts b/libs/chat/src/lib/client-tools/view-ask.type-spec.ts index 5d7422308..a28523304 100644 --- a/libs/chat/src/lib/client-tools/view-ask.type-spec.ts +++ b/libs/chat/src/lib/client-tools/view-ask.type-spec.ts @@ -1,7 +1,13 @@ // SPDX-License-Identifier: MIT import { Component, input } from '@angular/core'; import { z } from 'zod/v4'; -import type { ClientToolDef, ViewToolDef } from './tool-def'; +import type { + ClientToolDef, + ClientToolLifecycle, + ClientToolViewProps, + ViewToolDef, +} from './tool-def'; +import type { ToolCallStatus } from '../agent'; import { view, ask, tools } from './tools'; @Component({ template: '' }) @@ -28,6 +34,10 @@ const _reg = tools({ day_card: view('Show a day', daySchema, DayCardComponent) } // the result carries the component type. const _carries: ViewToolDef = dayView; +type DayClientToolViewProps = ClientToolViewProps; +const _status: ToolCallStatus | undefined = ({} as DayClientToolViewProps).status; +const _clientTool: ClientToolLifecycle | undefined = ({} as DayClientToolViewProps).clientTool; + // ❌ typo prop the component can't receive. const typoSchema = z.object({ dayz: z.number() }); // @ts-expect-error `dayz` is not an input of DayCardComponent 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 405ab18ab..d50d17f85 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 @@ -222,6 +222,46 @@ describe('ChatComponent — client-tools wiring', () => { void comp; }); + it('passes clientToolContinuationPolicy into the coordinator and emits limit diagnostics', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const cap = makeFakeCapability(); + const limitEvents: unknown[] = []; + let comp!: ChatComponent; + runInInjectionContext(injector, () => { + comp = new ChatComponent(); + setSignalInput(comp.clientTools, clientToolRegistry); + setSignalInput(comp.clientToolContinuationPolicy, { maxTurns: 1 }); + comp.clientToolContinuationLimit.subscribe((event) => limitEvents.push(event)); + setSignalInput(comp.agent, agentWithClientTools(cap.capability)); + TestBed.flushEffects(); + }); + await drainMicrotasks(); + + cap.pending.set([ + { id: 'first', name: 'get_weather', args: { city: 'SF' }, status: 'complete' }, + ]); + TestBed.flushEffects(); + await drainMicrotasks(); + cap.pending.set([ + { id: 'second', name: 'get_weather', args: { city: 'LA' }, status: 'complete' }, + ]); + TestBed.flushEffects(); + await drainMicrotasks(); + + expect(cap.resolve).toHaveBeenCalledTimes(1); + expect(limitEvents).toEqual([ + { + maxTurns: 1, + attemptedTurn: 2, + toolCallIds: ['second'], + toolNames: ['get_weather'], + }, + ]); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); + 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 b5813f910..5e7d96e87 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -13,6 +13,10 @@ 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 { + ClientToolContinuationLimitEvent, + ClientToolContinuationPolicy, +} 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'; @@ -372,6 +376,7 @@ export class ChatComponent { 'render_spec', ]); readonly clientToolExecutionGuard = input(undefined); + readonly clientToolContinuationPolicy = input(undefined); readonly showWelcome = computed(() => { if (this.welcomeDisabled()) return false; @@ -381,6 +386,7 @@ export class ChatComponent { }); readonly threadSelected = output(); readonly renderEvent = output(); + readonly clientToolContinuationLimit = output(); /** Emitted when the user clicks the regenerate button on an assistant message. */ readonly regenerate = output(); /** Emitted when the user rates an assistant message. */ @@ -405,8 +411,16 @@ export class ChatComponent { */ private readonly coordinator = computed(() => { const reg = this.clientTools(); + const policy = this.clientToolContinuationPolicy(); return reg ? createClientToolsCoordinator(reg, { executionGuard: this.clientToolExecutionGuard(), + continuationPolicy: { + ...policy, + onLimit: (event) => { + policy?.onLimit?.(event); + this.clientToolContinuationLimit.emit(event); + }, + }, }) : undefined; }); diff --git a/libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.spec.ts b/libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.spec.ts index 2f066e02a..a8990978b 100644 --- a/libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.spec.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Component, input, output, ChangeDetectionStrategy } from '@angular/core'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { Component, input, ChangeDetectionStrategy } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { views } from '@threadplane/render'; import type { RenderEvent } from '@threadplane/render'; import { mockAgent, type MockAgent } from '../../testing/mock-agent'; import type { Message, ToolCall } from '../../agent'; +import type { ClientToolLifecycle } from '../../client-tools/tool-def'; import { ChatToolViewsComponent } from './chat-tool-views.component'; // A minimal view component that renders the props it receives so the test @@ -14,12 +15,21 @@ import { ChatToolViewsComponent } from './chat-tool-views.component'; selector: 'chat-test-weather-card', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - template: `
{{ location() }}
{{ temperatureF() }}
{{ status() }}
`, + template: ` +
{{ location() }}
+
{{ temperatureF() }}
+
{{ status() }}
+
{{ clientTool()?.id }}
+
{{ clientTool()?.phase }}
+
{{ clientTool()?.hasResult }}
+
{{ clientTool()?.error }}
+ `, }) class TestWeatherCardComponent { readonly location = input(undefined); readonly temperatureF = input(undefined); readonly status = input(undefined); + readonly clientTool = input(undefined); } function mountHost(agent: MockAgent, message: Message | undefined) { @@ -55,6 +65,9 @@ describe('ChatToolViewsComponent', () => { expect(el.querySelector('chat-test-weather-card')).toBeTruthy(); expect(el.querySelector('.loc')?.textContent).toContain('San Francisco'); expect(el.querySelector('.st')?.textContent).toContain('running'); + expect(el.querySelector('.tool-id')?.textContent).toContain('c1'); + expect(el.querySelector('.tool-phase')?.textContent).toContain('running'); + expect(el.querySelector('.tool-result')?.textContent).toContain('false'); }); it('merges result fields on completion', () => { @@ -70,6 +83,26 @@ describe('ChatToolViewsComponent', () => { const el = fixture.nativeElement as HTMLElement; expect(el.querySelector('.temp')?.textContent).toContain('68'); expect(el.querySelector('.st')?.textContent).toContain('complete'); + expect(el.querySelector('.tool-phase')?.textContent).toContain('complete'); + expect(el.querySelector('.tool-result')?.textContent).toContain('true'); + }); + + it('passes error lifecycle state to the registered view', () => { + agent.toolCalls.set([ + { + id: 'c1', + name: 'weather_card', + args: { location: 'San Francisco' }, + status: 'error', + error: 'handler failed', + result: { error: 'handler failed' }, + }, + ] as ToolCall[]); + const fixture = mountHost(agent, msg); + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('.tool-phase')?.textContent).toContain('error'); + expect(el.querySelector('.tool-result')?.textContent).toContain('true'); + expect(el.querySelector('.tool-error')?.textContent).toContain('handler failed'); }); it('renders nothing for an unregistered tool name', () => { diff --git a/libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.ts b/libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.ts index c6f7c05a1..44adca25a 100644 --- a/libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.ts +++ b/libs/chat/src/lib/primitives/chat-tool-views/chat-tool-views.component.ts @@ -5,6 +5,7 @@ import type { Spec, StateStore } from '@json-render/core'; import type { RenderEvent, ViewRegistry } from '@threadplane/render'; import { toRenderRegistry } from '@threadplane/render'; import type { Agent, Message, ToolCall } from '../../agent'; +import type { ClientToolLifecycle } from '../../client-tools/tool-def'; import { resolveMessageToolCalls } from '../chat-tool-calls/resolve-message-tool-calls'; import { ChatGenerativeUiComponent } from '../chat-generative-ui/chat-generative-ui.component'; @@ -71,12 +72,25 @@ function toToolViewSpec(tc: ToolCall): Spec { elements: { [tc.name]: { type: tc.name, - props: { ...args, ...result, status: tc.status }, + props: { ...args, ...result, status: tc.status, clientTool: toClientToolLifecycle(tc) }, }, }, }; } +function toClientToolLifecycle(tc: ToolCall): ClientToolLifecycle { + const hasResult = tc.result !== undefined; + return { + id: tc.id, + name: tc.name, + status: tc.status, + phase: tc.status === 'error' ? 'error' : tc.status === 'complete' ? 'complete' : 'running', + hasResult, + ...(hasResult ? { result: tc.result } : {}), + ...(tc.error !== undefined ? { error: tc.error } : {}), + }; +} + function isRecord(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index b86c72223..a565f61a4 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -230,7 +230,25 @@ 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 { ClientToolContinuationOptions, ClientToolDef, ClientToolExecutionOptions, AnyFunctionToolDef, FunctionToolDef, FunctionToolHandlerContext, ViewToolDef, AskToolDef, ClientToolRegistry, StandardSchemaV1, StandardSchemaInferInput, StandardSchemaInferOutput } from './lib/client-tools/tool-def'; +export type { + ClientToolContinuationOptions, + ClientToolContinuationLimitEvent, + ClientToolContinuationPolicy, + ClientToolDef, + ClientToolExecutionOptions, + ClientToolLifecycle, + ClientToolLifecyclePhase, + ClientToolViewProps, + 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;