agentmux_srv/backend/
tool_calls.rs1use std::collections::HashMap;
12use std::sync::{Mutex, OnceLock};
13
14const KEEP_MS: u64 = 12 * 60 * 60 * 1000;
16const MAX_CALLS: usize = 4096;
18const START_SLACK_MS: u64 = 60_000;
22const CLOCK_SLACK_MS: u64 = 2_000;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Call {
29 pub block_id: String,
30 pub tool_id: String,
31 pub description: String,
32 pub at_ms: u64,
34}
35
36fn calls() -> &'static Mutex<HashMap<u32, Call>> {
37 static CALLS: OnceLock<Mutex<HashMap<u32, Call>>> = OnceLock::new();
38 CALLS.get_or_init(|| Mutex::new(HashMap::new()))
39}
40
41pub fn record(pid: u32, call: Call) {
43 let mut map = calls().lock().unwrap_or_else(|e| e.into_inner());
44 let cutoff = call.at_ms.saturating_sub(KEEP_MS);
45 map.retain(|_, c| c.at_ms >= cutoff);
46 if map.len() >= MAX_CALLS {
47 if let Some(oldest) = map.iter().min_by_key(|(_, c)| c.at_ms).map(|(p, _)| *p) {
48 map.remove(&oldest);
49 }
50 }
51 map.insert(pid, call);
52}
53
54pub fn lookup(pid: u32, started_at_ms: Option<u64>) -> Option<Call> {
58 let map = calls().lock().unwrap_or_else(|e| e.into_inner());
59 let call = map.get(&pid)?;
60 match started_at_ms {
61 Some(started) if started > call.at_ms + CLOCK_SLACK_MS || call.at_ms.saturating_sub(started) > START_SLACK_MS => None,
62 _ => Some(call.clone()),
63 }
64}
65
66pub fn parse_report(scopes: &[String], data: &serde_json::Value) -> Option<(u32, Call)> {
69 if data.get("op").and_then(|v| v.as_str()) != Some("call") {
70 return None;
71 }
72 let pid = u32::try_from(data.get("pid")?.as_u64()?).ok()?;
73 let tool_id = data.get("tool_id")?.as_str()?.to_string();
74 let description = data.get("description")?.as_str()?.trim().to_string();
75 if tool_id.is_empty() || description.is_empty() {
76 return None;
77 }
78 let block_id = scopes.iter().find_map(|s| s.strip_prefix("block:")).unwrap_or("").to_string();
79 let at_ms = data
80 .get("timestamp")
81 .and_then(|v| v.as_u64())
82 .unwrap_or_else(|| agentmux_common::time::now_ms() as u64);
83 Some((pid, Call { block_id, tool_id, description, at_ms }))
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89 use serde_json::json;
90
91 #[test]
92 fn parses_a_wrappers_report_and_nothing_else() {
93 let scopes = vec!["block:b1".to_string()];
94 let (pid, call) = parse_report(
95 &scopes,
96 &json!({"op": "call", "tool_id": "toolu_1", "pid": 4242, "description": " Run the srv tests ", "timestamp": 1000}),
97 )
98 .unwrap();
99 assert_eq!(pid, 4242);
100 assert_eq!(
101 call,
102 Call { block_id: "b1".into(), tool_id: "toolu_1".into(), description: "Run the srv tests".into(), at_ms: 1000 }
103 );
104 for other in [
105 json!({"op": "chunk", "tool_id": "t", "content": "x"}),
106 json!({"op": "pid", "tool_id": "t", "pid": 1}),
107 json!({"op": "call", "tool_id": "", "pid": 1, "description": "d"}),
108 json!({"op": "call", "tool_id": "t", "pid": 1, "description": " "}),
109 json!({"op": "call", "tool_id": "t", "description": "d"}),
110 ] {
111 assert!(parse_report(&scopes, &other).is_none(), "{other}");
112 }
113 }
114
115 #[test]
116 fn a_process_is_the_reporting_wrapper_only_if_it_started_before_the_report() {
117 let call = |at_ms| Call { block_id: "b".into(), tool_id: "t".into(), description: "Build".into(), at_ms };
118 record(910_001, call(500_000));
119 assert_eq!(lookup(910_001, Some(499_000)).unwrap().description, "Build");
120 assert!(lookup(910_001, None).is_some(), "an unknown start is trusted on the pid");
121 assert!(lookup(910_001, Some(501_000)).is_some(), "a start a second late is the clock's");
122 assert!(lookup(910_001, Some(500_000 + CLOCK_SLACK_MS + 1)).is_none(), "started after the report: the pid was reused");
123 assert!(lookup(910_001, Some(500_000 - START_SLACK_MS - 1)).is_none(), "far older than the report");
124 assert!(lookup(910_002, Some(499_000)).is_none());
125 }
126}