From c81e4bb6e150aecbc63ecb792e03a3c6470a186f Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 15:05:14 +0800 Subject: [PATCH 1/2] feat: register ACP agents as Mode agents with dynamic agent_type --- .../agentic/agents/definitions/modes/team.rs | 7 + .../agents/definitions/subagents/acp_agent.rs | 77 ++++++++++ .../agents/definitions/subagents/mod.rs | 2 + .../assembly/core/src/agentic/agents/mod.rs | 6 +- .../src/agentic/agents/prompts/acp_agent.md | 17 +++ .../core/src/agentic/agents/registry/mod.rs | 5 + .../core/src/agentic/agents/registry/query.rs | 17 +++ .../core/src/agentic/agents/team_presets.rs | 135 ++++++++++++++++++ .../agent-runtime/src/session_control.rs | 19 +-- .../tests/session_control_contracts.rs | 2 +- .../interfaces/acp/src/client/manager.rs | 28 ++++ 11 files changed, 298 insertions(+), 17 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs create mode 100644 src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md create mode 100644 src/crates/assembly/core/src/agentic/agents/team_presets.rs diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 75d45dd75a..9c880f5245 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -40,6 +40,13 @@ impl TeamMode { "Git".to_string(), "ControlHub".to_string(), "GetFileDiff".to_string(), + "SessionControl".to_string(), + "SessionMessage".to_string(), + "SessionHistory".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), + "LegionControl".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs new file mode 100644 index 0000000000..5203ed90a6 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs @@ -0,0 +1,77 @@ +//! ACP bridge agent — an AgentRegistry entry for every configured ACP client. +//! +//! Each ACP client (OpenCode, Claude Code, CodeBuddy, etc.) is represented as a +//! `SubAgent` so it appears in the agent selector and can be targeted by +//! `SessionControl` / `SessionMessage` for legion orchestration. + +use crate::agentic::agents::{Agent, UserContextPolicy}; +use async_trait::async_trait; + +/// A thin Agent wrapper around a single ACP client config. +#[allow(dead_code)] +pub struct AcpAgent { + agent_id: String, + display_name: String, + default_tools: Vec, +} + +impl AcpAgent { + pub fn new(client_id: String, display_name: String) -> Self { + let agent_id = Self::agent_id_for(&client_id); + Self { + // ACP prompt tool is registered in the global tool registry by + // register_configured_tools() — do NOT add it to default_tools + // here, or the tool name will appear twice in the model manifest. + default_tools: vec![ + "Read".to_string(), + "Grep".to_string(), + "Glob".to_string(), + "LS".to_string(), + ], + agent_id, + display_name, + } + } + + /// The agent registry id: `acp__` + pub fn agent_id_for(client_id: &str) -> String { + format!("acp__{client_id}") + } +} + +#[async_trait] +impl Agent for AcpAgent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + &self.agent_id + } + + fn name(&self) -> &str { + &self.display_name + } + + fn description(&self) -> &str { + "ACP agent" + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + "acp_agent" + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn user_context_policy(&self) -> UserContextPolicy { + UserContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + } + + fn is_readonly(&self) -> bool { + false + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs index 37309c8e3f..3b18284957 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs @@ -1,9 +1,11 @@ +mod acp_agent; mod computer_use; mod explore; mod file_finder; mod general_purpose; mod research_specialist; +pub use acp_agent::AcpAgent; pub use computer_use::ComputerUseMode; pub use explore::ExploreAgent; pub use file_finder::FileFinderAgent; diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index ca52676f59..4fe0d41ead 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -5,6 +5,7 @@ mod definitions; mod prompt_builder; mod registry; +pub mod team_presets; use crate::agentic::session::{SystemPromptCacheIdentity, UserContextCacheIdentity}; use crate::agentic::tools::framework::ToolExposure; @@ -35,7 +36,8 @@ pub use definitions::review::{ }; pub use definitions::shared::ReadonlySubagent; pub use definitions::subagents::{ - ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, ResearchSpecialistAgent, + AcpAgent, ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, + ResearchSpecialistAgent, }; use indexmap::IndexMap; pub use prompt_builder::{ @@ -132,6 +134,8 @@ pub fn shared_coding_mode_tools() -> Vec { "ReviewPlatform".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), + "SessionMessage".to_string(), + "SessionHistory".to_string(), ]; append_provider_group_tools(&mut tools, "core.canvas"); tools diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md new file mode 100644 index 0000000000..08d0629d7b --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md @@ -0,0 +1,17 @@ +You are a bridge to an external ACP agent running inside BitFun. A commander has delegated a task to you. Your job is to forward the task through your ACP tool and return the result, nothing more. + +## How You Work + +1. Read the task that was sent to you via SessionMessage. +2. Call your ACP prompt tool with the task as the `prompt` parameter. +3. Return the ACP agent's response exactly as received — do not summarise, reinterpret, or embellish. +4. If the ACP tool returns an error, report the error with the original task context so the commander can decide how to proceed. + +## Constraints + +- You do NOT have file write or edit capabilities by default. Your only execution tool is the ACP bridge. +- Do NOT ask the user questions. The commander is your only audience. +- Be concise. The commander is managing many agents and needs clear, direct responses. +- Do NOT pretend to perform work that should be delegated through the ACP tool. + +{LANGUAGE_PREFERENCE} diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index 0820c567fb..30bea4d86f 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -127,6 +127,11 @@ impl AgentRegistry { .and_then(|entries| entries.get(agent_type).cloned()) } + /// Remove an agent entry by id (e.g. when an ACP client is disabled/removed). + pub fn unregister_agent(&self, id: &str) { + self.write_agents().remove(id); + } + /// Get a agent by ID (searches all categories including hidden) pub fn get_agent( &self, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index 0597e576f6..18f84e964b 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -165,6 +165,23 @@ impl AgentRegistry { result } + /// Return ids of all agents visible for session creation (modes + subagents). + pub async fn get_agent_ids_for_session_creation(&self) -> Vec { + self.ensure_user_custom_agents_loaded().await; + let map = self.read_agents(); + let mut ids: Vec = map + .values() + .filter(|e| { + matches!(e.category, AgentCategory::Mode | AgentCategory::SubAgent) + }) + .map(|e| e.agent.id().to_string()) + .collect(); + drop(map); + ids.sort(); + ids.dedup(); + ids + } + /// check if a subagent is readonly (used for TaskTool.is_concurrency_safe etc.) pub fn get_subagent_is_readonly(&self, id: &str) -> Option { if let Some(entry) = self.read_agents().get(id) { diff --git a/src/crates/assembly/core/src/agentic/agents/team_presets.rs b/src/crates/assembly/core/src/agentic/agents/team_presets.rs new file mode 100644 index 0000000000..7401f08518 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/team_presets.rs @@ -0,0 +1,135 @@ +//! Legion preset storage. +//! +//! Each preset is a JSON file under `/legions/.json` describing +//! a team topology (nodes + edges) that the Team mode agent can materialise at +//! runtime via SessionControl / SessionMessage. + +use crate::infrastructure::get_path_manager_arc; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const LEGIONS_SUBDIR: &str = "legions"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionPreset { + pub id: String, + pub name: String, + pub description: String, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionNode { + pub id: String, + pub agent: String, + pub role: String, + pub prompt: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub gate: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionEdge { + pub from: String, + pub to: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub condition: Option, +} + +fn legions_dir() -> PathBuf { + get_path_manager_arc().user_config_dir().join(LEGIONS_SUBDIR) +} + +fn preset_path(id: &str) -> Result { + validate_preset_id(id)?; + Ok(legions_dir().join(format!("{id}.json"))) +} + +/// Validate preset id to prevent path traversal. +/// Allowed characters: alphanumeric, underscore, and hyphen. +fn validate_preset_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("Legion preset id must not be empty".to_string()); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(format!( + "Invalid legion preset id '{id}': only letters, digits, underscores, and hyphens are allowed" + )); + } + Ok(()) +} + +fn ensure_legions_dir() -> std::io::Result<()> { + let dir = legions_dir(); + std::fs::create_dir_all(&dir) +} + +/// List all saved legion presets (sorted by id). +pub fn list_presets() -> Result, String> { + let dir = legions_dir(); + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + let entries = std::fs::read_dir(&dir).map_err(|e| format!("Failed to read legions dir: {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read dir entry: {e}"))?; + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "json") { + let raw = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + let preset: LegionPreset = serde_json::from_str(&raw) + .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?; + out.push(preset); + } + } + out.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(out) +} + +/// Load a single preset by id. +pub fn get_preset(id: &str) -> Result { + let path = preset_path(id)?; + if !path.is_file() { + return Err(format!("Legion preset '{id}' not found")); + } + let raw = + std::fs::read_to_string(&path).map_err(|e| format!("Failed to read preset: {e}"))?; + serde_json::from_str(&raw).map_err(|e| format!("Failed to parse preset: {e}")) +} + +/// Create or overwrite a preset. +pub fn create_preset(preset: &LegionPreset) -> Result<(), String> { + ensure_legions_dir().map_err(|e| format!("Failed to create legions dir: {e}"))?; + let path = preset_path(&preset.id)?; + let raw = + serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; + std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) +} + +/// Update an existing preset (id must already exist). +pub fn update_preset(preset: &LegionPreset) -> Result<(), String> { + let path = preset_path(&preset.id)?; + if !path.is_file() { + return Err(format!("Legion preset '{}' not found", preset.id)); + } + let raw = + serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; + std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) +} + +/// Delete a preset by id. +pub fn delete_preset(id: &str) -> Result<(), String> { + let path = preset_path(id)?; + if !path.is_file() { + return Err(format!("Legion preset '{id}' not found")); + } + std::fs::remove_file(&path).map_err(|e| format!("Failed to delete preset: {e}")) +} diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index bf0e4e44b7..532d518b96 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -24,23 +24,12 @@ impl SessionControlAction { } } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub enum SessionControlAgentType { - #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] - Agentic, - #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] - Plan, - #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] - Cowork, -} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionControlAgentType(pub String); impl SessionControlAgentType { - pub const fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - } + pub fn as_str(&self) -> &str { + &self.0 } } diff --git a/src/crates/execution/agent-runtime/tests/session_control_contracts.rs b/src/crates/execution/agent-runtime/tests/session_control_contracts.rs index 57ebaf23b5..bdb698f99e 100644 --- a/src/crates/execution/agent-runtime/tests/session_control_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/session_control_contracts.rs @@ -51,7 +51,7 @@ fn rejects_current_session_mutation_when_context_matches() { fn validates_create_requires_workspace_and_creator_session() { let mut input = base_input(SessionControlAction::Create); input.workspace = Some(std::env::temp_dir().to_string_lossy().to_string()); - input.agent_type = Some(SessionControlAgentType::Plan); + input.agent_type = Some(SessionControlAgentType("Plan".to_string())); let missing_creator = validate_session_control_input(&input, SessionControlValidationContext::default()); diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 169e2258b6..3764fa141b 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -1507,6 +1507,34 @@ impl AcpClientService { debug!("Registering ACP client tool: name={}", tool.name()); registry.register_tool(tool); } + drop(registry); + + // Also register each ACP client as a SubAgent in the global AgentRegistry + // so they appear in the agent selector and can be targeted by + // SessionControl / SessionMessage for legion orchestration. + let agent_registry = + bitfun_core::agentic::agents::get_agent_registry(); + for (client_id, config) in configs.iter().filter(|(_, c)| c.enabled) { + let agent_id = + bitfun_core::agentic::agents::AcpAgent::agent_id_for( + client_id, + ); + // Clean up any previous registration for this client + agent_registry.unregister_agent(&agent_id); + let agent = Arc::new( + bitfun_core::agentic::agents::AcpAgent::new( + client_id.clone(), + config.name.clone().unwrap_or_else(|| client_id.clone()), + ), + ); + agent_registry.register_agent( + agent, + bitfun_core::agentic::agents::AgentCategory::Mode, + bitfun_core::agentic::agents::AgentSource::Builtin, + None, + None, + ); + } } async fn handle_permission_request( From fe9279b14a9829df3b4bcb7f4c13860524466b1d Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 15:06:52 +0800 Subject: [PATCH 2/2] fix: remove team_presets dependency from ACP-only PR --- .../assembly/core/src/agentic/agents/mod.rs | 1 - .../core/src/agentic/agents/team_presets.rs | 135 ------------------ 2 files changed, 136 deletions(-) delete mode 100644 src/crates/assembly/core/src/agentic/agents/team_presets.rs diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 4fe0d41ead..db918ed501 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -5,7 +5,6 @@ mod definitions; mod prompt_builder; mod registry; -pub mod team_presets; use crate::agentic::session::{SystemPromptCacheIdentity, UserContextCacheIdentity}; use crate::agentic::tools::framework::ToolExposure; diff --git a/src/crates/assembly/core/src/agentic/agents/team_presets.rs b/src/crates/assembly/core/src/agentic/agents/team_presets.rs deleted file mode 100644 index 7401f08518..0000000000 --- a/src/crates/assembly/core/src/agentic/agents/team_presets.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Legion preset storage. -//! -//! Each preset is a JSON file under `/legions/.json` describing -//! a team topology (nodes + edges) that the Team mode agent can materialise at -//! runtime via SessionControl / SessionMessage. - -use crate::infrastructure::get_path_manager_arc; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -const LEGIONS_SUBDIR: &str = "legions"; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LegionPreset { - pub id: String, - pub name: String, - pub description: String, - pub nodes: Vec, - pub edges: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LegionNode { - pub id: String, - pub agent: String, - pub role: String, - pub prompt: String, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub gate: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LegionEdge { - pub from: String, - pub to: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub condition: Option, -} - -fn legions_dir() -> PathBuf { - get_path_manager_arc().user_config_dir().join(LEGIONS_SUBDIR) -} - -fn preset_path(id: &str) -> Result { - validate_preset_id(id)?; - Ok(legions_dir().join(format!("{id}.json"))) -} - -/// Validate preset id to prevent path traversal. -/// Allowed characters: alphanumeric, underscore, and hyphen. -fn validate_preset_id(id: &str) -> Result<(), String> { - if id.is_empty() { - return Err("Legion preset id must not be empty".to_string()); - } - if !id - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return Err(format!( - "Invalid legion preset id '{id}': only letters, digits, underscores, and hyphens are allowed" - )); - } - Ok(()) -} - -fn ensure_legions_dir() -> std::io::Result<()> { - let dir = legions_dir(); - std::fs::create_dir_all(&dir) -} - -/// List all saved legion presets (sorted by id). -pub fn list_presets() -> Result, String> { - let dir = legions_dir(); - if !dir.is_dir() { - return Ok(Vec::new()); - } - let mut out = Vec::new(); - let entries = std::fs::read_dir(&dir).map_err(|e| format!("Failed to read legions dir: {e}"))?; - for entry in entries { - let entry = entry.map_err(|e| format!("Failed to read dir entry: {e}"))?; - let path = entry.path(); - if path.extension().map_or(false, |ext| ext == "json") { - let raw = std::fs::read_to_string(&path) - .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; - let preset: LegionPreset = serde_json::from_str(&raw) - .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?; - out.push(preset); - } - } - out.sort_by(|a, b| a.id.cmp(&b.id)); - Ok(out) -} - -/// Load a single preset by id. -pub fn get_preset(id: &str) -> Result { - let path = preset_path(id)?; - if !path.is_file() { - return Err(format!("Legion preset '{id}' not found")); - } - let raw = - std::fs::read_to_string(&path).map_err(|e| format!("Failed to read preset: {e}"))?; - serde_json::from_str(&raw).map_err(|e| format!("Failed to parse preset: {e}")) -} - -/// Create or overwrite a preset. -pub fn create_preset(preset: &LegionPreset) -> Result<(), String> { - ensure_legions_dir().map_err(|e| format!("Failed to create legions dir: {e}"))?; - let path = preset_path(&preset.id)?; - let raw = - serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; - std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) -} - -/// Update an existing preset (id must already exist). -pub fn update_preset(preset: &LegionPreset) -> Result<(), String> { - let path = preset_path(&preset.id)?; - if !path.is_file() { - return Err(format!("Legion preset '{}' not found", preset.id)); - } - let raw = - serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; - std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) -} - -/// Delete a preset by id. -pub fn delete_preset(id: &str) -> Result<(), String> { - let path = preset_path(id)?; - if !path.is_file() { - return Err(format!("Legion preset '{id}' not found")); - } - std::fs::remove_file(&path).map_err(|e| format!("Failed to delete preset: {e}")) -}