Skip to content
Merged
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
14 changes: 14 additions & 0 deletions crates/goose-cli/src/session/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,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),
_ => render_default_request(call, debug),
},
Err(e) => print_markdown(&e.to_string(), theme),
Expand Down Expand Up @@ -452,6 +453,19 @@ fn render_dynamic_task_request(call: &ToolCall, debug: bool) {
println!();
}

fn render_todo_request(call: &ToolCall, _debug: bool) {
print_tool_header(call);

// For todo tools, always show the full content without redaction
if let Some(Value::String(content)) = call.arguments.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!();
}

fn render_default_request(call: &ToolCall, debug: bool) {
print_tool_header(call);
print_params(&call.arguments, 0, debug);
Expand Down
21 changes: 8 additions & 13 deletions crates/goose-mcp/src/developer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2291,33 +2291,28 @@ mod tests {
assert!(text_editor_tool
.description
.as_ref()
.map_or(false, |desc| desc
.contains("Replace a string in a file with a new string")));
.is_some_and(|desc| desc.contains("Replace a string in a file with a new string")));
assert!(text_editor_tool
.description
.as_ref()
.map_or(false, |desc| desc
.contains("the `old_str` needs to exactly match one")));
.is_some_and(|desc| desc.contains("the `old_str` needs to exactly match one")));
assert!(text_editor_tool
.description
.as_ref()
.map_or(false, |desc| desc.contains("str_replace")));
.is_some_and(|desc| desc.contains("str_replace")));

// Should not contain editor API description or edit_file command
assert!(!text_editor_tool
.description
.as_ref()
.map_or(false, |desc| desc
.contains("Edit the file with the new content")));
.is_some_and(|desc| desc.contains("Edit the file with the new content")));
assert!(!text_editor_tool
.description
.as_ref()
.map_or(false, |desc| desc.contains("edit_file")));
assert!(!text_editor_tool
.description
.as_ref()
.map_or(false, |desc| desc
.contains("work out how to place old_str with it intelligently")));
.is_some_and(|desc| desc.contains("edit_file")));
assert!(!text_editor_tool.description.as_ref().is_some_and(
|desc| desc.contains("work out how to place old_str with it intelligently")
));

temp_dir.close().unwrap();
}
Expand Down
76 changes: 76 additions & 0 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ use super::final_output_tool::FinalOutputTool;
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::conversation::message::{Message, ToolRequest};

const DEFAULT_MAX_TURNS: u32 = 1000;
Expand Down Expand Up @@ -97,6 +100,7 @@ pub struct Agent {
pub(super) tool_route_manager: ToolRouteManager,
pub(super) scheduler_service: Mutex<Option<Arc<dyn SchedulerTrait>>>,
pub(super) retry_manager: RetryManager,
pub(super) todo_list: Arc<Mutex<String>>,
}

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -148,6 +152,15 @@ where
}

