Skip to content

feat(llm): stream LLM responses (default off)#4

Merged
maxie7 merged 3 commits into
mainfrom
feat/m7-streaming
Jul 22, 2026
Merged

feat(llm): stream LLM responses (default off)#4
maxie7 merged 3 commits into
mainfrom
feat/m7-streaming

Conversation

@maxie7

@maxie7 maxie7 commented Jul 22, 2026

Copy link
Copy Markdown
Owner

What

The LLM stage is request/response: the card says "explaining…" for the whole generation, then flips to fully explained in one step. This adds SSE streaming so the card's fields appear as the model completes them.

It is progressive display only — the final explanation is byte-identical, and the authoritative parse is unchanged. Default off: provider behaviour around stream + response_format varies enough that a regression in the explanation path would be worse than a card that feels slower. Enable with --llm-stream.

This closes Epic M7.

How

The parser was already most of the way there. decodeObject streams JSON tokens and stops at the tear — it was written for max_tokens truncation, and a partial stream is the same shape. So partialExplanation is parseExplanation minus the prose fallback, and progressive display cannot drift from the finished answer because it is the same code on the same bytes. stripFences already tolerated a fence or a prose preamble, so json-mode-off still streams progressively.

Progressive display is content-driven, not flag-driven: the extractor runs over the accumulated buffer and surfaces only completed field values. A model answering in prose simply produces no intermediate updates. Raw buffer text, a half-written field, a fence or a preamble never reach the card.

Updates fire on field completion, not on a tick — at most three per escalation, since the card renders three fields. No clock, so the tests stay deterministic.

The partial send drops rather than blocks. A blocking send would park the worker while the HTTP body is open, so a busy pipeline goroutine under a log flood would stall the SSE read path, trip our own --llm-timeout, and badge a perfectly good answer as incomplete. A partial is superseded by the next one and then by the authoritative result, which stays on the existing blocking path and is never dropped. This matches what the codebase already does for cosmetic updates.

The concurrency model is unchanged: no new locks, the pipeline goroutine remains the sole writer of the template map, -race green.

Getting the failure cases right

Most of the work here was in not reporting a good response as a broken one.

  • Byte limits sized for SSE framing. Every token arrives wrapped in a full chunk envelope (~246 bytes), so a 4096-token answer is ~984 KiB — within 4% of the old 1 MiB cap meant for a complete JSON body. A normal response would have tripped it and been retried and badged incomplete. The wire and the accumulated content now have separate budgets.
  • io.LimitReader plus explicit counting. The reader bounds memory (a provider streaming without newlines would otherwise buffer unboundedly); the counter is what distinguishes our limit from their close, since exhausting a LimitReader surfaces as a plain io.EOF.
  • Deterministic tears are not transient. Our own --llm-timeout and a byte-limit overrun both reproduce exactly on a second attempt — retrying buys nothing and burns a full extra generation. Both go straight to salvage.
  • A tear after the closing brace is not a tear. If the JSON object closed, the answer arrived; it is returned complete and unbadged, with no retry.
  • A salvaged card is marked answer incomplete, and the status line reads explaining… for the whole stream — a card showing a summary while reading as finished would be dishonest.
  • finish_reason: length stays a separate path, so the reasoning-model diagnostic naming --llm-max-tokens is preserved.
  • Split placeholders are closed by construction: a field is emitted only once its closing quote is consumed, so an anonymizer placeholder inside an emitted field is necessarily whole.

