feat(llm): stream LLM responses (default off)#4
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_formatvaries 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.
decodeObjectstreams JSON tokens and stops at the tear — it was written formax_tokenstruncation, and a partial stream is the same shape. SopartialExplanationisparseExplanationminus the prose fallback, and progressive display cannot drift from the finished answer because it is the same code on the same bytes.stripFencesalready 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,
-racegreen.Getting the failure cases right
Most of the work here was in not reporting a good response as a broken one.
io.LimitReaderplus 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 aLimitReadersurfaces as a plainio.EOF.--llm-timeoutand 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.answer incomplete, and the status line readsexplaining…for the whole stream — a card showing a summary while reading as finished would be dishonest.finish_reason: lengthstays a separate path, so the reasoning-model diagnostic naming--llm-max-tokensis preserved.Two bugs surfaced while writing the tests: a stream torn before its first field completed was salvaging through the prose fallback (so
{"summwould 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-racesuite ~16s; stressed clean at tui ×10, llm/pipeline/ingest ×15, coalescer ×30); one test needed a wider margin.examples/logscry.yamlgainsstream, and theanonymize/anonymize_suffixeskeys 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.\r\n, malformed payloads, a final line with no trailing newline, a newline-free runaway.--plainprints exactly oneESCALATED:and oneEXPLAINED:block.