Agent extension api (#5281)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-10-29 11:16:06 -04:00
committed by GitHub
parent c45ce49430
commit e7ca2e3bf7
22 changed files with 435 additions and 992 deletions
+4
View File
@@ -348,6 +348,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::resume_agent,
super::routes::agent::get_tools,
super::routes::agent::update_from_session,
super::routes::agent::agent_add_extension,
super::routes::agent::agent_remove_extension,
super::routes::agent::update_agent_provider,
super::routes::agent::update_router_tool_selector,
super::routes::reply::confirm_permission,
@@ -486,6 +488,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::agent::StartAgentRequest,
super::routes::agent::ResumeAgentRequest,
super::routes::agent::UpdateFromSessionRequest,
super::routes::agent::AddExtensionRequest,
super::routes::agent::RemoveExtensionRequest,
super::routes::setup::SetupResponse,
))
)]
+97
View File
@@ -11,6 +11,7 @@ use axum::{
};
use goose::config::PermissionManager;
use goose::agents::ExtensionConfig;
use goose::config::Config;
use goose::model::ModelConfig;
use goose::prompt_template::render_global_file;
@@ -70,6 +71,18 @@ pub struct ResumeAgentRequest {
load_model_and_extensions: bool,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct AddExtensionRequest {
session_id: String,
config: ExtensionConfig,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct RemoveExtensionRequest {
name: String,
session_id: String,
}
#[utoipa::path(
post,
path = "/agent/start",
@@ -451,6 +464,88 @@ async fn update_router_tool_selector(
))
}
#[utoipa::path(
post,
path = "/agent/add_extension",
request_body = AddExtensionRequest,
responses(
(status = 200, description = "Extension added", body = String),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 424, description = "Agent not initialized"),
(status = 500, description = "Internal server error")
)
)]
async fn agent_add_extension(
State(state): State<Arc<AppState>>,
Json(request): Json<AddExtensionRequest>,
) -> Result<StatusCode, ErrorResponse> {
// If this is a Stdio extension that uses npx, check for Node.js installation
#[cfg(target_os = "windows")]
if let ExtensionConfig::Stdio { cmd, .. } = &request.config {
if cmd.ends_with("npx.cmd") || cmd.ends_with("npx") {
let node_exists = std::path::Path::new(r"C:\Program Files\nodejs\node.exe").exists()
|| std::path::Path::new(r"C:\Program Files (x86)\nodejs\node.exe").exists();
if !node_exists {
let cmd_path = std::path::Path::new(&cmd);
let script_dir = cmd_path.parent().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
let install_script = script_dir.join("install-node.cmd");
if install_script.exists() {
eprintln!("Installing Node.js...");
let output = std::process::Command::new(&install_script)
.arg("https://nodejs.org/dist/v23.10.0/node-v23.10.0-x64.msi")
.output()
.map_err(|e| {
eprintln!("Failed to run Node.js installer: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !output.status.success() {
return Err(ErrorResponse::internal(format!(
"Failed to install Node.js: {}",
String::from_utf8_lossy(&output.stderr)
)));
}
eprintln!("Node.js installation completed");
} else {
return Err(ErrorResponse::internal(format!(
"Node.js not detected and no installer script not found at: {}",
install_script.display()
)));
}
}
}
}
let agent = state.get_agent(request.session_id).await?;
agent
.add_extension(request.config)
.await
.map_err(|e| ErrorResponse::internal(format!("Failed to add extension: {}", e)))?;
Ok(StatusCode::OK)
}
#[utoipa::path(
post,
path = "/agent/remove_extension",
request_body = RemoveExtensionRequest,
responses(
(status = 200, description = "Extension removed", body = String),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 424, description = "Agent not initialized"),
(status = 500, description = "Internal server error")
)
)]
async fn agent_remove_extension(
State(state): State<Arc<AppState>>,
Json(request): Json<RemoveExtensionRequest>,
) -> Result<StatusCode, ErrorResponse> {
let agent = state.get_agent(request.session_id).await?;
agent.remove_extension(&request.name).await?;
Ok(StatusCode::OK)
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
@@ -462,5 +557,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
post(update_router_tool_selector),
)
.route("/agent/update_from_session", post(update_from_session))
.route("/agent/add_extension", post(agent_add_extension))
.route("/agent/remove_extension", post(agent_remove_extension))
.with_state(state)
}
+15
View File
@@ -13,6 +13,15 @@ pub struct ErrorResponse {
pub status: StatusCode,
}
impl ErrorResponse {
pub(crate) fn internal(message: impl Into<String>) -> Self {
Self {
message: message.into(),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
impl IntoResponse for ErrorResponse {
fn into_response(self) -> Response {
let body = Json(serde_json::json!({
@@ -22,3 +31,9 @@ impl IntoResponse for ErrorResponse {
(self.status, body).into_response()
}
}
impl From<anyhow::Error> for ErrorResponse {
fn from(err: anyhow::Error) -> Self {
Self::internal(err.to_string())
}
}
-139
View File
@@ -1,139 +0,0 @@
use std::sync::Arc;
use crate::state::AppState;
use axum::{extract::State, routing::post, Json, Router};
use goose::agents::ExtensionConfig;
use http::StatusCode;
use serde::{Deserialize, Serialize};
use tracing;
#[derive(Serialize)]
struct ExtensionResponse {
error: bool,
message: Option<String>,
}
#[derive(Deserialize)]
struct AddExtensionRequest {
session_id: String,
#[serde(flatten)]
config: ExtensionConfig,
}
async fn add_extension(
State(state): State<Arc<AppState>>,
Json(request): Json<AddExtensionRequest>,
) -> Result<Json<ExtensionResponse>, StatusCode> {
// Log the request for debugging
tracing::info!(
"Received extension request for session: {}",
request.session_id
);
// If this is a Stdio extension that uses npx, check for Node.js installation
#[cfg(target_os = "windows")]
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()
|| std::path::Path::new(r"C:\Program Files (x86)\nodejs\node.exe").exists();
if !node_exists {
// Get the directory containing npx.cmd
let cmd_path = std::path::Path::new(&cmd);
let script_dir = cmd_path.parent().ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
// Run the Node.js installer script
let install_script = script_dir.join("install-node.cmd");
if install_script.exists() {
eprintln!("Installing Node.js...");
let output = std::process::Command::new(&install_script)
.arg("https://nodejs.org/dist/v23.10.0/node-v23.10.0-x64.msi")
.output()
.map_err(|e| {
eprintln!("Failed to run Node.js installer: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
if !output.status.success() {
eprintln!(
"Failed to install Node.js: {}",
String::from_utf8_lossy(&output.stderr)
);
return Ok(Json(ExtensionResponse {
error: true,
message: Some(format!(
"Failed to install Node.js: {}",
String::from_utf8_lossy(&output.stderr)
)),
}));
}
eprintln!("Node.js installation completed");
} else {
eprintln!(
"Node.js installer script not found at: {}",
install_script.display()
);
return Ok(Json(ExtensionResponse {
error: true,
message: Some("Node.js installer script not found".to_string()),
}));
}
}
}
}
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 {
Ok(_) => Ok(Json(ExtensionResponse {
error: false,
message: None,
})),
Err(e) => {
eprintln!("Failed to add extension configuration: {:?}", e);
Ok(Json(ExtensionResponse {
error: true,
message: Some(format!(
"Failed to add extension configuration, error: {:?}",
e
)),
}))
}
}
}
#[derive(Deserialize)]
struct RemoveExtensionRequest {
name: String,
session_id: String,
}
/// Handler for removing an extension by name
async fn remove_extension(
State(state): State<Arc<AppState>>,
Json(request): Json<RemoveExtensionRequest>,
) -> Result<Json<ExtensionResponse>, StatusCode> {
let agent = state.get_agent_for_route(request.session_id).await?;
match agent.remove_extension(&request.name).await {
Ok(_) => Ok(Json(ExtensionResponse {
error: false,
message: None,
})),
Err(e) => Ok(Json(ExtensionResponse {
error: true,
message: Some(format!("Failed to remove extension: {:?}", e)),
})),
}
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/extensions/add", post(add_extension))
.route("/extensions/remove", post(remove_extension))
.with_state(state)
}
+1 -2
View File
@@ -2,7 +2,6 @@ pub mod agent;
pub mod audio;
pub mod config_management;
pub mod errors;
pub mod extension;
pub mod recipe;
pub mod recipe_utils;
pub mod reply;
@@ -11,6 +10,7 @@ pub mod session;
pub mod setup;
pub mod status;
pub mod utils;
use std::sync::Arc;
use axum::Router;
@@ -22,7 +22,6 @@ pub fn configure(state: Arc<crate::state::AppState>) -> Router {
.merge(reply::routes(state.clone()))
.merge(agent::routes(state.clone()))
.merge(audio::routes(state.clone()))
.merge(extension::routes(state.clone()))
.merge(config_management::routes(state.clone()))
.merge(recipe::routes(state.clone()))
.merge(session::routes(state.clone()))
-1
View File
@@ -49,7 +49,6 @@ impl AppState {
self.agent_manager.get_or_create_agent(session_id).await
}
/// Get agent for route handlers - always uses Interactive mode and converts any error to 500
pub async fn get_agent_for_route(
&self,
session_id: String,