Skip to content

Commit 0ee1e06

Browse files
authored
Merge pull request #324 from kev1n77/feat/agent-card
feat: support delete/edit operation for Agent Cards
2 parents 4453a95 + bda4692 commit 0ee1e06

File tree

10 files changed

+595
-35
lines changed

10 files changed

+595
-35
lines changed

src/apps/desktop/src/api/subagent_api.rs

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
33
use crate::api::app_state::AppState;
44
use bitfun_core::agentic::agents::{
5-
AgentCategory, AgentInfo, CustomSubagent, CustomSubagentConfig, CustomSubagentKind,
6-
SubAgentSource,
5+
AgentCategory, AgentInfo, CustomSubagent, CustomSubagentConfig, CustomSubagentDetail,
6+
CustomSubagentKind, SubAgentSource,
77
};
88
use bitfun_core::service::config::types::SubAgentConfig;
99
use log::warn;
@@ -48,6 +48,26 @@ pub async fn list_subagents(
4848
Ok(result)
4949
}
5050

51+
#[derive(Debug, Clone, Deserialize)]
52+
#[serde(rename_all = "camelCase")]
53+
pub struct GetSubagentDetailRequest {
54+
pub subagent_id: String,
55+
pub workspace_path: Option<String>,
56+
}
57+
58+
#[tauri::command]
59+
pub async fn get_subagent_detail(
60+
state: State<'_, AppState>,
61+
request: GetSubagentDetailRequest,
62+
) -> Result<CustomSubagentDetail, String> {
63+
let workspace = workspace_root_from_request(request.workspace_path.as_deref());
64+
state
65+
.agent_registry
66+
.get_custom_subagent_detail(&request.subagent_id, workspace.as_deref())
67+
.await
68+
.map_err(|e| e.to_string())
69+
}
70+
5171
#[derive(Debug, Clone, Deserialize)]
5272
#[serde(rename_all = "camelCase")]
5373
pub struct DeleteSubagentRequest {
@@ -113,6 +133,43 @@ pub async fn delete_subagent(
113133
Ok(())
114134
}
115135

136+
#[derive(Debug, Clone, Deserialize)]
137+
#[serde(rename_all = "camelCase")]
138+
pub struct UpdateSubagentRequest {
139+
pub subagent_id: String,
140+
pub description: String,
141+
pub prompt: String,
142+
pub tools: Option<Vec<String>>,
143+
pub readonly: Option<bool>,
144+
pub workspace_path: Option<String>,
145+
}
146+
147+
#[tauri::command]
148+
pub async fn update_subagent(
149+
state: State<'_, AppState>,
150+
request: UpdateSubagentRequest,
151+
) -> Result<(), String> {
152+
if request.description.trim().is_empty() {
153+
return Err("Description cannot be empty".to_string());
154+
}
155+
if request.prompt.trim().is_empty() {
156+
return Err("Prompt cannot be empty".to_string());
157+
}
158+
let workspace = workspace_root_from_request(request.workspace_path.as_deref());
159+
state
160+
.agent_registry
161+
.update_custom_subagent_definition(
162+
&request.subagent_id,
163+
workspace.as_deref(),
164+
request.description.trim().to_string(),
165+
request.prompt.trim().to_string(),
166+
request.tools,
167+
request.readonly,
168+
)
169+
.await
170+
.map_err(|e| e.to_string())
171+
}
172+
116173
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
117174
#[serde(rename_all = "lowercase")]
118175
pub enum SubagentLevel {

src/apps/desktop/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,8 +404,10 @@ pub async fn run() {
404404
get_subagent_configs,
405405
set_subagent_config,
406406
list_subagents,
407+
get_subagent_detail,
407408
delete_subagent,
408409
create_subagent,
410+
update_subagent,
409411
reload_subagents,
410412
list_agent_tool_names,
411413
update_subagent_config,

src/crates/core/src/agentic/agents/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub use plan_mode::PlanMode;
3535
pub use prompt_builder::{PromptBuilder, PromptBuilderContext, RemoteExecutionHints};
3636
pub use registry::{
3737
get_agent_registry, AgentCategory, AgentInfo, AgentRegistry, CustomSubagentConfig,
38-
SubAgentSource,
38+
CustomSubagentDetail, SubAgentSource,
3939
};
4040
use std::any::Any;
4141

src/crates/core/src/agentic/agents/registry.rs

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,23 @@ impl AgentInfo {
108108
}
109109
}
110110

111+
/// Full sub-agent definition for editing (user/project custom agents only)
112+
#[derive(Debug, Clone, Serialize, Deserialize)]
113+
#[serde(rename_all = "camelCase")]
114+
pub struct CustomSubagentDetail {
115+
pub subagent_id: String,
116+
pub name: String,
117+
pub description: String,
118+
pub prompt: String,
119+
pub tools: Vec<String>,
120+
pub readonly: bool,
121+
pub enabled: bool,
122+
pub model: String,
123+
pub path: String,
124+
/// `"user"` or `"project"`
125+
pub level: String,
126+
}
127+
111128
fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str {
112129
match agent_type {
113130
"agentic" | "Cowork" | "Plan" | "debug" | "Claw" => "auto",
@@ -674,6 +691,215 @@ impl AgentRegistry {
674691
Ok(())
675692
}
676693

694+
/// Load custom subagents if needed, then return full definition for the editor UI
695+
pub async fn get_custom_subagent_detail(
696+
&self,
697+
agent_id: &str,
698+
workspace_root: Option<&Path>,
699+
) -> BitFunResult<CustomSubagentDetail> {
700+
if let Some(root) = workspace_root {
701+
self.load_custom_subagents(root).await;
702+
}
703+
self.get_custom_subagent_detail_inner(agent_id, workspace_root)
704+
}
705+
706+
fn get_custom_subagent_detail_inner(
707+
&self,
708+
agent_id: &str,
709+
workspace_root: Option<&Path>,
710+
) -> BitFunResult<CustomSubagentDetail> {
711+
let entry = self.find_agent_entry(agent_id, workspace_root).ok_or_else(|| {
712+
BitFunError::agent(format!("Subagent not found: {}", agent_id))
713+
})?;
714+
if entry.category != AgentCategory::SubAgent {
715+
return Err(BitFunError::agent(format!(
716+
"Agent '{}' is not a subagent",
717+
agent_id
718+
)));
719+
}
720+
if entry.subagent_source == Some(SubAgentSource::Builtin) {
721+
return Err(BitFunError::agent(
722+
"Built-in subagents cannot be edited here".to_string(),
723+
));
724+
}
725+
let custom = entry
726+
.agent
727+
.as_any()
728+
.downcast_ref::<CustomSubagent>()
729+
.ok_or_else(|| {
730+
BitFunError::agent(format!(
731+
"Subagent '{}' is not a custom subagent file",
732+
agent_id
733+
))
734+
})?;
735+
let (enabled, model) = match &entry.custom_config {
736+
Some(c) => (c.enabled, c.model.clone()),
737+
None => (custom.enabled, custom.model.clone()),
738+
};
739+
let level = match custom.kind {
740+
CustomSubagentKind::User => "user",
741+
CustomSubagentKind::Project => "project",
742+
};
743+
Ok(CustomSubagentDetail {
744+
subagent_id: agent_id.to_string(),
745+
name: custom.name.clone(),
746+
description: custom.description.clone(),
747+
prompt: custom.prompt.clone(),
748+
tools: custom.tools.clone(),
749+
readonly: custom.readonly,
750+
enabled,
751+
model,
752+
path: custom.path.clone(),
753+
level: level.to_string(),
754+
})
755+
}
756+
757+
/// Update description, prompt, tools, and readonly for a custom sub-agent (id and file path unchanged)
758+
pub async fn update_custom_subagent_definition(
759+
&self,
760+
agent_id: &str,
761+
workspace_root: Option<&Path>,
762+
description: String,
763+
prompt: String,
764+
tools: Option<Vec<String>>,
765+
readonly: Option<bool>,
766+
) -> BitFunResult<()> {
767+
if let Some(root) = workspace_root {
768+
self.load_custom_subagents(root).await;
769+
}
770+
let entry = self.find_agent_entry(agent_id, workspace_root).ok_or_else(|| {
771+
BitFunError::agent(format!("Subagent not found: {}", agent_id))
772+
})?;
773+
if entry.category != AgentCategory::SubAgent {
774+
return Err(BitFunError::agent(format!(
775+
"Agent '{}' is not a subagent",
776+
agent_id
777+
)));
778+
}
779+
if entry.subagent_source == Some(SubAgentSource::Builtin) {
780+
return Err(BitFunError::agent(
781+
"Built-in subagents cannot be edited".to_string(),
782+
));
783+
}
784+
let old = entry
785+
.agent
786+
.as_any()
787+
.downcast_ref::<CustomSubagent>()
788+
.ok_or_else(|| {
789+
BitFunError::agent(format!(
790+
"Subagent '{}' is not a custom subagent file",
791+
agent_id
792+
))
793+
})?;
794+
let tools = tools
795+
.filter(|t| !t.is_empty())
796+
.unwrap_or_else(|| {
797+
vec![
798+
"LS".to_string(),
799+
"Read".to_string(),
800+
"Glob".to_string(),
801+
"Grep".to_string(),
802+
]
803+
});
804+
let mut new_subagent = CustomSubagent::new(
805+
old.name.clone(),
806+
description,
807+
tools,
808+
prompt,
809+
readonly.unwrap_or(old.readonly),
810+
old.path.clone(),
811+
old.kind,
812+
);
813+
new_subagent.enabled = old.enabled;
814+
new_subagent.model = old.model.clone();
815+
816+
let valid_tools = get_all_registered_tool_names().await;
817+
let valid_models = Self::get_valid_model_ids().await;
818+
Self::validate_custom_subagent(&mut new_subagent, &valid_tools, &valid_models);
819+
820+
new_subagent.save_to_file(None, None)?;
821+
822+
self.replace_custom_subagent_entry(agent_id, workspace_root, new_subagent)
823+
}
824+
825+
fn replace_custom_subagent_entry(
826+
&self,
827+
agent_id: &str,
828+
workspace_root: Option<&Path>,
829+
new_subagent: CustomSubagent,
830+
) -> BitFunResult<()> {
831+
let mut map = self.write_agents();
832+
if map.contains_key(agent_id) {
833+
let old_entry = map.get(agent_id).ok_or_else(|| {
834+
BitFunError::agent(format!("Subagent not found: {}", agent_id))
835+
})?;
836+
if old_entry.category != AgentCategory::SubAgent {
837+
return Err(BitFunError::agent(format!(
838+
"Agent '{}' is not a subagent",
839+
agent_id
840+
)));
841+
}
842+
if old_entry.subagent_source == Some(SubAgentSource::Builtin) {
843+
return Err(BitFunError::agent(
844+
"Cannot replace built-in subagent".to_string(),
845+
));
846+
}
847+
let subagent_source = old_entry.subagent_source;
848+
let cfg = CustomSubagentConfig {
849+
enabled: new_subagent.enabled,
850+
model: new_subagent.model.clone(),
851+
};
852+
map.insert(
853+
agent_id.to_string(),
854+
AgentEntry {
855+
category: AgentCategory::SubAgent,
856+
subagent_source,
857+
agent: Arc::new(new_subagent),
858+
custom_config: Some(cfg),
859+
},
860+
);
861+
return Ok(());
862+
}
863+
drop(map);
864+
865+
let root = workspace_root.ok_or_else(|| {
866+
BitFunError::agent("Workspace path is required to update project subagent".to_string())
867+
})?;
868+
let mut pm = self.write_project_subagents();
869+
let entries = pm.get_mut(root).ok_or_else(|| {
870+
BitFunError::agent("Project subagent cache not loaded for this workspace".to_string())
871+
})?;
872+
let old_entry = entries.get(agent_id).ok_or_else(|| {
873+
BitFunError::agent(format!("Subagent not found: {}", agent_id))
874+
})?;
875+
if old_entry.category != AgentCategory::SubAgent {
876+
return Err(BitFunError::agent(format!(
877+
"Agent '{}' is not a subagent",
878+
agent_id
879+
)));
880+
}
881+
if old_entry.subagent_source == Some(SubAgentSource::Builtin) {
882+
return Err(BitFunError::agent(
883+
"Cannot replace built-in subagent".to_string(),
884+
));
885+
}
886+
let subagent_source = old_entry.subagent_source;
887+
let cfg = CustomSubagentConfig {
888+
enabled: new_subagent.enabled,
889+
model: new_subagent.model.clone(),
890+
};
891+
entries.insert(
892+
agent_id.to_string(),
893+
AgentEntry {
894+
category: AgentCategory::SubAgent,
895+
subagent_source,
896+
agent: Arc::new(new_subagent),
897+
custom_config: Some(cfg),
898+
},
899+
);
900+
Ok(())
901+
}
902+
677903
/// remove single non-built-in subagent, return its file path (used for caller to delete file)
678904
/// only allow removing entries that are SubAgent and not Builtin
679905
pub fn remove_subagent(&self, agent_id: &str) -> BitFunResult<Option<String>> {

0 commit comments

Comments
 (0)