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

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:268arguments: t.input || {}{}
  • workers/api/src/lib/coding-loop.ts:285const str = (v: unknown) => (typeof v === "string" ? v : "");
  • workers/api/src/lib/coding-loop.ts:288case "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:520const 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

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