Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c22d3f8
Platform Extension MOIM (Minus One Info Message)
tlongwell-block Oct 6, 2025
c821645
update prompts
tlongwell-block Oct 6, 2025
fd6e6c4
remove todo read tool
tlongwell-block Oct 6, 2025
e9c48e2
cleanup comments. moim logic in separate file
tlongwell-block Oct 6, 2025
5c28a48
comment
tlongwell-block Oct 6, 2025
250b3c8
comment
tlongwell-block Oct 6, 2025
beb11cd
changes per review. Remove option to configure moim. Filter moim from…
tlongwell-block Oct 6, 2025
0acdc97
cargo fmt
tlongwell-block Oct 6, 2025
9ec9fe8
dont need serial in goose cli anymore
tlongwell-block Oct 6, 2025
7813fb5
move moim to very end of history as a user message
tlongwell-block Oct 7, 2025
7a444c0
clean up comments
tlongwell-block Oct 7, 2025
d1499c5
Merge main into moim2
tlongwell-block Oct 7, 2025
599f61c
filter moim for scenario
tlongwell-block Oct 7, 2025
f85335d
lint
tlongwell-block Oct 7, 2025
ba2004e
Merge remote-tracking branch 'origin/main' into moim2
tlongwell-block Oct 8, 2025
811da83
Merge remote-tracking branch 'origin/main' into moim2
tlongwell-block Oct 10, 2025
f099955
dont add moim immediately after tool calls
tlongwell-block Oct 11, 2025
780e0ad
moim works better when it is behind the latest message
tlongwell-block Oct 13, 2025
b286cbd
simplify moim by simply prepending to last user role message
tlongwell-block Nov 5, 2025
d2f075f
Merge main into moim2 branch
tlongwell-block Nov 5, 2025
07c0ce2
tests
tlongwell-block Nov 5, 2025
528282a
Refactor MOIM injection to use Conversation type, remove redundant co…
tlongwell-block Nov 5, 2025
fae6e77
remove datetime from snapshots
tlongwell-block Nov 5, 2025
0604895
moim config
tlongwell-block Nov 6, 2025
b7b00ae
scenario tests dont work with a dynamic timestamp
tlongwell-block Nov 6, 2025
94f83e2
test minimization
tlongwell-block Nov 6, 2025
4853b30
simplify injection logic
tlongwell-block Nov 6, 2025
5f228ab
tests
tlongwell-block Nov 7, 2025
5607fd5
Merge remote-tracking branch 'origin/main' into moim2
tlongwell-block Nov 7, 2025
b153859
moim disable function tested by scenario tests. dont need a dedicated…
tlongwell-block Nov 7, 2025
3cdc5be
cleanup
tlongwell-block Nov 7, 2025
7871fa0
comment cleanup
tlongwell-block Nov 7, 2025
2eb9c81
Merge remote-tracking branch 'origin/main' into moim2
tlongwell-block Nov 14, 2025
2774671
run fix_conversation before returning moim-enriched conversation. Sim…
tlongwell-block Nov 14, 2025
df80a59
thread local moim skip in scenario tests. No global/serial changes.
tlongwell-block Nov 14, 2025
a7344a1
simplify tests and insertion logic when no assistant message
tlongwell-block Nov 14, 2025
a954dc6
whitespace
tlongwell-block Nov 14, 2025
282775e
fmt
tlongwell-block Nov 14, 2025
29fc6f5
autonomous todo directive
tlongwell-block Nov 15, 2025
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
2 changes: 1 addition & 1 deletion crates/goose-cli/src/commands/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -774,7 +774,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]
Expand Down
6 changes: 1 addition & 5 deletions crates/goose-cli/src/session/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,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),
Expand Down Expand Up @@ -495,13 +495,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!();
Expand Down
9 changes: 8 additions & 1 deletion crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1102,10 +1102,17 @@ impl Agent {
}
}

// Inject MOIM ephemeral context just before provider call
let messages_with_moim = super::moim::inject_moim(
conversation.messages(),
&self.extension_manager,
&session
).await;

let mut stream = Self::stream_response_from_provider(
self.provider().await?,
&system_prompt,
conversation.messages(),
&messages_with_moim,
&tools,
&toolshim_tools,
).await?;
Expand Down
26 changes: 26 additions & 0 deletions crates/goose/src/agents/extension_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,32 @@ impl ExtensionManager {
.get(&name.into())
.map(|ext| ext.get_client())
}

