Files
tkmind_go/crates/goose-server/src/routes/agent.rs
T
Rodolfo Olivieri adc0e221fc feat: gate telemetry behind Cargo feature flags (#8119)
Signed-off-by: Rodolfo Olivieri <rolivier@redhat.com>
Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
2026-03-26 16:33:43 +00:00

1343 lines
42 KiB
Rust

use crate::routes::errors::ErrorResponse;
use crate::routes::recipe_utils::{
apply_recipe_to_agent, build_recipe_with_parameter_values, load_recipe_by_id, validate_recipe,
};
use crate::state::AppState;
use axum::response::IntoResponse;
use axum::{
extract::{Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use goose::agents::{Container, ExtensionLoadResult};
use goose::goose_apps::{fetch_mcp_apps, GooseApp, McpAppCache};
use base64::Engine;
use goose::agents::reply_parts::is_tool_visible_to_app;
use goose::agents::ExtensionConfig;
use goose::config::resolve_extensions_for_new_session;
use goose::config::{Config, GooseMode};
use goose::model::ModelConfig;
use goose::providers::create;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use goose::session::session_manager::SessionType;
use goose::session::{EnabledExtensionsState, ExtensionState, Session};
use goose::{
agents::{extension::ToolInfo, extension_manager::get_parameter_names},
config::permission::PermissionLevel,
};
use rmcp::model::CallToolRequestParams;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tracing::{error, warn};
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateFromSessionRequest {
session_id: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateProviderRequest {
provider: String,
model: Option<String>,
session_id: String,
context_limit: Option<usize>,
request_params: Option<std::collections::HashMap<String, serde_json::Value>>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateSessionRequest {
session_id: String,
goose_mode: Option<String>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct GetToolsQuery {
extension_name: Option<String>,
session_id: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct StartAgentRequest {
working_dir: String,
#[serde(default)]
recipe: Option<Recipe>,
#[serde(default)]
recipe_id: Option<String>,
#[serde(default)]
recipe_deeplink: Option<String>,
#[serde(default)]
extension_overrides: Option<Vec<ExtensionConfig>>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct StopAgentRequest {
session_id: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct RestartAgentRequest {
session_id: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateWorkingDirRequest {
session_id: String,
working_dir: String,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ResumeAgentRequest {
session_id: String,
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,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct SetContainerRequest {
session_id: String,
container_id: Option<String>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ReadResourceRequest {
session_id: String,
extension_name: String,
uri: String,
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReadResourceResponse {
uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
mime_type: Option<String>,
text: String,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
meta: Option<serde_json::Map<String, Value>>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct CallToolRequest {
session_id: String,
name: String,
arguments: Value,
}
/// Ref-only alias so utoipa emits `$ref: "#/components/schemas/ContentBlock"`.
/// The actual schema is registered via `derive_utoipa!(RawContent as ContentBlockSchema => "ContentBlock")`.
#[allow(dead_code)]
pub enum ContentBlock {}
impl<'s> utoipa::ToSchema<'s> for ContentBlock {
fn schema() -> (
&'s str,
utoipa::openapi::RefOr<utoipa::openapi::schema::Schema>,
) {
// Delegate to the auto-generated schema
crate::openapi::ContentBlockSchema::schema()
}
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct CallToolResponse {
#[schema(value_type = Vec<ContentBlock>)]
content: Vec<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
structured_content: Option<Value>,
is_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "_meta")]
_meta: Option<Value>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct ResumeAgentResponse {
pub session: Session,
#[serde(skip_serializing_if = "Option::is_none")]
pub extension_results: Option<Vec<ExtensionLoadResult>>,
}
#[derive(Serialize, utoipa::ToSchema)]
pub struct RestartAgentResponse {
pub extension_results: Vec<ExtensionLoadResult>,
}
#[utoipa::path(
post,
path = "/agent/start",
request_body = StartAgentRequest,
responses(
(status = 200, description = "Agent started successfully", body = Session),
(status = 400, description = "Bad request", body = ErrorResponse),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 500, description = "Internal server error", body = ErrorResponse)
)
)]
#[allow(clippy::too_many_lines)]
async fn start_agent(
State(state): State<Arc<AppState>>,
Json(payload): Json<StartAgentRequest>,
) -> Result<Json<Session>, ErrorResponse> {
#[cfg(feature = "telemetry")]
goose::posthog::set_session_context("desktop", false);
let StartAgentRequest {
working_dir,
recipe,
recipe_id,
recipe_deeplink,
extension_overrides,
} = payload;
let original_recipe = if let Some(deeplink) = recipe_deeplink {
match recipe_deeplink::decode(&deeplink) {
Ok(recipe) => Some(recipe),
Err(err) => {
error!("Failed to decode recipe deeplink: {}", err);
#[cfg(feature = "telemetry")]
goose::posthog::emit_error("recipe_deeplink_decode_failed", &err.to_string());
return Err(ErrorResponse {
message: err.to_string(),
status: StatusCode::BAD_REQUEST,
});
}
}
} else if let Some(id) = recipe_id {
match load_recipe_by_id(state.as_ref(), &id).await {
Ok(recipe) => Some(recipe),
Err(err) => return Err(err),
}
} else {
recipe
};
if let Some(ref recipe) = original_recipe {
if let Err(err) = validate_recipe(recipe) {
return Err(ErrorResponse {
message: err.message,
status: err.status,
});
}
}
let name = "New Chat".to_string();
let manager = state.session_manager();
let config = Config::global();
let current_mode = config.get_goose_mode().unwrap_or_default();
let mut session = manager
.create_session(
PathBuf::from(&working_dir),
name,
SessionType::User,
current_mode,
)
.await
.map_err(|err| {
error!("Failed to create session: {}", err);
#[cfg(feature = "telemetry")]
goose::posthog::emit_error("session_create_failed", &err.to_string());
ErrorResponse {
message: format!("Failed to create session: {}", err),
status: StatusCode::BAD_REQUEST,
}
})?;
let recipe_extensions = original_recipe
.as_ref()
.and_then(|r| r.extensions.as_deref());
let extensions_to_use =
resolve_extensions_for_new_session(recipe_extensions, extension_overrides);
let mut extension_data = session.extension_data.clone();
let extensions_state = EnabledExtensionsState::new(extensions_to_use);
if let Err(e) = extensions_state.to_extension_data(&mut extension_data) {
tracing::warn!("Failed to initialize session with extensions: {}", e);
} else {
manager
.update(&session.id)
.extension_data(extension_data.clone())
.apply()
.await
.map_err(|err| {
error!("Failed to save initial extension state: {}", err);
ErrorResponse {
message: format!("Failed to save initial extension state: {}", err),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
}
if let Some(recipe) = original_recipe {
let mut update = manager.update(&session.id).recipe(Some(recipe.clone()));
if let Some(ref settings) = recipe.settings {
if let Some(ref provider) = settings.goose_provider {
update = update.provider_name(provider);
if let Some(ref model) = settings.goose_model {
if let Ok(model_config) = ModelConfig::new(model) {
update = update.model_config(model_config);
}
}
}
}
update.apply().await.map_err(|err| {
error!("Failed to update session with recipe: {}", err);
ErrorResponse {
message: format!("Failed to update session with recipe: {}", err),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
}
// Refetch session to get all updates
session = manager
.get_session(&session.id, false)
.await
.map_err(|err| {
error!("Failed to get updated session: {}", err);
ErrorResponse {
message: format!("Failed to get updated session: {}", err),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
// Eagerly start loading extensions in the background
let session_for_spawn = session.clone();
let state_for_spawn = state.clone();
let session_id_for_task = session.id.clone();
let task = tokio::spawn(async move {
match state_for_spawn
.get_agent(session_for_spawn.id.clone())
.await
{
Ok(agent) => {
let results = agent.load_extensions_from_session(&session_for_spawn).await;
tracing::debug!(
"Background extension loading completed for session {}",
session_for_spawn.id
);
results
}
Err(e) => {
tracing::warn!(
"Failed to create agent for background extension loading: {}",
e
);
vec![]
}
}
});
state
.set_extension_loading_task(session_id_for_task, task)
.await;
Ok(Json(session))
}
#[utoipa::path(
post,
path = "/agent/resume",
request_body = ResumeAgentRequest,
responses(
(status = 200, description = "Agent started successfully", body = ResumeAgentResponse),
(status = 400, description = "Bad request - invalid working directory"),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 500, description = "Internal server error")
)
)]
async fn resume_agent(
State(state): State<Arc<AppState>>,
Json(payload): Json<ResumeAgentRequest>,
) -> Result<Json<ResumeAgentResponse>, ErrorResponse> {
#[cfg(feature = "telemetry")]
goose::posthog::set_session_context("desktop", true);
let session = state
.session_manager()
.get_session(&payload.session_id, true)
.await
.map_err(|err| {
error!("Failed to resume session {}: {}", payload.session_id, err);
#[cfg(feature = "telemetry")]
goose::posthog::emit_error("session_resume_failed", &err.to_string());
ErrorResponse {
message: format!("Failed to resume session: {}", err),
status: StatusCode::NOT_FOUND,
}
})?;
let (extension_results, session) = if payload.load_model_and_extensions {
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await
.map_err(|code| ErrorResponse {
message: "Failed to get agent for route".into(),
status: code,
})?;
let provider_changed = agent
.restore_provider_from_session(&session)
.await
.map_err(|e| ErrorResponse {
message: e.to_string(),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let session = if provider_changed {
state
.session_manager()
.get_session(&payload.session_id, true)
.await
.map_err(|err| ErrorResponse {
message: format!("Failed to re-fetch session: {}", err),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?
} else {
session
};
let extension_results =
if let Some(results) = state.take_extension_loading_task(&payload.session_id).await {
tracing::debug!(
"Using background extension loading results for session {}",
payload.session_id
);
state
.remove_extension_loading_task(&payload.session_id)
.await;
results
} else {
tracing::debug!(
"No background task found, loading extensions for session {}",
payload.session_id
);
agent.load_extensions_from_session(&session).await
};
(Some(extension_results), session)
} else {
(None, session)
};
Ok(Json(ResumeAgentResponse {
session,
extension_results,
}))
}
#[utoipa::path(
post,
path = "/agent/update_from_session",
request_body = UpdateFromSessionRequest,
responses(
(status = 200, description = "Update agent from session data successfully"),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 424, description = "Agent not initialized"),
),
)]
async fn update_from_session(
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateFromSessionRequest>,
) -> Result<StatusCode, ErrorResponse> {
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await
.map_err(|status| ErrorResponse {
message: format!("Failed to get agent: {}", status),
status,
})?;
let session = state
.session_manager()
.get_session(&payload.session_id, false)
.await
.map_err(|err| ErrorResponse {
message: format!("Failed to get session: {}", err),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
if let Some(recipe) = session.recipe {
match build_recipe_with_parameter_values(
&recipe,
session.user_recipe_values.unwrap_or_default(),
)
.await
{
Ok(Some(recipe)) => {
if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await {
agent
.extend_system_prompt("recipe".to_string(), prompt)
.await;
}
}
Ok(None) => {
// Recipe has missing parameters
}
Err(e) => {
return Err(ErrorResponse {
message: e.to_string(),
status: StatusCode::INTERNAL_SERVER_ERROR,
});
}
}
}
Ok(StatusCode::OK)
}
#[utoipa::path(
get,
path = "/agent/tools",
params(
("extension_name" = Option<String>, Query, description = "Optional extension name to filter tools"),
("session_id" = String, Query, description = "Required session ID to scope tools to a specific session")
),
responses(
(status = 200, description = "Tools retrieved successfully", body = Vec<ToolInfo>),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 424, description = "Agent not initialized"),
(status = 500, description = "Internal server error")
)
)]
async fn get_tools(
State(state): State<Arc<AppState>>,
Query(query): Query<GetToolsQuery>,
) -> Result<Json<Vec<ToolInfo>>, StatusCode> {
let session_id = query.session_id;
let agent = state.get_agent_for_route(session_id.clone()).await?;
let goose_mode = agent.goose_mode().await;
let permission_manager = agent.config.permission_manager.clone();
let mut tools: Vec<ToolInfo> = agent
.list_tools(&session_id, query.extension_name)
.await
.into_iter()
.map(|tool| {
let permission = permission_manager
.get_user_permission(&tool.name)
.or_else(|| {
if goose_mode == GooseMode::SmartApprove {
permission_manager.get_smart_approve_permission(&tool.name)
} else if goose_mode == GooseMode::Approve {
Some(PermissionLevel::AskBefore)
} else {
None
}
});
ToolInfo::new(
&tool.name,
tool.description
.as_ref()
.map(|d| d.as_ref())
.unwrap_or_default(),
get_parameter_names(&tool),
permission,
)
.with_input_schema(serde_json::Value::Object(
tool.input_schema.as_ref().clone(),
))
})
.collect::<Vec<ToolInfo>>();
tools.sort_by(|a, b| a.name.cmp(&b.name));
Ok(Json(tools))
}
#[utoipa::path(
post,
path = "/agent/update_provider",
request_body = UpdateProviderRequest,
responses(
(status = 200, description = "Provider updated successfully"),
(status = 400, description = "Bad request - missing or invalid parameters"),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 424, description = "Agent not initialized"),
(status = 500, description = "Internal server error")
)
)]
async fn update_agent_provider(
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateProviderRequest>,
) -> Result<(), impl IntoResponse> {
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await
.map_err(|e| (e, "No agent for session id".to_owned()))?;
let config = Config::global();
let model = match payload.model.or_else(|| config.get_goose_model().ok()) {
Some(m) => m,
None => {
return Err((StatusCode::BAD_REQUEST, "No model specified".to_owned()));
}
};
let model_config = ModelConfig::new(&model)
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid model config: {}", e),
)
})?
.with_canonical_limits(&payload.provider)
.with_context_limit(payload.context_limit)
.with_request_params(payload.request_params);
let extensions =
EnabledExtensionsState::for_session(state.session_manager(), &payload.session_id, config)
.await;
let new_provider = create(&payload.provider, model_config, extensions)
.await
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Failed to create {} provider: {}", &payload.provider, e),
)
})?;
agent
.update_provider(new_provider, &payload.session_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update provider: {}", e),
)
})?;
// Propagate session mode to the new provider
let mode = agent.goose_mode().await;
agent
.update_goose_mode(mode, &payload.session_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to propagate mode to provider: {}", e),
)
})?;
Ok(())
}
#[utoipa::path(
post,
path = "/agent/update_session",
request_body = UpdateSessionRequest,
responses(
(status = 200, description = "Session updated"),
(status = 400, description = "Invalid request"),
(status = 500, description = "Internal error")
)
)]
async fn update_session(
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateSessionRequest>,
) -> Result<(), (StatusCode, String)> {
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await
.map_err(|e| (e, "No agent for session id".to_owned()))?;
if let Some(mode_str) = payload.goose_mode {
let mode: GooseMode = mode_str.parse().map_err(|_| {
(
StatusCode::BAD_REQUEST,
format!("Invalid mode: {}", mode_str),
)
})?;
agent
.update_goose_mode(mode, &payload.session_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to update mode: {}", e),
)
})?;
}
Ok(())
}
#[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> {
let extension_name = request.config.name();
let agent = state.get_agent(request.session_id.clone()).await?;
agent
.add_extension(request.config, &request.session_id)
.await
.map_err(|e| {
#[cfg(feature = "telemetry")]
goose::posthog::emit_error(
"extension_add_failed",
&format!("{}: {}", extension_name, 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.clone()).await?;
agent
.remove_extension(&request.name, &request.session_id)
.await
.map_err(|e| {
error!("Failed to remove extension: {}", e);
ErrorResponse {
message: format!("Failed to remove extension: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
Ok(StatusCode::OK)
}
#[utoipa::path(
post,
path = "/agent/set_container",
request_body = SetContainerRequest,
responses(
(status = 200, description = "Container set successfully"),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 424, description = "Agent not initialized"),
(status = 500, description = "Internal server error")
)
)]
async fn set_container(
State(state): State<Arc<AppState>>,
Json(request): Json<SetContainerRequest>,
) -> Result<StatusCode, ErrorResponse> {
let agent = state.get_agent(request.session_id.clone()).await?;
let container = request.container_id.map(Container::new);
agent.set_container(container).await;
Ok(StatusCode::OK)
}
#[utoipa::path(
post,
path = "/agent/stop",
request_body = StopAgentRequest,
responses(
(status = 200, description = "Agent stopped successfully", body = String),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
)
)]
async fn stop_agent(
State(state): State<Arc<AppState>>,
Json(payload): Json<StopAgentRequest>,
) -> Result<StatusCode, ErrorResponse> {
let session_id = payload.session_id;
state
.agent_manager
.remove_session(&session_id)
.await
.map_err(|e| ErrorResponse {
message: format!("Failed to stop agent for session {}: {}", session_id, e),
status: StatusCode::NOT_FOUND,
})?;
Ok(StatusCode::OK)
}
async fn restart_agent_internal(
state: &Arc<AppState>,
session_id: &str,
session: &Session,
) -> Result<Vec<ExtensionLoadResult>, ErrorResponse> {
// Remove existing agent (ignore error if not found)
let _ = state.agent_manager.remove_session(session_id).await;
let agent = state
.get_agent_for_route(session_id.to_string())
.await
.map_err(|code| ErrorResponse {
message: "Failed to create new agent during restart".into(),
status: code,
})?;
let provider_future = agent.restore_provider_from_session(session);
let extensions_future = agent.load_extensions_from_session(session);
let (provider_result, extension_results) = tokio::join!(provider_future, extensions_future);
provider_result.map_err(|e| ErrorResponse {
message: e.to_string(),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
if let Some(ref recipe) = session.recipe {
match build_recipe_with_parameter_values(
recipe,
session.user_recipe_values.clone().unwrap_or_default(),
)
.await
{
Ok(Some(recipe)) => {
if let Some(prompt) = apply_recipe_to_agent(&agent, &recipe, true).await {
agent
.extend_system_prompt("recipe".to_string(), prompt)
.await;
}
}
Ok(None) => {
// Recipe has missing parameters
}
Err(e) => {
return Err(ErrorResponse {
message: e.to_string(),
status: StatusCode::INTERNAL_SERVER_ERROR,
});
}
}
}
Ok(extension_results)
}
#[utoipa::path(
post,
path = "/agent/restart",
request_body = RestartAgentRequest,
responses(
(status = 200, description = "Agent restarted successfully", body = RestartAgentResponse),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
)
)]
async fn restart_agent(
State(state): State<Arc<AppState>>,
Json(payload): Json<RestartAgentRequest>,
) -> Result<Json<RestartAgentResponse>, ErrorResponse> {
let session_id = payload.session_id.clone();
let session = state
.session_manager()
.get_session(&session_id, false)
.await
.map_err(|err| {
error!("Failed to get session during restart: {}", err);
ErrorResponse {
message: format!("Failed to get session: {}", err),
status: StatusCode::NOT_FOUND,
}
})?;
let extension_results = restart_agent_internal(&state, &session_id, &session).await?;
Ok(Json(RestartAgentResponse { extension_results }))
}
#[utoipa::path(
post,
path = "/agent/update_working_dir",
request_body = UpdateWorkingDirRequest,
responses(
(status = 200, description = "Working directory updated and agent restarted successfully"),
(status = 400, description = "Bad request - invalid directory path"),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
)
)]
async fn update_working_dir(
State(state): State<Arc<AppState>>,
Json(payload): Json<UpdateWorkingDirRequest>,
) -> Result<StatusCode, ErrorResponse> {
let session_id = payload.session_id.clone();
let working_dir = payload.working_dir.trim();
if working_dir.is_empty() {
return Err(ErrorResponse {
message: "Working directory cannot be empty".into(),
status: StatusCode::BAD_REQUEST,
});
}
let path = PathBuf::from(working_dir);
if !path.exists() || !path.is_dir() {
return Err(ErrorResponse {
message: "Invalid directory path".into(),
status: StatusCode::BAD_REQUEST,
});
}
// Update the session's working directory
state
.session_manager()
.update(&session_id)
.working_dir(path)
.apply()
.await
.map_err(|e| {
error!("Failed to update session working directory: {}", e);
ErrorResponse {
message: format!("Failed to update working directory: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
// Get the updated session and restart the agent
let session = state
.session_manager()
.get_session(&session_id, false)
.await
.map_err(|err| {
error!("Failed to get session after working dir update: {}", err);
ErrorResponse {
message: format!("Failed to get session: {}", err),
status: StatusCode::NOT_FOUND,
}
})?;
restart_agent_internal(&state, &session_id, &session).await?;
Ok(StatusCode::OK)
}
async fn ensure_extensions_loaded(state: &AppState, session_id: &str) {
if let Some(_results) = state.take_extension_loading_task(session_id).await {
tracing::debug!(
"Awaited background extension loading for session {} before serving request",
session_id
);
state.remove_extension_loading_task(session_id).await;
}
}
#[utoipa::path(
post,
path = "/agent/read_resource",
request_body = ReadResourceRequest,
responses(
(status = 200, description = "Resource read successfully", body = ReadResourceResponse),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 424, description = "Agent not initialized"),
(status = 404, description = "Resource not found"),
(status = 500, description = "Internal server error")
)
)]
async fn read_resource(
State(state): State<Arc<AppState>>,
Json(payload): Json<ReadResourceRequest>,
) -> Result<Json<ReadResourceResponse>, StatusCode> {
use rmcp::model::ResourceContents;
ensure_extensions_loaded(&state, &payload.session_id).await;
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await?;
let read_result = agent
.extension_manager
.read_resource(
&payload.session_id,
&payload.uri,
&payload.extension_name,
CancellationToken::default(),
)
.await
.map_err(|e| {
tracing::error!(
"read_resource failed for session={}, uri={}, extension={}: {:?}",
payload.session_id,
payload.uri,
payload.extension_name,
e
);
StatusCode::INTERNAL_SERVER_ERROR
})?;
let content = read_result
.contents
.into_iter()
.next()
.ok_or(StatusCode::NOT_FOUND)?;
let (uri, mime_type, text, meta) = match content {
ResourceContents::TextResourceContents {
uri,
mime_type,
text,
meta,
} => (uri, mime_type, text, meta),
ResourceContents::BlobResourceContents {
uri,
mime_type,
blob,
meta,
} => {
let decoded = match base64::engine::general_purpose::STANDARD.decode(&blob) {
Ok(bytes) => {
String::from_utf8(bytes).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
}
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
(uri, mime_type, decoded, meta)
}
};
let meta_map = meta.map(|m| m.0);
Ok(Json(ReadResourceResponse {
uri,
mime_type,
text,
meta: meta_map,
}))
}
#[utoipa::path(
post,
path = "/agent/call_tool",
request_body = CallToolRequest,
responses(
(status = 200, description = "Resource read successfully", body = CallToolResponse),
(status = 401, description = "Unauthorized - invalid secret key"),
(status = 424, description = "Agent not initialized"),
(status = 404, description = "Resource not found"),
(status = 500, description = "Internal server error")
)
)]
async fn call_tool(
State(state): State<Arc<AppState>>,
Json(payload): Json<CallToolRequest>,
) -> Result<Json<CallToolResponse>, StatusCode> {
ensure_extensions_loaded(&state, &payload.session_id).await;
let agent = state
.get_agent_for_route(payload.session_id.clone())
.await?;
// Check app-side visibility: reject calls to tools that exclude "app"
let tools = agent.list_tools(&payload.session_id, None).await;
if let Some(tool) = tools.iter().find(|t| *t.name == payload.name) {
if !is_tool_visible_to_app(tool) {
warn!(
tool = %payload.name,
"Rejected app call to model-only tool"
);
return Err(StatusCode::FORBIDDEN);
}
}
let arguments = match payload.arguments {
Value::Object(map) => Some(map),
_ => None,
};
let tool_call = {
let mut params = CallToolRequestParams::new(payload.name);
if let Some(args) = arguments {
params = params.with_arguments(args);
}
params
};
let ctx = goose::agents::ToolCallContext::new(payload.session_id.clone(), None, None);
let tool_result = agent
.extension_manager
.dispatch_tool_call(&ctx, tool_call, CancellationToken::default())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let result = tool_result
.result
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let content = result
.content
.into_iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(CallToolResponse {
content,
structured_content: result.structured_content,
is_error: result.is_error.unwrap_or(false),
_meta: result.meta.and_then(|m| serde_json::to_value(m).ok()),
}))
}
#[derive(Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
pub struct ListAppsRequest {
session_id: Option<String>,
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ListAppsResponse {
pub apps: Vec<GooseApp>,
}
#[utoipa::path(
get,
path = "/agent/list_apps",
params(
ListAppsRequest
),
responses(
(status = 200, description = "List of apps retrieved successfully", body = ListAppsResponse),
(status = 401, description = "Unauthorized - Invalid or missing API key", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse),
),
security(
("api_key" = [])
),
tag = "Agent"
)]
async fn list_apps(
State(state): State<Arc<AppState>>,
Query(params): Query<ListAppsRequest>,
) -> Result<Json<ListAppsResponse>, ErrorResponse> {
let cache = McpAppCache::new().ok();
let Some(session_id) = params.session_id else {
let apps = cache
.as_ref()
.and_then(|c| c.list_apps().ok())
.unwrap_or_default();
return Ok(Json(ListAppsResponse { apps }));
};
let agent = state
.get_agent_for_route(session_id.clone())
.await
.map_err(|status| ErrorResponse {
message: "Failed to get agent".to_string(),
status,
})?;
let apps = fetch_mcp_apps(&agent.extension_manager, &session_id)
.await
.map_err(|e| ErrorResponse {
message: format!("Failed to list apps: {}", e.message),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
if let Some(cache) = cache.as_ref() {
let active_extensions: HashSet<String> = apps
.iter()
.flat_map(|app| app.mcp_servers.iter().cloned())
.collect();
for extension_name in active_extensions {
if let Err(e) = cache.delete_extension_apps(&extension_name) {
warn!(
"Failed to clean cache for extension {}: {}",
extension_name, e
);
}
}
for app in &apps {
if let Err(e) = cache.store_app(app) {
warn!("Failed to cache app {}: {}", app.resource.name, e);
}
}
}
Ok(Json(ListAppsResponse { apps }))
}
#[utoipa::path(
get,
path = "/agent/export_app/{name}",
params(
("name" = String, Path, description = "Name of the app to export")
),
responses(
(status = 200, description = "App HTML exported successfully", body = String),
(status = 404, description = "App not found", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse),
),
security(
("api_key" = [])
),
tag = "Agent"
)]
async fn export_app(
axum::extract::Path(name): axum::extract::Path<String>,
) -> Result<impl IntoResponse, ErrorResponse> {
let cache = McpAppCache::new().map_err(|e| ErrorResponse {
message: format!("Failed to access app cache: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let apps = cache.list_apps().map_err(|e| ErrorResponse {
message: format!("Failed to list apps: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let app = apps
.into_iter()
.find(|a| a.resource.name == name)
.ok_or_else(|| ErrorResponse {
message: format!("App '{}' not found", name),
status: StatusCode::NOT_FOUND,
})?;
let html = app.to_html().map_err(|e| ErrorResponse {
message: format!("Failed to generate HTML: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
Ok(html)
}
#[derive(Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportAppRequest {
pub html: String,
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ImportAppResponse {
pub name: String,
pub message: String,
}
#[utoipa::path(
post,
path = "/agent/import_app",
request_body = ImportAppRequest,
responses(
(status = 201, description = "App imported successfully", body = ImportAppResponse),
(status = 400, description = "Bad request - Invalid HTML", body = ErrorResponse),
(status = 500, description = "Internal server error", body = ErrorResponse),
),
security(
("api_key" = [])
),
tag = "Agent"
)]
async fn import_app(
Json(body): Json<ImportAppRequest>,
) -> Result<(StatusCode, Json<ImportAppResponse>), ErrorResponse> {
let cache = McpAppCache::new().map_err(|e| ErrorResponse {
message: format!("Failed to access app cache: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let mut app = GooseApp::from_html(&body.html).map_err(|e| ErrorResponse {
message: format!("Invalid Goose App HTML: {}", e),
status: StatusCode::BAD_REQUEST,
})?;
let original_name = app.resource.name.clone();
let mut counter = 1;
let existing_apps = cache.list_apps().unwrap_or_default();
let existing_names: HashSet<String> = existing_apps
.iter()
.map(|a| a.resource.name.clone())
.collect();
while existing_names.contains(&app.resource.name) {
app.resource.name = format!("{}_{}", original_name, counter);
app.resource.uri = format!("ui://apps/{}", app.resource.name);
counter += 1;
}
app.mcp_servers = vec!["apps".to_string()];
cache.store_app(&app).map_err(|e| ErrorResponse {
message: format!("Failed to store app: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
Ok((
StatusCode::CREATED,
Json(ImportAppResponse {
name: app.resource.name.clone(),
message: format!("App '{}' imported successfully", app.resource.name),
}),
))
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/agent/start", post(start_agent))
.route("/agent/resume", post(resume_agent))
.route("/agent/restart", post(restart_agent))
.route("/agent/update_working_dir", post(update_working_dir))
.route("/agent/tools", get(get_tools))
.route("/agent/read_resource", post(read_resource))
.route("/agent/call_tool", post(call_tool))
.route("/agent/list_apps", get(list_apps))
.route("/agent/export_app/{name}", get(export_app))
.route("/agent/import_app", post(import_app))
.route("/agent/update_provider", post(update_agent_provider))
.route("/agent/update_session", post(update_session))
.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))
.route("/agent/set_container", post(set_container))
.route("/agent/stop", post(stop_agent))
.with_state(state)
}