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
- 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.
- An empty instruction never reaches
deps.act — neither via the Pilot (coding-loop.ts:179) nor via the manual route (routes/coding.ts:520).
- An empty/failed decision does not silently consume a step against
pilotSteps.
- The Pilot reads
stopReason and distinguishes "the brain was truncated" from "the brain said nothing", reporting which.
- Consider raising or passing
maxTokens for decideCodingAction, given instructions legitimately contain scripts. State the number chosen and why.
- Tests: a zero-delta
tool_use at the stream layer; an empty send_message at the decision layer; the manual route.
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: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_messagesclips, anddescribe()would have emitted 120 characters had any existed.Mechanism — verified end to end
workers/api/src/lib/anthropic-stream.ts:153-155, oncontent_block_stopfor atool_use:A tool call that accumulated zero
input_json_deltafragments is handed back as a well-formed call with no arguments. Thecatchthree 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-24states the opposite intent: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"acrosscoding-loop.ts,coding-types.tsandworkflows/coding-session.tsreturns nothing. The only screen on the path isscreenInstruction(merge authority,coding-loop.ts:156), which does not consider emptiness.coding-loop.ts:179await deps.act(...)→packages/browser-runner/src/coding/runtime.ts:261session.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, setsrun = "thinking", and the loop thenwaitIdle()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
runAnthropicstreams unconditionally (user-ai.ts:171, landedd5c6d1athree days before this run) and defaultsmax_tokens: body.maxTokens ?? 1024(user-ai.ts:136).decideCodingAction(coding-loop.ts:270-276) passes nomaxTokens, 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. Amax_tokensstop 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_stopwith zeroinput_json_deltaon a mid-tool_usemax_tokensstop.Reinforcing the theory:
stopReason— the field #397 added precisely so a truncated reply is distinguishable — is discarded on this path.coding-loop.ts:276types the result as{ response?, tool_calls?, usage? }and never reads it. Its only consumer in the codebase isagent-think.ts:763(hitOutputCap), the chat path. The Pilot has the signal and throws it away.Acceptance criteria
tool_useblock that accumulated zero argument fragments is treated as the failure it is, consistent with the unparseable case atanthropic-stream.ts:158-163and with the docstring at:22-24.deps.act— neither via the Pilot (coding-loop.ts:179) nor via the manual route (routes/coding.ts:520).pilotSteps.stopReasonand distinguishes "the brain was truncated" from "the brain said nothing", reporting which.maxTokensfordecideCodingAction, given instructions legitimately contain scripts. State the number chosen and why.tool_useat the stream layer; an emptysend_messageat the decision layer; the manual route.