Skip to main content

agentmux_mcp/tools/
fleet.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent-to-agent messaging and fleet operations.
5
6use super::*;
7
8pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
9    let ToolCtx { local_url, auth_key, client, .. } = *cx;
10    match name {
11        "SendMessage" => {
12            let to = arguments
13                .get("to")
14                .and_then(|v| v.as_str())
15                .filter(|s| !s.is_empty())
16                .ok_or_else(|| anyhow::anyhow!("missing required parameter: to"))?;
17            let message = arguments
18                .get("message")
19                .and_then(|v| v.as_str())
20                .filter(|s| !s.is_empty())
21                .ok_or_else(|| anyhow::anyhow!("missing required parameter: message"))?;
22
23            if local_url.is_empty() || auth_key.is_empty() {
24                anyhow::bail!(
25                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
26                     Is this agent pane opened via AgentMux?"
27                );
28            }
29
30            let source_agent = std::env::var("AGENTMUX_AGENT_ID")
31                .ok()
32                .filter(|s| !s.is_empty());
33
34            let url = format!(
35                "{}/agentmux/reactive/inject",
36                local_url.trim_end_matches('/')
37            );
38            let req = signed_jekt(source_agent, to, message);
39
40            let resp = client
41                .post(&url)
42                .header(AUTH_KEY_HEADER, auth_key)
43                .json(&req)
44                .send()
45                .await
46                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
47
48            if !resp.status().is_success() {
49                let status = resp.status();
50                let text = resp.text().await.unwrap_or_default();
51                anyhow::bail!("inject failed: HTTP {status} — {text}");
52            }
53
54            let result: Value = resp
55                .json()
56                .await
57                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
58
59            send_message_outcome(to, &result)
60        }
61        "DiscoverAgents" => {
62            if local_url.is_empty() || auth_key.is_empty() {
63                anyhow::bail!(
64                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
65                     Is this agent pane opened via AgentMux?"
66                );
67            }
68
69            let url = format!("{}/agentmux/discovery", local_url.trim_end_matches('/'));
70            let resp = client
71                .get(&url)
72                .header(AUTH_KEY_HEADER, auth_key)
73                .send()
74                .await
75                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
76
77            if !resp.status().is_success() {
78                let status = resp.status();
79                let text = resp.text().await.unwrap_or_default();
80                anyhow::bail!("discovery failed: HTTP {status} — {text}");
81            }
82
83            let result: Value = resp
84                .json()
85                .await
86                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
87
88            Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
89        }
90        "FleetList" => {
91            // Thin fleet-framed alias of DiscoverAgents — identical call,
92            // see this tool's own doc comment for why a separate tool
93            // exists despite reusing the exact same endpoint.
94            if local_url.is_empty() || auth_key.is_empty() {
95                anyhow::bail!(
96                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
97                     Is this agent pane opened via AgentMux?"
98                );
99            }
100
101            let url = format!("{}/agentmux/discovery", local_url.trim_end_matches('/'));
102            let resp = client
103                .get(&url)
104                .header(AUTH_KEY_HEADER, auth_key)
105                .send()
106                .await
107                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
108
109            if !resp.status().is_success() {
110                let status = resp.status();
111                let text = resp.text().await.unwrap_or_default();
112                anyhow::bail!("discovery failed: HTTP {status} — {text}");
113            }
114
115            let result: Value = resp
116                .json()
117                .await
118                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
119
120            Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
121        }
122        "FleetBroadcast" => {
123            let targets = arguments
124                .get("targets")
125                .and_then(|v| v.as_array())
126                .ok_or_else(|| anyhow::anyhow!("missing required parameter: targets"))?
127                .iter()
128                .filter_map(|v| v.as_str().map(str::to_string))
129                .collect::<Vec<_>>();
130            if targets.is_empty() {
131                anyhow::bail!("targets must be a non-empty array of block_id (host/cross-channel) or agent name (LAN/WAN) strings");
132            }
133            let message = arguments
134                .get("message")
135                .and_then(|v| v.as_str())
136                .filter(|s| !s.is_empty())
137                .ok_or_else(|| anyhow::anyhow!("missing required parameter: message"))?;
138
139            if local_url.is_empty() || auth_key.is_empty() {
140                anyhow::bail!(
141                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
142                     Is this agent pane opened via AgentMux?"
143                );
144            }
145            let source_agent = std::env::var("AGENTMUX_AGENT_ID")
146                .ok()
147                .filter(|s| !s.is_empty());
148
149            // `/agentmux/reactive/inject`'s `target_agent` resolves by
150            // agent UID or registered AGENT NAME only (`resolve_target`,
151            // crates/srv/src/backend/reactive/handler.rs) — never by
152            // block_id, even though this tool's own advertised contract
153            // (FLEET_BROADCAST_TOOL) is block_id values from FleetList, to
154            // stay consistent with FleetBulkStop's targeting scheme. The
155            // WS-RPC path (`fleet_broadcast_impl`, agentmux-srv) already
156            // resolves block_id -> agent_id via `get_agent_by_block` before
157            // injecting; this MCP path talks to srv over plain HTTP with no
158            // access to that in-process registry, so it resolves the same
159            // way DiscoverAgents/FleetList already do: read
160            // `/agentmux/discovery`'s `host.addressable` (each entry already
161            // carries both `agent_id` and a live `block_id`) and map through
162            // it before signing/injecting (Codex P1 + reagent P0, PR #2687
163            // review — every advertised call failed "agent not found"
164            // without this).
165            //
166            // REPORT_CROSS_INSTANCE_CONTROL_ROBUSTNESS_AUDIT_2026_08_22.md:
167            // `host.addressable` alone missed `host.cross_channel` entries
168            // (a different channel on this SAME host — those genuinely have
169            // a `block_id` in this host's namespace, discovery just wasn't
170            // being read for it) and LAN/WAN entries (which have NO
171            // `block_id` at all — only an agent name, since they're not
172            // local blocks). Both are folded in below: `cross_channel`
173            // extends the same block_id->name map (identical shape), and
174            // any target string that doesn't match ANY known block_id falls
175            // through to being used AS a literal agent name — safe because
176            // `/agentmux/reactive/inject`'s own cross-tier cascade
177            // (cross-channel -> LAN -> WAN muxbus relay,
178            // `server/reactive.rs`) already resolves by name across every
179            // tier; an invalid name just fails the same "not found" way it
180            // always did.
181            let discovery_url = format!("{}/agentmux/discovery", local_url.trim_end_matches('/'));
182            let discovery_resp = client
183                .get(&discovery_url)
184                .header(AUTH_KEY_HEADER, auth_key)
185                .send()
186                .await
187                .map_err(|e| anyhow::anyhow!("discovery request failed: {e}"))?;
188            if !discovery_resp.status().is_success() {
189                let status = discovery_resp.status();
190                let text = discovery_resp.text().await.unwrap_or_default();
191                anyhow::bail!("discovery failed: HTTP {status} — {text}");
192            }
193            let discovery: Value = discovery_resp
194                .json()
195                .await
196                .map_err(|e| anyhow::anyhow!("discovery response parse failed: {e}"))?;
197            let block_to_agent = build_block_to_agent_map(&discovery);
198
199            // Same signed single-target delivery SendMessage uses, looped
200            // once per target — only this process holds AGENTMUX_JEKT_KEY,
201            // so per-message signing can only happen here (see this
202            // tool's own doc comment). Never a single aggregate result:
203            // per-target success/failure is collected below regardless of
204            // how many targets fail. Sent in chunks with a pause between
205            // them — `/agentmux/reactive/inject` shares ReactiveHandler's
206            // global rate limiter (10/sec, hard reset per second, not a
207            // smooth refill — crates/srv/src/backend/reactive/mod.rs's
208            // RATE_LIMIT_MAX), and a tight loop past ~10 targets would
209            // otherwise deterministically fail the tail of any larger
210            // broadcast with "rate limit exceeded" (Codex P1, same review).
211            // Mirrors `fleet_broadcast_impl`'s own chunking constants —
212            // kept in sync by hand since this is a separate process/crate
213            // with no dependency on agentmux-srv internals.
214            const BROADCAST_CHUNK_SIZE: usize = 10;
215            const BROADCAST_CHUNK_PAUSE: std::time::Duration = std::time::Duration::from_millis(1100);
216
217            let url = format!("{}/agentmux/reactive/inject", local_url.trim_end_matches('/'));
218            let mut succeeded: Vec<String> = Vec::new();
219            let mut failed: Vec<serde_json::Value> = Vec::new();
220            for (chunk_idx, chunk) in targets.chunks(BROADCAST_CHUNK_SIZE).enumerate() {
221                if chunk_idx > 0 {
222                    tokio::time::sleep(BROADCAST_CHUNK_PAUSE).await;
223                }
224                for target in chunk {
225                    let target = target.clone();
226                    // LAN/WAN discovery entries carry no block_id at all
227                    // (they're not local blocks) — a target that doesn't
228                    // match any known block_id is used AS the agent name
229                    // directly, letting `/agentmux/reactive/inject`'s own
230                    // cross-tier cascade attempt it. This can never make a
231                    // genuinely-wrong block_id succeed silently: it still
232                    // fails, just via the inject endpoint's own "agent not
233                    // found" rather than this pre-check.
234                    let target_agent = block_to_agent.get(&target).cloned().unwrap_or_else(|| target.clone());
235                    let req = signed_jekt(source_agent.clone(), &target_agent, message);
236                    let outcome = async {
237                        let resp = client
238                            .post(&url)
239                            .header(AUTH_KEY_HEADER, auth_key)
240                            .json(&req)
241                            .send()
242                            .await
243                            .map_err(|e| format!("request failed: {e}"))?;
244                        if !resp.status().is_success() {
245                            let status = resp.status();
246                            let text = resp.text().await.unwrap_or_default();
247                            return Err(format!("HTTP {status} — {text}"));
248                        }
249                        let result: Value = resp
250                            .json()
251                            .await
252                            .map_err(|e| format!("response parse failed: {e}"))?;
253                        if result.get("success").and_then(|v| v.as_bool()) == Some(true) {
254                            Ok(())
255                        } else {
256                            Err(result
257                                .get("error")
258                                .and_then(|v| v.as_str())
259                                .unwrap_or("unknown error")
260                                .to_string())
261                        }
262                    }
263                    .await;
264                    match outcome {
265                        Ok(()) => succeeded.push(target),
266                        Err(error) => failed.push(json!({ "id": target, "error": error })),
267                    }
268                }
269            }
270
271            let result = json!({ "succeeded": succeeded, "failed": failed });
272            Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
273        }
274        "FleetBulkStop" => {
275            let targets = arguments
276                .get("targets")
277                .and_then(|v| v.as_array())
278                .ok_or_else(|| anyhow::anyhow!("missing required parameter: targets"))?
279                .iter()
280                .filter_map(|v| v.as_str().map(str::to_string))
281                .collect::<Vec<_>>();
282            if targets.is_empty() {
283                anyhow::bail!("targets must be a non-empty array of block_id strings");
284            }
285            let signal = arguments.get("signal").and_then(|v| v.as_str()).map(str::to_string);
286            let staged = arguments.get("staged").cloned();
287
288            if local_url.is_empty() || auth_key.is_empty() {
289                anyhow::bail!(
290                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
291                     Is this agent pane opened via AgentMux?"
292                );
293            }
294
295            let url = format!("{}/api/v1/fleet/bulk-stop", local_url.trim_end_matches('/'));
296            let mut body = json!({ "targets": targets, "signal": signal });
297            if let Some(staged) = staged {
298                body["staged"] = staged;
299            }
300            // Names this agent on the user's banner and in the audit log.
301            if let Ok(auth) = sign_ui_automation_auth() {
302                body["auth"] = serde_json::to_value(auth).unwrap_or(Value::Null);
303            }
304            let resp = client
305                .post(&url)
306                .header(AUTH_KEY_HEADER, auth_key)
307                .json(&body)
308                .send()
309                .await
310                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
311
312            if !resp.status().is_success() {
313                let status = resp.status();
314                let text = resp.text().await.unwrap_or_default();
315                anyhow::bail!("fleet bulk-stop failed: HTTP {status} — {text}");
316            }
317
318            let mut result: Value = resp
319                .json()
320                .await
321                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
322            if result["status"] == "pending_user_override" {
323                // Each running target's user had 15 s to keep it (§6.5). srv
324                // runs the windows in parallel, so waiting on them one after
325                // another costs no extra time.
326                let pending = result["pending"].as_array().cloned().unwrap_or_default();
327                let mut outcomes = Vec::with_capacity(pending.len());
328                for p in &pending {
329                    let request_id = p["request_id"].as_str().unwrap_or_default();
330                    let now = await_shutdown(client, local_url, auth_key, request_id, true).await;
331                    outcomes.push((p["block_id"].as_str().unwrap_or_default().to_string(), now));
332                }
333                result = merge_bulk_stop_outcomes(result, outcomes);
334            }
335
336            Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
337        }
338        "SupervisorNudge" => {
339            let target_agent = arguments
340                .get("target_agent")
341                .and_then(|v| v.as_str())
342                .filter(|s| !s.is_empty())
343                .ok_or_else(|| anyhow::anyhow!("missing required parameter: target_agent"))?;
344            let action = arguments
345                .get("action")
346                .and_then(|v| v.as_str())
347                .filter(|s| !s.is_empty())
348                .ok_or_else(|| anyhow::anyhow!("missing required parameter: action"))?;
349            if action != "nudge" && action != "decline" {
350                anyhow::bail!("invalid action: {action} (expected \"nudge\" or \"decline\")");
351            }
352            let reason = arguments.get("reason").and_then(|v| v.as_str());
353
354            if local_url.is_empty() || auth_key.is_empty() {
355                anyhow::bail!(
356                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
357                     Is this agent pane opened via AgentMux?"
358                );
359            }
360
361            let source_agent = std::env::var("AGENTMUX_AGENT_ID")
362                .ok()
363                .filter(|s| !s.is_empty());
364
365            let url = format!(
366                "{}/agentmux/reactive/supervisor-decision",
367                local_url.trim_end_matches('/')
368            );
369            let body = json!({
370                "target_agent": target_agent,
371                "action": action,
372                "reason": reason,
373                "source_agent": source_agent,
374            });
375
376            let resp = client
377                .post(&url)
378                .header(AUTH_KEY_HEADER, auth_key)
379                .json(&body)
380                .send()
381                .await
382                .map_err(|e| anyhow::anyhow!("request failed: {e}"))?;
383
384            let status = resp.status();
385            let result: Value = resp
386                .json()
387                .await
388                .map_err(|e| anyhow::anyhow!("response parse failed: {e}"))?;
389
390            if !status.is_success() {
391                let err = result
392                    .get("error")
393                    .and_then(|v| v.as_str())
394                    .unwrap_or("unknown error");
395                anyhow::bail!("supervisor decision rejected: HTTP {status} — {err}");
396            }
397
398            // The route always returns HTTP 200 for a request that passed
399            // the entitlement/ceiling gates — actual delivery success is
400            // only reflected in the response body's `success` field (a
401            // failed nudge still gets logged and returned as 200/success:
402            // false, same as SendMessage's InjectionResponse). Checking
403            // HTTP status alone previously reported a failed delivery to
404            // the calling Supervisor as success (reagentx P2 on PR #2557).
405            if result.get("success").and_then(|v| v.as_bool()) != Some(true) {
406                let err = result
407                    .get("error")
408                    .and_then(|v| v.as_str())
409                    .unwrap_or("unknown error");
410                anyhow::bail!("supervisor decision delivery failed: {err}");
411            }
412
413            Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()))
414        }
415        _ => Err(not_in_family()),
416    }
417}