Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/agent-tools-capability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
"agents": minor
"@cloudflare/think": minor
"@cloudflare/ai-chat": minor
---

Move agent tools into a Lifecycle capability with a parent role and a child role.

**Existing code keeps compiling and running.** `runAgentTool`, `cancelAgentTool`, `hasAgentToolRun`, `clearAgentToolRuns`, `maxConcurrentAgentTools`, the `onAgentToolStart` / `onAgentToolFinish` / `onProgress` hooks, `agentTool()`, the `agent-tool-event` wire frames and `useAgentToolEvents()` are unchanged. `Agent` now installs the `AgentTools` capability as `this.agentTools` and forwards to it. `Think` and `AIChatAgent` install `AgentToolsChild` as `this.agentToolsChild`; their `startAgentToolRun` / `inspectAgentToolRun` / `tailAgentToolRun` / `getAgentToolChunks` / `cancelAgentToolRun` methods and the `formatAgentToolInput` / `getAgentToolOutput` / `getAgentToolSummary` / `formatDetachedCompletion` / `formatDetachedMilestone` hooks keep their signatures.

**New policy options**

- `agentToolReplayOnConnect: { maxRuns, maxChunksPerRun }` (static option;
`replayOnConnect` on `AgentToolsOptions`) bounds what one reconnecting client
is replayed: the newest N runs, and the last N stored chunks of each. Dropped
chunks still advance the frame sequence, so retained frames keep the sequence
numbers an uncapped replay would use and `useAgentToolEvents()` dedupes as
before. Both default to `Infinity` (today's behaviour); retention is unchanged.
- `maxConcurrentDetachedAgentTools` (live `Agent` field; `maxConcurrentDetached`
on `AgentToolsOptions`) caps non-terminal DETACHED runs separately, inside the
`maxConcurrentAgentTools` total. Default `Infinity`. A detached dispatch over
the cap fails exactly like the total cap does — synchronous `error` result and
row, `started` + `error` events, no child spawned.

**What changed underneath**

- The detached reconcile backbone is one singleflight Lifecycle job instead of a self-scheduling `_cfDetachedReconcileTick` schedule. Anything that listed schedules to observe it should read `agentTools.pendingDetachedReconcile()`.
- The backbone goes quiet after a give-up that tore its child down: the run stays outstanding for two top-cadence ticks so a racing late completion can still repair it, then stops re-arming. A soft no-progress give-up that left the child running is torn down once the absolute budget passes. A durable `onFinish` that throws is reported through `onError` and no longer skips the sibling runs or the re-arm in that tick.
- `cf_agent_tool_runs` is owned by the capability under its own schema version. Existing tables converge in place; new tables omit the never-read `input_redacted` column.
- Child-run chunk attribution is an explicit `observeChunk` / `observeError` tap from the harness's frame sender instead of a `broadcast()` override that parsed every outgoing frame. `interceptAgentToolBroadcast` and `AgentToolBroadcastHooks` are removed from `agents/chat`.
- Think and AIChatAgent share one child implementation. `AIChatAgent`'s `cf_ai_chat_agent_tool_runs` and `cf_ai_chat_agent_tool_milestones` rows fold into `cf_agent_tool_child_runs` and `cf_agent_tool_milestones` on first wake and the legacy tables are dropped. A Think turn that was skipped before running is sealed as `error` with a clear message, as before.
- Any `Agent` subclass can become an agent-tool child by installing `AgentToolsChild` and binding `setAgentToolsChildHost`.
- `AgentTools` exposes only its operations: `run`, `cancel`, `has`, `clear`, `replayToConnection`, `recoveryRunIds`, `scheduleStartupRecovery`, `reconcile`, `hasOutstandingDetachedRuns`, `pendingDetachedReconcile`, `armDetachedBackbone`, `reconcileTick`. The storage and streaming internals (`readRun`, `resultFromRow`, `updateTerminal`, `deliverDetachedTerminal`, `reattachToTerminal`, `forwardStream`, `broadcastStoredChunksFromAdapter`, `runDeferredFinishHooks`) are private; deferred finish hooks are drained inside `scheduleStartupRecovery`, their only caller. `getAgentToolsHost` is exported alongside `setAgentToolsHost` so a host can re-install a wrapped port.
- A milestone reached while the parent is tailing a detached child live is now delivered on the warm path instead of waiting for a reconcile tick (the run row read while forwarding did not project the run's milestone configuration).
- A child run that ends in `error`, `aborted` or `skipped` no longer calls the harness's `getAgentToolOutput` / `getAgentToolSummary` hooks, so overrides with completion side effects only run for a completed turn. Such a row carries no summary, matching what the parent already surfaced for a non-completed inspection.
- `AIChatAgent` deployments whose legacy `cf_ai_chat_agent_tool_runs` table predates the progress columns now fold in correctly instead of throwing during startup.
124 changes: 120 additions & 4 deletions docs/agents/agent-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,28 +262,65 @@ async onImportDone(run: AgentToolRunInfo, result: AgentToolLifecycleResult) {
}
```

> The parent-side engine behind these methods lives in the `AgentTools`
> Lifecycle capability, installed on every Agent as `this.agentTools`; it owns
> the `cf_agent_tool_runs` table and its own schema version. `runAgentTool`,
> `cancelAgentTool`, `hasAgentToolRun` and `clearAgentToolRuns` are thin
> facades over it, and the hooks (`onAgentToolStart`, `onAgentToolFinish`,
> `onProgress`) stay on the Agent.

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.
path delivers with low latency while the isolate is alive, and a reconcile
backbone — one self-re-arming Lifecycle job on an escalating 5s/15s/30s/120s
cadence, which completes once nothing is outstanding — 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.
the real result — a premature give-up never hides a late completion. When the
give-up managed to tear the child down, the backbone keeps watching for two
more top-cadence ticks (in case a completion was already racing the cancel)
and then lets the run settle. A give-up that could not reach the child leaves
it running and is torn down once the absolute ceiling passes. One run's
failing `onFinish` is reported through `onError` and never blocks its
siblings.
- **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.
cannot hold a concurrency 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"
```

### Bound how many runs a parent owns

Two live fields cap concurrent, non-terminal runs. Both default to `Infinity`
and are read on every dispatch, so a subclass may reassign them at any time:

```ts
class Assistant extends Agent<Env> {
override maxConcurrentAgentTools = 8; // every run, awaited or detached
override maxConcurrentDetachedAgentTools = 2; // background runs only
}
```

A detached run counts toward **both** budgets. A dispatch that would exceed
either fails the same way: a synchronous `error` result naming the cap it hit,
an `error` run row, `started` + `error` events, and no child spawned.

Cap detached runs separately because they hold their slot for their entire life
and have no awaiting turn to notice them piling up — without a detached-only cap,
a leak in background work starves the foreground turns that share the total
budget. The framework also warns once when 50 detached runs are live at the same
time.

### Notify the chat on completion (Think / AIChatAgent)

On a chat agent (`@cloudflare/think` or `AIChatAgent`) you usually want the model
Expand Down Expand Up @@ -322,6 +359,57 @@ watching within budget", never as a terminal failure. Only a non-`null`
inspection with a terminal `status` (`completed` / `error` / `aborted`)
finalizes a run.

### Make any Agent a child

`Think` and `AIChatAgent` are children out of the box. Any other `Agent`
subclass can be driven as an agent tool by installing the `AgentToolsChild`
capability and binding a small host port that says how to run one turn and
where its streamed chunks come from:

```ts
import { Agent, AgentToolsChild, setAgentToolsChildHost } from "agents";

export class Worker extends Agent<Env> {
readonly agentToolsChild = new AgentToolsChild();

constructor(ctx: AgentContext, env: Env) {
super(ctx, env);
this.lifecycle.use(this.agentToolsChild);
setAgentToolsChildHost(this.agentToolsChild, {
runTurn: ({ requestId, message, signal }) =>
this.runOneTurn(requestId, message, signal)
// ... stream store, messages, formatting hooks
});
}

// The parent calls these over RPC; forward them to the capability.
startAgentToolRun(input: unknown, options: { runId: string }) {
return this.agentToolsChild.startAgentToolRun(input, options);
}
cancelAgentToolRun(runId: string, reason?: unknown) {
return this.agentToolsChild.cancelAgentToolRun(runId, reason);
}
inspectAgentToolRun(runId: string) {
return this.agentToolsChild.inspectAgentToolRun(runId);
}
getAgentToolChunks(runId: string, options?: { afterSequence?: number }) {
return this.agentToolsChild.getAgentToolChunks(runId, options);
}
tailAgentToolRun(
runId: string,
options?: { afterSequence?: number; signal?: AbortSignal }
) {
return this.agentToolsChild.tailAgentToolRun(runId, options);
}
}
```

The child capability owns `cf_agent_tool_child_runs` and
`cf_agent_tool_milestones`. Chunk attribution is explicit: wherever the host
sends a chat response frame to clients, it also calls
`agentToolsChild.observeChunk(requestId, body)` (or `observeError` for an error
frame) so live tailers and the durable chunk log stay on one sequence line.

## Report progress and milestones

A sub-agent running as an agent tool — awaited or detached — can report mid-run
Expand Down Expand Up @@ -489,6 +577,34 @@ for (const message of messages) {
Imperative runs without a parent tool call are available as
`agentTools.unboundRuns`.

### Cap what a reconnect replays

On every (re)connect the parent replays its retained runs to that client as
`replay: true` frames. By default that is **every** retained run with **all** of
its stored chunks, which can be a large burst for a parent that has accumulated
many runs or long child transcripts. Bound it with the `agentToolReplayOnConnect`
static option:

```ts
class Assistant extends Agent<Env> {
static options = {
agentToolReplayOnConnect: { maxRuns: 20, maxChunksPerRun: 200 }
};
}
```

- `maxRuns` — replay only the newest N runs by start time. A run that is cut
sends no frames at all; it is not part of the replayed timeline.
- `maxChunksPerRun` — replay only the **last** N stored chunks of each run. The
tail is what a reconnecting client needs to render current state, and the
dropped chunks still advance the frame sequence, so the frames that are sent
carry the same sequence numbers an uncapped replay would use and the hook's
live-vs-replay dedupe is unaffected.

Both default to `Infinity` (no cap). Capping the replay never deletes anything —
the runs and their child facets are still retained, and a client can still drill
into a cut run. Use `clearAgentToolRuns()` to actually drop runs.

## Drill in and gate access

Agent tools are normal sub-agents. Connect to a retained child through the
Expand Down
Loading
Loading