feat: Hooks (#9093)

This commit is contained in:
Alex Hancock
2026-05-11 12:02:44 -04:00
committed by GitHub
parent f5eda3527c
commit 40d4b118bb
12 changed files with 1258 additions and 18 deletions
+101 -10
View File
@@ -171,6 +171,7 @@ pub struct Agent {
pub(super) retry_manager: RetryManager,
pub(super) tool_inspection_manager: ToolInspectionManager,
pub(super) hook_manager: crate::hooks::HookManager,
container: Mutex<Option<Container>>,
}
@@ -282,10 +283,63 @@ impl Agent {
permission_manager,
provider.clone(),
),
hook_manager: crate::hooks::HookManager::load(std::env::current_dir().ok().as_deref()),
container: Mutex::new(None),
}
}
/// Emit a lifecycle hook event with no extra context. Useful for events
/// that have no matcher (e.g. `SessionStart`, `SessionEnd`).
pub async fn emit_hook(&self, event: crate::hooks::HookEvent, session_id: &str) {
if !self.hook_manager.has_hooks(event) {
return;
}
self.hook_manager
.emit(event, crate::hooks::HookContext::new(event, session_id))
.await;
}
fn with_post_tool_hook(
&self,
result: ToolCallResult,
tool_call: &CallToolRequestParams,
session: &Session,
) -> ToolCallResult {
let hook_manager = self.hook_manager.clone();
let session_id = session.id.clone();
let working_dir = session.working_dir.to_string_lossy().to_string();
let tool_name = tool_call.name.to_string();
let tool_input = tool_call
.arguments
.as_ref()
.map(|a| serde_json::Value::Object(a.clone()));
let fut = async move {
let processed_result =
super::large_response_handler::process_tool_response(result.result.await);
let event = match &processed_result {
Ok(call_result) if call_result.is_error != Some(true) => {
crate::hooks::HookEvent::PostToolUse
}
_ => crate::hooks::HookEvent::PostToolUseFailure,
};
if hook_manager.has_hooks(event) {
let ctx = crate::hooks::HookContext::new(event, &session_id)
.with_tool(tool_name, tool_input)
.with_working_dir(working_dir);
hook_manager.emit(event, ctx).await;
}
processed_result
};
ToolCallResult {
notification_stream: result.notification_stream,
result: Box::new(fut.boxed()),
}
}
/// Create a tool inspection manager with default inspectors
fn create_tool_inspection_manager(
permission_manager: Arc<PermissionManager>,
@@ -613,22 +667,52 @@ impl Agent {
.await
.record_tool_arguments(&tool_call.arguments, &session.working_dir);
if self
.hook_manager
.has_hooks(crate::hooks::HookEvent::PreToolUse)
{
let ctx =
crate::hooks::HookContext::new(crate::hooks::HookEvent::PreToolUse, &session.id)
.with_tool(
tool_call.name.to_string(),
tool_call
.arguments
.as_ref()
.map(|a| serde_json::Value::Object(a.clone())),
)
.with_working_dir(session.working_dir.to_string_lossy().to_string());
self.hook_manager
.emit(crate::hooks::HookEvent::PreToolUse, ctx)
.await;
}
if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME {
let arguments = tool_call
.arguments
.clone()
.map(Value::Object)
.unwrap_or(Value::Object(serde_json::Map::new()));
let result = self
.handle_schedule_management(arguments, request_id.clone())
.await;
let wrapped_result = result.map(CallToolResult::success);
return (request_id, Ok(ToolCallResult::from(wrapped_result)));
return (
request_id,
Ok(self.with_post_tool_hook(
ToolCallResult::from(wrapped_result),
&tool_call,
session,
)),
);
}
if tool_call.name == FINAL_OUTPUT_TOOL_NAME {
return if let Some(final_output_tool) = self.final_output_tool.lock().await.as_mut() {
let result = final_output_tool.execute_tool_call(tool_call.clone()).await;
(request_id, Ok(result))
(
request_id,
Ok(self.with_post_tool_hook(result, &tool_call, session)),
)
} else {
(
request_id,
@@ -680,14 +764,7 @@ impl Agent {
(
request_id,
Ok(ToolCallResult {
notification_stream: result.notification_stream,
result: Box::new(
result
.result
.map(super::large_response_handler::process_tool_response),
),
}),
Ok(self.with_post_tool_hook(result, &tool_call, session)),
)
}
@@ -1086,6 +1163,20 @@ impl Agent {
let message_text = user_message.as_concat_text();
if self
.hook_manager
.has_hooks(crate::hooks::HookEvent::UserPromptSubmit)
{
let ctx = crate::hooks::HookContext::new(
crate::hooks::HookEvent::UserPromptSubmit,
&session_config.id,
)
.with_message(message_text.clone());
self.hook_manager
.emit(crate::hooks::HookEvent::UserPromptSubmit, ctx)
.await;
}
// Track custom slash command usage (don't track command name for privacy)
if message_text.trim().starts_with('/') {
let command = message_text.split_whitespace().next();