Skip to content
Merged
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
40 changes: 38 additions & 2 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,12 +249,42 @@ def sanitize_tool_call_arguments(
*,
logger=None,
session_id: str = None,
cursor: Optional[dict] = None,
) -> int:
"""Repair corrupted assistant tool-call argument JSON in-place."""
"""Repair corrupted assistant tool-call argument JSON in-place.

``cursor`` (optional) is a caller-owned dict used to skip re-validating
messages already validated on a previous call. It stores, under
``"prefix"``, the exact message *objects* (strong references) validated
last time, in order. On the next call, the longest contiguous prefix of
``messages`` whose objects are ``is``-identical to the stored prefix is
skipped; scanning starts at the first divergence (conservative: any
reordering, truncation, compression rewrite, or mid-list insertion breaks
identity at that index and everything from there is re-scanned).

Safety argument for skipping: a message in the matched prefix was fully
scanned before — every tool_call argument was either already valid JSON
or was rewritten to ``"{}"`` (valid). The only code paths that mutate
``function["arguments"]`` on live history dicts between calls are the
surrogate / non-ASCII sanitizers, which substitute characters *inside*
JSON string values and cannot invalidate JSON syntax. Compression,
repair, undo, and steer paths replace or reorder message dicts, which
breaks the identity match and forces a re-scan. Holding strong
references (the objects themselves, not ``id()``s) makes address reuse
aliasing (#50372-style) impossible.
"""
log = logger or logging.getLogger(__name__)
if not isinstance(messages, list):
return 0

start_index = 0
if cursor is not None:
prev_prefix = cursor.get("prefix")
if isinstance(prev_prefix, list):
limit = min(len(prev_prefix), len(messages))
while start_index < limit and messages[start_index] is prev_prefix[start_index]:
start_index += 1

repaired = 0
marker = _ra().AIAgent._TOOL_CALL_ARGUMENTS_CORRUPTION_MARKER

Expand All @@ -275,7 +305,7 @@ def _prepend_marker(tool_msg: dict) -> None:
existing_text = str(existing)
tool_msg["content"] = f"{marker}\n{existing_text}"

message_index = 0
message_index = start_index
while message_index < len(messages):
msg = messages[message_index]
if not isinstance(msg, dict) or msg.get("role") != "assistant":
Expand Down Expand Up @@ -356,6 +386,12 @@ def _prepend_marker(tool_msg: dict) -> None:

message_index += 1

if cursor is not None:
# Strong references to the exact objects validated this call, in
# order. Any future divergence (compression, undo, repair, steer)
# breaks identity at the divergent index and re-scans from there.
cursor["prefix"] = messages[:]

return repaired


