- Agents as tools·续
Agents as tools·续
export class ImportAgent extends Think {
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",
});
// ...
},
}),
};
}
}
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.
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:
export class Assistant extends Think {
async onProgress(run, progress) {
if (progress.milestone) {
// A durable milestone landed — branch on it.
}
console.log(run.runId, progress.phase, progress.fraction);
}
}
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:
await this.reportProgress({
milestone: "sources-gathered",
data: { sources: 2 },
});
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:
// "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" } },
});
// "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.
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.
}
}
}
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
Agents as tools are normal sub-agents. Connect to a retained child through the parent route:
useAgent({
agent: "Assistant",
name: userId,
sub: [{ agent: "Researcher", name: runId }],
});
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:
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:
await this.clearAgentToolRuns();
await this.clearAgentToolRuns({
status: ["completed", "error", "aborted", "interrupted"],
});
await this.clearAgentToolRuns({ olderThan: Date.now() - 7 * 24 * 60 * 60_000 });
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 or eviction) while a child run is still starting or running, it does not abandon the child. Startup recovery re-attaches to the live child and tails its stream to the child's terminal result. Because the child is a sub-agent with its own chatRecovery, it self-heals its own interrupted turn while the parent forwards its output. A completed child is finalized without re-running 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 samerunIdcan re-attach and collect it if it self-heals. - If you set a finite
agentToolReattachMaxWindowMsand it fires, the run is sealedreason: "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
reasonso the parent tool call returns a structured failure instead of hanging indefinitely.
A hung child can never block recovery forever. The no-progress budget bounds a silent child. A content runaway is bounded by the child's own chatRecovery (maxRecoveryWork and shouldKeepRecovering), not by a parent-only timer.
Monitor parent reconciliation through the agentTool observability channel:
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);
}
});
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.
Example
Agents as tools example
Run chat-capable sub-agents as retained tools, stream their timelines inline, and drill into child agents.
Related
Sub-agents
Spawn child agents with isolated storage, typed RPC, and nested client routing.
Chat agents
Build AI chat interfaces with AIChatAgent and useAgentChat.
Was this helpful?
YesNo
On this page
{"@context":"https://schema.org","@type":"TechArticle","@id":"https://developers.cloudflare.com/agents/runtime/execution/agent-tools/#page","headline":"Agents as tools","description":"Run Think and AIChatAgent sub-agents as retained, streaming tools from a parent agent.","url":"https://developers.cloudflare.com/agents/runtime/execution/agent-tools/","inLanguage":"en","image":"https://developers.cloudflare.com/agents/runtime/execution/agent-tools/og.png?v=b728c84fec3af14d","dateModified":"2026-06-26","publisher":{"@type":"Organization","name":"Cloudflare","description":"One platform for your apps, agents, and workforce. Build, secure, and scale without managing infrastructure","url":"https://www.cloudflare.com/","sameAs":["https://github.com/cloudflare","https://www.linkedin.com/company/cloudflare","https://x.com/cloudflare"],"logo":{"@type":"ImageObject","url":"https://developers.cloudflare.com/logo.svg"},"address":{"@type":"PostalAddress","streetAddress":"101 Townsend St","addressLocality":"San Francisco","addressRegion":"CA","postalCode":"94107","addressCountry":"US"},"contactPoint":[{"@type":"ContactPoint","contactType":"Customer Support","url":"https://support.cloudflare.com/","availableLanguage":["English"]},{"@type":"ContactPoint","contactType":"Sales","url":"https://www.cloudflare.com/contact/","availableLanguage":["English"]}]},"isPartOf":{"@type":"WebSite","@id":"https://developers.cloudflare.com/#website","name":"Cloudflare Docs","url":"https://developers.cloudflare.com/"}}
description: Run work that survives Durable Object eviction with runFiber(), startFiber(), keepAlive(), and crash recovery. title: Durable execution with fibers image: https://developers.cloudflare.com/agents/runtime/execution/durable-execution/og.png?v=a2889c46d9e8ecae
Documentation Index
Fetch the complete documentation index at: https://developers.cloudflare.com/agents/llms.txt
Use this file to discover all available pages before exploring further.
Docs