Skip to main content

hatchery_node/
tool_evidence.rs

1//! ACP tool calls as extra evidence for a task check. The node remembers the last finished
2//! tool calls of each task worktree (`task-<id>`) session: the command, whether the provider
3//! said it failed, how long it took (the node's own clock: ACP sends no duration) and why it
4//! did not run, if it did not. When the node sends its own `task.check_run`, it attaches the
5//! last call that ran the same command (see [`take`]). The proof stays the node's run: a tool
6//! call is only what the agent saw and never passes a check by itself.
7//!
8//! ACP turns every `tool_call_update` (also `in_progress`) into a completion, so the last
9//! update of a call wins. Command text is kept only for matching; it is never logged.
10
11use gate4agent_types::ProviderEvent;
12use serde_json::Value;
13use std::collections::{HashMap, VecDeque};
14use std::sync::Mutex;
15use std::time::{Instant, SystemTime, UNIX_EPOCH};
16
17/// Finished calls kept per worktree.
18const KEEP: usize = 32;
19/// Calls started but not finished yet, per worktree (a provider may never finish some).
20const OPEN_MAX: usize = 64;
21const COMMAND_MAX: usize = 500;
22
23/// A finished tool call of a session in a task worktree.
24#[derive(Clone, Debug, PartialEq)]
25pub struct ToolCall {
26    pub instance_id: u64,
27    pub tool_call_id: String,
28    pub command: String,
29    pub is_error: bool,
30    pub duration_ms: Option<u64>,
31    /// ACP `nonExecutionKind`: the call never ran (`user-rejected`, `cancelled`, ...).
32    pub not_run: Option<String>,
33    pub finished_at_ms: u64,
34}
35
36impl ToolCall {
37    /// The `tool_call` object of a `task.check_run` payload.
38    pub fn to_json(&self) -> Value {
39        serde_json::json!({ "instance_id": self.instance_id, "tool_call_id": self.tool_call_id, "command": self.command, "is_error": self.is_error,
40            "duration_ms": self.duration_ms, "not_run": self.not_run, "finished_at_ms": self.finished_at_ms })
41    }
42}
43
44#[derive(Default)]
45struct Workspace {
46    open: HashMap<(u64, String), (String, Instant)>,
47    done: VecDeque<ToolCall>,
48}
49
50static CALLS: Mutex<Option<HashMap<String, Workspace>>> = Mutex::new(None);
51
52/// The command a tool call runs: `rawInput.command` as one shell line or `command` + `args`
53/// (or an argv array), else a Bash-style title in backticks. `None`: not a command.
54pub fn command_of(name: &str, input_json: &str) -> Option<String> {
55    let input: Value = serde_json::from_str(input_json).unwrap_or(Value::Null);
56    let words = |v: &Value| v.as_array().and_then(|a| a.iter().map(|x| x.as_str().map(str::to_owned)).collect::<Option<Vec<_>>>());
57    let line = match &input["command"] {
58        Value::String(c) => match words(&input["args"]) {
59            Some(args) if !args.is_empty() => Some(format!("{c} {}", args.join(" "))),
60            _ => Some(c.clone()),
61        },
62        v @ Value::Array(_) => words(v).map(|w| match w.as_slice() {
63            // `["bash", "-lc", "<line>"]`: the line is the command.
64            [sh, flag, line] if flag.starts_with('-') && flag.ends_with('c') && !sh.is_empty() => line.clone(),
65            _ => w.join(" "),
66        }),
67        _ => None,
68    }
69    .or_else(|| name.trim().strip_prefix('`').and_then(|n| n.strip_suffix('`')).map(str::to_owned))?;
70    let line = normalize(&line);
71    (!line.is_empty() && line.len() <= COMMAND_MAX * 4).then_some(line)
72}
73
74fn normalize(s: &str) -> String {
75    s.split_whitespace().collect::<Vec<_>>().join(" ")
76}
77
78/// The call ran `check`: the same line, or the line after a `cd ... &&` / `;` prefix.
79pub fn runs(command: &str, check: &str) -> bool {
80    let check = normalize(check);
81    !check.is_empty() && (command == check || command.ends_with(&format!("&& {check}")) || command.ends_with(&format!("; {check}")))
82}
83
84fn now_ms() -> u64 {
85    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
86}
87
88/// Feed one provider event of an ACP session (only task worktrees are kept).
89pub fn observe(workspace: &str, instance: u64, event: &ProviderEvent) {
90    if !workspace.starts_with("task-") {
91        return;
92    }
93    let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
94    let ws = guard.get_or_insert_with(HashMap::new).entry(workspace.to_owned()).or_default();
95    match event {
96        ProviderEvent::ToolStarted { id, name, input_json, .. } => {
97            let Some(command) = command_of(name, input_json) else { return };
98            if ws.open.len() >= OPEN_MAX {
99                if let Some(oldest) = ws.open.iter().min_by_key(|(_, (_, at))| *at).map(|(k, _)| k.clone()) {
100                    ws.open.remove(&oldest);
101                }
102            }
103            ws.open.insert((instance, id.clone()), (command, Instant::now()));
104        }
105        ProviderEvent::ToolCompleted { id, is_error, duration_ms, non_execution_kind, .. } => {
106            let key = (instance, id.clone());
107            let Some((command, started)) = ws.open.get(&key).cloned() else { return };
108            let call = ToolCall {
109                instance_id: instance,
110                tool_call_id: id.chars().take(128).collect(),
111                command: command.chars().take(COMMAND_MAX).collect(),
112                is_error: *is_error,
113                duration_ms: duration_ms.or_else(|| Some(started.elapsed().as_millis() as u64)),
114                not_run: non_execution_kind.as_ref().map(|k| k.chars().take(32).collect()),
115                finished_at_ms: now_ms(),
116            };
117            // A later update of the same call replaces the earlier one.
118            ws.done.retain(|c| !(c.instance_id == instance && c.tool_call_id == call.tool_call_id));
119            ws.done.push_back(call);
120            while ws.done.len() > KEEP {
121                ws.done.pop_front();
122            }
123        }
124        _ => {}
125    }
126}
127
128/// The last finished call in `workspace` that ran `check`, removed so it backs one check run.
129pub fn take(workspace: &str, check: &str) -> Option<ToolCall> {
130    let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
131    let ws = guard.as_mut()?.get_mut(workspace)?;
132    let at = ws.done.iter().rposition(|c| runs(&c.command, check))?;
133    ws.done.remove(at)
134}
135
136/// A session ended: its unfinished calls go; finished ones stay for the next check.
137pub fn forget_open(workspace: &str, instance: u64) {
138    let mut guard = CALLS.lock().unwrap_or_else(|p| p.into_inner());
139    if let Some(ws) = guard.as_mut().and_then(|m| m.get_mut(workspace)) {
140        ws.open.retain(|(i, _), _| *i != instance);
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn started(id: &str, name: &str, input: &str) -> ProviderEvent {
149        ProviderEvent::ToolStarted { id: id.into(), name: name.into(), input_json: input.into(), agent_id: None }
150    }
151
152    fn finished(id: &str, is_error: bool, not_run: Option<&str>) -> ProviderEvent {
153        ProviderEvent::ToolCompleted { id: id.into(), output: String::new(), is_error, duration_ms: None, agent_id: None, non_execution_kind: not_run.map(str::to_owned) }
154    }
155
156    #[test]
157    fn reads_the_command_shapes_acp_agents_send() {
158        assert_eq!(command_of("Bash", r#"{"command":"cargo  test -q","description":"x"}"#).as_deref(), Some("cargo test -q"));
159        assert_eq!(command_of("t", r#"{"command":"cargo","args":["test","-q"]}"#).as_deref(), Some("cargo test -q"));
160        assert_eq!(command_of("t", r#"{"command":["bash","-lc","cargo test -q"]}"#).as_deref(), Some("cargo test -q"));
161        assert_eq!(command_of("`cargo test -q`", "{}").as_deref(), Some("cargo test -q"));
162        assert_eq!(command_of("Read", r#"{"file_path":"/x"}"#), None);
163    }
164
165    #[test]
166    fn matches_the_check_command_only() {
167        assert!(runs("cargo test -q", "cargo  test -q"));
168        assert!(runs("cd /w/task-7 && cargo test -q", "cargo test -q"));
169        assert!(!runs("cargo test -q --no-run", "cargo test -q"));
170        assert!(!runs("echo cargo test -q", "cargo test -q"));
171        assert!(!runs("", ""));
172    }
173
174    #[test]
175    fn keeps_the_last_finished_call_of_the_check_in_a_task_worktree() {
176        let ws = "task-9001";
177        observe(ws, 1, &started("a", "Bash", r#"{"command":"cargo test"}"#));
178        observe(ws, 1, &started("b", "Bash", r#"{"command":"ls"}"#));
179        observe(ws, 1, &finished("a", false, None));
180        observe(ws, 1, &finished("a", true, None)); // the final update wins
181        observe(ws, 1, &finished("b", false, None));
182        observe(ws, 1, &finished("zzz", false, None)); // never started: ignored
183        let call = take(ws, "cargo test").expect("the check call");
184        assert_eq!((call.tool_call_id.as_str(), call.is_error, call.instance_id), ("a", true, 1));
185        assert!(call.duration_ms.is_some() && call.finished_at_ms > 0);
186        assert_eq!(take(ws, "cargo test"), None, "one call backs one check run");
187        observe(ws, 2, &started("c", "Bash", r#"{"command":"cargo test"}"#));
188        forget_open(ws, 2);
189        observe(ws, 2, &finished("c", false, Some("cancelled")));
190        assert_eq!(take(ws, "cargo test"), None, "an ended session's open calls are gone");
191        observe("primary", 1, &started("d", "Bash", r#"{"command":"cargo test"}"#));
192        observe("primary", 1, &finished("d", false, None));
193        assert_eq!(take("primary", "cargo test"), None, "only task worktrees");
194    }
195
196    #[test]
197    fn a_call_that_never_ran_says_why() {
198        let ws = "task-9002";
199        observe(ws, 1, &started("x", "`make check`", "{}"));
200        observe(ws, 1, &finished("x", true, Some("user-rejected")));
201        let call = take(ws, "make check").unwrap();
202        assert_eq!(call.not_run.as_deref(), Some("user-rejected"));
203        assert_eq!(call.to_json()["not_run"], "user-rejected");
204    }
205}