Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -70,3 +70,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"
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");
Copy link
Collaborator

Choose a reason for hiding this comment

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

don't set environment variables in tests like this; if they fail you now changed the settings for the next test. also if you are going to add this to all run_scenario, maybe handle this there?

Copy link
Collaborator Author

@tlongwell-block tlongwell-block Sep 29, 2025

Choose a reason for hiding this comment

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

I have the tests in serial now. I set the env var at the top of the test and unset at the bottom. It won't effect other tests that way

I think you're right that I could get the same effect by un/setting it in run_scenario(), but still making sure that everything that calls run_scenario() is serial

The reason I didn't is just because I can't internally guarantee that run_scenario will be in serial, hence it would be possible for an environment race condition to occur if called by a non-serial test

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
}
}
29 changes: 5 additions & 24 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ use super::model_selector::autopilot::AutoPilot;
use super::platform_tools;
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
use crate::agents::subagent_task_config::TaskConfig;
use crate::agents::todo_tools::{
todo_read_tool, todo_write_tool, TODO_READ_TOOL_NAME, TODO_WRITE_TOOL_NAME,
};
use crate::agents::todo_tools::{todo_write_tool, TODO_WRITE_TOOL_NAME};
use crate::conversation::message::{Message, ToolRequest};
use crate::session::extension_data::ExtensionState;
use crate::session::{extension_data, SessionManager};
Expand Down Expand Up @@ -504,22 +502,6 @@ impl Agent {
"Frontend tool execution required".to_string(),
None,
)))
} else if tool_call.name == TODO_READ_TOOL_NAME {
// Handle task planner read tool
let todo_content = if let Some(session_config) = session {
SessionManager::get_session(&session_config.id, false)
.await
.ok()
.and_then(|metadata| {
extension_data::TodoState::from_extension_data(&metadata.extension_data)
.map(|state| state.content)
})
.unwrap_or_default()
} else {
String::new()
};

ToolCallResult::from(Ok(vec![Content::text(todo_content)]))
} else if tool_call.name == TODO_WRITE_TOOL_NAME {
// Handle task planner write tool
let content = tool_call
Expand Down Expand Up @@ -816,8 +798,8 @@ impl Agent {
platform_tools::manage_schedule_tool(),
]);

// Add task planner tools
prefixed_tools.extend([todo_read_tool(), todo_write_tool()]);
// Add task planner tool (write only, read happens via MOIM)
prefixed_tools.push(todo_write_tool());

// Dynamic task tool
prefixed_tools.push(create_dynamic_task_tool());
Expand Down Expand Up @@ -1105,6 +1087,7 @@ impl Agent {
conversation.messages(),
&tools,
&toolshim_tools,
&session,
).await?;

