bamboo_engine/external_agents/
config.rs1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub worker_bin: Option<String>,
26 #[serde(default)]
30 pub worker_args: Vec<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub fabric_dir: Option<String>,
35 #[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 #[serde(rename = "actor", alias = "subprocess")]
48 Actor,
49}
50
51#[derive(Debug, Clone, Deserialize)]
52pub struct SubagentRouting {
53 pub runtime: String, #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub agent_id: Option<String>,
56}
57
58pub 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
76pub 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
94pub const LOCAL_ACTOR_AGENT_ID: &str = "local-actor";
97
98pub 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 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 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 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 #[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 assert!(resolve_runtime_metadata(&config, "researcher").is_empty());
296 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 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 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}