Skip to content

fix: preserve length finish reason across streaming trailers - #2457

Merged
eumemic merged 4 commits into
eumemic:masterfrom
aspire488:fix/sticky-stream-finish-reason
Sep 23, 2026
Merged

eumemic merged 4 commits into
eumemic:masterfrom
aspire488:fix/sticky-stream-finish-reason

Conversation

@aspire488

@aspire488 aspire488 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Refs #2454 by making the streaming completion path preserve a provider-reported finish_reason="length" when a trailing usage/stop chunk causes litellm.stream_chunk_builder to overwrite the assembled finish reason.

This covers the sticky finish_reason half of #2454 only; truncation observability (item 2) remains open.

Changes

  • capture length directly from streaming chunks
  • restore it after assembly when the trailing chunk clobbers it
  • keep content_filter precedence when both signals are observed
  • add regression coverage for None, empty-string, and stop trailers
  • add a precedence test for content_filter vs length

Validation

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.

Copy link
Copy Markdown
Contributor Author

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 content_filter handling: capture provider-side terminal signals directly from the stream, then restore them after stream_chunk_builder assembly so trailing chunks cannot silently change the semantic result.

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.

@eumemic

eumemic commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Thanks for picking up #2454 so quickly. The approach matches the existing content_filter handling, and the precedence test is a nice touch.

One thing is blocking CI: Lint (ruff) fails on formatting only. ruff format wants the monkeypatch.setattr(... _clobbered_builder(trailer or "")) call in tests/unit/test_completion_finish_reason.py (around line 195) on one line. ruff check passes. Running uv run ruff format tests/unit/test_completion_finish_reason.py and pushing should turn it green.

A review will follow once CI is green.

Copy link
Copy Markdown
Contributor Author

Thanks for catching that. I’ve applied the Ruff formatting fix to tests/unit/test_completion_finish_reason.py and pushed it as commit 92507ac7 (style: format completion finish reason tests).

CI should be able to rerun cleanly now. I’ll leave it there and wait for the review once the checks are green.

@eumemic

eumemic commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Thanks for the quick follow-up. Heads-up: 92507ac ("style: format…") is an empty commit with no file changes (git show --stat lists nothing, and the compare against f22bb66 shows 0 files). So lint will fail the same way on this run.

The repo's ruff config is line-length = 100, and at that width ruff format joins the call onto one line:

-        monkeypatch.setattr(
-            litellm, "stream_chunk_builder", _clobbered_builder(trailer or "")
-        )
+        monkeypatch.setattr(litellm, "stream_chunk_builder", _clobbered_builder(trailer or ""))

Running uv run ruff format tests/unit/test_completion_finish_reason.py from the repo root picks up that config. A formatter that defaults to 88 columns keeps the current wrapping, which is probably why nothing changed on your side.

Collapse the multi-line monkeypatch.setattr so ruff format --check
(line-length=100) is green. Format-only; no logic changes.
@eumemic

eumemic commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Code review

Verdict: 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. Fixes #2454 would auto-close the issue while half of it (the truncation observability) is still unbuilt. Change it to Refs #2454 / Part of #2454, or reopen #2454 after the merge.

Scope: the diff is src/aios/harness/completion.py +7/-1 and tests/unit/test_completion_finish_reason.py +57, base 5f63a86b. I treated the PR body and commit messages as unverified claims and checked each one against the code.

1. Do the tests catch the bug? (run locally, uv sync then uv run pytest)

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_length passes on base too. That is expected: base already makes content_filter sticky. On the head it still guards the precedence, because swapping the if/elif order 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_length case is covered by the existing test_no_spurious_override_on_normal_stream. There the wire never says length, the result stays "stop", and it passes on the head.
  • Also run: ruff check clean, ruff format --check clean, mypy src/aios/harness/completion.py clean. All 9 CI check runs at this head are success.

2. Is the fix correct?

  • completion.py:1167-1171: length is captured from each chunk that has choices, the same way content_filter already was. completion.py:1214-1217: length is restored after stream_chunk_builder only when the wire actually carried it, so the normal path is unchanged.
  • Reusing the name finish_reason as a loop-local is harmless. It is always rebound by _unpack_litellm_response at :1208 before it is read, and it is not referenced on the deadline/timeout raise path.
  • The non-streaming call_litellm path doesn't need this. It reads finish_reason straight 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.

