Skip to content

Commit 1b65ff5

Browse files
Add retained streaming agent tools (#1421)
* Add agent tool orchestration Introduce first-class agent tools for running chat-capable Think sub-agents from a parent agent. This adds the parent run registry, event replay, cleanup, cancellation wiring, the AI SDK `agentTool` wrapper, React event aggregation, and the Think child adapter needed to stream retained child timelines through the parent connection. Rewrite the agents-as-tools example to consume the public APIs instead of the old helper-event prototype, and refresh docs, READMEs, design notes, tests, and release metadata so the feature is discoverable as the supported agent tools surface. Made-with: Cursor * Support AIChatAgent agent tools Extend the agent-tool child adapter contract to AIChatAgent so existing chat agents can run as retained, streaming tools with durable inspection, replay, and cancellation. Also update the shared live-tail transport for Durable Object RPC byte streams and document the headless client-tool limitation for follow-up work. Made-with: Cursor * Harden agent tool edge cases Persist structured agent-tool outputs, make AIChatAgent stream errors terminal, and expand cancellation/idempotency coverage so retained runs behave consistently across retries and replays. Refresh the docs and schema-version tests to reflect AIChatAgent support and the new parent registry column. Made-with: Cursor * Harden agent tool cancellation cleanup Clean up parent abort listeners after completed agent-tool runs and avoid acquiring stream readers when forwarding starts from an already-aborted signal. Add regression coverage for both edge cases so future cancellation changes preserve the resource cleanup behavior. Made-with: Cursor * Use polling helper for root keepAlive ref count Add expectRootKeepAliveRefCount helper that polls agent.getRootKeepAliveRefCount (up to 20 attempts with a short delay) and use it in sub-agent tests instead of ad-hoc setTimeout waits. This replaces fragile fixed delays with a deterministic polling assert to reduce test flakiness in packages/agents/src/tests/sub-agent.test.ts. * Skip malformed agent tool stream frames Drop malformed or shape-invalid NDJSON frames during agent-tool stream forwarding so a corrupted display chunk does not fail an otherwise completed child run. Add regression coverage for the byte-stream forwarding path. Made-with: Cursor * Test and fix agent-tool in-memory cleanup Add a unit test (packages/think/src/tests/agent-tools.test.ts) that verifies in-memory agent-tool bookkeeping is cleared after a run completes. Extend ThinkTestAgent with helpers to seed a last-error for a run and to inspect map sizes (seedAgentToolLastErrorForTest, getAgentToolCleanupMapSizesForTest). Fix cleanup logic in think.ts to remove entries from _agentToolLastErrors and _agentToolPreTurnAssistantIds when an agent-tool run is torn down to avoid retained in-memory state. * Add types for agent tool test utilities Introduce AgentToolInspection and ThinkAgentToolTestStub types and tighten test helpers' signatures. freshAgent now returns a Promise<ThinkAgentToolTestStub> (with a cast from getAgentByName) and waitForAgentToolRun accepts the stub and returns AgentToolInspection. These changes improve TypeScript safety for agent tool tests and make available explicit method shapes used in the tests (inspectAgentToolRun, seedAgentToolLastErrorForTest, startAgentToolRun, getAgentToolCleanupMapSizesForTest).
1 parent 06fb49b commit 1b65ff5

51 files changed

Lines changed: 4162 additions & 4957 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"agents": minor
3+
"@cloudflare/think": minor
4+
"@cloudflare/ai-chat": minor
5+
---
6+
7+
Add agent tool orchestration for running Think and AIChatAgent sub-agents as
8+
retained, streaming tools from a parent agent. The new surface includes
9+
`runAgentTool`, `agentTool`, parent-side run replay and cleanup, Think and
10+
AIChatAgent child adapter support, and headless React/client event state
11+
helpers.

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ The agent is a Durable Object, so it needs a binding and a SQLite migration in `
105105
| **Persistent State** | Syncs to all connected clients, survives restarts |
106106
| **Callable Methods** | Type-safe RPC via the `@callable()` decorator |
107107
| **Sub-agents** | Parent/child DO composition via facets, nested routing, and typed parent lookup |
108+
| **Agent Tools** | Run chat-capable sub-agents as tools with streaming child timelines |
108109
| **Scheduling** | One-time, recurring, and cron-based tasks |
109110
| **WebSockets** | Real-time bidirectional communication with lifecycle hooks |
110111
| **AI Chat** | Message persistence, resumable streaming, server/client tool execution |
@@ -142,7 +143,7 @@ The agent is a Durable Object, so it needs a binding and a SQLite migration in `
142143
The [`examples/`](examples) directory has 30+ self-contained demos. A non-exhaustive tour:
143144

144145
- **Showcase**[`playground/`](examples/playground) is the kitchen-sink app: state, callable methods, scheduling, chat, tools, MCP, workflows, email, voice — all in one UI
145-
- **Chat & assistants**[`assistant/`](examples/assistant), [`workspace-chat/`](examples/workspace-chat), [`resumable-stream-chat/`](examples/resumable-stream-chat), [`structured-input/`](examples/structured-input), [`dynamic-tools/`](examples/dynamic-tools), [`multi-ai-chat/`](examples/multi-ai-chat)
146+
- **Chat & assistants**[`assistant/`](examples/assistant), [`agents-as-tools/`](examples/agents-as-tools), [`workspace-chat/`](examples/workspace-chat), [`resumable-stream-chat/`](examples/resumable-stream-chat), [`structured-input/`](examples/structured-input), [`dynamic-tools/`](examples/dynamic-tools), [`multi-ai-chat/`](examples/multi-ai-chat)
146147
- **MCP**[`mcp/`](examples/mcp), [`mcp-client/`](examples/mcp-client), [`mcp-worker/`](examples/mcp-worker), [`mcp-worker-authenticated/`](examples/mcp-worker-authenticated), [`mcp-elicitation/`](examples/mcp-elicitation), [`mcp-rpc-transport/`](examples/mcp-rpc-transport), [`webmcp/`](examples/webmcp)
147148
- **Code Mode & sandboxes**[`codemode/`](examples/codemode), [`codemode-mcp/`](examples/codemode-mcp), [`codemode-mcp-openapi/`](examples/codemode-mcp-openapi), [`dynamic-workers/`](examples/dynamic-workers), [`dynamic-workers-playground/`](examples/dynamic-workers-playground), [`worker-bundler-playground/`](examples/worker-bundler-playground)
148149
- **Voice**[`voice-agent/`](examples/voice-agent), [`voice-input/`](examples/voice-input), [`elevenlabs-starter/`](examples/elevenlabs-starter)
@@ -165,7 +166,7 @@ npm start
165166
- [`docs/`](docs) directory in this repo (synced upstream)
166167
- [Anthropic Patterns guide](guides/anthropic-patterns) — sequential, routing, parallel, orchestrator, evaluator
167168
- [Human-in-the-Loop guide](guides/human-in-the-loop) — approval workflows with pause/resume
168-
- [`design/`](design) — architecture and design decision records (chat API, sub-agents RFC, workspace, voice, browser tools, retries, and more)
169+
- [`design/`](design) — architecture and design decision records (chat API, sub-agents, agent tools, workspace, voice, browser tools, retries, and more)
169170

170171
## Repository Structure
171172

design/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Keep it concise. A few paragraphs is fine. These are records, not essays.
8282
| `retries.md` | design doc | Retry system — primitives, integration points, backoff strategy, tradeoffs |
8383
| `visuals.md` | design doc | UI component library (Kumo), dark mode, custom patterns, routing integration |
8484
| `workspace.md` | design doc | Workspace — hybrid SQLite+R2 filesystem, bash, symlinks, observability |
85+
| `agent-tools.md` | design doc | Agent tools — chat sub-agent orchestration, parent registry, event replay |
8586
| `sub-agent-routing.md` | design doc | Sub-agent routing as shipped — facets, nested URLs, registry, parent lookup, caveats |
8687
| `rfc-sub-agents.md` | RFC | Sub-agents — child DOs via facets, typed stubs, built into Agent (accepted) |
8788
| `rfc-sub-agent-routing.md` | RFC | Sub-agent external addressability — nested URLs, `onBeforeSubAgent`, per-call bridge |

design/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The goal is to give contributors (and future-us) a quick way to understand _why_
1212
| [visuals.md](./visuals.md) | UI component library choice, Kumo usage, custom patterns |
1313
| [readonly-connections.md](./readonly-connections.md) | Readonly connection enforcement, storage, tradeoffs, and caveats |
1414
| [workspace.md](./workspace.md) | Workspace — hybrid SQLite+R2 filesystem, bash, symlinks |
15+
| [agent-tools.md](./agent-tools.md) | Agent tools — chat sub-agent orchestration, parent registry, replay |
1516
| [sub-agent-routing.md](./sub-agent-routing.md) | Sub-agent routing as shipped — facets, nested URLs, registry, parent lookup |
1617
| [rfc-sub-agents.md](./rfc-sub-agents.md) | RFC: Sub-agents — child DOs via facets, typed stubs, mixin API |
1718
| [rfc-helper-sub-agent-orchestration.md](./rfc-helper-sub-agent-orchestration.md) | RFC: Agent tool orchestration — `runAgentTool`, `agentTool`, event forwarding |

design/agent-tools.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Agent Tools
2+
3+
Agent tools are the orchestration layer that lets a parent agent run a
4+
chat-capable sub-agent as part of a larger operation. The shipped V1 follows
5+
[`rfc-helper-sub-agent-orchestration.md`](./rfc-helper-sub-agent-orchestration.md).
6+
7+
The parent owns a framework table, `cf_agent_tool_runs`, that records each
8+
logical run by `runId`: parent tool call id, child class, safe input preview,
9+
display order, status, summary, and terminal error metadata. The child remains a
10+
normal sub-agent facet and owns the full chat transcript plus resumable stream
11+
chunks. Think children use `cf_agent_tool_child_runs` to map `runId` to the
12+
underlying Think request and stream ids; AIChatAgent children use
13+
`cf_ai_chat_agent_tool_runs` to map `runId` to their `saveMessages()` request.
14+
15+
`runAgentTool(Cls, options)` is the foundational API. It inserts the parent row
16+
before waking the child, starts the child adapter idempotently by `runId`,
17+
forwards child `UIMessageChunk` bodies to parent clients as
18+
`agent-tool-event` frames, records a terminal state, and retains the child facet
19+
for replay and drill-in. `agentTool(Cls, options)` is a small AI SDK tool
20+
factory layered on top for model-selected dispatch.
21+
22+
The React surface is intentionally headless. `applyAgentToolEvent` reconstructs
23+
child `UIMessage.parts` from opaque chunk bodies and groups runs by parent tool
24+
call id; `useAgentToolEvents` subscribes to the existing parent connection and
25+
deduplicates replay/live races. Applications own layout, panels, and drill-in
26+
UI.
27+
28+
V1 supports Think children and AIChatAgent children. Live child chunks cross
29+
Durable Object RPC as byte-encoded newline-delimited records; the parent decodes
30+
them and broadcasts `agent-tool-event` frames. Cancellation is bridged by
31+
parent-side cancellation callbacks rather than serializing `AbortSignal` across
32+
Durable Object RPC. If a parent restarts while a run is non-terminal, V1 replays
33+
stored chunks and marks the parent row `interrupted`; live-tail reattach is
34+
deferred.
35+
36+
## Tradeoffs
37+
38+
- Runs and facets are retained by default so refresh, drill-in, and debugging
39+
work after completion. Applications must call `clearAgentToolRuns()` when
40+
clearing chat history or enforcing retention.
41+
- The parent registry stores input previews, not raw inputs, to avoid creating a
42+
second prompt store.
43+
- AIChatAgent agent-tool turns are headless. Server-side tools work normally,
44+
but browser-provided client tools are not available unless the application
45+
models the interaction as server-side state or a separate parent-mediated
46+
workflow.
47+
48+
## History
49+
50+
- [`rfc-helper-sub-agent-orchestration.md`](./rfc-helper-sub-agent-orchestration.md)
51+
— accepted V1 direction for `runAgentTool`, `agentTool`, event forwarding,
52+
replay, and cleanup.

docs/agent-tools.md

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# Agent Tools
2+
3+
Agent tools let one chat agent dispatch another chat-capable sub-agent as part
4+
of its work. The child is a real sub-agent with its own Durable Object storage,
5+
messages, tools, resumable stream, and drill-in URL. The parent keeps a small
6+
run registry so clients can render the child timeline, replay it after refresh,
7+
and clean it up later.
8+
9+
Agent tools support `@cloudflare/think` agents and `AIChatAgent` subclasses.
10+
`AIChatAgent` children run headlessly through `saveMessages()`, so they should
11+
use server-side tools. Browser-provided client tools are not available during an
12+
agent-tool turn unless you model that interaction as server-side state or a
13+
separate parent-mediated workflow.
14+
15+
## Use an Agent as an AI SDK tool
16+
17+
Use `agentTool()` when the parent model should decide when to call the helper.
18+
19+
```ts
20+
import { Think } from "@cloudflare/think";
21+
import { agentTool } from "agents/agent-tools";
22+
import { z } from "zod";
23+
24+
export class Researcher extends Think<Env> {
25+
getSystemPrompt() {
26+
return "Research the user's topic and end with a concise summary.";
27+
}
28+
}
29+
30+
export class Assistant extends Think<Env> {
31+
getTools() {
32+
return {
33+
research: agentTool(Researcher, {
34+
description: "Research one topic in depth.",
35+
displayName: "Researcher",
36+
inputSchema: z.object({
37+
query: z.string().min(3)
38+
})
39+
})
40+
};
41+
}
42+
}
43+
```
44+
45+
The child can also be an `AIChatAgent`:
46+
47+
```ts
48+
import { AIChatAgent } from "@cloudflare/ai-chat";
49+
import { agentTool } from "agents/agent-tools";
50+
import { convertToModelMessages, stepCountIs, streamText } from "ai";
51+
import { z } from "zod";
52+
53+
export class Summarizer extends AIChatAgent<Env> {
54+
protected override formatAgentToolInput(input: { text: string }, request) {
55+
return {
56+
id: `agent-tool-${request.runId}-input`,
57+
role: "user",
58+
parts: [{ type: "text", text: `Summarize:\n\n${input.text}` }]
59+
};
60+
}
61+
62+
async onChatMessage() {
63+
const result = streamText({
64+
model: this.env.MODEL,
65+
messages: await convertToModelMessages(this.messages)
66+
});
67+
return result.toUIMessageStreamResponse();
68+
}
69+
}
70+
71+
export class Assistant extends AIChatAgent<Env> {
72+
async onChatMessage() {
73+
const result = streamText({
74+
model: this.env.MODEL,
75+
messages: await convertToModelMessages(this.messages),
76+
tools: {
77+
summarize: agentTool(Summarizer, {
78+
description: "Summarize long text in a separate retained agent.",
79+
inputSchema: z.object({ text: z.string() })
80+
})
81+
},
82+
stopWhen: stepCountIs(5)
83+
});
84+
return result.toUIMessageStreamResponse();
85+
}
86+
}
87+
```
88+
89+
The generated tool calls `this.runAgentTool(ChildAgent, ...)`, streams
90+
`agent-tool-event` frames on the parent WebSocket, and returns the child
91+
summary to the parent model. If the run fails, aborts, or is interrupted, the
92+
tool returns a structured failure instead of an empty success value.
93+
94+
## Run an Agent tool imperatively
95+
96+
Use `runAgentTool()` for deterministic workflows, scheduled work, HTTP
97+
handlers, or fan-out code.
98+
99+
```ts
100+
const [a, b] = await Promise.allSettled([
101+
this.runAgentTool(Researcher, {
102+
input: { query: "HTTP/3" },
103+
parentToolCallId: toolCallId,
104+
displayOrder: 0
105+
}),
106+
this.runAgentTool(Researcher, {
107+
input: { query: "gRPC" },
108+
parentToolCallId: toolCallId,
109+
displayOrder: 1
110+
})
111+
]);
112+
```
113+
114+
`runAgentTool()` is idempotent by `runId`. Passing the same `runId` never starts
115+
a duplicate child turn. Completed, failed, aborted, and interrupted runs are
116+
retained until you explicitly clear them.
117+
118+
## Render child timelines in React
119+
120+
`useAgentToolEvents()` is a headless hook. It subscribes to the existing parent
121+
connection, deduplicates replay/live races, applies child `UIMessageChunk`
122+
bodies to message parts, and groups sibling runs by parent tool call id.
123+
124+
```tsx
125+
import { useAgent, useAgentToolEvents } from "agents/react";
126+
import { useAgentChat } from "@cloudflare/ai-chat/react";
127+
128+
const agent = useAgent({ agent: "Assistant", name: userId });
129+
const { messages } = useAgentChat({ agent });
130+
const agentTools = useAgentToolEvents({ agent });
131+
132+
for (const message of messages) {
133+
for (const part of message.parts) {
134+
if (part.type === "tool-call") {
135+
const runs = agentTools.getRunsForToolCall(part.toolCallId);
136+
// Render the child runs beside this tool call.
137+
}
138+
}
139+
}
140+
```
141+
142+
Imperative runs without a parent tool call are available as
143+
`agentTools.unboundRuns`.
144+
145+
## Drill in and gate access
146+
147+
Agent tools are normal sub-agents. Connect to a retained child through the
148+
parent route:
149+
150+
```ts
151+
useAgent({
152+
agent: "Assistant",
153+
name: userId,
154+
sub: [{ agent: "Researcher", name: runId }]
155+
});
156+
```
157+
158+
Gate external access with the parent registry so guessed run ids cannot spawn
159+
fresh child facets:
160+
161+
```ts
162+
override async onBeforeSubAgent(_request, child) {
163+
if (!this.hasAgentToolRun(child.className, child.name)) {
164+
return new Response("Not found", { status: 404 });
165+
}
166+
}
167+
```
168+
169+
## Clear retained runs
170+
171+
Runs and child facets are retained by default for refresh, drill-in, and later
172+
inspection. Delete them explicitly when clearing chat history or applying your
173+
own retention policy:
174+
175+
```ts
176+
await this.clearAgentToolRuns();
177+
await this.clearAgentToolRuns({
178+
status: ["completed", "error", "aborted", "interrupted"]
179+
});
180+
await this.clearAgentToolRuns({ olderThan: Date.now() - 7 * 24 * 60 * 60_000 });
181+
```
182+
183+
If a retained run is still `starting` or `running`, cleanup cancels the child
184+
before deleting its facet.

docs/chat-agents.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,9 @@ if (result.status === "aborted") {
355355

356356
The same `options.signal` is accepted by `continueLastTurn()`. See
357357
[`cloudflare/agents#1406`](https://github.com/cloudflare/agents/issues/1406)
358-
for the helper-as-sub-agent pattern that motivated the API.
358+
for the agent-tool orchestration pattern that motivated the API, and
359+
[Agent Tools](./agent-tools.md) for using `AIChatAgent` and Think subclasses as
360+
retained, streaming tools.
359361

360362
### `onChatResponse`
361363

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
- TODO: [AI SDK Integration](./ai-sdk.md) - Using Vercel AI SDK with agents
4444
- TODO: [TanStack Integration](./tanstack.md) - Using TanStack AI with agents
4545
- [Chat Agents](./chat-agents.md) - `AIChatAgent` class and `useAgentChat` React hook
46+
- [Agent Tools](./agent-tools.md) - Run chat-capable sub-agents as tools with streaming child timelines
4647
- [Server-Driven Messages](./server-driven-messages.md) - Autonomous agent workflows: scheduled follow-ups, queue processing, webhooks, chained reasoning
4748
- TODO: [Using AI Models](./using-ai-models.md) - OpenAI, Anthropic, Workers AI, and other providers
4849
- TODO: [RAG (Retrieval Augmented Generation)](./rag.md) - Vector search with Vectorize

docs/server-driven-messages.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,10 +416,10 @@ Pre-aborted signals short-circuit before any model work runs.
416416

417417
### Limitations
418418

419-
- **Signals cannot cross Durable Object boundaries.** `AbortSignal` is not an RPC-serializable type. Construct the controller inside the DO that calls `saveMessages`. To bridge a parent's abort intent into a child DO, return a `ReadableStream` from the child and let the parent cancel it — workerd propagates the cancel back to the source's `cancel` callback. See `examples/agents-as-tools` for the canonical helper-as-sub-agent pattern.
419+
- **Signals cannot cross Durable Object boundaries.** `AbortSignal` is not an RPC-serializable type. Construct the controller inside the DO that calls `saveMessages`. For Think child-agent orchestration, use [Agent Tools](./agent-tools.md); `runAgentTool()` bridges parent aborts into the child run. For lower-level custom RPC, return a `ReadableStream` from the child and let the parent cancel it — workerd propagates the cancel back to the source's `cancel` callback.
420420
- **Hibernation drops the listener.** The signal lives in memory. If the DO hibernates mid-turn and `chatRecovery` is enabled, the recovered turn calls `continueLastTurn()` internally without the original signal — an abort fired after restart has no effect on the recovered turn. This is true for top-level agents and sub-agents; sub-agent recovery still works, but the original caller's in-memory signal is gone. Override `onChatRecovery` (Think) or set `chatRecovery = false` for callers that need stronger guarantees.
421421

422-
This is the integration point for helper-as-sub-agent patterns where the parent's AI SDK abort signal needs to propagate into a child DO's `saveMessages` call. See [`cloudflare/agents#1406`](https://github.com/cloudflare/agents/issues/1406) for the original use case.
422+
This is the integration point for agent-tool orchestration where the parent's AI SDK abort signal needs to propagate into a child DO's `saveMessages` call. See [`cloudflare/agents#1406`](https://github.com/cloudflare/agents/issues/1406) for the original use case.
423423

424424
## Important notes
425425

docs/sub-agents.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ Sub-agents are child Durable Objects colocated under a parent agent. Each sub-ag
44

55
Use sub-agents when a single user or entity owns an open-ended set of long-lived agents — chats, documents, sessions, shards, projects — and you want each one to run in parallel with its own state while keeping one parent agent as the coordinator.
66

7+
If you want a parent chat agent to dispatch another chat-capable agent during a
8+
single turn and render that child's progress inline, use [Agent Tools](./agent-tools.md).
9+
Agent tools are built on sub-agents, but add a parent-side run registry,
10+
streaming `agent-tool-event` frames, replay, cancellation, and cleanup.
11+
712
## Overview
813

914
```typescript
@@ -355,6 +360,7 @@ See [`examples/multi-ai-chat`](https://github.com/cloudflare/agents/tree/main/ex
355360
## Related
356361

357362
- [Think sub-agents and programmatic turns](./think/sub-agents.md) — Think's `chat()` RPC method for streaming from a parent to a Think-based child
363+
- [Agent Tools](./agent-tools.md) — run Think or `AIChatAgent` sub-agents as tools with inline streaming child timelines
358364
- [Long-running agents](./long-running-agents.md) — how sub-agents fit alongside `schedule`, `runFiber`, and workflows
359365
- [Callable methods](./callable-methods.md)`@callable` methods work unchanged on sub-agents
360366
- [Scheduling](./scheduling.md) — scheduling primitives for top-level agents and sub-agents

0 commit comments

Comments
 (0)