1use std::sync::Arc;
2
3use crate::runtime::execution::{ExternalChildRunner, SpawnJob};
4use async_trait::async_trait;
5use bamboo_a2a::A2AJsonRpcClient;
6use bamboo_agent_core::{AgentError, AgentEvent};
7use bamboo_llm::Config;
8use tokio::sync::mpsc;
9use tokio_util::sync::CancellationToken;
10
11use super::a2a_adapter::A2AExternalChildRunner;
12use super::actor_adapter::ActorChildRunner;
13use super::config::{parse_external_agents, ExternalAgentProtocol};
14
15pub struct CompositeExternalChildRunner {
17 runners: Vec<Arc<dyn ExternalChildRunner>>,
18}
19
20impl CompositeExternalChildRunner {
21 pub fn new(runners: Vec<Arc<dyn ExternalChildRunner>>) -> Self {
22 Self { runners }
23 }
24}
25
26#[async_trait]
27impl ExternalChildRunner for CompositeExternalChildRunner {
28 async fn should_handle(&self, session: &bamboo_agent_core::Session) -> bool {
29 for runner in &self.runners {
30 if runner.should_handle(session).await {
31 return true;
32 }
33 }
34 false
35 }
36
37 async fn execute_external_child(
38 &self,
39 session: &mut bamboo_agent_core::Session,
40 job: &SpawnJob,
41 event_tx: mpsc::Sender<AgentEvent>,
42 cancel_token: CancellationToken,
43 ) -> crate::runtime::runner::Result<()> {
44 for runner in &self.runners {
45 if runner.should_handle(session).await {
46 return runner
47 .execute_external_child(session, job, event_tx, cancel_token)
48 .await;
49 }
50 }
51 Err(AgentError::LLM(
52 "No matching external child runner found for session metadata".to_string(),
53 ))
54 }
55
56 fn set_escalation_bridge(&self, bridge: Option<bamboo_subagent::executor::HostBridge>) {
61 for runner in &self.runners {
62 runner.set_escalation_bridge(bridge.clone());
63 }
64 }
65}
66
67pub fn build_external_child_runner(config: &Config) -> Arc<dyn ExternalChildRunner> {
76 let agents = parse_external_agents(config);
77
78 let mut runners: Vec<Arc<dyn ExternalChildRunner>> = Vec::new();
79
80 match build_local_actor_runner(config) {
84 Ok(runner) => runners.push(runner),
85 Err(e) => tracing::error!("local actor sub-agent runner unavailable: {e}"),
86 }
87
88 for (_agent_id, profile) in agents {
89 if matches!(profile.protocol, ExternalAgentProtocol::Actor) {
91 let Some(worker_bin) = profile.worker_bin.as_ref() else {
92 tracing::error!(
93 "Actor agent profile {} has no worker_bin; skipping",
94 profile.agent_id
95 );
96 continue;
97 };
98 let fabric_dir = profile
102 .fabric_dir
103 .clone()
104 .map(std::path::PathBuf::from)
105 .unwrap_or_else(bamboo_config::paths::subagents_dir);
106 let executor = match profile.executor.as_deref() {
107 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
108 Some("bamboo_runtime") | None => {
109 bamboo_subagent::provision::ExecutorSpec::BambooRuntime
110 }
111 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
114 binary: profile.claude_code_binary.clone(),
115 model: profile.claude_code_model.clone(),
116 permission_mode: profile.claude_code_permission_mode.clone(),
117 inherit_user_config: profile.claude_code_inherit_user_config,
118 forward_env: profile.claude_code_forward_env.clone(),
119 },
120 Some(other) => {
121 tracing::error!(
122 "Actor agent profile {} has unknown executor '{}'; skipping",
123 profile.agent_id,
124 other
125 );
126 continue;
127 }
128 };
129 runners.push(Arc::new(ActorChildRunner::new(
130 profile.agent_id.clone(),
131 std::path::PathBuf::from(worker_bin),
132 profile.worker_args.clone(),
133 fabric_dir,
134 executor,
135 extract_provider_credentials(config),
136 config.provider.clone(),
137 config
138 .subagents
139 .max_concurrent
140 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
141 )));
142 continue;
143 }
144
145 if !matches!(profile.protocol, ExternalAgentProtocol::A2aJsonRpc) {
146 tracing::warn!(
147 "External agent profile {} uses unsupported protocol {:?}",
148 profile.agent_id,
149 profile.protocol
150 );
151 continue;
152 }
153
154 let auth_token = match profile.auth_ref.as_ref() {
155 Some(ref_name) => match std::env::var(ref_name) {
156 Ok(token) => Some(token),
157 Err(_) => {
158 tracing::error!(
159 "External agent profile {} auth_ref env var {} is not set",
160 profile.agent_id,
161 ref_name
162 );
163 continue;
164 }
165 },
166 None => None,
167 };
168
169 let client_config = match A2AExternalChildRunner::build_client_config(&profile, auth_token)
170 {
171 Ok(cfg) => cfg,
172 Err(e) => {
173 tracing::error!(
174 "Failed to build A2A client config for profile {}: {}",
175 profile.agent_id,
176 e
177 );
178 continue;
179 }
180 };
181
182 let client = match A2AJsonRpcClient::new(client_config) {
183 Ok(c) => c,
184 Err(e) => {
185 tracing::error!(
186 "Failed to create A2A JSON-RPC client for profile {}: {}",
187 profile.agent_id,
188 e
189 );
190 continue;
191 }
192 };
193
194 runners.push(Arc::new(A2AExternalChildRunner::new(client, profile)));
195 }
196
197 Arc::new(CompositeExternalChildRunner::new(runners))
198}
199
200fn build_local_actor_runner(config: &Config) -> Result<Arc<dyn ExternalChildRunner>, String> {
205 let sub = &config.subagents;
206
207 let (worker_bin, worker_args) = match &sub.worker_bin {
208 Some(custom) => (
209 std::path::PathBuf::from(custom),
210 sub.worker_args.clone().unwrap_or_default(),
211 ),
212 None => (
213 std::env::current_exe().map_err(|e| format!("cannot locate own executable: {e}"))?,
214 sub.worker_args
215 .clone()
216 .unwrap_or_else(|| vec!["subagent-worker".to_string()]),
217 ),
218 };
219
220 let fabric_dir = sub
223 .fabric_dir
224 .clone()
225 .map(std::path::PathBuf::from)
226 .unwrap_or_else(bamboo_config::paths::subagents_dir);
227
228 let executor = match sub.executor.as_deref() {
229 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
230 Some("bamboo_runtime") | None => bamboo_subagent::provision::ExecutorSpec::BambooRuntime,
231 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
235 binary: sub.claude_code_binary.clone(),
236 model: sub.claude_code_model.clone(),
237 permission_mode: sub.claude_code_permission_mode.clone(),
238 inherit_user_config: sub.claude_code_inherit_user_config,
239 forward_env: sub.claude_code_forward_env.clone(),
240 },
241 Some(other) => return Err(format!("unknown subagents.executor '{other}'")),
242 };
243
244 Ok(Arc::new(
245 ActorChildRunner::new(
246 super::config::LOCAL_ACTOR_AGENT_ID.to_string(),
247 worker_bin,
248 worker_args,
249 fabric_dir,
250 executor,
251 extract_provider_credentials(config),
252 config.provider.clone(),
253 sub.max_concurrent
254 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
255 )
256 .with_remote_placements(resolve_remote_placements(
257 &sub.remote_placements,
258 &config.cluster_fabric.nodes,
259 ))
260 .with_schedulable_placements(resolve_schedulable_placements(
261 &sub.schedulable_placements,
262 &config.cluster_fabric.nodes,
263 ))
264 .with_bus(sub.broker.as_ref().map(|b| bamboo_subagent::BusEndpoint {
265 endpoint: b.endpoint.clone(),
266 token: b.token.clone(),
267 })),
268 ))
269}
270
271fn resolve_schedulable_placements(
280 placements: &[bamboo_config::SchedulablePlacement],
281 nodes: &[bamboo_config::cluster_fabric::Node],
282) -> std::collections::HashMap<String, super::actor_adapter::ResolvedSchedulablePlacement> {
283 placements
286 .iter()
287 .map(|p| {
288 (
289 p.role.clone(),
290 super::actor_adapter::ResolvedSchedulablePlacement {
291 pool: p.pool.clone(),
292 host_label: node_label_for_role(nodes, &p.pool),
295 },
296 )
297 })
298 .collect()
299}
300
301fn node_label_for_role(
305 nodes: &[bamboo_config::cluster_fabric::Node],
306 role: &str,
307) -> Option<String> {
308 nodes
309 .iter()
310 .find(|n| n.deploy.default_role.as_deref() == Some(role))
311 .map(node_display_name)
312}
313
314fn node_label_for_endpoint(
318 nodes: &[bamboo_config::cluster_fabric::Node],
319 endpoint: &str,
320) -> Option<String> {
321 let host = endpoint
322 .trim()
323 .trim_start_matches("wss://")
324 .trim_start_matches("ws://")
325 .split(['/', ':'])
326 .next()
327 .unwrap_or("");
328 if host.is_empty() {
329 return None;
330 }
331 nodes
332 .iter()
333 .find(|n| match &n.placement {
334 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host == host,
335 bamboo_config::cluster_fabric::NodePlacement::Local => false,
336 })
337 .map(node_display_name)
338}
339
340fn node_display_name(n: &bamboo_config::cluster_fabric::Node) -> String {
341 if !n.label.trim().is_empty() {
342 return n.label.clone();
343 }
344 match &n.placement {
345 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host.clone(),
346 bamboo_config::cluster_fabric::NodePlacement::Local => "local".to_string(),
347 }
348}
349
350fn endpoint_looks_public(endpoint: &str) -> bool {
361 if endpoint.starts_with("wss://") {
362 return true;
363 }
364 let host = endpoint
365 .strip_prefix("ws://")
366 .unwrap_or(endpoint)
367 .split(['/', ':'])
368 .next()
369 .unwrap_or("");
370 !(host == "localhost" || host == "127.0.0.1" || host == "::1" || host.is_empty())
371}
372
373fn resolve_remote_placements(
374 placements: &[bamboo_config::RemoteActorPlacement],
375 nodes: &[bamboo_config::cluster_fabric::Node],
376) -> std::collections::HashMap<String, super::actor_adapter::ResolvedRemotePlacement> {
377 let mut out = std::collections::HashMap::new();
378 for p in placements {
379 let token = match p.token_env.as_deref() {
380 Some(env_var) => match std::env::var(env_var) {
381 Ok(token) => Some(token),
382 Err(_) => {
383 tracing::error!(
384 "remote placement for role '{}' token_env '{}' is not set; \
385 skipping (role falls back to local, NOT unauthenticated remote)",
386 p.role,
387 env_var
388 );
389 continue;
390 }
391 },
392 None => {
393 if endpoint_looks_public(&p.endpoint) {
397 tracing::warn!(
398 "remote placement for role '{}' has no token_env but targets a \
399 public-looking endpoint '{}'; work will be dispatched with NO bearer. \
400 Set token_env (and use wss://) for any non-loopback worker.",
401 p.role,
402 p.endpoint
403 );
404 }
405 None
406 }
407 };
408 out.insert(
409 p.role.clone(),
410 super::actor_adapter::ResolvedRemotePlacement {
411 endpoint: p.endpoint.clone(),
412 token,
413 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
414 host_label: node_label_for_endpoint(nodes, &p.endpoint),
417 },
418 );
419 }
420 out
421}
422
423pub fn extract_provider_credentials(
431 config: &Config,
432) -> Vec<bamboo_subagent::provision::ScopedCredential> {
433 let mut out = Vec::new();
434
435 let mut push_legacy = |name: &str, api_key: &str, base_url: Option<String>| {
439 let api_key = api_key.trim().to_string();
440 if api_key.is_empty() {
441 return;
442 }
443 out.push(bamboo_subagent::provision::ScopedCredential {
444 provider: name.to_string(),
445 api_key,
446 base_url,
447 provider_type: Some(name.to_string()),
448 });
449 };
450 if let Some(c) = &config.providers.openai {
451 push_legacy("openai", &c.api_key, c.base_url.clone());
452 }
453 if let Some(c) = &config.providers.anthropic {
454 push_legacy("anthropic", &c.api_key, c.base_url.clone());
455 }
456 if let Some(c) = &config.providers.gemini {
457 push_legacy("gemini", &c.api_key, c.base_url.clone());
458 }
459 if let Some(c) = &config.providers.bodhi {
460 push_legacy("bodhi", &c.api_key, c.base_url.clone());
461 }
462
463 out.extend(config.provider_instances.iter().filter_map(|(id, inst)| {
468 let api_key = inst.api_key.trim().to_string();
469 if api_key.is_empty() {
470 return None;
471 }
472 Some(bamboo_subagent::provision::ScopedCredential {
473 provider: id.clone(),
474 api_key,
475 base_url: inst.base_url.clone(),
476 provider_type: Some(inst.provider_type.clone()),
477 })
478 }));
479
480 out
481}
482
483#[cfg(test)]
484mod extract_provider_credentials_tests {
485 use super::extract_provider_credentials;
486 use bamboo_config::{
487 AnthropicConfig, BodhiConfig, Config, OpenAIConfig, ProviderInstanceConfig,
488 };
489
490 fn instance(provider_type: &str, api_key: &str) -> ProviderInstanceConfig {
491 ProviderInstanceConfig {
492 provider_type: provider_type.to_string(),
493 label: None,
494 api_key: api_key.to_string(),
495 api_key_encrypted: None,
496 base_url: None,
497 model: None,
498 fast_model: None,
499 vision_model: None,
500 reasoning_effort: None,
501 responses_only_models: Vec::new(),
502 request_overrides: None,
503 enabled: true,
504 extra: Default::default(),
505 }
506 }
507
508 #[test]
509 fn no_config_yields_no_credentials() {
510 let config = Config::default();
511 assert!(extract_provider_credentials(&config).is_empty());
512 }
513
514 #[test]
519 fn legacy_only_config_yields_credential() {
520 let mut config = Config::default();
521 config.providers.anthropic = Some(AnthropicConfig {
522 api_key: "sk-ant-legacy".to_string(),
523 base_url: Some("https://api.anthropic.com".to_string()),
524 ..Default::default()
525 });
526
527 let creds = extract_provider_credentials(&config);
528 assert_eq!(creds.len(), 1);
529 let c = &creds[0];
530 assert_eq!(c.provider, "anthropic");
531 assert_eq!(c.api_key, "sk-ant-legacy");
532 assert_eq!(c.base_url.as_deref(), Some("https://api.anthropic.com"));
533 assert_eq!(c.provider_type.as_deref(), Some("anthropic"));
534 }
535
536 #[test]
540 fn legacy_bodhi_config_yields_credential() {
541 let mut config = Config::default();
542 config.providers.bodhi = Some(BodhiConfig {
543 api_key: "bhi_sk_legacy".to_string(),
544 api_key_encrypted: None,
545 base_url: None,
546 target_provider: None,
547 reasoning_effort: None,
548 extra: Default::default(),
549 });
550
551 let creds = extract_provider_credentials(&config);
552 assert_eq!(creds.len(), 1);
553 assert_eq!(creds[0].provider, "bodhi");
554 assert_eq!(creds[0].api_key, "bhi_sk_legacy");
555 }
556
557 #[test]
560 fn legacy_config_with_empty_api_key_is_skipped() {
561 let mut config = Config::default();
562 config.providers.openai = Some(OpenAIConfig::default());
563 assert!(extract_provider_credentials(&config).is_empty());
564 }
565
566 #[test]
569 fn legacy_and_instances_both_present_no_duplicates() {
570 let mut config = Config::default();
571 config.providers.anthropic = Some(AnthropicConfig {
572 api_key: "sk-ant-legacy".to_string(),
573 ..Default::default()
574 });
575 config
576 .provider_instances
577 .insert("openai-work".to_string(), instance("openai", "sk-oai-work"));
578
579 let mut creds = extract_provider_credentials(&config);
580 creds.sort_by(|a, b| a.provider.cmp(&b.provider));
581
582 assert_eq!(creds.len(), 2);
583 assert_eq!(creds[0].provider, "anthropic");
584 assert_eq!(creds[0].api_key, "sk-ant-legacy");
585 assert_eq!(creds[1].provider, "openai-work");
586 assert_eq!(creds[1].api_key, "sk-oai-work");
587 assert_eq!(creds[1].provider_type.as_deref(), Some("openai"));
588 }
589}
590
591#[cfg(test)]
592mod placement_resolver_tests {
593 use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
594 use bamboo_config::cluster_fabric::{
595 DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
596 };
597 use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
598
599 fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
600 Node {
601 id: id.into(),
602 label: label.into(),
603 placement: NodePlacement::Ssh(SshTarget {
604 host: host.into(),
605 port: 22,
606 username: "u".into(),
607 auth: SshAuth::SystemSshConfig,
608 host_key_fingerprint: None,
609 }),
610 trust_level: TrustLevel::default(),
611 deploy: DeployProfile {
612 default_role: default_role.map(String::from),
613 ..Default::default()
614 },
615 state: None,
616 enabled: true,
617 }
618 }
619
620 #[test]
621 fn node_display_name_prefers_label_then_ssh_host() {
622 let n = ssh_node("n1", "mini", "mini.local", None);
623 assert_eq!(node_display_name(&n), "mini");
624 let mut unlabeled = n.clone();
625 unlabeled.label = String::new();
626 assert_eq!(node_display_name(&unlabeled), "mini.local");
627 }
628
629 #[test]
630 fn schedulable_placement_takes_host_label_from_node_by_default_role() {
631 let nodes = vec![ssh_node(
632 "n1",
633 "mini",
634 "mini.local",
635 Some("mac-mini-monitor"),
636 )];
637 let placements = vec![SchedulablePlacement {
638 role: "mac-mini-monitor".into(),
639 pool: "mac-mini-monitor".into(),
640 ..Default::default()
641 }];
642 let out = resolve_schedulable_placements(&placements, &nodes);
643 let r = out.get("mac-mini-monitor").expect("role resolved");
644 assert_eq!(r.pool, "mac-mini-monitor");
645 assert_eq!(r.host_label.as_deref(), Some("mini"));
646 }
647
648 #[test]
649 fn remote_placement_takes_host_label_from_node_by_ssh_host() {
650 let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
651 let placements = vec![RemoteActorPlacement {
652 role: "explorer".into(),
653 endpoint: "ws://mini.local:8899".into(),
654 ..Default::default()
655 }];
656 let out = resolve_remote_placements(&placements, &nodes);
657 assert_eq!(
658 out.get("explorer").unwrap().host_label.as_deref(),
659 Some("mini")
660 );
661 }
662
663 #[test]
664 fn no_host_label_when_no_node_matches() {
665 let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
666 let sched = vec![SchedulablePlacement {
667 role: "x".into(),
668 pool: "unmatched".into(),
669 ..Default::default()
670 }];
671 assert_eq!(
672 resolve_schedulable_placements(&sched, &nodes)
673 .get("x")
674 .unwrap()
675 .host_label,
676 None
677 );
678 let remote = vec![RemoteActorPlacement {
679 role: "y".into(),
680 endpoint: "ws://other-host:9000".into(),
681 ..Default::default()
682 }];
683 assert_eq!(
684 resolve_remote_placements(&remote, &nodes)
685 .get("y")
686 .unwrap()
687 .host_label,
688 None
689 );
690 }
691}