Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions src/apps/desktop/src/api/subagent_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

use crate::api::app_state::AppState;
use bitfun_core::agentic::agents::{
AgentCategory, AgentInfo, CustomSubagent, CustomSubagentConfig, CustomSubagentKind,
SubAgentSource,
AgentCategory, AgentInfo, CustomSubagent, CustomSubagentConfig, CustomSubagentDetail,
CustomSubagentKind, SubAgentSource,
};
use bitfun_core::service::config::types::SubAgentConfig;
use log::warn;
Expand Down Expand Up @@ -48,6 +48,26 @@ pub async fn list_subagents(
Ok(result)
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GetSubagentDetailRequest {
pub subagent_id: String,
pub workspace_path: Option<String>,
}

#[tauri::command]
pub async fn get_subagent_detail(
state: State<'_, AppState>,
request: GetSubagentDetailRequest,
) -> Result<CustomSubagentDetail, String> {
let workspace = workspace_root_from_request(request.workspace_path.as_deref());
state
.agent_registry
.get_custom_subagent_detail(&request.subagent_id, workspace.as_deref())
.await
.map_err(|e| e.to_string())
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteSubagentRequest {
Expand Down Expand Up @@ -113,6 +133,43 @@ pub async fn delete_subagent(
Ok(())
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateSubagentRequest {
pub subagent_id: String,
pub description: String,
pub prompt: String,
pub tools: Option<Vec<String>>,
pub readonly: Option<bool>,
pub workspace_path: Option<String>,
}

#[tauri::command]
pub async fn update_subagent(
state: State<'_, AppState>,
request: UpdateSubagentRequest,
) -> Result<(), String> {
if request.description.trim().is_empty() {
return Err("Description cannot be empty".to_string());
}
if request.prompt.trim().is_empty() {
return Err("Prompt cannot be empty".to_string());
}
let workspace = workspace_root_from_request(request.workspace_path.as_deref());
state
.agent_registry
.update_custom_subagent_definition(
&request.subagent_id,
workspace.as_deref(),
request.description.trim().to_string(),
request.prompt.trim().to_string(),
request.tools,
request.readonly,
)
.await
.map_err(|e| e.to_string())
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SubagentLevel {
Expand Down
2 changes: 2 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,10 @@ pub async fn run() {
get_subagent_configs,
set_subagent_config,
list_subagents,
get_subagent_detail,
delete_subagent,
create_subagent,
update_subagent,
reload_subagents,
list_agent_tool_names,
update_subagent_config,
Expand Down
2 changes: 1 addition & 1 deletion src/crates/core/src/agentic/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub use plan_mode::PlanMode;
pub use prompt_builder::{PromptBuilder, PromptBuilderContext, RemoteExecutionHints};
pub use registry::{
get_agent_registry, AgentCategory, AgentInfo, AgentRegistry, CustomSubagentConfig,
SubAgentSource,
CustomSubagentDetail, SubAgentSource,
};
use std::any::Any;

Expand Down
226 changes: 226 additions & 0 deletions src/crates/core/src/agentic/agents/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@ impl AgentInfo {
}
}

/// Full sub-agent definition for editing (user/project custom agents only)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CustomSubagentDetail {
pub subagent_id: String,
pub name: String,
pub description: String,
pub prompt: String,
pub tools: Vec<String>,
pub readonly: bool,
pub enabled: bool,
pub model: String,
pub path: String,
/// `"user"` or `"project"`
pub level: String,
}

fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str {
match agent_type {
"agentic" | "Cowork" | "Plan" | "debug" | "Claw" => "auto",
Expand Down Expand Up @@ -674,6 +691,215 @@ impl AgentRegistry {
Ok(())
}

/// Load custom subagents if needed, then return full definition for the editor UI
pub async fn get_custom_subagent_detail(
&self,
agent_id: &str,
workspace_root: Option<&Path>,
) -> BitFunResult<CustomSubagentDetail> {
if let Some(root) = workspace_root {
self.load_custom_subagents(root).await;
}
self.get_custom_subagent_detail_inner(agent_id, workspace_root)
}

fn get_custom_subagent_detail_inner(
&self,
agent_id: &str,
workspace_root: Option<&Path>,
) -> BitFunResult<CustomSubagentDetail> {
let entry = self.find_agent_entry(agent_id, workspace_root).ok_or_else(|| {
BitFunError::agent(format!("Subagent not found: {}", agent_id))
})?;
if entry.category != AgentCategory::SubAgent {
return Err(BitFunError::agent(format!(
"Agent '{}' is not a subagent",
agent_id
)));
}
if entry.subagent_source == Some(SubAgentSource::Builtin) {
return Err(BitFunError::agent(
"Built-in subagents cannot be edited here".to_string(),
));
}
let custom = entry
.agent
.as_any()
.downcast_ref::<CustomSubagent>()
.ok_or_else(|| {
BitFunError::agent(format!(
"Subagent '{}' is not a custom subagent file",
agent_id
))
})?;
let (enabled, model) = match &entry.custom_config {
Some(c) => (c.enabled, c.model.clone()),
None => (custom.enabled, custom.model.clone()),
};
let level = match custom.kind {
CustomSubagentKind::User => "user",
CustomSubagentKind::Project => "project",
};
Ok(CustomSubagentDetail {
subagent_id: agent_id.to_string(),
name: custom.name.clone(),
description: custom.description.clone(),
prompt: custom.prompt.clone(),
tools: custom.tools.clone(),
readonly: custom.readonly,
enabled,
model,
path: custom.path.clone(),
level: level.to_string(),
})
}

/// Update description, prompt, tools, and readonly for a custom sub-agent (id and file path unchanged)
pub async fn update_custom_subagent_definition(
&self,
agent_id: &str,
workspace_root: Option<&Path>,
description: String,
prompt: String,
tools: Option<Vec<String>>,
readonly: Option<bool>,
) -> BitFunResult<()> {
if let Some(root) = workspace_root {
self.load_custom_subagents(root).await;
}
let entry = self.find_agent_entry(agent_id, workspace_root).ok_or_else(|| {
BitFunError::agent(format!("Subagent not found: {}", agent_id))
})?;
if entry.category != AgentCategory::SubAgent {
return Err(BitFunError::agent(format!(
"Agent '{}' is not a subagent",
agent_id
)));
}
if entry.subagent_source == Some(SubAgentSource::Builtin) {
return Err(BitFunError::agent(
"Built-in subagents cannot be edited".to_string(),
));
}
let old = entry
.agent
.as_any()
.downcast_ref::<CustomSubagent>()
.ok_or_else(|| {
BitFunError::agent(format!(
"Subagent '{}' is not a custom subagent file",
agent_id
))
})?;
let tools = tools
.filter(|t| !t.is_empty())
.unwrap_or_else(|| {
vec![
"LS".to_string(),
"Read".to_string(),
"Glob".to_string(),
"Grep".to_string(),
]
});
let mut new_subagent = CustomSubagent::new(
old.name.clone(),
description,
tools,
prompt,
readonly.unwrap_or(old.readonly),
old.path.clone(),
old.kind,
);
new_subagent.enabled = old.enabled;
new_subagent.model = old.model.clone();

let valid_tools = get_all_registered_tool_names().await;
let valid_models = Self::get_valid_model_ids().await;
Self::validate_custom_subagent(&mut new_subagent, &valid_tools, &valid_models);

new_subagent.save_to_file(None, None)?;

self.replace_custom_subagent_entry(agent_id, workspace_root, new_subagent)
}

fn replace_custom_subagent_entry(
&self,
agent_id: &str,
workspace_root: Option<&Path>,
new_subagent: CustomSubagent,
) -> BitFunResult<()> {
let mut map = self.write_agents();
if map.contains_key(agent_id) {
let old_entry = map.get(agent_id).ok_or_else(|| {
BitFunError::agent(format!("Subagent not found: {}", agent_id))
})?;
if old_entry.category != AgentCategory::SubAgent {
return Err(BitFunError::agent(format!(
"Agent '{}' is not a subagent",
agent_id
)));
}
if old_entry.subagent_source == Some(SubAgentSource::Builtin) {
return Err(BitFunError::agent(
"Cannot replace built-in subagent".to_string(),
));
}
let subagent_source = old_entry.subagent_source;
let cfg = CustomSubagentConfig {
enabled: new_subagent.enabled,
model: new_subagent.model.clone(),
};
map.insert(
agent_id.to_string(),
AgentEntry {
category: AgentCategory::SubAgent,
subagent_source,
agent: Arc::new(new_subagent),
custom_config: Some(cfg),
},
);
return Ok(());
}
drop(map);

let root = workspace_root.ok_or_else(|| {
BitFunError::agent("Workspace path is required to update project subagent".to_string())
})?;
let mut pm = self.write_project_subagents();
let entries = pm.get_mut(root).ok_or_else(|| {
BitFunError::agent("Project subagent cache not loaded for this workspace".to_string())
})?;
let old_entry = entries.get(agent_id).ok_or_else(|| {
BitFunError::agent(format!("Subagent not found: {}", agent_id))
})?;
if old_entry.category != AgentCategory::SubAgent {
return Err(BitFunError::agent(format!(
"Agent '{}' is not a subagent",
agent_id
)));
}
if old_entry.subagent_source == Some(SubAgentSource::Builtin) {
return Err(BitFunError::agent(
"Cannot replace built-in subagent".to_string(),
));
}
let subagent_source = old_entry.subagent_source;
let cfg = CustomSubagentConfig {
enabled: new_subagent.enabled,
model: new_subagent.model.clone(),
};
entries.insert(
agent_id.to_string(),
AgentEntry {
category: AgentCategory::SubAgent,
subagent_source,
agent: Arc::new(new_subagent),
custom_config: Some(cfg),
},
);
Ok(())
}

/// remove single non-built-in subagent, return its file path (used for caller to delete file)
/// only allow removing entries that are SubAgent and not Builtin
pub fn remove_subagent(&self, agent_id: &str) -> BitFunResult<Option<String>> {
Expand Down
Loading
Loading