Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/goose-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,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"
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
33 changes: 25 additions & 8 deletions crates/goose-cli/src/scenario_tests/scenarios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ mod tests {
use crate::scenario_tests::scenario_runner::run_scenario;
use anyhow::Result;
use goose::conversation::message::Message;
use serial_test::serial;

#[tokio::test]
#[serial]
async fn test_what_is_your_name() -> Result<()> {
run_scenario(
std::env::set_var("GOOSE_MOIM_ENABLED", "false");
let result = run_scenario(
"what_is_your_name",
text("what is your name"),
None,
Expand All @@ -25,13 +28,17 @@ mod tests {
Ok(())
},
)
.await
.await;
std::env::remove_var("GOOSE_MOIM_ENABLED");
result
}

#[tokio::test]
#[serial]
async fn test_weather_tool() -> Result<()> {
std::env::set_var("GOOSE_MOIM_ENABLED", "false");
// Google tells me it only knows about the weather in the US, so we skip it.
run_scenario(
let result = run_scenario(
"weather_tool",
text("tell me what the weather is in Berlin, Germany"),
Some(&["Google"]),
Expand All @@ -55,14 +62,18 @@ mod tests {
Ok(())
},
)
.await
.await;
std::env::remove_var("GOOSE_MOIM_ENABLED");
result
}

#[tokio::test]
#[serial]
async fn test_image_analysis() -> Result<()> {
std::env::set_var("GOOSE_MOIM_ENABLED", "false");
// Google says it doesn't know about images, the other providers complain about
// the image format, so we only run this for OpenAI and Anthropic.
run_scenario(
let result = run_scenario(
"image_analysis",
image("What do you see in this image?", "test_image"),
Some(&["Google", "azure_openai", "groq"]),
Expand All @@ -73,12 +84,16 @@ mod tests {
Ok(())
},
)
.await
.await;
std::env::remove_var("GOOSE_MOIM_ENABLED");
result
}

#[tokio::test]
#[serial]
async fn test_context_length_exceeded_error() -> Result<()> {
run_scenario(
std::env::set_var("GOOSE_MOIM_ENABLED", "false");
let result = run_scenario(
"context_length_exceeded",
Box::new(|provider| {
let model_config = provider.get_model_config();
Expand All @@ -100,6 +115,8 @@ mod tests {
Ok(())
},
)
.await
.await;
std::env::remove_var("GOOSE_MOIM_ENABLED");
result
}
}
7 changes: 2 additions & 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,10 @@ 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
// For todo_write, 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 @@ -1032,10 +1032,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
24 changes: 24 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,30 @@ 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!("Current date and time: {}\n", timestamp);

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);
}
}
}

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
164 changes: 164 additions & 0 deletions crates/goose/src/agents/moim.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
use crate::agents::extension_manager::ExtensionManager;
use crate::agents::SessionConfig;
use crate::config::Config;
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 config = Config::global();
let moim_enabled = config
.get_param::<bool>("goose_moim_enabled")
.unwrap_or(true);

if !moim_enabled {
tracing::debug!("MOIM disabled by configuration");
return messages.to_vec();
}

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()));

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 serial_test::serial;

#[tokio::test]
async fn test_moim_insertion_empty_conversation() {
// MOIM is enabled by default
let messages = vec![];
let extension_manager = ExtensionManager::new();

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

// MOIM always includes timestamp when enabled, so should have one message
assert_eq!(result.len(), 1);
assert!(result[0].id.as_ref().unwrap().starts_with("moim_"));

// Verify the message contains timestamp
let content = result[0].content.first().and_then(|c| c.as_text()).unwrap();
assert!(content.contains("Current date and time:"));
}

#[tokio::test]
async fn test_moim_insertion_with_messages() {
let messages = vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi there"),
];
let extension_manager = ExtensionManager::new();

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

// MOIM always includes timestamp, should have 3 messages with MOIM inserted before last
assert_eq!(result.len(), 3);
// MOIM should be inserted at position 1 (before the last message)
assert!(result[1].id.as_ref().unwrap().starts_with("moim_"));

// Verify the message contains timestamp
let content = result[1].content.first().and_then(|c| c.as_text()).unwrap();
assert!(content.contains("Current date and time:"));
}

#[tokio::test]
#[serial]
async fn test_moim_disabled() {
std::env::set_var("GOOSE_MOIM_ENABLED", "false");

let messages = vec![Message::user().with_text("Hello")];
let extension_manager = ExtensionManager::new();

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

// Should return original messages unchanged
assert_eq!(result.len(), 1);
assert!(result[0].id.is_none() || !result[0].id.as_ref().unwrap().starts_with("moim_"));

std::env::remove_var("GOOSE_MOIM_ENABLED");
}

#[test]
fn test_find_insertion_point_empty() {
let messages = vec![];
assert_eq!(find_moim_insertion_point(&messages), 0);
}

#[test]
fn test_find_insertion_point_single_message() {
let messages = vec![Message::user().with_text("Hello")];
assert_eq!(find_moim_insertion_point(&messages), 0);
}

#[test]
fn test_find_insertion_point_multiple_messages() {
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);
}
}
Loading
Loading