/// Collect and aggregate MOIM content from all platform extensions.
pub async fn collect_moim(&self) -> Option<String> {
use chrono::Local;

let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut content = format!("<info-msg>\nDatetime: {}\n", timestamp);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a list with join would be more memory effiicient


let extensions = self.extensions.lock().await;
for (name, extension) in extensions.iter() {
// Only platform extensions can provide MOIM
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)]
Expand Down
4 changes: 4 additions & 0 deletions crates/goose/src/agents/mcp_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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 // Default: most extensions won't provide MOIM
}
}

pub struct GooseClient {
Expand Down
1 change: 1 addition & 0 deletions crates/goose/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod final_output_tool;
mod large_response_handler;
pub mod mcp_client;
pub mod model_selector;
mod moim;
pub mod platform_tools;
pub mod prompt_manager;
pub mod recipe_tools;
Expand Down
241 changes: 241 additions & 0 deletions crates/goose/src/agents/moim.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
use crate::agents::extension_manager::ExtensionManager;
use crate::agents::SessionConfig;
use crate::conversation::message::{Message, MessageContent};
use uuid::Uuid;

/// Inject MOIM (Minus One Info Message) into conversation.
///
/// MOIM provides ephemeral context that's included in LLM calls
/// but never persisted to conversation history.
pub async fn inject_moim(
messages: &[Message],
extension_manager: &ExtensionManager,
_session: &Option<SessionConfig>,
) -> Vec<Message> {
let moim_content = match extension_manager.collect_moim().await {
Some(content) if !content.trim().is_empty() => content,
_ => {
tracing::debug!("No MOIM content available");
return messages.to_vec();
}
};

tracing::debug!("Injecting MOIM: {} chars", moim_content.len());

let moim_message = Message::user()
.with_text(moim_content)
.with_id(format!("moim_{}", Uuid::new_v4()))
.agent_only();

let mut messages_with_moim = messages.to_vec();

if messages_with_moim.is_empty() {
messages_with_moim.push(moim_message);
} else {
let insert_pos = find_moim_insertion_point(&messages_with_moim);
messages_with_moim.insert(insert_pos, moim_message);
}

messages_with_moim
}

/// Find a safe insertion point for MOIM that won't break tool call/response pairs.
fn find_moim_insertion_point(messages: &[Message]) -> usize {
if messages.is_empty() {
return 0;
}

let last_pos = messages.len() - 1;

// Don't break tool call/response pairs
if last_pos > 0 {
let prev_msg = &messages[last_pos - 1];
let curr_msg = &messages[last_pos];

let prev_has_tool_calls = prev_msg
.content
.iter()
.any(|c| matches!(c, MessageContent::ToolRequest(_)));

let curr_has_tool_responses = curr_msg
.content
.iter()
.any(|c| matches!(c, MessageContent::ToolResponse(_)));

if prev_has_tool_calls && curr_has_tool_responses {
tracing::debug!("MOIM: Adjusting position to avoid breaking tool pair");
return last_pos.saturating_sub(1);
}
}

last_pos
}

#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::{ToolRequest, ToolResponse};
use rmcp::model::{CallToolRequestParam, Content};
use rmcp::object;

#[test]
fn test_find_insertion_point_edge_cases() {
// Test empty messages
let messages = vec![];
assert_eq!(find_moim_insertion_point(&messages), 0);

// Test single message - should return 0 (insert at beginning)
let messages = vec![Message::user().with_text("Hello")];
assert_eq!(find_moim_insertion_point(&messages), 0);

// Test multiple messages - should return last position
let messages = vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi"),
Message::user().with_text("How are you?"),
];
assert_eq!(find_moim_insertion_point(&messages), 2);
}