let mut no_tools_called = true;
Expand Down Expand Up @@ -1741,13 +1724,11 @@ mod tests {
async fn test_todo_tools_integration() -> Result<()> {
let agent = Agent::new();

// Test that task planner tools are listed
// Test that task planner tool is listed (write only, read happens via MOIM)
let tools = agent.list_tools(None).await;

let todo_read = tools.iter().find(|tool| tool.name == TODO_READ_TOOL_NAME);
let todo_write = tools.iter().find(|tool| tool.name == TODO_WRITE_TOOL_NAME);

assert!(todo_read.is_some(), "TODO read tool should be present");
assert!(todo_write.is_some(), "TODO write tool should be present");
Ok(())
}
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 @@ -6,6 +6,7 @@ pub mod extension_manager;
pub mod final_output_tool;
mod large_response_handler;
pub mod model_selector;
pub mod moim;
pub mod platform_tools;
pub mod prompt_manager;
pub mod recipe_tools;
Expand Down
94 changes: 94 additions & 0 deletions crates/goose/src/agents/moim.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use crate::agents::types::SessionConfig;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::session::extension_data::{ExtensionState, TodoState};
use crate::session::SessionManager;
use chrono::Local;

async fn build_moim_content(session: &Option<SessionConfig>) -> Option<String> {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I'd really like to see this as something the tool itself can define rather than a specific global place.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I talked about my thinking in a reply above:

"... I think adding to build_moim_content() is the way to go here since there will probably be formatting and helpers involved when moving platform tool content from its storage into the MOIM (ie an array of sources is probably not sufficient, so using the build function is probably the best way forward)"

Having each tool register itself to a moim system would work, too, but we might still want to format how they fit together, which requires a global function like this one

also, this global function allows us to put data in the moim that isn't related to a tool. Like the timestamp here. This could also be environment information or other non-tool context that we find useful for the agent

Copy link
Collaborator

Choose a reason for hiding this comment

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

in light of the latest infra changes here's my proposal:

  • add a method to the platform extension type get_moim() and implement that for todo
  • add a method to extension_manager get_moim that goes through its platform extensions and collects them and then adds the current time
  • call this method from reply_internal (yeah, I know that is scary)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Working in #5027

let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut content = format!("Current date and time: {}\n", timestamp);

if let Some(todo_content) = get_todo_context(session).await {
content.push_str("\nCurrent tasks and notes:\n");
content.push_str(&todo_content);
content.push('\n');
}

Some(content)
}

/// Find a safe insertion point for MOIM.
///
/// We want to insert as close to the end as possible, but we must avoid
/// breaking tool call/response pairs. We check if inserting at a position
/// would separate a tool call from its response.
pub fn find_safe_insertion_point(messages: &[Message]) -> usize {
if messages.is_empty() {
return 0;
}

// Default to inserting before the last message
let last_pos = messages.len() - 1;

// Check if inserting at last_pos would break a tool pair
if last_pos > 0 {
let prev_msg = &messages[last_pos - 1];
let curr_msg = &messages[last_pos];

// If previous message has tool calls and current has matching responses,
// we can't insert between them
if prev_msg.is_tool_call() && curr_msg.is_tool_response() {
// Find the next best position (before the tool call)
return last_pos.saturating_sub(1);
}
}

last_pos
}

pub async fn inject_moim_if_enabled(
messages_for_provider: Conversation,
session: &Option<SessionConfig>,
) -> Conversation {
// Check if MOIM is enabled
let moim_enabled = crate::config::Config::global()
.get_param::<bool>("goose_moim_enabled")
.unwrap_or(true);

if !moim_enabled {
return messages_for_provider;
}

if let Some(moim_content) = build_moim_content(session).await {
let mut msgs = messages_for_provider.messages().to_vec();
let moim_msg = Message::user().with_text(moim_content);

if msgs.is_empty() {
// If conversation is empty, just add the MOIM
msgs.push(moim_msg);
} else {
// Find a safe position that won't break tool call/response pairs
let insert_pos = find_safe_insertion_point(&msgs);
msgs.insert(insert_pos, moim_msg);
}

Conversation::new_unvalidated(msgs)
} else {
messages_for_provider
}
}

async fn get_todo_context(session: &Option<SessionConfig>) -> Option<String> {
let session_config = session.as_ref()?;

match SessionManager::get_session(&session_config.id, false).await {
Ok(session_data) => TodoState::from_extension_data(&session_data.extension_data)
.map(|state| state.content)
.filter(|content| !content.trim().is_empty()),
Err(e) => {
tracing::debug!("Could not read session for MOIM: {}", e);
None
}
}
}
9 changes: 0 additions & 9 deletions crates/goose/src/agents/prompt_manager.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use chrono::Utc;
use serde_json::Value;
use std::collections::HashMap;

Expand All @@ -10,7 +9,6 @@ use crate::{config::Config, prompt_template, utils::sanitize_unicode_tags};
pub struct PromptManager {
system_prompt_override: Option<String>,
system_prompt_extras: Vec<String>,
current_date_timestamp: String,
}

impl Default for PromptManager {
Expand All @@ -24,8 +22,6 @@ impl PromptManager {
PromptManager {
system_prompt_override: None,
system_prompt_extras: Vec::new(),
// Use the fixed current date time so that prompt cache can be used.
current_date_timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
}
}

Expand Down Expand Up @@ -102,11 +98,6 @@ impl PromptManager {
);
}

context.insert(
"current_date_time",
Value::String(self.current_date_timestamp.clone()),
);

// Add the suggestion about disabling extensions if flag is true
context.insert(
"suggest_disable",
Expand Down
5 changes: 5 additions & 0 deletions crates/goose/src/agents/reply_parts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ impl Agent {
messages: &[Message],
tools: &[Tool],
toolshim_tools: &[Tool],
session: &Option<crate::agents::types::SessionConfig>,
) -> Result<MessageStream, ProviderError> {
let config = provider.get_model_config();

Expand All @@ -144,6 +145,10 @@ impl Agent {
Conversation::new_unvalidated(messages.to_vec())
};

// Inject MOIM (timestamp + TODO content) if enabled
let messages_for_provider =
super::moim::inject_moim_if_enabled(messages_for_provider, session).await;
Copy link
Collaborator

Choose a reason for hiding this comment

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

you should do this in agent.reply. there you can stick it into the block that already checks for session and make session non optional

Copy link
Collaborator Author

@tlongwell-block tlongwell-block Sep 29, 2025

Choose a reason for hiding this comment

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

I put it in stream_response_from_provider because I wanted it to be at the very, very edge of the shared inference execution path. This is the last point on that execution path that doesn't split between different providers

This makes reasoning about it much easier. There's no chance to be written to a session file, be compacted, etc, etc

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

As far as session optionality goes: it should 100% be required, but we can't do it yet. Once recipes/subagents/scheduler are on Agent Manager, they will have real sessions.

But, until then, stream_response_from_provider will be called without a session when dealing with recipes/subagents

Copy link
Collaborator

Choose a reason for hiding this comment

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

Seems like the dependency is there from how the TODO stool stores its state over actually needing the session.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes. Instead of passing the session to inject_moim_if_enabled, I could pass TodoState::from_extension_data directly? It just ended up looking cleaner this way

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah, no, lets keep passing the session in because we'll probably want to add more to moim from the session metadata later at some point. And its neater


// Clone owned data to move into the async stream
let system_prompt = system_prompt.to_owned();
let tools = tools.to_owned();
Expand Down
Loading
Loading