forge_guardrails/tool_output/state/
dedup.rs1use indexmap::IndexMap;
2use std::collections::{hash_map::DefaultHasher, VecDeque};
3use std::hash::{Hash, Hasher};
4use std::sync::Mutex;
5
6use super::memo::{MemoLookup, MemoRecord, MemoState};
7
8#[derive(Debug, Default)]
10pub struct ToolOutputCompressionState {
11 inner: Mutex<DedupState>,
12 memo: Mutex<MemoState>,
13}
14
15#[derive(Debug, Default)]
16struct DedupState {
17 sessions: IndexMap<String, VecDeque<DedupRecord>>,
18 session_order: VecDeque<String>,
19}
20
21#[derive(Debug, Clone)]
22struct DedupRecord {
23 hash: u64,
24 output_len: usize,
25 tool_name: String,
26 tool_call_id: String,
27}
28
29impl ToolOutputCompressionState {
30 pub fn new() -> Self {
32 Self::default()
33 }
34
35 pub(in crate::tool_output) fn deduplicate(
36 &self,
37 session_id: &str,
38 tool_call_id: &str,
39 tool_name: &str,
40 output: &str,
41 max_sessions: usize,
42 max_entries_per_session: usize,
43 ) -> Option<String> {
44 if session_id.is_empty() || tool_call_id.is_empty() || output.is_empty() {
45 return None;
46 }
47 let max_sessions = max_sessions.max(1);
48 let max_entries_per_session = max_entries_per_session.max(1);
49 let hash = hash_output(output);
50 let mut state = self.inner.lock().expect("tool output dedup lock");
51
52 let output_len = output.len();
53 if let Some(records) = state.sessions.get(session_id) {
54 if let Some(record) = records.iter().find(|record| {
55 record.tool_name == tool_name
56 && record.hash == hash
57 && record.output_len == output_len
58 }) {
59 if record.tool_call_id == tool_call_id {
63 return None;
64 }
65 return Some(format!(
73 "[Duplicate of {} ({}); see earlier result]",
74 record.tool_call_id, record.tool_name
75 ));
76 }
77 }
78
79 if !state.sessions.contains_key(session_id) {
80 state.session_order.push_back(session_id.to_string());
81 state
82 .sessions
83 .insert(session_id.to_string(), VecDeque::new());
84 }
85
86 let records = state
87 .sessions
88 .get_mut(session_id)
89 .expect("session inserted above");
90 records.push_back(DedupRecord {
91 hash,
92 output_len,
93 tool_name: tool_name.to_string(),
94 tool_call_id: tool_call_id.to_string(),
95 });
96 while records.len() > max_entries_per_session {
97 records.pop_front();
98 }
99
100 while state.sessions.len() > max_sessions {
101 let Some(oldest) = state.session_order.pop_front() else {
102 break;
103 };
104 if oldest != session_id {
105 state.sessions.shift_remove(&oldest);
106 } else {
107 state.session_order.push_back(oldest);
108 break;
109 }
110 }
111
112 None
113 }
114
115 pub(in crate::tool_output) fn lookup_memo(
116 &self,
117 session_id: &str,
118 tool_call_id: &str,
119 input_hash: u64,
120 input_len: usize,
121 config_fp: u64,
122 ) -> MemoLookup {
123 self.memo.lock().expect("tool output memo lock").lookup(
124 session_id,
125 tool_call_id,
126 input_hash,
127 input_len,
128 config_fp,
129 )
130 }
131
132 pub(in crate::tool_output) fn store_memo(
133 &self,
134 session_id: &str,
135 tool_call_id: &str,
136 record: MemoRecord,
137 max_sessions: usize,
138 ) {
139 self.memo.lock().expect("tool output memo lock").store(
140 session_id,
141 tool_call_id,
142 record,
143 max_sessions,
144 );
145 }
146}
147
148fn hash_output(output: &str) -> u64 {
149 let mut hasher = DefaultHasher::new();
150 output.hash(&mut hasher);
151 hasher.finish()
152}