Expand Down
13 changes: 13 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -1386,10 +1386,23 @@ def run_conversation(
# However, providers like Moonshot AI require a separate 'reasoning_content' field
# on assistant messages with tool_calls. We handle both cases here.
request_logger = getattr(agent, "logger", None) or logging.getLogger(__name__)
# Per-agent validation cursor: skips re-json.loads-ing tool_call
# arguments on history messages already validated in a previous
# iteration. Identity-keyed (strong refs) — compression/undo/repair
# rewriting the list breaks the prefix match and forces a re-scan
# from the divergence point. See sanitize_tool_call_arguments.
_sanitize_cursor = getattr(agent, "_sanitize_args_cursor", None)
if _sanitize_cursor is None:
_sanitize_cursor = {}
try:
agent._sanitize_args_cursor = _sanitize_cursor
except Exception:
pass
repaired_tool_calls = agent._sanitize_tool_call_arguments(
messages,
logger=request_logger,
session_id=agent.session_id,
cursor=_sanitize_cursor,
)
if repaired_tool_calls > 0:
request_logger.info(
Expand Down
88 changes: 83 additions & 5 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2851,14 +2851,92 @@ def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
image — the Anthropic pricing model — instead of counting raw base64
character length. Without this, a single ~1MB screenshot would be
estimated at ~250K tokens and trigger premature context compression.

Per-message results are memoized (see ``_estimate_message_tokens_cached``)
keyed on a deep *identity fingerprint* of the message, so re-walking a
long history every iteration only pays for messages whose object graph
actually changed. The memo is exact: equal fingerprints imply identical
leaf objects and structure, hence an identical estimate.
"""
_IMAGE_TOKEN_COST = 1500
text_tokens = 0
image_tokens = 0
total = 0
for msg in messages:
text_tokens += _estimate_message_tokens_without_images(msg)
image_tokens += _count_image_tokens(msg, _IMAGE_TOKEN_COST)
return text_tokens + image_tokens
total += _estimate_message_tokens_cached(msg, _IMAGE_TOKEN_COST)
return total


# --- Per-message token-estimate memo -------------------------------------
#
# ``estimate_messages_tokens_rough`` is called on the full history every
# loop iteration (conversation_loop preflight), repeatedly during compaction
# telemetry, and inside an O(n^2) shrink loop in moa_loop. The per-message
# helpers are pure functions of the message's value, so a memo keyed on a
# fingerprint that uniquely determines the value is exactly equivalent.
#
# Fingerprint design (soundness argument):
# * strings are fingerprinted by ``id()`` AND pinned (a strong reference is
# stored in the cache entry). While the entry lives, that id cannot be
# reused by another object, so id-equality implies object-equality —
# strings are immutable, so value-equality too (no #50372-style aliasing).
# * ints/floats/bools/None are fingerprinted by value.
# * dicts/lists recurse structurally, preserving key order — ``str(shadow)``
# depends on insertion order, so order is part of the key.
# * any other type aborts the memo and falls through to a direct compute.
# Equal fingerprints therefore imply deep-equal messages built from identical
# immutable leaves ⇒ identical ``str(shadow)`` bytes ⇒ identical estimate.
#
# Because the api_messages build shallow-copies history dicts each iteration,
# the copies share the same content strings — so unchanged history messages
# hit the memo even though the outer dicts are fresh objects every turn.
_MSG_TOKENS_CACHE: Dict[Any, Tuple[list, int]] = {}
_MSG_TOKENS_CACHE_MAX = 4096


def _msg_fingerprint(value: Any, pins: list) -> Any:
if value is None or value is True or value is False:
return value
t = type(value)
if t is str:
pins.append(value)
return ("s", id(value))
if t is int or t is float:
return ("n", t.__name__, value)
if t is dict:
return ("d", tuple(
(_msg_fingerprint(k, pins), _msg_fingerprint(v, pins))
for k, v in value.items()
))
if t is list:
return ("l", tuple(_msg_fingerprint(v, pins) for v in value))
if t is tuple:
return ("t", tuple(_msg_fingerprint(v, pins) for v in value))
raise ValueError("unfingerprintable message value")


def _estimate_message_tokens_cached(msg: Any, image_cost: int) -> int:
try:
pins: list = []
key = _msg_fingerprint(msg, pins)
hash(key)
except Exception:
return (
_estimate_message_tokens_without_images(msg)
+ _count_image_tokens(msg, image_cost)
)
cached = _MSG_TOKENS_CACHE.get(key)
if cached is not None:
return cached[1]
tokens = (
_estimate_message_tokens_without_images(msg)
+ _count_image_tokens(msg, image_cost)
)
_MSG_TOKENS_CACHE[key] = (pins, tokens)
while len(_MSG_TOKENS_CACHE) > _MSG_TOKENS_CACHE_MAX:
try:
_MSG_TOKENS_CACHE.pop(next(iter(_MSG_TOKENS_CACHE)))
except (StopIteration, KeyError, RuntimeError):
break
return tokens


def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int:
Expand Down
33 changes: 31 additions & 2 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2004,7 +2004,27 @@ def _flush_messages_to_session_db_unlocked(
if isinstance(item, dict)
}

for _msg_idx, msg in enumerate(messages):
# Bounded scan: skip the longest identity-matched prefix of the
# list snapshot taken at the end of the previous successful flush.
# Every message in that snapshot was already given its final
# disposition (written+stamped, stamped as durable history, or
# skipped as ephemeral scaffolding / non-dict), and no code path
# pops _DB_PERSISTED_MARKER from a live dict in place (compression
# strips markers on fresh copies, which breaks identity here and
# forces a full re-scan). Identity match ⇒ identical skip decision,
# so starting after the matched prefix is behavior-preserving.
_scan_start = 0
_prev_prefix = getattr(self, "_db_flush_scan_prefix", None)
if isinstance(_prev_prefix, list):
_limit = min(len(_prev_prefix), len(messages))
while (
_scan_start < _limit
and messages[_scan_start] is _prev_prefix[_scan_start]
):
_scan_start += 1

for _msg_idx in range(_scan_start, len(messages)):
msg = messages[_msg_idx]
if not isinstance(msg, dict):
continue
# Never write ephemeral recovery scaffolding to the session
Expand Down Expand Up @@ -2153,8 +2173,14 @@ def _flush_messages_to_session_db_unlocked(
# allocated next turn at a recycled address.
self._flushed_db_message_ids = set()
self._last_flushed_db_idx = len(messages)
# Snapshot for the bounded scan above — only on full success, so
# a partially-processed list can never be treated as settled.
self._db_flush_scan_prefix = messages[:]
return True
except Exception as e:
# Force a full re-scan on the next flush: an exception mid-loop
# leaves messages with mixed dispositions.
self._db_flush_scan_prefix = None
logger.warning("Session DB append_message failed: %s", e)
return False

Expand Down Expand Up @@ -6738,10 +6764,13 @@ def _sanitize_tool_call_arguments(
*,
logger=None,
session_id: str = None,
cursor=None,
) -> int:
"""Forwarder — see ``agent.agent_runtime_helpers.sanitize_tool_call_arguments``."""
from agent.agent_runtime_helpers import sanitize_tool_call_arguments
return sanitize_tool_call_arguments(messages, logger=logger, session_id=session_id)
return sanitize_tool_call_arguments(
messages, logger=logger, session_id=session_id, cursor=cursor
)

def _should_sanitize_tool_calls(self) -> bool:
"""Determine if tool_calls need sanitization for strict APIs.
Expand Down
Loading
Loading