mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
@@ -8,6 +8,7 @@ tokenizer_files/
|
||||
*.log
|
||||
tmp/
|
||||
|
||||
logs/
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
|
||||
@@ -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![],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -61,12 +61,8 @@ use super::model_selector::autopilot::AutoPilot;
|
||||
use super::platform_tools;
|
||||
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
|
||||
use crate::agents::subagent_task_config::TaskConfig;
|
||||
use crate::agents::todo_tools::{
|
||||
todo_read_tool, todo_write_tool, TODO_READ_TOOL_NAME, TODO_WRITE_TOOL_NAME,
|
||||
};
|
||||
use crate::conversation::message::{Message, ToolRequest};
|
||||
use crate::session::extension_data::ExtensionState;
|
||||
use crate::session::{extension_data, SessionManager};
|
||||
use crate::session::SessionManager;
|
||||
|
||||
const DEFAULT_MAX_TURNS: u32 = 1000;
|
||||
|
||||
@@ -300,7 +296,6 @@ impl Agent {
|
||||
permission_check_result: &PermissionCheckResult,
|
||||
message_tool_response: Arc<Mutex<Message>>,
|
||||
cancel_token: Option<tokio_util::sync::CancellationToken>,
|
||||
session: &Option<SessionConfig>,
|
||||
) -> Result<Vec<(String, ToolStream)>> {
|
||||
let mut tool_futures: Vec<(String, ToolStream)> = Vec::new();
|
||||
|
||||
@@ -308,12 +303,7 @@ impl Agent {
|
||||
for request in &permission_check_result.approved {
|
||||
if let Ok(tool_call) = request.tool_call.clone() {
|
||||
let (req_id, tool_result) = self
|
||||
.dispatch_tool_call(
|
||||
tool_call,
|
||||
request.id.clone(),
|
||||
cancel_token.clone(),
|
||||
session,
|
||||
)
|
||||
.dispatch_tool_call(tool_call, request.id.clone(), cancel_token.clone())
|
||||
.await;
|
||||
|
||||
tool_futures.push((
|
||||
@@ -393,7 +383,6 @@ impl Agent {
|
||||
tool_call: CallToolRequestParam,
|
||||
request_id: String,
|
||||
cancellation_token: Option<CancellationToken>,
|
||||
session: &Option<SessionConfig>,
|
||||
) -> (String, Result<ToolCallResult, ErrorData>) {
|
||||
if tool_call.name == PLATFORM_MANAGE_SCHEDULE_TOOL_NAME {
|
||||
let arguments = tool_call
|
||||
@@ -521,93 +510,6 @@ impl Agent {
|
||||
"Frontend tool execution required".to_string(),
|
||||
None,
|
||||
)))
|
||||
} else if tool_call.name == TODO_READ_TOOL_NAME {
|
||||
// Handle task planner read tool
|
||||
let todo_content = if let Some(session_config) = session {
|
||||
SessionManager::get_session(&session_config.id, false)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|metadata| {
|
||||
extension_data::TodoState::from_extension_data(&metadata.extension_data)
|
||||
.map(|state| state.content)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
ToolCallResult::from(Ok(vec![Content::text(todo_content)]))
|
||||
} else if tool_call.name == TODO_WRITE_TOOL_NAME {
|
||||
// Handle task planner write tool
|
||||
let content = match tool_call.arguments {
|
||||
Some(args) => args
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
None => "".to_string(),
|
||||
};
|
||||
|
||||
// Character limit validation
|
||||
let char_count = content.chars().count();
|
||||
let max_chars = std::env::var("GOOSE_TODO_MAX_CHARS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(50_000);
|
||||
|
||||
if max_chars > 0 && char_count > max_chars {
|
||||
ToolCallResult::from(Err(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!(
|
||||
"Todo list too large: {} chars (max: {})",
|
||||
char_count, max_chars
|
||||
),
|
||||
None,
|
||||
)))
|
||||
} else if let Some(session_config) = session {
|
||||
match SessionManager::get_session(&session_config.id, false).await {
|
||||
Ok(mut session) => {
|
||||
let todo_state = extension_data::TodoState::new(content);
|
||||
if todo_state
|
||||
.to_extension_data(&mut session.extension_data)
|
||||
.is_ok()
|
||||
{
|
||||
match SessionManager::update_session(&session_config.id)
|
||||
.extension_data(session.extension_data)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(_) => ToolCallResult::from(Ok(vec![Content::text(format!(
|
||||
"Updated ({} chars)",
|
||||
char_count
|
||||
))])),
|
||||
Err(_) => ToolCallResult::from(Err(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
"Failed to update session metadata".to_string(),
|
||||
None,
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
ToolCallResult::from(Err(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
"Failed to serialize TODO state".to_string(),
|
||||
None,
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(_) => ToolCallResult::from(Err(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
"Failed to read session metadata".to_string(),
|
||||
None,
|
||||
))),
|
||||
}
|
||||
} else {
|
||||
ToolCallResult::from(Err(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
"TODO tools require an active session to persist data".to_string(),
|
||||
None,
|
||||
)))
|
||||
}
|
||||
} else if tool_call.name == ROUTER_LLM_SEARCH_TOOL_NAME {
|
||||
match self
|
||||
.tool_route_manager
|
||||
@@ -762,11 +664,9 @@ impl Agent {
|
||||
pub async fn add_extension(&self, extension: ExtensionConfig) -> ExtensionResult<()> {
|
||||
match &extension {
|
||||
ExtensionConfig::Frontend {
|
||||
name: _,
|
||||
tools,
|
||||
instructions,
|
||||
bundled: _,
|
||||
available_tools: _,
|
||||
..
|
||||
} => {
|
||||
// For frontend tools, just store them in the frontend_tools map
|
||||
let mut frontend_tools = self.frontend_tools.lock().await;
|
||||
@@ -834,10 +734,6 @@ impl Agent {
|
||||
platform_tools::manage_extensions_tool(),
|
||||
platform_tools::manage_schedule_tool(),
|
||||
]);
|
||||
|
||||
// Add task planner tools
|
||||
prefixed_tools.extend([todo_read_tool(), todo_write_tool()]);
|
||||
|
||||
// Dynamic task tool
|
||||
prefixed_tools.push(create_dynamic_task_tool());
|
||||
|
||||
@@ -1248,7 +1144,6 @@ impl Agent {
|
||||
&permission_check_result,
|
||||
message_tool_response.clone(),
|
||||
cancel_token.clone(),
|
||||
&session
|
||||
).await?;
|
||||
|
||||
let tool_futures_arc = Arc::new(Mutex::new(tool_futures));
|
||||
@@ -1756,21 +1651,6 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_todo_tools_integration() -> Result<()> {
|
||||
let agent = Agent::new();
|
||||
|
||||
// Test that task planner tools are listed
|
||||
let tools = agent.list_tools(None).await;
|
||||
|
||||
let todo_read = tools.iter().find(|tool| tool.name == TODO_READ_TOOL_NAME);
|
||||
let todo_write = tools.iter().find(|tool| tool.name == TODO_WRITE_TOOL_NAME);
|
||||
|
||||
assert!(todo_read.is_some(), "TODO read tool should be present");
|
||||
assert!(todo_write.is_some(), "TODO write tool should be present");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_tool_inspection_manager_has_all_inspectors() -> Result<()> {
|
||||
let agent = Agent::new();
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use crate::agents::todo_extension;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::agents::mcp_client::McpClientTrait;
|
||||
use crate::config;
|
||||
use crate::config::extensions::name_to_key;
|
||||
use crate::config::permission::PermissionLevel;
|
||||
use once_cell::sync::Lazy;
|
||||
use rmcp::model::Tool;
|
||||
use rmcp::service::ClientInitializeError;
|
||||
use rmcp::ServiceError as ClientError;
|
||||
@@ -8,10 +14,6 @@ use thiserror::Error;
|
||||
use tracing::warn;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::config;
|
||||
use crate::config::extensions::name_to_key;
|
||||
use crate::config::permission::PermissionLevel;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
#[error("process quit before initialization: stderr = {stderr}")]
|
||||
pub struct ProcessExit {
|
||||
@@ -32,6 +34,37 @@ impl ProcessExit {
|
||||
}
|
||||
}
|
||||
|
||||
pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>> =
|
||||
Lazy::new(|| {
|
||||
let mut map = HashMap::new();
|
||||
|
||||
map.insert(
|
||||
todo_extension::EXTENSION_NAME,
|
||||
PlatformExtensionDef {
|
||||
name: todo_extension::EXTENSION_NAME,
|
||||
description:
|
||||
"Enable a todo list for Goose so it can keep track of what it is doing",
|
||||
default_enabled: true,
|
||||
client_factory: |ctx| Box::new(todo_extension::TodoClient::new(ctx).unwrap()),
|
||||
},
|
||||
);
|
||||
|
||||
map
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformExtensionContext {
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformExtensionDef {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub default_enabled: bool,
|
||||
pub client_factory: fn(PlatformExtensionContext) -> Box<dyn McpClientTrait>,
|
||||
}
|
||||
|
||||
/// Errors from Extension operation
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ExtensionError {
|
||||
@@ -151,16 +184,15 @@ pub enum ExtensionConfig {
|
||||
Sse {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
description: String,
|
||||
uri: String,
|
||||
#[serde(default)]
|
||||
envs: Envs,
|
||||
#[serde(default)]
|
||||
env_keys: Vec<String>,
|
||||
description: Option<String>,
|
||||
// NOTE: set timeout to be optional for compatibility.
|
||||
// However, new configurations should include this field.
|
||||
timeout: Option<u64>,
|
||||
/// Whether this extension is bundled with goose
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
@@ -171,6 +203,7 @@ pub enum ExtensionConfig {
|
||||
Stdio {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
description: String,
|
||||
cmd: String,
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
@@ -178,22 +211,30 @@ pub enum ExtensionConfig {
|
||||
#[serde(default)]
|
||||
env_keys: Vec<String>,
|
||||
timeout: Option<u64>,
|
||||
description: Option<String>,
|
||||
/// Whether this extension is bundled with goose
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
available_tools: Vec<String>,
|
||||
},
|
||||
/// Built-in extension that is part of the goose binary
|
||||
/// Built-in extension that is part of the bundled goose MCP server
|
||||
#[serde(rename = "builtin")]
|
||||
Builtin {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
description: String,
|
||||
display_name: Option<String>, // needed for the UI
|
||||
description: Option<String>,
|
||||
timeout: Option<u64>,
|
||||
/// Whether this extension is bundled with goose
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
available_tools: Vec<String>,
|
||||
},
|
||||
/// Platform extensions that have direct access to the agent etc and run in the agent process
|
||||
#[serde(rename = "platform")]
|
||||
Platform {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
@@ -204,6 +245,7 @@ pub enum ExtensionConfig {
|
||||
StreamableHttp {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
description: String,
|
||||
uri: String,
|
||||
#[serde(default)]
|
||||
envs: Envs,
|
||||
@@ -211,11 +253,9 @@ pub enum ExtensionConfig {
|
||||
env_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
headers: HashMap<String, String>,
|
||||
description: Option<String>,
|
||||
// NOTE: set timeout to be optional for compatibility.
|
||||
// However, new configurations should include this field.
|
||||
timeout: Option<u64>,
|
||||
/// Whether this extension is bundled with goose
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
@@ -226,11 +266,11 @@ pub enum ExtensionConfig {
|
||||
Frontend {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
description: String,
|
||||
/// The tools provided by the frontend
|
||||
tools: Vec<Tool>,
|
||||
/// Instructions for how to use these tools
|
||||
instructions: Option<String>,
|
||||
/// Whether this extension is bundled with goose
|
||||
#[serde(default)]
|
||||
bundled: Option<bool>,
|
||||
#[serde(default)]
|
||||
@@ -241,10 +281,9 @@ pub enum ExtensionConfig {
|
||||
InlinePython {
|
||||
/// The name used to identify this extension
|
||||
name: String,
|
||||
description: String,
|
||||
/// The Python code to execute
|
||||
code: String,
|
||||
/// Description of what the extension does
|
||||
description: Option<String>,
|
||||
/// Timeout in seconds
|
||||
timeout: Option<u64>,
|
||||
/// Python package dependencies required by this extension
|
||||
@@ -260,7 +299,7 @@ impl Default for ExtensionConfig {
|
||||
Self::Builtin {
|
||||
name: config::DEFAULT_EXTENSION.to_string(),
|
||||
display_name: Some(config::DEFAULT_DISPLAY_NAME.to_string()),
|
||||
description: None,
|
||||
description: "default".to_string(),
|
||||
timeout: Some(config::DEFAULT_EXTENSION_TIMEOUT),
|
||||
bundled: Some(true),
|
||||
available_tools: Vec::new(),
|
||||
@@ -275,7 +314,7 @@ impl ExtensionConfig {
|
||||
uri: uri.into(),
|
||||
envs: Envs::default(),
|
||||
env_keys: Vec::new(),
|
||||
description: Some(description.into()),
|
||||
description: description.into(),
|
||||
timeout: Some(timeout.into()),
|
||||
bundled: None,
|
||||
available_tools: Vec::new(),
|
||||
@@ -294,7 +333,7 @@ impl ExtensionConfig {
|
||||
envs: Envs::default(),
|
||||
env_keys: Vec::new(),
|
||||
headers: HashMap::new(),
|
||||
description: Some(description.into()),
|
||||
description: description.into(),
|
||||
timeout: Some(timeout.into()),
|
||||
bundled: None,
|
||||
available_tools: Vec::new(),
|
||||
@@ -313,7 +352,7 @@ impl ExtensionConfig {
|
||||
args: vec![],
|
||||
envs: Envs::default(),
|
||||
env_keys: Vec::new(),
|
||||
description: Some(description.into()),
|
||||
description: description.into(),
|
||||
timeout: Some(timeout.into()),
|
||||
bundled: None,
|
||||
available_tools: Vec::new(),
|
||||
@@ -329,7 +368,7 @@ impl ExtensionConfig {
|
||||
Self::InlinePython {
|
||||
name: name.into(),
|
||||
code: code.into(),
|
||||
description: Some(description.into()),
|
||||
description: description.into(),
|
||||
timeout: Some(timeout.into()),
|
||||
dependencies: None,
|
||||
available_tools: Vec::new(),
|
||||
@@ -379,6 +418,7 @@ impl ExtensionConfig {
|
||||
Self::StreamableHttp { name, .. } => name,
|
||||
Self::Stdio { name, .. } => name,
|
||||
Self::Builtin { name, .. } => name,
|
||||
Self::Platform { name, .. } => name,
|
||||
Self::Frontend { name, .. } => name,
|
||||
Self::InlinePython { name, .. } => name,
|
||||
}
|
||||
@@ -400,6 +440,9 @@ impl ExtensionConfig {
|
||||
| Self::Builtin {
|
||||
available_tools, ..
|
||||
}
|
||||
| Self::Platform {
|
||||
available_tools, ..
|
||||
}
|
||||
| Self::InlinePython {
|
||||
available_tools, ..
|
||||
}
|
||||
@@ -427,6 +470,7 @@ impl std::fmt::Display for ExtensionConfig {
|
||||
write!(f, "Stdio({}: {} {})", name, cmd, args.join(" "))
|
||||
}
|
||||
ExtensionConfig::Builtin { name, .. } => write!(f, "Builtin({})", name),
|
||||
ExtensionConfig::Platform { name, .. } => write!(f, "Platform({})", name),
|
||||
ExtensionConfig::Frontend { name, tools, .. } => {
|
||||
write!(f, "Frontend({}: {} tools)", name, tools.len())
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, warn};
|
||||
|
||||
use super::extension::{ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, ToolInfo};
|
||||
use super::extension::{
|
||||
ExtensionConfig, ExtensionError, ExtensionInfo, ExtensionResult, PlatformExtensionContext,
|
||||
ToolInfo, PLATFORM_EXTENSIONS,
|
||||
};
|
||||
use super::tool_execution::ToolCallResult;
|
||||
use crate::agents::extension::{Envs, ProcessExit};
|
||||
use crate::agents::extension_malware_check;
|
||||
@@ -85,6 +88,7 @@ impl Extension {
|
||||
/// Manages goose extensions / MCP clients and their interactions
|
||||
pub struct ExtensionManager {
|
||||
extensions: Mutex<HashMap<String, Extension>>,
|
||||
context: Mutex<PlatformExtensionContext>,
|
||||
}
|
||||
|
||||
/// A flattened representation of a resource used by the agent to prepare inference
|
||||
@@ -234,9 +238,18 @@ impl ExtensionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
extensions: Mutex::new(HashMap::new()),
|
||||
context: Mutex::new(PlatformExtensionContext { session_id: None }),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_context(&self, context: PlatformExtensionContext) {
|
||||
*self.context.lock().await = context;
|
||||
}
|
||||
|
||||
pub async fn get_context(&self) -> PlatformExtensionContext {
|
||||
self.context.lock().await.clone()
|
||||
}
|
||||
|
||||
pub async fn supports_resources(&self) -> bool {
|
||||
self.extensions
|
||||
.lock()
|
||||
@@ -417,16 +430,33 @@ impl ExtensionManager {
|
||||
available_tools: _,
|
||||
} => {
|
||||
let cmd = std::env::current_exe()
|
||||
.expect("should find the current executable")
|
||||
.to_str()
|
||||
.expect("should resolve executable to string path")
|
||||
.to_string();
|
||||
.and_then(|path| {
|
||||
path.to_str().map(|s| s.to_string()).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Invalid UTF-8 in executable path",
|
||||
)
|
||||
})
|
||||
})
|
||||
.map_err(|e| {
|
||||
ExtensionError::ConfigError(format!(
|
||||
"Failed to resolve executable path: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let command = Command::new(cmd).configure(|command| {
|
||||
command.arg("mcp").arg(name);
|
||||
});
|
||||
let client = child_process_client(command, timeout).await?;
|
||||
Box::new(client)
|
||||
}
|
||||
ExtensionConfig::Platform { name, .. } => {
|
||||
let def = PLATFORM_EXTENSIONS.get(name.as_str()).ok_or_else(|| {
|
||||
ExtensionError::ConfigError(format!("Unknown platform extension: {}", name))
|
||||
})?;
|
||||
let context = self.get_context().await;
|
||||
(def.client_factory)(context)
|
||||
}
|
||||
ExtensionConfig::InlinePython {
|
||||
name,
|
||||
code,
|
||||
@@ -453,7 +483,11 @@ impl ExtensionManager {
|
||||
|
||||
Box::new(client)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
ExtensionConfig::Frontend { .. } => {
|
||||
return Err(ExtensionError::ConfigError(
|
||||
"Invalid extension type: Frontend extensions cannot be added as server extensions".to_string()
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let server_info = client.get_info().cloned();
|
||||
@@ -1006,35 +1040,22 @@ impl ExtensionManager {
|
||||
let config = extension.config.clone();
|
||||
let description = match &config {
|
||||
ExtensionConfig::Builtin {
|
||||
name, display_name, ..
|
||||
description,
|
||||
display_name,
|
||||
..
|
||||
} => {
|
||||
// For builtin extensions, use display name if available
|
||||
display_name
|
||||
.as_ref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| name.clone())
|
||||
}
|
||||
ExtensionConfig::Sse {
|
||||
description, name, ..
|
||||
}
|
||||
| ExtensionConfig::StreamableHttp {
|
||||
description, name, ..
|
||||
}
|
||||
| ExtensionConfig::Stdio {
|
||||
description, name, ..
|
||||
}
|
||||
| ExtensionConfig::InlinePython {
|
||||
description, name, ..
|
||||
} => {
|
||||
// For SSE/StreamableHttp/Stdio/InlinePython, use description if available
|
||||
description
|
||||
.as_ref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("Extension '{}'", name))
|
||||
}
|
||||
ExtensionConfig::Frontend { name, .. } => {
|
||||
format!("Frontend extension '{}'", name)
|
||||
if description.is_empty() {
|
||||
display_name.as_deref().unwrap_or("Built-in extension")
|
||||
} else {
|
||||
description
|
||||
}
|
||||
}
|
||||
ExtensionConfig::Platform { description, .. }
|
||||
| ExtensionConfig::Sse { description, .. }
|
||||
| ExtensionConfig::StreamableHttp { description, .. }
|
||||
| ExtensionConfig::Stdio { description, .. }
|
||||
| ExtensionConfig::Frontend { description, .. }
|
||||
| ExtensionConfig::InlinePython { description, .. } => description,
|
||||
};
|
||||
disabled_extensions.push(format!("- {} - {}", config.name(), description));
|
||||
}
|
||||
@@ -1110,7 +1131,7 @@ mod tests {
|
||||
let config = ExtensionConfig::Builtin {
|
||||
name: name.clone(),
|
||||
display_name: Some(name.clone()),
|
||||
description: None,
|
||||
description: "built-in".to_string(),
|
||||
timeout: None,
|
||||
bundled: None,
|
||||
available_tools,
|
||||
|
||||
@@ -20,7 +20,7 @@ pub mod subagent;
|
||||
pub mod subagent_execution_tool;
|
||||
pub mod subagent_handler;
|
||||
mod subagent_task_config;
|
||||
pub mod todo_tools;
|
||||
pub(crate) mod todo_extension;
|
||||
mod tool_execution;
|
||||
mod tool_route_manager;
|
||||
mod tool_router_index_manager;
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
use crate::agents::extension::PlatformExtensionContext;
|
||||
use crate::agents::mcp_client::{Error, McpClientTrait};
|
||||
use crate::session::extension_data::ExtensionState;
|
||||
use crate::session::{extension_data, SessionManager};
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use indoc::indoc;
|
||||
use rmcp::model::{
|
||||
CallToolResult, Content, GetPromptResult, Implementation, InitializeResult, JsonObject,
|
||||
ListPromptsResult, ListResourcesResult, ListToolsResult, ProtocolVersion, ReadResourceResult,
|
||||
ServerCapabilities, ServerNotification, Tool, ToolAnnotations, ToolsCapability,
|
||||
};
|
||||
use rmcp::object;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub static EXTENSION_NAME: &str = "todo";
|
||||
|
||||
pub struct TodoClient {
|
||||
info: InitializeResult,
|
||||
context: PlatformExtensionContext,
|
||||
fallback_content: tokio::sync::RwLock<String>,
|
||||
}
|
||||
|
||||
impl TodoClient {
|
||||
pub fn new(context: PlatformExtensionContext) -> Result<Self> {
|
||||
let info = InitializeResult {
|
||||
protocol_version: ProtocolVersion::V_2025_03_26,
|
||||
capabilities: ServerCapabilities {
|
||||
tools: Some(ToolsCapability {
|
||||
list_changed: Some(false),
|
||||
}),
|
||||
resources: None,
|
||||
prompts: None,
|
||||
completions: None,
|
||||
experimental: None,
|
||||
logging: None,
|
||||
},
|
||||
server_info: Implementation {
|
||||
name: EXTENSION_NAME.to_string(),
|
||||
title: Some("Todo".to_string()),
|
||||
version: "1.0.0".to_string(),
|
||||
icons: None,
|
||||
website_url: None,
|
||||
},
|
||||
instructions: Some(indoc! {r#"
|
||||
Task Management
|
||||
|
||||
Use todo_read and todo_write for tasks with 2+ steps, multiple files/components, or uncertain scope.
|
||||
|
||||
Workflow:
|
||||
- Start: read → write checklist
|
||||
- During: read → update progress
|
||||
- End: verify all complete
|
||||
|
||||
Warning: todo_write overwrites entirely; always todo_read first (skipping is an error)
|
||||
|
||||
Keep items short, specific, action-oriented. Not using the todo tools for complex tasks is an error.
|
||||
|
||||
Template:
|
||||
- [ ] Implement feature X
|
||||
- [ ] Update API
|
||||
- [ ] Write tests
|
||||
- [ ] Run tests (subagent in parallel)
|
||||
- [ ] Run lint (subagent in parallel)
|
||||
- [ ] Blocked: waiting on credentials
|
||||
"#}.to_string()),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
context,
|
||||
fallback_content: tokio::sync::RwLock::new(String::new()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_read_todo(&self) -> Result<Vec<Content>, String> {
|
||||
if let Some(session_id) = &self.context.session_id {
|
||||
match SessionManager::get_session(session_id, false).await {
|
||||
Ok(metadata) => {
|
||||
let content =
|
||||
extension_data::TodoState::from_extension_data(&metadata.extension_data)
|
||||
.map(|state| state.content)
|
||||
.unwrap_or_default();
|
||||
Ok(vec![Content::text(content)])
|
||||
}
|
||||
Err(_) => Ok(vec![Content::text(String::new())]),
|
||||
}
|
||||
} else {
|
||||
let content = self.fallback_content.read().await;
|
||||
Ok(vec![Content::text(content.clone())])
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_write_todo(
|
||||
&self,
|
||||
arguments: Option<JsonObject>,
|
||||
) -> Result<Vec<Content>, String> {
|
||||
let content = arguments
|
||||
.as_ref()
|
||||
.ok_or("Missing arguments")?
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required parameter: content")?
|
||||
.to_string();
|
||||
|
||||
let char_count = content.chars().count();
|
||||
let max_chars = std::env::var("GOOSE_TODO_MAX_CHARS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(50_000);
|
||||
|
||||
if max_chars > 0 && char_count > max_chars {
|
||||
return Err(format!(
|
||||
"Todo list too large: {} chars (max: {})",
|
||||
char_count, max_chars
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(session_id) = &self.context.session_id {
|
||||
match SessionManager::get_session(session_id, false).await {
|
||||
Ok(mut session) => {
|
||||
let todo_state = extension_data::TodoState::new(content);
|
||||
if todo_state
|
||||
.to_extension_data(&mut session.extension_data)
|
||||
.is_ok()
|
||||
{
|
||||
match SessionManager::update_session(session_id)
|
||||
.extension_data(session.extension_data)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(vec![Content::text(format!(
|
||||
"Updated ({} chars)",
|
||||
char_count
|
||||
))]),
|
||||
Err(_) => Err("Failed to update session metadata".to_string()),
|
||||
}
|
||||
} else {
|
||||
Err("Failed to serialize TODO state".to_string())
|
||||
}
|
||||
}
|
||||
Err(_) => Err("Failed to read session metadata".to_string()),
|
||||
}
|
||||
} else {
|
||||
let mut fallback = self.fallback_content.write().await;
|
||||
*fallback = content;
|
||||
Ok(vec![Content::text(format!(
|
||||
"Updated ({} chars)",
|
||||
char_count
|
||||
))])
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool::new(
|
||||
"todo_read".to_string(),
|
||||
indoc! {r#"
|
||||
Read the entire TODO file content.
|
||||
|
||||
This tool reads the complete TODO file and returns its content as a string.
|
||||
Use this to view current tasks, notes, and any other information stored in the TODO file.
|
||||
|
||||
The tool will return an error if the TODO file doesn't exist or cannot be read.
|
||||
"#}.to_string(),
|
||||
object!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
).annotate(ToolAnnotations {
|
||||
title: Some("Read TODO file".to_string()),
|
||||
read_only_hint: Some(true),
|
||||
destructive_hint: Some(false),
|
||||
idempotent_hint: Some(true),
|
||||
open_world_hint: Some(false),
|
||||
}),
|
||||
Tool::new(
|
||||
"todo_write".to_string(),
|
||||
indoc! {r#"
|
||||
Write or overwrite the entire TODO file content.
|
||||
|
||||
This tool replaces the complete TODO file content with the provided string.
|
||||
Use this to update tasks, add new items, or reorganize the TODO file.
|
||||
|
||||
WARNING: This operation completely replaces the file content. Make sure to include
|
||||
all content you want to keep, not just the changes.
|
||||
|
||||
The tool will create the TODO file if it doesn't exist, or overwrite it if it does.
|
||||
Returns an error if the file cannot be written due to permissions or other I/O issues.
|
||||
"#}.to_string(),
|
||||
object!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The TODO list content to save"
|
||||
}
|
||||
},
|
||||
"required": ["content"]
|
||||
}),
|
||||
).annotate(ToolAnnotations {
|
||||
title: Some("Write TODO file".to_string()),
|
||||
read_only_hint: Some(false),
|
||||
destructive_hint: Some(true),
|
||||
idempotent_hint: Some(false),
|
||||
open_world_hint: Some(false),
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClientTrait for TodoClient {
|
||||
async fn list_resources(
|
||||
&self,
|
||||
_next_cursor: Option<String>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<ListResourcesResult, Error> {
|
||||
Err(Error::TransportClosed)
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
_uri: &str,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<ReadResourceResult, Error> {
|
||||
Err(Error::TransportClosed)
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_next_cursor: Option<String>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<ListToolsResult, Error> {
|
||||
Ok(ListToolsResult {
|
||||
tools: Self::get_tools(),
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
name: &str,
|
||||
arguments: Option<JsonObject>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<CallToolResult, Error> {
|
||||
let content = match name {
|
||||
"todo_read" => self.handle_read_todo().await,
|
||||
"todo_write" => self.handle_write_todo(arguments).await,
|
||||
_ => Err(format!("Unknown tool: {}", name)),
|
||||
};
|
||||
|
||||
match content {
|
||||
Ok(content) => Ok(CallToolResult::success(content)),
|
||||
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
|
||||
"Error: {}",
|
||||
error
|
||||
))])),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_prompts(
|
||||
&self,
|
||||
_next_cursor: Option<String>,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<ListPromptsResult, Error> {
|
||||
Err(Error::TransportClosed)
|
||||
}
|
||||
|
||||
async fn get_prompt(
|
||||
&self,
|
||||
_name: &str,
|
||||
_arguments: Value,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<GetPromptResult, Error> {
|
||||
Err(Error::TransportClosed)
|
||||
}
|
||||
|
||||
async fn subscribe(&self) -> mpsc::Receiver<ServerNotification> {
|
||||
mpsc::channel(1).1
|
||||
}
|
||||
|
||||
fn get_info(&self) -> Option<&InitializeResult> {
|
||||
Some(&self.info)
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
use indoc::indoc;
|
||||
use rmcp::model::{Tool, ToolAnnotations};
|
||||
use rmcp::object;
|
||||
|
||||
/// Tool name constant for reading task planner content
|
||||
pub const TODO_READ_TOOL_NAME: &str = "todo__read";
|
||||
|
||||
/// Tool name constant for writing task planner content
|
||||
pub const TODO_WRITE_TOOL_NAME: &str = "todo__write";
|
||||
|
||||
/// Creates a tool for reading task planner content.
|
||||
///
|
||||
/// This tool reads the entire task planner file content as a string.
|
||||
/// It is marked as read-only and safe to use repeatedly.
|
||||
///
|
||||
/// # Returns
|
||||
/// A configured `Tool` instance for reading task planner content
|
||||
pub fn todo_read_tool() -> Tool {
|
||||
Tool::new(
|
||||
TODO_READ_TOOL_NAME.to_string(),
|
||||
indoc! {r#"
|
||||
Read the entire TODO file content.
|
||||
|
||||
This tool reads the complete TODO file and returns its content as a string.
|
||||
Use this to view current tasks, notes, and any other information stored in the TODO file.
|
||||
|
||||
The tool will return an error if the TODO file doesn't exist or cannot be read.
|
||||
"#}
|
||||
.to_string(),
|
||||
object!({
|
||||
"type": "object",
|
||||
"required": [],
|
||||
"properties": {}
|
||||
}),
|
||||
)
|
||||
.annotate(ToolAnnotations {
|
||||
title: Some("Read TODO content".to_string()),
|
||||
read_only_hint: Some(true),
|
||||
destructive_hint: Some(false),
|
||||
idempotent_hint: Some(true),
|
||||
open_world_hint: Some(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a tool for writing task planner content.
|
||||
///
|
||||
/// This tool writes or overwrites the entire task planner file with new content.
|
||||
/// It replaces the complete file content with the provided string.
|
||||
///
|
||||
/// # Returns
|
||||
/// A configured `Tool` instance for writing task planner content
|
||||
pub fn todo_write_tool() -> Tool {
|
||||
Tool::new(
|
||||
TODO_WRITE_TOOL_NAME.to_string(),
|
||||
indoc! {r#"
|
||||
Write or overwrite the entire TODO file content.
|
||||
|
||||
This tool replaces the complete TODO file content with the provided string.
|
||||
Use this to update tasks, add new items, or reorganize the TODO file.
|
||||
|
||||
WARNING: This operation completely replaces the file content. Make sure to include
|
||||
all content you want to keep, not just the changes.
|
||||
|
||||
The tool will create the TODO file if it doesn't exist, or overwrite it if it does.
|
||||
Returns an error if the file cannot be written due to permissions or other I/O issues.
|
||||
"#}
|
||||
.to_string(),
|
||||
object!({
|
||||
"type": "object",
|
||||
"required": ["content"],
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The complete content to write to the TODO file. This will replace all existing content."
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.annotate(ToolAnnotations {
|
||||
title: Some("Write TODO content".to_string()),
|
||||
read_only_hint: Some(false),
|
||||
destructive_hint: Some(true), // It overwrites the entire file
|
||||
idempotent_hint: Some(true), // Writing the same content multiple times has the same effect
|
||||
open_world_hint: Some(false),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod unit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_todo_read_tool_creation() {
|
||||
let tool = todo_read_tool();
|
||||
|
||||
// Verify tool name
|
||||
assert_eq!(tool.name, TODO_READ_TOOL_NAME);
|
||||
|
||||
// Verify description exists and is not empty
|
||||
assert!(tool.description.is_some());
|
||||
let description = tool.description.as_ref().unwrap();
|
||||
assert!(!description.is_empty());
|
||||
|
||||
// Verify input schema
|
||||
let schema = &tool.input_schema;
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert_eq!(schema["required"].as_array().unwrap().len(), 0);
|
||||
|
||||
// Verify annotations
|
||||
let annotations = tool.annotations.as_ref().unwrap();
|
||||
assert_eq!(annotations.title, Some("Read TODO content".to_string()));
|
||||
assert_eq!(annotations.read_only_hint, Some(true));
|
||||
assert_eq!(annotations.destructive_hint, Some(false));
|
||||
assert_eq!(annotations.idempotent_hint, Some(true));
|
||||
assert_eq!(annotations.open_world_hint, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_todo_write_tool_creation() {
|
||||
let tool = todo_write_tool();
|
||||
|
||||
// Verify tool name
|
||||
assert_eq!(tool.name, TODO_WRITE_TOOL_NAME);
|
||||
|
||||
// Verify description exists and is not empty
|
||||
assert!(tool.description.is_some());
|
||||
let description = tool.description.as_ref().unwrap();
|
||||
assert!(!description.is_empty());
|
||||
|
||||
// Verify input schema
|
||||
let schema = &tool.input_schema;
|
||||
assert_eq!(schema["type"], "object");
|
||||
|
||||
// Verify required parameters
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert_eq!(required.len(), 1);
|
||||
assert_eq!(required[0], "content");
|
||||
|
||||
// Verify properties
|
||||
assert!(schema["properties"]["content"].is_object());
|
||||
assert_eq!(schema["properties"]["content"]["type"], "string");
|
||||
|
||||
// Verify annotations
|
||||
let annotations = tool.annotations.as_ref().unwrap();
|
||||
assert_eq!(annotations.title, Some("Write TODO content".to_string()));
|
||||
assert_eq!(annotations.read_only_hint, Some(false));
|
||||
assert_eq!(annotations.destructive_hint, Some(true));
|
||||
assert_eq!(annotations.idempotent_hint, Some(true));
|
||||
assert_eq!(annotations.open_world_hint, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_name_constants() {
|
||||
// Verify the constants follow the naming pattern
|
||||
assert!(TODO_READ_TOOL_NAME.starts_with("todo__"));
|
||||
assert!(TODO_WRITE_TOOL_NAME.starts_with("todo__"));
|
||||
assert_eq!(TODO_READ_TOOL_NAME, "todo__read");
|
||||
assert_eq!(TODO_WRITE_TOOL_NAME, "todo__write");
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,7 @@ impl Agent {
|
||||
}
|
||||
|
||||
if confirmation.permission == Permission::AllowOnce || confirmation.permission == Permission::AlwaysAllow {
|
||||
// Clone tool_call to avoid moving it
|
||||
let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone(), &None).await;
|
||||
let (req_id, tool_result) = self.dispatch_tool_call(tool_call.clone(), request.id.clone(), cancellation_token.clone()).await;
|
||||
let mut futures = tool_futures.lock().await;
|
||||
|
||||
futures.push((req_id, match tool_result {
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user