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
99 .fabric_dir
100 .clone()
101 .map(std::path::PathBuf::from)
102 .unwrap_or_else(|| std::env::temp_dir().join("bamboo-subagents"));
103 let executor = match profile.executor.as_deref() {
104 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
105 Some("bamboo_runtime") | None => {
106 bamboo_subagent::provision::ExecutorSpec::BambooRuntime
107 }
108 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
111 binary: profile.claude_code_binary.clone(),
112 model: profile.claude_code_model.clone(),
113 permission_mode: profile.claude_code_permission_mode.clone(),
114 inherit_user_config: profile.claude_code_inherit_user_config,
115 forward_env: profile.claude_code_forward_env.clone(),
116 },
117 Some(other) => {
118 tracing::error!(
119 "Actor agent profile {} has unknown executor '{}'; skipping",
120 profile.agent_id,
121 other
122 );
123 continue;
124 }
125 };
126 runners.push(Arc::new(ActorChildRunner::new(
127 profile.agent_id.clone(),
128 std::path::PathBuf::from(worker_bin),
129 profile.worker_args.clone(),
130 fabric_dir,
131 executor,
132 extract_provider_credentials(config),
133 config.provider.clone(),
134 config
135 .subagents
136 .max_concurrent
137 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
138 )));
139 continue;
140 }
141
142 if !matches!(profile.protocol, ExternalAgentProtocol::A2aJsonRpc) {
143 tracing::warn!(
144 "External agent profile {} uses unsupported protocol {:?}",
145 profile.agent_id,
146 profile.protocol
147 );
148 continue;
149 }
150
151 let auth_token = match profile.auth_ref.as_ref() {
152 Some(ref_name) => match std::env::var(ref_name) {
153 Ok(token) => Some(token),
154 Err(_) => {
155 tracing::error!(
156 "External agent profile {} auth_ref env var {} is not set",
157 profile.agent_id,
158 ref_name
159 );
160 continue;
161 }
162 },
163 None => None,
164 };
165
166 let client_config = match A2AExternalChildRunner::build_client_config(&profile, auth_token)
167 {
168 Ok(cfg) => cfg,
169 Err(e) => {
170 tracing::error!(
171 "Failed to build A2A client config for profile {}: {}",
172 profile.agent_id,
173 e
174 );
175 continue;
176 }
177 };
178
179 let client = match A2AJsonRpcClient::new(client_config) {
180 Ok(c) => c,
181 Err(e) => {
182 tracing::error!(
183 "Failed to create A2A JSON-RPC client for profile {}: {}",
184 profile.agent_id,
185 e
186 );
187 continue;
188 }
189 };
190
191 runners.push(Arc::new(A2AExternalChildRunner::new(client, profile)));
192 }
193
194 Arc::new(CompositeExternalChildRunner::new(runners))
195}
196
197fn build_local_actor_runner(config: &Config) -> Result<Arc<dyn ExternalChildRunner>, String> {
202 let sub = &config.subagents;
203
204 let (worker_bin, worker_args) = match &sub.worker_bin {
205 Some(custom) => (
206 std::path::PathBuf::from(custom),
207 sub.worker_args.clone().unwrap_or_default(),
208 ),
209 None => (
210 std::env::current_exe().map_err(|e| format!("cannot locate own executable: {e}"))?,
211 sub.worker_args
212 .clone()
213 .unwrap_or_else(|| vec!["subagent-worker".to_string()]),
214 ),
215 };
216
217 let fabric_dir = sub
218 .fabric_dir
219 .clone()
220 .map(std::path::PathBuf::from)
221 .unwrap_or_else(|| std::env::temp_dir().join("bamboo-subagents"));
222
223 let executor = match sub.executor.as_deref() {
224 Some("echo") => bamboo_subagent::provision::ExecutorSpec::Echo,
225 Some("bamboo_runtime") | None => bamboo_subagent::provision::ExecutorSpec::BambooRuntime,
226 Some("claude_code") => bamboo_subagent::provision::ExecutorSpec::ClaudeCode {
230 binary: sub.claude_code_binary.clone(),
231 model: sub.claude_code_model.clone(),
232 permission_mode: sub.claude_code_permission_mode.clone(),
233 inherit_user_config: sub.claude_code_inherit_user_config,
234 forward_env: sub.claude_code_forward_env.clone(),
235 },
236 Some(other) => return Err(format!("unknown subagents.executor '{other}'")),
237 };
238
239 Ok(Arc::new(
240 ActorChildRunner::new(
241 super::config::LOCAL_ACTOR_AGENT_ID.to_string(),
242 worker_bin,
243 worker_args,
244 fabric_dir,
245 executor,
246 extract_provider_credentials(config),
247 config.provider.clone(),
248 sub.max_concurrent
249 .unwrap_or(super::actor_adapter::DEFAULT_MAX_CONCURRENT_ACTORS),
250 )
251 .with_remote_placements(resolve_remote_placements(
252 &sub.remote_placements,
253 &config.cluster_fabric.nodes,
254 ))
255 .with_schedulable_placements(resolve_schedulable_placements(
256 &sub.schedulable_placements,
257 &config.cluster_fabric.nodes,
258 ))
259 .with_bus(sub.broker.as_ref().map(|b| bamboo_subagent::BusEndpoint {
260 endpoint: b.endpoint.clone(),
261 token: b.token.clone(),
262 })),
263 ))
264}
265
266fn resolve_schedulable_placements(
275 placements: &[bamboo_config::SchedulablePlacement],
276 nodes: &[bamboo_config::cluster_fabric::Node],
277) -> std::collections::HashMap<String, super::actor_adapter::ResolvedSchedulablePlacement> {
278 placements
281 .iter()
282 .map(|p| {
283 (
284 p.role.clone(),
285 super::actor_adapter::ResolvedSchedulablePlacement {
286 pool: p.pool.clone(),
287 host_label: node_label_for_role(nodes, &p.pool),
290 },
291 )
292 })
293 .collect()
294}
295
296fn node_label_for_role(
300 nodes: &[bamboo_config::cluster_fabric::Node],
301 role: &str,
302) -> Option<String> {
303 nodes
304 .iter()
305 .find(|n| n.deploy.default_role.as_deref() == Some(role))
306 .map(node_display_name)
307}
308
309fn node_label_for_endpoint(
313 nodes: &[bamboo_config::cluster_fabric::Node],
314 endpoint: &str,
315) -> Option<String> {
316 let host = endpoint
317 .trim()
318 .trim_start_matches("wss://")
319 .trim_start_matches("ws://")
320 .split(['/', ':'])
321 .next()
322 .unwrap_or("");
323 if host.is_empty() {
324 return None;
325 }
326 nodes
327 .iter()
328 .find(|n| match &n.placement {
329 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host == host,
330 bamboo_config::cluster_fabric::NodePlacement::Local => false,
331 })
332 .map(node_display_name)
333}
334
335fn node_display_name(n: &bamboo_config::cluster_fabric::Node) -> String {
336 if !n.label.trim().is_empty() {
337 return n.label.clone();
338 }
339 match &n.placement {
340 bamboo_config::cluster_fabric::NodePlacement::Ssh(t) => t.host.clone(),
341 bamboo_config::cluster_fabric::NodePlacement::Local => "local".to_string(),
342 }
343}
344
345fn endpoint_looks_public(endpoint: &str) -> bool {
356 if endpoint.starts_with("wss://") {
357 return true;
358 }
359 let host = endpoint
360 .strip_prefix("ws://")
361 .unwrap_or(endpoint)
362 .split(['/', ':'])
363 .next()
364 .unwrap_or("");
365 !(host == "localhost" || host == "127.0.0.1" || host == "::1" || host.is_empty())
366}
367
368fn resolve_remote_placements(
369 placements: &[bamboo_config::RemoteActorPlacement],
370 nodes: &[bamboo_config::cluster_fabric::Node],
371) -> std::collections::HashMap<String, super::actor_adapter::ResolvedRemotePlacement> {
372 let mut out = std::collections::HashMap::new();
373 for p in placements {
374 let token = match p.token_env.as_deref() {
375 Some(env_var) => match std::env::var(env_var) {
376 Ok(token) => Some(token),
377 Err(_) => {
378 tracing::error!(
379 "remote placement for role '{}' token_env '{}' is not set; \
380 skipping (role falls back to local, NOT unauthenticated remote)",
381 p.role,
382 env_var
383 );
384 continue;
385 }
386 },
387 None => {
388 if endpoint_looks_public(&p.endpoint) {
392 tracing::warn!(
393 "remote placement for role '{}' has no token_env but targets a \
394 public-looking endpoint '{}'; work will be dispatched with NO bearer. \
395 Set token_env (and use wss://) for any non-loopback worker.",
396 p.role,
397 p.endpoint
398 );
399 }
400 None
401 }
402 };
403 out.insert(
404 p.role.clone(),
405 super::actor_adapter::ResolvedRemotePlacement {
406 endpoint: p.endpoint.clone(),
407 token,
408 ca_cert_file: p.ca_cert_file.as_ref().map(std::path::PathBuf::from),
409 host_label: node_label_for_endpoint(nodes, &p.endpoint),
412 },
413 );
414 }
415 out
416}
417
418pub fn extract_provider_credentials(
423 config: &Config,
424) -> Vec<bamboo_subagent::provision::ScopedCredential> {
425 let mut out = Vec::new();
426
427 if let Ok(serde_json::Value::Object(providers)) = serde_json::to_value(&config.providers) {
429 out.extend(providers.into_iter().filter_map(|(name, slot)| {
430 let api_key = slot.get("api_key")?.as_str()?.trim().to_string();
431 if api_key.is_empty() {
432 return None;
433 }
434 Some(bamboo_subagent::provision::ScopedCredential {
435 provider: name.clone(),
436 api_key,
437 base_url: slot
438 .get("base_url")
439 .and_then(|v| v.as_str())
440 .map(str::to_string),
441 provider_type: Some(name),
442 })
443 }));
444 }
445
446 out.extend(config.provider_instances.iter().filter_map(|(id, inst)| {
451 let api_key = inst.api_key.trim().to_string();
452 if api_key.is_empty() {
453 return None;
454 }
455 Some(bamboo_subagent::provision::ScopedCredential {
456 provider: id.clone(),
457 api_key,
458 base_url: inst.base_url.clone(),
459 provider_type: Some(inst.provider_type.clone()),
460 })
461 }));
462
463 out
464}
465
466#[cfg(test)]
467mod placement_resolver_tests {
468 use super::{node_display_name, resolve_remote_placements, resolve_schedulable_placements};
469 use bamboo_config::cluster_fabric::{
470 DeployProfile, Node, NodePlacement, SshAuth, SshTarget, TrustLevel,
471 };
472 use bamboo_config::{RemoteActorPlacement, SchedulablePlacement};
473
474 fn ssh_node(id: &str, label: &str, host: &str, default_role: Option<&str>) -> Node {
475 Node {
476 id: id.into(),
477 label: label.into(),
478 placement: NodePlacement::Ssh(SshTarget {
479 host: host.into(),
480 port: 22,
481 username: "u".into(),
482 auth: SshAuth::SystemSshConfig,
483 host_key_fingerprint: None,
484 }),
485 trust_level: TrustLevel::default(),
486 deploy: DeployProfile {
487 default_role: default_role.map(String::from),
488 ..Default::default()
489 },
490 state: None,
491 enabled: true,
492 }
493 }
494
495 #[test]
496 fn node_display_name_prefers_label_then_ssh_host() {
497 let n = ssh_node("n1", "mini", "mini.local", None);
498 assert_eq!(node_display_name(&n), "mini");
499 let mut unlabeled = n.clone();
500 unlabeled.label = String::new();
501 assert_eq!(node_display_name(&unlabeled), "mini.local");
502 }
503
504 #[test]
505 fn schedulable_placement_takes_host_label_from_node_by_default_role() {
506 let nodes = vec![ssh_node(
507 "n1",
508 "mini",
509 "mini.local",
510 Some("mac-mini-monitor"),
511 )];
512 let placements = vec![SchedulablePlacement {
513 role: "mac-mini-monitor".into(),
514 pool: "mac-mini-monitor".into(),
515 ..Default::default()
516 }];
517 let out = resolve_schedulable_placements(&placements, &nodes);
518 let r = out.get("mac-mini-monitor").expect("role resolved");
519 assert_eq!(r.pool, "mac-mini-monitor");
520 assert_eq!(r.host_label.as_deref(), Some("mini"));
521 }
522
523 #[test]
524 fn remote_placement_takes_host_label_from_node_by_ssh_host() {
525 let nodes = vec![ssh_node("n1", "mini", "mini.local", None)];
526 let placements = vec![RemoteActorPlacement {
527 role: "explorer".into(),
528 endpoint: "ws://mini.local:8899".into(),
529 ..Default::default()
530 }];
531 let out = resolve_remote_placements(&placements, &nodes);
532 assert_eq!(
533 out.get("explorer").unwrap().host_label.as_deref(),
534 Some("mini")
535 );
536 }
537
538 #[test]
539 fn no_host_label_when_no_node_matches() {
540 let nodes = vec![ssh_node("n1", "mini", "mini.local", Some("other-role"))];
541 let sched = vec![SchedulablePlacement {
542 role: "x".into(),
543 pool: "unmatched".into(),
544 ..Default::default()
545 }];
546 assert_eq!(
547 resolve_schedulable_placements(&sched, &nodes)
548 .get("x")
549 .unwrap()
550 .host_label,
551 None
552 );
553 let remote = vec![RemoteActorPlacement {
554 role: "y".into(),
555 endpoint: "ws://other-host:9000".into(),
556 ..Default::default()
557 }];
558 assert_eq!(
559 resolve_remote_placements(&remote, &nodes)
560 .get("y")
561 .unwrap()
562 .host_label,
563 None
564 );
565 }
566}