mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
Platform Extension MOIM (Minus One Info Message) (#5027)
This commit is contained in:
Generated
+1
@@ -2755,6 +2755,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"shlex",
|
||||
"tar",
|
||||
"temp-env",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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!();
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -1163,6 +1163,28 @@ impl ExtensionManager {
|
||||
.get(&name.into())
|
||||
.map(|ext| ext.get_client())
|
||||
}
|
||||
|
||||
pub async fn collect_moim(&self) -> Option<String> {
|
||||
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let mut content = format!("<info-msg>\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</info-msg>");
|
||||
|
||||
Some(content)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -76,6 +76,10 @@ pub trait McpClientTrait: Send + Sync {
|
||||
async fn subscribe(&self) -> mpsc::Receiver<ServerNotification>;
|
||||
|
||||
fn get_info(&self) -> Option<&InitializeResult>;
|
||||
|
||||
async fn get_moim(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GooseClient {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<bool> = 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::<Vec<_>>()
|
||||
.join("");
|
||||
assert!(merged_content.contains("Hello"));
|
||||
assert!(merged_content.contains("<info-msg>"));
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>()
|
||||
.join("");
|
||||
assert!(merged_content.contains("Hello"));
|
||||
assert!(merged_content.contains("<info-msg>"));
|
||||
}
|
||||
|
||||
#[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("<info-msg>")));
|
||||
|
||||
assert!(
|
||||
has_moim,
|
||||
"MOIM should be in message before latest assistant message"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
-2
@@ -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
|
||||
|
||||
-2
@@ -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
|
||||
|
||||
@@ -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 tools for complex tasks is an error.
|
||||
Keep items short, specific, action-oriented. Not using the todo tool 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<Vec<Content>, 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<JsonObject>,
|
||||
@@ -154,61 +145,32 @@ impl TodoClient {
|
||||
}
|
||||
|
||||
fn get_tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool::new(
|
||||
"todo_read".to_string(),
|
||||
indoc! {r#"
|
||||
Read the entire TODO file content.
|
||||
let schema = schema_for!(TodoWriteParams);
|
||||
let schema_value =
|
||||
serde_json::to_value(schema).expect("Failed to serialize TodoWriteParams schema");
|
||||
|
||||
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.
|
||||
vec![Tool::new(
|
||||
"todo_write".to_string(),
|
||||
indoc! {r#"
|
||||
Overwrite the entire TODO content.
|
||||
|
||||
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.
|
||||
The content persists across conversation turns and compaction. Use this for:
|
||||
- Task tracking and progress updates
|
||||
- Important notes and reminders
|
||||
|
||||
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.
|
||||
|
||||
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<CallToolResult, Error> {
|
||||
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<String> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user