agents
Version:
A home for your AI agents
583 lines (487 loc) • 22.7 kB
Markdown
# Agent Tools
Agent tools let one chat agent dispatch another chat-capable sub-agent as part
of its work — per-run, parent-supervised delegation is the flagship use case
for facet-backed dynamic agents. The child is a real sub-agent with its own Durable Object storage,
messages, tools, resumable stream, and drill-in URL. The parent keeps a small
run registry so clients can render the child timeline, replay it after refresh,
and clean it up later.
Agent tools support `@cloudflare/think` agents and `AIChatAgent` subclasses.
`AIChatAgent` children run headlessly through `saveMessages()`, so they should
use server-side tools. Browser-provided client tools are not available during an
agent-tool turn unless you model that interaction as server-side state or a
separate parent-mediated workflow.
For Think children, prefer agent tools when the parent model or workflow
delegates work and you want retained child runs, event replay, abort bridging,
and UI drill-in. Use raw `subAgent(...).chat()` only for lower-level streaming
RPC where your code owns forwarding, cancellation, and replay policy. For the
full comparison, see [Choosing a turn API](https://github.com/cloudflare/agents/blob/main/docs/think/index.md#choosing-a-turn-api).
## Use an Agent as an AI SDK tool
Use `agentTool()` when the parent model should decide when to call the helper.
```ts
import { Think } from "@cloudflare/think";
import { agentTool } from "agents/agent-tools";
import { z } from "zod";
export class Researcher extends Think<Env> {
getSystemPrompt() {
return "Research the user's topic and end with a concise summary.";
}
}
export class Assistant extends Think<Env> {
getTools() {
return {
research: agentTool(Researcher, {
description: "Research one topic in depth.",
displayName: "Researcher",
inputSchema: z.object({
query: z.string().min(3)
})
})
};
}
}
```
### Use Valibot or another schema library
`agentTool()` accepts the AI SDK's flexible schema format. For Valibot, wrap the
schema with `valibotSchema()` so the AI SDK receives both runtime validation and
the JSON Schema required by the model. Use `@ai-sdk/valibot` v2 with AI SDK 6
and v3 with AI SDK 7:
```ts
import { valibotSchema } from "@ai-sdk/valibot";
import { agentTool } from "agents/agent-tools";
import * as v from "valibot";
const researchInput = valibotSchema(
v.object({
query: v.pipe(v.string(), v.minLength(3))
})
);
const research = agentTool(Researcher, {
description: "Research one topic in depth.",
inputSchema: researchInput
});
```
You can also provide a Zod schema, a Standard JSON Schema-compatible schema, or
a raw JSON Schema wrapped with `jsonSchema()` from `ai`. A validation-only
Standard Schema is not sufficient for a tool input because it does not provide
the JSON Schema sent to the model.
The child can also be an `AIChatAgent`:
```ts
import { AIChatAgent } from "@cloudflare/ai-chat";
import { agentTool } from "agents/agent-tools";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { z } from "zod";
export class Summarizer extends AIChatAgent<Env> {
protected override formatAgentToolInput(input: { text: string }, request) {
return {
id: `agent-tool-${request.runId}-input`,
role: "user",
parts: [{ type: "text", text: `Summarize:\n\n${input.text}` }]
};
}
async onChatMessage() {
const result = streamText({
model: this.env.MODEL,
messages: await convertToModelMessages(this.messages)
});
return result.toUIMessageStreamResponse();
}
}
export class Assistant extends AIChatAgent<Env> {
async onChatMessage() {
const result = streamText({
model: this.env.MODEL,
messages: await convertToModelMessages(this.messages),
tools: {
summarize: agentTool(Summarizer, {
description: "Summarize long text in a separate retained agent.",
inputSchema: z.object({ text: z.string() })
})
},
stopWhen: stepCountIs(5)
});
return result.toUIMessageStreamResponse();
}
}
```
The generated tool calls `this.runAgentTool(ChildAgent, ...)`, streams
`agent-tool-event` frames on the parent WebSocket, and returns the child
summary to the parent model. If the run fails, aborts, or is interrupted, the
tool returns a structured `AgentToolFailure` instead of an empty success value:
```ts
type AgentToolFailure = {
ok: false;
status: "error" | "aborted" | "interrupted";
error: string; // human-readable, safe to surface
retryable: boolean;
// Present only when `status` is "interrupted":
reason?: AgentToolInterruptedReason;
childStillRunning?: boolean;
};
type AgentToolInterruptedReason =
| "no-progress"
| "window-exceeded"
| "not-tailable"
| "inspect-timeout"
| "inspect-failed"
| "recovery-deadline"
| "budget-exceeded";
```
`retryable` is `true` only for an `interrupted` run — the child was reset or
superseded by a deploy or parent recovery and never reached a logical outcome,
so re-dispatching the same call can succeed. A genuine `error` or an intentional
`aborted` is `retryable: false`. This lets a parent prompt convention or an
orchestration harness re-run a transient interruption rather than reporting it
to the user as a final failure. `AgentToolFailure` is exported from `agents`.
On an `interrupted` run, `reason` gives a machine-readable cause and
`childStillRunning` reports whether the child was still working when the parent
stopped waiting (`true`) or has since been torn down (`false`). Branch on these
instead of parsing the `error` prose — for example, re-dispatch a `no-progress`
interrupt (the child may still self-heal) but reconnect to or surface a
`window-exceeded` one (the child was torn down). Both `reason` and
`childStillRunning` are also mirrored onto the `agent-tool-event` wire frame and
the `useAgentToolEvents()` run state.
For `Think` children that do workflow-style work without user-facing assistant
text, override `getAgentToolOutput()` and, if needed, `getAgentToolSummary()`.
Assistant text remains the default summary when present, but a Think agent-tool
run can complete successfully without emitting text chunks.
Persist any structured output before the child turn finishes, because
`getAgentToolOutput()` is read as soon as `saveMessages()` resolves. Keep
`getAgentToolSummary()` concise for display; the full structured value is stored
separately as the tool output.
```ts
export class Extractor extends Think<Env> {
protected override getAgentToolOutput(runId: string) {
const rows = this.sql<{ result_json: string }>`
SELECT result_json FROM extraction_runs WHERE id = ${runId}
`;
return rows[0] ? JSON.parse(rows[0].result_json) : undefined;
}
protected override getAgentToolSummary(_runId: string, output: unknown) {
return output ? "Extraction complete" : "";
}
}
```
## Run an Agent tool imperatively
Use `runAgentTool()` for deterministic workflows, scheduled work, HTTP
handlers, or fan-out code.
```ts
const [a, b] = await Promise.allSettled([
this.runAgentTool(Researcher, {
input: { query: "HTTP/3" },
parentToolCallId: toolCallId,
displayOrder: 0
}),
this.runAgentTool(Researcher, {
input: { query: "gRPC" },
parentToolCallId: toolCallId,
displayOrder: 1
})
]);
```
`runAgentTool()` is idempotent by `runId`. Passing the same `runId` never starts
a duplicate child turn. Completed, failed, aborted, and interrupted runs are
retained until you explicitly clear them.
## Detached (background) runs
By default `runAgentTool()` **awaits** the child to terminal before returning.
For long-running work — large imports, video renders, deep research — that you
do not want to block the dispatching turn on, pass `detached`. The run is
dispatched, the current turn continues, and `runAgentTool()` returns a handle
immediately:
```ts
type DetachedRunAgentToolResult = {
runId: string;
agentType: string;
status: "running" | "error"; // "error" only if dispatch itself was rejected
};
```
`detached: true` is fire-and-forget — observe the run through `agent-tool-event`
frames (the same ones `useAgentToolEvents()` consumes) and the global
`onAgentToolFinish()` hook. Pass an object to wire a targeted, durable
completion callback:
```ts
async startImport(input: ImportInput) {
const { runId } = await this.runAgentTool(ImportAgent, {
input,
detached: { onFinish: "onImportDone", maxBudgetMs: 60 * 60 * 1000 }
});
return runId;
}
// Fires once, even if the Durable Object was evicted and rehydrated while the
// child ran. Referenced by METHOD NAME (like schedule()) — never a closure,
// which cannot survive eviction.
async onImportDone(run: AgentToolRunInfo, result: AgentToolLifecycleResult) {
switch (result.status) {
case "completed":
await this.markImportReady(run.runId, result.summary);
break;
case "error":
await this.markImportFailed(run.runId, result.error);
break;
case "interrupted":
// reason "budget-exceeded" ⇒ the run hit its maxBudgetMs ceiling.
// interrupted is soft: a child that finishes anyway re-fires this hook
// with "completed", so make the handler idempotent.
break;
}
}
```
Key behaviors:
- **Durable completion.** Delivery survives eviction and deploys: a warm fast
path delivers with low latency while the isolate is alive, and a
self-scheduling reconcile backbone finalizes anything the fast path missed.
Delivery is exactly-once on the happy path; under a crash it is at-least-once,
so `onFinish` handlers must be idempotent.
- **Give-up vs. finish are independent.** A budget give-up is delivered as
`status: "interrupted"`, `reason: "budget-exceeded"`. Because `interrupted` is
soft, a child that completes after the give-up still re-fires `onFinish` with
the real result — a premature give-up never hides a late completion.
- **Bounded.** Every detached run has an absolute `maxBudgetMs` ceiling
(per-run, or the `detachedMaxBudgetMs` static option; default 24h). On expiry
the parent gives up watching and tears the child down so an abandoned run
cannot hold a `maxConcurrentAgentTools` slot forever.
- **No inherited signal.** A detached run must outlive the spawning turn, so it
does **not** inherit `options.signal`. Cancel it explicitly:
```ts
await this.cancelAgentTool(runId); // idempotent; delivers onFinish "aborted"
```
### Notify the chat on completion (Think / AIChatAgent)
On a chat agent (`@cloudflare/think` or `AIChatAgent`) you usually want the model
to _react_ to a finished background run. Instead of wiring `onFinish` by hand,
pass `notify: true` — when the run finishes the agent injects a message into the
chat (idempotent per run + status, so an exactly-once finish never duplicates)
and the model takes its next turn with the result in context:
```ts
await this.runAgentTool(ResearchAgent, { input, detached: { notify: true } });
```
If your app routes or hides synthetic messages by `metadata.source`, pass your
own source:
```ts
await this.runAgentTool(ResearchAgent, {
input,
detached: { notify: { source: "research-background" } }
});
```
Override `formatDetachedCompletion(run, result)` to customize the injected text,
or return an empty string to suppress the notification for a given outcome. An
explicit `onFinish` takes precedence over `notify`.
### The `inspectAgentToolRun` contract
A child's `inspectAgentToolRun(runId)` returns the run's current status snapshot,
or `null`. **`null` does not mean "failed"** — it means the child has no record
of that run _yet_. This is normal immediately after dispatch (the child may
still be persisting its first row) and is also what a freshly-rehydrated child
returns before it has lazily reconciled a stale `running` row. Callers — and the
framework's own reconcile backbone — treat `null` as "not terminal, keep
watching within budget", never as a terminal failure. Only a non-`null`
inspection with a terminal `status` (`completed` / `error` / `aborted`)
finalizes a run.
## Report progress and milestones
A sub-agent running as an agent tool — awaited or detached — can report mid-run
progress so a parent can render a live status line, meter the run server-side, or
react to a named checkpoint before the run finishes. Call `reportProgress()` from
inside the child (for example, from a tool's `execute`):
```ts
export class ImportAgent extends Think<Env> {
getTools() {
return {
ingest: tool({
inputSchema: z.object({ url: z.string() }),
execute: async ({ url }) => {
// Ephemeral progress: drives a generic bar / phase / status line.
await this.reportProgress({
fraction: 0.6,
phase: "ingesting",
message: "Ingested 40k/80k rows"
});
// ...
}
})
};
}
}
```
`reportProgress()` is available on chat agents (`@cloudflare/think` and
`AIChatAgent`). It is a no-op with a development warning on the base `Agent`
class and when called outside an active agent-tool run, so the same child code is
safe to run standalone. The framework resolves the active run from the current
turn — you never thread a run id.
```ts
reportProgress<T>(
progress: {
fraction?: number; // 0..1 — drives a progress bar
message?: string; // human-readable status line
phase?: string; // coarse phase label, e.g. "ingesting"
milestone?: string; // present ⇒ a durable milestone (see below)
data?: T; // app-specific payload; live-only unless persisted
},
options?: { persist?: boolean }
): Promise<void>;
```
Ephemeral signals ride the child's own turn stream as a transient
`data-agent-progress` part, so they re-broadcast to the parent's connected
clients and surface on `AgentToolRunState.progress` through `useAgentToolEvents()`
— a background-runs tray can render a live bar, phase, and status line without
drilling in. Bursts are coalesced (latest-wins; a `fraction >= 1` frame always
flushes). The `data` field is live-only unless you pass `{ persist: true }`.
### Observe progress on the parent
Override `onProgress()` to meter, steer, or surface progress server-side. It
fires best-effort whenever a child progress signal is forwarded through the
parent, for both awaited and detached runs:
```ts
export class Assistant extends Think<Env> {
override async onProgress(
run: AgentToolRunInfo,
progress: AgentToolProgressSnapshot
) {
if (progress.milestone) {
// A durable milestone landed — branch on it.
}
console.log(run.runId, progress.phase, progress.fraction);
}
}
```
`onProgress()` is not durable: after eviction a detached run's latest snapshot is
reconstructed from `inspectAgentToolRun().progress` on reconcile rather than
re-firing the hook. The latest snapshot is also persisted on the child run row,
so a rehydrated parent can answer "where is this run" without having tailed the
live stream.
### Durable milestones
Naming a `milestone` promotes a signal from the ephemeral tier to a **durable**
one — there is still only one emit method:
```ts
await this.reportProgress({
milestone: "sources-gathered",
data: { sources: 2 }
});
```
A milestone is persisted as one row on the child with a monotonic per-run
`sequence`, and rides the stream as a **persisted** `data-agent-milestone` part
(unlike transient progress). It therefore survives eviction, replays on drill-in,
and is surfaced — deduped by `sequence` — on `AgentToolRunState.milestones` and
`inspectAgentToolRun().milestones`. `onProgress()` fires for milestones too, with
`progress.milestone` set, so a consumer can branch on milestone versus ephemeral
progress.
### Notify the chat on a milestone (Think / AIChatAgent)
For a detached run on a chat agent, `detached: { onMilestones }` surfaces a chat
message when a configured milestone lands, _before_ the run finishes. Each
`(runId, name)` fires at most once — whether observed live or reconciled after
eviction — so the deterministic id collapses warm and cold delivery to
at-most-once:
```ts
// "narrate" (default): inject a synthetic assistant status line — no model turn.
await this.runAgentTool(Researcher, {
input,
detached: { onMilestones: ["sources-gathered"] }
});
// "react": post a user-role turn so the model responds (steer, start dependent
// work). Costs a model turn.
await this.runAgentTool(Researcher, {
input,
detached: { onMilestones: { names: ["needs-approval"], mode: "react" } }
});
```
Override `formatDetachedMilestone(run, milestone)` to customize the wording, or
return an empty string to suppress a given milestone. Synthetic narrate messages
carry `metadata.source`, so clients can render them as an agent event rather than
a human turn.
### Resetting no-progress budget for detached runs
Once a detached child has reported at least one signal, the reconcile backbone
gives up if the run then goes silent for `detachedNoProgressBudgetMs` (default 1
hour; per-run override via `detached: { noProgressBudgetMs }`). This surfaces as
`status: "interrupted"`, `reason: "no-progress"`. A child that never reports is
bounded only by the absolute `detachedMaxBudgetMs` ceiling — a run is never
given up on merely for being slow. Set `noProgressBudgetMs` to `0` or `Infinity`
to disable the resetting window for a run.
## Render child timelines in React
`useAgentToolEvents()` is a headless hook. It subscribes to the existing parent
connection, deduplicates replay/live races, applies child `UIMessageChunk`
bodies to message parts, and groups sibling runs by parent tool call id. Each
run state carries `progress` and `milestones`, so a background-runs tray can
render a live bar, phase, and milestone chips without drilling in.
```tsx
import { useAgent, useAgentToolEvents } from "agents/react";
import { useAgentChat } from "@cloudflare/ai-chat/react";
const agent = useAgent({ agent: "Assistant", name: userId });
const { messages } = useAgentChat({ agent });
const agentTools = useAgentToolEvents({ agent });
for (const message of messages) {
for (const part of message.parts) {
if (part.type === "tool-call") {
const runs = agentTools.getRunsForToolCall(part.toolCallId);
// Render the child runs beside this tool call.
}
}
}
```
Imperative runs without a parent tool call are available as
`agentTools.unboundRuns`.
## Drill in and gate access
Agent tools are normal sub-agents. Connect to a retained child through the
parent route:
```ts
useAgent({
agent: "Assistant",
name: userId,
sub: [{ agent: "Researcher", name: runId }]
});
```
Gate external access with the parent registry so guessed run ids cannot spawn
fresh child facets:
```ts
override async onBeforeSubAgent(_request, child) {
if (!this.hasAgentToolRun(child.className, child.name)) {
return new Response("Not found", { status: 404 });
}
}
```
## Clear retained runs
Runs and child facets are retained by default for refresh, drill-in, and later
inspection. Delete them explicitly when clearing chat history or applying your
own retention policy:
```ts
await this.clearAgentToolRuns();
await this.clearAgentToolRuns({
status: ["completed", "error", "aborted", "interrupted"]
});
await this.clearAgentToolRuns({ olderThan: Date.now() - 7 * 24 * 60 * 60_000 });
```
If a retained run is still `starting` or `running`, cleanup cancels the child
before deleting its facet.
## Interrupted runs and recovery
Agent-tool runs are retained in the parent. If the parent restarts (deploy,
eviction) while a child run is still `starting` or `running`, the parent does
not immediately abandon it. Startup recovery re-attaches to the live child and
tails its stream to the child's real terminal result: the child is a sub-agent
with its own `chatRecovery`, so it self-heals its interrupted turn while the
parent forwards its output and collects the final outcome. A completed child is
finalized in the parent without re-running already-finished work.
The re-attach wait is **progress-keyed**, not a fixed wall clock. Two static
`options` tune it:
| Option | Default | Behavior |
| -------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `agentToolReattachNoProgressTimeoutMs` | `120000` (2 min) | How long the parent waits with **no** forward progress before giving up. Resets on every forwarded chunk, so a streaming child is followed through to terminal. |
| `agentToolReattachMaxWindowMs` | `Infinity` | Optional hard wall-clock ceiling on a single re-attach. Uncapped by default (mirrors chat recovery's `maxRecoveryWork`), so a healthy, long-running child is never cut off. Set a finite value to impose a cap. |
Give-up outcomes map to the `AgentToolFailure` fields:
- A child that goes **silent** for a full no-progress window is sealed
`reason: "no-progress"`, `childStillRunning: true`. This seal is **soft** —
the child is left running, so re-dispatching the same `runId` can re-attach
and collect it if it self-heals.
- If you set a finite `agentToolReattachMaxWindowMs` and it fires, the run is
sealed `reason: "window-exceeded"`, `childStillRunning: false`, and the child
is **torn down** (it has had its full window and is treated as exhausted).
- A child that cannot be tailed or inspected, or that exceeds the overall
recovery deadline, is sealed with the matching `reason` so the parent tool
call returns a structured failure instead of hanging indefinitely.
A genuinely hung child can never block recovery forever: the no-progress budget
bounds a silent child, and a content-runaway is bounded uniformly (live and
recovery) by the child's own `chatRecovery` `maxRecoveryWork` /
`shouldKeepRecovering`, not by a parent-only timer.
Monitor parent reconciliation through the `agentTool` observability channel:
```ts
import { subscribe } from "agents/observability";
const unsubscribe = subscribe("agentTool", (event) => {
if (event.type === "agent_tool:recovery:row") {
console.log("Recovered agent-tool row", event.payload);
}
});
```
Raw `diagnostics_channel` subscribers should use the channel name
`agents:agent_tool`.