Skip to main content

bob_core/
instrumenter.rs

1//! # Instrumenter Trait
2//!
3//! Fine-grained observation hooks for agent execution.
4//!
5//! Inspired by rustic-ai's `Instrumenter` trait, this provides granular hooks
6//! for every stage of agent execution. Unlike [`EventSink`] which emits discrete
7//! events, the [`Instrumenter`] trait provides structured callbacks with
8//! dedicated info structs for each lifecycle event.
9//!
10//! All methods have default no-op implementations, so implementors only need
11//! to override the hooks they care about.
12
13use crate::types::{FinishReason, TokenUsage, ToolDescriptor};
14
15// ── Info Structs ──────────────────────────────────────────────────────
16
17/// Info passed to [`Instrumenter::on_run_start`].
18#[derive(Debug, Clone)]
19pub struct RunStartInfo<'a> {
20    pub session_id: &'a str,
21    pub model: &'a str,
22}
23
24/// Info passed to [`Instrumenter::on_run_end`].
25#[derive(Debug, Clone)]
26pub struct RunEndInfo<'a> {
27    pub session_id: &'a str,
28    pub finish_reason: FinishReason,
29    pub usage: &'a TokenUsage,
30}
31
32/// Info passed to [`Instrumenter::on_run_error`].
33#[derive(Debug, Clone)]
34pub struct RunErrorInfo<'a> {
35    pub session_id: &'a str,
36    pub error: &'a str,
37}
38
39/// Info passed to [`Instrumenter::on_model_request`].
40#[derive(Debug, Clone)]
41pub struct ModelRequestInfo<'a> {
42    pub session_id: &'a str,
43    pub step: u32,
44    pub model: &'a str,
45}
46
47/// Info passed to [`Instrumenter::on_model_response`].
48#[derive(Debug, Clone)]
49pub struct ModelResponseInfo<'a> {
50    pub session_id: &'a str,
51    pub step: u32,
52    pub model: &'a str,
53    pub usage: &'a TokenUsage,
54}
55
56/// Info passed to [`Instrumenter::on_model_error`].
57#[derive(Debug, Clone)]
58pub struct ModelErrorInfo<'a> {
59    pub session_id: &'a str,
60    pub step: u32,
61    pub model: &'a str,
62    pub error: &'a str,
63}
64
65/// Info passed to [`Instrumenter::on_tool_call`].
66#[derive(Debug, Clone)]
67pub struct ToolCallInfo<'a> {
68    pub session_id: &'a str,
69    pub step: u32,
70    pub tool: &'a str,
71}
72
73/// Info passed to [`Instrumenter::on_tool_end`].
74#[derive(Debug, Clone)]
75pub struct ToolEndInfo<'a> {
76    pub session_id: &'a str,
77    pub step: u32,
78    pub tool: &'a str,
79    pub is_error: bool,
80}
81
82/// Info passed to [`Instrumenter::on_tool_error`].
83#[derive(Debug, Clone)]
84pub struct ToolErrorInfo<'a> {
85    pub session_id: &'a str,
86    pub step: u32,
87    pub tool: &'a str,
88    pub error: &'a str,
89}
90
91/// Info passed to [`Instrumenter::on_tool_discovered`].
92#[derive(Debug, Clone)]
93pub struct ToolDiscoveredInfo<'a> {
94    pub tools: &'a [ToolDescriptor],
95}
96
97/// Info passed to [`Instrumenter::on_output_validation_error`].
98#[derive(Debug, Clone)]
99pub struct OutputValidationErrorInfo<'a> {
100    pub session_id: &'a str,
101    pub error: &'a str,
102}
103
104// ── Instrumenter Trait ────────────────────────────────────────────────
105
106/// Fine-grained observation hooks for agent execution.
107///
108/// All methods have default no-op implementations.
109pub trait Instrumenter: Send + Sync {
110    /// Called when an agent run starts.
111    fn on_run_start(&self, _info: &RunStartInfo<'_>) {}
112    /// Called when an agent run completes successfully.
113    fn on_run_end(&self, _info: &RunEndInfo<'_>) {}
114    /// Called when an agent run fails.
115    fn on_run_error(&self, _info: &RunErrorInfo<'_>) {}
116    /// Called before an LLM request.
117    fn on_model_request(&self, _info: &ModelRequestInfo<'_>) {}
118    /// Called after an LLM response.
119    fn on_model_response(&self, _info: &ModelResponseInfo<'_>) {}
120    /// Called when an LLM request fails.
121    fn on_model_error(&self, _info: &ModelErrorInfo<'_>) {}
122    /// Called when a tool call starts.
123    fn on_tool_call(&self, _info: &ToolCallInfo<'_>) {}
124    /// Called when a tool call completes.
125    fn on_tool_end(&self, _info: &ToolEndInfo<'_>) {}
126    /// Called when a tool call fails.
127    fn on_tool_error(&self, _info: &ToolErrorInfo<'_>) {}
128    /// Called when tools are discovered.
129    fn on_tool_discovered(&self, _info: &ToolDiscoveredInfo<'_>) {}
130    /// Called when output validation fails.
131    fn on_output_validation_error(&self, _info: &OutputValidationErrorInfo<'_>) {}
132}
133
134// ── Implementations ───────────────────────────────────────────────────
135
136/// A no-op instrumenter that does nothing.
137#[derive(Debug, Clone, Copy, Default)]
138pub struct NoopInstrumenter;
139
140impl Instrumenter for NoopInstrumenter {}
141
142#[cfg(test)]
143mod tests {
144    use std::sync::{Arc, Mutex};
145
146    use super::*;
147
148    /// Tracks which hooks were called.
149    #[derive(Debug, Default)]
150    struct RecordingInstrumenter {
151        calls: Mutex<Vec<String>>,
152    }
153
154    impl Instrumenter for RecordingInstrumenter {
155        fn on_run_start(&self, _info: &RunStartInfo<'_>) {
156            self.calls.lock().unwrap_or_else(|p| p.into_inner()).push("run_start".into());
157        }
158        fn on_run_end(&self, _info: &RunEndInfo<'_>) {
159            self.calls.lock().unwrap_or_else(|p| p.into_inner()).push("run_end".into());
160        }
161        fn on_model_request(&self, _info: &ModelRequestInfo<'_>) {
162            self.calls.lock().unwrap_or_else(|p| p.into_inner()).push("model_request".into());
163        }
164        fn on_tool_call(&self, _info: &ToolCallInfo<'_>) {
165            self.calls.lock().unwrap_or_else(|p| p.into_inner()).push("tool_call".into());
166        }
167    }
168
169    #[test]
170    fn noop_instrumenter_is_default() {
171        let inst = NoopInstrumenter;
172        inst.on_run_start(&RunStartInfo { session_id: "s1", model: "test" });
173        // Just verify no panic.
174    }
175
176    #[test]
177    fn recording_instrumenter_tracks_calls() {
178        let inst = RecordingInstrumenter::default();
179        inst.on_run_start(&RunStartInfo { session_id: "s1", model: "test" });
180        inst.on_model_request(&ModelRequestInfo { session_id: "s1", step: 1, model: "test" });
181        inst.on_run_end(&RunEndInfo {
182            session_id: "s1",
183            finish_reason: FinishReason::Stop,
184            usage: &TokenUsage { prompt_tokens: 10, completion_tokens: 20 },
185        });
186
187        let calls = inst.calls.lock().unwrap_or_else(|p| p.into_inner());
188        assert_eq!(&*calls, &["run_start", "model_request", "run_end"]);
189    }
190
191    #[test]
192    fn instrumenter_is_object_safe() {
193        let _inst: Arc<dyn Instrumenter> = Arc::new(NoopInstrumenter);
194    }
195}