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