Platform Extension MOIM (Minus One Info Message) (#5027)

This commit is contained in:
tlongwell-block
2025-11-16 12:14:49 -05:00
committed by GitHub
parent 5066a09c77
commit 02ca6f7ff1
16 changed files with 226 additions and 92 deletions
Generated
+1
View File
@@ -2755,6 +2755,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"shlex",
"tar",
"temp-env",
+1
View File
@@ -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"
+1 -1
View File
@@ -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);
}
+1 -5
View File
@@ -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!();
+6 -1
View File
@@ -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)]
+4
View File
@@ -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 {
+1
View File
@@ -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;
+137
View File
@@ -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
@@ -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
@@ -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
+49 -76
View File
@@ -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 -1
View File
@@ -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:
-2
View File
@@ -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