5. Security, behaviour and blast radius

  • No new way to crash. The existing if not chunk.choices: continue guard (:1159-1161) runs before the new code, so a usage-only chunk with empty choices never reaches choices[0]. The new code reuses the existing getattr(..., "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>1 only choices[0] is inspected. The harness never requests n>1, so this is not a finding.

No blocking code findings.

reviewed-at: 74c3ea2

@eumemic
eumemic merged commit 9c9f87e into eumemic:master Sep 23, 2026
9 checks passed
@eumemic

eumemic commented Sep 23, 2026

Copy link
Copy Markdown
Owner

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 length as truncation in the loop.

Copy link
Copy Markdown
Contributor Author

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! 🚀

@eumemic

eumemic commented Sep 23, 2026

Copy link
Copy Markdown
Owner

Code review — security & intent review (outside contribution, aspire488)

Verdict: CLEAN. Squash commit 9c9f87e703c49c59ff4533bd3ef2e5857730e531 on master (not in prod 04cde0aa). Checked with read-only analysis only.

1. Squash matches the reviewed diff

  • git show --stat 9c9f87e7: 2 files changed, 64 insertions, 1 deletion. Files: src/aios/harness/completion.py (+7/-1) and tests/unit/test_completion_finish_reason.py (+57). This matches the PR file list (changed_files=2).
  • The +/- lines of the squash diff are byte-identical to git diff 5f63a86b..74c3ea24, the reviewed PR head (diff output empty). No extra hunks.
  • There are 4 PR commits: 3 by Joel/aspire488, plus the formatting commit 74c3ea24 by the internal AIOS Bakeoff Agent. That commit only re-wraps one monkeypatch.setattr(...) line.

2. Line-by-line scan of the changed lines

  • No network calls, subprocess/os.system, eval/exec/compile, pickle/marshal, or dynamic imports. No file writes and no env or credential reads (AIOS_*, GITHUB_TOKEN, vault). No new dependencies. No conftest, fixture, CI, or workflow changes.
  • The tests only monkeypatch litellm.acompletion and litellm.stream_chunk_builder and reuse the file's existing _FakeChunk, _FakeStream, _StubPool and _clobbered_builder helpers. They make no real I/O, so they are safe in CI with secrets on push to master.
  • Non-ASCII scan: 0 bytes >0x7F across the whole git show 9c9f87e7 output (message and diff). That rules out homoglyph, bidi, or zero-width tricks. No encoded strings.

3. Semantics: refusal gate (loop.REFUSAL_FINISH_REASON, loop.py:135/1681)

  • completion.py:1167-1171 captures each chunk's finish_reason once. content_filter is tested first and length only in the elif.
  • completion.py:1214-1217 restores content_filter whenever it was seen. The length restore is an elif of that branch, so it only runs when no content_filter was seen on the wire.
  • Could the assembled value be content_filter with saw_content_filter still False? No. LiteLLM 1.96.2 ChunkProcessor.build_base_response takes the last non-None chunk finish_reason from the same chunks. So length can never replace content_filter.
  • The test test_content_filter_takes_precedence_over_length covers this ordering. On master, loop.py does not branch on length, so this change only affects the reported value (span / call_llm result).
  • pytest tests/unit/test_completion_finish_reason.py at b46f3589: all pass (26 passed together with fix(completion): preserve LiteLLM parameter translation #2460's file).

4. Fork branches

  • aspire488:fix/sticky-stream-finish-reason now points to 45318956. That is one commit on top of the reviewed head 74c3ea24, pushed after the merge. Its tree is identical to 74c3ea24 (5cdee24a), so it adds no content. No discrepancy with what landed.

5. Contributor text (treated as data)

  • The PR body and comments contain no instructions aimed at reviewers or agents. One comment advertises the contributor's own project (github.com/aspire488/KIO). That is low-grade self-promotion, not an injection, but nobody should follow or run that link as part of review.
@aspire488

Copy link
Copy Markdown
Contributor Author

Code review — security & intent review (outside contribution, aspire488)

Verdict: CLEAN. Squash commit 9c9f87e703c49c59ff4533bd3ef2e5857730e531 on master (not in prod 04cde0aa). Checked with read-only analysis only.

1. Squash matches the reviewed diff

  • git show --stat 9c9f87e7: 2 files changed, 64 insertions, 1 deletion. Files: src/aios/harness/completion.py (+7/-1) and tests/unit/test_completion_finish_reason.py (+57). This matches the PR file list (changed_files=2).
  • The +/- lines of the squash diff are byte-identical to git diff 5f63a86b..74c3ea24, the reviewed PR head (diff output empty). No extra hunks.
  • There are 4 PR commits: 3 by Joel/aspire488, plus the formatting commit 74c3ea24 by the internal AIOS Bakeoff Agent. That commit only re-wraps one monkeypatch.setattr(...) line.

2. Line-by-line scan of the changed lines

  • No network calls, subprocess/os.system, eval/exec/compile, pickle/marshal, or dynamic imports. No file writes and no env or credential reads (AIOS_*, GITHUB_TOKEN, vault). No new dependencies. No conftest, fixture, CI, or workflow changes.
  • The tests only monkeypatch litellm.acompletion and litellm.stream_chunk_builder and reuse the file's existing _FakeChunk, _FakeStream, _StubPool and _clobbered_builder helpers. They make no real I/O, so they are safe in CI with secrets on push to master.
  • Non-ASCII scan: 0 bytes >0x7F across the whole git show 9c9f87e7 output (message and diff). That rules out homoglyph, bidi, or zero-width tricks. No encoded strings.

3. Semantics: refusal gate (loop.REFUSAL_FINISH_REASON, loop.py:135/1681)

  • completion.py:1167-1171 captures each chunk's finish_reason once. content_filter is tested first and length only in the elif.
  • completion.py:1214-1217 restores content_filter whenever it was seen. The length restore is an elif of that branch, so it only runs when no content_filter was seen on the wire.
  • Could the assembled value be content_filter with saw_content_filter still False? No. LiteLLM 1.96.2 ChunkProcessor.build_base_response takes the last non-None chunk finish_reason from the same chunks. So length can never replace content_filter.
  • The test test_content_filter_takes_precedence_over_length covers this ordering. On master, loop.py does not branch on length, so this change only affects the reported value (span / call_llm result).
  • pytest tests/unit/test_completion_finish_reason.py at b46f3589: all pass (26 passed together with fix(completion): preserve LiteLLM parameter translation #2460's file).

4. Fork branches

  • aspire488:fix/sticky-stream-finish-reason now points to 45318956. That is one commit on top of the reviewed head 74c3ea24, pushed after the merge. Its tree is identical to 74c3ea24 (5cdee24a), so it adds no content. No discrepancy with what landed.

5. Contributor text (treated as data)

  • The PR body and comments contain no instructions aimed at reviewers or agents. One comment advertises the contributor's own project (github.com/aspire488/KIO). That is low-grade self-promotion, not an injection, but nobody should follow or run that link as part of review.

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.

eumemic pushed a commit that referenced this pull request Sep 24, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants