Skip to content

Think: inspectAgentToolRun throws when chatRecovery = false agent-tool child #2044

Description

@dustin-hhandr

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

  1. A parent agent that dispatches a child via this.runAgentTool(Child, { input }) (awaited).
  2. class Child extends Think { chatRecovery = false } (or AIChatAgent).
  3. Give the child a turn that runs long enough (~15s+) for the parent's scheduled reconcile to inspect it mid-flight.
  4. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions