use agent_sdk_foundation::events::AgentEvent;
use agent_sdk_foundation::llm;
use agent_sdk_foundation::types::{ToolInvocation, ToolResult, ToolTier};
use async_trait::async_trait;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ToolDecision {
Allow,
Block(String),
RequiresConfirmation(String),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum RequestDecision {
Proceed,
Modify(Box<llm::ChatRequest>),
Block(String),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ResponseDecision {
Accept,
Block(String),
RetryWithFeedback(String),
}
#[async_trait]
pub trait AgentHooks: Send + Sync {
async fn pre_tool_use(&self, invocation: &ToolInvocation) -> ToolDecision {
match invocation.tier {
ToolTier::Observe => ToolDecision::Allow,
ToolTier::Confirm => {
ToolDecision::RequiresConfirmation(format!("Confirm {}?", invocation.tool_name))
}
}
}
async fn post_tool_use(&self, _tool_name: &str, _result: &ToolResult) {
}
async fn on_event(&self, _event: &AgentEvent) {
}
async fn on_error(&self, _error: &anyhow::Error) -> bool {
false
}
async fn on_context_compact(&self, _messages: &[llm::Message]) -> Option<String> {
None
}
async fn pre_llm_request(&self, _request: &llm::ChatRequest) -> RequestDecision {
RequestDecision::Proceed
}
async fn on_llm_response(&self, _response: &llm::ChatResponse) -> ResponseDecision {
ResponseDecision::Accept
}
}
#[derive(Clone, Copy, Default)]
pub struct DefaultHooks;
#[async_trait]
impl AgentHooks for DefaultHooks {}
#[derive(Clone, Copy, Default)]
pub struct AllowAllHooks;
#[async_trait]
impl AgentHooks for AllowAllHooks {
async fn pre_tool_use(&self, _invocation: &ToolInvocation) -> ToolDecision {
ToolDecision::Allow
}
}
#[derive(Clone, Copy, Default)]
pub struct LoggingHooks;
#[async_trait]
impl AgentHooks for LoggingHooks {
async fn pre_tool_use(&self, invocation: &ToolInvocation) -> ToolDecision {
log::debug!(
"Pre-tool use tool={} input={:?} tier={:?}",
invocation.tool_name,
invocation.requested_input,
invocation.tier,
);
DefaultHooks.pre_tool_use(invocation).await
}
async fn post_tool_use(&self, tool_name: &str, result: &ToolResult) {
log::debug!(
"Post-tool use tool={tool_name} success={} duration_ms={:?}",
result.success,
result.duration_ms
);
}
async fn on_event(&self, event: &AgentEvent) {
log::debug!("Agent event {event:?}");
}
async fn on_error(&self, error: &anyhow::Error) -> bool {
log::error!("Agent error {error:?}");
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn invocation(tier: ToolTier) -> ToolInvocation {
ToolInvocation {
tool_call_id: "call_1".to_string(),
tool_name: "danger".to_string(),
display_name: "Danger".to_string(),
tier,
requested_input: json!({}),
effective_input: json!({}),
listen_context: None,
}
}
#[tokio::test]
async fn default_hooks_gate_confirm_tier() {
let decision = DefaultHooks
.pre_tool_use(&invocation(ToolTier::Confirm))
.await;
assert!(
matches!(decision, ToolDecision::RequiresConfirmation(_)),
"Confirm tier must require confirmation, got {decision:?}"
);
}
#[tokio::test]
async fn default_hooks_auto_allow_observe_tier() {
let decision = DefaultHooks
.pre_tool_use(&invocation(ToolTier::Observe))
.await;
assert!(
matches!(decision, ToolDecision::Allow),
"Observe tier may auto-run, got {decision:?}"
);
}
#[tokio::test]
async fn default_hooks_llm_guardrails_are_permissive_noops() {
let request = llm::ChatRequest::new("sys", vec![llm::Message::user("hi")]);
assert!(matches!(
DefaultHooks.pre_llm_request(&request).await,
RequestDecision::Proceed
));
let response = llm::ChatResponse {
id: "resp_1".to_string(),
content: Vec::new(),
model: "test-model".to_string(),
stop_reason: None,
usage: llm::Usage {
input_tokens: 0,
output_tokens: 0,
cached_input_tokens: 0,
cache_creation_input_tokens: 0,
},
};
assert!(matches!(
DefaultHooks.on_llm_response(&response).await,
ResponseDecision::Accept
));
}
}