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
5 changes: 4 additions & 1 deletion agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,8 @@ def sanitize_tool_call_arguments(
session_id: str = None,
) -> int:
"""Repair corrupted assistant tool-call argument JSON in-place."""
from agent.message_sanitization import repair_tool_call_arguments_with_status

log = logger or logging.getLogger(__name__)
if not isinstance(messages, list):
return 0
Expand Down Expand Up @@ -308,6 +310,7 @@ def _prepend_marker(tool_msg: dict) -> None:
except json.JSONDecodeError:
tool_call_id = tool_call.get("id")
function_name = function.get("name", "?")
repair = repair_tool_call_arguments_with_status(arguments, function_name)
preview = arguments[:80]
log.warning(
"Corrupted tool_call arguments repaired before request "
Expand All @@ -318,7 +321,7 @@ def _prepend_marker(tool_msg: dict) -> None:
function_name,
preview,
)
function["arguments"] = "{}"
function["arguments"] = repair.arguments

existing_tool_msg = None
scan_index = message_index + 1
Expand Down
13 changes: 7 additions & 6 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from agent.model_metadata import is_local_endpoint
from agent.message_sanitization import (
_sanitize_surrogates,
_repair_tool_call_arguments,
repair_tool_call_arguments_with_status,
)
from tools.terminal_tool import is_persistent_env
from utils import base_url_host_matches, base_url_hostname, env_float, env_int
Expand Down Expand Up @@ -2234,12 +2234,13 @@ def _call_chat_completions():
# commas, unclosed brackets, Python None, etc.
# Without repair, these hit the truncation handler
# and kill the session. _repair_tool_call_arguments
# returns "{}" for unrepairable args, which is far
# better than a crashed session.
repaired = _repair_tool_call_arguments(arguments, tool_name)
if repaired != "{}":
# reports unrepairable args separately from legitimate
# empty-object normalization, so only genuine failures
# hit the truncation handler.
repair = repair_tool_call_arguments_with_status(arguments, tool_name)
if repair.success:
# Successfully repaired — use the fixed args
arguments = repaired
arguments = repair.arguments
else:
# Unrepairable — flag for truncation handling
has_truncated_tool_args = True
Expand Down
57 changes: 36 additions & 21 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from agent.memory_manager import build_memory_context_block
from agent.message_sanitization import (
close_interrupted_tool_sequence,
_repair_tool_call_arguments,
repair_tool_call_arguments_with_status,
_sanitize_messages_non_ascii,
_sanitize_messages_surrogates,
_sanitize_structure_non_ascii,
Expand Down Expand Up @@ -932,10 +932,10 @@ def run_conversation(
),
}}
except Exception:
tc["function"]["arguments"] = _repair_tool_call_arguments(
tc["function"]["arguments"] = repair_tool_call_arguments_with_status(
tc["function"]["arguments"],
tc["function"].get("name", "?"),
)
).arguments
new_tcs.append(tc)
am["tool_calls"] = new_tcs

Expand Down Expand Up @@ -4346,7 +4346,14 @@ def _perform_api_call(next_api_kwargs):
try:
json.loads(args)
except json.JSONDecodeError as e:
invalid_json_args.append((tc.function.name, str(e)))
repair = repair_tool_call_arguments_with_status(
args,
tc.function.name,
)
if repair.success:
tc.function.arguments = repair.arguments
continue
invalid_json_args.append((tc, str(e)))

if invalid_json_args:
# Check if the invalid JSON is due to truncation rather
Expand All @@ -4357,31 +4364,39 @@ def _perform_api_call(next_api_kwargs):
# (after stripping whitespace) are cut off mid-stream.
_truncated = any(
not (tc.function.arguments or "").rstrip().endswith(("}", "]"))
for tc in assistant_message.tool_calls
if tc.function.name in {n for n, _ in invalid_json_args}
for tc, _ in invalid_json_args
)
if _truncated:
agent._vprint(
f"{agent.log_prefix}⚠️ Truncated tool call arguments detected "
f"(finish_reason={finish_reason!r}) — refusing to execute.",
f"(finish_reason={finish_reason!r}) — returning tool error.",
force=True,
)
agent._invalid_json_retries = 0
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)
return {
"final_response": "Response truncated due to output length limit",
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"partial": True,
"error": "Response truncated due to output length limit",
}
recovery_assistant = agent._build_assistant_message(assistant_message, finish_reason)
messages.append(recovery_assistant)
invalid_call_ids = {tc.id for tc, _ in invalid_json_args}
for tc in assistant_message.tool_calls:
if tc.id in invalid_call_ids:
tool_result = (
"Error: Tool call arguments were truncated due to the "
"output length limit and could not be repaired. Please "
"shorten the content or split it into multiple tool calls."
)
else:
tool_result = "Skipped: other tool call in this response had truncated arguments."
messages.append({
"role": "tool",
"name": tc.function.name,
"tool_call_id": tc.id,
"content": tool_result,
})
continue

