Skip to content

Commit 59c61fe

Browse files
committed
feat: add client tool continuation policy
1 parent e631701 commit 59c61fe

25 files changed

Lines changed: 1088 additions & 36 deletions

apps/website/content/docs/chat/api/api-docs.json

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1821,6 +1821,18 @@
18211821
"description": "",
18221822
"optional": false
18231823
},
1824+
{
1825+
"name": "clientToolContinuationLimit",
1826+
"type": "OutputEmitterRef<ClientToolContinuationLimitEvent>",
1827+
"description": "",
1828+
"optional": false
1829+
},
1830+
{
1831+
"name": "clientToolContinuationPolicy",
1832+
"type": "InputSignal<ClientToolContinuationPolicy | undefined>",
1833+
"description": "",
1834+
"optional": false
1835+
},
18241836
{
18251837
"name": "clientToolExecutionGuard",
18261838
"type": "InputSignal<ClientToolExecutionGuard | undefined>",
@@ -6712,6 +6724,38 @@
67126724
],
67136725
"examples": []
67146726
},
6727+
{
6728+
"name": "ClientToolContinuationLimitEvent",
6729+
"kind": "interface",
6730+
"description": "Diagnostic emitted when client-tool continuation would exceed the configured max turn count.",
6731+
"properties": [
6732+
{
6733+
"name": "attemptedTurn",
6734+
"type": "number",
6735+
"description": "",
6736+
"optional": false
6737+
},
6738+
{
6739+
"name": "maxTurns",
6740+
"type": "number",
6741+
"description": "",
6742+
"optional": false
6743+
},
6744+
{
6745+
"name": "toolCallIds",
6746+
"type": "readonly string[]",
6747+
"description": "",
6748+
"optional": false
6749+
},
6750+
{
6751+
"name": "toolNames",
6752+
"type": "readonly string[]",
6753+
"description": "",
6754+
"optional": false
6755+
}
6756+
],
6757+
"examples": []
6758+
},
67156759
{
67166760
"name": "ClientToolContinuationOptions",
67176761
"kind": "interface",
@@ -6726,6 +6770,26 @@
67266770
],
67276771
"examples": []
67286772
},
6773+
{
6774+
"name": "ClientToolContinuationPolicy",
6775+
"kind": "interface",
6776+
"description": "Policy for automatic client-tool continuation.",
6777+
"properties": [
6778+
{
6779+
"name": "maxTurns",
6780+
"type": "number",
6781+
"description": "Maximum consecutive client-tool continuation groups per user turn. Default: 10. Use 0 for unlimited.",
6782+
"optional": true
6783+
},
6784+
{
6785+
"name": "onLimit",
6786+
"type": "(event: ClientToolContinuationLimitEvent) => void",
6787+
"description": "Called when the max-turn guard stops a continuation group.",
6788+
"optional": true
6789+
}
6790+
],
6791+
"examples": []
6792+
},
67296793
{
67306794
"name": "ClientToolExecutionGuard",
67316795
"kind": "interface",
@@ -6862,6 +6926,62 @@
68626926
"type": "(toolCall: ToolCall, result: ClientToolResult) => void",
68636927
"description": "",
68646928
"optional": true
6929+
},
6930+
{
6931+
"name": "shouldExecuteToolCall",
6932+
"type": "(toolCall: ToolCall) => boolean",
6933+
"description": "",
6934+
"optional": true
6935+
}
6936+
],
6937+
"examples": []
6938+
},
6939+
{
6940+
"name": "ClientToolLifecycle",
6941+
"kind": "interface",
6942+
"description": "",
6943+
"properties": [
6944+
{
6945+
"name": "error",
6946+
"type": "unknown",
6947+
"description": "",
6948+
"optional": true
6949+
},
6950+
{
6951+
"name": "hasResult",
6952+
"type": "boolean",
6953+
"description": "",
6954+
"optional": false
6955+
},
6956+
{
6957+
"name": "id",
6958+
"type": "string",
6959+
"description": "",
6960+
"optional": false
6961+
},
6962+
{
6963+
"name": "name",
6964+
"type": "string",
6965+
"description": "",
6966+
"optional": false
6967+
},
6968+
{
6969+
"name": "phase",
6970+
"type": "ClientToolLifecyclePhase",
6971+
"description": "",
6972+
"optional": false
6973+
},
6974+
{
6975+
"name": "result",
6976+
"type": "unknown",
6977+
"description": "",
6978+
"optional": true
6979+
},
6980+
{
6981+
"name": "status",
6982+
"type": "ToolCallStatus",
6983+
"description": "",
6984+
"optional": false
68656985
}
68666986
],
68676987
"examples": []
@@ -8201,6 +8321,13 @@
82018321
"signature": "object | object | object",
82028322
"examples": []
82038323
},
8324+
{
8325+
"name": "ClientToolLifecyclePhase",
8326+
"kind": "type",
8327+
"description": "",
8328+
"signature": "\"running\" | \"complete\" | \"error\"",
8329+
"examples": []
8330+
},
82048331
{
82058332
"name": "ClientToolRegistry",
82068333
"kind": "type",
@@ -8215,6 +8342,13 @@
82158342
"signature": "object | object",
82168343
"examples": []
82178344
},
8345+
{
8346+
"name": "ClientToolViewProps",
8347+
"kind": "type",
8348+
"description": "",
8349+
"signature": "StandardSchemaInferOutput<S> & { clientTool?: ClientToolLifecycle; status?: ToolCallStatus }",
8350+
"examples": []
8351+
},
82188352
{
82198353
"name": "ContentBlock",
82208354
"kind": "type",

cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ test('client-tools: view tool renders the model-filled component', async ({ page
1818
await expect(page.getByText("Here's the weather card for Tokyo")).toBeVisible({ timeout: 30000 });
1919
});
2020

21+
test('client-tools: terminal view tool renders without a follow-up assistant run', async ({ page }) => {
22+
await page.goto('/');
23+
const input = page.getByRole('textbox', { name: /message|prompt/i });
24+
await input.fill('Show me a quiet weather snapshot for Oslo');
25+
await page.getByRole('button', { name: /send message/i }).click();
26+
27+
const card = page.locator('app-weather-card');
28+
await expect(card).toBeVisible({ timeout: 30000 });
29+
await expect(card).toContainText('Oslo');
30+
await expect(page.getByText(/quiet weather snapshot/i)).toHaveCount(1);
31+
await expect(page.getByText(/Here's the weather card for Oslo/i)).toHaveCount(0);
32+
});
33+
2134
// ask tool (HITL): confirm_booking renders, the user confirms, the emitted value resumes the run.
2235
// This test does NOT use submitAndWaitForResponse because that helper awaits a finalized assistant
2336
// message — but the first run ends with only a tool call, so the helper would block until the user

cockpit/ag-ui/client-tools/angular/e2e/fixtures/client-tools.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
"match": { "userMessage": "Show me a weather card for Tokyo" },
1717
"response": { "toolCalls": [ { "name": "weather_card", "arguments": { "location": "Tokyo", "temperatureF": 72, "conditions": "Clear", "humidity": 50, "windMph": 6 } } ] }
1818
},
19+
{
20+
"match": { "userMessage": "Show me a quiet weather snapshot for Oslo" },
21+
"response": { "toolCalls": [ { "name": "weather_snapshot", "arguments": { "location": "Oslo", "temperatureF": 41, "conditions": "Cloudy", "humidity": 64, "windMph": 9 } } ] }
22+
},
1923
{
2024
"match": { "userMessage": "Book a table for two at 7pm", "hasToolResult": true },
2125
"response": { "content": "Your booking is confirmed — see you at 7pm." }

cockpit/ag-ui/client-tools/angular/src/app/client-tools.component.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,50 @@ const clientTools = tools({
2727
z.object({ location: z.string() }),
2828
async ({ location }) => ({ location, temperatureF: 68, conditions: 'Sunny', humidity: 55, windMph: 8 }),
2929
),
30+
slow_status_check: action(
31+
'Run a cancellable local status check. Use when the user asks to test stopping a slow browser tool.',
32+
z.object({ label: z.string().default('status check') }),
33+
async ({ label }, { signal }) => {
34+
await waitForAbortableDelay(3000, signal);
35+
return { label, status: 'complete' };
36+
},
37+
),
3038
weather_card: view(
3139
'Display a weather card for a location with the given readings.',
3240
weatherCardSchema,
3341
WeatherCardComponent,
3442
),
43+
weather_snapshot: view(
44+
'Display a weather card as a terminal snapshot without asking the assistant to summarize afterwards.',
45+
weatherCardSchema,
46+
WeatherCardComponent,
47+
{ followUp: false },
48+
),
3549
confirm_booking: ask(
3650
'Ask the user to confirm a booking before finalizing it.',
3751
confirmBookingSchema,
3852
ConfirmBookingComponent,
3953
),
4054
});
4155

56+
function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise<void> {
57+
return new Promise((resolve, reject) => {
58+
if (signal.aborted) {
59+
reject(new DOMException('Aborted', 'AbortError'));
60+
return;
61+
}
62+
const timeout = window.setTimeout(resolve, ms);
63+
signal.addEventListener(
64+
'abort',
65+
() => {
66+
window.clearTimeout(timeout);
67+
reject(new DOMException('Aborted', 'AbortError'));
68+
},
69+
{ once: true },
70+
);
71+
});
72+
}
73+
4274
@Component({
4375
selector: 'app-client-tools',
4476
standalone: true,

cockpit/ag-ui/client-tools/angular/src/app/confirm-booking.component.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// SPDX-License-Identifier: MIT
22
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
3-
import type { ViewProps } from '@threadplane/chat';
3+
import type { ClientToolViewProps } from '@threadplane/chat';
44
import { injectRenderHost } from '@threadplane/render';
55
import { confirmBookingSchema } from './schemas';
66

@@ -16,32 +16,32 @@ import { confirmBookingSchema } from './schemas';
1616
* `confirmed()` is defined we render a FROZEN line with no buttons; the live
1717
* interactive card only shows while `confirmed()` is still undefined.
1818
*
19-
* Input types for schema-derived props are anchored to `ViewProps<typeof
19+
* Input types for schema-derived props are anchored to `ClientToolViewProps<typeof
2020
* confirmBookingSchema>` — a schema change is a compile error here.
2121
*/
2222

2323
/** Props this component receives from the `confirm_booking` schema. */
24-
type ConfirmBookingProps = ViewProps<typeof confirmBookingSchema>;
24+
type ConfirmBookingProps = ClientToolViewProps<typeof confirmBookingSchema>;
2525

2626
@Component({
2727
selector: 'app-confirm-booking',
2828
standalone: true,
2929
changeDetection: ChangeDetectionStrategy.OnPush,
3030
template: `
3131
@if (confirmed() === undefined) {
32-
<div class="cb">
32+
<div class="cb" [attr.data-phase]="clientTool()?.phase">
3333
<p class="cb__summary">{{ summary() }}</p>
3434
<div class="cb__actions">
3535
<button type="button" class="cb__btn cb__btn--primary" (click)="respond(true)">Confirm</button>
3636
<button type="button" class="cb__btn" (click)="respond(false)">Cancel</button>
3737
</div>
3838
</div>
3939
} @else if (confirmed() === true) {
40-
<div class="cb cb--resolved">
40+
<div class="cb cb--resolved" [attr.data-phase]="clientTool()?.phase">
4141
<p class="cb__summary">Booking confirmed ✓</p>
4242
</div>
4343
} @else {
44-
<div class="cb cb--resolved">
44+
<div class="cb cb--resolved" [attr.data-phase]="clientTool()?.phase">
4545
<p class="cb__summary">Booking cancelled</p>
4646
</div>
4747
}
@@ -61,6 +61,7 @@ export class ConfirmBookingComponent {
6161
readonly summary = input<ConfirmBookingProps['summary']>();
6262
/** Spread back onto props after the ask resolves (undefined while interactive). */
6363
readonly confirmed = input<boolean | undefined>(undefined);
64+
readonly clientTool = input<ConfirmBookingProps['clientTool']>();
6465
private readonly host = injectRenderHost();
6566
protected respond(confirmed: boolean): void {
6667
this.host.result({ confirmed });

cockpit/ag-ui/client-tools/angular/src/app/weather-card.component.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// SPDX-License-Identifier: MIT
22
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
3-
import type { ViewProps } from '@threadplane/chat';
3+
import type { ClientToolViewProps } from '@threadplane/chat';
44
import { weatherCardSchema } from './schemas';
55

66
/**
@@ -10,12 +10,12 @@ import { weatherCardSchema } from './schemas';
1010
* `status` of 'running' | 'complete'. Renders a loading affordance until the
1111
* result arrives.
1212
*
13-
* Input types are derived from {@link weatherCardSchema} via `ViewProps` so
13+
* Input types are derived from {@link weatherCardSchema} via `ClientToolViewProps` so
1414
* that a schema change is a compile error here under `strict: true`.
1515
*/
1616

1717
/** Props this component receives from the `weather_card` schema. */
18-
type WeatherCardProps = ViewProps<typeof weatherCardSchema>;
18+
type WeatherCardProps = ClientToolViewProps<typeof weatherCardSchema>;
1919

2020
@Component({
2121
selector: 'app-weather-card',
@@ -59,7 +59,8 @@ export class WeatherCardComponent {
5959
readonly humidity = input<WeatherCardProps['humidity']>();
6060
readonly windMph = input<WeatherCardProps['windMph']>();
6161
/** Extra input not in the schema: injected by the framework for rendering state. */
62-
readonly status = input<'running' | 'complete'>();
62+
readonly status = input<WeatherCardProps['status']>();
63+
readonly clientTool = input<WeatherCardProps['clientTool']>();
6364

64-
readonly pending = computed(() => this.status() !== 'complete' || this.temperatureF() === undefined);
65+
readonly pending = computed(() => this.clientTool()?.phase !== 'complete' || this.temperatureF() === undefined);
6566
}

cockpit/ag-ui/client-tools/python/prompts/client-tools.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ three client tools — call the right one and do not answer in prose first:
88
- When the user asks to *show* or *display* a weather card, call `weather_card`
99
with the location and plausible readings (temperatureF, conditions, humidity,
1010
windMph). After it renders, briefly confirm.
11+
- When the user asks for a quiet or terminal weather snapshot, call
12+
`weather_snapshot` with the location and plausible readings. Do not summarize
13+
afterwards; the rendered card is the final response.
14+
- When the user asks to test stopping a slow browser tool, call
15+
`slow_status_check` with a short label.
1116
- When the user asks to book or reserve something, call `confirm_booking` with a
1217
one-line `summary` of what they're booking. After the user responds, confirm
1318
if they accepted or acknowledge if they cancelled.

cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ test('client-tools: view tool renders the model-filled component', async ({ page
1818
await expect(page.getByText("Here's the weather card for Tokyo")).toBeVisible({ timeout: 30000 });
1919
});
2020

21+
test('client-tools: terminal view tool renders without a follow-up assistant run', async ({ page }) => {
22+
await page.goto('/');
23+
const input = page.getByRole('textbox', { name: /message|prompt/i });
24+
await input.fill('Show me a quiet weather snapshot for Oslo');
25+
await page.getByRole('button', { name: /send message/i }).click();
26+
27+
const card = page.locator('app-weather-card');
28+
await expect(card).toBeVisible({ timeout: 30000 });
29+
await expect(card).toContainText('Oslo');
30+
await expect(page.getByText(/quiet weather snapshot/i)).toHaveCount(1);
31+
await expect(page.getByText(/Here's the weather card for Oslo/i)).toHaveCount(0);
32+
});
33+
2134
// ask tool (HITL): confirm_booking renders, the user confirms, the emitted value resumes the run.
2235
// This test does NOT use submitAndWaitForResponse because that helper awaits a finalized assistant
2336
// message — but the first run ends with only a tool call, so the helper would block until the user

cockpit/langgraph/client-tools/angular/e2e/fixtures/client-tools.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
"match": { "userMessage": "Show me a weather card for Tokyo" },
1717
"response": { "toolCalls": [ { "name": "weather_card", "arguments": { "location": "Tokyo", "temperatureF": 72, "conditions": "Clear", "humidity": 50, "windMph": 6 } } ] }
1818
},
19+
{
20+
"match": { "userMessage": "Show me a quiet weather snapshot for Oslo" },
21+
"response": { "toolCalls": [ { "name": "weather_snapshot", "arguments": { "location": "Oslo", "temperatureF": 41, "conditions": "Cloudy", "humidity": 64, "windMph": 9 } } ] }
22+
},
1923
{
2024
"match": { "userMessage": "Book a table for two at 7pm", "hasToolResult": true },
2125
"response": { "content": "Your booking is confirmed — see you at 7pm." }

0 commit comments

Comments
 (0)