Repository navigation
fix: preserve length finish reason across streaming trailers - #2457
Conversation
|
Thanks for the detailed issue and the red-test direction. I’ve opened #2457 with the focused fix and regression coverage. The implementation follows the same general approach as the existing One other project I’ve been building that may be relevant to the broader runtime/orchestration side of this work is KIO (Kernel for Intelligent Orchestration). It’s a separate project focused on deterministic orchestration, provider routing, execution boundaries, lifecycle/state handling, and resource-disciplined agent runtime design. No expectation at all — just mentioning it in case the architecture or ideas are useful to look at: https://github.com/aspire488/KIO Appreciate the issue being scoped this clearly; it made the implementation fairly straightforward. |
|
Thanks for picking up #2454 so quickly. The approach matches the existing One thing is blocking CI: A review will follow once CI is green. |
|
Thanks for catching that. I’ve applied the Ruff formatting fix to CI should be able to rerun cleanly now. I’ll leave it there and wait for the review once the checks are green. |
|
Thanks for the quick follow-up. Heads-up: The repo's ruff config is - monkeypatch.setattr(
- litellm, "stream_chunk_builder", _clobbered_builder(trailer or "")
- )
+ monkeypatch.setattr(litellm, "stream_chunk_builder", _clobbered_builder(trailer or ""))Running |
Collapse the multi-line monkeypatch.setattr so ruff format --check (line-length=100) is green. Format-only; no logic changes.
Code reviewVerdict: GREEN, with one condition on the PR text. The code is correct and the tests catch the bug. The PR does not meet all of #2454, though. Scope: the diff is 1. Do the tests catch the bug? (run locally,
|
| tree | tests/unit/test_completion_finish_reason.py |
|---|---|
PR head 74c3ea24 |
10 passed (GREEN) |
head with only completion.py reverted to 5f63a86b (git checkout 5f63a86b -- src/aios/harness/completion.py) |
3 failed, 7 passed (RED): test_length_survives_trailing_clobber[None], [""], [stop], each returning the trailer value instead of "length" |
test_content_filter_takes_precedence_over_lengthpasses on base too. That is expected: base already makescontent_filtersticky. On the head it still guards the precedence, because swapping theif/eliforder would turn it red.- Streaming: finish_reason='length' is clobbered by trailing usage chunks, so truncated turns record as clean (split from #2453) #2454's
test_no_spurious_lengthcase is covered by the existingtest_no_spurious_override_on_normal_stream. There the wire never sayslength, the result stays"stop", and it passes on the head. - Also run:
ruff checkclean,ruff format --checkclean,mypy src/aios/harness/completion.pyclean. All 9 CI check runs at this head aresuccess.
2. Is the fix correct?
completion.py:1167-1171:lengthis captured from each chunk that has choices, the same waycontent_filteralready was.completion.py:1214-1217:lengthis restored afterstream_chunk_builderonly when the wire actually carried it, so the normal path is unchanged.- Reusing the name
finish_reasonas a loop-local is harmless. It is always rebound by_unpack_litellm_responseat:1208before it is read, and it is not referenced on the deadline/timeout raise path. - The non-streaming
call_litellmpath doesn't need this. It readsfinish_reasonstraight from one response (_unpack_litellm_response,:928), and there is no last-wins assembly.
3. Precedence: content_filter wins over length. This is the right choice.
The only branch in loop.py that reads finish_reason is if finish_reason == REFUSAL_FINISH_REASON (loop.py:1681). That branch latches the session into the errored state, skips persisting the turn, and does not dispatch its tool calls. If length could beat content_filter, a refusal that also hit the cap would be persisted as a normal turn and its possibly cut-off tool calls would run. That is the exact hazard the refusal gate exists to stop. content_filter sits in the if and length in the elif, so the refusal gate cannot be weakened by this change.
4. Does this change anything on master today? No.
stream_litellmis called only fromloop.py:1443(thesubscribedbranch). After that,loop.pyreadsllm_response.finish_reason(:1578) only for the refusal check at:1681.TRUNCATED_FINISH_REASON,step.model_output_truncatedandoutput_truncateddo not exist on master. Searchingsrc/for"length"finds only this diff andmodel_binding.map_finish_reason(model_binding.py:190). That function maps a bound workflow's return value; it is not fed bystream_litellm.call_llm(workflows/run_llm.py:266,285) does returnfinish_reasonto scripts, but it uses the non-streamingcall_litellm, which was already honest.- So on master the value is now correct, but nothing reads it. The PR makes streaming
finish_reasontruthful, and that is a prerequisite for Streaming: finish_reason='length' is clobbered by trailing usage chunks, so truncated turns record as clean (split from #2453) #2454. Streaming: finish_reason='length' is clobbered by trailing usage chunks, so truncated turns record as clean (split from #2453) #2454's property ("must be recorded as truncated on both paths") still needs its item 2: thestep.model_output_truncatedwarning, thefinish_reason/output_truncatedstamps on themodel_request_endspan (loop.py:1614-1640), and the loop-levelhas_subscriber=Truetest. That is the consumer Streaming: finish_reason='length' is clobbered by trailing usage chunks, so truncated turns record as clean (split from #2453) #2454 intends, and it still has to be built. Hence the condition at the top about closing the issue.
5. Security, behaviour and blast radius
- No new way to crash. The existing
if not chunk.choices: continueguard (:1159-1161) runs before the new code, so a usage-only chunk with empty choices never reacheschoices[0]. The new code reuses the existinggetattr(..., "finish_reason", None)guard, so a choice without the attribute is still safe. - The only behaviour change is that a streamed response returns
"length"in cases where it used to return""/None/"stop", and today nothing on master branches on that. No I/O, auth, persistence, or untrusted-input handling is touched. - Same as the existing content_filter logic: with
n>1onlychoices[0]is inspected. The harness never requestsn>1, so this is not a finding.
No blocking code findings.
reviewed-at: 74c3ea2
|
Merged, thank you. This was a clean, well-targeted fix: correct precedence, and tests that fail without it. I've reopened #2454 only for the separate follow-up, recording the now-honest |
|
Thanks a lot for reviewing and merging this! Really appreciate the thoughtful feedback throughout the process — it helped make the fix more robust and properly scoped. I’m glad this could be useful to aios, and I’d be happy to keep contributing as I find other issues where I can make a meaningful improvement. Looking forward to working with you all again on future changes! 🚀 |
Code review — security & intent review (outside contribution, aspire488)Verdict: CLEAN. Squash commit 1. Squash matches the reviewed diff
2. Line-by-line scan of the changed lines
3. Semantics: refusal gate (
4. Fork branches
5. Contributor text (treated as data)
|
Thanks for the thorough security and intent review. I really appreciate the extra verification, especially checking the squash commit against the reviewed diff and validating the precedence behavior independently. Glad to see the final tree was confirmed identical and that there were no security or intent issues introduced by the contribution. Also appreciate the clarification around the reopened #2454 follow-up — it makes the boundary between the landed fix and the remaining observability work clear. Thanks again for taking the time to review it so carefully. |
## Summary Follow-up to #2454 after #2457 made streaming `finish_reason="length"` reliable. This records the now-truthful provider signal as truncation telemetry at the loop layer. ### Changes - stamp `finish_reason` on `model_request_end` - stamp `output_truncated=true` when `finish_reason="length"` - emit `step.model_output_truncated` with the model, finish reason, and streaming-path flag - add a loop-level E2E regression using a real SSE subscriber so the streaming path is exercised ### Validation The new test asserts that the subscriber-backed model call uses `stream=True` and that the resulting `model_request_end` span records both `finish_reason="length"` and `output_truncated=true`. I have not executed the E2E suite in the GitHub connector environment; CI should provide runtime validation. Refs #2454 --------- Co-authored-by: eumemic <ceo@eumemic.ai>
Summary
Refs #2454 by making the streaming completion path preserve a provider-reported
finish_reason="length"when a trailing usage/stop chunk causeslitellm.stream_chunk_builderto overwrite the assembled finish reason.This covers the sticky finish_reason half of #2454 only; truncation observability (item 2) remains open.
Changes
lengthdirectly from streaming chunkscontent_filterprecedence when both signals are observedNone, empty-string, andstoptrailerscontent_filtervslengthValidation
I added focused regression tests matching the issue's red-test sketch. I have not executed the test suite in the GitHub connector environment, so CI should provide the runtime validation.
AI assistance was used to help implement and review the patch.