lean_ctx/core/context_kernel/
a2a_fixes.rs1use std::collections::HashSet;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8const DEFAULT_AGENT_BUDGET: usize = 1_000_000;
9const BUDGET_WARNING_PERCENT: f64 = 80.0;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct MessageEntry {
14 pub category: String,
15 pub body: String,
16 pub from_agent: String,
17 pub timestamp_epoch: u64,
18}
19
20pub type ScratchpadEntry = MessageEntry;
22
23pub fn message_id(message: &MessageEntry) -> String {
25 let mut hasher = blake3::Hasher::new();
26 for field in [&message.category, &message.body, &message.from_agent] {
27 hasher.update(&(field.len() as u64).to_le_bytes());
28 hasher.update(field.as_bytes());
29 }
30 hasher.finalize().to_hex()[..16].to_owned()
31}
32
33pub fn filter_truly_unread<'a>(
35 messages: &'a [ScratchpadEntry],
36 read_ids: &HashSet<String>,
37) -> Vec<&'a ScratchpadEntry> {
38 messages
39 .iter()
40 .filter(|message| !read_ids.contains(&message_id(message)))
41 .collect()
42}
43
44pub fn real_agent_budget(config_limit: Option<usize>) -> usize {
46 config_limit.unwrap_or(DEFAULT_AGENT_BUDGET)
47}
48
49#[derive(Debug, Clone, PartialEq)]
51pub enum BudgetCheckResult {
52 Allowed {
53 remaining: usize,
54 },
55 Denied {
56 over_by: usize,
57 },
58 Warning {
59 remaining: usize,
60 threshold_pct: f64,
61 },
62}
63
64pub fn check_budget_with_real_limit(
66 current_used: usize,
67 budget_limit: usize,
68 tokens_to_consume: usize,
69) -> BudgetCheckResult {
70 let projected = current_used.saturating_add(tokens_to_consume);
71 if projected > budget_limit {
72 return BudgetCheckResult::Denied {
73 over_by: projected - budget_limit,
74 };
75 }
76
77 let remaining = budget_limit - projected;
78 let threshold_pct = if budget_limit == 0 {
79 100.0
80 } else {
81 projected as f64 * 100.0 / budget_limit as f64
82 };
83 if threshold_pct >= BUDGET_WARNING_PERCENT {
84 BudgetCheckResult::Warning {
85 remaining,
86 threshold_pct,
87 }
88 } else {
89 BudgetCheckResult::Allowed { remaining }
90 }
91}
92
93pub fn compact_json(pretty: &Value) -> String {
95 match serde_json::to_string(&strip_nulls(pretty)) {
96 Ok(compact) => compact,
97 Err(_) => "null".to_owned(),
98 }
99}
100
101pub fn json_token_estimate(json: &str) -> usize {
103 json.chars().count().div_ceil(4)
104}
105
106pub fn strip_nulls(value: &Value) -> Value {
108 prune_value(value).unwrap_or(Value::Null)
109}
110
111fn prune_value(value: &Value) -> Option<Value> {
112 match value {
113 Value::Null => None,
114 Value::String(text) if text.is_empty() => None,
115 Value::Array(values) => {
116 let values: Vec<_> = values.iter().filter_map(prune_value).collect();
117 (!values.is_empty()).then_some(Value::Array(values))
118 }
119 Value::Object(fields) => {
120 let fields: serde_json::Map<String, Value> = fields
121 .iter()
122 .filter_map(|(key, value)| prune_value(value).map(|value| (key.clone(), value)))
123 .collect();
124 (!fields.is_empty()).then_some(Value::Object(fields))
125 }
126 _ => Some(value.clone()),
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use std::collections::HashSet;
133
134 use serde_json::json;
135
136 use super::{
137 BudgetCheckResult, MessageEntry, check_budget_with_real_limit, compact_json,
138 filter_truly_unread, json_token_estimate, message_id, real_agent_budget, strip_nulls,
139 };
140
141 fn message(body: &str) -> MessageEntry {
142 MessageEntry {
143 category: "status".to_owned(),
144 body: body.to_owned(),
145 from_agent: "agent-a".to_owned(),
146 timestamp_epoch: 42,
147 }
148 }
149
150 #[test]
151 fn filter_removes_already_read() {
152 let messages: Vec<_> = (0..5).map(|index| message(&index.to_string())).collect();
153 let read_ids = [message_id(&messages[1]), message_id(&messages[3])]
154 .into_iter()
155 .collect::<HashSet<_>>();
156
157 let unread = filter_truly_unread(&messages, &read_ids);
158
159 assert_eq!(unread.len(), 3);
160 assert_eq!(unread[0].body, "0");
161 assert_eq!(unread[1].body, "2");
162 assert_eq!(unread[2].body, "4");
163 }
164
165 #[test]
166 fn message_id_is_deterministic() {
167 let first = message("same content");
168 let mut second = first.clone();
169 second.timestamp_epoch = 99;
170 assert_eq!(message_id(&first), message_id(&second));
171 assert_eq!(message_id(&first).len(), 16);
172 }
173
174 #[test]
175 fn real_budget_not_max() {
176 assert_eq!(real_agent_budget(None), 1_000_000);
177 assert_ne!(real_agent_budget(None), usize::MAX);
178 assert_eq!(real_agent_budget(Some(250_000)), 250_000);
179 }
180
181 #[test]
182 fn budget_denied_when_exceeded() {
183 assert_eq!(
184 check_budget_with_real_limit(900_000, 1_000_000, 200_000),
185 BudgetCheckResult::Denied { over_by: 100_000 }
186 );
187 }
188
189 #[test]
190 fn budget_warning_at_80_percent() {
191 assert_eq!(
192 check_budget_with_real_limit(800_000, 1_000_000, 10_000),
193 BudgetCheckResult::Warning {
194 remaining: 190_000,
195 threshold_pct: 81.0,
196 }
197 );
198 }
199
200 #[test]
201 fn compact_json_smaller() {
202 let value = json!({
203 "messages": [
204 {"body": "short", "metadata": null, "tags": []},
205 {"body": "reply", "metadata": null, "tags": []}
206 ],
207 "unused": null
208 });
209 let pretty = serde_json::to_string_pretty(&value).expect("test JSON must serialize");
210 let compact = compact_json(&value);
211 assert!(compact.len() * 2 < pretty.len());
212 }
213
214 #[test]
215 fn strip_nulls_removes_null_fields() {
216 assert_eq!(strip_nulls(&json!({"a": 1, "b": null})), json!({"a": 1}));
217 }
218
219 #[test]
220 fn token_estimate_reasonable() {
221 let estimate = json_token_estimate(&"x".repeat(1_000));
222 assert!((200..=300).contains(&estimate));
223 }
224}