Skip to main content

bamboo_engine/external_agents/
runtime.rs

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
15/// Composite router that delegates to the first matching external child runner.
16pub 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    /// #68: fan the per-run escalation bridge out to every inner runner. The
57    /// composite is what `build_external_child_runner` returns and what the
58    /// worker retains, so without this forward the bind would hit the trait's
59    /// no-op default and the wrapped `ActorChildRunner`s would never see it.
60    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
67/// Build the child runner from the application config.
68///
69/// Sub-agents always run as actors (the in-process runtime was removed), so the
70/// built-in **local actor** worker is always part of the composite — its worker
71/// binary, arguments, and discovery dir are all derived; no expert tables
72/// needed. Expert `externalAgents` profiles add extra routers so
73/// `external.agent_id` metadata can pin specific roles to other agents. Returns
74/// a composite router that delegates to the first matching runner.
75pub 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    // The built-in local actor worker is the default runtime for every
81    // sub-agent. Always build it; a build failure here is logged and leaves the
82    // composite without a default handler (dispatch then errors clearly).
83    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        // Actor protocol: spawn a local worker binary over the bamboo-subagent WS protocol.
90        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                // #443: binary/model/permission_mode/isolation/env-forward
109                // are plumbed from the profile's `claude_code_*` fields.
110                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
197/// Build the built-in local actor runner from the typed `subagents`
198/// config. Everything is derived: worker = the current bamboo executable +
199/// `subagent-worker`, fabric = per-user temp dir — unless expert fields
200/// override them.
201fn 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        // #443: binary/model/permission_mode/isolation/env-forward are
227        // plumbed from `subagents.claude_code_*` — mirrors the matching arm
228        // in `build_external_child_runner` above.
229        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
266/// Resolve config `schedulable_placements` into runner-ready handles (#181, P2b),
267/// keyed by role. Mirrors `resolve_remote_placements`: the bearer is read from
268/// `token_env` HERE (the raw token never rides the config) and is used for BOTH
269/// the registry query and the chosen worker's connect. If `token_env` is `Some`
270/// but the env var is UNSET, log an error and SKIP that placement so a misconfig
271/// fails SAFE to the local path rather than querying/connecting with no bearer. A
272/// placement with no `token_env` is tokenless (trusted/loopback link only).
273/// Duplicate roles: last one wins.
274fn 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    // Phase 3: a pool is just a bus role. The runner picks a live connected worker
279    // of that role via the bus presence query — no registry url / token / cert.
280    placements
281        .iter()
282        .map(|p| {
283            (
284                p.role.clone(),
285                super::actor_adapter::ResolvedSchedulablePlacement {
286                    pool: p.pool.clone(),
287                    // The badge shows the cluster node's own metadata: a node
288                    // deployed to serve this pool (its `deploy.default_role`).
289                    host_label: node_label_for_role(nodes, &p.pool),
290                },
291            )
292        })
293        .collect()
294}
295
296/// Friendly display name for a cluster node whose worker serves `role`
297/// (`deploy.default_role`) — the operator `label`, else its ssh host. Used to
298/// stamp the UI placement badge from the node's own metadata.
299fn 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
309/// Friendly display name for a cluster node whose ssh host matches `endpoint`'s
310/// host — so a `remote_placements` endpoint pointing at a known node shows the
311/// node's label rather than a bare IP.
312fn 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
345/// Resolve config `remote_placements` into runner-ready handles (#193), keyed by
346/// role. The bearer is read from `token_env` HERE (mirroring the A2A `auth_ref`
347/// handling at ~runtime.rs:142): if the env var is set use it; if `token_env` is
348/// `Some` but the var is UNSET, log an error and SKIP that placement so a
349/// misconfig fails SAFE to the local path rather than connecting to a remote
350/// worker with no bearer. A placement with no `token_env` connects tokenless
351/// (trusted/loopback link only). Duplicate roles: last one wins.
352/// Heuristic: does this endpoint reach off-box (so a missing bearer is a real
353/// exposure)? `wss://` is always public-grade; for `ws://` we flag any host that
354/// is not loopback/localhost.
355fn 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                // A tokenless placement is only safe on a trusted link. Warn if
389                // it targets what looks like a public endpoint (wss:// or a
390                // non-loopback host) so an operator footgun is visible in logs.
391                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                // Badge from the node's own metadata when the endpoint points at
410                // a known cluster node; else the endpoint host is used downstream.
411                host_label: node_label_for_endpoint(nodes, &p.endpoint),
412            },
413        );
414    }
415    out
416}
417
418/// Snapshot per-provider credentials from the parent config for actor
419/// provisioning. Serialized generically so this code does not chase the
420/// per-provider config struct shapes — any slot with a non-empty `api_key`
421/// yields a scoped credential (plus `base_url` when present).
422pub fn extract_provider_credentials(
423    config: &Config,
424) -> Vec<bamboo_subagent::provision::ScopedCredential> {
425    let mut out = Vec::new();
426
427    // Legacy single-instance slots: providers.anthropic / openai / …
428    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    // Multi-instance providers: provider_instances keyed by instance id; the
447    // child routes by instance id, the worker constructs by provider_type.
448    // Read the typed struct directly — `api_key` is hydrated in memory but
449    // deliberately `skip_serializing`, so a serde projection would miss it.
450    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}