Skip to main content

bamboo_server_tools/
cluster_tool.rs

1//! `cluster` — the agent's read-only window into the operator-managed Remote
2//! Cluster Fabric (RFC v2 §5, the progressive-disclosure ladder).
3//!
4//! Rung 0 (this tool's description) advertises the capability in the cached
5//! prompt prefix. Rungs 1–2 are tool-pull: `list` (inventory), `describe` (one
6//! node's capabilities), `status` (a node's live deploy state). Everything
7//! volatile is returned by the CALL — nothing is injected into the prompt
8//! prefix — so the 1h cache is never busted.
9//!
10//! It NEVER exposes credentials. Dispatch (deploying a worker onto a node,
11//! driving it) is a separate concern: the operator deploys from the UI, and the
12//! agent commands the resulting worker with `ask_agent` by the `worker_id` this
13//! tool surfaces.
14
15use 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    /// Read-only access for the inventory actions (list/describe/status).
30    config: Arc<RwLock<Config>>,
31    /// Shared deploy engine (one registry across HTTP + agent surfaces).
32    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
41/// Map a fabric engine error to a tool error.
42fn 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    /// Compact inventory of all nodes + clusters.
53    List,
54    /// One node's capabilities (model/role/workspace/placement/status).
55    Describe { node: String },
56    /// One node's live deploy state.
57    Status { node: String },
58    /// Deploy a worker onto a managed node (credentials resolved server-side).
59    Deploy {
60        node: String,
61        #[serde(default)]
62        echo: bool,
63    },
64    /// Stop a worker previously deployed onto a node.
65    Stop { node: String },
66}
67
68/// A node's address line for display (NEVER includes credentials).
69fn 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
87/// Compact one-line-per-node summary for `list`.
88fn 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        // node id → first cluster name (for the brief).
106        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        // Delegate to the shared engine (one registry across HTTP + agent), which
178        // resolves stored creds server-side and persists NodeState.
179        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    /// A throwaway in-process broker on a random port (token `"t"`).
329    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    /// Join a worker to the bus so the post-deploy presence verify can see it
342    /// (mailbox id == worker id, registered under `role`). Keep the handle alive.
343    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        // No credential material anywhere in the serialized output.
376        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        // Exercises the register/stop plumbing through the tool. `/usr/bin/true`
421        // stands in for the `bamboo` binary (spawned with broker-agent args it
422        // ignores), so it drives the spawn/register path only. Deploy now runs a
423        // post-deploy presence verify for NON-echo workers too, so we stand up a
424        // real broker and join a matching worker (id "node-n1" under the resident
425        // default role "general-purpose") to stand in for the process dialing home.
426        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        // The worker "dials home": a live bus client under the node's resolved role.
437        let _worker = join_worker(&endpoint, "node-n1", "general-purpose").await;
438
439        // Unique data dir so the persisted config.json doesn't collide with peers.
440        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}