1use 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
25pub type DeployedRegistry = Arc<Mutex<HashMap<String, Deployed>>>;
28
29pub struct Deployed {
31 pub env: String,
32 pub handle: DeployedAgent,
33}
34
35pub struct DeployAgentTool {
36 broker_endpoint: String,
37 broker_token: String,
38 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#[derive(Debug, Deserialize)]
61struct DeployParams {
62 #[serde(default)]
64 id: Option<String>,
65 #[serde(default)]
66 role: Option<String>,
67 #[serde(default)]
69 model: Option<String>,
70 #[serde(default)]
72 env: Option<String>,
73 #[serde(default)]
75 image: Option<String>,
76 #[serde(default)]
78 host: Option<String>,
79 #[serde(default)]
80 workspace: Option<String>,
81 #[serde(default)]
83 echo: bool,
84}
85
86#[derive(Debug, Deserialize)]
87#[serde(tag = "action", rename_all = "snake_case")]
88enum DeployArgs {
89 Deploy(DeployParams),
91 Stop { id: String },
93 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 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 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 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 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 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 DeployAgentTool::new("ws://localhost:0", "test-token", "/bin/true", registry)
322 }
323
324 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 fn pid_alive(pid: u32) -> bool {
336 std::process::Command::new("kill")
337 .args(["-0", &pid.to_string()])
338 .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 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 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 let listed = parse(tool.list().await.unwrap());
385 assert!(listed["agents"].as_array().unwrap().is_empty());
386
387 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 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}