feat(agents): agent tools as a Lifecycle capability with parent and child roles - #2227
feat(agents): agent tools as a Lifecycle capability with parent and child roles#2227mattzcarey wants to merge 11 commits into
Conversation
Move the agent-tool parent engine out of Agent into AgentTools, a LifecycleCapability that owns cf_agent_tool_runs under its own schema key and runs the detached reconcile backbone as a singleflight Lifecycle job instead of a self-scheduling Scheduler callback. Agent keeps runAgentTool, cancelAgentTool, hasAgentToolRun, clearAgentToolRuns and the hooks as thin facades over this.agentTools. Add AgentToolsChild, the child role, built from the Think and AIChatAgent implementations: one set of child-run and milestone tables, the adapter methods the parent calls, and an explicit observeChunk tap in place of the broadcast-snooping interceptor.
AIChatAgent installs AgentToolsChild, binds its host port, keeps the five adapter methods as facades, and taps chat frames explicitly from _broadcastChatMessage instead of overriding broadcast. The legacy cf_ai_chat_agent_tool_* tables fold into the shared child tables on start.
Think installs AgentToolsChild, binds its host port, keeps the adapter methods as facades, and taps chat frames from _broadcastChat instead of overriding broadcast. The child capability exposes activeRunForRequest so both harnesses decide stream cutover without reading its table.
Both harnesses now tap chat frames explicitly through AgentToolsChild, so the snooping interceptor and its host substrate have no callers. Document how any Agent subclass becomes an agent-tool child.
🦋 Changeset detectedLatest commit: e4ff9b2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🟡 agents import sizesMeasured 343 runtime imports as minified bundles. The primary size is gzip; raw minified size is included for diagnosis. An existing import growing by more than 10% is marked red. This report is informational.
Compared Changed imports (154)
All 342 current runtime imports
Reported by agent-think[bot]. |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
The parent agent-tool capability exposed eight storage/streaming internals (`readRun`, `resultFromRow`, `updateTerminal`, `deliverDetachedTerminal`, `reattachToTerminal`, `forwardStream`, `broadcastStoredChunksFromAdapter`, `runDeferredFinishHooks`) only because test workers monkey-patched them. They are now private; `runDeferredFinishHooks` is folded into the startup-recovery pass that was its only caller. The public surface is dispatch and operations: run, cancel, has, clear, replayToConnection, recoveryRunIds, scheduleStartupRecovery, reconcile, hasOutstandingDetachedRuns, pendingDetachedReconcile, armDetachedBackbone, reconcileTick. Every fault the tests injected through those seams now acts at a boundary the capability already depends on: a scripted CHILD adapter installed through the host port (`getAgentToolsHost` is now exported so a host or test can re-install a wrapped port), or a public operation — `reconcile` for recovery seals, `reconcileTick` for detached delivery, `run` for live forwarding. Parent rows are read with plain SQL in the test agents that already seed them.
`replayToConnection` sent every retained run with all of its stored chunks to each new connection — an unbounded burst for a parent that has accumulated many runs or long child transcripts. `AgentToolsOptions.replayOnConnect`, wired from the `agentToolReplayOnConnect` static option, bounds it: - `maxRuns` keeps the newest N runs by `started_at` (still emitted oldest-first); a cut run sends no frames at all. - `maxChunksPerRun` keeps the LAST N stored chunks of each run, and the dropped chunks still advance the frame sequence, so retained frames carry the same sequence numbers an uncapped replay would use and the client's live-vs-replay dedupe is unaffected. Both default to `Infinity`, so behaviour is unchanged unless set. Retention is untouched: a cut run is still stored and still drillable.
`maxConcurrent` counted awaited and detached runs together, so background work that piles up starves the foreground turns sharing the budget — and the only remedy was lowering the total cap for everything. `maxConcurrentDetached` (`AgentToolsOptions`) and `maxConcurrentDetachedAgentTools` (the live Agent field, read through the host port like the total cap) bound detached dispatches on their own. Default `Infinity`, so nothing changes unless set. Detached runs still count toward the total cap; the detached cap is checked after it. Either rejection is identical and synchronous: an `error` row, the `started` + `error` events, no child spawned, and a message naming the cap that was hit. The live-detached-count warning now points at the detached cap.
- The legacy `cf_ai_chat_agent_tool_runs` fold named `progress_json` and `last_signal_at` unconditionally, but `ai-chat` releases predating the progress work created that table without them (later releases added them by ALTER). Such a deployment upgrading straight to this version threw in `onStart` and never became reachable. The copied column list is now the intersection of the wanted columns with the legacy table's actual ones. - `readRun` omitted `detached_on_milestones`, which the warm-tail milestone delivery reads, so a milestone reached while the parent was tailing live was never notified — only the backbone tick delivered it (latent in the original `_readAgentToolRun` too). Fixed with the narrowing commit; the regression test lands here. - The child seal called the host's `output` / `summary` projections for every outcome, so user `getAgentToolOutput` / `getAgentToolSummary` overrides with completion side effects fired on error, aborted and skipped runs. They are now computed only for a `completed` seal, which also stops a non-completed row carrying a summary — matching what the parent surfaces for a non-completed inspection.
| const existing = this.#readRun(runId); | ||
| if (existing) { |
There was a problem hiding this comment.
🟡 Reused run ID changes child
Reusing runId with another class makes run() treat the existing row as that class's run. It can resolve the wrong child or return a false detached handle.
Learn more
A run ID is the primary key of cf_agent_tool_runs, so an existing row already fixes the child class in agent_type. The current branch checks only the ID, but later reattachment resolves agentType from the newly supplied class at the child lookup. Completed awaited runs instead return the old row, producing inconsistent behavior across statuses.
Example: A caller starts ResearchAgent with runId: "job-1", then calls run(SummaryAgent, { runId: "job-1" }). A running row causes the parent to resolve a SummaryAgent facet named job-1, although the recorded and active child is ResearchAgent. A detached retry immediately returns { agentType: "SummaryAgent", status: "running" } even though no such child started.
Recommended fix: When an existing row is found, compare existing.agent_type with cls.name. Reject a mismatch with a clear error, or consistently use the persisted class and return its identity. Rejecting is safer because the caller's requested class and input cannot be honored idempotently.
Was this helpful? React with 👍 or 👎 to provide feedback.
… React test The React replay test still called the removed seedInterruptedRunForTest callable; use sealInterruptedRunForTest, which drives the real reconcile path against a scripted silent child.
Summary
Extracts the agent-tool engine out of
Agent,ThinkandAIChatAgentinto one capability,agents/agent-tools, with a parent role (AgentTools) and a child role (AgentToolsChild). Public API, hooks, wire frames and the React hook are unchanged. Design spec: agent tools were the one concern in the two big classes that crosses Durable Object boundaries with a contract the base class dictates, so both chat harnesses had to re-implement the same ~700 lines of child side, ~85% of it verbatim.Sizes:
Agent−2.4k lines,Think−0.7k,AIChatAgent−0.7k. The capability is ~4.3k lines including the child role.What moved where
AgentTools, installed byAgentasthis.agentTools): dispatch,cf_agent_tool_runsunder its own schema key, chunk forwarding, detached fast path, startup reattach and reconcile, replay on connect.runAgentTooland friends are one-line facades. The hooks and the three protected seams (_runDetachedDelivery,_onAgentToolStreamProgress,_deliverDetachedMilestone) stay onAgentso subclass overrides keep working. Host bindings arrive throughsetAgentToolsHost, the same WeakMap aperture shape assetSchedulerCallbackResolver.AgentToolsChild, installed by Think and AIChatAgent asthis.agentToolsChild): child-run and milestone tables, the five adapter methods the parent calls over RPC, progress, tail, stale-run reconcile. Bound throughsetAgentToolsChildHost. Think's schema is canonical plus AIChatAgent'sinput_jsonand itsrequest_idindex. AIChatAgent's legacy tables fold in on first wake._cfDetachedReconcileTickschedule, its listing/dedupe and the in-isolate arming mutex are gone. Tests observe it throughagentTools.pendingDetachedReconcile().observeChunk/observeErrortap from each harness's single frame sender (_broadcastChat,_broadcastChatMessage). Thebroadcast()override, the idle guard, the JSON parse of every outgoing frame and the leaky negative cache are deleted, along withinterceptAgentToolBroadcastinagents/chat.Agentsubclass can now be a child; documented indocs/agents/agent-tools.md.The capability's public surface
AgentToolsexposes operations only:run,cancel,has,clear,replayToConnection,recoveryRunIds,scheduleStartupRecovery,reconcile,hasOutstandingDetachedRuns,pendingDetachedReconcile,armDetachedBackbone,reconcileTick(plus theonStart/onJobLifecycle hooks). The storage and streaming internals —readRun,resultFromRow,updateTerminal,deliverDetachedTerminal,reattachToTerminal,forwardStream,broadcastStoredChunksFromAdapter,runDeferredFinishHooks— are private; deferred finish hooks are drained insidescheduleStartupRecovery, their only caller.reconcilestays public as a legitimate "recover now" operation and says so in its doc comment.Nothing is exposed for tests. The faults the Think and ai-chat test workers used to inject by monkey-patching those seams now act at a boundary the capability already depends on: a scripted CHILD adapter installed through the host port (
getAgentToolsHostis exported next tosetAgentToolsHostso a host — or a test — can re-install a wrapped port), driven through a public operation (reconcilefor recovery seals,reconcileTickfor detached delivery,runfor live forwarding). Parent rows are read with plain SQL in the test agents that already seed them.Two new policy options
agentToolReplayOnConnect: { maxRuns, maxChunksPerRun }(static option;replayOnConnectonAgentToolsOptions) bounds the reconnect burst: the newest N runs bystarted_at, still replayed oldest-first, and the last N stored chunks of each run. A cut run sends no frames at all; dropped chunks still advance the frame sequence, so retained frames carry the sequence numbers an uncapped replay would use anduseAgentToolEvents()dedupes exactly as before. Both default toInfinity— no behaviour change — and capping the replay never deletes a run.maxConcurrentDetachedAgentTools(liveAgentfield;maxConcurrentDetachedonAgentToolsOptions) caps non-terminal DETACHED runs separately, inside themaxConcurrentAgentToolstotal. DefaultInfinity. Detached runs still count toward the total; a detached dispatch over the detached cap fails exactly like the total cap does — synchronouserrorrow,started+errorevents, no child spawned, message naming the detached cap. The live-detached-count warning now points at this knob.Deviations from the spec worth knowing
_cf_capabilityRPC aperture. Neither turned out to be needed:Agent's onConnect wrapper callsagentTools.replayToConnection, and the harnesses keep one-line facades for the five child RPC names so the stub wire is byte-identical. Both can be added later if a third host wants them.AgentToolsChild.activeRunForRequest(requestId)was added so both harnesses decide stream cutover without reading the capability's table.Review fixes folded in
cf_ai_chat_agent_tool_runsfold namedprogress_json/last_signal_atunconditionally, butai-chatreleases predating the progress work created that table without them (later releases added them by ALTER), so such a deployment upgrading straight to this version threw inonStartand never became reachable. The copied column list is now the intersection with the legacy table's actual columns.readRunomitteddetached_on_milestones, which the warm-tail milestone delivery reads, so a milestone reached while the parent was tailing live was never notified — only the backbone tick delivered it. Latent in the original_readAgentToolRuntoo, so this is a behaviour improvement, with a regression test that fails without it.output/summaryprojections for every outcome, so usergetAgentToolOutput/getAgentToolSummaryoverrides with completion side effects fired on error/aborted/skipped runs. They now run only for acompletedseal, which also stops a non-completed row carrying a summary — matching what the parent already surfaced for a non-completed inspection (#terminalResultFromInspectiononly reads summary oncompleted).Verification
Known pre-existing flake: the workerd pool occasionally segfaults partway through
ai-chat/src/tests/agent-tools.test.tswhen run in isolation. It passes in the full-suite run.