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
180 .deployer
181 .deploy(node_id, echo)
182 .await
183 .map_err(to_tool_error)?;
184 let worker_id = state.worker_id.clone().unwrap_or_default();
185 Ok(tool_json(json!({
186 "node": node_id,
187 "worker_id": worker_id,
188 "status": "deployed",
189 "note": format!(
190 "worker '{worker_id}' is dialing the broker; command it with ask_agent(target=\"{worker_id}\", …)."
191 ),
192 })))
193 }
194
195 async fn stop(&self, node_id: &str) -> Result<ToolResult, ToolError> {
196 self.deployer.stop(node_id).await.map_err(to_tool_error)?;
197 Ok(tool_json(json!({ "node": node_id, "status": "stopped" })))
198 }
199}
200
201fn tool_json(value: Value) -> ToolResult {
202 ToolResult {
203 success: true,
204 result: value.to_string(),
205 display_preference: None,
206 images: Vec::new(),
207 }
208}
209
210#[async_trait]
211impl Tool for ClusterTool {
212 fn name(&self) -> &str {
213 "cluster"
214 }
215
216 fn description(&self) -> &str {
217 "Inspect your operator-managed remote clusters: machines (\"nodes\") grouped into clusters \
218 that you can run work on. Use this to DISCOVER what compute you have, then dispatch to it.\n\
219 \n\
220 PREFER LOCAL: default to a local `SubAgent` for delegation. Dispatch to a cluster node ONLY \
221 when the task genuinely needs THAT machine (its data, GPU, network location). Deploying a \
222 node uploads a binary and adds network latency — don't route here by default.\n\
223 \n\
224 ACTIONS:\n\
225 - action=list — compact inventory: every node's id, label, target (user@host or local), \
226 status, its worker_id if deployed, and cluster membership. Start here.\n\
227 - action=describe node=<id> — one node's capabilities: placement, role, model, workspace, \
228 status, worker_id.\n\
229 - action=status node=<id> — one node's live deploy state (deployed_at, pid, last error).\n\
230 - action=deploy node=<id> [echo=true] — deploy a worker onto the node (credentials are \
231 resolved by the backend; you never see them). Returns a worker_id. Use echo=true for a \
232 no-LLM connectivity smoke test.\n\
233 - action=stop node=<id> — stop the worker you deployed on that node.\n\
234 \n\
235 DISPATCH: after deploy (or for an already-running node), command its worker_id with \
236 ask_agent(target=<worker_id>, question=…, mode=query|steer). For PARALLEL work: list the \
237 cluster, deploy to several nodes, then ask_agent each and gather. You address nodes by id \
238 and never handle credentials."
239 }
240
241 fn parameters_schema(&self) -> Value {
242 json!({
243 "type": "object",
244 "properties": {
245 "action": { "type": "string", "enum": ["list", "describe", "status", "deploy", "stop"] },
246 "node": { "type": "string", "description": "node id (required for describe/status/deploy/stop)." },
247 "echo": { "type": "boolean", "description": "deploy: run the no-LLM echo executor (connectivity smoke)." }
248 },
249 "required": ["action"]
250 })
251 }
252
253 async fn invoke(&self, args: Value, _ctx: ToolCtx) -> Result<ToolOutcome, ToolError> {
254 let parsed: ClusterArgs = serde_json::from_value(args)
255 .map_err(|e| ToolError::InvalidArguments(format!("Invalid cluster args: {e}")))?;
256 let result = match parsed {
257 ClusterArgs::List => self.list().await,
258 ClusterArgs::Describe { node } => self.describe(&node).await,
259 ClusterArgs::Status { node } => self.status(&node).await,
260 ClusterArgs::Deploy { node, echo } => self.deploy(&node, echo).await,
261 ClusterArgs::Stop { node } => self.stop(&node).await,
262 }?;
263 Ok(ToolOutcome::Completed(result))
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use bamboo_config::cluster_fabric::{
271 Cluster, DeployProfile, NodeState, NodeStatus, SshAuth, SshTarget, TrustLevel,
272 };
273
274 fn config_with(nodes: Vec<Node>, clusters: Vec<Cluster>) -> Arc<RwLock<Config>> {
275 let mut cfg = Config::default();
276 cfg.cluster_fabric.nodes = nodes;
277 cfg.cluster_fabric.clusters = clusters;
278 Arc::new(RwLock::new(cfg))
279 }
280
281 fn deployer_for(config: Arc<RwLock<Config>>) -> Arc<crate::fabric_deploy::FabricDeployer> {
282 Arc::new(crate::fabric_deploy::FabricDeployer::new(
283 config,
284 Arc::new(tokio::sync::Mutex::new(())),
285 std::env::temp_dir(),
286 Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
287 "/usr/bin/true",
288 ))
289 }
290
291 fn tool(config: Arc<RwLock<Config>>) -> ClusterTool {
292 let deployer = deployer_for(config.clone());
293 ClusterTool::new(config, deployer)
294 }
295
296 fn ssh_node(id: &str, running: bool) -> Node {
297 Node {
298 id: id.to_string(),
299 label: format!("label-{id}"),
300 placement: NodePlacement::Ssh(SshTarget {
301 host: "10.0.0.9".into(),
302 port: 22,
303 username: "deploy".into(),
304 auth: SshAuth::Password {
305 password: "SECRET".into(),
306 password_encrypted: None,
307 },
308 host_key_fingerprint: None,
309 }),
310 trust_level: TrustLevel::Trusted,
311 deploy: DeployProfile {
312 default_role: Some("worker".into()),
313 ..Default::default()
314 },
315 state: running.then(|| NodeState {
316 status: NodeStatus::Running,
317 worker_id: Some(format!("node-{id}")),
318 ..Default::default()
319 }),
320 enabled: true,
321 }
322 }
323
324 fn parse(r: ToolResult) -> Value {
325 serde_json::from_str(&r.result).unwrap()
326 }
327
328 async fn start_broker() -> (String, tempfile::TempDir) {
330 let dir = tempfile::tempdir().unwrap();
331 let core = Arc::new(bamboo_broker::BrokerCore::new(dir.path()));
332 let server = Arc::new(bamboo_broker::BrokerServer::new(core, "t"));
333 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
334 let addr = listener.local_addr().unwrap();
335 tokio::spawn(async move {
336 let _ = server.serve(listener).await;
337 });
338 (format!("ws://{addr}"), dir)
339 }
340
341 async fn join_worker(endpoint: &str, id: &str, role: &str) -> bamboo_broker::BrokerClient {
344 let mut c = bamboo_broker::BrokerClient::connect(
345 endpoint,
346 bamboo_subagent::AgentRef {
347 session_id: id.into(),
348 role: Some(role.into()),
349 },
350 "t",
351 )
352 .await
353 .unwrap();
354 c.subscribe().await.unwrap();
355 c
356 }
357
358 #[tokio::test]
359 async fn list_summarizes_nodes_without_secrets() {
360 let cfg = config_with(
361 vec![ssh_node("n1", true)],
362 vec![Cluster {
363 name: "prod".into(),
364 description: None,
365 node_ids: vec!["n1".into()],
366 }],
367 );
368 let tool = tool(cfg);
369 let out = parse(tool.list().await.unwrap());
370 let node = &out["nodes"][0];
371 assert_eq!(node["target"], "deploy@10.0.0.9:22");
372 assert_eq!(node["status"], "running");
373 assert_eq!(node["worker_id"], "node-n1");
374 assert_eq!(node["cluster"], "prod");
375 assert!(!out.to_string().contains("SECRET"));
377 assert!(!out.to_string().contains("password"));
378 }
379
380 #[tokio::test]
381 async fn describe_exposes_capabilities_not_creds() {
382 let cfg = config_with(vec![ssh_node("n1", true)], vec![]);
383 let tool = tool(cfg);
384 let out = parse(tool.describe("n1").await.unwrap());
385 assert_eq!(out["placement"], "ssh");
386 assert_eq!(out["role"], "worker");
387 assert_eq!(out["worker_id"], "node-n1");
388 assert!(!out.to_string().contains("SECRET"));
389 }
390
391 #[tokio::test]
392 async fn describe_unknown_node_errors() {
393 let cfg = config_with(vec![], vec![]);
394 let tool = tool(cfg);
395 assert!(tool.describe("nope").await.is_err());
396 }
397
398 #[tokio::test]
399 async fn status_reports_not_deployed_for_fresh_node() {
400 let cfg = config_with(vec![ssh_node("n1", false)], vec![]);
401 let tool = tool(cfg);
402 let out = parse(tool.status("n1").await.unwrap());
403 assert_eq!(out["status"], "not_deployed");
404 }
405
406 fn local_node(id: &str) -> Node {
407 Node {
408 id: id.to_string(),
409 label: id.to_string(),
410 placement: NodePlacement::Local,
411 trust_level: TrustLevel::Trusted,
412 deploy: DeployProfile::default(),
413 state: None,
414 enabled: true,
415 }
416 }
417
418 #[tokio::test]
419 async fn deploy_local_node_registers_worker_then_stop_clears_it() {
420 let (endpoint, _broker_dir) = start_broker().await;
427 let mut config = Config::default();
428 config.cluster_fabric.nodes = vec![local_node("n1")];
429 config.subagents.broker = Some(bamboo_config::BrokerClientConfig {
430 endpoint: endpoint.clone(),
431 token: "t".into(),
432 token_encrypted: None,
433 });
434 let cfg = Arc::new(RwLock::new(config));
435
436 let _worker = join_worker(&endpoint, "node-n1", "general-purpose").await;
438
439 let data_dir =
441 std::env::temp_dir().join(format!("bamboo-clustertool-{}", std::process::id()));
442 let _ = std::fs::create_dir_all(&data_dir);
443 let deployer = Arc::new(crate::fabric_deploy::FabricDeployer::new(
444 cfg.clone(),
445 Arc::new(tokio::sync::Mutex::new(())),
446 &data_dir,
447 Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
448 "/usr/bin/true",
449 ));
450 let registry = deployer.registry();
451 let t = ClusterTool::new(cfg, deployer);
452
453 let key = crate::registry_keys::node_key("n1");
454 let out = parse(t.deploy("n1", false).await.unwrap());
455 assert_eq!(out["worker_id"], "node-n1");
456 assert_eq!(out["status"], "deployed");
457 assert!(
458 registry.lock().await.contains_key(&key),
459 "handle registered"
460 );
461
462 let stopped = parse(t.stop("n1").await.unwrap());
463 assert_eq!(stopped["status"], "stopped");
464 assert!(!registry.lock().await.contains_key(&key), "handle removed");
465 let _ = std::fs::remove_dir_all(&data_dir);
466 }
467
468 #[tokio::test]
469 async fn deploy_unknown_node_errors() {
470 let cfg = config_with(vec![], vec![]);
471 let tool = tool(cfg);
472 assert!(tool.deploy("nope", true).await.is_err());
473 }
474}