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")]
40 pub executor: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub claude_code_binary: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub claude_code_model: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub claude_code_permission_mode: Option<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub claude_code_inherit_user_config: Option<bool>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub claude_code_forward_env: Option<Vec<String>>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub codex_binary: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub codex_model: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub codex_mode: Option<bamboo_config::CodexMode>,
77 #[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 #[serde(rename = "actor", alias = "subprocess")]
104 Actor,
105}
106
107#[derive(Debug, Clone, Deserialize)]
108pub struct SubagentRouting {
109 pub runtime: String, #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub agent_id: Option<String>,
112}
113
114pub 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
132pub 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
150pub const LOCAL_ACTOR_AGENT_ID: &str = "local-actor";
153
154pub 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 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 }
209
210 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 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 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 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}