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/// Sub-agents always run as actors (the in-process runtime was removed), so the
101/// default for every type is the built-in **local actor** worker. The only
102/// exception is the legacy expert `subagentRouting[type]` table, which can still
103/// pin a specific role to an external agent (e.g. a remote `a2a_jsonrpc` service
104/// or a custom actor profile). A legacy `runtime: "bamboo"` entry — which used
105/// to mean in-process — now falls through to the local-actor default.
106pub fn resolve_runtime_metadata(config: &Config, subagent_type: &str) -> HashMap<String, String> {
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    let routing = parse_subagent_routing(config);
119    let agents = parse_external_agents(config);
120
121    // Legacy expert routing: pin a specific role to an external agent.
122    if let Some(route) = routing.get(subagent_type) {
123        if route.runtime == "external" {
124            let mut metadata = HashMap::new();
125            metadata.insert("runtime.kind".to_string(), "external".to_string());
126
127            if let Some(agent_id) = &route.agent_id {
128                metadata.insert("external.agent_id".to_string(), agent_id.clone());
129
130                if let Some(profile) = agents.get(agent_id) {
131                    metadata.insert(
132                        "external.protocol".to_string(),
133                        match profile.protocol {
134                            ExternalAgentProtocol::A2aJsonRpc => "a2a_jsonrpc".to_string(),
135                            ExternalAgentProtocol::Actor => "actor".to_string(),
136                        },
137                    );
138                    metadata.insert(
139                        "external.permission_profile".to_string(),
140                        profile.permission_profile.clone(),
141                    );
142                    if let Some(url) = &profile.agent_card_url {
143                        metadata.insert("external.agent_card_url".to_string(), url.clone());
144                    }
145                }
146            }
147
148            return metadata;
149        }
150        // `runtime: "bamboo"` (or anything non-external): fall through to the
151        // local-actor default below.
152    }
153
154    // Default: the built-in local actor worker.
155    local_actor_metadata()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn parse_external_agents_from_config_extra() {
164        let mut config = Config::default();
165        config.extra.insert(
166            "externalAgents".to_string(),
167            serde_json::json!({
168                "remote_impl": {
169                    "agent_id": "remote_impl",
170                    "protocol": "a2a_jsonrpc",
171                    "agent_card_url": "https://example.com/agent-card.json",
172                    "auth_ref": "REMOTE_IMPL_TOKEN",
173                    "permission_profile": "remote_limited"
174                }
175            }),
176        );
177
178        let agents = parse_external_agents(&config);
179        assert_eq!(agents.len(), 1);
180        let profile = agents.get("remote_impl").unwrap();
181        assert_eq!(profile.agent_id, "remote_impl");
182        assert!(matches!(
183            profile.protocol,
184            ExternalAgentProtocol::A2aJsonRpc
185        ));
186        assert_eq!(
187            profile.agent_card_url,
188            Some("https://example.com/agent-card.json".to_string())
189        );
190        assert_eq!(profile.auth_ref, Some("REMOTE_IMPL_TOKEN".to_string()));
191    }
192
193    #[test]
194    fn parse_subagent_routing_from_config_extra() {
195        let mut config = Config::default();
196        config.extra.insert(
197            "subagentRouting".to_string(),
198            serde_json::json!({
199                "impl": { "runtime": "external", "agent_id": "remote_impl" },
200                "plan": { "runtime": "bamboo" }
201            }),
202        );
203
204        let routing = parse_subagent_routing(&config);
205        assert_eq!(routing.len(), 2);
206        assert_eq!(routing.get("impl").unwrap().runtime, "external");
207        assert_eq!(
208            routing.get("impl").unwrap().agent_id,
209            Some("remote_impl".to_string())
210        );
211        assert_eq!(routing.get("plan").unwrap().runtime, "bamboo");
212    }
213
214    #[test]
215    fn resolve_runtime_metadata_routes_impl_to_external() {
216        let mut config = Config::default();
217        config.extra.insert(
218            "externalAgents".to_string(),
219            serde_json::json!({
220                "remote_impl": {
221                    "agent_id": "remote_impl",
222                    "protocol": "a2a_jsonrpc",
223                    "permission_profile": "remote_limited"
224                }
225            }),
226        );
227        config.extra.insert(
228            "subagentRouting".to_string(),
229            serde_json::json!({
230                "impl": { "runtime": "external", "agent_id": "remote_impl" }
231            }),
232        );
233
234        let metadata = resolve_runtime_metadata(&config, "impl");
235        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
236        assert_eq!(
237            metadata.get("external.protocol"),
238            Some(&"a2a_jsonrpc".to_string())
239        );
240        assert_eq!(
241            metadata.get("external.agent_id"),
242            Some(&"remote_impl".to_string())
243        );
244    }
245
246    #[test]
247    fn unknown_type_defaults_to_local_actor() {
248        // Sub-agents always run as actors: a type with no expert routing
249        // resolves to the built-in local actor worker.
250        let mut config = Config::default();
251        config.subagents = Default::default();
252        let metadata = resolve_runtime_metadata(&config, "unknown");
253        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
254        assert_eq!(
255            metadata.get("external.protocol"),
256            Some(&"actor".to_string())
257        );
258        assert_eq!(
259            metadata.get("external.agent_id"),
260            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
261        );
262    }
263
264    #[test]
265    fn every_type_defaults_to_local_actor() {
266        let config = Config::default();
267        let metadata = resolve_runtime_metadata(&config, "researcher");
268        assert_eq!(metadata.get("runtime.kind"), Some(&"external".to_string()));
269        assert_eq!(
270            metadata.get("external.protocol"),
271            Some(&"actor".to_string())
272        );
273        assert_eq!(
274            metadata.get("external.agent_id"),
275            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
276        );
277    }
278
279    #[test]
280    fn legacy_bamboo_routing_falls_through_to_local_actor() {
281        // `runtime: "bamboo"` used to mean in-process; with in-process removed
282        // it falls through to the local-actor default.
283        let mut config = Config::default();
284        config.extra.insert(
285            "subagentRouting".to_string(),
286            serde_json::json!({
287                "plan": { "runtime": "bamboo" }
288            }),
289        );
290
291        let metadata = resolve_runtime_metadata(&config, "plan");
292        assert_eq!(
293            metadata.get("external.agent_id"),
294            Some(&LOCAL_ACTOR_AGENT_ID.to_string())
295        );
296    }
297
298    #[test]
299    fn legacy_external_routing_selects_remote_agent() {
300        let mut config = Config::default();
301        config.extra.insert(
302            "externalAgents".to_string(),
303            serde_json::json!({
304                "remote_impl": {
305                    "agent_id": "remote_impl",
306                    "protocol": "a2a_jsonrpc",
307                    "permission_profile": "remote_limited"
308                }
309            }),
310        );
311        config.extra.insert(
312            "subagentRouting".to_string(),
313            serde_json::json!({
314                "impl": { "runtime": "external", "agent_id": "remote_impl" }
315            }),
316        );
317
318        // explicit legacy per-type external routing still selects the remote agent
319        let metadata = resolve_runtime_metadata(&config, "impl");
320        assert_eq!(
321            metadata.get("external.agent_id"),
322            Some(&"remote_impl".to_string())
323        );
324    }
325}