Skip to content
Draft
51 changes: 51 additions & 0 deletions .changeset/detached-agent-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
"agents": minor
---

Add first-class detached ("background") agent-tool runs with a durable
completion hook (cloudflare/agents#1752).

`runAgentTool(cls, { detached })` now dispatches a sub-agent **without blocking
the calling turn**, returning a `{ runId, agentType, status: "running" }` handle
immediately:

```ts
// Fire-and-forget — observe via agent-tool-event frames + onAgentToolFinish.
const { runId } = await this.runAgentTool(ImportAgent, {
input,
detached: true
});

// Or wire a durable, eviction-surviving completion callback (by METHOD NAME,
// like schedule()):
await this.runAgentTool(ImportAgent, {
input,
detached: { onFinish: "onImportDone", maxBudgetMs: 60 * 60 * 1000 }
});

async onImportDone(run: AgentToolRunInfo, result: AgentToolLifecycleResult) {
// Branch on result.status: "completed" | "error" | "aborted" | "interrupted".
// A budget give-up arrives as interrupted / reason "budget-exceeded".
}
```

Highlights:

- **Durable, exactly-once-on-the-happy-path completion.** A warm fast path
(low-latency while the isolate is alive) plus a self-scheduling reconcile
backbone (survives eviction / deploys) route through one guarded delivery
funnel. Two independent ledger slots (finish / give-up) with a claim+lease
mean a premature give-up can never dedupe a child's real late completion away.
- **No silent abandonment.** Detached runs are never sealed `interrupted` just
because their dispatching turn ended (the normal state for a background run);
the backbone owns them and re-arms on restart.
- **Bounded.** An absolute `maxBudgetMs` ceiling (default 24h, configurable via
the `detachedMaxBudgetMs` static option) gives up — surfaced as `interrupted`
with the new `budget-exceeded` reason — and tears the child down so an
abandoned run cannot hold a concurrency slot forever.
- **`cancelAgentTool(runId)`** cancels a detached (or awaited) run by id through
the same guarded path, so a wired `onFinish` still fires once with
`status: "aborted"`.

A detached run deliberately does NOT inherit `options.signal` (it must outlive
the spawning turn); cancel it explicitly with `cancelAgentTool`.
22 changes: 22 additions & 0 deletions .changeset/detached-think-notify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@cloudflare/think": minor
---

Add `detached: { notify: true }` support for `runAgentTool` on Think agents
(cloudflare/agents#1752).

When a detached sub-agent run finishes, Think can inject a message back into the
chat so the model reacts to the result — without you wiring `onFinish` by hand:

```ts
await this.runAgentTool(ResearchAgent, {
input,
detached: { notify: true }
});
```

The injected turn is idempotent per run + terminal status (built on
`submitMessages`), so an exactly-once finish never duplicates, while a soft
give-up followed by a real late completion surfaces as two distinct turns.
Override `formatDetachedCompletion(run, result)` to customize (or suppress) the
injected text.
1 change: 1 addition & 0 deletions design/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ Keep it concise. A few paragraphs is fine. These are records, not essays.
| `rfc-sub-agents.md` | RFC | Sub-agents — child DOs via facets, typed stubs, built into Agent (accepted) |
| `rfc-sub-agent-routing.md` | RFC | Sub-agent external addressability — nested URLs, `onBeforeSubAgent`, per-call bridge |
| `rfc-helper-sub-agent-orchestration.md` | RFC | Agent tool orchestration — `runAgentTool`, `agentTool`, event forwarding |
| `rfc-detached-agent-tools.md` | RFC | Detached ("background") agent-tool runs — `detached` mode, durable named-method completion hook |
| `rfc-think-multi-session.md` | RFC | Multi-session Think / Chats pattern — parent directory + per-chat child DOs |
| `rfc-chat-recovery-work-budget.md` | RFC | Decouple chat-recovery duration from the runaway guard — work budget + `shouldKeepRecovering` (accepted) |
| `rfc-ai-chat-maintenance.md` | RFC | AIChatAgent first-class stance, shared chat toolkit, multi-session example direction |
Expand Down
937 changes: 937 additions & 0 deletions design/rfc-detached-agent-tools.md

Large diffs are not rendered by default.

103 changes: 102 additions & 1 deletion docs/agent-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ type AgentToolInterruptedReason =
| "not-tailable"
| "inspect-timeout"
| "inspect-failed"
| "recovery-deadline";
| "recovery-deadline"
| "budget-exceeded";
```

`retryable` is `true` only for an `interrupted` run — the child was reset or
Expand Down Expand Up @@ -181,6 +182,106 @@ const [a, b] = await Promise.allSettled([
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 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 (via `submitMessages`, so it
is idempotent per run + status) and the model takes its next turn with the
result in context:

```ts
await this.runAgentTool(ResearchAgent, { input, detached: { notify: true } });
```

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.

## Render child timelines in React

`useAgentToolEvents()` is a headless hook. It subscribes to the existing parent
Expand Down
11 changes: 11 additions & 0 deletions examples/agents-as-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ This example now uses the shipped primitives:
- `agentTool(Researcher, ...)` for ordinary LLM-selected helper calls.
- `this.runAgentTool(Researcher, ...)` for explicit fan-out from the `compare`
tool.
- `this.runAgentTool(Researcher, { detached: { notify: true } })` for a
**background (detached)** run that returns immediately and posts its result
back into the chat when it finishes (see `research_background`).
- `this.cancelAgentTool(runId)` to stop a background run early.
- `useAgentToolEvents({ agent })` to collect `agent-tool-event` frames in React.
- `clearAgentToolRuns()` to delete retained child facets when the chat is
cleared.
Expand All @@ -28,6 +32,8 @@ Open the dev URL and ask for research or planning:
- _Find me three good arguments for and against monorepos._
- _What changed in HTTP/3 versus HTTP/2?_
- _Plan how I should add rate limiting to a Worker._
- _Research the history of TLS in the background — don't make me wait._
(dispatches a detached run; the result arrives as a follow-up message)

The assistant can call `research`, `plan`, or `compare`. Each call starts a real
Think sub-agent (`Researcher` or `Planner`) with its own model, tools, messages,
Expand Down Expand Up @@ -56,6 +62,11 @@ The important pieces are:
- **Parallel fan-out.** `compare` dispatches two `Researcher` runs with the same
parent tool call id and different display order values, so both panels render
under one tool part.
- **Background (detached) runs.** `research_background` calls `runAgentTool`
with `detached: { notify: true }`. The turn returns immediately with a run id
while the Researcher keeps working; when it finishes the framework injects the
result back into the chat (durably, even across parent eviction or reconnect)
so the model reacts to it. `cancelBackground(runId)` stops it early.
- **Drill-in.** Each panel has an open button that connects directly to
`/sub/{agent}/{runId}` with `useAgentChat`; it is the child agent's real chat,
not a synthetic event viewer.
Expand Down
42 changes: 42 additions & 0 deletions examples/agents-as-tools/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,11 @@ export class Assistant extends Think<Env> {
"You are a friendly, concise assistant.",
"Use `research` for deep background, `compare` for two-topic comparisons,",
"and `plan` for implementation/refactor planning.",
"When the user wants something investigated 'in the background' or asks you",
"not to wait, use `research_background`: it returns immediately with a run",
"id while the Researcher keeps working. Tell the user it is running and",
"that you will report back when it finishes — a follow-up message will",
"arrive automatically with the result, and you should react to it then.",
"After tools return, give the user a brief response grounded in the",
"agent tools' findings. If a branch reports an error, acknowledge it",
"instead of pretending it succeeded."
Expand All @@ -178,6 +183,33 @@ export class Assistant extends Think<Env> {
description: z.string().min(5)
})
}),
research_background: tool({
description:
"Dispatch a Researcher agent in the BACKGROUND (detached). Returns " +
"immediately with a run id; the result is injected back into the " +
"chat automatically when it finishes, even across reconnects.",
inputSchema: z.object({
query: z.string().min(3)
}),
execute: async ({ query }) => {
// Detached: does not block this turn, survives parent eviction, and
// `notify: true` posts the completion back into the chat so the model
// reacts to it later. `cancelBackground(runId)` can stop it early.
const dispatched = await this.runAgentTool<ResearchInput>(
Researcher,
{
input: { query },
display: { name: "Researcher" },
detached: { notify: true, maxBudgetMs: 5 * 60 * 1000 }
}
);
return {
status: "dispatched",
runId: dispatched.runId,
note: "Running in the background; I'll report back when it's done."
};
}
}),
compare: tool({
description:
"Dispatch two Researcher agents in parallel to compare related topics.",
Expand Down Expand Up @@ -247,6 +279,16 @@ export class Assistant extends Think<Env> {
async clearHelperRuns(): Promise<void> {
await this.clearAgentToolRuns();
}

/**
* Cancel a background (detached) run early. Idempotent: a no-op if the run
* already finished. Delivers the `notify` completion with an "aborted" status
* so the chat still reflects the outcome.
*/
@callable()
async cancelBackground(runId: string): Promise<void> {
await this.cancelAgentTool(runId);
}
}

export default {
Expand Down
77 changes: 75 additions & 2 deletions packages/agents/src/agent-tool-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,19 @@ export type AgentToolTerminalStatus = Extract<
* - `inspect-failed` — inspecting the child failed during parent recovery.
* - `recovery-deadline` — the overall parent-recovery deadline elapsed before
* this run could be reconciled.
* - `budget-exceeded` — a detached run's absolute `maxBudgetMs` ceiling elapsed
* before it reached a terminal. The parent gave up watching and tore the
* child down. Like `window-exceeded` this is a soft seal: a child that
* completes anyway can still repair the run and re-fire the completion hook.
*/
export type AgentToolInterruptedReason =
| "no-progress"
| "window-exceeded"
| "not-tailable"
| "inspect-timeout"
| "inspect-failed"
| "recovery-deadline";
| "recovery-deadline"
| "budget-exceeded";

/**
* Structured failure envelope an `agentTool()` returns when a sub-agent run
Expand Down Expand Up @@ -99,14 +104,82 @@ export type AgentToolLifecycleResult = {
childStillRunning?: boolean;
};

export type RunAgentToolOptions<Input = unknown> = {
/**
* Configuration for a detached ("background") agent-tool run. See
* `design/rfc-detached-agent-tools.md`.
*
* Callbacks are referenced by **method name** on the dispatching agent (the same
* durable, eviction-surviving pattern as `Agent.schedule`) — never closures,
* which cannot be rehydrated after the Durable Object is evicted.
*
* `Self` is threaded from `runAgentTool(cls, options)` so the method names are
* type-checked against the calling agent's own methods.
*/
export type DetachedAgentToolConfig<Self = Record<string, unknown>> = {
/**
* Method invoked once per terminal delivery. Branch on `result.status`:
* `"completed" | "error" | "aborted" | "interrupted"`. A budget give-up
* arrives as `status: "interrupted"` with `reason: "budget-exceeded"`; because
* `interrupted` is soft, a child that later completes can fire the hook again
* with `"completed"`, so a give-up never hides a late real result. Make the
* handler idempotent.
*/
onFinish?: Extract<keyof Self, string>;
/**
* Absolute safety ceiling — a backstop against a child that runs forever. On
* expiry the parent gives up watching (delivers `onFinish` with
* `interrupted` / `budget-exceeded`) and tears the child down. Defaults to the
* parent-level `detachedMaxBudgetMs`.
*/
maxBudgetMs?: number;
/**
* Chat-agent convenience (`@cloudflare/think` / `AIChatAgent`): when the run
* finishes, inject a message into the chat so the model can react to the
* result, instead of you wiring `onFinish` by hand. Sugar that auto-targets
* the agent's `_cfDetachedNotifyFinish` hook; ignored on a base `Agent` that
* does not implement it, and ignored when `onFinish` is also set (an explicit
* `onFinish` wins). Override `formatDetachedCompletion()` to customize the
* injected text.
*/
notify?: boolean;
};

export type RunAgentToolOptions<
Input = unknown,
Self = Record<string, unknown>
> = {
input: Input;
runId?: string;
parentToolCallId?: string;
displayOrder?: number;
signal?: AbortSignal;
inputPreview?: unknown;
display?: AgentToolDisplayMetadata;
/**
* Run the sub-agent **detached**: dispatch it, let the current turn continue,
* and (optionally) get a durable callback when it finishes. `true` is
* fire-and-forget (observe via `agent-tool-event` frames + the global
* `onAgentToolFinish` hook); an object adds the targeted, eviction-surviving
* `onFinish` callback. A detached run does NOT inherit `options.signal` — it
* must outlive the spawning turn; cancel it explicitly via `cancelAgentTool`.
*/
detached?: boolean | DetachedAgentToolConfig<Self>;
};

/**
* Result of dispatching a detached run. Returns immediately after dispatch
* rather than after completion.
*/
export type DetachedRunAgentToolResult = {
runId: string;
agentType: string;
/**
* `"running"` on a successful dispatch; `"error"` if dispatch itself failed
* (e.g. the `maxConcurrentAgentTools` cap was exceeded — rejected
* synchronously, no child started, no callback wired).
*/
status: "running" | "error";
error?: string;
};

export type RunAgentToolResult<Output = unknown> = {
Expand Down
Loading
Loading