#[test]
fn test_find_insertion_point_tool_pair_detection() {
// Helper to create tool request and response
let tool_request = ToolRequest {
id: "test_tool_1".to_string(),
tool_call: Ok(CallToolRequestParam {
name: "test_tool".into(),
arguments: Some(object!({"key": "value"})),
}),
};

let tool_response = ToolResponse {
id: "test_tool_1".to_string(),
tool_result: Ok(vec![Content::text("Tool executed successfully")]),
};

// Test: Tool call/response pair at the end - should back up
let messages = vec![
Message::user().with_text("Please use a tool"),
Message::assistant()
.with_text("I'll use the tool now.")
.with_content(MessageContent::ToolRequest(tool_request.clone())),
Message::user().with_content(MessageContent::ToolResponse(tool_response.clone())),
];
assert_eq!(find_moim_insertion_point(&messages), 1); // Should back up to position 1

// Test: Tool pair in the middle with more messages after
let messages_with_more = vec![
Message::user().with_text("First request"),
Message::assistant().with_content(MessageContent::ToolRequest(tool_request.clone())),
Message::user().with_content(MessageContent::ToolResponse(tool_response)),
Message::assistant().with_text("Tool completed, here's the result"),
];
assert_eq!(find_moim_insertion_point(&messages_with_more), 3); // Should use last position
}

#[test]
fn test_find_insertion_point_non_tool_pairs() {
let tool_request = ToolRequest {
id: "test_tool_1".to_string(),
tool_call: Ok(CallToolRequestParam {
name: "test_tool".into(),
arguments: Some(object!({"key": "value"})),
}),
};

let tool_response = ToolResponse {
id: "test_tool_1".to_string(),
tool_result: Ok(vec![Content::text("Tool executed")]),
};

// Test: Tool request without matching response
let messages = vec![
Message::user().with_text("Please use a tool"),
Message::assistant().with_content(MessageContent::ToolRequest(tool_request)),
Message::assistant().with_text("Actually, let me reconsider."),
];
assert_eq!(find_moim_insertion_point(&messages), 2); // No pair, use last position

// Test: Tool response without preceding request
let messages = vec![
Message::user().with_text("Here's a tool response from earlier"),
Message::assistant().with_text("Okay, I see that."),
Message::user().with_content(MessageContent::ToolResponse(tool_response)),
];
assert_eq!(find_moim_insertion_point(&messages), 2); // No pair, use last position
}

#[tokio::test]
async fn test_moim_injection_basic() {
let extension_manager = ExtensionManager::new();

// Test empty conversation
let messages = vec![];
let result = inject_moim(&messages, &extension_manager, &None).await;
assert_eq!(result.len(), 1);
assert!(result[0].id.as_ref().unwrap().starts_with("moim_"));

// Verify MOIM content and metadata
let content = result[0].content.first().and_then(|c| c.as_text()).unwrap();
assert!(content.contains("<info-msg>"));
assert!(content.contains("Datetime:"));
assert!(!result[0].is_user_visible());
assert!(result[0].is_agent_visible());

// Test with existing messages
let messages = vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi there"),
];
let result = inject_moim(&messages, &extension_manager, &None).await;

assert_eq!(result.len(), 3);
// MOIM should be at position 1 (before the last message)
assert!(result[1].id.as_ref().unwrap().starts_with("moim_"));
assert!(!result[1].is_user_visible());
assert!(result[1].is_agent_visible());
}

#[tokio::test]
async fn test_moim_injection_preserves_tool_pair() {
let tool_request = ToolRequest {
id: "test_tool_1".to_string(),
tool_call: Ok(CallToolRequestParam {
name: "test_tool".into(),
arguments: Some(object!({"key": "value"})),
}),
};

let tool_response = ToolResponse {
id: "test_tool_1".to_string(),
tool_result: Ok(vec![Content::text("Tool executed successfully")]),
};

let messages = vec![
Message::user().with_text("Please use a tool"),
Message::assistant()
.with_text("I'll use the tool now.")
.with_content(MessageContent::ToolRequest(tool_request)),
Message::user().with_content(MessageContent::ToolResponse(tool_response)),
];

let extension_manager = ExtensionManager::new();
let result = inject_moim(&messages, &extension_manager, &None).await;

// Should have 4 messages total (3 original + 1 MOIM)
assert_eq!(result.len(), 4);

// MOIM should be at position 1 (before the tool request/response pair)
assert!(result[1].id.as_ref().unwrap().starts_with("moim_"));

// Verify the tool pair is still together (positions 2 and 3)
assert!(result[2]
.content
.iter()
.any(|c| matches!(c, MessageContent::ToolRequest(_))));
assert!(result[3]
.content
.iter()
.any(|c| matches!(c, MessageContent::ToolResponse(_))));
}
}
Loading
Loading