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