fix(agent): run the outbound payload sanitizers on the summary request

Building the summary through `_build_api_kwargs` means it now carries
`tools`, but it still skipped the `_sanitize_structure_surrogates` /
`_force_ascii_payload` chokepoint the main loop applies after building
(turn_api_request). On cache-planned routes the main loop scrubs a deep
copy of the tools, so `agent.tools` can still hold a lone surrogate that
providers reject with a non-retryable 400 (#50959 class) — a request the
tool-less summary never used to make. Apply the same two passes here.
This commit is contained in:
kshitijk4poor
2026-09-14 14:32:34 +00:00
committed by kshitij
parent 488f2fc86d
commit 53cfca9766
2 changed files with 25 additions and 1 deletions
+8 -1
View File
@@ -38,7 +38,8 @@ from agent.model_metadata import is_local_endpoint
from agent.message_content import flatten_message_text
from agent.message_metadata import append_message, stamp_message_timestamp
from agent.message_sanitization import (
_sanitize_surrogates, _repair_tool_call_arguments, normalize_finish_reason as _normalize_finish_reason,
_sanitize_structure_non_ascii, _sanitize_structure_surrogates, _sanitize_surrogates,
_repair_tool_call_arguments, normalize_finish_reason as _normalize_finish_reason,
)
from agent.reasoning_summaries import append_streamed_reasoning_detail, separate_glued_reasoning_blocks
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
@@ -2055,6 +2056,12 @@ def _chat_summary_attempt(agent, api_messages: list, api_request_id: str):
# tool_choice="none": SGLang removes tools from its rendered prompt in that
# mode, which would invalidate the cached prefix this path is preserving.
summary_kwargs = agent._build_api_kwargs(api_messages)
# Same outbound chokepoint as turn_api_request: the summary now carries ``tools``, and on
# cache-planned routes the main loop scrubbed a deep copy, so ``agent.tools`` may still hold
# the lone surrogates / non-ASCII bytes the provider 400s on (#50959 class).
_sanitize_structure_surrogates(summary_kwargs)
if agent._force_ascii_payload:
_sanitize_structure_non_ascii(summary_kwargs)
def _attempt(retry_count: int) -> str:
summary_client = agent._ensure_primary_openai_client(reason="iteration_limit_summary_retry" if retry_count else "iteration_limit_summary")
+17
View File
@@ -2808,6 +2808,23 @@ class TestHandleMaxIterations:
assert all(summary["model"] == ordinary["model"] for summary in summaries)
assert all(summary["messages"][0] == ordinary["messages"][0] for summary in summaries)
def test_summary_request_scrubs_surrogates_in_tool_schema(self, agent):
"""The summary rides the same outbound surrogate chokepoint as the main loop (#50959 class)."""
agent.client.chat.completions.create.return_value = _mock_response(content="Summary")
agent._cached_system_prompt = "You are helpful."
agent.tools = [{"type": "function", "function": {
"name": "web_search", "description": "lone surrogate \ud83d here",
"parameters": {"type": "object", "properties": {}},
}}]
result = agent._handle_max_iterations([{"role": "user", "content": "do stuff"}], 60)
assert result == "Summary"
sent = agent.client.chat.completions.create.call_args.kwargs
description = sent["tools"][0]["function"]["description"]
assert "\ud83d" not in description
description.encode("utf-8") # a provider serializes this; lone surrogates raise here
def test_summary_tool_call_only_response_retries_once(self, agent):
"""A tool-only summary is scrubbed to empty and receives one retry."""
agent.client.chat.completions.create.side_effect = [