Skip to main content

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 {}