Think keeps an agent-tool child's starting messages (its pre-turn baseline) in memory only · Issue #2608 · cloudflare/agents · GitHub
Skip to content

Think keeps an agent-tool child's starting messages (its pre-turn baseline) in memory only #2608

Open

Description

@jackdnl

Drafted 2026-10-16 from Joke's production (account schematik, script joke).
Versions: agents 0.28.0, @cloudflare/think 0.20.2, ai 7.0.137, wrangler 4.147.0,
compatibility_date 2026-10-01, nodejs_compat. Object layout: JokeScope (root DO) →
JokeSession (Think facet) → JokeWorker (Think facet, run with runAgentTool, detached).


What happens

Think.startAgentToolRun() records which assistant messages existed before the run's turn:

this._agentToolPreTurnAssistantIds.set(options.runId, new Set(this.messages.filter(m => m.role === "assistant").map(m => m.id)));

That map is in memory only. After an eviction or deploy it is empty, and two things read it:

  1. _getAgentToolFinalText(runId) (the default getAgentToolSummary) returns the text of the first assistant
    message not in the baseline. With an empty baseline that is the oldest assistant message in the facet.
  2. _hasRecoveredAgentToolAssistantTurn(runId) (used by _reconcileStaleAgentToolChildRun) treats "any assistant
    message not in the baseline" as proof the run produced a turn. Its comment says "A dedicated child facet
    starts with no assistant messages, so a missing in-memory pre-turn snapshot is treated as empty."

That assumption is false for a child seeded with context before its run starts (a forked sub-agent: the parent
calls child.addMessages(parentContext) and then runAgentTool). After an eviction:

  • the summary becomes the forked context's first assistant reply (an old answer of the parent), and
  • a child whose turn was lost before it wrote anything is sealed completed (with that wrong summary) instead of
    error/interrupted, so the parent reports a result the child never produced.

A child without seeded context hits the other branch: the run is sealed error with
"Agent tool run was interrupted before the child could finish.", even when its own chat recovery would have
resumed it moments later (the reconcile runs on the parent's inspect, which can come before the child's recovery
has recorded an incident).

Evidence

  • Production, 2026-10-15 16:28 UTC: a detached worker (w-toolu_015JsF4y216TTBR4XpgyQbmK, "Restart of a
    stopped worker …") ended error: Agent tool run was interrupted before the child could finish.; our agent then
    had to pick the work up in a separate recovery turn. Nothing of the worker's own turn was resumed.
  • We override getAgentToolSummary because the default returned the parent's earlier reply for forked workers
    after an eviction (our worker.ts: "Think's own summary is never used: it joins every line of the first
    assistant message it finds, which after an eviction is the forked context's").

Repro notes

  1. Parent Think runs runAgentTool(Child, { runId, input, detached: { onFinish } }); before that it seeds the
    child with await (await this.subAgent(Child, runId)).addMessages([userMsg, assistantMsg("old answer")]).
  2. While the child is inside its first model step (a slow scripted model), abort the child facet
    (this.abortSubAgent(Child, runId) from the parent, or ctx.abort() in the child) and make the child's chat
    recovery unable to resume (e.g. recovery disabled, or the model call failing).
  3. Let the parent's backbone inspect the child (_cfDetachedReconcileTick).
  4. Seeded child: the run is completed with summary "old answer". Unseeded child: error with
    "Agent tool run was interrupted before the child could finish.".

What would help

  • Persist the baseline with the run (e.g. a pre_turn_message_ids_json column or the id of the run's input
    message on cf_agent_tool_child_runs) and use it after restarts.
  • Or anchor on the run's own input message: everything after the message formatAgentToolInput() produced is
    the run's turn.
  • Before sealing a stale run, give the child's own chat recovery a chance to classify the interrupted turn
    (or report interrupted with a reason instead of error, so callers can tell "lost" from "failed").

Our workaround

We anchor the answer on the run's own task message id (task:<runId>), and treat Think's
"interrupted before the child could finish" error (and a no-progress give-up) as a run to carry on once from what
it did.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    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