Describe the bug
An agent-tool child (Think/AIChatAgent facet dispatched via runAgentTool) that sets chatRecovery = false throws a TypeError from inspectAgentToolRun whenever the parent's scheduled reconcile inspects it while the run is still in flight. It is deterministic — one throw per child per run — and surfaces in the runtime tail as {eventType:"rpc", outcome:"exception", entrypoint:"<ChildClass>", event:{rpcMethod:"inspectAgentToolRun"}} with an empty exceptions[] (the parent's reconcile swallows the rejection via .catch(() => ({status:"failed"})), so it never reaches app logs).
To Reproduce
- A parent agent that dispatches a child via
this.runAgentTool(Child, { input }) (awaited).
class Child extends Think { chatRecovery = false } (or AIChatAgent).
- Give the child a turn that runs long enough (~15s+) for the parent's scheduled reconcile to inspect it mid-flight.
- Observe one
inspectAgentToolRun outcome:"exception" per child in the tail. The run still completes.
Expected behavior
inspectAgentToolRun should not throw for a chatRecovery = false child. Inspecting a still-running child mid-flight should report it as running (so the parent re-attaches), regardless of whether chat-recovery is enabled — the same outcome a recovery-enabled child already gets via the "in-progress" short-circuit. Disabling chatRecovery should not turn a benign in-flight inspect into a throw (and, per Additional context, must not risk a false-error terminal on a run that is still succeeding).
Version:
agents@0.20.1
@cloudflare/think@0.15.1
ai@6
Additional context (From Claude)
Root cause (verified against the installed @cloudflare/think@0.15.1 dist — line numbers drift, so this cites the expression). In _reconcileStaleAgentToolChildRun the early-return guard is:
if (recovery === "in-progress" || this._resumableStream.hasActiveStream()) return;
- A recovery-enabled child writes a recovery incident for an in-flight turn, so classifyAgentToolChildRecovery returns "in-progress" and the || short-circuits before the second operand. This is the documented guard that leaves a still-running run alone for the parent's re-attach.
- A chatRecovery = false child never writes an incident (every incident-writing path is gated behind if (this.chatRecovery)), so classify returns "none", the first operand is false, and the runtime must evaluate this._resumableStream.hasActiveStream().
_resumableStream is only assigned deep inside onStart (initialize_think_chat, after hydrate_think_session). The scheduled reconcile can resolve the child facet onto a different, under-initialized facet instance than the one streaming the turn (two _cf_initAsFacet resolutions per run are observable in the tail). On that instance _resumableStream is still undefined, so this._resumableStream.hasActiveStream() throws. The staleness check _isStaleAgentToolChildRun returns true here because it keys on the in-memory _agentToolAbortControllers map, which is populated only on the owning instance — a non-owning inspect instance sees an empty map and treats a running run as stale.
Why it matters beyond the noise — a latent silent-corruption path. Today the throw is benign: it happens before the reconcile's terminal-guessing UPDATE ... WHERE completed_at IS NULL, so the child's own finalizer still wins and the run completes correctly. But the same branch, reached on a fully-initialized non-owning instance (where _resumableStream is defined → no throw, and hasActiveStream() returns false because the live stream is on the other instance), falls through to UPDATE ... SET status='error', completed_at=now WHERE completed_at IS NULL on a child that is still running and will succeed. The child's real finalizer then no-ops (completed_at already set), the row is stranded error, and the parent's _terminalResultFromInspection reports a false failure to the model for a run that actually completed — no throw, no signal. The exception is the lucky manifestation of a timing/instance-provisioning-dependent branch.
Suggested fix (any of):
- Null-guard the deref: this._resumableStream?.hasActiveStream().
- Base the still-running determination on a durable liveness marker rather than the in-memory _agentToolAbortControllers map (which is instance-local and empty on a non-owning inspect instance), so a mid-flight run is recognized regardless of which facet instance handles the inspect.
- Treat chatRecovery = false as a first-class agent-tool-child configuration in the reconcile path — the incident-based "in-progress" guard is currently the only thing that reliably protects a still-running child, and it is unavailable when recovery is disabled.
Consumer-side workaround. We shadow the child's _reconcileStaleAgentToolChildRun with a no-op — the child's recovery is owned by the parent's run-registry, so the self-reconcile is unwanted regardless of the throw. This closes both the exception and the silent-corruption branch without wrapping inspectAgentToolRun (so genuine inspect errors still propagate).
Related (all closed) — same _reconcileAgentToolRuns / _cf_initAsFacet / child-inspect subsystem: #1595, #1630, #1475.
Describe the bug
An agent-tool child (
Think/AIChatAgentfacet dispatched viarunAgentTool) that setschatRecovery = falsethrows aTypeErrorfrominspectAgentToolRunwhenever the parent's scheduled reconcile inspects it while the run is still in flight. It is deterministic — one throw per child per run — and surfaces in the runtime tail as{eventType:"rpc", outcome:"exception", entrypoint:"<ChildClass>", event:{rpcMethod:"inspectAgentToolRun"}}with an emptyexceptions[](the parent's reconcile swallows the rejection via.catch(() => ({status:"failed"})), so it never reaches app logs).To Reproduce
this.runAgentTool(Child, { input })(awaited).class Child extends Think { chatRecovery = false }(orAIChatAgent).inspectAgentToolRunoutcome:"exception"per child in the tail. The run still completes.Expected behavior
inspectAgentToolRun should not throw for a chatRecovery = false child. Inspecting a still-running child mid-flight should report it as running (so the parent re-attaches), regardless of whether chat-recovery is enabled — the same outcome a recovery-enabled child already gets via the "in-progress" short-circuit. Disabling chatRecovery should not turn a benign in-flight inspect into a throw (and, per Additional context, must not risk a false-error terminal on a run that is still succeeding).
Version:
agents@0.20.1@cloudflare/think@0.15.1ai@6Additional context (From Claude)
Root cause (verified against the installed @cloudflare/think@0.15.1 dist — line numbers drift, so this cites the expression). In _reconcileStaleAgentToolChildRun the early-return guard is:
_resumableStream is only assigned deep inside onStart (initialize_think_chat, after hydrate_think_session). The scheduled reconcile can resolve the child facet onto a different, under-initialized facet instance than the one streaming the turn (two _cf_initAsFacet resolutions per run are observable in the tail). On that instance _resumableStream is still undefined, so this._resumableStream.hasActiveStream() throws. The staleness check _isStaleAgentToolChildRun returns true here because it keys on the in-memory _agentToolAbortControllers map, which is populated only on the owning instance — a non-owning inspect instance sees an empty map and treats a running run as stale.
Why it matters beyond the noise — a latent silent-corruption path. Today the throw is benign: it happens before the reconcile's terminal-guessing UPDATE ... WHERE completed_at IS NULL, so the child's own finalizer still wins and the run completes correctly. But the same branch, reached on a fully-initialized non-owning instance (where _resumableStream is defined → no throw, and hasActiveStream() returns false because the live stream is on the other instance), falls through to UPDATE ... SET status='error', completed_at=now WHERE completed_at IS NULL on a child that is still running and will succeed. The child's real finalizer then no-ops (completed_at already set), the row is stranded error, and the parent's _terminalResultFromInspection reports a false failure to the model for a run that actually completed — no throw, no signal. The exception is the lucky manifestation of a timing/instance-provisioning-dependent branch.
Suggested fix (any of):
Consumer-side workaround. We shadow the child's _reconcileStaleAgentToolChildRun with a no-op — the child's recovery is owned by the parent's run-registry, so the self-reconcile is unwanted regardless of the throw. This closes both the exception and the silent-corruption branch without wrapping inspectAgentToolRun (so genuine inspect errors still propagate).
Related (all closed) — same _reconcileAgentToolRuns / _cf_initAsFacet / child-inspect subsystem: #1595, #1630, #1475.