Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,28 @@ impl CliSession {

/// Start an interactive session, optionally with an initial message
pub async fn interactive(&mut self, prompt: Option<String>) -> Result<()> {
self.agent
.emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id)
.await;

let result = self.run_interactive(prompt).await;

self.agent
.emit_hook(goose::hooks::HookEvent::SessionEnd, &self.session_id)
.await;

if result.is_ok() {
println!(
"\n {} {}",
console::style("●").red(),
console::style(format!("session closed · {}", &self.session_id)).dim()
);
}

result
}

async fn run_interactive(&mut self, prompt: Option<String>) -> Result<()> {
if let Some(prompt) = prompt {
let msg = Message::user().with_text(&prompt);
self.process_message(msg, CancellationToken::default(), true)
Expand Down Expand Up @@ -536,12 +558,6 @@ impl CliSession {
.await?;
}

println!(
"\n {} {}",
console::style("●").red(),
console::style(format!("session closed · {}", &self.session_id)).dim()
);

Ok(())
}

Expand Down Expand Up @@ -1044,9 +1060,17 @@ impl CliSession {

/// Process a single message and exit
pub async fn headless(&mut self, prompt: String) -> Result<()> {
self.agent
.emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id)
.await;
let message = Message::user().with_text(&prompt);
self.process_message(message, CancellationToken::default(), false)
.await?;
let result = self
.process_message(message, CancellationToken::default(), false)
.await;
self.agent
.emit_hook(goose::hooks::HookEvent::SessionEnd, &self.session_id)
.await;
result?;
Ok(())
}

Expand Down
111 changes: 101 additions & 10 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>>,
}

Expand Down Expand Up @@ -282,10 +283,63 @@ impl Agent {
permission_manager,
provider.clone(),
),
hook_manager: crate::hooks::HookManager::load(std::env::current_dir().ok().as_deref()),
Comment thread
alexhancock marked this conversation as resolved.
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);
Comment thread
alexhancock marked this conversation as resolved.
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>,
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PreToolUse would allow blocking tool execution, right? Is that something we'd come back to?

Looks like open-plugins itself doesn't specify, but I believe in Claude Code this is designed such that you could e.g. have a hook deny a tool call

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting! Going to merge to take advantage of the green ci but will make a note to revisit in a future change

.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,
Expand Down Expand Up @@ -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)),
)
}

Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading