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.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub executor: Option<String>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub enum ExternalAgentProtocol {
44    #[serde(rename = "a2a_jsonrpc")]
45    A2aJsonRpc,
46    /// Independent actor process speaking the `bamboo-subagent` WebSocket protocol.
47    #[serde(rename = "actor", alias = "subprocess")]
48    Actor,
49}
50
51#[derive(Debug, Clone, Deserialize)]
52pub struct SubagentRouting {
53    pub runtime: String, // "external" or "bamboo"
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub agent_id: Option<String>,
56}
57
58/// Parse external agent profiles from `Config.extra["externalAgents"]`.
59pub fn parse_external_agents(config: &Config) -> HashMap<String, ExternalAgentProfile> {
60    let Some(value) = config.extra.get("externalAgents") else {
61        return HashMap::new();
62    };
63
64    match serde_json::from_value(value.clone()) {
65        Ok(agents) => agents,
66        Err(error) => {
67            tracing::error!(
68                "Invalid externalAgents config; external agent routing disabled: {}",
69                error
70            );
71            HashMap::new()
72        }
73    }
74}
75
76/// Parse subagent routing table from `Config.extra["subagentRouting"]`.
77pub fn parse_subagent_routing(config: &Config) -> HashMap<String, SubagentRouting> {
78    let Some(value) = config.extra.get("subagentRouting") else {
79        return HashMap::new();
80    };
81
82    match serde_json::from_value(value.clone()) {
83        Ok(routing) => routing,
84        Err(error) => {
85            tracing::error!(
86                "Invalid subagentRouting config; external subagent routing disabled: {}",
87                error
88            );
89            HashMap::new()
90        }
91    }
92}
93
94/// Synthetic agent id for the built-in local actor worker (friendly
95/// `subagents.runtime = "actor"` path; no `externalAgents` entry needed).
96pub const LOCAL_ACTOR_AGENT_ID: &str = "local-actor";
97
98/// Resolve runtime metadata for a subagent_type based on config routing.
99///
100/// Precedence:
101/// 1. typed `subagents.overrides[type]` (explicit per-role, can force either mode)
102/// 2. legacy `subagentRouting[type]` (expert tables in `config.extra`)
103/// 3. typed `subagents.runtime` global default
104pub fn resolve_runtime_metadata(config: &Config, subagent_type: &str) -> HashMap<String, String> {
105    use bamboo_config::SubagentRuntimeMode;
106
107    let local_actor_metadata = || {
108        HashMap::from([
109            ("runtime.kind".to_string(), "external".to_string()),
110            ("external.protocol".to_string(), "actor".to_string()),
111            (
112                "external.agent_id".to_string(),
113                LOCAL_ACTOR_AGENT_ID.to_string(),
114            ),
115        ])
116    };
117
118    // 1. Explicit per-role override: decides absolutely (either direction).
119    if let Some(mode) = config.subagents.overrides.get(subagent_type) {
120        return match mode {
121            SubagentRuntimeMode::Actor => local_actor_metadata(),
122            SubagentRuntimeMode::InProcess => HashMap::new(),
123        };
124    }
125
126    let routing = parse_subagent_routing(config);
127    let agents = parse_external_agents(config);
128
129    let mut metadata = HashMap::new();
130
131    let Some(route) = routing.get(subagent_type) else {
132        // 3. No expert routing for this type: apply the typed global default.
133        if config.subagents.runtime == SubagentRuntimeMode::Actor {
134            return local_actor_metadata();
135        }
136        return metadata;
137    };
138
139    if route.runtime == "external" {
140        metadata.insert("runtime.kind".to_string(), "external".to_string());
141
142        if let Some(agent_id) = &route.agent_id {
143            metadata.insert("external.agent_id".to_string(), agent_id.clone());
144
145            if let Some(profile) = agents.get(agent_id) {
146                metadata.insert(
147                    "external.protocol".to_string(),
148                    match profile.protocol {
149                        ExternalAgentProtocol::A2aJsonRpc => "a2a_jsonrpc".to_string(),
150                        ExternalAgentProtocol::Actor => "actor".to_string(),
151                    },
152                );
153                metadata.insert(
154                    "external.permission_profile".to_string(),
155                    profile.permission_profile.clone(),
156                );
157                if let Some(url) = &profile.agent_card_url {
158                    metadata.insert("external.agent_card_url".to_string(), url.clone());
159                }
160            }
161        }
162    }
163
164    metadata
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn parse_external_agents_from_config_extra() {
173        let mut config = Config::default();
174        config.extra.insert(
175            "externalAgents".to_string(),
176            serde_json::json!({
177                "remote_impl": {
178                    "agent_id": "remote_impl",
179                    "protocol": "a2a_jsonrpc",
180                    "agent_card_url": "https://example.com/agent-card.json",
181                    "auth_ref": "REMOTE_IMPL_TOKEN",
182                    "permission_profile": "remote_limited"
183                }
184            }),
185        );
186
187        let agents = parse_external_agents(&config);
188        assert_eq!(agents.len(), 1);
189        let profile = agents.get("remote_impl").unwrap();
190        assert_eq!(profile.agent_id, "remote_impl");
191        assert!(matches!(
192            profile.protocol,
193            ExternalAgentProtocol::A2aJsonRpc
194        ));
195        assert_eq!(
196            profile.agent_card_url,
197            Some("https://example.com/agent-card.json".to_string())
198        );
199        assert_eq!(profile.auth_ref, Some("REMOTE_IMPL_TOKEN".to_string()));
200    }
201
202    #[test]
203    fn parse_subagent_routing_from_config_extra() {
204        let mut config = Config::default();
205        config.extra.insert(
206            "subagentRouting".to_string(),
207            serde_json::json!({
208                "impl": { "runtime": "external", "agent_id": "remote_impl" },
209                "plan": { "runtime": "bamboo" }
210            }),
211        );
212
213        let routing = parse_subagent_routing(&config);
214        assert_eq!(routing.len(), 2);
215        assert_eq!(routing.get("impl").unwrap().runtime, "external");
216        assert_eq!(
217            routing.get("impl").unwrap().agent_id,
218            Some("remote_impl".to_string())
219        );
220        assert_eq!(routing.get("plan").unwrap().runtime, "bamboo");
221    }
222
223    #[test]
224    fn resolve_runtime_metadata_routes_impl_to_external() {
225        let mut config = Config::default();
226        config.extra.insert(
227            "externalAgents".to_string(),
228            serde_json::json!({
229                "remote_impl": {
230                    "agent_id": "remote_impl",
231                    "protocol": "a2a_jsonrpc",
232                    "permission_profile": "remote_limited"
233                }
234            }),
235        );
236        config.extra.insert(
237            "subagentRouting".to_string(),
238            serde_json::json!({
239                "impl": { "runtime": "external", "agent_id": "remote_impl" }
240            }),
241        );
242
243        let metadata = resolve_runtime_metadata(&config, "impl");
244        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
245        assert_eq!(
246            metadata.get("external.protocol"),
247            Some(&"a2a_jsonrpc".to_string())
248        );
249        assert_eq!(
250            metadata.get("external.agent_id"),
251            Some(&"remote_impl".to_string())
252        );
253    }
254
255    #[test]
256    fn resolve_runtime_metadata_returns_empty_for_unknown_type() {
257        // Hermetic: Config::default() loads the developer's real
258        // ~/.bamboo/config.json, which may set subagents.runtime=actor.
259        // Pin the typed section to its true default for this assertion.
260        let mut config = Config::default();
261        config.subagents = Default::default();
262        let metadata = resolve_runtime_metadata(&config, "unknown");
263        assert!(metadata.is_empty());
264    }
265
266    // ---- friendly typed `subagents` config ----
267
268    #[test]
269    fn typed_global_actor_routes_every_type() {
270        let mut config = Config::default();
271        config.subagents.runtime = bamboo_config::SubagentRuntimeMode::Actor;
272
273        let metadata = resolve_runtime_metadata(&config, "researcher");
274        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
275        assert_eq!(
276            metadata.get("external.protocol"),
277            Some(&"actor".to_string())
278        );
279        assert_eq!(
280            metadata.get("external.agent_id"),
281            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
282        );
283    }
284
285    #[test]
286    fn typed_override_beats_global_default() {
287        let mut config = Config::default();
288        config.subagents.runtime = bamboo_config::SubagentRuntimeMode::Actor;
289        config.subagents.overrides.insert(
290            "researcher".to_string(),
291            bamboo_config::SubagentRuntimeMode::InProcess,
292        );
293
294        // override forces in-process even though the global default is subprocess
295        assert!(resolve_runtime_metadata(&config, "researcher").is_empty());
296        // other types still follow the global default
297        assert!(!resolve_runtime_metadata(&config, "coder").is_empty());
298    }
299
300    #[test]
301    fn typed_override_beats_legacy_routing() {
302        let mut config = Config::default();
303        config.extra.insert(
304            "subagentRouting".to_string(),
305            serde_json::json!({
306                "impl": { "runtime": "external", "agent_id": "remote_impl" }
307            }),
308        );
309        config.subagents.overrides.insert(
310            "impl".to_string(),
311            bamboo_config::SubagentRuntimeMode::Actor,
312        );
313
314        let metadata = resolve_runtime_metadata(&config, "impl");
315        // the typed per-role override wins over the legacy expert table
316        assert_eq!(
317            metadata.get("external.agent_id"),
318            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
319        );
320    }
321
322    #[test]
323    fn legacy_routing_beats_typed_global_default() {
324        let mut config = Config::default();
325        config.subagents.runtime = bamboo_config::SubagentRuntimeMode::Actor;
326        config.extra.insert(
327            "externalAgents".to_string(),
328            serde_json::json!({
329                "remote_impl": {
330                    "agent_id": "remote_impl",
331                    "protocol": "a2a_jsonrpc",
332                    "permission_profile": "remote_limited"
333                }
334            }),
335        );
336        config.extra.insert(
337            "subagentRouting".to_string(),
338            serde_json::json!({
339                "impl": { "runtime": "external", "agent_id": "remote_impl" }
340            }),
341        );
342
343        // explicit legacy per-type routing still selects the remote agent
344        let metadata = resolve_runtime_metadata(&config, "impl");
345        assert_eq!(
346            metadata.get("external.agent_id"),
347            Some(&"remote_impl".to_string())
348        );
349    }
350}