# Track retries for invalid JSON arguments
agent._invalid_json_retries += 1

tool_name, error_msg = invalid_json_args[0]
tool_name, error_msg = invalid_json_args[0][0].function.name, invalid_json_args[0][1]
agent._buffer_vprint(f"⚠️ Invalid JSON in tool call arguments for '{tool_name}': {error_msg}")

if agent._invalid_json_retries < 3:
Expand All @@ -4399,10 +4414,10 @@ def _perform_api_call(next_api_kwargs):
messages.append(recovery_assistant)

# Respond with tool error results for each tool call
invalid_names = {name for name, _ in invalid_json_args}
invalid_call_ids = {tc.id for tc, _ in invalid_json_args}
for tc in assistant_message.tool_calls:
if tc.function.name in invalid_names:
err = next(e for n, e in invalid_json_args if n == tc.function.name)
if tc.id in invalid_call_ids:
err = next(e for invalid_tc, e in invalid_json_args if invalid_tc.id == tc.id)
tool_result = (
f"Error: Invalid JSON arguments. {err}. "
f"For tools with no required parameters, use an empty object: {{}}. "
Expand Down
47 changes: 37 additions & 10 deletions agent/message_sanitization.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,20 @@
import json
import logging
import re
from dataclasses import dataclass
from typing import Any

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class ToolCallArgumentsRepair:
"""Result of a tool-call argument repair attempt."""

arguments: str
repaired: bool
success: bool

# Lone surrogate code points are invalid in UTF-8 and crash json.dumps
# inside the OpenAI SDK. Used by every surrogate-sanitization helper
# below as well as by run_agent and the CLI for paste-from-clipboard
Expand Down Expand Up @@ -182,26 +192,31 @@ def _escape_invalid_chars_in_json_strings(raw: str) -> str:
return "".join(out)


def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
def repair_tool_call_arguments_with_status(
raw_args: str,
tool_name: str = "?",
) -> ToolCallArgumentsRepair:
"""Attempt to repair malformed tool_call argument JSON.

Models like GLM-5.1 via Ollama can produce truncated JSON, trailing
commas, Python ``None``, etc. The API proxy rejects these with HTTP 400
"invalid tool call arguments". This function applies common repairs;
if all fail it returns ``"{}"`` so the request succeeds (better than
crashing the session). All repairs are logged at WARNING level.
"invalid tool call arguments". This function applies common repairs and
reports whether repair actually succeeded. Empty argument strings and
Python ``None`` are successful normalisations to ``{}``; unrepairable
non-empty JSON returns ``{}`` with ``success=False`` so callers can surface
a model-visible tool error instead of executing an empty call.
"""
raw_stripped = raw_args.strip() if isinstance(raw_args, str) else ""

# Fast-path: empty / whitespace-only -> empty object
if not raw_stripped:
logger.warning("Sanitized empty tool_call arguments for %s", tool_name)
return "{}"
return ToolCallArgumentsRepair("{}", True, True)

# Python-literal None -> normalise to {}
if raw_stripped == "None":
logger.warning("Sanitized Python-None tool_call arguments for %s", tool_name)
return "{}"
return ToolCallArgumentsRepair("{}", True, True)

# Repair pass 0: llama.cpp backends sometimes emit literal control
# characters (tabs, newlines) inside JSON string values. json.loads
Expand All @@ -216,14 +231,19 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
"Repaired unescaped control chars in tool_call arguments for %s",
tool_name,
)
return reserialised
return ToolCallArgumentsRepair(
reserialised,
reserialised != raw_stripped,
True,
)
except (json.JSONDecodeError, TypeError, ValueError):
pass

