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