Bug: LLM stream EOF without terminal finish is committed as a successful partial response #373
andrew-stelmach-fleet
started this conversation in
General
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Summary
An LLM stream that yields partial content and then reaches clean EOF without a terminal
finishchunk is committed as a successful assistant response. The turn ends ascompleted, the partial response enters derived history, and request-error/retry policy is bypassed.Tested on
masterat47f943859bef60e4160492346772ded9b24f765a.Minimal reproduction
I mounted the public minimal agent composition (
LlmRuntime,SessionStore,SystemPrompt,ToolRuntime,AgentRegistry, andAgentLoop) with a customLlmAdapterwhose stream emits:Then I submitted one ordinary
agent.followup()and waited for idle.Observed result:
{ "assistantMessage": "partial response", "turnEnd": { "kind": "completed" }, "derivedHistoryContainsPartialReply": true }Repeating the same reproduction with
InvariantRegistryand@deepseek-ai/dsh-llm/invariantexplicitly mounted correctly produces no assistant message and ends the turn with an invariant error. This means the safety check exists only as optional diagnostics, not at the required runtime boundary.Expected behavior
Every composition should fail closed when an adapter or
llm/streammiddleware reaches EOF without exactly one terminalfinishchunk:assistant/message;Root cause
adapterStream()acceptsiterator.next().doneas a normal return.BlockAssembler.finishdefaults an absent finish to{ kind: 'stop' }.This conflicts with the adapter contract that every exposed outcome ends in exactly one terminal finish chunk:
packages/llm/llm/README.mdandStreamChunk.The shipped base composition mounts the LLM runtime and agent loop but not the invariant registry/companions, so ordinary product and custom compositions can take the fail-open path.
Impact
A buggy third-party adapter, clean-EOF transport bug, or stream middleware that accidentally suppresses the terminal chunk can durably turn truncated model output into a successful response. This bypasses retry behavior and contaminates later model context. An incomplete tool-call stream may also be materialized with defaulted fields and reach tool handling.
Verification
The disposable real-loop reproduction was removed after execution. Focused existing tests also demonstrate the split contract:
Both pass: assembler/property coverage currently codifies missing finish as stop, while the optional invariant rejects the same malformed stream.
Acceptance criteria
finishproduces a structured failure at the normal runtime boundary.assistant/messageor tool call is committed.I searched all current Discussions before posting. Discussions #199, #231, and #232 cover different reasoning-content and iterator-cleanup failures; I found no report for clean EOF being accepted as a successful completion.
All reactions