You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
S2 - degraded behavior (per-tool-call transient allocations, RSS-growth amplifier on long tool-heavy turns, especially when args include large embedded strings or deep trees).
Current behavior
hash_value(&serde_json::Value) is called on the args of every successful tool call from crates/zeroclaw-runtime/src/agent/turn/results_collect.rs:85 (loop_detector.record(&tool_name, args, &outcome.output)). The current implementation:
canonicalise returns an owned serde_json::Value — every Object key cloned, every nested Object/Array recursively rebuilt, every leaf cloned.
serde_json::to_string then serializes that owned tree into a fresh String, allocating ~the same size again.
The intermediate canonicalString and the canonicalised Value are both dropped at the end of the function, but on glibc the freed chunks stay parked in the per-thread arena — so the transient allocations add up to monotonic RSS growth on long tool-heavy turns, similar in shape to the MCP-spec cloning issue split out into #8642.
Expected behavior
Produce the same hash (deterministic over object-key ordering) without materialising either the canonicalised Value or the canonical String. Walk the input &Value recursively, sorting object keys on the fly, and feed a deterministic byte sequence directly to a Hasher. The hasher only needs a stable byte stream keyed by structure, so the canonical tree is never needed as a Rust value.
Steps to reproduce
# Reproducible from code inspection on current upstream state.# 1. A tool whose args include a large embedded string (e.g. shell with a 100 KB command, file_write with a 100 KB payload) is invoked many times in one turn.# 2. Each call goes through collect_tool_results → loop_detector.record → hash_value(args).# 3. hash_value allocates (a) an owned canonicalised Value tree and (b) a canonical String of equivalent size.# 4. Both are dropped on return; under glibc, the freed chunks remain in the per-thread arena.# 5. After many tool calls, /proc/self/status Rss is materially higher than the live working set suggests.
Impact
Hot path: called once per successful tool call inside the agent loop.
Hash output is internal to loop detection, so swapping the implementation does not change any observable cross-process behaviour or persisted state, as long as the hash remains deterministic over the same arg tree.
Acceptance criteria
A drop-in hash_value that produces the same hash for any two serde_json::Value inputs that canonicalise would consider equal (same keys / same leaf values, regardless of object-key order or array order).
No owned serde_json::Value allocation; no intermediate String allocation proportional to the input.
All existing tests in loop_detector (detect_exact_repeat, detect_ping_pong, detect_no_progress, progress, and the hashing regression test if any) keep passing.
A new test pins the streaming hash to the legacy canonicalise + serde_json::to_string hash for a battery of inputs (nested objects, arrays of objects, large strings, numbers, bools, null, mixed).
Affected component
crates/zeroclaw-runtime/src/agent/loop_detector.rs—hash_value/canonicalise.Severity
S2 - degraded behavior (per-tool-call transient allocations, RSS-growth amplifier on long tool-heavy turns, especially when args include large embedded strings or deep trees).
Current behavior
hash_value(&serde_json::Value)is called on the args of every successful tool call fromcrates/zeroclaw-runtime/src/agent/turn/results_collect.rs:85(loop_detector.record(&tool_name, args, &outcome.output)). The current implementation:Two deep clones of the entire JSON tree per call:
canonicalisereturns an ownedserde_json::Value— every Object key cloned, every nested Object/Array recursively rebuilt, every leaf cloned.serde_json::to_stringthen serializes that owned tree into a freshString, allocating ~the same size again.The intermediate
canonicalStringand the canonicalisedValueare both dropped at the end of the function, but on glibc the freed chunks stay parked in the per-thread arena — so the transient allocations add up to monotonic RSS growth on long tool-heavy turns, similar in shape to the MCP-spec cloning issue split out into #8642.Expected behavior
Produce the same hash (deterministic over object-key ordering) without materialising either the canonicalised
Valueor the canonicalString. Walk the input&Valuerecursively, sorting object keys on the fly, and feed a deterministic byte sequence directly to aHasher. The hasher only needs a stable byte stream keyed by structure, so the canonical tree is never needed as a Rust value.Steps to reproduce
Impact
Acceptance criteria
hash_valuethat produces the same hash for any twoserde_json::Valueinputs thatcanonicalisewould consider equal (same keys / same leaf values, regardless of object-key order or array order).serde_json::Valueallocation; no intermediateStringallocation proportional to the input.loop_detector(detect_exact_repeat,detect_ping_pong,detect_no_progress,progress, and the hashing regression test if any) keep passing.