Repository navigation
Description
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_usewhose 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"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
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_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. - 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
stopReasonand distinguishes "the brain was truncated" from "the brain said nothing", reporting which. - Consider raising or passing
maxTokensfordecideCodingAction, given instructions legitimately contain scripts. State the number chosen and why. - Tests: a zero-delta
tool_useat the stream layer; an emptysend_messageat the decision layer; the manual route.
Activity
fix(coding): an instruction with no words is never driven into the en…
serge-ivo commentedon Aug 12, 2026
Landed in
36706e0, deployed (Deploy API Worker + CI both green).What was built, against the six criteria:
anthropic-stream.tsno longer accepts every zero-fragmenttool_useas 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 noinput_json_delta(tool-registry.ts:216declares one, and the existing test atanthropic-stream.test.ts:100pins that behaviour). The emptiness is now recorded at block close and ruled on infinish(), wherestop_reasonhas arrived: no arguments ANDmax_tokensis 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.runCodingLooprefuses to pass a blank message todeps.act, andPOST …/sessions/:id/messageanswers 400 instead of writing an empty user turn to the engine's stdin. Guarded in the loop rather than intoDecisionbecause the workflow wrapsdecidein a budget reservation, so the mapper is not the choke point —deps.actis.actionevent, so it never incrementspilotSteps, and it is not silent: the refusal goes intoactionLog(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.decideCodingActionnow readsstopReason. "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.maxTokens: 2048for the Pilot decision (was the inherited 1024). ~8,000 characters, which fits a shell script;generationBudgetMs(2048)is 88s, inside bothAI_TOTAL_TIMEOUT_MS(180s) and the decide step's 3-minute Workflow timeout — 4096 computes to 156s and leaves almost no margin.tool_useunder a cap stop and under a clean stop (both directions), the unterminated block, empty/whitespacesend_messageat 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:The 400 is raised before the runner lookup, so nothing reached the engine.
Not verified, and still labelled inferred: the
max_tokenstrigger. I did not reproduce Anthropic emittingcontent_block_stopwith zeroinput_json_deltaon a mid-tool_usecap 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.serge-ivo commentedon Aug 12, 2026
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.
serge-ivo commentedon Aug 12, 2026
Correction to the line above — shell ate a backticked word: "the
max_tokensmechanism remains unreproduced".feat(feedback): a complaint about a turn lands in a table that outliv…
feat(feedback): a message can be reported from the bubble it is in, a…
fix(coding): a repeated instruction is bounded in code and the Pilot …
fix(coding): the Pilot is told when the engine refused a turn, and st…
fix(coding): an instruction that speaks for the owner is stamped wher…
fix(#907): SSE termination-boundary matrix — output-cap and transport…