agnt_core/observer.rs
1//! The [`Observer`] trait — single extension point for lifecycle hooks.
2//!
3//! Observers see every step start/end and every tool start/end during an
4//! agent's execution. They are the designated integration point for:
5//!
6//! - Audit logging (persist everything to an external system)
7//! - Human-in-the-loop approval (block or deny tool calls)
8//! - Event bus publishing (stream to NATS, Kafka, Redis)
9//! - Metrics collection (latency histograms, error rates)
10//! - OpenTelemetry spans (via a `tracing-opentelemetry` bridge)
11//!
12//! There is exactly ONE observer per [`Agent`](crate::Agent), not a Vec.
13//! Users who want to fan out to multiple destinations can wrap their
14//! concerns in a single composite `Observer` impl.
15
16use crate::message::{Message, ToolCall};
17
18/// Result of a tool execution, passed to observers after dispatch.
19#[derive(Debug, Clone)]
20pub struct ToolResult {
21 pub name: String,
22 pub output: Result<String, String>,
23 pub duration_us: u64,
24}
25
26/// Context for a step lifecycle event.
27///
28/// Carries the session id and the user input that triggered this step.
29/// Expands in v0.3 to include step number and deadline.
30#[derive(Debug, Clone)]
31pub struct StepContext {
32 pub session: String,
33 pub user_input: String,
34}
35
36/// Disposition returned by [`Observer::should_dispatch`] — whether a tool
37/// call should proceed, be refused, or be intercepted.
38///
39/// v0.3 C2. Added as the canonical extension point for trust tier enforcement,
40/// human-in-the-loop approval, and policy gating. The agent treats
41/// [`Disposition::Refused`] as a synthetic tool result (fed back to the model
42/// as an error) so the loop continues instead of aborting.
43#[derive(Debug, Clone)]
44pub enum Disposition {
45 /// The tool call may proceed normally.
46 Allow,
47 /// The tool call is refused. The provided message becomes the tool
48 /// result passed back to the model ("wrapped" in the standard
49 /// `<tool_output>` envelope by the agent).
50 Refused(String),
51}
52
53/// Lifecycle observer. Every method has a default no-op implementation so
54/// implementors override only the hooks they care about.
55pub trait Observer: Send + Sync {
56 /// Called when [`Agent::step`](crate::Agent::step) begins.
57 fn on_step_start(&self, _ctx: &StepContext) {}
58
59 /// Called before each tool dispatch inside the step loop.
60 fn on_tool_start(&self, _call: &ToolCall) {}
61
62 /// Called after each tool dispatch completes, with the result.
63 fn on_tool_end(&self, _call: &ToolCall, _result: &ToolResult) {}
64
65 /// Called when the step loop terminates with a final assistant message.
66 fn on_step_end(&self, _response: &Message) {}
67
68 /// Called if the step loop errors out before producing a final message.
69 fn on_step_error(&self, _error: &str) {}
70
71 /// v0.3 C2 — policy gate fired BEFORE every tool dispatch.
72 ///
73 /// Returning [`Disposition::Allow`] (the default) lets the call proceed.
74 /// Returning [`Disposition::Refused`] causes the agent to skip the actual
75 /// tool call and return the provided message to the model as a synthetic
76 /// tool result. The loop continues — the model may choose to call a
77 /// different tool, retry with different arguments, or stop.
78 ///
79 /// This is the canonical extension point for:
80 /// - Trust tier enforcement (deny by policy)
81 /// - Human-in-the-loop approval (block until a human clicks "allow")
82 /// - Quota accounting layered on top of the built-in `Agent::tool_quotas`
83 /// - Content filtering on tool arguments
84 ///
85 /// Default impl always allows. Existing `Observer` implementations do
86 /// not need to change.
87 fn should_dispatch(&self, _call: &ToolCall) -> Disposition {
88 Disposition::Allow
89 }
90}
91
92/// A no-op observer used as the default when the agent is constructed
93/// without one.
94pub struct NoOpObserver;
95
96impl Observer for NoOpObserver {}