perf(agent): memoize send-path tool-call argument canonicalization

The pre-send normalization pass re-canonicalized every historical tool
call's argument JSON on every API-call iteration — quadratic in session
tool-call count. Route it through a bounded value-keyed memo (the
_MSG_TOKENS_CACHE idiom from agent/model_metadata.py): per-iteration
cost is now proportional to new tool calls, not all of them.

Measured (simulated growing session, repo venv): session-total
canonicalization cost 1056 ms -> 58 ms at 500 tool calls x 2 KB args
(18.3x), 5744 ms -> 79 ms at 500 x 16 KB (72.5x). Byte-parity with the
pre-fix logic is asserted at every iteration (unicode, nested,
malformed, empty, non-string args), and a call-count test proves
json.loads invocations went from K(K+1)/2 to K per session.
This commit is contained in:
spfcraze 2026-07-31 23:40:52 -04:00 committed by kshitij
parent a2f95e4c0e
commit cf803603dc
2 changed files with 294 additions and 23 deletions

View File

@ -738,6 +738,79 @@ _CONTENT_POLICY_RECOVERY_HINT = (
)
# Memo for the send-path tool-call argument canonicalization inside
# run_conversation(). That pass re-canonicalizes the arguments string of
# EVERY historical tool call on EVERY API-call iteration (quadratic in
# session tool-call count), and the api_messages copies share the exact
# argument string objects with the persisted history, so the same strings
# come through unchanged iteration after iteration.
#
# Soundness: canonicalization is a pure, deterministic function of the
# input string (fixed separators, sort_keys=True), so a value-keyed memo
# is exact — equal inputs always produce the canonical form computed the
# first time. Malformed strings raise out of json.loads BEFORE anything
# is stored, so the repair fallback below is never memoized and reruns on
# every occurrence, exactly as before. Bounded FIFO eviction mirrors the
# _MSG_TOKENS_CACHE idiom in agent/model_metadata.py.
_CANON_ARGS_CACHE: Dict[str, str] = {}
_CANON_ARGS_CACHE_MAX = 4096
def _canonicalize_tool_call_arguments(arg_str: str) -> str:
"""Return the canonical wire form of a tool-call arguments JSON string.
Raises whatever ``json.loads`` raises on malformed input; the caller
falls back to ``_repair_tool_call_arguments``, exactly as before.
"""
cached = _CANON_ARGS_CACHE.get(arg_str)
if cached is not None:
return cached
canonical = json.dumps(
json.loads(arg_str), separators=(",", ":"), sort_keys=True,
)
_CANON_ARGS_CACHE[arg_str] = canonical
while len(_CANON_ARGS_CACHE) > _CANON_ARGS_CACHE_MAX:
try:
_CANON_ARGS_CACHE.pop(next(iter(_CANON_ARGS_CACHE)))
except (StopIteration, KeyError, RuntimeError):
break
return canonical
def _canonicalize_api_tool_calls(api_messages) -> None:
"""Canonicalize tool-call argument JSON on the send-path message copy.
Rewrites each message's ``tool_calls`` in place (copy-on-write for the
tool-call dicts it canonicalizes; the persisted history is untouched).
The pass still traverses every message and tool call each iteration;
the memo above bounds the JSON parse/serialize work to one round-trip
per UNIQUE argument string instead of one per string per iteration
the quadratic part of the cost. The remaining traversal is pointer
chasing and dict copies, cheap next to a json.loads + json.dumps.
"""
for am in api_messages:
tcs = am.get("tool_calls")
if not tcs:
continue
new_tcs = []
for tc in tcs:
if isinstance(tc, dict) and "function" in tc:
try:
tc = {**tc, "function": {
**tc["function"],
"arguments": _canonicalize_tool_call_arguments(
tc["function"]["arguments"]
),
}}
except Exception:
tc["function"]["arguments"] = _repair_tool_call_arguments(
tc["function"]["arguments"],
tc["function"].get("name", "?"),
)
new_tcs.append(tc)
am["tool_calls"] = new_tcs
def _invalid_tool_name_error_content(name: str, valid_tool_names) -> str:
"""Error-result content for a tool call whose name isn't a real tool.
@ -1719,29 +1792,7 @@ def run_conversation(
for am in api_messages:
if isinstance(am.get("content"), str):
am["content"] = am["content"].strip()
for am in api_messages:
tcs = am.get("tool_calls")
if not tcs:
continue
new_tcs = []
for tc in tcs:
if isinstance(tc, dict) and "function" in tc:
try:
args_obj = json.loads(tc["function"]["arguments"])
tc = {**tc, "function": {
**tc["function"],
"arguments": json.dumps(
args_obj, separators=(",", ":"),
sort_keys=True,
),
}}
except Exception:
tc["function"]["arguments"] = _repair_tool_call_arguments(
tc["function"]["arguments"],
tc["function"].get("name", "?"),
)
new_tcs.append(tc)
am["tool_calls"] = new_tcs
_canonicalize_api_tool_calls(api_messages)
# Proactively strip any surrogate characters before the API call.
# Models served via Ollama (Kimi K2.5, GLM-5, Qwen) can return

View File

@ -0,0 +1,220 @@
"""Byte-parity + complexity proof for the memoized send-path tool-call
argument canonicalization (agent/conversation_loop.py).
The pre-fix inline loop re-ran ``json.loads`` + ``json.dumps(sort_keys=True)``
on EVERY historical tool call's arguments on EVERY API-call iteration —
quadratic in session tool-call count. The fix routes the same logic through
``_canonicalize_api_tool_calls`` with a bounded value-keyed memo
(``_CANON_ARGS_CACHE``).
These tests drive the real shipped function (no copies of the new code) and
assert:
1. byte-parity with the pre-fix logic across a growing simulated session
(unicode, nested, malformed, empty, and non-string arguments included);
2. the persisted history is never mutated (copy-on-write preserved);
3. determinism + idempotence of the canonical form;
4. malformed inputs are never memoized (repair path reruns, as before);
5. the cache stays bounded;
6. complexity: json.loads call count is LINEAR in unique tool calls under
the fix, vs quadratic under the pre-fix logic a deterministic proof
(call counts, not wall clock) that the O(n^2) is gone.
"""
import copy
import json
import random
import pytest
import agent.conversation_loop as cl
from agent.message_sanitization import _repair_tool_call_arguments
random.seed(1234)
UNI = "日本語テキスト🎉 café Ω ≈ 中文字符串"
@pytest.fixture(autouse=True)
def _clear_canon_cache():
# getattr (not cl._CANON_ARGS_CACHE) keeps this fixture from erroring at
# setup on the pre-fix tree, so sabotage runs record real test FAILURES
# (AttributeError inside each test body) instead of collection errors.
cache = getattr(cl, "_CANON_ARGS_CACHE", None)
if cache is not None:
cache.clear()
yield
if cache is not None:
cache.clear()
def build_history(n_tool_calls, arg_bytes=2048):
"""Synthetic session: n assistant tool-call messages (+ tool results).
Includes unicode, malformed, and empty argument strings the cases the
send-path normalization actually sees.
"""
msgs = []
filler = "x" * (arg_bytes - 200)
for i in range(n_tool_calls):
args = json.dumps({"path": f"/tmp/file_{i}.py", "content": filler,
"u": UNI, "n": i, "mode": "write"})
if i % 9 == 8:
args = '{"broken": tru' # malformed -> repair path
elif i % 6 == 5:
args = "" # empty -> repair path
msgs.append({
"role": "assistant", "content": "",
"tool_calls": [{"id": f"call_{i}", "type": "function",
"function": {"name": "write_file",
"arguments": args}}],
})
msgs.append({"role": "tool", "tool_call_id": f"call_{i}",
"name": "write_file", "content": f"result {i} {UNI}"})
return msgs
def canonicalize_pass_OLD(api_messages):
"""Byte-exact reference of the pre-fix inline loop."""
for am in api_messages:
tcs = am.get("tool_calls")
if not tcs:
continue
new_tcs = []
for tc in tcs:
if isinstance(tc, dict) and "function" in tc:
try:
args_obj = json.loads(tc["function"]["arguments"])
tc = {**tc, "function": {
**tc["function"],
"arguments": json.dumps(
args_obj, separators=(",", ":"),
sort_keys=True,
),
}}
except Exception:
tc["function"]["arguments"] = _repair_tool_call_arguments(
tc["function"]["arguments"],
tc["function"].get("name", "?"),
)
new_tcs.append(tc)
am["tool_calls"] = new_tcs
class TestByteParity:
def test_growing_session_every_iteration(self):
"""OLD vs NEW must produce identical api_messages at EVERY iteration
of a growing session not just the final state."""
n = 60
history = build_history(n)
for k in range(1, n + 1):
prefix = history[: 2 * k]
old_msgs = copy.deepcopy(prefix)
new_msgs = copy.deepcopy(prefix)
canonicalize_pass_OLD(old_msgs)
cl._canonicalize_api_tool_calls(new_msgs)
assert old_msgs == new_msgs, f"diverged at iteration {k}"
def test_history_not_mutated(self):
"""The canonicalize path is copy-on-write: with valid args, the
persisted history bytes stay intact even though api_messages
shallow-copies history dicts (shares the nested function dicts).
(Malformed args take the in-place repair path pre-existing
behavior, identical in both implementations; see parity tests.)"""
history = build_history(20)
for m in history: # all-valid: canonicalize path only
if m.get("tool_calls"):
fn = m["tool_calls"][0]["function"]
fn["arguments"] = json.dumps({"id": m["tool_calls"][0]["id"],
"u": UNI})
before = copy.deepcopy(history)
api_messages = [dict(m) for m in history] # shallow, like the loop
cl._canonicalize_api_tool_calls(api_messages)
assert history == before
def test_non_string_arguments_parity(self):
"""A dict (not str) in 'arguments' takes the repair path in both
implementations the memo must not change that."""
msgs = [{"role": "assistant", "content": "",
"tool_calls": [{"id": "c1", "type": "function",
"function": {"name": "t",
"arguments": {"a": 1}}}]}]
old_msgs = copy.deepcopy(msgs)
new_msgs = copy.deepcopy(msgs)
canonicalize_pass_OLD(old_msgs)
cl._canonicalize_api_tool_calls(new_msgs)
assert old_msgs == new_msgs
class TestMemoSemantics:
def test_deterministic_and_idempotent(self):
raw = json.dumps({"b": 2, "a": UNI, "nested": {"z": [3, 2, 1]}})
canon = cl._canonicalize_tool_call_arguments(raw)
assert canon == cl._canonicalize_tool_call_arguments(raw)
assert cl._canonicalize_tool_call_arguments(canon) == canon
# exact canonical form: sorted keys, tight separators, ascii-escaped
assert canon == json.dumps(json.loads(raw), separators=(",", ":"),
sort_keys=True)
assert canon == canon.encode().decode() # pure ASCII wire form
def test_cache_hit_skips_json_loads(self):
raw = json.dumps({"k": "v"})
cl._canonicalize_tool_call_arguments(raw)
assert raw in cl._CANON_ARGS_CACHE
def test_malformed_never_memoized(self):
with pytest.raises(Exception):
cl._canonicalize_tool_call_arguments('{"broken": tru')
assert cl._CANON_ARGS_CACHE == {}
def test_cache_bounded(self):
for i in range(cl._CANON_ARGS_CACHE_MAX + 100):
cl._canonicalize_tool_call_arguments(json.dumps({"i": i}))
assert len(cl._CANON_ARGS_CACHE) <= cl._CANON_ARGS_CACHE_MAX
class TestComplexityProof:
def test_json_loads_linear_not_quadratic(self, monkeypatch):
"""Deterministic perf proof: count json.loads invocations.
Pre-fix logic: one loads per tool call PER ITERATION -> K(K+1)/2 for
a K-tool-call session. Fixed logic: one loads per UNIQUE argument
string, ever -> K. (Malformed arguments raise and are never
memoized in EITHER implementation covered in the parity tests
so this proof uses an all-valid history to compare exactly.)
"""
n = 40
history = build_history(n)
# force every argument string valid so both implementations take
# only the canonicalize path (repair path is parity-tested elsewhere)
for m in history:
if m.get("tool_calls"):
fn = m["tool_calls"][0]["function"]
fn["arguments"] = json.dumps({"name": fn["name"],
"id": m["tool_calls"][0]["id"],
"u": UNI})
def counting_loads(counter):
real_loads = json.loads
def wrapper(*a, **kw):
counter[0] += 1
return real_loads(*a, **kw)
return wrapper
# OLD: quadratic — K(K+1)/2 loads over a K-iteration session
old_counter = [0]
monkeypatch.setattr(json, "loads", counting_loads(old_counter))
for k in range(1, n + 1):
canonicalize_pass_OLD(copy.deepcopy(history[: 2 * k]))
monkeypatch.undo()
assert old_counter[0] == n * (n + 1) // 2
# NEW: linear — each unique string loaded exactly once, ever
new_counter = [0]
monkeypatch.setattr(json, "loads", counting_loads(new_counter))
for k in range(1, n + 1):
cl._canonicalize_api_tool_calls(copy.deepcopy(history[: 2 * k]))
monkeypatch.undo()
assert new_counter[0] == n
# quadratic -> linear, by exact call count
assert old_counter[0] == (n + 1) / 2 * new_counter[0]