use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use tokio::sync::Mutex;
use bamboo_agent_core::tools::{Tool, ToolError, ToolExecutionContext, ToolResult};
use bamboo_broker::{
AgentDeployment, DeployedAgent, Deployer, DockerDeployer, LocalProcessDeployer, SshDeployer,
};
pub type DeployedRegistry = Arc<Mutex<HashMap<String, Deployed>>>;
pub struct Deployed {
pub env: String,
pub handle: DeployedAgent,
}
pub struct DeployAgentTool {
broker_endpoint: String,
broker_token: String,
bamboo_bin: PathBuf,
registry: DeployedRegistry,
}
impl DeployAgentTool {
pub fn new(
broker_endpoint: impl Into<String>,
broker_token: impl Into<String>,
bamboo_bin: impl Into<PathBuf>,
registry: DeployedRegistry,
) -> Self {
Self {
broker_endpoint: broker_endpoint.into(),
broker_token: broker_token.into(),
bamboo_bin: bamboo_bin.into(),
registry,
}
}
}
#[derive(Debug, Deserialize)]
struct DeployParams {
#[serde(default)]
id: Option<String>,
#[serde(default)]
role: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
env: Option<String>,
#[serde(default)]
image: Option<String>,
#[serde(default)]
host: Option<String>,
#[serde(default)]
workspace: Option<String>,
#[serde(default)]
echo: bool,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
enum DeployArgs {
Deploy(DeployParams),
Stop { id: String },
List,
}
impl DeployAgentTool {
async fn deploy(&self, params: DeployParams) -> Result<ToolResult, ToolError> {
let DeployParams {
id,
role,
model,
env,
image,
host,
workspace,
echo,
} = params;
let id = id.filter(|s| !s.trim().is_empty()).unwrap_or_else(|| {
format!("agent-{}", &uuid::Uuid::new_v4().simple().to_string()[..8])
});
let env = env.unwrap_or_else(|| "local".to_string());
let deployer: Box<dyn Deployer> = match env.as_str() {
"local" => Box::new(LocalProcessDeployer::new(self.bamboo_bin.clone())),
"docker" => {
let image = image.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
ToolError::InvalidArguments("env=docker requires `image`".to_string())
})?;
Box::new(
DockerDeployer::new(image)
.mount_home(bamboo_config::paths::resolve_bamboo_dir()),
)
}
"ssh" => {
let host = host.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
ToolError::InvalidArguments("env=ssh requires `host`".to_string())
})?;
Box::new(SshDeployer::new(host))
}
other => {
return Err(ToolError::InvalidArguments(format!(
"unknown env '{other}' (use local|docker|ssh)"
)))
}
};
let broker_endpoint = if env == "docker" {
self.broker_endpoint
.replace("127.0.0.1", "host.docker.internal")
.replace("localhost", "host.docker.internal")
} else {
self.broker_endpoint.clone()
};
let deployment = AgentDeployment {
id: id.clone(),
role,
broker_endpoint,
token: self.broker_token.clone(),
model,
workspace,
echo,
mcp_proxy: Some(bamboo_broker::ORCHESTRATOR_ID.to_string()),
};
let handle = deployer
.deploy(&deployment)
.await
.map_err(|e| ToolError::Execution(format!("deploy '{id}' ({env}) failed: {e}")))?;
self.registry.lock().await.insert(
id.clone(),
Deployed {
env: env.clone(),
handle,
},
);
Ok(tool_json(json!({
"id": id,
"env": env,
"status": "deployed",
"note": format!("worker '{id}' is connecting to the broker; ask it with ask_agent(target=\"{id}\", ...)"),
})))
}
async fn stop(&self, id: String) -> Result<ToolResult, ToolError> {
match self.registry.lock().await.remove(&id) {
Some(d) => {
d.handle.shutdown().await;
Ok(tool_json(json!({ "id": id, "status": "stopped" })))
}
None => Ok(tool_json(json!({ "id": id, "status": "not_found" }))),
}
}
async fn list(&self) -> Result<ToolResult, ToolError> {
let reg = self.registry.lock().await;
let agents: Vec<_> = reg
.iter()
.map(|(id, d)| json!({ "id": id, "env": d.env }))
.collect();
Ok(tool_json(json!({ "agents": agents })))
}
}
fn tool_json(value: serde_json::Value) -> ToolResult {
ToolResult {
success: true,
result: value.to_string(),
display_preference: None,
images: Vec::new(),
}
}
#[async_trait]
impl Tool for DeployAgentTool {
fn name(&self) -> &str {
"deploy_agent"
}
fn description(&self) -> &str {
"Spin up a NEW worker agent on demand, anywhere, and manage its lifecycle. This is how you \
scale yourself out: you deploy a fresh broker-agent, then drive it with ask_agent. The \
worker connects back to the same message broker you are on, and inherits your MCP servers \
+ skills (via the orchestrator MCP proxy), so it can do real work — not just echo.\n\
\n\
THREE PLACEMENTS (action=deploy, pick with `env`):\n\
- env=local (default) — a subprocess on THIS machine. Fastest; use for extra parallel \
hands here.\n\
- env=docker — a container (requires `image`, e.g. \"bamboo:latest\"). Isolated; your \
bamboo home is mounted so it shares your config. Use for sandboxed or clean-env work.\n\
- env=ssh — a process on a REMOTE host (requires `host`, e.g. \"user@box\"). Use to run \
work near other machines/data or to borrow remote compute.\n\
\n\
OTHER ACTIONS: action=stop (id=…) tears a worker down and frees it; action=list shows the \
workers you currently have running. Workers are kept alive until you stop them or the \
server exits.\n\
\n\
WORKED EXAMPLE (scale out, use, tear down):\n\
1. deploy_agent(action=deploy, env=local, role=\"tester\", model=\"anthropic:claude-opus-4-8\") \
→ returns id \"agent-7f8e9d\".\n\
2. ask_agent(target=\"agent-7f8e9d\", question=\"Run the full test suite and report \
failures.\", mode=steer).\n\
3. deploy_agent(action=list) → confirm it (and any siblings) are running.\n\
4. deploy_agent(action=stop, id=\"agent-7f8e9d\") → once its work is collected.\n\
\n\
Tip: use echo=true to deploy a dependency-free no-LLM worker for a connectivity smoke test \
before committing to a real model. Returned id is what you pass as ask_agent's `target`."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["deploy", "stop", "list"] },
"id": { "type": "string", "description": "deploy: worker id (auto if omitted). stop: id to stop." },
"role": { "type": "string", "description": "deploy: role/profile label." },
"model": { "type": "string", "description": "deploy: provider:model for the worker." },
"env": { "type": "string", "enum": ["local", "docker", "ssh"], "description": "deploy: where to run (default local)." },
"image": { "type": "string", "description": "deploy: docker image (env=docker)." },
"host": { "type": "string", "description": "deploy: remote host (env=ssh)." },
"workspace": { "type": "string", "description": "deploy: worker working directory." },
"echo": { "type": "boolean", "description": "deploy: run the no-LLM echo executor (smoke)." }
},
"required": ["action"]
})
}
async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
.await
}
async fn execute_with_context(
&self,
args: serde_json::Value,
_ctx: ToolExecutionContext<'_>,
) -> Result<ToolResult, ToolError> {
let parsed: DeployArgs = serde_json::from_value(args)
.map_err(|e| ToolError::InvalidArguments(format!("Invalid deploy_agent args: {e}")))?;
match parsed {
DeployArgs::Deploy(params) => self.deploy(params).await,
DeployArgs::Stop { id } => self.stop(id).await,
DeployArgs::List => self.list().await,
}
}
}