Skip to content

[enhancement] A failed tool round is not resumable — the retry re-fetches what the platform already paid for, and only 120 chars of the result survive #442

Description

@serge-ivo

Follow-up to #427 item 5: a retry re-runs the tool the platform already paid for

#427's streaming fix (d5c6d1a) and tool-result cap (efe285a) are shipped. Its item 5
"make the failure resumable: the tool result is already in hand, so a retry should be able to re-run
only the generation rather than re-calling the tool"
— was deliberately not attempted, and the
implementer asked for this ticket so #427 can close.

Filing it as asked. Streaming makes this rarer; it does not make it go away — any provider error in
round 2+ (5xx, an overloaded model, a stall, the 180s ceiling) lands in exactly the same place, after
the tool has run.

What it costs, from #427's own production evidence

05:08:52  "Start implementing"  → github_read_issue (1658 chars)  → timeout
05:15:38  "Start implementing"  → github_read_issue (1982 chars)  → timeout

The same issue, fetched twice, six minutes apart, because the retry had no way to say "you already
have this"
. Where a round's tools are writes rather than reads, the same retry re-executes side
effects — the cross-round dedup (agent-think.ts:791-800) is scoped to one turn and cannot see
the previous request.

What actually survives today — one correction to #427

#427 says "the user's message and the tool result both survive". The message survives. The tool
result survives only as a 120-character preview:

// agent-think.ts:996-998
const shortContent = toolResult.content.slice(0, 120);
allToolLog.push(`${icon} **${tc.name}** ${shortContent}`);

withPartialToolLog (:1055-1064) attaches that array to the thrown error; agent-do.ts:566-580
writes it as one system message, then an Error: … system message. Everything the next round would
have needed — the full content, the tool_use id, the arguments, the is_error flag,
outcomeById — is function-local in runAgentThink and dies with the throw.

So the retry is not merely choosing to re-call the tool; it has no alternative, and the
120-char preview is in the transcript as prose, which is the shape #398 removed from the live path
for good reasons.

What "resumable" needs

  1. Persist the round, structurally. On a provider failure with completed rounds, store what the
    next round would have been fed: the assistant tool_use blocks (rawResult.contentBlocks) and
    the tool_result blocks keyed by id (outcomeById, already capped by capToolResult). Attach it
    to the failed turn — as a message with a non-rendered payload, or a sibling row keyed by the
    turn's id. It must be capped and expiring; a stored round is prompt-sized, not log-sized.
  2. A resume entry point. runAgentThink starts at the top of the loop by construction. Resuming
    means re-entering with aiMessages already carrying the persisted round, the round counter
    advanced, and the dedup map seeded — so the model continues from "here are your results"
    instead of "here is the question".
  3. Decide who triggers it. Cheapest: the same retry the user already performs, with the DO
    noticing a resumable round on the previous turn. More explicit: a "Continue" action on the error
    message. The first is invisible and does the right thing; the second is honest about what it is
    doing. I would ship the first and label it in the transcript ("continued from the tool
    results already fetched"
    ), because a user who retries is asking for the answer, not for a
    decision about caching.
  4. Expire it. A resumable round is only valid while its results are still true. A short TTL
    (minutes) and invalidation on any new user message are enough; a resumed github_read_issue from
    yesterday is worse than a re-fetch.

Keep what already works

Alternatives considered and rejected

  • Automatic retry inside the request. Rejected in [bug] Chat times out at 25s on the tool loop's second round — a 4096-token cap under a 25s non-streaming ceiling is an impossible combination #427 for the deterministic case, and still
    wrong: it spends the user's credit twice inside one request with no signal.
  • A generic tool-result cache keyed by (tool, args). Bigger and less safe: it would also
    short-circuit deliberate re-reads (the mutation-count dedup at :791-800 exists precisely
    because "a read is only worth repeating when something CHANGED"), and it says nothing about write
    tools. Resuming ONE failed turn is the smaller, exactly-scoped claim.
  • Persist every round, always. Rejected: prompt-sized writes on the hot path for a case that is
    now rare. Write only on failure, and only when at least one tool executed.

Acceptance criteria

  • A turn that executes a tool and then fails in generation stores that round's tool_use +
    tool_result blocks against the failed turn, capped and expiring.
  • Retrying that turn produces an answer without re-invoking the tool — asserted with a tool
    double that counts calls.
  • With no stored round (or an expired one), behaviour is byte-identical to today, including the
    partial-tool-log system message.
  • The transcript says the turn was continued from already-fetched results.
  • A resumed turn never emits a partial or duplicated reply.

Regression risk

  • Duplicate side effects are the thing to be careful about, in both directions: resuming must
    not re-run a write tool, and it must not skip one whose result was never recorded. Persist the
    round only after every tool in it has settled.
  • The stored blocks are model-visible input. They must go through the same capToolResult seam
    (lib/tool-result-cap.ts) rather than a second cap that can drift, and they must never be rendered
    to the user as an assistant message.
  • agent-do.ts message storage is per-instance DO state; a new payload shape on a message needs the
    readers (/messages, the console, agent_trace) to ignore what they do not understand.

Files: workers/api/src/agent-think.ts:780-800,873-905,996-1000,1030-1064,
workers/api/src/agent-do.ts:560-592, workers/api/src/lib/tool-result-cap.ts,
workers/api/src/lib/ai-deadlines.ts, workers/api/src/lib/anthropic-stream.ts.
Related: #427 (items 1-4 shipped; this is its item 5 — #427 can close once this exists),
#420 (do-not-lose-the-turn in voice), #398 (why the round must be structured, not prose),
#395.

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