UNPKG

agents

Version:

A home for your AI agents

608 lines (508 loc) • 24 kB
# 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. ### Deliver only progress and the final result By default, the parent forwards every chunk of the child's stream to its clients, and the child broadcasts the same chunks to its own connections. When nobody watches the child's text as it streams (for example, a server-side pipeline that only reads the result), pass `eventDelivery: "terminal"`: ```ts const result = await this.runAgentTool(ResearchAgent, { input: { query: "Durable Objects" }, eventDelivery: "terminal" }); ``` The run still produces `started`, progress, milestone, and terminal (`finished`, `error`, `aborted`, or `interrupted`) events, and the result, summary, and output are unchanged. Ordinary child chunks are not forwarded to the parent's clients, are not replayed on reconnect, and are not broadcast by the child. The child still stores its own conversation, so `getAgentToolChunks()` keeps working. A client that drills in to the child sees the stored conversation when it connects and the final messages when the run ends, but does not see text stream live while the run is in flight. Use the default `eventDelivery` for runs you expect users to watch. Detached runs do not support `eventDelivery: "terminal"` and reject it. ## 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, so a healthy, long-running child is never cut off; a runaway child is bounded by its own `chatRecovery` budgets. 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`.