fix(agent): scope continuation dedupe to stream stubs

(cherry picked from commit 82acba16e08cce9f613598325d7c566838fb002e)
This commit is contained in:
fangliquanflq
2026-08-26 16:38:40 +08:00
committed by kshitij
parent 3814aab269
commit d98deef030
4 changed files with 40 additions and 15 deletions

View File

@@ -288,15 +288,20 @@ def _continuation_overlap_length(previous: str, continuation: str) -> int:
return matched if matched >= _MIN_CONTINUATION_OVERLAP else 0
def _join_truncated_parts(parts: List[str]) -> str:
"""Join continuation fragments without repeating a recovered tail."""
def _join_truncated_parts(parts: List[str | tuple[str, bool]]) -> str:
"""Join continuation fragments, deduping only interrupted-stream seams."""
joined = ""
for part in parts:
if joined and part:
previous_was_partial_stub = False
for fragment in parts:
part, is_partial_stub = (
fragment if isinstance(fragment, tuple) else (fragment, False)
)
if previous_was_partial_stub and joined and part:
part = part[_continuation_overlap_length(joined, part):]
if joined and not joined[-1].isspace() and part and not part[0].isspace():
joined += "\n"
joined += part
previous_was_partial_stub = is_partial_stub
return joined
@@ -1377,7 +1382,7 @@ class _LoopState:
restart_count: int = 0
_outer_error_count: int = 0 # outer-loop exceptions this turn (#92450), see _MAX_OUTER_LOOP_ERRORS
truncated_tool_call_retries: int = 0
truncated_response_parts: List[str] = field(default_factory=list)
truncated_response_parts: List[tuple[str, bool]] = field(default_factory=list)
compression_attempts: int = 0
_last_preflight_pressure: Optional[int] = None
# A provider overflow outweighs the rough-estimate calibration that defers preflight after

View File

@@ -229,7 +229,7 @@ def finish_text_response(
codex_ack_continuations = 0
if truncated_response_parts:
final_response = _join_truncated_parts([*truncated_response_parts, final_response])
final_response = _join_truncated_parts([*truncated_response_parts, (final_response, False)])
truncated_response_parts = []
length_continue_retries = 0
# The continuation recovered, so the fragments stay in the transcript.

View File

@@ -131,7 +131,7 @@ class TruncationVerdict:
result: Optional[Dict[str, Any]]
messages: List[Dict[str, Any]]
length_continue_retries: int
truncated_response_parts: List[str]
truncated_response_parts: List[Any]
truncated_tool_call_retries: int
retry_count: int
compression_attempts: int
@@ -257,7 +257,7 @@ def _continue_text(st: _Trunc, _retry: TurnRetryState, assistant_message: Any) -
interim_msg = agent._build_assistant_message(assistant_message, st.finish_reason)
interim_msg["_length_continuation_fragment"] = True # ceiling exit drops these
append_message(messages, interim_msg)
st.truncated_response_parts.append(_interim_content)
st.truncated_response_parts.append((_interim_content, st.is_stub))
filled = st.window_filled
if n < 4 and filled is None:
@@ -360,7 +360,7 @@ def recover_from_truncation(
agent: Any, response: Any, finish_reason: str, _retry: TurnRetryState, *,
messages: List[Dict[str, Any]], conversation_history: Any, api_kwargs: Any, api_call_count: int,
effective_task_id: Any, current_turn_user_idx: Any, length_continue_retries: int,
truncated_response_parts: List[str], truncated_tool_call_retries: int, retry_count: int,
truncated_response_parts: List[Any], truncated_tool_call_retries: int, retry_count: int,
compression_attempts: int,
) -> TruncationVerdict:
"""Recover from a truncated response. Order is load-bearing: thinking exhaustion and

View File

@@ -366,16 +366,16 @@ class TestLengthContinuationAssembly:
)
joined = _join_truncated_parts([
f"Investigation details.\n\n{conclusion}",
conclusion,
(f"Investigation details.\n\n{conclusion}", True),
(conclusion, False),
])
assert joined.count(conclusion) == 1
def test_distinct_continuation_is_preserved(self):
assert _join_truncated_parts([
"The first half ends here",
"and the second half adds new information.",
("The first half ends here", True),
("and the second half adds new information.", False),
]) == (
"The first half ends here\n"
"and the second half adds new information."
@@ -385,8 +385,8 @@ class TestLengthContinuationAssembly:
repeated = "A sufficiently long repeated transition sentence ends here."
assert _join_truncated_parts([
f"Existing answer. {repeated}",
f"{repeated} New inbound details remain visible.",
(f"Existing answer. {repeated}", True),
(f"{repeated} New inbound details remain visible.", False),
]) == (
f"Existing answer. {repeated} New inbound details remain visible."
)
@@ -491,6 +491,26 @@ class TestConversationLoopPartialStreamContinuation:
assert "forty-two" in result["final_response"]
assert result["final_response"].count(repeated_tail) == 1
def test_output_limit_continuation_preserves_intentional_repetition(self, loop_agent):
from tests.run_agent.test_run_agent import _mock_response
repeated = "This intentionally repeated sentence is longer than thirty-two characters."
first = _mock_response(
content=f"First copy: {repeated}", finish_reason=FINISH_REASON_LENGTH,
)
continuation = _mock_response(content=repeated, finish_reason="stop")
loop_agent.client.chat.completions.create.side_effect = [first, continuation]
with (
patch.object(loop_agent, "_persist_session"),
patch.object(loop_agent, "_save_trajectory"),
patch.object(loop_agent, "_cleanup_task_resources"),
):
result = loop_agent.run_conversation("repeat this sentence twice")
assert loop_agent.client.chat.completions.create.call_count == 2
assert result["final_response"].count(repeated) == 2
class TestContentFilterStallActivatesFallback:
"""Regression for #32421: a provider output-layer content safety filter