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
129 changes: 110 additions & 19 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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())
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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)
Comment thread
Kvadratni marked this conversation as resolved.
.await
{
crate::hooks::HookDecision::Allow => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -3372,6 +3402,7 @@ exit 0
Ok(Self {
temp_dir,
hook_log: plugin_dir.join("hook.log"),
payload_path: plugin_dir.join("payload.json"),
})
}

Expand All @@ -3393,6 +3424,11 @@ exit 0
.lines()
.count()
}

fn stop_payload(&self) -> Result<Value> {
let payload = std::fs::read_to_string(&self.payload_path)?;
Ok(serde_json::from_str(&payload)?)
}
}

struct CountingTextProvider {
Expand Down Expand Up @@ -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<MessageStream, ProviderError> {
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,
}
Expand Down Expand Up @@ -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();
Expand Down
11 changes: 11 additions & 0 deletions crates/goose/src/hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ pub struct HookContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_assistant_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
}

Expand All @@ -179,6 +181,7 @@ impl HookContext {
tool_input: None,
tool_output: None,
message: None,
last_assistant_message: None,
working_dir: None,
}
}
Expand All @@ -203,6 +206,14 @@ impl HookContext {
self
}

pub fn with_last_assistant_message(mut self, message: impl Into<String>) -> 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<String>) -> Self {
self.working_dir = Some(dir.into());
self
Expand Down
14 changes: 12 additions & 2 deletions documentation/docs/guides/context-engineering/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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:

Expand All @@ -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
Expand Down
Loading