From 1a02e8a7932e72593086ccce07632d5632a264c4 Mon Sep 17 00:00:00 2001 From: kshitij Date: Fri, 7 Aug 2026 15:13:02 +0530 Subject: [PATCH] fix(agent): preserve destroyed tool-call argument bytes in the WARNING log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- agent/agent_runtime_helpers.py | 14 ++++++++++++-- agent/message_sanitization.py | 17 +++++++++++++++-- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 062549ed7f..4a1e969e92 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -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 "-", diff --git a/agent/message_sanitization.py b/agent/message_sanitization.py index dc4df3dd27..0fa7d95fff 100644 --- a/agent/message_sanitization.py +++ b/agent/message_sanitization.py @@ -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 "{}"