fix(agent): preserve destroyed tool-call argument bytes in the WARNING log

Review follow-up (W1): the pre-send transcript sanitizer
(agent_runtime_helpers.sanitize_tool_call_arguments) runs on the
PERSISTED messages list before every api_messages build and rewrites any
json.loads-failing argument string to "{}" in the transcript, prepending
a corruption marker to the paired tool result. That in-transcript repair
is deliberate (the stored turn must be replayable next call), but it
destroys the model's original bytes — for a truncated write_file call
those bytes are the user's streamed file content (#80498), and they
previously survived only as an 80-char log preview.

Until a sidecar-preservation design exists, make the bytes recoverable:
both destruction sites (the transcript sanitizer's WARNING and
_repair_tool_call_arguments' unrepairable-path WARNING) now log the full
original argument string bounded at 100KB instead of 80 chars. Corrupted
calls are rare; an oversized WARNING is a fair price for the only copy
of real user content.
This commit is contained in:
kshitij
2026-08-07 15:13:02 +05:30
committed by kshitij
parent c18e19c3c7
commit 1a02e8a793
2 changed files with 27 additions and 4 deletions

View File

@@ -33,6 +33,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from hermes_cli.timeouts import get_provider_request_timeout
from agent.message_sanitization import _FULL_ARGS_LOG_BOUND
from agent.prompt_builder import format_steer_marker
from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_result_message
from agent.trajectory import convert_scratchpad_to_think
@@ -385,10 +386,19 @@ def sanitize_tool_call_arguments(
# itself becomes an orphan (#58168).
tool_call_id = _ra().AIAgent._get_tool_call_id_static(tool_call) or None
function_name = function.get("name", "?")
preview = arguments[:80]
# Log the FULL original argument string (bounded), not an
# 80-char preview: this branch is about to overwrite the
# only copy of these bytes in the transcript with "{}", and
# for a truncated write_file/patch call the destroyed
# arguments contain real user content (#80498 — streamed
# file content survived only as a log preview). A corrupted
# call is rare, so the oversized WARNING is a fair price for
# making the data recoverable from agent.log.
preview = arguments[:_FULL_ARGS_LOG_BOUND]
log.warning(
"Corrupted tool_call arguments repaired before request "
"(session=%s, message_index=%s, tool_call_id=%s, function=%s, preview=%r)",
"(session=%s, message_index=%s, tool_call_id=%s, function=%s, "
"original_arguments=%r)",
session_id or "-",
message_index,
tool_call_id or "-",

View File

@@ -183,6 +183,15 @@ def _escape_invalid_chars_in_json_strings(raw: str) -> str:
return "".join(out)
# When a repair is about to destroy the only copy of a tool call's original
# argument bytes (rewriting them to "{}"), the WARNING log is the last
# surviving copy of content that can hold real user data (#80498). Bound the
# logged string at this size instead of a short preview so it stays
# recoverable from agent.log without letting a pathological payload flood
# the log.
_FULL_ARGS_LOG_BOUND = 100_000
def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
"""Attempt to repair malformed tool_call argument JSON.
@@ -271,11 +280,15 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
pass
# Last resort: replace with empty object so the API request doesn't
# crash the entire session.
# crash the entire session. Log the FULL original string (bounded) —
# for callers that discard the original (e.g. the pre-send transcript
# sanitizer), this WARNING is the last surviving copy of bytes that can
# contain real user content (#80498: a truncated write_file call's
# streamed file content).
logger.warning(
"Unrepairable tool_call arguments for %s — "
"replaced with empty object (was: %s)",
tool_name, raw_stripped[:80],
tool_name, raw_stripped[:_FULL_ARGS_LOG_BOUND],
)
return "{}"