# Attempt common JSON repairs
fixed = raw_stripped
# 1. Strip trailing commas before } or ]
fixed = re.sub(r',\s*([}\]])', r'\1', fixed)
fixed = re.sub(r',\s*$', '', fixed)
# 2. Close unclosed structures
open_curly = fixed.count('{') - fixed.count('}')
open_bracket = fixed.count('[') - fixed.count(']')
Expand All @@ -250,7 +270,7 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
"Repaired malformed tool_call arguments for %s: %s → %s",
tool_name, raw_stripped[:80], fixed[:80],
)
return fixed
return ToolCallArgumentsRepair(fixed, fixed != raw_stripped, True)
except json.JSONDecodeError:
pass

Expand All @@ -265,7 +285,7 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
"Repaired control-char-laced tool_call arguments for %s: %s → %s",
tool_name, raw_stripped[:80], escaped[:80],
)
return escaped
return ToolCallArgumentsRepair(escaped, True, True)
except (json.JSONDecodeError, TypeError, ValueError):
pass

Expand All @@ -276,7 +296,12 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
"replaced with empty object (was: %s)",
tool_name, raw_stripped[:80],
)
return "{}"
return ToolCallArgumentsRepair("{}", True, False)


def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
"""Backward-compatible wrapper returning only repaired argument JSON."""
return repair_tool_call_arguments_with_status(raw_args, tool_name).arguments


def close_interrupted_tool_sequence(messages: list, final_response: Any = None) -> bool:
Expand Down Expand Up @@ -468,6 +493,8 @@ def _walk(node):
"_sanitize_structure_surrogates",
"_sanitize_messages_surrogates",
"_escape_invalid_chars_in_json_strings",
"ToolCallArgumentsRepair",
"repair_tool_call_arguments_with_status",
"_repair_tool_call_arguments",
"_strip_non_ascii",
"_sanitize_messages_non_ascii",
Expand Down
2 changes: 1 addition & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ class AIAgent:

_TOOL_CALL_ARGUMENTS_CORRUPTION_MARKER = (
"[hermes-agent: tool call arguments were corrupted in this session and "
"have been dropped to keep the conversation alive. See issue #15236.]"
"were repaired or dropped to keep the conversation alive. See issue #15236.]"
)

@property
Expand Down
20 changes: 19 additions & 1 deletion tests/run_agent/test_repair_tool_call_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json

from agent.message_sanitization import repair_tool_call_arguments_with_status
from run_agent import _repair_tool_call_arguments


Expand Down Expand Up @@ -80,6 +81,18 @@ def test_unrepairable_partial_returns_empty_object(self):
# Truncated in the middle of a string key — bracket closing won't help
assert _repair_tool_call_arguments('{"truncated": "val', "t") == "{}"

def test_unrepairable_partial_reports_failure(self):
result = repair_tool_call_arguments_with_status('{"truncated": "val', "t")
assert result.arguments == "{}"
assert result.repaired is True
assert result.success is False

def test_legitimate_empty_object_reports_success(self):
result = repair_tool_call_arguments_with_status(" \n\t ", "t")
assert result.arguments == "{}"
assert result.repaired is True
assert result.success is True

# -- Valid JSON passthrough (this path is via except, but still works) --

def test_already_valid_json_passes_through(self):
Expand All @@ -98,6 +111,12 @@ def test_trailing_comma_plus_unclosed_brace(self):
# May or may not fully recover — verify valid JSON at minimum.
json.loads(result)

def test_repairable_truncation_reports_success(self):
result = repair_tool_call_arguments_with_status('{"a": 1, "b": 2,', "t")
assert json.loads(result.arguments) == {"a": 1, "b": 2}
assert result.repaired is True
assert result.success is True

def test_real_world_glm_truncation(self):
"""Simulates GLM-5.1 truncating mid-argument."""
raw = '{"command": "ls -la /tmp", "timeout": 30, "background":'
Expand Down Expand Up @@ -139,4 +158,3 @@ def test_control_chars_with_trailing_comma(self):
result = _repair_tool_call_arguments(raw, "t")
parsed = json.loads(result)
assert "line" in parsed["msg"]

Loading
Loading