Skip to main content

agent_sdk_tools/
hooks.rs

1//! Agent lifecycle hooks for customization.
2//!
3//! Hooks allow you to intercept and customize agent behavior at key points:
4//!
5//! - [`AgentHooks::pre_tool_use`] - Control tool execution permissions
6//! - [`AgentHooks::post_tool_use`] - React to tool completion
7//! - [`AgentHooks::on_event`] - Log or process events
8//! - [`AgentHooks::on_error`] - Handle errors and decide recovery
9//!
10//! # Built-in Implementations
11//!
12//! - [`DefaultHooks`] - Tier-based permissions (default)
13//! - [`AllowAllHooks`] - Allow all tools without confirmation
14//! - [`LoggingHooks`] - Debug logging for all events
15
16use agent_sdk_foundation::events::AgentEvent;
17use agent_sdk_foundation::llm;
18use agent_sdk_foundation::types::{ToolInvocation, ToolResult, ToolTier};
19use async_trait::async_trait;
20
21/// Decision returned by pre-tool hooks
22#[derive(Debug, Clone)]
23#[non_exhaustive]
24pub enum ToolDecision {
25    /// Allow the tool to execute
26    Allow,
27    /// Block the tool execution with a message
28    Block(String),
29    /// Tool requires user confirmation.
30    RequiresConfirmation(String),
31}
32
33/// Decision returned by [`AgentHooks::pre_llm_request`] — an input guardrail
34/// that runs before the outbound [`llm::ChatRequest`] is sent to the provider.
35///
36/// This is the place for prompt-injection scrubbing, PII gating, or
37/// system-prompt policy enforcement.
38///
39/// **Forward-compat is fail-open:** the agent loop matches this enum with a
40/// wildcard arm that treats unknown variants as `Proceed`, so a *restrictive*
41/// variant added here in a future release is silently ignored by loops built
42/// against older SDKs. Never add a variant whose ignored meaning weakens a
43/// security decision — extend `Block`/`Modify` semantics instead, or plan a
44/// breaking change.
45#[derive(Debug, Clone)]
46#[non_exhaustive]
47pub enum RequestDecision {
48    /// Send the request unchanged.
49    Proceed,
50    /// Send a modified request instead of the original.
51    Modify(Box<llm::ChatRequest>),
52    /// Refuse to call the model; the string explains why.
53    Block(String),
54}
55
56/// Decision returned by [`AgentHooks::on_llm_response`] — an output guardrail
57/// that runs after the provider responds but before the response is persisted
58/// and surfaced.
59///
60/// This is the place for output moderation or secret-leakage detection.
61///
62/// **Forward-compat is fail-open:** the agent loop matches this enum with a
63/// wildcard arm that treats unknown variants as `Accept`, so a *restrictive*
64/// variant added here in a future release is silently ignored by loops built
65/// against older SDKs. Never add a variant whose ignored meaning weakens a
66/// security decision — extend `Block`/`RetryWithFeedback` semantics instead,
67/// or plan a breaking change.
68#[derive(Debug, Clone)]
69#[non_exhaustive]
70pub enum ResponseDecision {
71    /// Accept the response as-is.
72    Accept,
73    /// Reject the response; the string explains why.
74    Block(String),
75    /// Reject the response and feed the string back to the model so it can
76    /// retry on the next turn.
77    RetryWithFeedback(String),
78}
79
80/// Lifecycle hooks for the agent loop.
81/// Implement this trait to customize agent behavior.
82#[async_trait]
83pub trait AgentHooks: Send + Sync {
84    /// Called before a tool is executed.
85    ///
86    /// Receives a structured [`ToolInvocation`] that bundles tool identity,
87    /// tier, requested input, effective input, and listen-context — everything
88    /// a server-side policy engine needs for an allow / block / confirm decision.
89    ///
90    /// Return [`ToolDecision::Allow`] to proceed, [`ToolDecision::Block`] to
91    /// reject, or [`ToolDecision::RequiresConfirmation`] to yield for user
92    /// approval.
93    async fn pre_tool_use(&self, invocation: &ToolInvocation) -> ToolDecision {
94        match invocation.tier {
95            ToolTier::Observe => ToolDecision::Allow,
96            ToolTier::Confirm => {
97                ToolDecision::RequiresConfirmation(format!("Confirm {}?", invocation.tool_name))
98            }
99        }
100    }
101
102    /// Called after a tool completes execution.
103    async fn post_tool_use(&self, _tool_name: &str, _result: &ToolResult) {
104        // Default: no-op
105    }
106
107    /// Called when the agent emits an event.
108    /// Can be used for logging, metrics, or custom handling.
109    async fn on_event(&self, _event: &AgentEvent) {
110        // Default: no-op
111    }
112
113    /// Called when an error occurs.
114    /// Return true to attempt recovery, false to abort.
115    async fn on_error(&self, _error: &anyhow::Error) -> bool {
116        // Default: don't recover
117        false
118    }
119
120    /// Called when context is about to be compacted due to length.
121    /// Return a summary to use, or None to use default summarization.
122    async fn on_context_compact(&self, _messages: &[llm::Message]) -> Option<String> {
123        // Default: use built-in summarization
124        None
125    }
126
127    /// Input guardrail: called with the outbound [`llm::ChatRequest`] before it
128    /// is sent to the provider.
129    ///
130    /// Return [`RequestDecision::Proceed`] to send it unchanged,
131    /// [`RequestDecision::Modify`] to substitute a sanitized request, or
132    /// [`RequestDecision::Block`] to refuse the call (e.g. prompt-injection or
133    /// PII policy). The default proceeds unchanged.
134    ///
135    /// **Fresh-input timing:** for a run's first call on fresh `Text` /
136    /// `Message` input the hook runs at *ingestion time*, against the
137    /// request built from existing history plus the candidate message —
138    /// BEFORE the message is durably appended, so a `Block` leaves nothing
139    /// in history. The request actually sent may then differ only by
140    /// SDK-added content: context compaction (a summary of the exact
141    /// content the hook already saw — `Block` decisions err conservative)
142    /// or a turn-budget reminder. A `Modify` on that first call is sent
143    /// as-is (bypassing compaction once; an oversized replacement degrades
144    /// to the normal overflow recovery). The hook fires exactly once per
145    /// LLM call in every mode.
146    async fn pre_llm_request(&self, _request: &llm::ChatRequest) -> RequestDecision {
147        RequestDecision::Proceed
148    }
149
150    /// Output guardrail: called with the provider's [`llm::ChatResponse`] before
151    /// it is persisted or surfaced.
152    ///
153    /// Return [`ResponseDecision::Accept`] to keep it,
154    /// [`ResponseDecision::Block`] to reject it, or
155    /// [`ResponseDecision::RetryWithFeedback`] to reject it and steer a retry
156    /// (e.g. output moderation or secret-leakage detection). The default
157    /// accepts.
158    ///
159    /// **Retry cap:** every `RetryWithFeedback` pays for another LLM
160    /// round-trip, so the loop bounds *consecutive* rejections at 8. When
161    /// the cap is reached the run terminates with an error naming this hook
162    /// instead of retrying (and billing) indefinitely. The rejection streak
163    /// is persisted in the thread's `AgentState`, so the cap binds both
164    /// in-process looping runs and host-driven single-turn orchestration
165    /// (repeated `run_turn` calls rehydrate the streak from the state
166    /// store). The counter resets whenever a response is accepted.
167    ///
168    /// **Streaming caveat:** the hook runs on the *complete* response, after
169    /// the model has finished. In streaming mode the text deltas have already
170    /// been emitted (and recorded) as stream events by then, so a `Block` /
171    /// `RetryWithFeedback` decision keeps the response out of the thread's
172    /// message history and out of the model's context, but a live consumer
173    /// may have rendered the deltas. Disable streaming when blocked content
174    /// must never be surfaced.
175    async fn on_llm_response(&self, _response: &llm::ChatResponse) -> ResponseDecision {
176        ResponseDecision::Accept
177    }
178}
179
180/// Default hooks implementation that uses tier-based decisions
181#[derive(Clone, Copy, Default)]
182pub struct DefaultHooks;
183
184#[async_trait]
185impl AgentHooks for DefaultHooks {}
186
187/// Hooks that allow all tools without confirmation
188#[derive(Clone, Copy, Default)]
189pub struct AllowAllHooks;
190
191#[async_trait]
192impl AgentHooks for AllowAllHooks {
193    async fn pre_tool_use(&self, _invocation: &ToolInvocation) -> ToolDecision {
194        ToolDecision::Allow
195    }
196}
197
198/// Hooks that log all events (useful for debugging)
199#[derive(Clone, Copy, Default)]
200pub struct LoggingHooks;
201
202#[async_trait]
203impl AgentHooks for LoggingHooks {
204    async fn pre_tool_use(&self, invocation: &ToolInvocation) -> ToolDecision {
205        log::debug!(
206            "Pre-tool use tool={} input={:?} tier={:?}",
207            invocation.tool_name,
208            invocation.requested_input,
209            invocation.tier,
210        );
211        DefaultHooks.pre_tool_use(invocation).await
212    }
213
214    async fn post_tool_use(&self, tool_name: &str, result: &ToolResult) {
215        log::debug!(
216            "Post-tool use tool={tool_name} success={} duration_ms={:?}",
217            result.success,
218            result.duration_ms
219        );
220    }
221
222    async fn on_event(&self, event: &AgentEvent) {
223        log::debug!("Agent event {event:?}");
224    }
225
226    async fn on_error(&self, error: &anyhow::Error) -> bool {
227        log::error!("Agent error {error:?}");
228        false
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use serde_json::json;
236
237    fn invocation(tier: ToolTier) -> ToolInvocation {
238        ToolInvocation {
239            tool_call_id: "call_1".to_string(),
240            tool_name: "danger".to_string(),
241            display_name: "Danger".to_string(),
242            tier,
243            requested_input: json!({}),
244            effective_input: json!({}),
245            listen_context: None,
246        }
247    }
248
249    #[tokio::test]
250    async fn default_hooks_gate_confirm_tier() {
251        // A Confirm-tier tool must yield for confirmation under the default
252        // policy — side-effecting tools never auto-run.
253        let decision = DefaultHooks
254            .pre_tool_use(&invocation(ToolTier::Confirm))
255            .await;
256        assert!(
257            matches!(decision, ToolDecision::RequiresConfirmation(_)),
258            "Confirm tier must require confirmation, got {decision:?}"
259        );
260    }
261
262    #[tokio::test]
263    async fn default_hooks_auto_allow_observe_tier() {
264        let decision = DefaultHooks
265            .pre_tool_use(&invocation(ToolTier::Observe))
266            .await;
267        assert!(
268            matches!(decision, ToolDecision::Allow),
269            "Observe tier may auto-run, got {decision:?}"
270        );
271    }
272
273    #[tokio::test]
274    async fn default_hooks_llm_guardrails_are_permissive_noops() {
275        let request = llm::ChatRequest::new("sys", vec![llm::Message::user("hi")]);
276        assert!(matches!(
277            DefaultHooks.pre_llm_request(&request).await,
278            RequestDecision::Proceed
279        ));
280
281        let response = llm::ChatResponse {
282            id: "resp_1".to_string(),
283            content: Vec::new(),
284            model: "test-model".to_string(),
285            stop_reason: None,
286            usage: llm::Usage {
287                input_tokens: 0,
288                output_tokens: 0,
289                cached_input_tokens: 0,
290                cache_creation_input_tokens: 0,
291            },
292        };
293        assert!(matches!(
294            DefaultHooks.on_llm_response(&response).await,
295            ResponseDecision::Accept
296        ));
297    }
298}