diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 3e62fd6ae..653aad1ab 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -68,6 +68,7 @@ use tracing::{debug, error, info, instrument, warn}; const DEFAULT_MAX_TURNS: u32 = 1000; const DEFAULT_STOP_HOOK_BLOCK_CAP: u32 = 8; const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation..."; +const MAX_TURNS_MESSAGE: &str = "I've reached the maximum number of actions I can do without user input. Would you like me to continue?"; const DEFAULT_FRONTEND_INSTRUCTIONS: &str = "The following tools are provided directly by the frontend and will be executed by the frontend when called."; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -424,6 +425,39 @@ impl Agent { .await; } + fn stop_hook_context( + session_id: &str, + last_assistant_message: &str, + ) -> crate::hooks::HookContext { + crate::hooks::HookContext::new(crate::hooks::HookEvent::Stop, session_id) + .with_last_assistant_message(last_assistant_message.to_string()) + } + + async fn emit_stop_hook(&self, session_id: &str, last_assistant_message: &str) { + if !self.hook_manager.has_hooks(crate::hooks::HookEvent::Stop) { + return; + } + self.hook_manager + .emit( + crate::hooks::HookEvent::Stop, + Self::stop_hook_context(session_id, last_assistant_message), + ) + .await; + } + + async fn emit_stop_hook_blocking( + &self, + session_id: &str, + last_assistant_message: &str, + ) -> crate::hooks::HookDecision { + self.hook_manager + .emit_blocking( + crate::hooks::HookEvent::Stop, + Self::stop_hook_context(session_id, last_assistant_message), + ) + .await + } + pub async fn steer(&self, session_id: &str, message: Message) { self.pending_steers .lock() @@ -1883,18 +1917,14 @@ impl Agent { guard.as_mut().and_then(|fot| fot.final_output.take()) }; if let Some(output) = final_output { + last_assistant_text = output.clone(); let message = Message::assistant().with_text(output); yield AgentEvent::Message(message.clone()); session_manager.add_message(&session_config.id, &message).await?; conversation.push(message); - let ctx = crate::hooks::HookContext::new( - crate::hooks::HookEvent::Stop, - &session_config.id, - ); match self - .hook_manager - .emit_blocking(crate::hooks::HookEvent::Stop, ctx) + .emit_stop_hook_blocking(&session_config.id, &last_assistant_text) .await { crate::hooks::HookDecision::Allow => { @@ -1926,11 +1956,8 @@ impl Agent { turns_taken += 1; } if turns_taken > max_turns { - yield AgentEvent::Message( - Message::assistant().with_text( - "I've reached the maximum number of actions I can do without user input. Would you like me to continue?" - ) - ); + last_assistant_text = MAX_TURNS_MESSAGE.to_string(); + yield AgentEvent::Message(Message::assistant().with_text(last_assistant_text.clone())); break; } @@ -1951,6 +1978,7 @@ impl Agent { &tools, &toolshim_tools, ).await?; + last_assistant_text.clear(); let current_turn_tool_count = conversation.messages().iter() .flat_map(|m| m.content.iter()) @@ -2038,7 +2066,7 @@ impl Agent { if num_tool_requests == 0 { let text = filtered_response.as_concat_text(); if !text.is_empty() { - last_assistant_text = text; + last_assistant_text.push_str(&text); } messages_to_add.push(response); continue; @@ -2549,6 +2577,7 @@ impl Agent { } if let Some(output) = pending_final_output.take() { + last_assistant_text = output.clone(); let message = Message::assistant().with_text(output); messages_to_add.push(message.clone()); yield AgentEvent::Message(message); @@ -2574,13 +2603,8 @@ impl Agent { } if exit_chat { - let ctx = crate::hooks::HookContext::new( - crate::hooks::HookEvent::Stop, - &session_config.id, - ); match self - .hook_manager - .emit_blocking(crate::hooks::HookEvent::Stop, ctx) + .emit_stop_hook_blocking(&session_config.id, &last_assistant_text) .await { crate::hooks::HookDecision::Allow => { @@ -2613,7 +2637,7 @@ impl Agent { } if !stop_hook_handled_for_exit { - self.emit_hook(crate::hooks::HookEvent::Stop, &session_config.id).await; + self.emit_stop_hook(&session_config.id, &last_assistant_text).await; } }.instrument(reply_stream_span)); Ok(inner) @@ -3340,11 +3364,17 @@ if [ $((count % 2)) -eq 1 ]; then exit 2 fi exit 0 +"#; + + const RECORD_PAYLOAD_SCRIPT: &str = r#"#!/bin/sh +cat > "$PLUGIN_ROOT/payload.json" +exit 0 "#; struct StopHookTestEnv { temp_dir: TempDir, hook_log: PathBuf, + payload_path: PathBuf, } impl StopHookTestEnv { @@ -3372,6 +3402,7 @@ exit 0 Ok(Self { temp_dir, hook_log: plugin_dir.join("hook.log"), + payload_path: plugin_dir.join("payload.json"), }) } @@ -3393,6 +3424,11 @@ exit 0 .lines() .count() } + + fn stop_payload(&self) -> Result { + let payload = std::fs::read_to_string(&self.payload_path)?; + Ok(serde_json::from_str(&payload)?) + } } struct CountingTextProvider { @@ -3432,6 +3468,33 @@ exit 0 } } + struct ChunkedTextProvider; + + #[async_trait::async_trait] + impl crate::providers::base::Provider for ChunkedTextProvider { + async fn stream( + &self, + _model_config: &goose_providers::model::ModelConfig, + _session_id: &str, + _system_prompt: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> Result { + let usage = ProviderUsage::new("mock-model".to_string(), Usage::default()); + Ok(Box::pin(futures::stream::iter(vec![ + Ok((Some(Message::assistant().with_text("streamed ")), None)), + Ok(( + Some(Message::assistant().with_text("assistant reply")), + Some(usage), + )), + ]))) + } + + fn get_name(&self) -> &str { + "chunked-text" + } + } + struct RefusingProvider { call_count: AtomicUsize, } @@ -3649,6 +3712,34 @@ exit 0 Ok(()) } + #[tokio::test] + async fn stop_hook_payload_includes_streamed_assistant_reply_text() -> Result<()> { + let env = StopHookTestEnv::new(RECORD_PAYLOAD_SCRIPT)?; + let provider = Arc::new(ChunkedTextProvider); + let (agent, session_id) = + create_test_agent(env.data_dir(), env.hook_manager(), provider).await?; + + let messages = run_stop_hook_test_turn(&agent, &session_id, "hello").await?; + let texts = visible_texts(&messages); + assert_eq!(texts.join(""), "streamed assistant reply"); + + let payload = env.stop_payload()?; + assert_eq!(payload.get("event").and_then(Value::as_str), Some("Stop")); + assert_eq!( + payload.get("session_id").and_then(Value::as_str), + Some(session_id.as_str()) + ); + assert_eq!( + payload + .get("last_assistant_message") + .and_then(Value::as_str), + Some("streamed assistant reply") + ); + assert!(payload.get("message").is_none()); + + Ok(()) + } + #[tokio::test] async fn test_add_final_output_tool() -> Result<()> { let agent = Agent::new(); diff --git a/crates/goose/src/hooks/mod.rs b/crates/goose/src/hooks/mod.rs index d85668038..89bc39295 100644 --- a/crates/goose/src/hooks/mod.rs +++ b/crates/goose/src/hooks/mod.rs @@ -166,6 +166,8 @@ pub struct HookContext { #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub last_assistant_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub working_dir: Option, } @@ -179,6 +181,7 @@ impl HookContext { tool_input: None, tool_output: None, message: None, + last_assistant_message: None, working_dir: None, } } @@ -203,6 +206,14 @@ impl HookContext { self } + pub fn with_last_assistant_message(mut self, message: impl Into) -> Self { + let message = message.into(); + if !message.is_empty() { + self.last_assistant_message = Some(message); + } + self + } + pub fn with_working_dir(mut self, dir: impl Into) -> Self { self.working_dir = Some(dir.into()); self diff --git a/documentation/docs/guides/context-engineering/hooks.md b/documentation/docs/guides/context-engineering/hooks.md index cb17b9466..fb45b73f4 100644 --- a/documentation/docs/guides/context-engineering/hooks.md +++ b/documentation/docs/guides/context-engineering/hooks.md @@ -133,7 +133,7 @@ Use `${PLUGIN_ROOT}` in a command to reference the plugin directory. goose also |---|---|---| | `SessionStart` | A session starts | None | | `SessionEnd` | A session ends | None | -| `Stop` | goose receives a stop event | None | +| `Stop` | goose finishes a turn or receives a stop event | None | | `UserPromptSubmit` | The user submits a prompt | Prompt text | | `PreToolUse` | Before goose runs a tool | Tool name | | `PostToolUse` | After a tool succeeds | Tool name | @@ -151,7 +151,7 @@ The matcher is a regular expression matched against the most relevant string for ## Hook Payload -When a hook runs, goose writes a JSON payload to the command's stdin. The payload always includes the event name and session ID, and may include fields such as the tool name, tool input, user message, or working directory. +When a hook runs, goose writes a JSON payload to the command's stdin. The payload always includes the event name and session ID, and may include fields such as the tool name, tool input, user message, last assistant message, or working directory. Example payload for a tool event: @@ -166,6 +166,16 @@ Example payload for a tool event: } ``` +Example payload for a `Stop` event after an assistant reply: + +```json +{ + "event": "Stop", + "session_id": "abc-123", + "last_assistant_message": "Done. I updated the file and ran the tests." +} +``` + Example script that reads the payload: ```bash