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, ToolError, ToolExecutionContext, 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 };
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}