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, or `"codex"` for the selected Codex mode.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub executor: Option<String>,
41    /// `executor = "claude_code"` only: override the `claude` executable.
42    /// `None` runs `claude` resolved from `PATH`.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub claude_code_binary: Option<String>,
45    /// `executor = "claude_code"` only: `--model` override. `None` omits the
46    /// flag (CLI default).
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub claude_code_model: Option<String>,
49    /// `executor = "claude_code"` only: `--permission-mode` override. `None`
50    /// still passes an EXPLICIT `default` to the CLI (issue #443 — the
51    /// headless stream-json default is `auto`, which self-approves every
52    /// tool and never asks); it does not mean "omit the flag".
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub claude_code_permission_mode: Option<String>,
55    /// `executor = "claude_code"` only: `true` lets the child inherit the
56    /// invoking user's `~/.claude` MCP servers/skills/settings. `false`/unset
57    /// (the default) isolates it (`--strict-mcp-config` +
58    /// `--setting-sources project`).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub claude_code_inherit_user_config: Option<bool>,
61    /// `executor = "claude_code"` only: extra env var NAMES forwarded
62    /// verbatim from this process's env to the child, on top of the fixed
63    /// HOME/PATH/SHELL/TERM/LANG/LC_*/TMPDIR/USER/LOGNAME allowlist.
64    /// Forwarding `ANTHROPIC_API_KEY` here is an explicit opt-in that flips
65    /// billing from the CLI's own subscription auth to the API key.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub claude_code_forward_env: Option<Vec<String>>,
68    /// `executor = "codex"` only: override the `codex` executable.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub codex_binary: Option<String>,
71    /// `executor = "codex"` only: `--model` override.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub codex_model: Option<String>,
74    /// `exec` (default) or long-lived `app_server` JSON-RPC mode.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub codex_mode: Option<bamboo_config::CodexMode>,
77    /// `inherit | api_key | custom | bamboo` (unset defaults to bamboo).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub codex_auth_mode: Option<bamboo_config::CodexAuthMode>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub codex_base_url: Option<String>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub codex_wire_api: Option<bamboo_config::CodexWireApi>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub codex_provider_key_ref: Option<bamboo_config::CredentialRef>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub codex_forward_env: Option<Vec<String>>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub codex_sandbox: Option<bamboo_config::CodexSandbox>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub codex_approval_policy: Option<bamboo_config::CodexApprovalPolicy>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub codex_network_access: Option<bool>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub codex_allow_danger_bypass: Option<bool>,
96}
97
98#[derive(Debug, Clone, Deserialize)]
99pub enum ExternalAgentProtocol {
100    #[serde(rename = "a2a_jsonrpc")]
101    A2aJsonRpc,
102    /// Independent actor process speaking the `bamboo-subagent` WebSocket protocol.
103    #[serde(rename = "actor", alias = "subprocess")]
104    Actor,
105}
106
107#[derive(Debug, Clone, Deserialize)]
108pub struct SubagentRouting {
109    pub runtime: String, // "external" or "bamboo"
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub agent_id: Option<String>,
112}
113
114/// Parse external agent profiles from `Config.extra["externalAgents"]`.
115pub fn parse_external_agents(config: &Config) -> HashMap<String, ExternalAgentProfile> {
116    let Some(value) = config.extra.get("externalAgents") else {
117        return HashMap::new();
118    };
119
120    match serde_json::from_value(value.clone()) {
121        Ok(agents) => agents,
122        Err(error) => {
123            tracing::error!(
124                "Invalid externalAgents config; external agent routing disabled: {}",
125                error
126            );
127            HashMap::new()
128        }
129    }
130}
131
132/// Parse subagent routing table from `Config.extra["subagentRouting"]`.
133pub fn parse_subagent_routing(config: &Config) -> HashMap<String, SubagentRouting> {
134    let Some(value) = config.extra.get("subagentRouting") else {
135        return HashMap::new();
136    };
137
138    match serde_json::from_value(value.clone()) {
139        Ok(routing) => routing,
140        Err(error) => {
141            tracing::error!(
142                "Invalid subagentRouting config; external subagent routing disabled: {}",
143                error
144            );
145            HashMap::new()
146        }
147    }
148}
149
150/// Synthetic agent id for the built-in local actor worker (friendly
151/// `subagents.runtime = "actor"` path; no `externalAgents` entry needed).
152pub const LOCAL_ACTOR_AGENT_ID: &str = "local-actor";
153
154/// Resolve runtime metadata for a subagent_type based on config routing.
155///
156/// Sub-agents always run as actors (the in-process runtime was removed), so the
157/// default for every type is the built-in **local actor** worker. The only
158/// exception is the legacy expert `subagentRouting[type]` table, which can still
159/// pin a specific role to an external agent (e.g. a remote `a2a_jsonrpc` service
160/// or a custom actor profile). A legacy `runtime: "bamboo"` entry — which used
161/// to mean in-process — now falls through to the local-actor default.
162pub fn resolve_runtime_metadata(config: &Config, subagent_type: &str) -> HashMap<String, String> {
163    let local_actor_metadata = || {
164        HashMap::from([
165            ("runtime.kind".to_string(), "external".to_string()),
166            ("external.protocol".to_string(), "actor".to_string()),
167            (
168                "external.agent_id".to_string(),
169                LOCAL_ACTOR_AGENT_ID.to_string(),
170            ),
171        ])
172    };
173
174    let routing = parse_subagent_routing(config);
175    let agents = parse_external_agents(config);
176
177    // Legacy expert routing: pin a specific role to an external agent.
178    if let Some(route) = routing.get(subagent_type) {
179        if route.runtime == "external" {
180            let mut metadata = HashMap::new();
181            metadata.insert("runtime.kind".to_string(), "external".to_string());
182
183            if let Some(agent_id) = &route.agent_id {
184                metadata.insert("external.agent_id".to_string(), agent_id.clone());
185
186                if let Some(profile) = agents.get(agent_id) {
187                    metadata.insert(
188                        "external.protocol".to_string(),
189                        match profile.protocol {
190                            ExternalAgentProtocol::A2aJsonRpc => "a2a_jsonrpc".to_string(),
191                            ExternalAgentProtocol::Actor => "actor".to_string(),
192                        },
193                    );
194                    metadata.insert(
195                        "external.permission_profile".to_string(),
196                        profile.permission_profile.clone(),
197                    );
198                    if let Some(url) = &profile.agent_card_url {
199                        metadata.insert("external.agent_card_url".to_string(), url.clone());
200                    }
201                }
202            }
203
204            return metadata;
205        }
206        // `runtime: "bamboo"` (or anything non-external): fall through to the
207        // local-actor default below.
208    }
209
210    // Default: the built-in local actor worker.
211    local_actor_metadata()
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn parse_external_agents_from_config_extra() {
220        let mut config = Config::default();
221        config.extra.insert(
222            "externalAgents".to_string(),
223            serde_json::json!({
224                "remote_impl": {
225                    "agent_id": "remote_impl",
226                    "protocol": "a2a_jsonrpc",
227                    "agent_card_url": "https://example.com/agent-card.json",
228                    "auth_ref": "REMOTE_IMPL_TOKEN",
229                    "permission_profile": "remote_limited"
230                }
231            }),
232        );
233
234        let agents = parse_external_agents(&config);
235        assert_eq!(agents.len(), 1);
236        let profile = agents.get("remote_impl").unwrap();
237        assert_eq!(profile.agent_id, "remote_impl");
238        assert!(matches!(
239            profile.protocol,
240            ExternalAgentProtocol::A2aJsonRpc
241        ));
242        assert_eq!(
243            profile.agent_card_url,
244            Some("https://example.com/agent-card.json".to_string())
245        );
246        assert_eq!(profile.auth_ref, Some("REMOTE_IMPL_TOKEN".to_string()));
247    }
248
249    #[test]
250    fn parse_external_codex_permission_fields() {
251        let mut config = Config::default();
252        config.extra.insert(
253            "externalAgents".to_string(),
254            serde_json::json!({
255                "codex_research": {
256                    "agent_id": "codex_research",
257                    "protocol": "actor",
258                    "permission_profile": "research",
259                    "executor": "codex",
260                    "codex_mode": "app_server",
261                    "codex_sandbox": "read-only",
262                    "codex_approval_policy": "on-request",
263                    "codex_network_access": false,
264                    "codex_allow_danger_bypass": false
265                }
266            }),
267        );
268
269        let profile = parse_external_agents(&config)
270            .remove("codex_research")
271            .expect("typed Codex profile");
272        assert_eq!(
273            profile.codex_mode,
274            Some(bamboo_config::CodexMode::AppServer)
275        );
276        assert_eq!(
277            profile.codex_sandbox,
278            Some(bamboo_config::CodexSandbox::ReadOnly)
279        );
280        assert_eq!(
281            profile.codex_approval_policy,
282            Some(bamboo_config::CodexApprovalPolicy::OnRequest)
283        );
284        assert_eq!(profile.codex_network_access, Some(false));
285        assert_eq!(profile.codex_allow_danger_bypass, Some(false));
286    }
287
288    #[test]
289    fn parse_subagent_routing_from_config_extra() {
290        let mut config = Config::default();
291        config.extra.insert(
292            "subagentRouting".to_string(),
293            serde_json::json!({
294                "impl": { "runtime": "external", "agent_id": "remote_impl" },
295                "plan": { "runtime": "bamboo" }
296            }),
297        );
298
299        let routing = parse_subagent_routing(&config);
300        assert_eq!(routing.len(), 2);
301        assert_eq!(routing.get("impl").unwrap().runtime, "external");
302        assert_eq!(
303            routing.get("impl").unwrap().agent_id,
304            Some("remote_impl".to_string())
305        );
306        assert_eq!(routing.get("plan").unwrap().runtime, "bamboo");
307    }
308
309    #[test]
310    fn resolve_runtime_metadata_routes_impl_to_external() {
311        let mut config = Config::default();
312        config.extra.insert(
313            "externalAgents".to_string(),
314            serde_json::json!({
315                "remote_impl": {
316                    "agent_id": "remote_impl",
317                    "protocol": "a2a_jsonrpc",
318                    "permission_profile": "remote_limited"
319                }
320            }),
321        );
322        config.extra.insert(
323            "subagentRouting".to_string(),
324            serde_json::json!({
325                "impl": { "runtime": "external", "agent_id": "remote_impl" }
326            }),
327        );
328
329        let metadata = resolve_runtime_metadata(&config, "impl");
330        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
331        assert_eq!(
332            metadata.get("external.protocol"),
333            Some(&"a2a_jsonrpc".to_string())
334        );
335        assert_eq!(
336            metadata.get("external.agent_id"),
337            Some(&"remote_impl".to_string())
338        );
339    }
340
341    #[test]
342    fn unknown_type_defaults_to_local_actor() {
343        // Sub-agents always run as actors: a type with no expert routing
344        // resolves to the built-in local actor worker.
345        let mut config = Config::default();
346        *config.subagents_mut() = Default::default();
347        let metadata = resolve_runtime_metadata(&config, "unknown");
348        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
349        assert_eq!(
350            metadata.get("external.protocol"),
351            Some(&"actor".to_string())
352        );
353        assert_eq!(
354            metadata.get("external.agent_id"),
355            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
356        );
357    }
358
359    #[test]
360    fn every_type_defaults_to_local_actor() {
361        let config = Config::default();
362        let metadata = resolve_runtime_metadata(&config, "researcher");
363        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
364        assert_eq!(
365            metadata.get("external.protocol"),
366            Some(&"actor".to_string())
367        );
368        assert_eq!(
369            metadata.get("external.agent_id"),
370            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
371        );
372    }
373
374    #[test]
375    fn legacy_bamboo_routing_falls_through_to_local_actor() {
376        // `runtime: "bamboo"` used to mean in-process; with in-process removed
377        // it falls through to the local-actor default.
378        let mut config = Config::default();
379        config.extra.insert(
380            "subagentRouting".to_string(),
381            serde_json::json!({
382                "plan": { "runtime": "bamboo" }
383            }),
384        );
385
386        let metadata = resolve_runtime_metadata(&config, "plan");
387        assert_eq!(
388            metadata.get("external.agent_id"),
389            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
390        );
391    }
392
393    #[test]
394    fn legacy_external_routing_selects_remote_agent() {
395        let mut config = Config::default();
396        config.extra.insert(
397            "externalAgents".to_string(),
398            serde_json::json!({
399                "remote_impl": {
400                    "agent_id": "remote_impl",
401                    "protocol": "a2a_jsonrpc",
402                    "permission_profile": "remote_limited"
403                }
404            }),
405        );
406        config.extra.insert(
407            "subagentRouting".to_string(),
408            serde_json::json!({
409                "impl": { "runtime": "external", "agent_id": "remote_impl" }
410            }),
411        );
412
413        // explicit legacy per-type external routing still selects the remote agent
414        let metadata = resolve_runtime_metadata(&config, "impl");
415        assert_eq!(
416            metadata.get("external.agent_id"),
417            Some(&"remote_impl".to_string())
418        );
419    }
420}