Skip to content

[bug] A tool_use block with zero argument fragments becomes an empty instruction driven into the engine — 11 of 19 Loop steps sent "" #504

Closed

Description

@serge-ivo

11 of 19 Loop steps drove an empty string into the engine

Measured in production. Instance f8ddc272… (Heartfull, Repo Coder), run at 22:57:58 UTC 2026-08-11, trace_id 2ab928b6-cb89-4b82-80a9-3dddbe47e536. Eleven of nineteen Loop steps posted literally:

**Loop → engine** (step N): message:

with nothing after it — steps 1–5 (22:58:19–23:00:44), 8–12 (23:01:37–23:03:08) and 17 (23:07:46).

This is not MCP truncation: the whole string is ~36 chars, far below where instance_messages clips, and describe() would have emitted 120 characters had any existed.

Mechanism — verified end to end

workers/api/src/lib/anthropic-stream.ts:153-155, on content_block_stop for a tool_use:

const raw = (this.partialJson.get(index) ?? "").trim();
if (!raw) {
    block.input = {};

A tool call that accumulated zero input_json_delta fragments is handed back as a well-formed call with no arguments. The catch three lines below (:158-163) correctly throws for arguments that arrived and did not parse — the empty case takes the other branch and passes silently.

The module's own docstring at :22-24 states the opposite intent:

"Same reasoning for a tool_use whose accumulated arguments do not parse — a half-built call is worse than no call, because the loop would act on it."

Zero arguments is the same hazard the docstring names, and it is the one case that is not caught.

Nothing downstream catches it either:

  • workers/api/src/lib/user-ai.ts:268 — arguments: t.input || {} → {}
  • workers/api/src/lib/coding-loop.ts:285 — const str = (v: unknown) => (typeof v === "string" ? v : "");
  • workers/api/src/lib/coding-loop.ts:288 — case "send_message": return { action: { kind: "message", text: str(a.text) } }; → text: ""
  • grep -n "text.trim()\|!a.text\|text ===\|text.length" across coding-loop.ts, coding-types.ts and workflows/coding-session.ts returns nothing. The only screen on the path is screenInstruction (merge authority, coding-loop.ts:156), which does not consider emptiness.
  • coding-loop.ts:179 await deps.act(...) → packages/browser-runner/src/coding/runtime.ts:261 session.input(action.text) → packages/browser-runner/src/coding/headless.ts:451, which writes {"type":"user","message":{"role":"user","content":[{"type":"text","text":""}]}} to Claude Code's stdin, sets run = "thinking", and the loop then waitIdle()s on it (coding-loop.ts:183).

The same hole exists on the manual route: workers/api/src/routes/coding.ts:520 — const action: CodingActionKind = { kind: "message", text: String(body.text ?? "") }; with no emptiness check.

Cost

Each empty step spends one BYOK Claude decision, one engine turn and one idle-poll cycle, and counts against the step budget (pilotSteps, workflows/coding-session.ts:501). Roughly 4.6 minutes of this run's 10.7 went this way, and the owner watching the Assistant tab saw eleven blank instruction lines.

Trigger — INFERRED, not measured

runAnthropic streams unconditionally (user-ai.ts:171, landed d5c6d1a three days before this run) and defaults max_tokens: body.maxTokens ?? 1024 (user-ai.ts:136). decideCodingAction (coding-loop.ts:270-276) passes no maxTokens, so every Pilot decision is capped at 1024 output tokens — while the instructions this run was emitting embedded a whole shell script and a CI job. A max_tokens stop at the tool-use block boundary produces exactly the zero-delta case above.

Consistent with the observed pattern: the empties clustered around the two large multi-file instructions, and the run recovered each time the brain fell back to a short instruction (steps 6 and 13). Not confirmed — the experiment that would close it is verifying that Anthropic emits content_block_stop with zero input_json_delta on a mid-tool_use max_tokens stop.

Reinforcing the theory: stopReason — the field #397 added precisely so a truncated reply is distinguishable — is discarded on this path. coding-loop.ts:276 types the result as { response?, tool_calls?, usage? } and never reads it. Its only consumer in the codebase is agent-think.ts:763 (hitOutputCap), the chat path. The Pilot has the signal and throws it away.

Acceptance criteria

  1. A tool_use block that accumulated zero argument fragments is treated as the failure it is, consistent with the unparseable case at anthropic-stream.ts:158-163 and with the docstring at :22-24.
  2. An empty instruction never reaches deps.act — neither via the Pilot (coding-loop.ts:179) nor via the manual route (routes/coding.ts:520).
  3. An empty/failed decision does not silently consume a step against pilotSteps.
  4. The Pilot reads stopReason and distinguishes "the brain was truncated" from "the brain said nothing", reporting which.
  5. Consider raising or passing maxTokens for decideCodingAction, given instructions legitimately contain scripts. State the number chosen and why.
  6. Tests: a zero-delta tool_use at the stream layer; an empty send_message at the decision layer; the manual route.

Activity

  1. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    Landed in 36706e0, deployed (Deploy API Worker + CI both green).

    What was built, against the six criteria:

    1. anthropic-stream.ts no longer accepts every zero-fragment tool_use as well-formed — but it does not blanket-throw, because your criterion as written would have broken a real case: a tool with an empty input schema genuinely sends no input_json_delta (tool-registry.ts:216 declares one, and the existing test at anthropic-stream.test.ts:100 pins that behaviour). The emptiness is now recorded at block close and ruled on in finish(), where stop_reason has arrived: no arguments AND max_tokens is the failure. A tool block that started and never closed throws unconditionally — that is the unparseable case with the error hidden, and it had no test either.
    2. runCodingLoop refuses to pass a blank message to deps.act, and POST …/sessions/:id/message answers 400 instead of writing an empty user turn to the engine's stdin. Guarded in the loop rather than in toDecision because the workflow wraps decide in a budget reservation, so the mapper is not the choke point — deps.act is.
    3. A withheld instruction emits no action event, so it never increments pilotSteps, and it is not silent: the refusal goes into actionLog (the log the next decision renders, which is how the merge-authority refusal already talks to the brain) and is posted to chat. Bounded at three in a row, reset by any instruction that goes out — the run you measured recovered each time it fell back to a short instruction, so a non-resetting counter would kill runs that work.
    4. decideCodingAction now reads stopReason. "Cut off at the output limit" and "the brain said nothing" are reported as different failures, both to the brain in its step log and to you in chat.
    5. maxTokens: 2048 for the Pilot decision (was the inherited 1024). ~8,000 characters, which fits a shell script; generationBudgetMs(2048) is 88s, inside both AI_TOTAL_TIMEOUT_MS (180s) and the decide step's 3-minute Workflow timeout — 4096 computes to 156s and leaves almost no margin.
    6. Tests at all three layers: zero-delta tool_use under a cap stop and under a clean stop (both directions), the unterminated block, empty/whitespace send_message at the decision layer including the truncated-vs-empty wording and the reset behaviour, and the manual route.

    Verified in production (after the deploy landed), against your own Heartfull session csess_c960d431:

    {"text":""}    -> 400 {"error":"An instruction can't be empty — send the text you want the engine to act on."}
    {"text":"   "} -> 400 (same)
    {}             -> 400 (same)
    

    The 400 is raised before the runner lookup, so nothing reached the engine.

    Not verified, and still labelled inferred: the max_tokens trigger. I did not reproduce Anthropic emitting content_block_stop with zero input_json_delta on a mid-tool_use cap stop, and the fix does not depend on it — the loop and route guards hold whatever the cause. The Pilot-side path (an empty decision being withheld in a live run) is not observable from here without starting an autonomous run on your repos; it will show on your next Loop as a **Loop** — empty instruction not sent (…) line instead of a blank one.

  2. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    Closing: all six acceptance criteria are delivered, with criterion 1 corrected as described above (a zero-argument tool call is legitimate under a clean stop, so the rule is zero arguments plus a cap stop). The one thing still open is the hypothesis, not a deliverable — the mechanism remains unreproduced and is labelled as such in the code and the commit.

  3. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    Correction to the line above — shell ate a backticked word: "the max_tokens mechanism remains unreproduced".

  4. added a commit that references this issue on Oct 11, 2026
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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions