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