gglib_core/domain/agent/loop_detection/signature.rs
1//! Stable signatures for a tool-call batch.
2//!
3//! A batch's identity, for the purpose of asking whether it repeated. Split
4//! from the detector because it answers a different question — *is this the
5//! same request* — from the one the detector asks, which is *did the same
6//! request get the same answer*. See `results.rs` for the other half.
7
8use serde_json::Value;
9
10use super::super::fnv1a::fnv1a_64;
11use super::super::tool_types::ToolCall;
12
13// =============================================================================
14// Signature helpers
15// =============================================================================
16
17/// Maximum recursion depth for [`stable_repr_inner`].
18///
19/// Deeply-nested JSON arguments (e.g. from a hostile tool result fed back
20/// into tool arguments) would otherwise cause unbounded stack growth. Values
21/// beyond this depth are replaced with the sentinel `"..."`.
22///
23/// **Truncation impact on hashing**: values deeper than this limit are
24/// collapsed to the same sentinel string, meaning structurally distinct
25/// deeply-nested arguments will produce **identical hashes**. This is
26/// acceptable because the loop detector is a best-effort guard — a false
27/// positive (treating distinct deep arguments as a loop) is safe (it aborts
28/// the run), while a false negative cannot occur for shallow arguments which
29/// represent the vast majority of real tool calls.
30pub(super) const MAX_REPR_DEPTH: usize = 16;
31
32/// Produce a **deterministic string representation** of a [`serde_json::Value`]
33/// suitable for stable hashing.
34///
35/// Object keys are sorted recursively so that `{"b":2,"a":1}` and
36/// `{"a":1,"b":2}` produce identical output. Array element order is
37/// preserved. Recursion is capped at [`MAX_REPR_DEPTH`] to prevent
38/// stack overflow on adversarially nested inputs; values beyond that depth
39/// are replaced with the sentinel `"..."`, which means two deeply-nested
40/// values that differ only below depth 16 will hash identically.
41///
42/// The output is **not** valid JSON — it is intentionally compact and only
43/// used as a pre-image for FNV-1a; never parsed or returned to callers.
44pub(super) fn stable_repr(v: &Value) -> String {
45 stable_repr_inner(v, 0)
46}
47
48fn stable_repr_inner(v: &Value, depth: usize) -> String {
49 if depth >= MAX_REPR_DEPTH {
50 return "\"...\"".to_owned();
51 }
52 match v {
53 Value::Object(map) => {
54 let mut pairs: Vec<(&String, &Value)> = map.iter().collect();
55 pairs.sort_unstable_by_key(|(k, _)| k.as_str());
56 let inner = pairs
57 .into_iter()
58 .map(|(k, v)| {
59 format!(
60 "{}:{}",
61 serde_json::to_string(k)
62 .expect("in-memory String serialisation is infallible"),
63 stable_repr_inner(v, depth + 1)
64 )
65 })
66 .collect::<Vec<_>>()
67 .join(",");
68 format!("{{{inner}}}")
69 }
70 Value::Array(arr) => {
71 let inner = arr
72 .iter()
73 .map(|e| stable_repr_inner(e, depth + 1))
74 .collect::<Vec<_>>()
75 .join(",");
76 format!("[{inner}]")
77 }
78 _ => v.to_string(),
79 }
80}
81
82/// Compute the individual signature for a single [`ToolCall`].
83///
84/// Format: `"{name}:{fnv1a_64(canonical_args_json):016x}"`
85///
86/// Arguments are serialised via [`stable_repr`] before hashing so that
87/// logically identical arguments always hash identically regardless of JSON
88/// key ordering.
89pub(super) fn tool_signature(call: &ToolCall) -> String {
90 let canonical = stable_repr(&call.arguments);
91 format!("{}:{:016x}", call.name, fnv1a_64(&canonical))
92}
93
94/// Compute the batch signature for a slice of [`ToolCall`]s.
95///
96/// Individual signatures are sorted before joining so that the result is
97/// independent of the order in which the LLM emitted the calls.
98pub fn batch_signature(calls: &[ToolCall]) -> String {
99 let mut sigs: Vec<String> = calls.iter().map(tool_signature).collect();
100 sigs.sort_unstable();
101 sigs.join("|")
102}
103
104#[cfg(test)]
105#[path = "signature_tests.rs"]
106mod tests;