Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,18 @@
"description": "",
"optional": false
},
{
"name": "clientToolContinuationLimit",
"type": "OutputEmitterRef<ClientToolContinuationLimitEvent>",
"description": "",
"optional": false
},
{
"name": "clientToolContinuationPolicy",
"type": "InputSignal<ClientToolContinuationPolicy | undefined>",
"description": "",
"optional": false
},
{
"name": "clientToolExecutionGuard",
"type": "InputSignal<ClientToolExecutionGuard | undefined>",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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": []
Expand Down Expand Up @@ -8201,6 +8321,13 @@
"signature": "object | object | object",
"examples": []
},
{
"name": "ClientToolLifecyclePhase",
"kind": "type",
"description": "",
"signature": "\"running\" | \"complete\" | \"error\"",
"examples": []
},
{
"name": "ClientToolRegistry",
"kind": "type",
Expand All @@ -8215,6 +8342,13 @@
"signature": "object | object",
"examples": []
},
{
"name": "ClientToolViewProps",
"kind": "type",
"description": "",
"signature": "StandardSchemaInferOutput<S> & { clientTool?: ClientToolLifecycle; status?: ToolCallStatus }",
"examples": []
},
{
"name": "ContentBlock",
"kind": "type",
Expand Down
13 changes: 13 additions & 0 deletions cockpit/ag-ui/client-tools/angular/e2e/client-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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." }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,50 @@ 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,
ConfirmBookingComponent,
),
});

function waitForAbortableDelay(ms: number, signal: AbortSignal): Promise<void> {
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -16,32 +16,32 @@ 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<typeof
* Input types for schema-derived props are anchored to `ClientToolViewProps<typeof
* confirmBookingSchema>` — a schema change is a compile error here.
*/

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

@Component({
selector: 'app-confirm-booking',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (confirmed() === undefined) {
<div class="cb">
<div class="cb" [attr.data-phase]="clientTool()?.phase">
<p class="cb__summary">{{ summary() }}</p>
<div class="cb__actions">
<button type="button" class="cb__btn cb__btn--primary" (click)="respond(true)">Confirm</button>
<button type="button" class="cb__btn" (click)="respond(false)">Cancel</button>
</div>
</div>
} @else if (confirmed() === true) {
<div class="cb cb--resolved">
<div class="cb cb--resolved" [attr.data-phase]="clientTool()?.phase">
<p class="cb__summary">Booking confirmed ✓</p>
</div>
} @else {
<div class="cb cb--resolved">
<div class="cb cb--resolved" [attr.data-phase]="clientTool()?.phase">
<p class="cb__summary">Booking cancelled</p>
</div>
}
Expand All @@ -61,6 +61,7 @@ export class ConfirmBookingComponent {
readonly summary = input<ConfirmBookingProps['summary']>();
/** Spread back onto props after the ask resolves (undefined while interactive). */
readonly confirmed = input<boolean | undefined>(undefined);
readonly clientTool = input<ConfirmBookingProps['clientTool']>();
private readonly host = injectRenderHost();
protected respond(confirmed: boolean): void {
this.host.result({ confirmed });
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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<typeof weatherCardSchema>;
type WeatherCardProps = ClientToolViewProps<typeof weatherCardSchema>;

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

readonly pending = computed(() => this.status() !== 'complete' || this.temperatureF() === undefined);
readonly pending = computed(() => this.clientTool()?.phase !== 'complete' || this.temperatureF() === undefined);
}
5 changes: 5 additions & 0 deletions cockpit/ag-ui/client-tools/python/prompts/client-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions cockpit/langgraph/client-tools/angular/e2e/client-tools.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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." }
Expand Down
Loading
Loading