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, ToolError, ToolExecutionContext, 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 execute(&self, args: Value) -> Result<ToolResult, ToolError> {
250 self.execute_with_context(args, ToolExecutionContext::none("tool_call"))
251 .await
252 }
253
254 async fn execute_with_context(
255 &self,
256 args: Value,
257 _ctx: ToolExecutionContext<'_>,
258 ) -> Result<ToolResult, ToolError> {
259 let parsed: ClusterArgs = serde_json::from_value(args)
260 .map_err(|e| ToolError::InvalidArguments(format!("Invalid cluster args: {e}")))?;
261 match parsed {
262 ClusterArgs::List => self.list().await,
263 ClusterArgs::Describe { node } => self.describe(&node).await,
264 ClusterArgs::Status { node } => self.status(&node).await,
265 ClusterArgs::Deploy { node, echo } => self.deploy(&node, echo).await,
266 ClusterArgs::Stop { node } => self.stop(&node).await,
267 }
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.broker = Some(bamboo_config::BrokerClientConfig {
434 endpoint: endpoint.clone(),
435 token: "t".into(),
436 token_encrypted: None,
437 });
438 let cfg = Arc::new(RwLock::new(config));
439
440 let _worker = join_worker(&endpoint, "node-n1", "general-purpose").await;
442
443 let data_dir = std::env::temp_dir().join(format!("bamboo-clustertool-{}", std::process::id()));
445 let _ = std::fs::create_dir_all(&data_dir);
446 let deployer = Arc::new(crate::fabric_deploy::FabricDeployer::new(
447 cfg.clone(),
448 Arc::new(tokio::sync::Mutex::new(())),
449 &data_dir,
450 Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
451 "/usr/bin/true",
452 ));
453 let registry = deployer.registry();
454 let t = ClusterTool::new(cfg, deployer);
455
456 let key = crate::registry_keys::node_key("n1");
457 let out = parse(t.deploy("n1", false).await.unwrap());
458 assert_eq!(out["worker_id"], "node-n1");
459 assert_eq!(out["status"], "deployed");
460 assert!(registry.lock().await.contains_key(&key), "handle registered");
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}