* edit profiles.json * move default crate configuration to a default_profiles.json button to open mcp config in editor initialse empty mcp config files fix test new JSON structure remove editor buttons fmt and types * feat: add profile field to follow-up attempt (#442) * move default crate configuration to a default_profiles.json * new JSON structure * feat: add profile field to follow-up attempt; fix follow ups using wrong session id at 2nd+ follow up fmt Profile selection (vibe-kanban cf714482) Right now in the frontend, when viewing a task card, we show the base_coding_agent from the task attempt. We should also show the currently selected profile there in the same way feat: add watchkill support to CommandBuilder and integrate with Claude executor feat: refactor profile handling to use ProfileVariant across executors and requests feat: restructure command modes in default_profiles.json for clarity and consistency update profile handling to use ProfileVariant across components and add mode selection fmt feat: refactor profile handling to use variants instead of modes across components and update related structures Fix frontend * Refactor coding agent representation in task and task attempt models - Changed `base_coding_agent` field to `profile` in `TaskWithAttemptStatus` and `TaskAttempt` structs. - Updated SQL queries and data handling to reflect the new `profile` field. - Modified related API endpoints and request/response structures to use `profile` instead of `base_coding_agent`. - Adjusted frontend API calls and components to align with the updated data structure. - Removed unused `BaseCodingAgent` enum and related type guards from the frontend. - Enhanced MCP server configuration handling to utilize the new profile-based approach. feat: Introduce MCP configuration management - Added `McpConfig` struct for managing MCP server configurations. - Implemented reading and writing of agent config files in JSON and TOML formats. - Refactored MCP server handling in the `McpServers` component to utilize the new configuration structure. - Removed deprecated `agent_config.rs` and updated related imports. - Enhanced error handling for MCP server operations. - Updated frontend strategies to accommodate the new MCP configuration structure. feat: Introduce MCP configuration management - Added `McpConfig` struct for managing MCP server configurations. - Implemented reading and writing of agent config files in JSON and TOML formats. - Refactored MCP server handling in the `McpServers` component to utilize the new configuration structure. - Removed deprecated `agent_config.rs` and updated related imports. - Enhanced error handling for MCP server operations. - Updated frontend strategies to accommodate the new MCP configuration structure. Best effort migration; add missing feature flag feat: refactor execution process handling and introduce profile variant extraction feat: add default follow-up variant handling in task details context feat: enhance profile variant selection with dropdown menus in onboarding and task sections fmt, types * refactor: rename ProfileVariant to ProfileVariantLabel; Modified AgentProfile to wrap AgentProfileVariant Fmt, clippy * Fix rebase issues * refactor: replace OnceLock with RwLock for AgentProfiles caching; update profile retrieval in executors and routes --------- Co-authored-by: Gabriel Gordon-Hall <ggordonhall@gmail.com> Fmt Fix tests refactor: clean up unused imports and default implementations in executor modules Move profiles to profiles.rs * rename profile to profile_variant_label for readability rename AgentProfile to ProfileConfig, AgentProfileVariant to VariantAgentConfig * remove duplicated profile state * Amp yolo --------- Co-authored-by: Alex Netsch <alex@bloop.ai>
382 lines
12 KiB
Rust
382 lines
12 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use axum::{
|
|
body::Body,
|
|
extract::{Path, Query, State},
|
|
http,
|
|
response::{Json as ResponseJson, Response},
|
|
routing::{get, put},
|
|
Json, Router,
|
|
};
|
|
use deployment::{Deployment, DeploymentError};
|
|
use executors::{
|
|
mcp_config::{read_agent_config, write_agent_config, McpConfig},
|
|
profile::ProfileConfigs,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use services::services::config::{save_config_to_file, Config, ConfigError, SoundFile};
|
|
use tokio::fs;
|
|
use ts_rs::TS;
|
|
use utils::{assets::config_path, response::ApiResponse};
|
|
|
|
use crate::{error::ApiError, DeploymentImpl};
|
|
|
|
pub fn router() -> Router<DeploymentImpl> {
|
|
Router::new()
|
|
.route("/info", get(get_user_system_info))
|
|
.route("/config", put(update_config))
|
|
.route("/sounds/{sound}", get(get_sound))
|
|
.route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers))
|
|
.route("/profiles", get(get_profiles).put(update_profiles))
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
|
pub struct Environment {
|
|
pub os_type: String,
|
|
pub os_version: String,
|
|
pub os_architecture: String,
|
|
pub bitness: String,
|
|
}
|
|
|
|
impl Default for Environment {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl Environment {
|
|
pub fn new() -> Self {
|
|
let info = os_info::get();
|
|
Environment {
|
|
os_type: info.os_type().to_string(),
|
|
os_version: info.version().to_string(),
|
|
os_architecture: info.architecture().unwrap_or("unknown").to_string(),
|
|
bitness: info.bitness().to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, TS)]
|
|
pub struct UserSystemInfo {
|
|
pub config: Config,
|
|
#[serde(flatten)]
|
|
pub profiles: ProfileConfigs,
|
|
pub environment: Environment,
|
|
}
|
|
|
|
// TODO: update frontend, BE schema has changed, this replaces GET /config and /config/constants
|
|
#[axum::debug_handler]
|
|
async fn get_user_system_info(
|
|
State(deployment): State<DeploymentImpl>,
|
|
) -> ResponseJson<ApiResponse<UserSystemInfo>> {
|
|
let config = deployment.config().read().await;
|
|
|
|
let user_system_info = UserSystemInfo {
|
|
config: config.clone(),
|
|
profiles: ProfileConfigs::get_cached(),
|
|
environment: Environment::new(),
|
|
};
|
|
|
|
ResponseJson(ApiResponse::success(user_system_info))
|
|
}
|
|
|
|
async fn update_config(
|
|
State(deployment): State<DeploymentImpl>,
|
|
Json(new_config): Json<Config>,
|
|
) -> ResponseJson<ApiResponse<Config>> {
|
|
let config_path = config_path();
|
|
|
|
match save_config_to_file(&new_config, &config_path).await {
|
|
Ok(_) => {
|
|
let mut config = deployment.config().write().await;
|
|
*config = new_config.clone();
|
|
drop(config);
|
|
|
|
ResponseJson(ApiResponse::success(new_config))
|
|
}
|
|
Err(e) => ResponseJson(ApiResponse::error(&format!("Failed to save config: {}", e))),
|
|
}
|
|
}
|
|
|
|
async fn get_sound(Path(sound): Path<SoundFile>) -> Result<Response, ApiError> {
|
|
let sound = sound.serve().await.map_err(DeploymentError::Other)?;
|
|
let response = Response::builder()
|
|
.status(http::StatusCode::OK)
|
|
.header(
|
|
http::header::CONTENT_TYPE,
|
|
http::HeaderValue::from_static("audio/wav"),
|
|
)
|
|
.body(Body::from(sound.data.into_owned()))
|
|
.unwrap();
|
|
Ok(response)
|
|
}
|
|
|
|
#[derive(TS, Debug, Deserialize)]
|
|
pub struct McpServerQuery {
|
|
profile: String,
|
|
}
|
|
|
|
#[derive(TS, Debug, Serialize, Deserialize)]
|
|
pub struct GetMcpServerResponse {
|
|
// servers: HashMap<String, Value>,
|
|
mcp_config: McpConfig,
|
|
config_path: String,
|
|
}
|
|
|
|
#[derive(TS, Debug, Serialize, Deserialize)]
|
|
pub struct UpdateMcpServersBody {
|
|
servers: HashMap<String, Value>,
|
|
}
|
|
|
|
async fn get_mcp_servers(
|
|
State(_deployment): State<DeploymentImpl>,
|
|
Query(query): Query<McpServerQuery>,
|
|
) -> Result<ResponseJson<ApiResponse<GetMcpServerResponse>>, ApiError> {
|
|
let profiles = ProfileConfigs::get_cached();
|
|
let profile = profiles.get_profile(&query.profile).ok_or_else(|| {
|
|
ApiError::Config(ConfigError::ValidationError(format!(
|
|
"Profile not found: {}",
|
|
query.profile
|
|
)))
|
|
})?;
|
|
|
|
if !profile.default.agent.supports_mcp() {
|
|
return Ok(ResponseJson(ApiResponse::error(
|
|
"This executor does not support MCP servers",
|
|
)));
|
|
}
|
|
|
|
// Resolve supplied config path or agent default
|
|
let config_path = match profile.get_mcp_config_path() {
|
|
Some(path) => path,
|
|
None => {
|
|
return Ok(ResponseJson(ApiResponse::error(
|
|
"Could not determine config file path",
|
|
)));
|
|
}
|
|
};
|
|
|
|
let mut mcpc = profile.default.agent.get_mcp_config();
|
|
let raw_config = read_agent_config(&config_path, &mcpc).await?;
|
|
let servers = get_mcp_servers_from_config_path(&raw_config, &mcpc.servers_path);
|
|
mcpc.set_servers(servers);
|
|
Ok(ResponseJson(ApiResponse::success(GetMcpServerResponse {
|
|
mcp_config: mcpc,
|
|
config_path: config_path.to_string_lossy().to_string(),
|
|
})))
|
|
}
|
|
|
|
async fn update_mcp_servers(
|
|
State(_deployment): State<DeploymentImpl>,
|
|
Query(query): Query<McpServerQuery>,
|
|
Json(payload): Json<UpdateMcpServersBody>,
|
|
) -> Result<ResponseJson<ApiResponse<String>>, ApiError> {
|
|
let profiles = ProfileConfigs::get_cached();
|
|
let agent = &profiles
|
|
.get_profile(&query.profile)
|
|
.ok_or_else(|| {
|
|
ApiError::Config(ConfigError::ValidationError(format!(
|
|
"Profile not found: {}",
|
|
query.profile
|
|
)))
|
|
})?
|
|
.default
|
|
.agent;
|
|
|
|
if !agent.supports_mcp() {
|
|
return Ok(ResponseJson(ApiResponse::error(
|
|
"This executor does not support MCP servers",
|
|
)));
|
|
}
|
|
|
|
// Resolve supplied config path or agent default
|
|
let config_path = match agent.default_mcp_config_path() {
|
|
Some(path) => path,
|
|
None => {
|
|
return Ok(ResponseJson(ApiResponse::error(
|
|
"Could not determine config file path",
|
|
)))
|
|
}
|
|
};
|
|
|
|
let mcpc = agent.get_mcp_config();
|
|
match update_mcp_servers_in_config(&config_path, &mcpc, payload.servers).await {
|
|
Ok(message) => Ok(ResponseJson(ApiResponse::success(message))),
|
|
Err(e) => Ok(ResponseJson(ApiResponse::error(&format!(
|
|
"Failed to update MCP servers: {}",
|
|
e
|
|
)))),
|
|
}
|
|
}
|
|
|
|
async fn update_mcp_servers_in_config(
|
|
config_path: &std::path::Path,
|
|
mcpc: &McpConfig,
|
|
new_servers: HashMap<String, Value>,
|
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
|
// Ensure parent directory exists
|
|
if let Some(parent) = config_path.parent() {
|
|
fs::create_dir_all(parent).await?;
|
|
}
|
|
// Read existing config (JSON or TOML depending on agent)
|
|
let mut config = read_agent_config(config_path, mcpc).await?;
|
|
|
|
// Get the current server count for comparison
|
|
let old_servers = get_mcp_servers_from_config_path(&config, &mcpc.servers_path).len();
|
|
|
|
// Set the MCP servers using the correct attribute path
|
|
set_mcp_servers_in_config_path(&mut config, &mcpc.servers_path, &new_servers)?;
|
|
|
|
// Write the updated config back to file (JSON or TOML depending on agent)
|
|
write_agent_config(config_path, mcpc, &config).await?;
|
|
|
|
let new_count = new_servers.len();
|
|
let message = match (old_servers, new_count) {
|
|
(0, 0) => "No MCP servers configured".to_string(),
|
|
(0, n) => format!("Added {} MCP server(s)", n),
|
|
(old, new) if old == new => format!("Updated MCP server configuration ({} server(s))", new),
|
|
(old, new) => format!(
|
|
"Updated MCP server configuration (was {}, now {})",
|
|
old, new
|
|
),
|
|
};
|
|
|
|
Ok(message)
|
|
}
|
|
|
|
/// Helper function to get MCP servers from config using a path
|
|
fn get_mcp_servers_from_config_path(raw_config: &Value, path: &[String]) -> HashMap<String, Value> {
|
|
let mut current = raw_config;
|
|
for part in path {
|
|
current = match current.get(part) {
|
|
Some(val) => val,
|
|
None => return HashMap::new(),
|
|
};
|
|
}
|
|
// Extract the servers object
|
|
match current.as_object() {
|
|
Some(servers) => servers
|
|
.iter()
|
|
.map(|(k, v)| (k.clone(), v.clone()))
|
|
.collect(),
|
|
None => HashMap::new(),
|
|
}
|
|
}
|
|
|
|
/// Helper function to set MCP servers in config using a path
|
|
fn set_mcp_servers_in_config_path(
|
|
raw_config: &mut Value,
|
|
path: &[String],
|
|
servers: &HashMap<String, Value>,
|
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
// Ensure config is an object
|
|
if !raw_config.is_object() {
|
|
*raw_config = serde_json::json!({});
|
|
}
|
|
|
|
let mut current = raw_config;
|
|
// Navigate/create the nested structure (all parts except the last)
|
|
for part in &path[..path.len() - 1] {
|
|
if current.get(part).is_none() {
|
|
current
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.insert(part.to_string(), serde_json::json!({}));
|
|
}
|
|
current = current.get_mut(part).unwrap();
|
|
if !current.is_object() {
|
|
*current = serde_json::json!({});
|
|
}
|
|
}
|
|
|
|
// Set the final attribute
|
|
let final_attr = path.last().unwrap();
|
|
current
|
|
.as_object_mut()
|
|
.unwrap()
|
|
.insert(final_attr.to_string(), serde_json::to_value(servers)?);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct ProfilesContent {
|
|
pub content: String,
|
|
pub path: String,
|
|
}
|
|
|
|
async fn get_profiles(
|
|
State(_deployment): State<DeploymentImpl>,
|
|
) -> ResponseJson<ApiResponse<ProfilesContent>> {
|
|
let profiles_path = utils::assets::profiles_path();
|
|
|
|
let mut profiles = ProfileConfigs::from_defaults();
|
|
if let Ok(user_content) = std::fs::read_to_string(&profiles_path) {
|
|
match serde_json::from_str::<ProfileConfigs>(&user_content) {
|
|
Ok(user_profiles) => {
|
|
// Override defaults with user profiles that have the same label
|
|
for user_profile in user_profiles.profiles {
|
|
if let Some(default_profile) = profiles
|
|
.profiles
|
|
.iter_mut()
|
|
.find(|p| p.default.label == user_profile.default.label)
|
|
{
|
|
*default_profile = user_profile;
|
|
} else {
|
|
profiles.profiles.push(user_profile);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to parse profiles.json: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
let content = serde_json::to_string_pretty(&profiles).unwrap_or_else(|e| {
|
|
tracing::error!("Failed to serialize profiles to JSON: {}", e);
|
|
serde_json::to_string_pretty(&ProfileConfigs::from_defaults())
|
|
.unwrap_or_else(|_| "{}".to_string())
|
|
});
|
|
|
|
ResponseJson(ApiResponse::success(ProfilesContent {
|
|
content,
|
|
path: profiles_path.display().to_string(),
|
|
}))
|
|
}
|
|
|
|
async fn update_profiles(
|
|
State(_deployment): State<DeploymentImpl>,
|
|
body: String,
|
|
) -> ResponseJson<ApiResponse<String>> {
|
|
let profiles: ProfileConfigs = match serde_json::from_str(&body) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
return ResponseJson(ApiResponse::error(&format!(
|
|
"Invalid profiles format: {}",
|
|
e
|
|
)))
|
|
}
|
|
};
|
|
|
|
let profiles_path = utils::assets::profiles_path();
|
|
|
|
// Simply save all profiles as provided by the user
|
|
let formatted = serde_json::to_string_pretty(&profiles).unwrap();
|
|
match fs::write(&profiles_path, formatted).await {
|
|
Ok(_) => {
|
|
tracing::info!("All profiles saved to {:?}", profiles_path);
|
|
// Reload the cached profiles
|
|
ProfileConfigs::reload();
|
|
ResponseJson(ApiResponse::success(
|
|
"Profiles updated successfully".to_string(),
|
|
))
|
|
}
|
|
Err(e) => ResponseJson(ApiResponse::error(&format!(
|
|
"Failed to save profiles: {}",
|
|
e
|
|
))),
|
|
}
|
|
}
|