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")]
41 pub executor: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub claude_code_binary: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub claude_code_model: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub claude_code_permission_mode: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub claude_code_inherit_user_config: Option<bool>,
62 #[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 #[serde(rename = "actor", alias = "subprocess")]
77 Actor,
78}
79
80#[derive(Debug, Clone, Deserialize)]
81pub struct SubagentRouting {
82 pub runtime: String, #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub agent_id: Option<String>,
85}
86
87pub 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
105pub 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
123pub const LOCAL_ACTOR_AGENT_ID: &str = "local-actor";
126
127pub 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 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 }
182
183 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 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 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 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}