Skip to main content

zeroclaw_eval/
observer.rs

1//! An [`Observer`] that records tool-call outcomes and token usage from a run.
2
3use std::sync::{Mutex, MutexGuard};
4use zeroclaw_api::observability_traits::{Observer, ObserverEvent, ObserverMetric};
5
6/// One dispatched tool call as observed at the dispatch boundary.
7///
8/// Unlike the bare `(name, success)` pair this replaces, a `RecordedCall` carries the
9/// arguments the agent actually dispatched and the output the tool actually returned.
10/// That is what lets a fixture grade a round trip instead of grading text the replay
11/// provider scripted for itself.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct RecordedCall {
14    /// Tool name, as dispatched.
15    pub name: String,
16    /// Serialized JSON arguments the agent passed to the tool. Empty when the
17    /// dispatch boundary did not report any.
18    pub arguments: String,
19    /// Tool output (or error reason) as returned. Empty when not reported.
20    pub result: String,
21    /// Whether the call reported success.
22    pub success: bool,
23}
24
25/// Captures each dispatched tool call (name, arguments, result, success) and accumulates
26/// reported token usage across the run.
27#[derive(Default)]
28pub struct RecordingObserver {
29    tool_calls: Mutex<Vec<RecordedCall>>,
30    input_tokens: Mutex<u64>,
31    output_tokens: Mutex<u64>,
32}
33
34fn lock_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
35    match mutex.lock() {
36        Ok(guard) => guard,
37        Err(poisoned) => poisoned.into_inner(),
38    }
39}
40
41impl RecordingObserver {
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Every dispatched tool call, in call order, with arguments and results.
47    pub fn calls(&self) -> Vec<RecordedCall> {
48        lock_recover(&self.tool_calls).clone()
49    }
50
51    /// Names of tools that were dispatched, in call order.
52    pub fn tool_names(&self) -> Vec<String> {
53        lock_recover(&self.tool_calls)
54            .iter()
55            .map(|c| c.name.clone())
56            .collect()
57    }
58
59    /// True when every dispatched tool call reported success (vacuously true if none).
60    pub fn all_tools_succeeded(&self) -> bool {
61        lock_recover(&self.tool_calls).iter().all(|c| c.success)
62    }
63
64    /// Accumulated `(input_tokens, output_tokens)` reported by the provider.
65    pub fn tokens(&self) -> (u64, u64) {
66        (
67            *lock_recover(&self.input_tokens),
68            *lock_recover(&self.output_tokens),
69        )
70    }
71}
72
73impl Observer for RecordingObserver {
74    fn record_event(&self, event: &ObserverEvent) {
75        match event {
76            ObserverEvent::ToolCall {
77                tool,
78                success,
79                arguments,
80                result,
81                ..
82            } => {
83                lock_recover(&self.tool_calls).push(RecordedCall {
84                    name: tool.clone(),
85                    arguments: arguments.clone().unwrap_or_default(),
86                    result: result.clone().unwrap_or_default(),
87                    success: *success,
88                });
89            }
90            ObserverEvent::LlmResponse {
91                input_tokens,
92                output_tokens,
93                ..
94            } => {
95                if let Some(i) = input_tokens {
96                    *lock_recover(&self.input_tokens) += i;
97                }
98                if let Some(o) = output_tokens {
99                    *lock_recover(&self.output_tokens) += o;
100                }
101            }
102            _ => {}
103        }
104    }
105
106    fn record_metric(&self, _metric: &ObserverMetric) {}
107
108    fn name(&self) -> &str {
109        "eval-recording"
110    }
111
112    fn as_any(&self) -> &dyn std::any::Any {
113        self
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::time::Duration;
121    use zeroclaw_api::observability_traits::{Observer, ObserverEvent};
122
123    fn tool_call_event(tool: &str, success: bool) -> ObserverEvent {
124        tool_call_event_with(tool, success, None, None)
125    }
126
127    fn tool_call_event_with(
128        tool: &str,
129        success: bool,
130        arguments: Option<&str>,
131        result: Option<&str>,
132    ) -> ObserverEvent {
133        ObserverEvent::ToolCall {
134            parent_agent_alias: None,
135            tool: tool.to_string(),
136            tool_call_id: None,
137            duration: Duration::from_millis(10),
138            success,
139            arguments: arguments.map(str::to_string),
140            result: result.map(str::to_string),
141            channel: None,
142            agent_alias: None,
143            turn_id: None,
144        }
145    }
146
147    fn llm_event(input: u64, output: u64) -> ObserverEvent {
148        ObserverEvent::LlmResponse {
149            parent_agent_alias: None,
150            model_provider: String::new(),
151            model: String::new(),
152            duration: Duration::from_millis(50),
153            success: true,
154            error_message: None,
155            input_tokens: Some(input),
156            output_tokens: Some(output),
157            messages: None,
158            channel: None,
159            agent_alias: None,
160            turn_id: None,
161        }
162    }
163
164    #[test]
165    fn new_observer_is_empty() {
166        let obs = RecordingObserver::new();
167        assert!(obs.tool_names().is_empty());
168        assert!(obs.all_tools_succeeded());
169        assert_eq!(obs.tokens(), (0, 0));
170    }
171
172    #[test]
173    fn tool_names_records_order() {
174        let obs = RecordingObserver::new();
175        obs.record_event(&tool_call_event("search", true));
176        obs.record_event(&tool_call_event("write", true));
177        assert_eq!(
178            obs.tool_names(),
179            vec!["search".to_string(), "write".to_string()]
180        );
181    }
182
183    #[test]
184    fn all_tools_succeeded_false_on_failure() {
185        let obs = RecordingObserver::new();
186        obs.record_event(&tool_call_event("ok", true));
187        obs.record_event(&tool_call_event("bad", false));
188        assert!(!obs.all_tools_succeeded());
189    }
190
191    #[test]
192    fn tokens_accumulate_across_llm_responses() {
193        let obs = RecordingObserver::new();
194        obs.record_event(&llm_event(100, 50));
195        obs.record_event(&llm_event(200, 80));
196        assert_eq!(obs.tokens(), (300, 130));
197    }
198
199    #[test]
200    fn unrelated_events_are_ignored() {
201        let obs = RecordingObserver::new();
202        obs.record_event(&ObserverEvent::HeartbeatTick);
203        assert!(obs.tool_names().is_empty());
204        assert_eq!(obs.tokens(), (0, 0));
205    }
206
207    #[test]
208    fn recording_observer_captures_tool_arguments_and_results() {
209        // B1: a Unicode argument must survive the dispatch boundary byte-for-byte, and
210        // the tool's own output must be recorded separately from any scripted response.
211        let obs = RecordingObserver::new();
212        obs.record_event(&tool_call_event_with(
213            "echo",
214            true,
215            Some(r#"{"message":"naïve café 日本語 ✓"}"#),
216            Some("naïve café 日本語 ✓"),
217        ));
218
219        let calls = obs.calls();
220        assert_eq!(calls.len(), 1);
221        assert_eq!(calls[0].name, "echo");
222        assert_eq!(calls[0].arguments, r#"{"message":"naïve café 日本語 ✓"}"#);
223        assert_eq!(calls[0].result, "naïve café 日本語 ✓");
224        assert!(calls[0].success);
225    }
226
227    #[test]
228    fn recorded_calls_default_to_empty_strings_when_boundary_omits_payload() {
229        let obs = RecordingObserver::new();
230        obs.record_event(&tool_call_event("echo", true));
231        let calls = obs.calls();
232        assert_eq!(calls.len(), 1);
233        assert!(calls[0].arguments.is_empty());
234        assert!(calls[0].result.is_empty());
235    }
236
237    #[test]
238    fn calls_preserve_dispatch_order_with_payloads() {
239        let obs = RecordingObserver::new();
240        obs.record_event(&tool_call_event_with(
241            "echo",
242            true,
243            Some(r#"{"message":"alpha"}"#),
244            Some("alpha"),
245        ));
246        obs.record_event(&tool_call_event_with(
247            "echo",
248            true,
249            Some(r#"{"message":"beta"}"#),
250            Some("beta"),
251        ));
252        let calls = obs.calls();
253        assert_eq!(calls.len(), 2);
254        assert!(calls[0].arguments.contains("alpha"));
255        assert!(calls[1].arguments.contains("beta"));
256    }
257}