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
+1
View File
@@ -8,6 +8,7 @@ tokenizer_files/
*.log
tmp/
logs/
# Generated by Cargo
# will have compiled files and executables
debug/
+52 -93
View File
@@ -28,27 +28,6 @@ use std::error::Error;
// cursor-selected and cursor-unselected items.
const MULTISELECT_VISIBILITY_HINT: &str = "<";
fn get_display_name(extension_id: &str) -> String {
match extension_id {
"developer" => "Developer Tools".to_string(),
"computercontroller" => "Computer Controller".to_string(),
"autovisualiser" => "Auto Visualiser".to_string(),
"memory" => "Memory".to_string(),
"tutorial" => "Tutorial".to_string(),
"jetbrains" => "JetBrains".to_string(),
// Add other extensions as needed
_ => {
extension_id
.chars()
.next()
.unwrap_or_default()
.to_uppercase()
.collect::<String>()
+ &extension_id[1..]
}
}
}
pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
let config = Config::global();
@@ -128,14 +107,7 @@ pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
// This operation is best-effort and errors are ignored
ExtensionConfigManager::set(ExtensionEntry {
enabled: true,
config: ExtensionConfig::Builtin {
name: "developer".to_string(),
display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()),
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: Some(true),
description: None,
available_tools: Vec::new(),
},
config: ExtensionConfig::default(),
})?;
}
Ok(false) => {
@@ -747,35 +719,40 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
match extension_type {
// TODO we'll want a place to collect all these options, maybe just an enum in goose-mcp
"built-in" => {
let extension = cliclack::select("Which built-in extension would you like to enable?")
.item(
let extensions = vec![
(
"autovisualiser",
"Auto Visualiser",
"Data visualisation and UI generation tools",
)
.item(
),
(
"computercontroller",
"Computer Controller",
"controls for webscraping, file caching, and automations",
)
.item(
),
(
"developer",
"Developer Tools",
"Code editing and shell access",
)
.item("jetbrains", "JetBrains", "Connect to jetbrains IDEs")
.item(
),
("jetbrains", "JetBrains", "Connect to jetbrains IDEs"),
(
"memory",
"Memory",
"Tools to save and retrieve durable memories",
)
.item(
),
(
"tutorial",
"Tutorial",
"Access interactive tutorials and guides",
)
.interact()?
.to_string();
),
];
let mut select = cliclack::select("Which built-in extension would you like to enable?");
for (id, name, desc) in &extensions {
select = select.item(id, name, desc);
}
let extension = select.interact()?.to_string();
let timeout: u64 = cliclack::input("Please set the timeout for this tool (in secs):")
.placeholder(&goose::config::DEFAULT_EXTENSION_TIMEOUT.to_string())
@@ -785,7 +762,11 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
})
.interact()?;
let display_name = get_display_name(&extension);
let (display_name, description) = extensions
.iter()
.find(|(id, _, _)| id == &extension)
.map(|(_, name, desc)| (name.to_string(), desc.to_string()))
.unwrap_or_else(|| (extension.clone(), extension.clone()));
ExtensionConfigManager::set(ExtensionEntry {
enabled: true,
@@ -794,7 +775,7 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
display_name: Some(display_name),
timeout: Some(timeout),
bundled: Some(true),
description: None,
description,
available_tools: Vec::new(),
},
})?;
@@ -841,20 +822,13 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
let cmd = parts.next().unwrap_or("").to_string();
let args: Vec<String> = parts.map(String::from).collect();
let add_desc = cliclack::confirm("Would you like to add a description?").interact()?;
let description = if add_desc {
let desc = cliclack::input("Enter a description for this extension:")
.placeholder("Description")
.validate(|input: &String| match input.parse::<String>() {
Ok(_) => Ok(()),
Err(_) => Err("Please enter a valid description"),
})
.interact()?;
Some(desc)
} else {
None
};
let description = cliclack::input("Enter a description for this extension:")
.placeholder("Description")
.validate(|input: &String| match input.parse::<String>() {
Ok(_) => Ok(()),
Err(_) => Err("Please enter a valid description"),
})
.interact()?;
let add_env =
cliclack::confirm("Would you like to add environment variables?").interact()?;
@@ -945,21 +919,13 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
})
.interact()?;
let add_desc = cliclack::confirm("Would you like to add a description?").interact()?;
let description = if add_desc {
let desc = cliclack::input("Enter a description for this extension:")
.placeholder("Description")
.validate(|input: &String| match input.parse::<String>() {
Ok(_) => Ok(()),
Err(_) => Err("Please enter a valid description"),
})
.interact()?;
Some(desc)
} else {
None
};
let description = cliclack::input("Enter a description for this extension:")
.placeholder("Description")
.validate(|input: &String| match input.parse::<String>() {
Ok(_) => Ok(()),
Err(_) => Err("Please enter a valid description"),
})
.interact()?;
let add_env =
cliclack::confirm("Would you like to add environment variables?").interact()?;
@@ -1048,23 +1014,16 @@ pub fn configure_extensions_dialog() -> Result<(), Box<dyn Error>> {
})
.interact()?;
let add_desc = cliclack::confirm("Would you like to add a description?").interact()?;
let description = if add_desc {
let desc = cliclack::input("Enter a description for this extension:")
.placeholder("Description")
.validate(|input: &String| {
if input.trim().is_empty() {
Err("Please enter a valid description")
} else {
Ok(())
}
})
.interact()?;
Some(desc)
} else {
None
};
let description = cliclack::input("Enter a description for this extension:")
.placeholder("Description")
.validate(|input: &String| {
if input.trim().is_empty() {
Err("Please enter a valid description")
} else {
Ok(())
}
})
.interact()?;
let add_headers =
cliclack::confirm("Would you like to add custom headers?").interact()?;
@@ -1762,7 +1721,7 @@ pub async fn handle_openrouter_auth() -> Result<(), Box<dyn Error>> {
),
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: Some(true),
description: None,
description: "Developer extension".to_string(),
available_tools: Vec::new(),
},
}) {
@@ -1865,7 +1824,7 @@ pub async fn handle_tetrate_auth() -> Result<(), Box<dyn Error>> {
),
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: Some(true),
description: None,
description: "Developer extension".to_string(),
available_tools: Vec::new(),
},
}) {
@@ -55,6 +55,7 @@ fn extract_secrets_from_extensions(
ExtensionConfig::Stdio { name, env_keys, .. } => (name, env_keys),
ExtensionConfig::StreamableHttp { name, env_keys, .. } => (name, env_keys),
ExtensionConfig::Builtin { name, .. } => (name, &Vec::new()),
ExtensionConfig::Platform { name, .. } => (name, &Vec::new()),
ExtensionConfig::Frontend { name, .. } => (name, &Vec::new()),
ExtensionConfig::InlinePython { name, .. } => (name, &Vec::new()),
};
@@ -140,7 +141,7 @@ mod tests {
uri: "sse://example.com".to_string(),
envs: Envs::new(HashMap::new()),
env_keys: vec!["GITHUB_TOKEN".to_string(), "GITHUB_API_URL".to_string()],
description: None,
description: "github-mcp".to_string(),
timeout: None,
bundled: None,
available_tools: Vec::new(),
@@ -152,14 +153,14 @@ mod tests {
envs: Envs::new(HashMap::new()),
env_keys: vec!["SLACK_TOKEN".to_string()],
timeout: None,
description: None,
description: "slack-mcp".to_string(),
bundled: None,
available_tools: Vec::new(),
},
ExtensionConfig::Builtin {
name: "builtin-ext".to_string(),
display_name: None,
description: None,
description: "builtin-ext".to_string(),
timeout: None,
bundled: None,
available_tools: Vec::new(),
@@ -237,7 +238,7 @@ mod tests {
uri: "sse://example.com".to_string(),
envs: Envs::new(HashMap::new()),
env_keys: vec!["API_KEY".to_string()],
description: None,
description: "service-a".to_string(),
timeout: None,
bundled: None,
available_tools: Vec::new(),
@@ -249,7 +250,7 @@ mod tests {
envs: Envs::new(HashMap::new()),
env_keys: vec!["API_KEY".to_string()], // Same original key, different extension
timeout: None,
description: None,
description: "service-b".to_string(),
bundled: None,
available_tools: Vec::new(),
},
@@ -296,7 +297,7 @@ mod tests {
uri: "sse://parent.com".to_string(),
envs: Envs::new(HashMap::new()),
env_keys: vec!["PARENT_TOKEN".to_string()],
description: None,
description: "parent-ext".to_string(),
timeout: None,
bundled: None,
available_tools: Vec::new(),
@@ -203,7 +203,7 @@ where
ExtensionConfig::Builtin {
name: "".to_string(),
display_name: None,
description: None,
description: "".to_string(),
timeout: None,
bundled: None,
available_tools: vec![],
+8
View File
@@ -7,6 +7,7 @@ use goose::config::{Config, ExtensionConfig, ExtensionConfigManager};
use goose::providers::create;
use goose::recipe::{Response, SubRecipe};
use goose::agents::extension::PlatformExtensionContext;
use goose::session::SessionManager;
use rustyline::EditMode;
use std::collections::HashSet;
@@ -281,6 +282,13 @@ pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
Some(session.id)
};
agent
.extension_manager
.set_context(PlatformExtensionContext {
session_id: session_id.clone(),
})
.await;
if session_config.resume {
if let Some(session_id) = session_id.as_ref() {
// Read the session metadata from database
+4 -4
View File
@@ -207,7 +207,7 @@ impl CliSession {
args: parts.iter().map(|s| s.to_string()).collect(),
envs: Envs::new(envs),
env_keys: Vec::new(),
description: Some(goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string()),
description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(),
// TODO: should set timeout
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: None,
@@ -241,7 +241,7 @@ impl CliSession {
uri: extension_url,
envs: Envs::new(HashMap::new()),
env_keys: Vec::new(),
description: Some(goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string()),
description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(),
// TODO: should set timeout
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: None,
@@ -276,7 +276,7 @@ impl CliSession {
envs: Envs::new(HashMap::new()),
env_keys: Vec::new(),
headers: HashMap::new(),
description: Some(goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string()),
description: goose::config::DEFAULT_EXTENSION_DESCRIPTION.to_string(),
// TODO: should set timeout
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: None,
@@ -306,7 +306,7 @@ impl CliSession {
// TODO: should set a timeout
timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: None,
description: None,
description: name.trim().to_string(),
available_tools: Vec::new(),
};
self.agent
+5 -183
View File
@@ -2,106 +2,24 @@ use std::sync::Arc;
use crate::state::AppState;
use axum::{extract::State, routing::post, Json, Router};
use goose::agents::{extension::Envs, ExtensionConfig};
use goose::agents::ExtensionConfig;
use http::StatusCode;
use rmcp::model::Tool;
use serde::{Deserialize, Serialize};
use tracing;
/// Enum representing the different types of extension configuration requests.
#[derive(Deserialize)]
#[serde(tag = "type")]
enum ExtensionConfigRequest {
/// Server-Sent Events (SSE) extension.
#[serde(rename = "sse")]
Sse {
/// The name to identify this extension
name: String,
/// The URI endpoint for the SSE extension.
uri: String,
#[serde(default)]
/// Map of environment variable key to values.
envs: Envs,
/// List of environment variable keys. The server will fetch their values from the keyring.
#[serde(default)]
env_keys: Vec<String>,
timeout: Option<u64>,
},
/// Standard I/O (stdio) extension.
#[serde(rename = "stdio")]
Stdio {
/// The name to identify this extension
name: String,
/// The command to execute.
cmd: String,
/// Arguments for the command.
#[serde(default)]
args: Vec<String>,
#[serde(default)]
/// Map of environment variable key to values.
envs: Envs,
/// List of environment variable keys. The server will fetch their values from the keyring.
#[serde(default)]
env_keys: Vec<String>,
timeout: Option<u64>,
},
/// Built-in extension that is part of the goose binary.
#[serde(rename = "builtin")]
Builtin {
/// The name of the built-in extension.
name: String,
display_name: Option<String>,
timeout: Option<u64>,
},
/// Streamable HTTP extension using MCP Streamable HTTP specification.
#[serde(rename = "streamable_http")]
StreamableHttp {
/// The name to identify this extension
name: String,
/// The URI endpoint for the streamable HTTP extension.
uri: String,
#[serde(default)]
/// Map of environment variable key to values.
envs: Envs,
/// List of environment variable keys. The server will fetch their values from the keyring.
#[serde(default)]
env_keys: Vec<String>,
/// Custom headers to include in requests.
#[serde(default)]
headers: std::collections::HashMap<String, String>,
timeout: Option<u64>,
},
/// Frontend extension that provides tools to be executed by the frontend.
#[serde(rename = "frontend")]
Frontend {
/// The name to identify this extension
name: String,
/// The tools provided by this extension
tools: Vec<Tool>,
/// Optional instructions for using the tools
instructions: Option<String>,
},
}
/// Response structure for adding an extension.
///
/// - `error`: Indicates whether an error occurred (`true`) or not (`false`).
/// - `message`: Provides detailed error information when `error` is `true`.
#[derive(Serialize)]
struct ExtensionResponse {
error: bool,
message: Option<String>,
}
/// Request structure for adding an extension, combining session_id with the extension config
#[derive(Deserialize)]
struct AddExtensionRequest {
session_id: String,
#[serde(flatten)]
config: ExtensionConfigRequest,
config: ExtensionConfig,
}
/// Handler for adding a new extension configuration.
async fn add_extension(
State(state): State<Arc<AppState>>,
Json(request): Json<AddExtensionRequest>,
@@ -112,12 +30,9 @@ async fn add_extension(
request.session_id
);
let session_id = request.session_id.clone();
let extension_request = request.config;
// If this is a Stdio extension that uses npx, check for Node.js installation
#[cfg(target_os = "windows")]
if let ExtensionConfigRequest::Stdio { cmd, .. } = &extension_request {
if let ExtensionConfig::Stdio { cmd, .. } = &request.config {
if cmd.ends_with("npx.cmd") || cmd.ends_with("npx") {
// Check if Node.js is installed in standard locations
let node_exists = std::path::Path::new(r"C:\Program Files\nodejs\node.exe").exists()
@@ -169,101 +84,8 @@ async fn add_extension(
}
}
// Construct ExtensionConfig with Envs populated from keyring based on provided env_keys.
let extension_config: ExtensionConfig = match extension_request {
ExtensionConfigRequest::Sse {
name,
uri,
envs,
env_keys,
timeout,
} => ExtensionConfig::Sse {
name,
uri,
envs,
env_keys,
description: None,
timeout,
bundled: None,
available_tools: Vec::new(),
},
ExtensionConfigRequest::StreamableHttp {
name,
uri,
envs,
env_keys,
headers,
timeout,
} => ExtensionConfig::StreamableHttp {
name,
uri,
envs,
env_keys,
headers,
description: None,
timeout,
bundled: None,
available_tools: Vec::new(),
},
ExtensionConfigRequest::Stdio {
name,
cmd,
args,
envs,
env_keys,
timeout,
} => {
// TODO: We can uncomment once bugs are fixed. Check allowlist for Stdio extensions
// if !is_command_allowed(&cmd, &args) {
// return Ok(Json(ExtensionResponse {
// error: true,
// message: Some(format!(
// "Extension '{}' is not in the allowed extensions list. Command: '{} {}'. If you require access please ask your administrator to update the allowlist.",
// args.join(" "),
// cmd, args.join(" ")
// )),
// }));
// }
ExtensionConfig::Stdio {
name,
cmd,
args,
description: None,
envs,
env_keys,
timeout,
bundled: None,
available_tools: Vec::new(),
}
}
ExtensionConfigRequest::Builtin {
name,
display_name,
timeout,
} => ExtensionConfig::Builtin {
name,
display_name,
timeout,
bundled: None,
description: None,
available_tools: Vec::new(),
},
ExtensionConfigRequest::Frontend {
name,
tools,
instructions,
} => ExtensionConfig::Frontend {
name,
tools,
instructions,
bundled: None,
available_tools: Vec::new(),
},
};
let agent = state.get_agent_for_route(session_id).await?;
let response = agent.add_extension(extension_config).await;
let agent = state.get_agent_for_route(request.session_id).await?;
let response = agent.add_extension(request.config).await;
// Respond with the result.
match response {
+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();
+1 -1
View File
@@ -630,7 +630,7 @@ mod final_output_tool_tests {
};
let (_, result) = agent
.dispatch_tool_call(tool_call, "request_id".to_string(), None, &None)
.dispatch_tool_call(tool_call, "request_id".to_string(), None)
.await;
assert!(result.is_ok(), "Tool call should succeed");
@@ -176,7 +176,8 @@ mod tests {
"extensions": [
{
"type": "builtin",
"name": "developer"
"name": "developer",
"description": "developer"
}
]
});
@@ -421,6 +422,7 @@ mod tests {
{
"type": "stdio",
"name": "custom",
"description": "Custom stdio",
"cmd": "echo",
"args": ["test"]
}
+1 -1
View File
@@ -188,7 +188,7 @@ async fn test_replayed_session(
let envs = Envs::new(env);
let extension_config = ExtensionConfig::Stdio {
name: "test".to_string(),
description: Some("Test".to_string()),
description: "Test".to_string(),
cmd,
args,
envs,
+1 -1
View File
@@ -817,7 +817,7 @@ async fn test_schedule_tool_dispatch() {
};
let (request_id, result) = agent
.dispatch_tool_call(tool_call, "test_dispatch".to_string(), None, &None)
.dispatch_tool_call(tool_call, "test_dispatch".to_string(), None)
.await;
assert_eq!(request_id, "test_dispatch");
assert!(result.is_ok());
+49 -17
View File
@@ -2313,6 +2313,7 @@
"description": "Server-sent events client with a URI endpoint",
"required": [
"name",
"description",
"uri",
"type"
],
@@ -2325,12 +2326,10 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
"type": "string"
},
"env_keys": {
"type": "array",
@@ -2367,6 +2366,7 @@
"description": "Standard I/O client with command and arguments",
"required": [
"name",
"description",
"cmd",
"args",
"type"
@@ -2386,15 +2386,13 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"cmd": {
"type": "string"
},
"description": {
"type": "string",
"nullable": true
"type": "string"
},
"env_keys": {
"type": "array",
@@ -2425,9 +2423,10 @@
},
{
"type": "object",
"description": "Built-in extension that is part of the goose binary",
"description": "Built-in extension that is part of the bundled goose MCP server",
"required": [
"name",
"description",
"type"
],
"properties": {
@@ -2439,12 +2438,10 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
"type": "string"
},
"display_name": {
"type": "string",
@@ -2468,11 +2465,46 @@
}
}
},
{
"type": "object",
"description": "Platform extensions that have direct access to the agent etc and run in the agent process",
"required": [
"name",
"description",
"type"
],
"properties": {
"available_tools": {
"type": "array",
"items": {
"type": "string"
}
},
"bundled": {
"type": "boolean",
"nullable": true
},
"description": {
"type": "string"
},
"name": {
"type": "string",
"description": "The name used to identify this extension"
},
"type": {
"type": "string",
"enum": [
"platform"
]
}
}
},
{
"type": "object",
"description": "Streamable HTTP client with a URI endpoint using MCP Streamable HTTP specification",
"required": [
"name",
"description",
"uri",
"type"
],
@@ -2485,12 +2517,10 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"description": {
"type": "string",
"nullable": true
"type": "string"
},
"env_keys": {
"type": "array",
@@ -2533,6 +2563,7 @@
"description": "Frontend-provided tools that will be called through the frontend",
"required": [
"name",
"description",
"tools",
"type"
],
@@ -2545,9 +2576,11 @@
},
"bundled": {
"type": "boolean",
"description": "Whether this extension is bundled with goose",
"nullable": true
},
"description": {
"type": "string"
},
"instructions": {
"type": "string",
"description": "Instructions for how to use these tools",
@@ -2577,6 +2610,7 @@
"description": "Inline Python code that will be executed using uvx",
"required": [
"name",
"description",
"code",
"type"
],
@@ -2600,9 +2634,7 @@
"nullable": true
},
"description": {
"type": "string",
"description": "Description of what the extension does",
"nullable": true
"type": "string"
},
"name": {
"type": "string",
+15 -23
View File
@@ -192,11 +192,8 @@ export type ExtendPromptResponse = {
*/
export type ExtensionConfig = {
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
description?: string | null;
description: string;
env_keys?: Array<string>;
envs?: Envs;
/**
@@ -209,12 +206,9 @@ export type ExtensionConfig = {
} | {
args: Array<string>;
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
cmd: string;
description?: string | null;
description: string;
env_keys?: Array<string>;
envs?: Envs;
/**
@@ -225,11 +219,8 @@ export type ExtensionConfig = {
type: 'stdio';
} | {
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
description?: string | null;
description: string;
display_name?: string | null;
/**
* The name used to identify this extension
@@ -239,11 +230,17 @@ export type ExtensionConfig = {
type: 'builtin';
} | {
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
description?: string | null;
description: string;
/**
* The name used to identify this extension
*/
name: string;
type: 'platform';
} | {
available_tools?: Array<string>;
bundled?: boolean | null;
description: string;
env_keys?: Array<string>;
envs?: Envs;
headers?: {
@@ -258,10 +255,8 @@ export type ExtensionConfig = {
uri: string;
} | {
available_tools?: Array<string>;
/**
* Whether this extension is bundled with goose
*/
bundled?: boolean | null;
description: string;
/**
* Instructions for how to use these tools
*/
@@ -285,10 +280,7 @@ export type ExtensionConfig = {
* Python package dependencies required by this extension
*/
dependencies?: Array<string> | null;
/**
* Description of what the extension does
*/
description?: string | null;
description: string;
/**
* The name used to identify this extension
*/
@@ -38,11 +38,6 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
// Effect to handle message content changes and ensure persistence
useEffect(() => {
// Log content display for debugging
window.electron.logInfo(
`Displaying content for message: ${message.id} content: ${displayText}`
);
// If we're not editing, update the edit content to match the current message
if (!isEditing) {
setEditContent(displayText);
@@ -9,7 +9,6 @@ import {
createExtensionConfig,
ExtensionFormData,
extensionToFormData,
extractExtensionConfig,
getDefaultFormData,
} from './utils';
@@ -84,33 +83,25 @@ export default function ExtensionsSection({
await getExtensions(true); // Force refresh - this will update the context
}, [getExtensions]);
const handleExtensionToggle = async (extension: FixedExtensionEntry) => {
const handleExtensionToggle = async (extensionConfig: FixedExtensionEntry) => {
if (customToggle) {
await customToggle(extension);
await customToggle(extensionConfig);
return true;
}
// If extension is enabled, we are trying to toggle if off, otherwise on
const toggleDirection = extension.enabled ? 'toggleOff' : 'toggleOn';
const extensionConfig = extractExtensionConfig(extension);
const toggleDirection = extensionConfig.enabled ? 'toggleOff' : 'toggleOn';
// eslint-disable-next-line no-useless-catch
try {
await toggleExtension({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
toastOptions: { silent: false },
sessionId: sessionId,
});
await toggleExtension({
toggle: toggleDirection,
extensionConfig: extensionConfig,
addToConfig: addExtension,
toastOptions: { silent: false },
sessionId: sessionId,
});
await fetchExtensions(); // Refresh the list after successful toggle
return true; // Indicate success
} catch (error) {
// Don't refresh the extension list on failure - this allows our visual state rollback to work
// The actual state in the config hasn't changed anyway
throw error; // Re-throw to let the ExtensionItem component know it failed
}
await fetchExtensions();
return true;
};
const handleConfigureClick = (extension: FixedExtensionEntry) => {
@@ -49,6 +49,7 @@ describe('Agent API', () => {
describe('extensionApiCall', () => {
const mockExtensionConfig: ExtensionConfig = {
type: 'stdio',
description: 'description',
name: 'test-extension',
cmd: 'python',
args: ['script.py'],
@@ -237,6 +238,7 @@ describe('Agent API', () => {
const mockExtensionConfig: ExtensionConfig = {
type: 'stdio',
name: 'Test Extension',
description: 'Test description',
cmd: 'python',
args: ['script.py'],
};
@@ -286,6 +288,7 @@ describe('Agent API', () => {
const sseConfig: ExtensionConfig = {
type: 'sse',
name: 'SSE Extension',
description: 'Test description',
uri: 'http://localhost:8080/events',
};
@@ -8,7 +8,7 @@ type BundledExtension = {
id: string;
name: string;
display_name?: string;
description?: string;
description: string;
enabled: boolean;
type: 'builtin' | 'stdio' | 'sse';
cmd?: string;
@@ -59,18 +59,19 @@ export async function syncBundledExtensions(
switch (bundledExt.type) {
case 'builtin':
extConfig = {
name: bundledExt.name,
display_name: bundledExt.display_name,
type: bundledExt.type,
name: bundledExt.name,
description: bundledExt.description,
display_name: bundledExt.display_name,
timeout: bundledExt.timeout ?? 300,
bundled: true,
};
break;
case 'stdio':
extConfig = {
type: bundledExt.type,
name: bundledExt.name,
description: bundledExt.description,
type: bundledExt.type,
timeout: bundledExt.timeout,
cmd: bundledExt.cmd || '',
args: bundledExt.args || [],
@@ -81,9 +82,9 @@ export async function syncBundledExtensions(
break;
case 'sse':
extConfig = {
type: bundledExt.type,
name: bundledExt.name,
description: bundledExt.description,
type: bundledExt.type,
timeout: bundledExt.timeout,
uri: bundledExt.uri || '',
bundled: true,
@@ -25,6 +25,7 @@ describe('Extension Manager', () => {
const mockExtensionConfig = {
type: 'stdio' as const,
name: 'test-extension',
description: 'test-extension',
cmd: 'python',
args: ['script.py'],
timeout: 300,
@@ -80,79 +80,42 @@ export default function ExtensionList({
}
// Helper functions
// Helper function to get a friendly title from extension name
export function getFriendlyTitle(extension: FixedExtensionEntry): string {
let name = '';
// if it's a builtin, check if there's a display_name (old configs didn't have this field)
if (
'bundled' in extension &&
extension.bundled === true &&
'display_name' in extension &&
extension.display_name
) {
// If we have a display_name for a builtin, use it directly
return extension.display_name;
} else {
// For non-builtins or builtins without display_name
name = extension.name;
}
// Format the name to be more readable
const name = (extension.type === 'builtin' && extension.display_name) || extension.name;
return name
.split(/[-_]/) // Split on hyphens and underscores
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
export interface SubtitleParts {
description: string | null;
command: string | null;
function normalizeExtensionName(name: string): string {
return name.toLowerCase().replace(/\s+/g, '');
}
// Helper function to get a subtitle based on extension type and configuration
export function getSubtitle(config: ExtensionConfig): SubtitleParts {
if (config.type === 'builtin') {
// Find matching extension in the data
const extensionData = builtInExtensionsData.find(
(ext) =>
ext.name.toLowerCase().replace(/\s+/g, '') === config.name.toLowerCase().replace(/\s+/g, '')
);
return {
description: extensionData?.description || 'Built-in extension',
command: null,
};
}
export function getSubtitle(config: ExtensionConfig) {
switch (config.type) {
case 'builtin': {
const extensionData = builtInExtensionsData.find(
(ext) => normalizeExtensionName(ext.name) === normalizeExtensionName(config.name)
);
return {
description: extensionData?.description || config.description || 'Built-in extension',
command: null,
};
}
case 'sse':
case 'streamable_http': {
const prefix = `${config.type.toUpperCase().replace('_', ' ')} extension`;
return {
description: `${prefix}${config.description ? ': ' + config.description : ''}`,
command: config.uri || null,
};
}
if (config.type === 'stdio') {
// Only include command if it exists
const full_command = config.cmd
? combineCmdAndArgs(removeShims(config.cmd), config.args)
: null;
return {
description: config.description || null,
command: full_command,
};
default:
return {
description: config.description || null,
command: 'cmd' in config ? combineCmdAndArgs(removeShims(config.cmd), config.args) : null,
};
}
if (config.type === 'sse') {
const description = config.description
? `SSE extension: ${config.description}`
: 'SSE extension';
const command = config.uri || null;
return { description, command };
}
if (config.type === 'streamable_http') {
const description = config.description
? `Streamable HTTP extension: ${config.description}`
: 'Streamable HTTP extension';
const command = config.uri || null;
return { description, command };
}
return {
description: 'Unknown type of extension',
command: null,
};
}
@@ -6,7 +6,6 @@ import {
createExtensionConfig,
splitCmdAndArgs,
combineCmdAndArgs,
extractExtensionConfig,
replaceWithShims,
removeShims,
extractCommand,
@@ -153,6 +152,7 @@ describe('Extension Utils', () => {
const extension: FixedExtensionEntry = {
type: 'stdio',
name: 'legacy-extension',
description: 'legacy',
cmd: 'node',
args: ['app.js'],
enabled: true,
@@ -176,6 +176,7 @@ describe('Extension Utils', () => {
const extension: FixedExtensionEntry = {
type: 'builtin',
name: 'developer',
description: 'developer',
enabled: true,
};
@@ -183,7 +184,7 @@ describe('Extension Utils', () => {
expect(formData).toEqual({
name: 'developer',
description: '',
description: 'developer',
type: 'builtin',
cmd: undefined,
endpoint: undefined,
@@ -284,7 +285,7 @@ describe('Extension Utils', () => {
it('should create builtin extension config', () => {
const formData = {
name: 'developer',
description: '',
description: 'developer',
type: 'builtin' as const,
cmd: '',
endpoint: '',
@@ -299,6 +300,7 @@ describe('Extension Utils', () => {
expect(config).toEqual({
type: 'builtin',
name: 'developer',
description: 'developer',
timeout: 300,
});
});
@@ -340,30 +342,6 @@ describe('Extension Utils', () => {
});
});
describe('extractExtensionConfig', () => {
it('should extract extension config from fixed entry', () => {
const fixedEntry: FixedExtensionEntry = {
type: 'stdio',
name: 'test-extension',
cmd: 'python',
args: ['script.py'],
enabled: true,
timeout: 300,
};
const config = extractExtensionConfig(fixedEntry);
expect(config).toEqual({
type: 'stdio',
name: 'test-extension',
cmd: 'python',
args: ['script.py'],
enabled: true,
timeout: 300,
});
});
});
describe('replaceWithShims', () => {
beforeEach(() => {
mockElectron.getBinaryPath.mockImplementation((binary: string) => {
@@ -96,12 +96,11 @@ export function extensionToFormData(extension: FixedExtensionEntry): ExtensionFo
return {
name: extension.name || '',
description:
extension.type === 'stdio' || extension.type === 'sse' || extension.type === 'streamable_http'
? extension.description || ''
: '',
description: extension.description || '',
type:
extension.type === 'frontend' || extension.type === 'inline_python'
extension.type === 'frontend' ||
extension.type === 'inline_python' ||
extension.type === 'platform'
? 'stdio'
: extension.type,
cmd: extension.type === 'stdio' ? combineCmdAndArgs(extension.cmd, extension.args) : undefined,
@@ -166,6 +165,7 @@ export function createExtensionConfig(formData: ExtensionFormData): ExtensionCon
return {
type: formData.type,
name: formData.name,
description: formData.description,
timeout: formData.timeout,
};
}
@@ -186,17 +186,6 @@ export function combineCmdAndArgs(cmd: string, args: string[]): string {
return [cmd, ...args].join(' ');
}
/**
* Extracts the ExtensionConfig from a FixedExtensionEntry object
* @param fixedEntry - The FixedExtensionEntry object
* @returns The ExtensionConfig portion of the object
*/
export function extractExtensionConfig(fixedEntry: FixedExtensionEntry): ExtensionConfig {
// todo: enabled not used?
const { ...extensionConfig } = fixedEntry;
return extensionConfig;
}
export async function replaceWithShims(cmd: string) {
const binaryPathMap: Record<string, string> = {
goosed: await window.electron.getBinaryPath('goosed'),
@@ -56,6 +56,7 @@ export default function PermissionRulesModal({ isOpen, onClose }: PermissionRule
enabledExtensions.push({
name: 'platform',
type: 'builtin',
description: 'platform',
enabled: true,
});
// Sort extensions by name to maintain consistent order
@@ -50,9 +50,11 @@ export default function PermissionSettingsView({ onClose }: { onClose: () => voi
const extensionsList = await getExtensions(true); // Force refresh
// Filter out disabled extensions
const enabledExtensions = extensionsList.filter((extension) => extension.enabled);
// TODO(Douwe): this should really be a real extension:
enabledExtensions.push({
name: 'platform',
type: 'builtin',
description: 'platform',
enabled: true,
});
// Sort extensions by name to maintain consistent order
+7 -1
View File
@@ -20,6 +20,7 @@ describe('Recipe Validation', () => {
type: 'builtin',
name: 'developer',
display_name: 'Developer',
description: 'Developer',
timeout: 300,
bundled: true,
},
@@ -36,6 +37,7 @@ describe('Recipe Validation', () => {
{
type: 'builtin',
name: 'developer',
description: 'Developer',
},
],
};
@@ -58,6 +60,7 @@ describe('Recipe Validation', () => {
{
type: 'builtin',
name: 'developer',
description: 'developer',
},
],
};
@@ -75,6 +78,7 @@ describe('Recipe Validation', () => {
{
type: 'builtin',
name: 'developer',
description: 'developer',
},
],
};
@@ -180,9 +184,9 @@ describe('Recipe Validation', () => {
...validRecipe,
extensions: [
{
// Only required fields for builtin extension
type: 'builtin',
name: 'developer',
description: 'description',
},
],
};
@@ -550,10 +554,12 @@ describe('Recipe Validation', () => {
{
type: 'builtin',
name: 'developer',
description: 'developer',
},
{
type: 'builtin',
name: 'computercontroller',
description: 'computercontroller',
},
],
};
+1 -3
View File
@@ -3,7 +3,6 @@ import {
syncBundledExtensions,
addToAgentOnStartup,
} from '../components/settings/extensions';
import { extractExtensionConfig } from '../components/settings/extensions/utils';
import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext';
import { addSubRecipesToAgent } from '../recipe/add_sub_recipe_on_agent';
import {
@@ -272,8 +271,7 @@ export const initializeSystem = async (
options?.setIsExtensionsLoading?.(true);
const extensionLoadingPromises = enabledExtensions.map(async (extensionEntry) => {
const extensionConfig = extractExtensionConfig(extensionEntry);
const extensionLoadingPromises = enabledExtensions.map(async (extensionConfig) => {
const extensionName = extensionConfig.name;
try {