Two bugs surfaced while writing the tests: a stream torn before its first field completed was salvaging through the prose fallback (so {"summ would have rendered as the summary), and the closed-object check had to move ahead of the tear check or a complete answer burned the retry budget first.

Also in this PR

  • chore(ci): go test -race ./... added to CI and to the verify skill — the concurrency model is load-bearing and nothing was checking it automatically. The timing-sensitive tests were audited first (full -race suite ~16s; stressed clean at tui ×10, llm/pipeline/ingest ×15, coalescer ×30); one test needed a wider margin.
  • examples/logscry.yaml gains stream, and the anonymize / anonymize_suffixes keys that were missed in the v0.4.0 anonymization work.

Verification

  • gofmt, go vet, golangci-lint (0 issues), go test ./..., go test -race ./..., go build ./... — all green.
  • Adversarial SSE chunking: boundaries mid-JSON-token, several events per read, keep-alives, \r\n, malformed payloads, a final line with no trailing newline, a newline-free runaway.
  • Three tests were verified to fail without their fix, since a test that would pass anyway is worthless.
  • Manual: against a fake SSE provider the card fills in field by field; the same content non-streamed yields a byte-identical final explanation; --plain prints exactly one ESCALATED: and one EXPLAINED: block.

maxie7 and others added 3 commits July 22, 2026 13:50
Default OFF. Provider behaviour around stream + response_format varies enough
that a regression in the explanation path would be worse than a card that
feels slower; flip the default in a later release after real use.

Streaming changes only WHEN fields appear. The final parse stays
parseExplanation, unchanged and authoritative -- progressive display is a
strict subset of it (partialExplanation is the same function without the prose
fallback), so what shows early can never drift from the finished answer.

Framing is read as a byte stream via bufio, not as whole messages, so a chunk
boundary mid-JSON-token, several events in one read, keep-alives and blank
lines are all invisible. ReadString hands back a trailing partial line
TOGETHER with io.EOF, so the data is consumed before the error is considered:
an unterminated final line is exactly where a provider that skips the last
newline puts its [DONE] or finish_reason, i.e. the evidence of a clean end.

Updates fire on FIELD COMPLETION -- at most three per escalation, no timer,
no clock in the tests -- and the send DROPS rather than blocks. That matters:
the callback runs on the worker with the body still open, so a blocking send
would let a busy pipeline goroutine stall the read until our own deadline
fired, and since that deadline is deliberately non-retryable a good answer
would come back salvaged and badged incomplete. A slow UI must never be able
to damage a response. A partial is superseded by the next and finally by the
authoritative result, which keeps the existing blocking send.

The anonymizer needs no special case: a partial carries only fields the
decoder saw CLOSED, so a placeholder split across chunks cannot reach a card
half-written. Only salvaged prose can end mid-token, and that is trimmed.

Failure semantics, each guarding against downgrading a good answer:
  - a tear AFTER the model closed its JSON is not a tear -- no retry, no badge
  - our own deadline and our own byte budget are DETERMINISTIC, so they skip
    the retry and go straight to salvage; a genuine transport tear still
    retries. Note the existing "never retry 4xx" guard cannot help here: the
    status was already 200 by the time a stream dies
  - salvage is badged Truncated, because a half-formed likely_cause that
    renders like a finished one is advice someone acts on
  - finish_reason: length stays a CLEAN end and keeps its --llm-max-tokens
    diagnostic rather than silently becoming a partial card
  - a 400/422 drops streaming first, then response_format: parse quality is
    load-bearing, streaming is cosmetic

maxStreamBytes (8 MiB) is separate from maxBodyBytes because SSE inflates the
same answer ~10x -- ~246 bytes of chunk envelope per token. At --llm-max-tokens
4096, which the README recommends for reasoning models, a NORMAL response is
~984 KiB and would have tripped the 1 MiB cap. io.LimitReader is kept as the
memory backstop (bufio buffers until a newline, so a newline-free stream would
grow unbounded) while explicit counting is what tells our limit from their
disconnect -- a LimitReader running out is an indistinguishable io.EOF.

pipeline.attach had to stop decrementing "explaining" on every update: with
several pending updates per escalation the counter went to zero and then
negative while cards were still filling in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Says plainly what streaming does not do -- it is not a speed-up of the model,
the final explanation is byte-identical -- and names the two cases where it
visibly does nothing: a model answering in prose has no completed fields to
show, and --plain prints only the finished answer. Also documents the
"answer incomplete" badge, since a salvaged card is the one place a user
could otherwise mistake a partial verdict for a final one.

M7 is now complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The concurrency model is load-bearing -- one goroutine owns the template map,
the LLM pool must never block ingest -- and nothing was checking it
automatically. Added as its own step rather than replacing the plain test run,
so a failure says whether it is a logic bug or a data race.

Audited the timing-sensitive tests first, because an intermittently red CI is
worse than no race check: people stop reading it. Full -race suite is ~16s
locally; stressed clean at tui x10, llm/pipeline/ingest x15, and the coalescer
idle-flush x30. Only one test needed widening.

TestCoalesceFlushesPartialAfterIdleTimeout drove the coalescer at a 50ms idle
timeout and sent TWO lines that both had to land inside that window; a stall
between them flushes the header alone and fails on missing content. Its 2s
deadline is no protection -- that only catches a flush that never happens --
so the margin had to go on the timeout itself. Now 500ms.

Checked and deliberately left alone: explain_test.go's 50ms guard is a
NEGATIVE assertion (Run must not have returned), and openai_compatible_test's
50ms timeout asserts a timeout does fire -- for both, slowness makes the test
more reliable, not less. coalesce_test's drive() pre-buffers and closes its
input so the idle timeout never elapses. The remaining 1-5s deadlines guard
microsecond operations. The score package is the slowest under -race at ~15s
but is pure fake-clock arithmetic: slow, not flaky.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@maxie7
maxie7 merged commit 132dd8b into main Jul 22, 2026
1 check passed
@maxie7
maxie7 deleted the feat/m7-streaming branch July 22, 2026 13:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant