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
11 changes: 11 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from agent.trajectory import convert_scratchpad_to_think
from agent.credential_pool import STATUS_EXHAUSTED
from agent.error_classifier import FailoverReason
from agent.message_sanitization import _normalize_assistant_tool_call_content
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -2514,6 +2515,16 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
dropped_empty_tool_calls,
)

# --- Normalize assistant messages: tool_calls + content:"" -> content:None ---
# DeepSeek (and other strict OpenAI-compatible providers) return assistant
# messages with tool_calls and content:"" on the initial response. When
# these messages are re-sent in a subsequent turn, the API rejects them
# with HTTP 400 "An assistant message with 'tool_calls' must be followed
# by tool messages responding to each 'tool_call_id'." Normalizing to
# content:None avoids this while preserving the message's intent (the model
# produced no text, only tool calls). (#63200)
_normalize_assistant_tool_call_content(messages)

# --- Repair tool_calls whose function.name is empty/missing ---
# Some providers (and partially-streamed responses) emit a tool_call with
# id="call_xxx" but function.name="". Downstream Responses-API adapters
Expand Down
50 changes: 50 additions & 0 deletions agent/message_sanitization.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,27 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
except (json.JSONDecodeError, TypeError, ValueError):
pass

# Repair pass 5: concatenated JSON objects (Gemini OpenAI-compat).
# When the payload is ≥2 complete objects glued together, return the
# first one rather than giving up entirely.
try:
decoder = json.JSONDecoder()
first_obj, end = decoder.raw_decode(raw_stripped)
tail = raw_stripped[end:].strip()
if tail:
# At least one more complete object follows — try to decode
# it to confirm; if it works we have ≥2 objects.
decoder.raw_decode(tail)
result = json.dumps(first_obj, separators=(",", ":"))
logger.warning(
"Repaired concatenated tool_call arguments for %s — "
"split ≥2 objects, returning first (was: %s)",
tool_name, raw_stripped[:80],
)
return result
except (json.JSONDecodeError, TypeError, ValueError):
pass

# Last resort: replace with empty object so the API request doesn't
# crash the entire session.
logger.warning(
Expand Down Expand Up @@ -461,6 +482,34 @@ def _walk(node):
return found


def _normalize_assistant_tool_call_content(messages: list) -> bool:
"""Normalize assistant messages that have ``tool_calls`` and ``content: ""`` to ``content: None``.

Some providers (notably DeepSeek via the OpenAI-compatible endpoint) return
assistant messages with ``content: ""`` alongside ``tool_calls``. When these
messages are later re-sent in a followup turn the API strictly validates
message structure and rejects the empty-string content with::

HTTP 400: An assistant message with 'tool_calls' must be followed by
tool messages responding to each 'tool_call_id'.

Normalizing ``content: ""`` → ``content: None`` avoids this rejection while
preserving the message's semantic intent (the model produced no text, only
tool calls). Other providers (GLM/Zhipu) silently accept both formats, so
this is a universal safety net applied before every API call.

Mutates messages in place. Returns True if any messages were normalized.
"""
found = False
for msg in messages:
if not isinstance(msg, dict):
continue
if msg.get("role") == "assistant" and msg.get("tool_calls") and msg.get("content") == "":
msg["content"] = None
found = True
return found


__all__ = [
"_SURROGATE_RE",
"close_interrupted_tool_sequence",
Expand All @@ -474,4 +523,5 @@ def _walk(node):
"_sanitize_tools_non_ascii",
"_strip_images_from_messages",
"_sanitize_structure_non_ascii",
"_normalize_assistant_tool_call_content",
]
Loading