Platform extensions sketch (#4868)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Douwe Osinga
2025-10-03 13:45:37 -04:00
committed by GitHub
parent 5b8efb5b9a
commit 96ded37e15
36 changed files with 742 additions and 847 deletions
+3 -123
View File
@@ -61,12 +61,8 @@ 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::conversation::message::{Message, ToolRequest};
use crate::session::extension_data::ExtensionState;
use crate::session::{extension_data, SessionManager};
use crate::session::SessionManager;
const DEFAULT_MAX_TURNS: u32 = 1000;
@@ -300,7 +296,6 @@ impl Agent {
permission_check_result: &PermissionCheckResult,
message_tool_response: Arc<Mutex<Message>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
session: &Option<SessionConfig>,
) -> Result<Vec<(String, ToolStream)>> {
let mut tool_futures: Vec<(String, ToolStream)> = Vec::new();
@@ -308,12 +303,7 @@ impl Agent {
for request in &permission_check_result.approved {
if let Ok(tool_call) = request.tool_call.clone() {
let (req_id, tool_result) = self
.dispatch_tool_call(
tool_call,
request.id.clone(),
cancel_token.clone(),
session,
)
.dispatch_tool_call(tool_call, request.id.clone(), cancel_token.clone())
.await;
tool_futures.push((
@@ -393,7 +383,6 @@ impl Agent {
tool_call: CallToolRequestParam,
request_id: String,
cancellation_token: Option<CancellationToken>,
session: &Option<SessionConfig>,
) -> (String, Result<ToolCallResult, ErrorData>) {
if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME {
let arguments = tool_call
@@ -521,93 +510,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 = match tool_call.arguments {
Some(args) => args
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
None => "".to_string(),
};
// Character limit validation
let char_count = content.chars().count();
let max_chars = std::env::var("GOOSE_TODO_MAX_CHARS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(50_000);
if max_chars > 0 && char_count > max_chars {
ToolCallResult::from(Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!(
"Todo list too large: {} chars (max: {})",
char_count, max_chars
),
None,
)))
} else if let Some(session_config) = session {
match SessionManager::get_session(&session_config.id, false).await {
Ok(mut session) => {
let todo_state = extension_data::TodoState::new(content);
if todo_state
.to_extension_data(&mut session.extension_data)
.is_ok()
{
match SessionManager::update_session(&session_config.id)
.extension_data(session.extension_data)
.apply()
.await
{
Ok(_) => ToolCallResult::from(Ok(vec![Content::text(format!(
"Updated ({} chars)",
char_count
))])),
Err(_) => ToolCallResult::from(Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
"Failed to update session metadata".to_string(),
None,
))),
}
} else {
ToolCallResult::from(Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
"Failed to serialize TODO state".to_string(),
None,
)))
}
}
Err(_) => ToolCallResult::from(Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
"Failed to read session metadata".to_string(),
None,
))),
}
} else {
ToolCallResult::from(Err(ErrorData::new(
ErrorCode::INTERNAL_ERROR,
"TODO tools require an active session to persist data".to_string(),
None,
)))
}
} else if tool_call.name == ROUTER_LLM_SEARCH_TOOL_NAME {
match self
.tool_route_manager
@@ -762,11 +664,9 @@ impl Agent {
pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> {
match &extension {
ExtensionConfig::Frontend {
name: _,
tools,
instructions,
bundled: _,
available_tools: _,
..
} => {
// For frontend tools, just store them in the frontend_tools map
let mut frontend_tools = self.frontend_tools.lock().await;
@@ -834,10 +734,6 @@ impl Agent {
platform_tools::manage_extensions_tool(),
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());
@@ -1248,7 +1144,6 @@ impl Agent {
&permission_check_result,
message_tool_response.clone(),
cancel_token.clone(),
&session
).await?;
let tool_futures_arc = Arc::new(Mutex::new(tool_futures));
@@ -1756,21 +1651,6 @@ mod tests {
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");
Ok(())
}
#[tokio::test]
async fn test_tool_inspection_manager_has_all_inspectors() -> Result<()> {
let agent = Agent::new();
+65 -21
View File
@@ -1,5 +1,11 @@
use crate::agents::todo_extension;
use std::collections::HashMap;
use crate::agents::mcp_client::McpClientTrait;
use crate::config;
use crate::config::extensions::name_to_key;
use crate::config::permission::PermissionLevel;
use once_cell::sync::Lazy;
use rmcp::model::Tool;
use rmcp::service::ClientInitializeError;
use rmcp::ServiceError as ClientError;
@@ -8,10 +14,6 @@ use thiserror::Error;
use tracing::warn;
use utoipa::ToSchema;
use crate::config;
use crate::config::extensions::name_to_key;
use crate::config::permission::PermissionLevel;
#[derive(Error, Debug)]
#[error("process quit before initialization: stderr = {stderr}")]
pub struct ProcessExit {
@@ -32,6 +34,37 @@ impl ProcessExit {
}
}
pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>> =
Lazy::new(|| {
let mut map = HashMap::new();
map.insert(
todo_extension::EXTENSION_NAME,
PlatformExtensionDef {
name: todo_extension::EXTENSION_NAME,
description:
"Enable a todo list for Goose so it can keep track of what it is doing",
default_enabled: true,
client_factory: |ctx| Box::new(todo_extension::TodoClient::new(ctx).unwrap()),
},
);
map
});
#[derive(Debug, Clone)]
pub struct PlatformExtensionContext {
pub session_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct PlatformExtensionDef {
pub name: &'static str,
pub description: &'static str,
pub default_enabled: bool,
pub client_factory: fn(PlatformExtensionContext) -> Box<dyn McpClientTrait>,
}
/// Errors from Extension operation
#[derive(Error, Debug)]
pub enum ExtensionError {
@@ -151,16 +184,15 @@ pub enum ExtensionConfig {
Sse {
/// The name used to identify this extension
name: String,
description: String,
uri: String,
#[serde(default)]
envs: Envs,
#[serde(default)]
env_keys: Vec<String>,
description: Option<String>,
// NOTE: set timeout to be optional for compatibility.
// However, new configurations should include this field.
timeout: Option<u64>,
/// Whether this extension is bundled with goose
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
@@ -171,6 +203,7 @@ pub enum ExtensionConfig {
Stdio {
/// The name used to identify this extension
name: String,
description: String,
cmd: String,
args: Vec<String>,
#[serde(default)]
@@ -178,22 +211,30 @@ pub enum ExtensionConfig {
#[serde(default)]
env_keys: Vec<String>,
timeout: Option<u64>,
description: Option<String>,
/// Whether this extension is bundled with goose
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
/// Built-in extension that is part of the goose binary
/// Built-in extension that is part of the bundled goose MCP server
#[serde(rename = "builtin")]
Builtin {
/// The name used to identify this extension
name: String,
description: String,
display_name: Option<String>, // needed for the UI
description: Option<String>,
timeout: Option<u64>,
/// Whether this extension is bundled with goose
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
available_tools: Vec<String>,
},
/// Platform extensions that have direct access to the agent etc and run in the agent process
#[serde(rename = "platform")]
Platform {
/// The name used to identify this extension
name: String,
description: String,
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
@@ -204,6 +245,7 @@ pub enum ExtensionConfig {
StreamableHttp {
/// The name used to identify this extension
name: String,
description: String,
uri: String,
#[serde(default)]
envs: Envs,
@@ -211,11 +253,9 @@ pub enum ExtensionConfig {
env_keys: Vec<String>,
#[serde(default)]
headers: HashMap<String, String>,
description: Option<String>,
// NOTE: set timeout to be optional for compatibility.
// However, new configurations should include this field.
timeout: Option<u64>,
/// Whether this extension is bundled with goose
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
@@ -226,11 +266,11 @@ pub enum ExtensionConfig {
Frontend {
/// The name used to identify this extension
name: String,
description: String,
/// The tools provided by the frontend
tools: Vec<Tool>,
/// Instructions for how to use these tools
instructions: Option<String>,
/// Whether this extension is bundled with goose
#[serde(default)]
bundled: Option<bool>,
#[serde(default)]
@@ -241,10 +281,9 @@ pub enum ExtensionConfig {
InlinePython {
/// The name used to identify this extension
name: String,
description: String,
/// The Python code to execute
code: String,
/// Description of what the extension does
description: Option<String>,
/// Timeout in seconds
timeout: Option<u64>,
/// Python package dependencies required by this extension
@@ -260,7 +299,7 @@ impl Default for ExtensionConfig {
Self::Builtin {
name: config::DEFAULT_EXTENSION.to_string(),
display_name: Some(config::DEFAULT_DISPLAY_NAME.to_string()),
description: None,
description: "default".to_string(),
timeout: Some(config::DEFAULT_EXTENSION_TIMEOUT),
bundled: Some(true),
available_tools: Vec::new(),
@@ -275,7 +314,7 @@ impl ExtensionConfig {
uri: uri.into(),
envs: Envs::default(),
env_keys: Vec::new(),
description: Some(description.into()),
description: description.into(),
timeout: Some(timeout.into()),
bundled: None,
available_tools: Vec::new(),
@@ -294,7 +333,7 @@ impl ExtensionConfig {
envs: Envs::default(),
env_keys: Vec::new(),
headers: HashMap::new(),
description: Some(description.into()),
description: description.into(),
timeout: Some(timeout.into()),
bundled: None,
available_tools: Vec::new(),
@@ -313,7 +352,7 @@ impl ExtensionConfig {
args: vec![],
envs: Envs::default(),
env_keys: Vec::new(),
description: Some(description.into()),
description: description.into(),
timeout: Some(timeout.into()),
bundled: None,
available_tools: Vec::new(),
@@ -329,7 +368,7 @@ impl ExtensionConfig {
Self::InlinePython {
name: name.into(),
code: code.into(),
description: Some(description.into()),
description: description.into(),
timeout: Some(timeout.into()),
dependencies: None,
available_tools: Vec::new(),
@@ -379,6 +418,7 @@ impl ExtensionConfig {
Self::StreamableHttp { name, .. } => name,
Self::Stdio { name, .. } => name,
Self::Builtin { name, .. } => name,
Self::Platform { name, .. } => name,
Self::Frontend { name, .. } => name,
Self::InlinePython { name, .. } => name,
}
@@ -400,6 +440,9 @@ impl ExtensionConfig {
| Self::Builtin {
available_tools, ..
}
| Self::Platform {
available_tools, ..
}
| Self::InlinePython {
available_tools, ..
}
@@ -427,6 +470,7 @@ impl std::fmt::Display for ExtensionConfig {
write!(f, "Stdio({}: {} {})", name, cmd, args.join(" "))
}
ExtensionConfig::Builtin { name, .. } => write!(f, "Builtin({})", name),
ExtensionConfig::Platform { name, .. } => write!(f, "Platform({})", name),
ExtensionConfig::Frontend { name, tools, .. } => {
write!(f, "Frontend({}: {} tools)", name, tools.len())
}
+55 -34
View File
@@ -24,7 +24,10 @@ use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
use tracing::{error, warn};
use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo};
use super::extension::{
ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, PlatformExtensionContext,
ToolInfo, PLATFORM_EXTENSIONS,
};
use super::tool_execution::ToolCallResult;
use crate::agents::extension::{Envs, ProcessExit};
use crate::agents::extension_malware_check;
@@ -85,6 +88,7 @@ impl Extension {
/// Manages goose extensions / MCP clients and their interactions
pub struct ExtensionManager {
extensions: Mutex<HashMap<String, Extension>>,
context: Mutex<PlatformExtensionContext>,
}
/// A flattened representation of a resource used by the agent to prepare inference
@@ -234,9 +238,18 @@ impl ExtensionManager {
pub fn new() -> Self {
Self {
extensions: Mutex::new(HashMap::new()),
context: Mutex::new(PlatformExtensionContext { session_id: None }),
}
}
pub async fn set_context(&self, context: PlatformExtensionContext) {
*self.context.lock().await = context;
}
pub async fn get_context(&self) -> PlatformExtensionContext {
self.context.lock().await.clone()
}
pub async fn supports_resources(&self) -> bool {
self.extensions
.lock()
@@ -417,16 +430,33 @@ impl ExtensionManager {
available_tools: _,
} => {
let cmd = std::env::current_exe()
.expect("should find the current executable")
.to_str()
.expect("should resolve executable to string path")
.to_string();
.and_then(|path| {
path.to_str().map(|s| s.to_string()).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Invalid UTF-8 in executable path",
)
})
})
.map_err(|e| {
ExtensionError::ConfigError(format!(
"Failed to resolve executable path: {}",
e
))
})?;
let command = Command::new(cmd).configure(|command| {
command.arg("mcp").arg(name);
});
let client = child_process_client(command, timeout).await?;
Box::new(client)
}
ExtensionConfig::Platform { name, .. } => {
let def = PLATFORM_EXTENSIONS.get(name.as_str()).ok_or_else(|| {
ExtensionError::ConfigError(format!("Unknown platform extension: {}", name))
})?;
let context = self.get_context().await;
(def.client_factory)(context)
}
ExtensionConfig::InlinePython {
name,
code,
@@ -453,7 +483,11 @@ impl ExtensionManager {
Box::new(client)
}
_ => unreachable!(),
ExtensionConfig::Frontend { .. } => {
return Err(ExtensionError::ConfigError(
"Invalid extension type: Frontend extensions cannot be added as server extensions".to_string()
));
}
};
let server_info = client.get_info().cloned();
@@ -1006,35 +1040,22 @@ impl ExtensionManager {
let config = extension.config.clone();
let description = match &config {
ExtensionConfig::Builtin {
name, display_name, ..
description,
display_name,
..
} => {
// For builtin extensions, use display name if available
display_name
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| name.clone())
}
ExtensionConfig::Sse {
description, name, ..
}
| ExtensionConfig::StreamableHttp {
description, name, ..
}
| ExtensionConfig::Stdio {
description, name, ..
}
| ExtensionConfig::InlinePython {
description, name, ..
} => {
// For SSE/StreamableHttp/Stdio/InlinePython, use description if available
description
.as_ref()
.map(|s| s.to_string())
.unwrap_or_else(|| format!("Extension '{}'", name))
}
ExtensionConfig::Frontend { name, .. } => {
format!("Frontend extension '{}'", name)
if description.is_empty() {
display_name.as_deref().unwrap_or("Built-in extension")
} else {
description
}
}
ExtensionConfig::Platform { description, .. }
| ExtensionConfig::Sse { description, .. }
| ExtensionConfig::StreamableHttp { description, .. }
| ExtensionConfig::Stdio { description, .. }
| ExtensionConfig::Frontend { description, .. }
| ExtensionConfig::InlinePython { description, .. } => description,
};
disabled_extensions.push(format!("- {} - {}", config.name(), description));
}
@@ -1110,7 +1131,7 @@ mod tests {
let config = ExtensionConfig::Builtin {
name: name.clone(),
display_name: Some(name.clone()),
description: None,
description: "built-in".to_string(),
timeout: None,
bundled: None,
available_tools,
+1 -1
View File
@@ -20,7 +20,7 @@ pub mod subagent;
pub mod subagent_execution_tool;
pub mod subagent_handler;
mod subagent_task_config;
pub mod todo_tools;
pub(crate) mod todo_extension;
mod tool_execution;
mod tool_route_manager;
mod tool_router_index_manager;
+289
View File
@@ -0,0 +1,289 @@
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::mcp_client::{Error, McpClientTrait};
use crate::session::extension_data::ExtensionState;
use crate::session::{extension_data, SessionManager};
use anyhow::Result;
use async_trait::async_trait;
use indoc::indoc;
use rmcp::model::{
CallToolResult, Content, GetPromptResult, Implementation, InitializeResult, JsonObject,
ListPromptsResult, ListResourcesResult, ListToolsResult, ProtocolVersion, ReadResourceResult,
ServerCapabilities, ServerNotification, Tool, ToolAnnotations, ToolsCapability,
};
use rmcp::object;
use serde_json::Value;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub static EXTENSION_NAME: &str = "todo";
pub struct TodoClient {
info: InitializeResult,
context: PlatformExtensionContext,
fallback_content: tokio::sync::RwLock<String>,
}
impl TodoClient {
pub fn new(context: PlatformExtensionContext) -> Result<Self> {
let info = InitializeResult {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: Some(false),
}),
resources: None,
prompts: None,
completions: None,
experimental: None,
logging: None,
},
server_info: Implementation {
name: EXTENSION_NAME.to_string(),
title: Some("Todo".to_string()),
version: "1.0.0".to_string(),
icons: None,
website_url: None,
},
instructions: Some(indoc! {r#"
Task Management
Use todo_read and todo_write for tasks with 2+ steps, multiple files/components, or uncertain scope.
Workflow:
- Start: read → write checklist
- During: read → update progress
- End: verify all complete
Warning: todo_write overwrites entirely; always todo_read first (skipping is an error)
Keep items short, specific, action-oriented. Not using the todo tools for complex tasks is an error.
Template:
- [ ] Implement feature X
- [ ] Update API
- [ ] Write tests
- [ ] Run tests (subagent in parallel)
- [ ] Run lint (subagent in parallel)
- [ ] Blocked: waiting on credentials
"#}.to_string()),
};
Ok(Self {
info,
context,
fallback_content: tokio::sync::RwLock::new(String::new()),
})
}
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>,
) -> Result<Vec<Content>, String> {
let content = arguments
.as_ref()
.ok_or("Missing arguments")?
.get("content")
.and_then(|v| v.as_str())
.ok_or("Missing required parameter: content")?
.to_string();
let char_count = content.chars().count();
let max_chars = std::env::var("GOOSE_TODO_MAX_CHARS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(50_000);
if max_chars > 0 && char_count > max_chars {
return Err(format!(
"Todo list too large: {} chars (max: {})",
char_count, max_chars
));
}
if let Some(session_id) = &self.context.session_id {
match SessionManager::get_session(session_id, false).await {
Ok(mut session) => {
let todo_state = extension_data::TodoState::new(content);
if todo_state
.to_extension_data(&mut session.extension_data)
.is_ok()
{
match SessionManager::update_session(session_id)
.extension_data(session.extension_data)
.apply()
.await
{
Ok(_) => Ok(vec![Content::text(format!(
"Updated ({} chars)",
char_count
))]),
Err(_) => Err("Failed to update session metadata".to_string()),
}
} else {
Err("Failed to serialize TODO state".to_string())
}
}
Err(_) => Err("Failed to read session metadata".to_string()),
}
} else {
let mut fallback = self.fallback_content.write().await;
*fallback = content;
Ok(vec![Content::text(format!(
"Updated ({} chars)",
char_count
))])
}
}
fn get_tools() -> Vec<Tool> {
vec![
Tool::new(
"todo_read".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",
"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.
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",
"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),
})
]
}
}
#[async_trait]
impl McpClientTrait for TodoClient {
async fn list_resources(
&self,
_next_cursor: Option<String>,
_cancellation_token: CancellationToken,
) -> Result<ListResourcesResult, Error> {
Err(Error::TransportClosed)
}
async fn read_resource(
&self,
_uri: &str,
_cancellation_token: CancellationToken,
) -> Result<ReadResourceResult, Error> {
Err(Error::TransportClosed)
}
async fn list_tools(
&self,
_next_cursor: Option<String>,
_cancellation_token: CancellationToken,
) -> Result<ListToolsResult, Error> {
Ok(ListToolsResult {
tools: Self::get_tools(),
next_cursor: None,
})
}
async fn call_tool(
&self,
name: &str,
arguments: Option<JsonObject>,
_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)),
};
match content {
Ok(content) => Ok(CallToolResult::success(content)),
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: {}",
error
))])),
}
}
async fn list_prompts(
&self,
_next_cursor: Option<String>,
_cancellation_token: CancellationToken,
) -> Result<ListPromptsResult, Error> {
Err(Error::TransportClosed)
}
async fn get_prompt(
&self,
_name: &str,
_arguments: Value,
_cancellation_token: CancellationToken,
) -> Result<GetPromptResult, Error> {
Err(Error::TransportClosed)
}
async fn subscribe(&self) -> mpsc::Receiver<ServerNotification> {
mpsc::channel(1).1
}
fn get_info(&self) -> Option<&InitializeResult> {
Some(&self.info)
}
}
-160
View File
@@ -1,160 +0,0 @@
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 unit_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");
}
}
+1 -2
View File
@@ -90,8 +90,7 @@ impl Agent {
}
if confirmation.permission == Permission::AllowOnce || confirmation.permission == Permission::AlwaysAllow {
// Clone tool_call to avoid moving it
let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), &None).await;
let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone()).await;
let mut futures = tool_futures.lock().await;
futures.push((req_id, match tool_result {