diff --git a/Cargo.lock b/Cargo.lock index d96926345278..a5a2d38b4bde 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2755,6 +2755,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "serial_test", "shlex", "tar", "temp-env", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index dbb546843778..7fc20c75a57e 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -69,3 +69,4 @@ tempfile = "3" temp-env = { version = "0.3.6", features = ["async_closure"] } test-case = "3.3" tokio = { version = "1.43", features = ["rt", "macros"] } +serial_test = "3.2.0" diff --git a/crates/goose-cli/src/commands/acp.rs b/crates/goose-cli/src/commands/acp.rs index dd54152193cc..37896208ee8b 100644 --- a/crates/goose-cli/src/commands/acp.rs +++ b/crates/goose-cli/src/commands/acp.rs @@ -871,7 +871,7 @@ print(\"hello, world\") format_tool_name("platform__manage_extensions"), "Platform: Manage Extensions" ); - assert_eq!(format_tool_name("todo__read"), "Todo: Read"); + assert_eq!(format_tool_name("todo__write"), "Todo: Write"); } #[test] diff --git a/crates/goose-cli/src/scenario_tests/scenario_runner.rs b/crates/goose-cli/src/scenario_tests/scenario_runner.rs index 8d7474d4859c..ccbbfba4fec4 100644 --- a/crates/goose-cli/src/scenario_tests/scenario_runner.rs +++ b/crates/goose-cli/src/scenario_tests/scenario_runner.rs @@ -141,6 +141,8 @@ where use goose::config::ExtensionConfig; use tokio::sync::Mutex; + goose::agents::moim::SKIP.with(|f| f.set(true)); + if let Ok(path) = dotenv() { println!("Loaded environment from {:?}", path); } diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index 14b598e3b638..f9785061594a 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -261,7 +261,7 @@ fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { "developer__text_editor" => render_text_editor_request(call, debug), "developer__shell" => render_shell_request(call, debug), "dynamic_task__create_task" => render_dynamic_task_request(call, debug), - "todo__read" | "todo__write" => render_todo_request(call, debug), + "todo__write" => render_todo_request(call, debug), _ => render_default_request(call, debug), }, Err(e) => print_markdown(&e.to_string(), theme), @@ -505,13 +505,9 @@ fn render_dynamic_task_request(call: &CallToolRequestParam, debug: bool) { fn render_todo_request(call: &CallToolRequestParam, _debug: bool) { print_tool_header(call); - // For todo tools, always show the full content without redaction if let Some(args) = &call.arguments { if let Some(Value::String(content)) = args.get("content") { println!("{}: {}", style("content").dim(), style(content).green()); - } else { - // For todo__read, there are no arguments - // Just print an empty line for consistency } } println!(); diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 59c55422ad61..d0551990b501 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -946,10 +946,15 @@ impl Agent { } } + let conversation_with_moim = super::moim::inject_moim( + conversation.clone(), + &self.extension_manager, + ).await; + let mut stream = Self::stream_response_from_provider( self.provider().await?, &system_prompt, - conversation.messages(), + conversation_with_moim.messages(), &tools, &toolshim_tools, ).await?; diff --git a/crates/goose/src/agents/extension_manager.rs b/crates/goose/src/agents/extension_manager.rs index 3ae559fcae93..0401746ec7e4 100644 --- a/crates/goose/src/agents/extension_manager.rs +++ b/crates/goose/src/agents/extension_manager.rs @@ -1163,6 +1163,28 @@ impl ExtensionManager { .get(&name.into()) .map(|ext| ext.get_client()) } + + pub async fn collect_moim(&self) -> Option { + let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let mut content = format!("\nDatetime: {}\n", timestamp); + + let extensions = self.extensions.lock().await; + for (name, extension) in extensions.iter() { + if let ExtensionConfig::Platform { .. } = &extension.config { + let client = extension.get_client(); + let client_guard = client.lock().await; + if let Some(moim_content) = client_guard.get_moim().await { + tracing::debug!("MOIM content from {}: {} chars", name, moim_content.len()); + content.push('\n'); + content.push_str(&moim_content); + } + } + } + + content.push_str("\n"); + + Some(content) + } } #[cfg(test)] diff --git a/crates/goose/src/agents/mcp_client.rs b/crates/goose/src/agents/mcp_client.rs index d42d62488d9b..c5d37ce43956 100644 --- a/crates/goose/src/agents/mcp_client.rs +++ b/crates/goose/src/agents/mcp_client.rs @@ -76,6 +76,10 @@ pub trait McpClientTrait: Send + Sync { async fn subscribe(&self) -> mpsc::Receiver; fn get_info(&self) -> Option<&InitializeResult>; + + async fn get_moim(&self) -> Option { + None + } } pub struct GooseClient { diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 0f633307c747..9c80f1d64bf0 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -8,6 +8,7 @@ pub mod final_output_tool; mod large_response_handler; pub mod mcp_client; pub mod model_selector; +pub mod moim; pub mod platform_tools; pub mod prompt_manager; pub mod recipe_tools; diff --git a/crates/goose/src/agents/moim.rs b/crates/goose/src/agents/moim.rs new file mode 100644 index 000000000000..953f6905909d --- /dev/null +++ b/crates/goose/src/agents/moim.rs @@ -0,0 +1,137 @@ +use crate::agents::extension_manager::ExtensionManager; +use crate::conversation::message::Message; +use crate::conversation::{fix_conversation, Conversation}; +use rmcp::model::Role; + +// Test-only utility. Do not use in production code. No `test` directive due to call outside crate. +thread_local! { + pub static SKIP: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +pub async fn inject_moim( + conversation: Conversation, + extension_manager: &ExtensionManager, +) -> Conversation { + if SKIP.with(|f| f.get()) { + return conversation; + } + + if let Some(moim) = extension_manager.collect_moim().await { + let mut messages = conversation.messages().clone(); + let idx = messages + .iter() + .rposition(|m| m.role == Role::Assistant) + .unwrap_or(0); + messages.insert(idx, Message::user().with_text(moim)); + + let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages)); + + let has_unexpected_issues = issues + .iter() + .any(|issue| !issue.contains("Merged consecutive user messages")); + + if has_unexpected_issues { + tracing::warn!("MOIM injection caused unexpected issues: {:?}", issues); + return conversation; + } + + return fixed; + } + conversation +} + +#[cfg(test)] +mod tests { + use super::*; + use rmcp::model::CallToolRequestParam; + + #[tokio::test] + async fn test_moim_injection_before_assistant() { + let em = ExtensionManager::new_without_provider(); + + let conv = Conversation::new_unvalidated(vec![ + Message::user().with_text("Hello"), + Message::assistant().with_text("Hi"), + Message::user().with_text("Bye"), + ]); + let result = inject_moim(conv, &em).await; + let msgs = result.messages(); + + assert_eq!(msgs.len(), 3); + assert_eq!(msgs[0].content[0].as_text().unwrap(), "Hello"); + assert_eq!(msgs[1].content[0].as_text().unwrap(), "Hi"); + + let merged_content = msgs[0] + .content + .iter() + .filter_map(|c| c.as_text()) + .collect::>() + .join(""); + assert!(merged_content.contains("Hello")); + assert!(merged_content.contains("")); + } + + #[tokio::test] + async fn test_moim_injection_no_assistant() { + let em = ExtensionManager::new_without_provider(); + + let conv = Conversation::new_unvalidated(vec![Message::user().with_text("Hello")]); + let result = inject_moim(conv, &em).await; + + assert_eq!(result.messages().len(), 1); + + let merged_content = result.messages()[0] + .content + .iter() + .filter_map(|c| c.as_text()) + .collect::>() + .join(""); + assert!(merged_content.contains("Hello")); + assert!(merged_content.contains("")); + } + + #[tokio::test] + async fn test_moim_with_tool_calls() { + let em = ExtensionManager::new_without_provider(); + + let conv = Conversation::new_unvalidated(vec![ + Message::user().with_text("Search for something"), + Message::assistant() + .with_text("I'll search for you") + .with_tool_request( + "search_1", + Ok(CallToolRequestParam { + name: "search".into(), + arguments: None, + }), + ), + Message::user().with_tool_response("search_1", Ok(vec![])), + Message::assistant() + .with_text("I need to search more") + .with_tool_request( + "search_2", + Ok(CallToolRequestParam { + name: "search".into(), + arguments: None, + }), + ), + Message::user().with_tool_response("search_2", Ok(vec![])), + ]); + + let result = inject_moim(conv, &em).await; + let msgs = result.messages(); + + assert_eq!(msgs.len(), 6); + + let moim_msg = &msgs[3]; + let has_moim = moim_msg + .content + .iter() + .any(|c| c.as_text().is_some_and(|t| t.contains(""))); + + assert!( + has_moim, + "MOIM should be in message before latest assistant message" + ); + } +} diff --git a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__basic.snap b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__basic.snap index c88109a7b2e3..fa99f52b7866 100644 --- a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__basic.snap +++ b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__basic.snap @@ -5,8 +5,6 @@ expression: system_prompt You are a general-purpose AI agent called goose, created by Block, the parent company of Square, CashApp, and Tidal. goose is being developed as an open-source software project. -The current date is 1970-01-01 00:00:00. - goose uses LLM providers with tool calling capability. You can be used with different language models (gpt-4o, claude-sonnet-4, o1, llama-3.2, deepseek-r1, etc). These models have varying knowledge cut-off dates depending on when they were trained, but typically it's between 5-10 diff --git a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__one_extension.snap b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__one_extension.snap index 18656ece918c..d3037a9d17fa 100644 --- a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__one_extension.snap +++ b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__one_extension.snap @@ -5,8 +5,6 @@ expression: system_prompt You are a general-purpose AI agent called goose, created by Block, the parent company of Square, CashApp, and Tidal. goose is being developed as an open-source software project. -The current date is 1970-01-01 00:00:00. - goose uses LLM providers with tool calling capability. You can be used with different language models (gpt-4o, claude-sonnet-4, o1, llama-3.2, deepseek-r1, etc). These models have varying knowledge cut-off dates depending on when they were trained, but typically it's between 5-10 diff --git a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__typical_setup.snap b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__typical_setup.snap index 1c7af49c5634..b299f2e1a6be 100644 --- a/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__typical_setup.snap +++ b/crates/goose/src/agents/snapshots/goose__agents__prompt_manager__tests__typical_setup.snap @@ -5,8 +5,6 @@ expression: system_prompt You are a general-purpose AI agent called goose, created by Block, the parent company of Square, CashApp, and Tidal. goose is being developed as an open-source software project. -The current date is 1970-01-01 00:00:00. - goose uses LLM providers with tool calling capability. You can be used with different language models (gpt-4o, claude-sonnet-4, o1, llama-3.2, deepseek-r1, etc). These models have varying knowledge cut-off dates depending on when they were trained, but typically it's between 5-10 diff --git a/crates/goose/src/agents/todo_extension.rs b/crates/goose/src/agents/todo_extension.rs index 5bb6d9eae51e..d5ce432d9bfc 100644 --- a/crates/goose/src/agents/todo_extension.rs +++ b/crates/goose/src/agents/todo_extension.rs @@ -10,13 +10,19 @@ use rmcp::model::{ ListPromptsResult, ListResourcesResult, ListToolsResult, ProtocolVersion, ReadResourceResult, ServerCapabilities, ServerNotification, Tool, ToolAnnotations, ToolsCapability, }; -use rmcp::object; +use schemars::{schema_for, JsonSchema}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; pub static EXTENSION_NAME: &str = "todo"; +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +struct TodoWriteParams { + content: String, +} + pub struct TodoClient { info: InitializeResult, context: PlatformExtensionContext, @@ -47,16 +53,19 @@ impl TodoClient { instructions: Some(indoc! {r#" Task Management - Use todo_read and todo_write for tasks with 2+ steps, multiple files/components, or uncertain scope. + Use todo_write for tasks with 2+ steps, multiple files/components, or uncertain scope. + Your TODO content is automatically available in your context. Workflow: - - Start: read → write checklist - - During: read → update progress + - Start: write initial checklist + - During: update progress - End: verify all complete - Warning: todo_write overwrites entirely; always todo_read first (skipping is an error) + Warning: todo_write overwrites entirely; always include ALL content you want to keep + + Keep items short, specific, action-oriented. Not using the todo tool for complex tasks is an error. - Keep items short, specific, action-oriented. Not using the todo tools for complex tasks is an error. + For autonomous work, missing requirements means failure - document all requirements in TODO immediately. Template: - [ ] Implement feature X @@ -75,24 +84,6 @@ impl TodoClient { }) } - async fn handle_read_todo(&self) -> Result, String> { - if let Some(session_id) = &self.context.session_id { - match SessionManager::get_session(session_id, false).await { - Ok(metadata) => { - let content = - extension_data::TodoState::from_extension_data(&metadata.extension_data) - .map(|state| state.content) - .unwrap_or_default(); - Ok(vec![Content::text(content)]) - } - Err(_) => Ok(vec![Content::text(String::new())]), - } - } else { - let content = self.fallback_content.read().await; - Ok(vec![Content::text(content.clone())]) - } - } - async fn handle_write_todo( &self, arguments: Option, @@ -154,61 +145,32 @@ impl TodoClient { } fn get_tools() -> Vec { - vec![ - Tool::new( - "todo_read".to_string(), - indoc! {r#" - Read the entire TODO file content. - - This tool reads the complete TODO file and returns its content as a string. - Use this to view current tasks, notes, and any other information stored in the TODO file. + let schema = schema_for!(TodoWriteParams); + let schema_value = + serde_json::to_value(schema).expect("Failed to serialize TodoWriteParams schema"); - The tool will return an error if the TODO file doesn't exist or cannot be read. - "#}.to_string(), - object!({ - "type": "object", - "properties": {}, - "required": [] - }), - ).annotate(ToolAnnotations { - title: Some("Read TODO file".to_string()), - read_only_hint: Some(true), - destructive_hint: Some(false), - idempotent_hint: Some(true), - open_world_hint: Some(false), - }), - Tool::new( - "todo_write".to_string(), - indoc! {r#" - Write or overwrite the entire TODO file content. + vec![Tool::new( + "todo_write".to_string(), + indoc! {r#" + Overwrite the entire TODO content. - This tool replaces the complete TODO file content with the provided string. - Use this to update tasks, add new items, or reorganize the TODO file. + The content persists across conversation turns and compaction. Use this for: + - Task tracking and progress updates + - Important notes and reminders - WARNING: This operation completely replaces the file content. Make sure to include + WARNING: This operation completely replaces the existing content. Always include all content you want to keep, not just the changes. - - The tool will create the TODO file if it doesn't exist, or overwrite it if it does. - Returns an error if the file cannot be written due to permissions or other I/O issues. - "#}.to_string(), - object!({ - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "The TODO list content to save" - } - }, - "required": ["content"] - }), - ).annotate(ToolAnnotations { - title: Some("Write TODO file".to_string()), - read_only_hint: Some(false), - destructive_hint: Some(true), - idempotent_hint: Some(false), - open_world_hint: Some(false), - }) - ] + "#} + .to_string(), + schema_value.as_object().unwrap().clone(), + ) + .annotate(ToolAnnotations { + title: Some("Write TODO".to_string()), + read_only_hint: Some(false), + destructive_hint: Some(true), + idempotent_hint: Some(false), + open_world_hint: Some(false), + })] } } @@ -248,7 +210,6 @@ impl McpClientTrait for TodoClient { _cancellation_token: CancellationToken, ) -> Result { let content = match name { - "todo_read" => self.handle_read_todo().await, "todo_write" => self.handle_write_todo(arguments).await, _ => Err(format!("Unknown tool: {}", name)), }; @@ -286,4 +247,16 @@ impl McpClientTrait for TodoClient { fn get_info(&self) -> Option<&InitializeResult> { Some(&self.info) } + + async fn get_moim(&self) -> Option { + let session_id = self.context.session_id.as_ref()?; + let metadata = SessionManager::get_session(session_id, false).await.ok()?; + let state = extension_data::TodoState::from_extension_data(&metadata.extension_data)?; + + if state.content.trim().is_empty() { + return None; + } + + Some(format!("Current tasks and notes:\n{}\n", state.content)) + } } diff --git a/crates/goose/src/prompts/subagent_system.md b/crates/goose/src/prompts/subagent_system.md index 7bb9b8ef1580..35ab938e1be4 100644 --- a/crates/goose/src/prompts/subagent_system.md +++ b/crates/goose/src/prompts/subagent_system.md @@ -1,4 +1,4 @@ -You are a specialized subagent within the goose AI framework, created by Block. You were spawned by the main goose agent to handle a specific task efficiently. The current date is {{current_date_time}}. +You are a specialized subagent within the goose AI framework, created by Block. You were spawned by the main goose agent to handle a specific task efficiently. # Your Role You are an autonomous subagent with these characteristics: diff --git a/crates/goose/src/prompts/system.md b/crates/goose/src/prompts/system.md index 55a9e6f59562..795934e23514 100644 --- a/crates/goose/src/prompts/system.md +++ b/crates/goose/src/prompts/system.md @@ -1,8 +1,6 @@ You are a general-purpose AI agent called goose, created by Block, the parent company of Square, CashApp, and Tidal. goose is being developed as an open-source software project. -The current date is {{current_date_time}}. - goose uses LLM providers with tool calling capability. You can be used with different language models (gpt-4o, claude-sonnet-4, o1, llama-3.2, deepseek-r1, etc). These models have varying knowledge cut-off dates depending on when they were trained, but typically it's between 5-10