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.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    /// A throwaway in-process broker on a random port (token `"t"`).
325    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    /// Join a worker to the bus so the post-deploy presence verify can see it
338    /// (mailbox id == worker id, registered under `role`). Keep the handle alive.
339    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        // No credential material anywhere in the serialized output.
372        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        // Exercises the register/stop plumbing through the tool. `/usr/bin/true`
417        // stands in for the `bamboo` binary (spawned with broker-agent args it
418        // ignores), so it drives the spawn/register path only. Deploy now runs a
419        // post-deploy presence verify for NON-echo workers too, so we stand up a
420        // real broker and join a matching worker (id "node-n1" under the resident
421        // default role "general-purpose") to stand in for the process dialing home.
422        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        // The worker "dials home": a live bus client under the node's resolved role.
433        let _worker = join_worker(&endpoint, "node-n1", "general-purpose").await;
434
435        // Unique data dir so the persisted config.json doesn't collide with peers.
436        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}