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, ToolError, ToolExecutionContext, 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        };
167        let handle = deployer
168            .deploy(&deployment)
169            .await
170            .map_err(|e| ToolError::Execution(format!("deploy '{id}' ({env}) failed: {e}")))?;
171
172        self.registry.lock().await.insert(
173            id.clone(),
174            Deployed {
175                env: env.clone(),
176                handle,
177            },
178        );
179
180        Ok(tool_json(json!({
181            "id": id,
182            "env": env,
183            "status": "deployed",
184            "note": format!("worker '{id}' is connecting to the broker; ask it with ask_agent(target=\"{id}\", ...)"),
185        })))
186    }
187
188    async fn stop(&self, id: String) -> Result<ToolResult, ToolError> {
189        match self.registry.lock().await.remove(&id) {
190            Some(d) => {
191                d.handle.shutdown().await;
192                Ok(tool_json(json!({ "id": id, "status": "stopped" })))
193            }
194            None => Ok(tool_json(json!({ "id": id, "status": "not_found" }))),
195        }
196    }
197
198    async fn list(&self) -> Result<ToolResult, ToolError> {
199        let reg = self.registry.lock().await;
200        let agents: Vec<_> = reg
201            .iter()
202            .map(|(id, d)| json!({ "id": id, "env": d.env }))
203            .collect();
204        Ok(tool_json(json!({ "agents": agents })))
205    }
206}
207
208fn tool_json(value: serde_json::Value) -> ToolResult {
209    ToolResult {
210        success: true,
211        result: value.to_string(),
212        display_preference: None,
213        images: Vec::new(),
214    }
215}
216
217#[async_trait]
218impl Tool for DeployAgentTool {
219    fn name(&self) -> &str {
220        "deploy_agent"
221    }
222
223    fn description(&self) -> &str {
224        "Spin up a NEW worker agent on demand, anywhere, and manage its lifecycle. This is how you \
225         scale yourself out: you deploy a fresh broker-agent, then drive it with ask_agent. The \
226         worker connects back to the same message broker you are on, and inherits your MCP servers \
227         + skills (via the orchestrator MCP proxy), so it can do real work — not just echo.\n\
228         \n\
229         THREE PLACEMENTS (action=deploy, pick with `env`):\n\
230         - env=local (default) — a subprocess on THIS machine. Fastest; use for extra parallel \
231         hands here.\n\
232         - env=docker — a container (requires `image`, e.g. \"bamboo:latest\"). Isolated; your \
233         bamboo home is mounted so it shares your config. Use for sandboxed or clean-env work.\n\
234         - env=ssh — a process on a REMOTE host (requires `host`, e.g. \"user@box\"). Use to run \
235         work near other machines/data or to borrow remote compute.\n\
236         \n\
237         OTHER ACTIONS: action=stop (id=…) tears a worker down and frees it; action=list shows the \
238         workers you currently have running. Workers are kept alive until you stop them or the \
239         server exits.\n\
240         \n\
241         WORKED EXAMPLE (scale out, use, tear down):\n\
242         1. deploy_agent(action=deploy, env=local, role=\"tester\", model=\"anthropic:claude-opus-4-8\") \
243         → returns id \"agent-7f8e9d\".\n\
244         2. ask_agent(target=\"agent-7f8e9d\", question=\"Run the full test suite and report \
245         failures.\", mode=steer).\n\
246         3. deploy_agent(action=list) → confirm it (and any siblings) are running.\n\
247         4. deploy_agent(action=stop, id=\"agent-7f8e9d\") → once its work is collected.\n\
248         \n\
249         Tip: use echo=true to deploy a dependency-free no-LLM worker for a connectivity smoke test \
250         before committing to a real model. Returned id is what you pass as ask_agent's `target`."
251    }
252
253    fn parameters_schema(&self) -> serde_json::Value {
254        json!({
255            "type": "object",
256            "properties": {
257                "action": { "type": "string", "enum": ["deploy", "stop", "list"] },
258                "id": { "type": "string", "description": "deploy: worker id (auto if omitted). stop: id to stop." },
259                "role": { "type": "string", "description": "deploy: role/profile label." },
260                "model": { "type": "string", "description": "deploy: provider:model for the worker." },
261                "env": { "type": "string", "enum": ["local", "docker", "ssh"], "description": "deploy: where to run (default local)." },
262                "image": { "type": "string", "description": "deploy: docker image (env=docker)." },
263                "host": { "type": "string", "description": "deploy: remote host (env=ssh)." },
264                "workspace": { "type": "string", "description": "deploy: worker working directory." },
265                "echo": { "type": "boolean", "description": "deploy: run the no-LLM echo executor (smoke)." }
266            },
267            "required": ["action"]
268        })
269    }
270
271    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
272        self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
273            .await
274    }
275
276    async fn execute_with_context(
277        &self,
278        args: serde_json::Value,
279        _ctx: ToolExecutionContext<'_>,
280    ) -> Result<ToolResult, ToolError> {
281        let parsed: DeployArgs = serde_json::from_value(args)
282            .map_err(|e| ToolError::InvalidArguments(format!("Invalid deploy_agent args: {e}")))?;
283        match parsed {
284            DeployArgs::Deploy(params) => self.deploy(params).await,
285            DeployArgs::Stop { id } => self.stop(id).await,
286            DeployArgs::List => self.list().await,
287        }
288    }
289}
290
291#[cfg(all(test, unix))]
292mod tests {
293    use super::*;
294
295    fn empty_registry() -> DeployedRegistry {
296        std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()))
297    }
298
299    fn tool_with(registry: DeployedRegistry) -> DeployAgentTool {
300        // bamboo_bin is never spawned in these tests (we don't drive deploy()).
301        DeployAgentTool::new("ws://localhost:0", "test-token", "/bin/true", registry)
302    }
303
304    /// A trivial long-running child so the kill/wait path is genuinely exercised.
305    fn spawn_sleeper(id: &str, cleanup: Option<Vec<String>>) -> DeployedAgent {
306        let child = tokio::process::Command::new("sleep")
307            .arg("60")
308            .kill_on_drop(true)
309            .spawn()
310            .expect("spawn sleep");
311        DeployedAgent::from_parts(id, child, cleanup)
312    }
313
314    /// True while `pid` is a live process (POSIX `kill -0`).
315    fn pid_alive(pid: u32) -> bool {
316        std::process::Command::new("kill")
317            .args(["-0", &pid.to_string()])
318            // `kill -0` on a reaped pid prints "No such process" to stderr; that
319            // stderr is the expected signal, not test noise — silence it.
320            .stderr(std::process::Stdio::null())
321            .status()
322            .map(|s| s.success())
323            .unwrap_or(false)
324    }
325
326    fn parse(result: ToolResult) -> serde_json::Value {
327        serde_json::from_str(&result.result).expect("tool result is JSON")
328    }
329
330    #[tokio::test]
331    async fn deploy_list_stop_lifecycle_kills_process() {
332        let registry = empty_registry();
333        let tool = tool_with(registry.clone());
334
335        // (1) register a worker (the registry effect of a successful deploy); list shows it.
336        let agent = spawn_sleeper("w1", None);
337        let pid = agent.pid().expect("child has a pid");
338        registry.lock().await.insert(
339            "w1".to_string(),
340            Deployed {
341                env: "local".into(),
342                handle: agent,
343            },
344        );
345        assert!(
346            pid_alive(pid),
347            "registered worker process should be running"
348        );
349
350        let listed = parse(tool.list().await.unwrap());
351        let agents = listed["agents"].as_array().unwrap();
352        assert_eq!(agents.len(), 1);
353        assert_eq!(agents[0]["id"], "w1");
354        assert_eq!(agents[0]["env"], "local");
355
356        // (2) stop: removes the entry AND kills the process (shutdown awaits the child).
357        let stopped = parse(tool.stop("w1".to_string()).await.unwrap());
358        assert_eq!(stopped["id"], "w1");
359        assert_eq!(stopped["status"], "stopped");
360        assert!(!pid_alive(pid), "stopped worker process must be killed");
361
362        // (3) list after stop is empty.
363        let listed = parse(tool.list().await.unwrap());
364        assert!(listed["agents"].as_array().unwrap().is_empty());
365
366        // (4) double-stop (already removed) is a no-op, not a crash.
367        let again = parse(tool.stop("w1".to_string()).await.unwrap());
368        assert_eq!(again["status"], "not_found");
369    }
370
371    #[tokio::test]
372    async fn stop_unknown_id_is_not_found_not_a_crash() {
373        let tool = tool_with(empty_registry());
374        let r = parse(tool.stop("never-deployed".to_string()).await.unwrap());
375        assert_eq!(r["status"], "not_found");
376    }
377
378    #[tokio::test]
379    async fn deployed_agent_shutdown_kills_and_runs_cleanup() {
380        // A unique marker the cleanup command will `touch` — proves cleanup ran.
381        let marker = std::env::temp_dir().join(format!(
382            "bamboo_deploy_cleanup_{}_{:?}.marker",
383            std::process::id(),
384            std::time::SystemTime::now()
385                .duration_since(std::time::UNIX_EPOCH)
386                .unwrap()
387                .as_nanos()
388        ));
389        let _ = std::fs::remove_file(&marker);
390
391        let agent = spawn_sleeper(
392            "cleanup-worker",
393            Some(vec![
394                "sh".into(),
395                "-c".into(),
396                format!("touch {}", marker.display()),
397            ]),
398        );
399        let pid = agent.pid().expect("child has a pid");
400
401        agent.shutdown().await;
402
403        assert!(!pid_alive(pid), "shutdown must kill the process");
404        assert!(
405            marker.exists(),
406            "shutdown must run the cleanup command (docker rm -f path)"
407        );
408        let _ = std::fs::remove_file(&marker);
409    }
410}