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:
@@ -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();
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user