feat: add /goal command for agent self-evaluation before finishing (#9069)

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Neale
2026-05-20 13:23:58 +10:00
committed by GitHub
parent 971c690328
commit bc6293ce70
3 changed files with 391 additions and 0 deletions
+62
View File
@@ -213,6 +213,8 @@ pub struct Agent {
pub(super) tool_inspection_manager: ToolInspectionManager,
pub(super) hook_manager: crate::hooks::HookManager,
container: Mutex<Option<Container>>,
goal: Mutex<Option<String>>,
grind: Mutex<Option<String>>,
}
#[derive(Clone, Debug)]
@@ -330,6 +332,8 @@ impl Agent {
),
hook_manager: crate::hooks::HookManager::load(std::env::current_dir().ok().as_deref()),
container: Mutex::new(None),
goal: Mutex::new(None),
grind: Mutex::new(None),
}
}
@@ -1636,6 +1640,7 @@ impl Agent {
});
let mut compaction_attempts = 0;
let mut last_assistant_text = String::new();
let mut goal_check_pending = false;
let mut tool_pair_summarization_done = false;
loop {
@@ -1986,6 +1991,8 @@ impl Agent {
}
no_tools_called = false;
// Agent is actively working — re-check goal when it next finishes
goal_check_pending = false;
}
}
#[allow(unused_variables)]
@@ -2136,7 +2143,46 @@ impl Agent {
None if did_recovery_compact_this_iteration => {
// continue from last user message after recovery compact
}
None if self.goal.lock().await.is_some() && !goal_check_pending => {
goal_check_pending = true;
let goal = self.goal.lock().await.clone().unwrap();
let nudge = format!(
"Before finishing, check whether the following goal has been fully met:\n\n\
**Goal:** {goal}\n\n\
If not, continue working toward it."
);
let message = Message::user().with_text(&nudge)
.with_visibility(false, true);
messages_to_add.push(message);
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::InlineMessage,
format!("Goal: {goal}"),
)
);
}
None if self.grind.lock().await.is_some() => {
let grind = self.grind.lock().await.clone().unwrap();
let nudge = format!(
"Keep working. The grind goal is not yet complete:\n\n\
**Goal:** {grind}\n\n\
Continue until it is fully done."
);
let message = Message::user().with_text(&nudge)
.with_visibility(false, true);
messages_to_add.push(message);
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::InlineMessage,
format!("Grind: {grind}"),
)
);
}
None => {
self.set_goal(None).await;
self.set_grind(None).await;
match self.handle_retry_logic(&mut conversation, &session_config, &initial_messages).await {
Ok(should_retry) => {
if should_retry {
@@ -2224,6 +2270,22 @@ impl Agent {
prompt_manager.add_system_prompt_extra(key, instruction);
}
pub async fn set_goal(&self, goal: Option<String>) {
*self.goal.lock().await = goal;
}
pub async fn get_goal(&self) -> Option<String> {
self.goal.lock().await.clone()
}
pub async fn set_grind(&self, goal: Option<String>) {
*self.grind.lock().await = goal;
}
pub async fn get_grind(&self) -> Option<String> {
self.grind.lock().await.clone()
}
pub async fn update_provider(
&self,
provider: Arc<dyn Provider>,
@@ -41,6 +41,15 @@ static COMMANDS: &[CommandDef] = &[
name: "doctor",
description: "Check that your Goose setup is working",
},
CommandDef {
name: "goal",
description: "Set a goal the agent must satisfy before finishing, or clear with /goal off",
},
CommandDef {
name: "grind",
description:
"Set a goal the agent pursues relentlessly until max_turns, or clear with /grind off",
},
];
pub struct ParsedSlashCommand<'a> {
@@ -101,6 +110,8 @@ impl Agent {
"clear" => self.handle_clear_command(session_id).await,
"skills" => self.handle_skills_command(session_id).await,
"doctor" => Ok(Some(crate::doctor::run(self, session_id).await?)),
"goal" => self.handle_goal_command(params_str).await,
"grind" => self.handle_grind_command(params_str).await,
_ => {
self.handle_recipe_command(command, params_str, session_id)
.await
@@ -460,6 +471,54 @@ impl Agent {
Ok(Some(Message::user().with_text(prompt)))
}
async fn handle_goal_command(&self, params_str: &str) -> Result<Option<Message>> {
if params_str.is_empty() {
let current = self.get_goal().await;
let text = match current {
Some(goal) => format!("Current goal: {goal}"),
None => "No goal set. Use `/goal <description>` to set one.".to_string(),
};
return Ok(Some(Message::assistant().with_text(text)));
}
if params_str == "off" || params_str == "clear" || params_str == "none" {
self.set_goal(None).await;
return Ok(Some(
Message::assistant().with_text("Goal cleared. The agent will finish normally."),
));
}
let goal = params_str.to_string();
self.set_goal(Some(goal.clone())).await;
Ok(Some(Message::assistant().with_text(format!(
"Goal set. The agent will verify this goal is met before finishing:\n\n> {goal}"
))))
}
async fn handle_grind_command(&self, params_str: &str) -> Result<Option<Message>> {
if params_str.is_empty() {
let current = self.get_grind().await;
let text = match current {
Some(goal) => format!("Current grind goal: {goal}"),
None => "No grind goal set. Use `/grind <description>` to set one.".to_string(),
};
return Ok(Some(Message::assistant().with_text(text)));
}
if params_str == "off" || params_str == "clear" {
self.set_grind(None).await;
return Ok(Some(
Message::assistant().with_text("Grind cleared. The agent will finish normally."),
));
}
let goal = params_str.to_string();
self.set_grind(Some(goal.clone())).await;
Ok(Some(Message::assistant().with_text(format!(
"Grind goal set. The agent will keep working until max_turns is reached:\n\n> {goal}"
))))
}
}
#[cfg(test)]
+270
View File
@@ -1119,6 +1119,276 @@ mod tests {
}
}
#[cfg(test)]
mod goal_checking_tests {
use super::*;
use async_trait::async_trait;
use goose::agents::AgentConfig;
use goose::agents::SessionConfig;
use goose::config::permission::PermissionManager;
use goose::config::GooseMode;
use goose::conversation::message::Message;
use goose::model::ModelConfig;
use goose::providers::base::{
stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata,
ProviderUsage, Usage,
};
use goose::providers::errors::ProviderError;
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use rmcp::model::Tool;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use tempfile::TempDir;
struct GoalTextProvider {
call_count: AtomicU32,
}
impl GoalTextProvider {
fn new() -> Self {
Self {
call_count: AtomicU32::new(0),
}
}
}
impl ProviderDef for GoalTextProvider {
type Provider = Self;
fn metadata() -> ProviderMetadata {
ProviderMetadata {
name: "goal-mock".to_string(),
display_name: "Goal Mock Provider".to_string(),
description: "Mock provider for goal testing".to_string(),
default_model: "mock-model".to_string(),
known_models: vec![],
model_doc_link: "".to_string(),
config_keys: vec![],
setup_steps: vec![],
model_selection_hint: None,
}
}
fn from_env(
_model: ModelConfig,
_extensions: Vec<goose::config::ExtensionConfig>,
) -> futures::future::BoxFuture<'static, anyhow::Result<Self>> {
Box::pin(async { Ok(Self::new()) })
}
}
#[async_trait]
impl Provider for GoalTextProvider {
async fn stream(
&self,
_model_config: &ModelConfig,
_session_id: &str,
_system_prompt: &str,
_messages: &[Message],
_tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
let text = format!("Response number {count}");
let message = Message::assistant().with_text(&text);
let usage = ProviderUsage::new(
"mock-model".to_string(),
Usage::new(Some(10), Some(5), Some(15)),
);
Ok(stream_from_single_message(message, usage))
}
fn get_model_config(&self) -> ModelConfig {
ModelConfig::new("mock-model").unwrap()
}
fn get_name(&self) -> &str {
"goal-mock"
}
}
fn create_agent_with_session_naming_disabled(
session_manager: Arc<SessionManager>,
) -> Agent {
let config = AgentConfig::new(
session_manager,
PermissionManager::instance(),
None,
GooseMode::Auto,
true,
GoosePlatform::GooseCli,
);
Agent::with_config(config)
}
#[tokio::test]
async fn test_goal_nudges_agent_before_exit() -> Result<()> {
let temp_dir = TempDir::new()?;
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let agent = create_agent_with_session_naming_disabled(session_manager.clone());
let provider = Arc::new(GoalTextProvider::new());
let session = session_manager
.create_session(
PathBuf::default(),
"goal-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
agent.update_provider(provider.clone(), &session.id).await?;
agent
.set_goal(Some("Ensure the sky is blue".to_string()))
.await;
let session_config = SessionConfig {
id: session.id.clone(),
schedule_id: None,
max_turns: Some(10),
retry_config: None,
};
let reply_stream = agent
.reply(Message::user().with_text("Hello"), session_config, None)
.await?;
tokio::pin!(reply_stream);
let mut messages = Vec::new();
while let Some(event) = reply_stream.next().await {
match event {
Ok(AgentEvent::Message(msg)) => messages.push(msg),
Ok(_) => {}
Err(e) => return Err(e),
}
}
let call_count = provider.call_count.load(Ordering::SeqCst);
assert!(
call_count > 1,
"Expected provider to be called more than once due to goal checking, got {call_count}"
);
assert!(
call_count <= 3,
"Expected at most 3 provider calls (1 initial + 1 goal check + 1 exit), got {call_count}"
);
// The goal nudge should NOT appear in yielded events (it's internal)
let nudge_messages: Vec<_> = messages
.iter()
.filter(|m| {
m.as_concat_text()
.contains("check whether the following goal")
})
.collect();
assert!(
nudge_messages.is_empty(),
"Goal nudge should be hidden from user, but found {} in events",
nudge_messages.len()
);
// Goal should be cleared after being met
assert_eq!(
agent.get_goal().await,
None,
"Goal should be cleared after the agent finishes with it met"
);
Ok(())
}
#[tokio::test]
async fn test_no_goal_exits_immediately() -> Result<()> {
let temp_dir = TempDir::new()?;
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let agent = create_agent_with_session_naming_disabled(session_manager.clone());
let provider = Arc::new(GoalTextProvider::new());
let session = session_manager
.create_session(
PathBuf::default(),
"no-goal-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
agent.update_provider(provider.clone(), &session.id).await?;
let session_config = SessionConfig {
id: session.id.clone(),
schedule_id: None,
max_turns: Some(10),
retry_config: None,
};
let reply_stream = agent
.reply(Message::user().with_text("Hello"), session_config, None)
.await?;
tokio::pin!(reply_stream);
while let Some(event) = reply_stream.next().await {
match event {
Ok(_) => {}
Err(e) => return Err(e),
}
}
let call_count = provider.call_count.load(Ordering::SeqCst);
assert_eq!(
call_count, 1,
"Without a goal, provider should be called exactly once, got {call_count}"
);
Ok(())
}
#[tokio::test]
async fn test_goal_command_set_and_clear() -> Result<()> {
let temp_dir = TempDir::new()?;
let session_manager = Arc::new(SessionManager::new(temp_dir.path().to_path_buf()));
let agent = create_agent_with_session_naming_disabled(session_manager.clone());
let session = session_manager
.create_session(
PathBuf::default(),
"goal-cmd-test".to_string(),
SessionType::Hidden,
GooseMode::default(),
)
.await?;
// No goal initially
let result = agent.execute_command("/goal", &session.id).await?.unwrap();
assert!(result.as_concat_text().contains("No goal set"));
// Set a goal
let result = agent
.execute_command("/goal make all tests pass", &session.id)
.await?
.unwrap();
assert!(result.as_concat_text().contains("Goal set"));
assert_eq!(
agent.get_goal().await,
Some("make all tests pass".to_string())
);
// Query it
let result = agent.execute_command("/goal", &session.id).await?.unwrap();
assert!(result.as_concat_text().contains("make all tests pass"));
// Clear it
let result = agent
.execute_command("/goal off", &session.id)
.await?
.unwrap();
assert!(result.as_concat_text().contains("cleared"));
assert_eq!(agent.get_goal().await, None);
Ok(())
}
}
mod cumulative_token_tests {
use super::*;
use async_trait::async_trait;