Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 48 additions & 10 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,29 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
"you were planning).]"
)

def _append_continuation_user_message(
messages: List[Dict[str, Any]],
content: str,
) -> None:
"""Coalesce a continuation prompt with an adjacent user turn."""
if messages and isinstance(messages[-1], dict) and messages[-1].get("role") == "user":
previous = messages[-1]
previous_content = previous.get("content")
if isinstance(previous_content, str):
previous["content"] = (
f"{previous_content}\n\n{content}"
if previous_content and content
else (previous_content or content)
)
return
if isinstance(previous_content, list):
previous["content"] = [
*previous_content,
{"type": "text", "text": content},
]
return
messages.append({"role": "user", "content": content})


# Shared recovery hint appended to every content-policy refusal message. Both
# the HTTP-200 refusal path (``finish_reason=content_filter``) and the
Expand Down Expand Up @@ -2025,15 +2048,25 @@ def _perform_api_call(next_api_kwargs):
)
if assistant_message is not None and not _trunc_has_tool_calls:
length_continue_retries += 1
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(interim_msg)
_is_partial_stream_stub = (
getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID
)
_trunc_content_empty = not bool(
getattr(assistant_message, "content", None)
)
# Gemini rejects history containing an assistant
# turn with neither content nor tool_calls. A
# partial-stream-stub can legitimately have no
# recoverable visible text after a network reset;
# ask for continuation without poisoning the next
# request payload with an empty assistant message.
if not (_is_partial_stream_stub and _trunc_content_empty):
Comment thread
Qwinty marked this conversation as resolved.
interim_msg = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(interim_msg)
if assistant_message.content:
truncated_response_parts.append(assistant_message.content)

if length_continue_retries < 4:
_is_partial_stream_stub = (
getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID
)
_dropped_tools = getattr(
response, "_dropped_tool_names", None
)
Expand Down Expand Up @@ -2061,11 +2094,16 @@ def _perform_api_call(next_api_kwargs):
_continue_content = _get_continuation_prompt(
_is_partial_stream_stub, _dropped_tools
)
continue_msg = {
"role": "user",
"content": _continue_content,
}
messages.append(continue_msg)
if _is_partial_stream_stub and _trunc_content_empty:
_append_continuation_user_message(
messages,
_continue_content,
)
else:
messages.append({
"role": "user",
"content": _continue_content,
})
agent._session_messages = messages
_retry.restart_with_length_continuation = True
break
Expand Down
100 changes: 100 additions & 0 deletions tests/run_agent/test_partial_stream_finish_reason.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,106 @@ def test_partial_stream_stub_does_not_exit_loop_immediately(self, loop_agent):
assert "forty-two" in result["final_response"]


def test_empty_partial_stream_stub_skips_empty_assistant_turn(self, loop_agent):
"""Gemini rejects assistant messages with neither content nor
tool_calls. When a network reset leaves no recoverable text, the loop
should request continuation without appending an empty assistant turn."""

from tests.run_agent.test_run_agent import _mock_assistant_msg, _mock_response

partial_stub = SimpleNamespace(
id=PARTIAL_STREAM_STUB_ID,
model="test/model",
choices=[SimpleNamespace(
index=0,
message=_mock_assistant_msg(content=None),
finish_reason=FINISH_REASON_LENGTH,
)],
usage=None,
)
continuation = _mock_response(
content="Recovered answer.", finish_reason="stop",
)

loop_agent.client.chat.completions.create.side_effect = [
partial_stub, 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("ask me something")

assert result["completed"] is True
assert result["final_response"] == "Recovered answer."

second_call_kwargs = loop_agent.client.chat.completions.create.call_args_list[1]
msgs = second_call_kwargs.kwargs.get("messages") or second_call_kwargs.args[0].get("messages")
assert not any(
m.get("role") == "assistant"
and not (m.get("content") or "")
and not m.get("tool_calls")
for m in msgs
), msgs
assert msgs[-1]["role"] == "user"
assert "network error mid-stream" in (msgs[-1].get("content") or "")

def test_empty_partial_stream_stub_coalesces_multimodal_user_continuation(
self,
loop_agent,
):
"""The retry prompt must remain in the initial multimodal user turn."""
from tests.run_agent.test_run_agent import _mock_response, _mock_assistant_msg

partial_stub = SimpleNamespace(
id=PARTIAL_STREAM_STUB_ID,
model="test/model",
choices=[SimpleNamespace(
index=0,
message=_mock_assistant_msg(content=None),
finish_reason=FINISH_REASON_LENGTH,
)],
usage=None,
)
continuation = _mock_response(
content="Recovered multimodal answer.",
finish_reason="stop",
)
loop_agent.client.chat.completions.create.side_effect = [
partial_stub,
continuation,
]
multimodal_prompt = [
{"type": "text", "text": "Describe this image."},
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,YWJj"},
},
]

with (
patch.object(loop_agent, "_persist_session"),
patch.object(loop_agent, "_save_trajectory"),
patch.object(loop_agent, "_cleanup_task_resources"),
patch.object(loop_agent, "_model_supports_vision", return_value=True),
):
result = loop_agent.run_conversation(multimodal_prompt)

assert result["completed"] is True
second_call_kwargs = loop_agent.client.chat.completions.create.call_args_list[1]
msgs = second_call_kwargs.kwargs.get("messages") or second_call_kwargs.args[0].get("messages")
assert not any(
first.get("role") == second.get("role") == "user"
for first, second in zip(msgs, msgs[1:])
), msgs
final_user = next(m for m in reversed(msgs) if m.get("role") == "user")
assert isinstance(final_user["content"], list)
assert final_user["content"][-1]["type"] == "text"
assert "network error mid-stream" in final_user["content"][-1]["text"]


class TestContentFilterStallActivatesFallback:
"""Regression for #32421: a provider output-layer content safety filter
(e.g. MiniMax ``output new_sensitive (1027)``) terminates a streaming
Expand Down
Loading