impl Agent {
const DEFAULT_TODO_MAX_CHARS: usize = 50_000;

fn get_todo_max_chars() -> usize {
std::env::var("GOOSE_TODO_MAX_CHARS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(Self::DEFAULT_TODO_MAX_CHARS)
}

pub fn new() -> Self {
// Create channels with buffer size 32 (adjust if needed)
let (confirm_tx, confirm_rx) = mpsc::channel(32);
Expand All @@ -173,6 +186,7 @@ impl Agent {
tool_route_manager: ToolRouteManager::new(),
scheduler_service: Mutex::new(None),
retry_manager,
todo_list: Arc::new(Mutex::new(String::new())),
}
}

Expand Down Expand Up @@ -467,6 +481,45 @@ impl Agent {
ToolCallResult::from(Err(ToolError::ExecutionError(
"Frontend tool execution required".to_string(),
)))
} else if tool_call.name == TODO_READ_TOOL_NAME {
// Handle task planner read tool
let todo_content = self.todo_list.lock().await.clone();
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
.arguments
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();

// Acquire lock first to prevent race condition
let mut todo_list = self.todo_list.lock().await;

// Character limit validation
let char_count = content.chars().count();
let max_chars = Self::get_todo_max_chars();

// Simple validation - reject if over limit (0 means unlimited)
if max_chars > 0 && char_count > max_chars {
return (
request_id,
Ok(ToolCallResult::from(Err(ToolError::ExecutionError(
format!(
"Todo list too large: {} chars (max: {})",
char_count, max_chars
),
)))),
);
}

*todo_list = content;

ToolCallResult::from(Ok(vec![Content::text(format!(
"Updated ({} chars)",
char_count
))]))
} else if tool_call.name == ROUTER_VECTOR_SEARCH_TOOL_NAME
|| tool_call.name == ROUTER_LLM_SEARCH_TOOL_NAME
{
Expand Down Expand Up @@ -684,6 +737,9 @@ impl Agent {
platform_tools::manage_schedule_tool(),
]);

// Add task planner tools
prefixed_tools.extend([todo_read_tool(), todo_write_tool()]);

// Dynamic task tool
prefixed_tools.push(create_dynamic_task_tool());

Expand Down Expand Up @@ -1431,4 +1487,24 @@ mod tests {
assert!(system_prompt.contains(&final_output_tool_system_prompt));
Ok(())
}

#[tokio::test]
async fn test_todo_tools_integration() -> Result<()> {
let agent = Agent::new();

// Test that task planner tools are listed
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");

// Test todo_list initialization
let todo_content = agent.todo_list.lock().await;
assert_eq!(*todo_content, "", "TODO list should be initially empty");

Ok(())
}
}
1 change: 1 addition & 0 deletions crates/goose/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod subagent;
pub mod subagent_execution_tool;
pub mod subagent_handler;
mod subagent_task_config;
pub mod todo_tools;
mod tool_execution;
mod tool_route_manager;
mod tool_router_index_manager;
Expand Down
160 changes: 160 additions & 0 deletions crates/goose/src/agents/todo_tools.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
use indoc::indoc;
use rmcp::model::{Tool, ToolAnnotations};
use rmcp::object;

/// Tool name constant for reading task planner content
pub const TODO_READ_TOOL_NAME: &str = "todo__read";

/// Tool name constant for writing task planner content
pub const TODO_WRITE_TOOL_NAME: &str = "todo__write";

/// Creates a tool for reading task planner content.
///
/// This tool reads the entire task planner file content as a string.
/// It is marked as read-only and safe to use repeatedly.
///
/// # Returns
/// A configured `Tool` instance for reading task planner content
pub fn todo_read_tool() -> Tool {
Tool::new(
TODO_READ_TOOL_NAME.to_string(),
indoc! {r#"
Read the entire TODO file content.

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.

The tool will return an error if the TODO file doesn't exist or cannot be read.
"#}
.to_string(),
object!({
"type": "object",
"required": [],
"properties": {}
}),
)
.annotate(ToolAnnotations {
title: Some("Read TODO content".to_string()),
read_only_hint: Some(true),
destructive_hint: Some(false),
idempotent_hint: Some(true),
open_world_hint: Some(false),
})
}

/// Creates a tool for writing task planner content.
///
/// This tool writes or overwrites the entire task planner file with new content.
/// It replaces the complete file content with the provided string.
///
/// # Returns
/// A configured `Tool` instance for writing task planner content
pub fn todo_write_tool() -> Tool {
Tool::new(
TODO_WRITE_TOOL_NAME.to_string(),
indoc! {r#"
Write or overwrite the entire TODO file content.

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
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",
"required": ["content"],
"properties": {
"content": {
"type": "string",
"description": "The complete content to write to the TODO file. This will replace all existing content."
}
}
}),
)
.annotate(ToolAnnotations {
title: Some("Write TODO content".to_string()),
read_only_hint: Some(false),
destructive_hint: Some(true), // It overwrites the entire file
idempotent_hint: Some(true), // Writing the same content multiple times has the same effect
open_world_hint: Some(false),
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_todo_read_tool_creation() {
let tool = todo_read_tool();

// Verify tool name
assert_eq!(tool.name, TODO_READ_TOOL_NAME);

// Verify description exists and is not empty
assert!(tool.description.is_some());
let description = tool.description.as_ref().unwrap();
assert!(!description.is_empty());

// Verify input schema
let schema = &tool.input_schema;
assert_eq!(schema["type"], "object");
assert_eq!(schema["required"].as_array().unwrap().len(), 0);

// Verify annotations
let annotations = tool.annotations.as_ref().unwrap();
assert_eq!(annotations.title, Some("Read TODO content".to_string()));
assert_eq!(annotations.read_only_hint, Some(true));
assert_eq!(annotations.destructive_hint, Some(false));
assert_eq!(annotations.idempotent_hint, Some(true));
assert_eq!(annotations.open_world_hint, Some(false));
}

#[test]
fn test_todo_write_tool_creation() {
let tool = todo_write_tool();

// Verify tool name
assert_eq!(tool.name, TODO_WRITE_TOOL_NAME);

// Verify description exists and is not empty
assert!(tool.description.is_some());
let description = tool.description.as_ref().unwrap();
assert!(!description.is_empty());

// Verify input schema
let schema = &tool.input_schema;
assert_eq!(schema["type"], "object");

// Verify required parameters
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "content");

// Verify properties
assert!(schema["properties"]["content"].is_object());
assert_eq!(schema["properties"]["content"]["type"], "string");

// Verify annotations
let annotations = tool.annotations.as_ref().unwrap();
assert_eq!(annotations.title, Some("Write TODO content".to_string()));
assert_eq!(annotations.read_only_hint, Some(false));
assert_eq!(annotations.destructive_hint, Some(true));
assert_eq!(annotations.idempotent_hint, Some(true));
assert_eq!(annotations.open_world_hint, Some(false));
}

#[test]
fn test_tool_name_constants() {
// Verify the constants follow the naming pattern
assert!(TODO_READ_TOOL_NAME.starts_with("todo__"));
assert!(TODO_WRITE_TOOL_NAME.starts_with("todo__"));
assert_eq!(TODO_READ_TOOL_NAME, "todo__read");
assert_eq!(TODO_WRITE_TOOL_NAME, "todo__write");
}
}
17 changes: 17 additions & 0 deletions crates/goose/src/prompts/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ No extensions are defined. You should let the user know that they should add ext

{{tool_selection_strategy}}

# Task Management

- Required — use `todo__read` and `todo__write` for any task with 2+ steps, multiple files/components, or uncertain scope. Skipping them is an error.
- Start — `todo__read`, then `todo__write` a brief checklist (Markdown checkboxes).
- During — after each major action, update via `todo__write`: mark done, add/edit items, note blockers/dependencies.
- Finish — ensure every item is checked, or clearly list what remains.
- Overwrite warning — `todo__write` replaces the entire list; always read before writing. It is an error to not read before writing.
- Quality — keep items short, specific, and action‑oriented.

Template:
```markdown
- [ ] Implement feature X
- [ ] Update API
- [ ] Write tests
- [ ] Blocked: waiting on credentials
```

# Response Guidelines

- Use Markdown formatting for all responses.
Expand Down
Loading
Loading