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 {
+75 -4
View File
@@ -1,8 +1,11 @@
use super::base::Config;
use crate::agents::extension::PLATFORM_EXTENSIONS;
use crate::agents::ExtensionConfig;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use tracing::warn;
use utoipa::ToSchema;
pub const DEFAULT_EXTENSION: &str = "developer";
@@ -29,10 +32,78 @@ pub struct ExtensionConfigManager;
impl ExtensionConfigManager {
fn get_extensions_map() -> Result<HashMap<String, ExtensionEntry>> {
let config = Config::global();
Ok(config
.get_param(EXTENSIONS_CONFIG_KEY)
.unwrap_or_else(|_| HashMap::new()))
let raw: Value = Config::global()
.get_param::<Value>(EXTENSIONS_CONFIG_KEY)
.unwrap_or_else(|err| {
warn!(
"Failed to load {}: {err}. Falling back to empty object.",
EXTENSIONS_CONFIG_KEY
);
Value::Object(serde_json::Map::new())
});
let mut extensions_map: HashMap<String, ExtensionEntry> = match raw {
Value::Object(obj) => {
let mut m = HashMap::with_capacity(obj.len());
for (k, mut v) in obj {
if let Value::Object(ref mut inner) = v {
match inner.get("description") {
Some(Value::Null) | None => {
inner.insert(
"description".to_string(),
Value::String(String::new()),
);
}
_ => {}
}
}
match serde_json::from_value::<ExtensionEntry>(v.clone()) {
Ok(entry) => {
m.insert(k, entry);
}
Err(err) => {
let bad_json = serde_json::to_string(&v).unwrap_or_else(|e| {
format!("<failed to serialize malformed value: {e}>")
});
warn!(
extension = %k,
error = %err,
bad_json = %bad_json,
"Skipping malformed extension"
);
}
}
}
m
}
other => {
warn!(
"Expected object for {}, got {}. Using empty map.",
EXTENSIONS_CONFIG_KEY, other
);
HashMap::new()
}
};
if !extensions_map.is_empty() {
for (name, def) in PLATFORM_EXTENSIONS.iter() {
if !extensions_map.contains_key(*name) {
extensions_map.insert(
name.to_string(),
ExtensionEntry {
config: ExtensionConfig::Platform {
name: def.name.to_string(),
description: def.description.to_string(),
bundled: Some(true),
available_tools: Vec::new(),
},
enabled: true,
},
);
}
}
}
Ok(extensions_map)
}
fn save_extensions_map(extensions: HashMap<String, ExtensionEntry>) -> Result<()> {
+7
View File
@@ -1,3 +1,4 @@
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::Agent;
use crate::config::APP_STRATEGY;
use crate::model::ModelConfig;
@@ -119,6 +120,12 @@ impl AgentManager {
let agent = Arc::new(Agent::new());
agent.set_scheduler(Arc::clone(&self.scheduler)).await;
agent
.extension_manager
.set_context(PlatformExtensionContext {
session_id: Some(session_id.clone()),
})
.await;
if let Some(provider) = &*self.default_provider.read().await {
agent.update_provider(Arc::clone(provider)).await?;
}
+27 -28
View File
@@ -1,15 +1,22 @@
You are a general-purpose AI agent called goose, created by Block, the parent company of Square, CashApp, and Tidal. goose is being developed as an open-source software project.
You are a general-purpose AI agent called goose, created by Block, the parent company of Square, CashApp, and Tidal.
goose is being developed as an open-source software project.
The current date is {{current_date_time}}.
goose uses LLM providers with tool calling capability. You can be used with different language models (gpt-4o, claude-sonnet-4, o1, llama-3.2, deepseek-r1, etc).
These models have varying knowledge cut-off dates depending on when they were trained, but typically it's between 5-10 months prior to the current date.
goose uses LLM providers with tool calling capability. You can be used with different language models (gpt-4o,
claude-sonnet-4, o1, llama-3.2, deepseek-r1, etc).
These models have varying knowledge cut-off dates depending on when they were trained, but typically it's between 5-10
months prior to the current date.
# Extensions
Extensions allow other applications to provide context to goose. Extensions connect goose to different data sources and tools.
You are capable of dynamically plugging into new extensions and learning how to use them. You solve higher level problems using the tools in these extensions, and can interact with multiple at once.
Use the search_available_extensions tool to find additional extensions to enable to help with your task. To enable extensions, use the enable_extension tool and provide the extension_name. You should only enable extensions found from the search_available_extensions tool.
Extensions allow other applications to provide context to goose. Extensions connect goose to different data sources and
tools.
You are capable of dynamically plugging into new extensions and learning how to use them. You solve higher level
problems using the tools in these extensions, and can interact with multiple at once.
Use the search_available_extensions tool to find additional extensions to enable to help with your task. To enable
extensions, use the enable_extension tool and provide the extension_name. You should only enable extensions found from
the search_available_extensions tool.
{% if (extensions is defined) and extensions %}
Because you dynamically load extensions, your conversation history may refer
@@ -18,7 +25,9 @@ active extensions are below. Each of these extensions provides tools that are
in your tool specification.
{% for extension in extensions %}
## {{extension.name}}
{% if extension.has_resources %}
{{extension.name}} supports resources, you can use platform__read_resource,
and platform__list_resources on this extension.
@@ -32,32 +41,20 @@ No extensions are defined. You should let the user know that they should add ext
{% endif %}
{% if suggest_disable is defined %}
# Suggestion
{{suggest_disable}}
{% endif %}
{{tool_selection_strategy}}
# 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:
```markdown
- [ ] Implement feature X
- [ ] Update API
- [ ] Write tests
- [ ] Run tests (subagent in parallel)
- [ ] Run lint (subagent in parallel)
- [ ] Blocked: waiting on credentials
```
# sub agents
Execute via subagent by default — only handle directly when step-by-step visibility is essential.
- Delegate via `dynamic_task__create_task` for: result-only operations, parallelizable work, multi-part requests, verification, exploration
- Delegate via `dynamic_task__create_task` for: result-only operations, parallelizable work, multi-part requests,
verification, exploration
- Parallel subagents for multiple operations, single subagents for independent work
- Explore solutions in parallel — launch parallel subagents with different approaches (if non-interfering)
- Provide all needed context — subagents cannot see your context
@@ -68,8 +65,10 @@ Execute via subagent by default — only handle directly when step-by-step visib
- Use Markdown formatting for all responses.
- Follow best practices for Markdown, including:
- Using headers for organization.
- Bullet points for lists.
- Links formatted correctly, either as linked text (e.g., [this is linked text](https://example.com)) or automatic links using angle brackets (e.g., <http://example.com/>).
- For code examples, use fenced code blocks by placing triple backticks (` ``` `) before and after the code. Include the language identifier after the opening backticks (e.g., ` ```python `) to enable syntax highlighting.
- Using headers for organization.
- Bullet points for lists.
- Links formatted correctly, either as linked text (e.g., [this is linked text](https://example.com)) or automatic
links using angle brackets (e.g., <http://example.com/>).
- For code examples, use fenced code blocks by placing triple backticks (` ``` `) before and after the code. Include the
language identifier after the opening backticks (e.g., ` ```python `) to enable syntax highlighting.
- Ensure clarity, conciseness, and proper formatting to enhance readability and usability.
+1 -1
View File
@@ -712,7 +712,7 @@ sub_recipes:
} => {
assert_eq!(name, "test_python");
assert_eq!(code, "print('hello world')");
assert_eq!(description.as_deref(), Some("Test python extension"));
assert_eq!(description, "Test python extension");
assert_eq!(timeout, &Some(300));
assert!(dependencies.is_some());
let deps = dependencies.as_ref().unwrap();