Skip to main content

bamboo_server_tools/
deploy_agent.rs

1//! `deploy_agent` — the AI-callable "spin up a worker myself" tool.
2//!
3//! Lets a running (root) agent deploy a new broker-agent worker on demand — as a
4//! local subprocess, in a Docker container, or on a remote host over SSH — wired
5//! to the configured broker. The agent then commands it with `ask_agent` by the
6//! returned id. Deployed handles are kept alive in a registry (they are
7//! kill-on-drop) and torn down via `action=stop` (or when the server exits).
8//!
9//! Only registered on the Root surface when a broker is configured.
10
11use std::collections::HashMap;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use serde::Deserialize;
17use serde_json::json;
18use tokio::sync::{Mutex, RwLock};
19
20use bamboo_agent_core::tools::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
21use bamboo_broker::{
22    AgentDeployment, DeployedAgent, Deployer, DockerDeployer, LocalProcessDeployer, SshDeployer,
23};
24use bamboo_config::Config;
25
26/// Keeps deployed workers alive (the handles are kill-on-drop) and lets `stop`
27/// tear them down. Shared for the server's lifetime.
28pub type DeployedRegistry = Arc<Mutex<HashMap<String, Deployed>>>;
29
30/// One live deployment: how it was deployed + the kill-on-drop handle.
31pub struct Deployed {
32    pub env: String,
33    pub handle: DeployedAgent,
34}
35
36pub struct DeployAgentTool {
37    broker_endpoint: String,
38    broker_token: String,
39    /// Path to the `bamboo` binary used for local subprocess deploys.
40    bamboo_bin: PathBuf,
41    registry: DeployedRegistry,
42    /// Live config, read (never written) to resolve a scoped `ProvisionSpec`
43    /// for `env=docker` deploys — the assigned model's credential only, never
44    /// the whole config or the master encryption key (#46).
45    config: Arc<RwLock<Config>>,
46}
47
48impl DeployAgentTool {
49    pub fn new(
50        broker_endpoint: impl Into<String>,
51        broker_token: impl Into<String>,
52        bamboo_bin: impl Into<PathBuf>,
53        registry: DeployedRegistry,
54        config: Arc<RwLock<Config>>,
55    ) -> Self {
56        Self {
57            broker_endpoint: broker_endpoint.into(),
58            broker_token: broker_token.into(),
59            bamboo_bin: bamboo_bin.into(),
60            registry,
61            config,
62        }
63    }
64}
65
66/// Parameters for `action=deploy`, grouped so the deploy call stays tidy.
67#[derive(Debug, Deserialize)]
68struct DeployParams {
69    /// Worker id (its broker mailbox key). Auto-generated when omitted.
70    #[serde(default)]
71    id: Option<String>,
72    #[serde(default)]
73    role: Option<String>,
74    /// `provider:model` for the worker's agent (ignored when `echo`).
75    #[serde(default)]
76    model: Option<String>,
77    /// Where to run it: `local` (default), `docker`, or `ssh`.
78    #[serde(default)]
79    env: Option<String>,
80    /// Docker image (required when `env=docker`).
81    #[serde(default)]
82    image: Option<String>,
83    /// Remote host (required when `env=ssh`).
84    #[serde(default)]
85    host: Option<String>,
86    #[serde(default)]
87    workspace: Option<String>,
88    /// Run the dependency-free echo executor (no LLM) — smoke/testing.
89    #[serde(default)]
90    echo: bool,
91}
92
93#[derive(Debug, Deserialize)]
94#[serde(tag = "action", rename_all = "snake_case")]
95enum DeployArgs {
96    /// Deploy a new worker and return its id.
97    Deploy(DeployParams),
98    /// Stop a previously-deployed worker and remove it.
99    Stop { id: String },
100    /// List currently-deployed workers.
101    List,
102}
103
104impl DeployAgentTool {
105    async fn deploy(&self, params: DeployParams) -> Result<ToolResult, ToolError> {
106        let DeployParams {
107            id,
108            role,
109            model,
110            env,
111            image,
112            host,
113            workspace,
114            echo,
115        } = params;
116        let id = id.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| {
117            format!("agent-{}", &uuid::Uuid::new_v4().simple().to_string()[..8])
118        });
119        let env = env.unwrap_or_else(|| "local".to_string());
120
121        // A container cannot reach the host's loopback; for docker, address the
122        // broker via host.docker.internal (the deployer maps it to the host
123        // gateway). local/ssh keep the configured endpoint as-is. Computed
124        // before the docker spec build below, which also ships this endpoint
125        // (as the worker's `bus` target) inside the ProvisionSpec.
126        let broker_endpoint = if env == "docker" {
127            self.broker_endpoint
128                .replace("127.0.0.1", "host.docker.internal")
129                .replace("localhost", "host.docker.internal")
130        } else {
131            self.broker_endpoint.clone()
132        };
133
134        // docker=only: a parent-resolved ProvisionSpec (assigned model's
135        // credential, no encryption key, no full config) shipped over a
136        // one-shot stdin pipe — NOT the orchestrator's whole `~/.bamboo` home
137        // (#46). `None` falls back to a homeless, credential-less container
138        // (fine for `echo=true` smoke tests; a real worker then has nothing to
139        // authenticate with, which surfaces at the presence-verify/first-task
140        // step rather than silently handing over every provider key).
141        let mut spec_json: Option<String> = None;
142
143        let deployer: Box<dyn Deployer> = match env.as_str() {
144            "local" => Box::new(LocalProcessDeployer::new(self.bamboo_bin.clone())),
145            "docker" => {
146                let image = image.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
147                    ToolError::InvalidArguments("env=docker requires `image`".to_string())
148                })?;
149                {
150                    let cfg = self.config.read().await;
151                    spec_json = crate::fabric_deploy::build_ondemand_provision_spec(
152                        &id,
153                        role.as_deref(),
154                        model.as_deref(),
155                        workspace.as_deref(),
156                        std::env::temp_dir().join("bamboo-docker-agents").join(&id),
157                        &broker_endpoint,
158                        &self.broker_token,
159                        &cfg,
160                        echo,
161                    );
162                }
163                // No `--network host`: the worker stays on an isolated bridge
164                // network and reaches the host broker via host.docker.internal
165                // (DockerDeployer adds the host-gateway alias above). No home
166                // mount either — the spec built above is the worker's only
167                // source of model/creds/MCP-proxy.
168                Box::new(DockerDeployer::new(image))
169            }
170            "ssh" => {
171                let host = host.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
172                    ToolError::InvalidArguments("env=ssh requires `host`".to_string())
173                })?;
174                Box::new(SshDeployer::new(host))
175            }
176            other => {
177                return Err(ToolError::InvalidArguments(format!(
178                    "unknown env '{other}' (use local|docker|ssh)"
179                )))
180            }
181        };
182
183        let deployment = AgentDeployment {
184            id: id.clone(),
185            role,
186            broker_endpoint,
187            token: self.broker_token.clone(),
188            model,
189            workspace,
190            echo,
191            // Deployed workers proxy MCP to the orchestrator (single MCP host).
192            mcp_proxy: Some(bamboo_broker::ORCHESTRATOR_ID.to_string()),
193            log_path: None,
194            spec_json,
195            // No self-signed CA to trust: on-demand-deploy targets (local/docker/
196            // ssh via this tool) don't yet expose a per-deploy `--tls-ca-cert`
197            // param; `wss://` still works here against a CA-signed broker cert
198            // (or a self-signed one whose CA is already in the OS trust store).
199            tls_ca_cert: None,
200        };
201        let handle = deployer
202            .deploy(&deployment)
203            .await
204            .map_err(|e| ToolError::Execution(format!("deploy '{id}' ({env}) failed: {e}")))?;
205
206        // Namespace the registry key so an agent-chosen id can never collide
207        // with a cluster-fabric node id in the SHARED registry (cross-eviction).
208        self.registry.lock().await.insert(
209            crate::registry_keys::agent_key(&id),
210            Deployed {
211                env: env.clone(),
212                handle,
213            },
214        );
215
216        Ok(tool_json(json!({
217            "id": id,
218            "env": env,
219            "status": "deployed",
220            "note": format!("worker '{id}' is connecting to the broker; ask it with ask_agent(target=\"{id}\", ...)"),
221        })))
222    }
223
224    async fn stop(&self, id: String) -> Result<ToolResult, ToolError> {
225        // Take the entry out FIRST, then shut down without holding the registry
226        // lock: shutdown is now graceful (SIGTERM + drain grace window, #49), so
227        // it can take seconds — other deploy/stop/list calls must not serialize
228        // behind it.
229        let removed = self
230            .registry
231            .lock()
232            .await
233            .remove(&crate::registry_keys::agent_key(&id));
234        match removed {
235            Some(d) => {
236                d.handle.shutdown().await;
237                Ok(tool_json(json!({ "id": id, "status": "stopped" })))
238            }
239            None => Ok(tool_json(json!({ "id": id, "status": "not_found" }))),
240        }
241    }
242
243    async fn list(&self) -> Result<ToolResult, ToolError> {
244        let reg = self.registry.lock().await;
245        // The registry is shared with the cluster fabric, so show every worker
246        // with its source (agent-deployed vs cluster node) and the bare id.
247        let agents: Vec<_> = reg
248            .iter()
249            .map(|(key, d)| {
250                let (source, id) = crate::registry_keys::split(key);
251                json!({ "id": id, "source": source, "env": d.env })
252            })
253            .collect();
254        Ok(tool_json(json!({ "agents": agents })))
255    }
256}
257
258fn tool_json(value: serde_json::Value) -> ToolResult {
259    ToolResult {
260        success: true,
261        result: value.to_string(),
262        display_preference: None,
263        images: Vec::new(),
264    }
265}
266
267#[async_trait]
268impl Tool for DeployAgentTool {
269    fn name(&self) -> &str {
270        "deploy_agent"
271    }
272
273    fn description(&self) -> &str {
274        "Spin up a NEW worker agent on demand, anywhere, and manage its lifecycle. This is how you \
275         scale yourself out: you deploy a fresh broker-agent, then drive it with ask_agent. The \
276         worker connects back to the same message broker you are on, and inherits your MCP servers \
277         + skills (via the orchestrator MCP proxy), so it can do real work — not just echo.\n\
278         \n\
279         PREFER LOCAL. Default to a local `SubAgent` (an in-context child) for delegation. Reach for \
280         a REMOTE worker (env=ssh, or a cluster node) ONLY when the task genuinely needs THAT \
281         machine — its data, GPU, network location/proximity, or a clean sandbox. Remote adds a \
282         binary upload, deploy cost, network latency, and can hit host firewalls; do not pick it by \
283         default. Local-subprocess (env=local) is fine for extra parallel hands here.\n\
284         \n\
285         THREE PLACEMENTS (action=deploy, pick with `env`):\n\
286         - env=local (default) — a subprocess on THIS machine. Fastest; use for extra parallel \
287         hands here.\n\
288         - env=docker — a container (requires `image`, e.g. \"bamboo:latest\"). Isolated; it gets \
289         only the assigned model's credential (via a one-shot handoff, not your whole config) plus \
290         your MCP servers proxied through you. Use for sandboxed or clean-env work.\n\
291         - env=ssh — a process on a REMOTE host (requires `host`, e.g. \"user@box\"). Use to run \
292         work near other machines/data or to borrow remote compute.\n\
293         \n\
294         OTHER ACTIONS: action=stop (id=…) tears a worker down and frees it; action=list shows the \
295         workers you currently have running. Workers are kept alive until you stop them or the \
296         server exits.\n\
297         \n\
298         WORKED EXAMPLE (scale out, use, tear down):\n\
299         1. deploy_agent(action=deploy, env=local, role=\"tester\", model=\"anthropic:claude-opus-4-8\") \
300         → returns id \"agent-7f8e9d\".\n\
301         2. ask_agent(target=\"agent-7f8e9d\", question=\"Run the full test suite and report \
302         failures.\", mode=steer).\n\
303         3. deploy_agent(action=list) → confirm it (and any siblings) are running.\n\
304         4. deploy_agent(action=stop, id=\"agent-7f8e9d\") → once its work is collected.\n\
305         \n\
306         Tip: use echo=true to deploy a dependency-free no-LLM worker for a connectivity smoke test \
307         before committing to a real model. Returned id is what you pass as ask_agent's `target`."
308    }
309
310    fn parameters_schema(&self) -> serde_json::Value {
311        json!({
312            "type": "object",
313            "properties": {
314                "action": { "type": "string", "enum": ["deploy", "stop", "list"] },
315                "id": { "type": "string", "description": "deploy: worker id (auto if omitted). stop: id to stop." },
316                "role": { "type": "string", "description": "deploy: role/profile label." },
317                "model": { "type": "string", "description": "deploy: provider:model for the worker." },
318                "env": { "type": "string", "enum": ["local", "docker", "ssh"], "description": "deploy: where to run (default local)." },
319                "image": { "type": "string", "description": "deploy: docker image (env=docker)." },
320                "host": { "type": "string", "description": "deploy: remote host (env=ssh)." },
321                "workspace": { "type": "string", "description": "deploy: worker working directory." },
322                "echo": { "type": "boolean", "description": "deploy: run the no-LLM echo executor (smoke)." }
323            },
324            "required": ["action"]
325        })
326    }
327
328    fn classify(&self, _args: &serde_json::Value) -> ToolClass {
329        ToolClass::MUTATING_SERIAL.promotable()
330    }
331
332    async fn invoke(
333        &self,
334        args: serde_json::Value,
335        _ctx: ToolCtx,
336    ) -> Result<ToolOutcome, ToolError> {
337        let parsed: DeployArgs = serde_json::from_value(args)
338            .map_err(|e| ToolError::InvalidArguments(format!("Invalid deploy_agent args: {e}")))?;
339        match parsed {
340            DeployArgs::Deploy(params) => self.deploy(params).await,
341            DeployArgs::Stop { id } => self.stop(id).await,
342            DeployArgs::List => self.list().await,
343        }
344        .map(ToolOutcome::Completed)
345    }
346}
347
348#[cfg(all(test, unix))]
349mod tests {
350    use super::*;
351
352    fn empty_registry() -> DeployedRegistry {
353        std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()))
354    }
355
356    fn tool_with(registry: DeployedRegistry) -> DeployAgentTool {
357        // bamboo_bin is never spawned in these tests (we don't drive deploy()).
358        DeployAgentTool::new(
359            "ws://localhost:0",
360            "test-token",
361            "/bin/true",
362            registry,
363            std::sync::Arc::new(tokio::sync::RwLock::new(bamboo_config::Config::default())),
364        )
365    }
366
367    /// A trivial long-running child so the kill/wait path is genuinely exercised.
368    fn spawn_sleeper(id: &str, cleanup: Option<Vec<String>>) -> DeployedAgent {
369        let child = tokio::process::Command::new("sleep")
370            .arg("60")
371            .kill_on_drop(true)
372            .spawn()
373            .expect("spawn sleep");
374        DeployedAgent::from_parts(id, child, cleanup)
375    }
376
377    /// True while `pid` is a live process (POSIX `kill -0`).
378    fn pid_alive(pid: u32) -> bool {
379        std::process::Command::new("kill")
380            .args(["-0", &pid.to_string()])
381            // `kill -0` on a reaped pid prints "No such process" to stderr; that
382            // stderr is the expected signal, not test noise — silence it.
383            .stderr(std::process::Stdio::null())
384            .status()
385            .map(|s| s.success())
386            .unwrap_or(false)
387    }
388
389    fn parse(result: ToolResult) -> serde_json::Value {
390        serde_json::from_str(&result.result).expect("tool result is JSON")
391    }
392
393    #[tokio::test]
394    async fn deploy_list_stop_lifecycle_kills_process() {
395        let registry = empty_registry();
396        let tool = tool_with(registry.clone());
397
398        // (1) register a worker (the registry effect of a successful deploy); list shows it.
399        // Use the namespaced key so the tool's stop()/list() find it.
400        let agent = spawn_sleeper("w1", None);
401        let pid = agent.pid().expect("child has a pid");
402        registry.lock().await.insert(
403            crate::registry_keys::agent_key("w1"),
404            Deployed {
405                env: "local".into(),
406                handle: agent,
407            },
408        );
409        assert!(
410            pid_alive(pid),
411            "registered worker process should be running"
412        );
413
414        let listed = parse(tool.list().await.unwrap());
415        let agents = listed["agents"].as_array().unwrap();
416        assert_eq!(agents.len(), 1);
417        assert_eq!(agents[0]["id"], "w1");
418        assert_eq!(agents[0]["env"], "local");
419
420        // (2) stop: removes the entry AND kills the process (shutdown awaits the child).
421        let stopped = parse(tool.stop("w1".to_string()).await.unwrap());
422        assert_eq!(stopped["id"], "w1");
423        assert_eq!(stopped["status"], "stopped");
424        assert!(!pid_alive(pid), "stopped worker process must be killed");
425
426        // (3) list after stop is empty.
427        let listed = parse(tool.list().await.unwrap());
428        assert!(listed["agents"].as_array().unwrap().is_empty());
429
430        // (4) double-stop (already removed) is a no-op, not a crash.
431        let again = parse(tool.stop("w1".to_string()).await.unwrap());
432        assert_eq!(again["status"], "not_found");
433    }
434
435    #[tokio::test]
436    async fn stop_unknown_id_is_not_found_not_a_crash() {
437        let tool = tool_with(empty_registry());
438        let r = parse(tool.stop("never-deployed".to_string()).await.unwrap());
439        assert_eq!(r["status"], "not_found");
440    }
441
442    #[tokio::test]
443    async fn deployed_agent_shutdown_kills_and_runs_cleanup() {
444        // A unique marker the cleanup command will `touch` — proves cleanup ran.
445        let marker = std::env::temp_dir().join(format!(
446            "bamboo_deploy_cleanup_{}_{:?}.marker",
447            std::process::id(),
448            std::time::SystemTime::now()
449                .duration_since(std::time::UNIX_EPOCH)
450                .unwrap()
451                .as_nanos()
452        ));
453        let _ = std::fs::remove_file(&marker);
454
455        let agent = spawn_sleeper(
456            "cleanup-worker",
457            Some(vec![
458                "sh".into(),
459                "-c".into(),
460                format!("touch {}", marker.display()),
461            ]),
462        );
463        let pid = agent.pid().expect("child has a pid");
464
465        agent.shutdown().await;
466
467        assert!(!pid_alive(pid), "shutdown must kill the process");
468        assert!(
469            marker.exists(),
470            "shutdown must run the cleanup command (docker rm -f path)"
471        );
472        let _ = std::fs::remove_file(&marker);
473    }
474}