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, 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
26pub type DeployedRegistry = Arc<Mutex<HashMap<String, Deployed>>>;
29
30pub struct Deployed {
32 pub env: String,
33 pub handle: DeployedAgent,
34}
35
36pub struct DeployAgentTool {
37 broker_endpoint: String,
38 broker_token: String,
39 bamboo_bin: PathBuf,
41 registry: DeployedRegistry,
42 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#[derive(Debug, Deserialize)]
68struct DeployParams {
69 #[serde(default)]
71 id: Option<String>,
72 #[serde(default)]
73 role: Option<String>,
74 #[serde(default)]
76 model: Option<String>,
77 #[serde(default)]
79 env: Option<String>,
80 #[serde(default)]
82 image: Option<String>,
83 #[serde(default)]
85 host: Option<String>,
86 #[serde(default)]
87 workspace: Option<String>,
88 #[serde(default)]
90 echo: bool,
91}
92
93#[derive(Debug, Deserialize)]
94#[serde(tag = "action", rename_all = "snake_case")]
95enum DeployArgs {
96 Deploy(DeployParams),
98 Stop { id: String },
100 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 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 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 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 mcp_proxy: Some(bamboo_broker::ORCHESTRATOR_ID.to_string()),
193 log_path: None,
194 spec_json,
195 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 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 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 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 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 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 fn pid_alive(pid: u32) -> bool {
379 std::process::Command::new("kill")
380 .args(["-0", &pid.to_string()])
381 .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 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 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 let listed = parse(tool.list().await.unwrap());
428 assert!(listed["agents"].as_array().unwrap().is_empty());
429
430 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 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}