1use std::sync::Arc;
16
17use async_trait::async_trait;
18use serde::Deserialize;
19use serde_json::{json, Value};
20use tokio::sync::RwLock;
21
22use bamboo_agent_core::tools::{Tool, ToolCtx, ToolError, ToolOutcome, ToolResult};
23use bamboo_config::cluster_fabric::{Node, NodePlacement};
24use bamboo_config::Config;
25
26use crate::fabric_deploy::{FabricDeployer, FabricError};
27
28pub struct ClusterTool {
29 config: Arc<RwLock<Config>>,
31 deployer: Arc<FabricDeployer>,
33}
34
35impl ClusterTool {
36 pub fn new(config: Arc<RwLock<Config>>, deployer: Arc<FabricDeployer>) -> Self {
37 Self { config, deployer }
38 }
39}
40
41fn to_tool_error(e: FabricError) -> ToolError {
43 match e {
44 FabricError::NotFound(m) | FabricError::BadRequest(m) => ToolError::InvalidArguments(m),
45 FabricError::Internal(m) => ToolError::Execution(m),
46 }
47}
48
49#[derive(Debug, Deserialize)]
50#[serde(tag = "action", rename_all = "snake_case")]
51enum ClusterArgs {
52 List,
54 Describe { node: String },
56 Status { node: String },
58 Deploy {
60 node: String,
61 #[serde(default)]
62 echo: bool,
63 },
64 Stop { node: String },
66}
67
68fn node_target(node: &Node) -> String {
70 match &node.placement {
71 NodePlacement::Local => "local".to_string(),
72 NodePlacement::Ssh(t) => format!("{}@{}:{}", t.username, t.host, t.port),
73 }
74}
75
76fn node_status(node: &Node) -> &'static str {
77 match node.state.as_ref().map(|s| s.status) {
78 Some(bamboo_config::cluster_fabric::NodeStatus::NotDeployed) | None => "not_deployed",
79 Some(bamboo_config::cluster_fabric::NodeStatus::Deploying) => "deploying",
80 Some(bamboo_config::cluster_fabric::NodeStatus::Running) => "running",
81 Some(bamboo_config::cluster_fabric::NodeStatus::Unreachable) => "unreachable",
82 Some(bamboo_config::cluster_fabric::NodeStatus::Stopped) => "stopped",
83 Some(bamboo_config::cluster_fabric::NodeStatus::Failed) => "failed",
84 }
85}
86
87fn node_brief(node: &Node, cluster: Option<&str>) -> Value {
89 json!({
90 "id": node.id,
91 "label": node.label,
92 "target": node_target(node),
93 "status": node_status(node),
94 "worker_id": node.state.as_ref().and_then(|s| s.worker_id.clone()),
95 "cluster": cluster,
96 "enabled": node.enabled,
97 })
98}
99
100impl ClusterTool {
101 async fn list(&self) -> Result<ToolResult, ToolError> {
102 let cfg = self.config.read().await;
103 let fabric = &cfg.cluster_fabric;
104
105 let cluster_of = |id: &str| -> Option<String> {
107 fabric
108 .clusters
109 .iter()
110 .find(|c| c.node_ids.iter().any(|n| n == id))
111 .map(|c| c.name.clone())
112 };
113
114 let nodes: Vec<Value> = fabric
115 .nodes
116 .iter()
117 .map(|n| node_brief(n, cluster_of(&n.id).as_deref()))
118 .collect();
119 let clusters: Vec<Value> = fabric
120 .clusters
121 .iter()
122 .map(|c| {
123 json!({
124 "name": c.name,
125 "description": c.description,
126 "node_ids": c.node_ids,
127 })
128 })
129 .collect();
130
131 Ok(tool_json(json!({
132 "nodes": nodes,
133 "clusters": clusters,
134 "hint": "Use action=describe node=<id> for capabilities, then drive a running worker with ask_agent(target=<worker_id>, …).",
135 })))
136 }
137
138 async fn describe(&self, node_id: &str) -> Result<ToolResult, ToolError> {
139 let cfg = self.config.read().await;
140 let node = cfg
141 .cluster_fabric
142 .node(node_id)
143 .ok_or_else(|| ToolError::InvalidArguments(format!("unknown node '{node_id}'")))?;
144
145 Ok(tool_json(json!({
146 "id": node.id,
147 "label": node.label,
148 "target": node_target(node),
149 "placement": match &node.placement {
150 NodePlacement::Local => "local",
151 NodePlacement::Ssh(_) => "ssh",
152 },
153 "trust_level": format!("{:?}", node.trust_level).to_lowercase(),
154 "status": node_status(node),
155 "worker_id": node.state.as_ref().and_then(|s| s.worker_id.clone()),
156 "enabled": node.enabled,
157 "role": node.deploy.default_role,
158 "model": node.deploy.model,
159 "workspace": node.deploy.workspace,
160 })))
161 }
162
163 async fn status(&self, node_id: &str) -> Result<ToolResult, ToolError> {
164 let cfg = self.config.read().await;
165 let node = cfg
166 .cluster_fabric
167 .node(node_id)
168 .ok_or_else(|| ToolError::InvalidArguments(format!("unknown node '{node_id}'")))?;
169 Ok(tool_json(json!({
170 "id": node.id,
171 "status": node_status(node),
172 "state": node.state,
173 })))
174 }
175
176 async fn deploy(&self, node_id: &str, echo: bool) -> Result<ToolResult, ToolError> {
177 let state = self.deployer.deploy(node_id, echo).await.map_err(to_tool_error)?;
180 let worker_id = state.worker_id.clone().unwrap_or_default();
181 Ok(tool_json(json!({
182 "node": node_id,
183 "worker_id": worker_id,
184 "status": "deployed",
185 "note": format!(
186 "worker '{worker_id}' is dialing the broker; command it with ask_agent(target=\"{worker_id}\", …)."
187 ),
188 })))
189 }
190
191 async fn stop(&self, node_id: &str) -> Result<ToolResult, ToolError> {
192 self.deployer.stop(node_id).await.map_err(to_tool_error)?;
193 Ok(tool_json(json!({ "node": node_id, "status": "stopped" })))
194 }
195}
196
197fn tool_json(value: Value) -> ToolResult {
198 ToolResult {
199 success: true,
200 result: value.to_string(),
201 display_preference: None,
202 images: Vec::new(),
203 }
204}
205
206#[async_trait]
207impl Tool for ClusterTool {
208 fn name(&self) -> &str {
209 "cluster"
210 }
211
212 fn description(&self) -> &str {
213 "Inspect your operator-managed remote clusters: machines (\"nodes\") grouped into clusters \
214 that you can run work on. Use this to DISCOVER what compute you have, then dispatch to it.\n\
215 \n\
216 PREFER LOCAL: default to a local `SubAgent` for delegation. Dispatch to a cluster node ONLY \
217 when the task genuinely needs THAT machine (its data, GPU, network location). Deploying a \
218 node uploads a binary and adds network latency — don't route here by default.\n\
219 \n\
220 ACTIONS:\n\
221 - action=list — compact inventory: every node's id, label, target (user@host or local), \
222 status, its worker_id if deployed, and cluster membership. Start here.\n\
223 - action=describe node=<id> — one node's capabilities: placement, role, model, workspace, \
224 status, worker_id.\n\
225 - action=status node=<id> — one node's live deploy state (deployed_at, pid, last error).\n\
226 - action=deploy node=<id> [echo=true] — deploy a worker onto the node (credentials are \
227 resolved by the backend; you never see them). Returns a worker_id. Use echo=true for a \
228 no-LLM connectivity smoke test.\n\
229 - action=stop node=<id> — stop the worker you deployed on that node.\n\
230 \n\
231 DISPATCH: after deploy (or for an already-running node), command its worker_id with \
232 ask_agent(target=<worker_id>, question=…, mode=query|steer). For PARALLEL work: list the \
233 cluster, deploy to several nodes, then ask_agent each and gather. You address nodes by id \
234 and never handle credentials."
235 }
236
237 fn parameters_schema(&self) -> Value {
238 json!({
239 "type": "object",
240 "properties": {
241 "action": { "type": "string", "enum": ["list", "describe", "status", "deploy", "stop"] },
242 "node": { "type": "string", "description": "node id (required for describe/status/deploy/stop)." },
243 "echo": { "type": "boolean", "description": "deploy: run the no-LLM echo executor (connectivity smoke)." }
244 },
245 "required": ["action"]
246 })
247 }
248
249 async fn invoke(&self, args: Value, _ctx: ToolCtx) -> Result<ToolOutcome, ToolError> {
250 let parsed: ClusterArgs = serde_json::from_value(args)
251 .map_err(|e| ToolError::InvalidArguments(format!("Invalid cluster args: {e}")))?;
252 let result = match parsed {
253 ClusterArgs::List => self.list().await,
254 ClusterArgs::Describe { node } => self.describe(&node).await,
255 ClusterArgs::Status { node } => self.status(&node).await,
256 ClusterArgs::Deploy { node, echo } => self.deploy(&node, echo).await,
257 ClusterArgs::Stop { node } => self.stop(&node).await,
258 }?;
259 Ok(ToolOutcome::Completed(result))
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use bamboo_config::cluster_fabric::{
267 Cluster, DeployProfile, NodeState, NodeStatus, SshAuth, SshTarget, TrustLevel,
268 };
269
270 fn config_with(nodes: Vec<Node>, clusters: Vec<Cluster>) -> Arc<RwLock<Config>> {
271 let mut cfg = Config::default();
272 cfg.cluster_fabric.nodes = nodes;
273 cfg.cluster_fabric.clusters = clusters;
274 Arc::new(RwLock::new(cfg))
275 }
276
277 fn deployer_for(config: Arc<RwLock<Config>>) -> Arc<crate::fabric_deploy::FabricDeployer> {
278 Arc::new(crate::fabric_deploy::FabricDeployer::new(
279 config,
280 Arc::new(tokio::sync::Mutex::new(())),
281 std::env::temp_dir(),
282 Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
283 "/usr/bin/true",
284 ))
285 }
286
287 fn tool(config: Arc<RwLock<Config>>) -> ClusterTool {
288 let deployer = deployer_for(config.clone());
289 ClusterTool::new(config, deployer)
290 }
291
292 fn ssh_node(id: &str, running: bool) -> Node {
293 Node {
294 id: id.to_string(),
295 label: format!("label-{id}"),
296 placement: NodePlacement::Ssh(SshTarget {
297 host: "10.0.0.9".into(),
298 port: 22,
299 username: "deploy".into(),
300 auth: SshAuth::Password {
301 password: "SECRET".into(),
302 password_encrypted: None,
303 },
304 host_key_fingerprint: None,
305 }),
306 trust_level: TrustLevel::Trusted,
307 deploy: DeployProfile {
308 default_role: Some("worker".into()),
309 ..Default::default()
310 },
311 state: running.then(|| NodeState {
312 status: NodeStatus::Running,
313 worker_id: Some(format!("node-{id}")),
314 ..Default::default()
315 }),
316 enabled: true,
317 }
318 }
319
320 fn parse(r: ToolResult) -> Value {
321 serde_json::from_str(&r.result).unwrap()
322 }
323
324 async fn start_broker() -> (String, tempfile::TempDir) {
326 let dir = tempfile::tempdir().unwrap();
327 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
328 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
329 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
330 let addr = listener.local_addr().unwrap();
331 tokio::spawn(async move {
332 let _ = server.serve(listener).await;
333 });
334 (format!("ws://{addr}"), dir)
335 }
336
337 async fn join_worker(endpoint: &str, id: &str, role: &str) -> bamboo_broker::BrokerClient {
340 let mut c = bamboo_broker::BrokerClient::connect(
341 endpoint,
342 bamboo_subagent::AgentRef {
343 session_id: id.into(),
344 role: Some(role.into()),
345 },
346 "t",
347 )
348 .await
349 .unwrap();
350 c.subscribe().await.unwrap();
351 c
352 }
353
354 #[tokio::test]
355 async fn list_summarizes_nodes_without_secrets() {
356 let cfg = config_with(
357 vec![ssh_node("n1", true)],
358 vec![Cluster {
359 name: "prod".into(),
360 description: None,
361 node_ids: vec!["n1".into()],
362 }],
363 );
364 let tool = tool(cfg);
365 let out = parse(tool.list().await.unwrap());
366 let node = &out["nodes"][0];
367 assert_eq!(node["target"], "deploy@10.0.0.9:22");
368 assert_eq!(node["status"], "running");
369 assert_eq!(node["worker_id"], "node-n1");
370 assert_eq!(node["cluster"], "prod");
371 assert!(!out.to_string().contains("SECRET"));
373 assert!(!out.to_string().contains("password"));
374 }
375
376 #[tokio::test]
377 async fn describe_exposes_capabilities_not_creds() {
378 let cfg = config_with(vec![ssh_node("n1", true)], vec![]);
379 let tool = tool(cfg);
380 let out = parse(tool.describe("n1").await.unwrap());
381 assert_eq!(out["placement"], "ssh");
382 assert_eq!(out["role"], "worker");
383 assert_eq!(out["worker_id"], "node-n1");
384 assert!(!out.to_string().contains("SECRET"));
385 }
386
387 #[tokio::test]
388 async fn describe_unknown_node_errors() {
389 let cfg = config_with(vec![], vec![]);
390 let tool = tool(cfg);
391 assert!(tool.describe("nope").await.is_err());
392 }
393
394 #[tokio::test]
395 async fn status_reports_not_deployed_for_fresh_node() {
396 let cfg = config_with(vec![ssh_node("n1", false)], vec![]);
397 let tool = tool(cfg);
398 let out = parse(tool.status("n1").await.unwrap());
399 assert_eq!(out["status"], "not_deployed");
400 }
401
402 fn local_node(id: &str) -> Node {
403 Node {
404 id: id.to_string(),
405 label: id.to_string(),
406 placement: NodePlacement::Local,
407 trust_level: TrustLevel::Trusted,
408 deploy: DeployProfile::default(),
409 state: None,
410 enabled: true,
411 }
412 }
413
414 #[tokio::test]
415 async fn deploy_local_node_registers_worker_then_stop_clears_it() {
416 let (endpoint, _broker_dir) = start_broker().await;
423 let mut config = Config::default();
424 config.cluster_fabric.nodes = vec![local_node("n1")];
425 config.subagents.broker = Some(bamboo_config::BrokerClientConfig {
426 endpoint: endpoint.clone(),
427 token: "t".into(),
428 token_encrypted: None,
429 });
430 let cfg = Arc::new(RwLock::new(config));
431
432 let _worker = join_worker(&endpoint, "node-n1", "general-purpose").await;
434
435 let data_dir = std::env::temp_dir().join(format!("bamboo-clustertool-{}", std::process::id()));
437 let _ = std::fs::create_dir_all(&data_dir);
438 let deployer = Arc::new(crate::fabric_deploy::FabricDeployer::new(
439 cfg.clone(),
440 Arc::new(tokio::sync::Mutex::new(())),
441 &data_dir,
442 Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
443 "/usr/bin/true",
444 ));
445 let registry = deployer.registry();
446 let t = ClusterTool::new(cfg, deployer);
447
448 let key = crate::registry_keys::node_key("n1");
449 let out = parse(t.deploy("n1", false).await.unwrap());
450 assert_eq!(out["worker_id"], "node-n1");
451 assert_eq!(out["status"], "deployed");
452 assert!(registry.lock().await.contains_key(&key), "handle registered");
453
454 let stopped = parse(t.stop("n1").await.unwrap());
455 assert_eq!(stopped["status"], "stopped");
456 assert!(!registry.lock().await.contains_key(&key), "handle removed");
457 let _ = std::fs::remove_dir_all(&data_dir);
458 }
459
460 #[tokio::test]
461 async fn deploy_unknown_node_errors() {
462 let cfg = config_with(vec![], vec![]);
463 let tool = tool(cfg);
464 assert!(tool.deploy("nope", true).await.is_err());
465 }
466}