Skip to main content

bamboo_engine/external_agents/
config.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3
4use bamboo_llm::Config;
5
6#[derive(Debug, Clone, Deserialize)]
7pub struct ExternalAgentProfile {
8    pub agent_id: String,
9    pub protocol: ExternalAgentProtocol,
10    #[serde(default, skip_serializing_if = "Option::is_none")]
11    pub agent_card_url: Option<String>,
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub rpc_url_override: Option<String>,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub auth_ref: Option<String>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub tenant: Option<String>,
18    pub permission_profile: String,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub skill: Option<String>,
21    #[serde(default)]
22    pub allow_non_streaming_fallback: bool,
23    /// Actor protocol only: path to the worker binary to spawn.
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub worker_bin: Option<String>,
26    /// Actor protocol only: fixed arguments passed to the worker binary
27    /// (e.g. `["subagent-worker"]` when `worker_bin` is the main `bamboo`
28    /// binary). Per-child data never rides here — it goes in the spec.
29    #[serde(default)]
30    pub worker_args: Vec<String>,
31    /// Actor protocol only: directory the worker self-registers into
32    /// (Tier-1 file fabric). Defaults to a per-user temp dir when unset.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub fabric_dir: Option<String>,
35    /// Actor protocol only: which engine the worker runs.
36    /// `"bamboo_runtime"` (default) for the real agent loop, `"echo"` for a
37    /// dependency-free smoke run through the whole chain, `"claude_code"` to
38    /// drive the official Claude Code CLI (see the `claude_code_*` fields
39    /// below).
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub executor: Option<String>,
42    /// `executor = "claude_code"` only: override the `claude` executable.
43    /// `None` runs `claude` resolved from `PATH`.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub claude_code_binary: Option<String>,
46    /// `executor = "claude_code"` only: `--model` override. `None` omits the
47    /// flag (CLI default).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub claude_code_model: Option<String>,
50    /// `executor = "claude_code"` only: `--permission-mode` override. `None`
51    /// still passes an EXPLICIT `default` to the CLI (issue #443 — the
52    /// headless stream-json default is `auto`, which self-approves every
53    /// tool and never asks); it does not mean "omit the flag".
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub claude_code_permission_mode: Option<String>,
56    /// `executor = "claude_code"` only: `true` lets the child inherit the
57    /// invoking user's `~/.claude` MCP servers/skills/settings. `false`/unset
58    /// (the default) isolates it (`--strict-mcp-config` +
59    /// `--setting-sources project`).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub claude_code_inherit_user_config: Option<bool>,
62    /// `executor = "claude_code"` only: extra env var NAMES forwarded
63    /// verbatim from this process's env to the child, on top of the fixed
64    /// HOME/PATH/SHELL/TERM/LANG/LC_*/TMPDIR/USER/LOGNAME allowlist.
65    /// Forwarding `ANTHROPIC_API_KEY` here is an explicit opt-in that flips
66    /// billing from the CLI's own subscription auth to the API key.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub claude_code_forward_env: Option<Vec<String>>,
69}
70
71#[derive(Debug, Clone, Deserialize)]
72pub enum ExternalAgentProtocol {
73    #[serde(rename = "a2a_jsonrpc")]
74    A2aJsonRpc,
75    /// Independent actor process speaking the `bamboo-subagent` WebSocket protocol.
76    #[serde(rename = "actor", alias = "subprocess")]
77    Actor,
78}
79
80#[derive(Debug, Clone, Deserialize)]
81pub struct SubagentRouting {
82    pub runtime: String, // "external" or "bamboo"
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub agent_id: Option<String>,
85}
86
87/// Parse external agent profiles from `Config.extra["externalAgents"]`.
88pub fn parse_external_agents(config: &Config) -> HashMap<String, ExternalAgentProfile> {
89    let Some(value) = config.extra.get("externalAgents") else {
90        return HashMap::new();
91    };
92
93    match serde_json::from_value(value.clone()) {
94        Ok(agents) => agents,
95        Err(error) => {
96            tracing::error!(
97                "Invalid externalAgents config; external agent routing disabled: {}",
98                error
99            );
100            HashMap::new()
101        }
102    }
103}
104
105/// Parse subagent routing table from `Config.extra["subagentRouting"]`.
106pub fn parse_subagent_routing(config: &Config) -> HashMap<String, SubagentRouting> {
107    let Some(value) = config.extra.get("subagentRouting") else {
108        return HashMap::new();
109    };
110
111    match serde_json::from_value(value.clone()) {
112        Ok(routing) => routing,
113        Err(error) => {
114            tracing::error!(
115                "Invalid subagentRouting config; external subagent routing disabled: {}",
116                error
117            );
118            HashMap::new()
119        }
120    }
121}
122
123/// Synthetic agent id for the built-in local actor worker (friendly
124/// `subagents.runtime = "actor"` path; no `externalAgents` entry needed).
125pub const LOCAL_ACTOR_AGENT_ID: &str = "local-actor";
126
127/// Resolve runtime metadata for a subagent_type based on config routing.
128///
129/// Sub-agents always run as actors (the in-process runtime was removed), so the
130/// default for every type is the built-in **local actor** worker. The only
131/// exception is the legacy expert `subagentRouting[type]` table, which can still
132/// pin a specific role to an external agent (e.g. a remote `a2a_jsonrpc` service
133/// or a custom actor profile). A legacy `runtime: "bamboo"` entry — which used
134/// to mean in-process — now falls through to the local-actor default.
135pub fn resolve_runtime_metadata(config: &Config, subagent_type: &str) -> HashMap<String, String> {
136    let local_actor_metadata = || {
137        HashMap::from([
138            ("runtime.kind".to_string(), "external".to_string()),
139            ("external.protocol".to_string(), "actor".to_string()),
140            (
141                "external.agent_id".to_string(),
142                LOCAL_ACTOR_AGENT_ID.to_string(),
143            ),
144        ])
145    };
146
147    let routing = parse_subagent_routing(config);
148    let agents = parse_external_agents(config);
149
150    // Legacy expert routing: pin a specific role to an external agent.
151    if let Some(route) = routing.get(subagent_type) {
152        if route.runtime == "external" {
153            let mut metadata = HashMap::new();
154            metadata.insert("runtime.kind".to_string(), "external".to_string());
155
156            if let Some(agent_id) = &route.agent_id {
157                metadata.insert("external.agent_id".to_string(), agent_id.clone());
158
159                if let Some(profile) = agents.get(agent_id) {
160                    metadata.insert(
161                        "external.protocol".to_string(),
162                        match profile.protocol {
163                            ExternalAgentProtocol::A2aJsonRpc => "a2a_jsonrpc".to_string(),
164                            ExternalAgentProtocol::Actor => "actor".to_string(),
165                        },
166                    );
167                    metadata.insert(
168                        "external.permission_profile".to_string(),
169                        profile.permission_profile.clone(),
170                    );
171                    if let Some(url) = &profile.agent_card_url {
172                        metadata.insert("external.agent_card_url".to_string(), url.clone());
173                    }
174                }
175            }
176
177            return metadata;
178        }
179        // `runtime: "bamboo"` (or anything non-external): fall through to the
180        // local-actor default below.
181    }
182
183    // Default: the built-in local actor worker.
184    local_actor_metadata()
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn parse_external_agents_from_config_extra() {
193        let mut config = Config::default();
194        config.extra.insert(
195            "externalAgents".to_string(),
196            serde_json::json!({
197                "remote_impl": {
198                    "agent_id": "remote_impl",
199                    "protocol": "a2a_jsonrpc",
200                    "agent_card_url": "https://example.com/agent-card.json",
201                    "auth_ref": "REMOTE_IMPL_TOKEN",
202                    "permission_profile": "remote_limited"
203                }
204            }),
205        );
206
207        let agents = parse_external_agents(&config);
208        assert_eq!(agents.len(), 1);
209        let profile = agents.get("remote_impl").unwrap();
210        assert_eq!(profile.agent_id, "remote_impl");
211        assert!(matches!(
212            profile.protocol,
213            ExternalAgentProtocol::A2aJsonRpc
214        ));
215        assert_eq!(
216            profile.agent_card_url,
217            Some("https://example.com/agent-card.json".to_string())
218        );
219        assert_eq!(profile.auth_ref, Some("REMOTE_IMPL_TOKEN".to_string()));
220    }
221
222    #[test]
223    fn parse_subagent_routing_from_config_extra() {
224        let mut config = Config::default();
225        config.extra.insert(
226            "subagentRouting".to_string(),
227            serde_json::json!({
228                "impl": { "runtime": "external", "agent_id": "remote_impl" },
229                "plan": { "runtime": "bamboo" }
230            }),
231        );
232
233        let routing = parse_subagent_routing(&config);
234        assert_eq!(routing.len(), 2);
235        assert_eq!(routing.get("impl").unwrap().runtime, "external");
236        assert_eq!(
237            routing.get("impl").unwrap().agent_id,
238            Some("remote_impl".to_string())
239        );
240        assert_eq!(routing.get("plan").unwrap().runtime, "bamboo");
241    }
242
243    #[test]
244    fn resolve_runtime_metadata_routes_impl_to_external() {
245        let mut config = Config::default();
246        config.extra.insert(
247            "externalAgents".to_string(),
248            serde_json::json!({
249                "remote_impl": {
250                    "agent_id": "remote_impl",
251                    "protocol": "a2a_jsonrpc",
252                    "permission_profile": "remote_limited"
253                }
254            }),
255        );
256        config.extra.insert(
257            "subagentRouting".to_string(),
258            serde_json::json!({
259                "impl": { "runtime": "external", "agent_id": "remote_impl" }
260            }),
261        );
262
263        let metadata = resolve_runtime_metadata(&config, "impl");
264        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
265        assert_eq!(
266            metadata.get("external.protocol"),
267            Some(&"a2a_jsonrpc".to_string())
268        );
269        assert_eq!(
270            metadata.get("external.agent_id"),
271            Some(&"remote_impl".to_string())
272        );
273    }
274
275    #[test]
276    fn unknown_type_defaults_to_local_actor() {
277        // Sub-agents always run as actors: a type with no expert routing
278        // resolves to the built-in local actor worker.
279        let mut config = Config::default();
280        config.subagents = Default::default();
281        let metadata = resolve_runtime_metadata(&config, "unknown");
282        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
283        assert_eq!(
284            metadata.get("external.protocol"),
285            Some(&"actor".to_string())
286        );
287        assert_eq!(
288            metadata.get("external.agent_id"),
289            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
290        );
291    }
292
293    #[test]
294    fn every_type_defaults_to_local_actor() {
295        let config = Config::default();
296        let metadata = resolve_runtime_metadata(&config, "researcher");
297        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
298        assert_eq!(
299            metadata.get("external.protocol"),
300            Some(&"actor".to_string())
301        );
302        assert_eq!(
303            metadata.get("external.agent_id"),
304            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
305        );
306    }
307
308    #[test]
309    fn legacy_bamboo_routing_falls_through_to_local_actor() {
310        // `runtime: "bamboo"` used to mean in-process; with in-process removed
311        // it falls through to the local-actor default.
312        let mut config = Config::default();
313        config.extra.insert(
314            "subagentRouting".to_string(),
315            serde_json::json!({
316                "plan": { "runtime": "bamboo" }
317            }),
318        );
319
320        let metadata = resolve_runtime_metadata(&config, "plan");
321        assert_eq!(
322            metadata.get("external.agent_id"),
323            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
324        );
325    }
326
327    #[test]
328    fn legacy_external_routing_selects_remote_agent() {
329        let mut config = Config::default();
330        config.extra.insert(
331            "externalAgents".to_string(),
332            serde_json::json!({
333                "remote_impl": {
334                    "agent_id": "remote_impl",
335                    "protocol": "a2a_jsonrpc",
336                    "permission_profile": "remote_limited"
337                }
338            }),
339        );
340        config.extra.insert(
341            "subagentRouting".to_string(),
342            serde_json::json!({
343                "impl": { "runtime": "external", "agent_id": "remote_impl" }
344            }),
345        );
346
347        // explicit legacy per-type external routing still selects the remote agent
348        let metadata = resolve_runtime_metadata(&config, "impl");
349        assert_eq!(
350            metadata.get("external.agent_id"),
351            Some(&"remote_impl".to_string())
352        );
353    }
354}