Skip to main content

agentmux_mcp/
tool_helpers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Helpers the tool handlers share: cron, durations, quit-self and bulk-stop outcomes, delivery text.
5//! Split out of main.rs unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.2).
6
7use super::*;
8
9pub(crate) async fn cron_set_enabled(
10    client: &reqwest::Client,
11    local_url: &str,
12    auth_key: &str,
13    id: &str,
14    action: &str,
15) -> Result<String> {
16    let url = format!("{}/agentmux/cron/{}", local_url.trim_end_matches('/'), id);
17    let body = serde_json::json!({"action": action});
18    let resp = client.patch(&url).header(AUTH_KEY_HEADER, auth_key).json(&body).send().await
19        .map_err(|e| anyhow::anyhow!("cron {action} request failed: {e}"))?;
20    let status = resp.status();
21    if status.as_u16() == 404 {
22        return Ok(format!("Job '{id}' not found"));
23    }
24    if !status.is_success() {
25        let text = resp.text().await.unwrap_or_default();
26        anyhow::bail!("Cron{} failed: HTTP {status} — {text}", if action == "pause" { "Pause" } else { "Resume" });
27    }
28    Ok(format!("Cron job {id} {}", if action == "pause" { "paused" } else { "resumed" }))
29}
30
31/// Parse a loop interval string into a `Duration`. Accepts a number with an
32/// optional unit suffix: `s` (seconds), `m` (minutes), `h` (hours); a bare
33/// number is treated as minutes. Clamped to [10s, 24h].
34pub(crate) fn parse_interval(s: &str) -> Result<Duration> {
35    let s = s.trim();
36    if s.is_empty() {
37        anyhow::bail!("interval is empty");
38    }
39    let (num_part, mult_secs) = if let Some(n) = s.strip_suffix('s').or_else(|| s.strip_suffix('S')) {
40        (n, 1.0_f64)
41    } else if let Some(n) = s.strip_suffix('m').or_else(|| s.strip_suffix('M')) {
42        (n, 60.0)
43    } else if let Some(n) = s.strip_suffix('h').or_else(|| s.strip_suffix('H')) {
44        (n, 3600.0)
45    } else {
46        (s, 60.0) // bare number → minutes
47    };
48    let val: f64 = num_part
49        .trim()
50        .parse()
51        .map_err(|_| anyhow::anyhow!("invalid interval '{s}' (expected e.g. 30s, 5m, 1h)"))?;
52    if !val.is_finite() || val <= 0.0 {
53        anyhow::bail!("interval must be a positive number: '{s}'");
54    }
55    let secs = (val * mult_secs).round() as u64;
56    Ok(Duration::from_secs(secs.clamp(10, 24 * 3600)))
57}
58
59/// Human-readable representation of a clamped Duration — used in the Loop
60/// success message so the reported cadence matches the actual run cadence.
61pub(crate) fn format_duration(d: Duration) -> String {
62    let s = d.as_secs();
63    if s % 3600 == 0 {
64        format!("{}h", s / 3600)
65    } else if s % 60 == 0 {
66        format!("{}m", s / 60)
67    } else {
68        format!("{}s", s)
69    }
70}
71
72/// What `QuitSelf` tells the agent, from srv's answer (§4.2, §6.3).
73pub(crate) fn quit_self_result(status: u16, body: &Value) -> anyhow::Result<String> {
74    match status {
75        202 => Ok("Quit scheduled. Your session ends when this turn finishes: give the user a one-line goodbye and start no new work.".to_string()),
76        200 => Ok("Already quitting — nothing more to do.".to_string()),
77        403 => anyhow::bail!(
78            "QuitSelf refused ({}): only the user's own message that started this turn can ask you to quit, and nothing else may have been delivered since. Do NOT retry. Tell the user they can type /quit in your pane.",
79            body.get("refused").and_then(|v| v.as_str()).unwrap_or("not allowed")
80        ),
81        _ => anyhow::bail!("QuitSelf failed: HTTP {status} — {}", body),
82    }
83}
84
85/// Polls srv for a shutdown waiting on the user's override
86/// (SPEC_AGENT_SELF_QUIT_2026_09_24.md §6.5) until it leaves `pending` — and
87/// `proceeding` too when `to_the_end` — and returns the last status seen. One
88/// long request instead would outlast the client's own timeout.
89pub(crate) async fn await_shutdown(
90    client: &reqwest::Client,
91    local_url: &str,
92    auth_key: &str,
93    request_id: &str,
94    to_the_end: bool,
95) -> Value {
96    let url = format!("{}/api/v1/agent/shutdown/{request_id}", local_url.trim_end_matches('/'));
97    // The window, the turn end a self-quit waits for (30 s) and the graceful
98    // stop itself, with room to spare.
99    let give_up = std::time::Instant::now() + std::time::Duration::from_secs(90);
100    let mut last = Value::Null;
101    while std::time::Instant::now() < give_up {
102        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
103        if let Ok(r) = client.get(&url).header(AUTH_KEY_HEADER, auth_key).send().await {
104            last = r.json().await.unwrap_or(last);
105        }
106        match last["status"].as_str().unwrap_or("") {
107            "" | "pending" => {}
108            "proceeding" if to_the_end => {}
109            _ => break,
110        }
111    }
112    last
113}
114
115/// What `ClosePane block_id=` tells the agent once the target's user has
116/// answered (§6.5).
117pub(crate) fn close_pane_outcome(block: &str, now: &Value) -> anyhow::Result<String> {
118    // Joined a shutdown already under way that does less than close the pane.
119    if let Some(via) = now["via"].as_str().filter(|v| *v != "ClosePane") {
120        let by = now["by"].as_str().unwrap_or("another agent");
121        return match now["status"].as_str().unwrap_or("") {
122            "kept_by_user" => Ok(format!("Not closed: the user chose to keep pane {block:?} running.")),
123            // Found gone (closed, or its agent already stopped): retrying
124            // would only find it gone again.
125            "superseded" => Ok(format!("Pane {block:?} was already closed, or its agent already stopped: nothing more to do.")),
126            "failed" => anyhow::bail!("close failed: {}", now["error"].as_str().unwrap_or("no detail")),
127            "" | "pending" => anyhow::bail!("close of {block:?}: no answer from the user's override window"),
128            state => Ok(format!(
129                "{by}'s {via} was already shutting pane {block:?}'s agent down ({state}); the pane itself may still be open — call ClosePane again to close it."
130            )),
131        };
132    }
133    match now["status"].as_str().unwrap_or("") {
134        "shut_down" => Ok(format!("Closed pane {block:?}: its user didn't keep it within 15 s.")),
135        "kept_by_user" => Ok(format!("Not closed: the user chose to keep pane {block:?} running.")),
136        "superseded" => Ok(format!("Pane {block:?} was already closed.")),
137        "proceeding" => Ok(format!("Pane {block:?} is shutting down: its user didn't keep it.")),
138        "failed" => anyhow::bail!("close failed: {}", now["error"].as_str().unwrap_or("no detail")),
139        _ => anyhow::bail!("close of {block:?}: no answer from the user's override window"),
140    }
141}
142
143/// Folds each waited-on target into `FleetBulkStop`'s usual shape: stopped
144/// (or already gone) → `succeeded`; kept by the user or failed → `failed`,
145/// with the kept ones also listed in `kept_by_user`.
146pub(crate) fn merge_bulk_stop_outcomes(pending: Value, outcomes: Vec<(String, Value)>) -> Value {
147    let mut succeeded = pending["succeeded"].as_array().cloned().unwrap_or_default();
148    let mut failed = pending["failed"].as_array().cloned().unwrap_or_default();
149    let mut kept = Vec::new();
150    for (block, now) in outcomes {
151        match now["status"].as_str().unwrap_or("") {
152            "shut_down" | "superseded" | "proceeding" => succeeded.push(json!(block)),
153            "kept_by_user" => {
154                failed.push(json!({ "id": block, "error": "the user kept it running" }));
155                kept.push(json!(block));
156            }
157            "failed" => failed.push(json!({ "id": block, "error": now["error"].as_str().unwrap_or("stop failed") })),
158            _ => failed.push(json!({ "id": block, "error": "no answer from the user's override window" })),
159        }
160    }
161    json!({
162        "succeeded": succeeded,
163        "failed": failed,
164        "kept_by_user": kept,
165        "aborted_early": pending["aborted_early"].as_bool().unwrap_or(false),
166    })
167}
168
169/// What `QuitSelf` tells the agent once the user's override window has
170/// settled (§6.5).
171pub(crate) fn quit_self_outcome(state: &str, body: &Value) -> anyhow::Result<String> {
172    match state {
173        "kept_by_user" => Ok("The user chose to keep you running. Do NOT quit and do not call QuitSelf again for this; carry on.".to_string()),
174        "proceeding" | "shut_down" => Ok("The user didn't stop it: you shut down when this turn ends. Give the user a one-line goodbye and start no new work.".to_string()),
175        "superseded" => Ok("The user closed you themselves meanwhile.".to_string()),
176        _ => anyhow::bail!(
177            "QuitSelf: the shutdown {state}: {}",
178            body.get("error").and_then(|v| v.as_str()).unwrap_or("no detail")
179        ),
180    }
181}
182
183/// `SendMessage`'s answer when srv accepted the message but queued it because
184/// the target's process is starting up, restarting or stopping
185/// (SPEC_JEKT_IMMEDIATE_DELIVERY_2026_09_28.md §2.1). A target that is up gets
186/// it at once, mid-turn included, and is reported as delivered.
187pub(crate) fn deferred_delivery_text(to: &str) -> String {
188    format!(
189        "QUEUED for {to} — their agent is starting up, restarting or stopping, \
190         so it has not reached them yet. Their AgentMux delivers it as soon as \
191         their agent is up. Don't resend it."
192    )
193}
194
195/// What `SendMessage` tells the sender, from srv's `/agentmux/reactive/inject`
196/// body. Each answer keeps its first word (`Delivered`, `QUEUED`, `HELD`) so
197/// existing prompts keep working, and adds the message id and the receiver's
198/// condition where srv's answer already implies it
199/// (SPEC_JEKT_DELIVERY_STATES_AND_MAILBOX_2026_10_01.md §5.2, Phase 0 item 3).
200pub(crate) fn send_message_outcome(to: &str, result: &Value) -> Result<String> {
201    // srv echoes the sender's own msgid, or the cloud relay's injection id on
202    // the relay branch; it is the id the receiver's `MSGID=` shows.
203    let id = match result.get("request_id").and_then(|v| v.as_str()) {
204        Some(id) if !id.is_empty() => format!(" id={id}"),
205        _ => String::new(),
206    };
207    if result.get("success").and_then(|v| v.as_bool()) == Some(true) {
208        // `success: true` spans two very different outcomes and the old
209        // message conflated them, which made agent-to-agent delivery
210        // unfalsifiable from the sender side: a name that exists on no
211        // machine anywhere reported exactly the same string as a message
212        // injected into a live conversation. Verified against a running
213        // srv — `SendMessage(to="definitely-not-a-real-agent-xyz123")`
214        // returned "Message sent to ...", and the server log showed "cloud
215        // relay: queued for WAN delivery" for it, identical to a real remote
216        // agent.
217        //
218        // The response already carries the distinction: the handler sets
219        // `block_id` to the receiving block on local/host delivery
220        // (backend/reactive/handler.rs), while the cloud-relay path leaves it
221        // `None` because no receiver has seen the message yet
222        // (server/reactive.rs, `try_cloud_relay` — "Queued is not delivered").
223        if result.get("block_id").and_then(|v| v.as_str()).is_some() {
224            // srv queues a message while the target's process is starting
225            // up, restarting or stopping (SPEC_JEKT_IMMEDIATE_DELIVERY_2026_09_28.md
226            // §2.1) and says so with `deferred`; "injected" would be untrue.
227            if result.get("deferred").and_then(|v| v.as_bool()) == Some(true) {
228                return Ok(format!("{}{id}", deferred_delivery_text(to)));
229            }
230            return Ok(format!("Delivered to {to} — injected into their running conversation.{id}"));
231        }
232        // The relay accepts any name; it says when the target is not one of
233        // the sender's own account's agents. An agent
234        // counts once it has run while signed in, so one of the sender's own
235        // that hasn't yet still gets `false` and can still receive it (Codex
236        // P2 on #4249): a hint, never "only another account".
237        let not_yours = if result.get("target_in_account").and_then(|v| v.as_bool()) == Some(false) {
238            " No agent of that name has signed in from your account yet, so check the spelling. \
239             It is still delivered if one of your agents with that name signs in within 30 \
240             minutes, or if another account has an agent with that name."
241        } else {
242            ""
243        };
244        return Ok(format!(
245            "QUEUED for {to} via the cloud relay (unconfirmed, expires in 30 min) — NOT yet \
246             delivered. The relay accepted it; their AgentMux picks it up when it next syncs, \
247             and the relay drops it after 30 minutes if nothing does (which is what happens if \
248             that instance stays offline). You get this same result for an agent name that \
249             does not exist anywhere, so check the spelling against DiscoverAgents if you \
250             expected local delivery.{not_yours}{id}"
251        ));
252    }
253    if result.get("held").and_then(|v| v.as_bool()) == Some(true) {
254        // SPEC_DURABLE_JEKT_DELIVERY_2026_09_24.md: the target is a known
255        // agent that is not running anywhere srv can reach, so srv kept the
256        // message and delivers it when the agent starts. Since Phase 0 item 4
257        // it also holds for a receiver that is here but not signed in.
258        if result.get("held_reason").and_then(|v| v.as_str()) == Some("needs_login") {
259            return Ok(format!(
260                "HELD for {to} (needs_login) — not delivered yet. {to}'s agent cannot start \
261                 because it is not signed in; this AgentMux instance (channel) keeps the message \
262                 and delivers it within about a minute of their signing in, for up to 24 hours. \
263                 Do not resend it.{id}"
264            ));
265        }
266        return Ok(format!(
267            "HELD for {to} (not_running) — not delivered yet. {to} is not running; this \
268             AgentMux instance (channel) keeps the message and delivers it when {to} starts \
269             here, for up to 24 hours. Do not resend it.{id}"
270        ));
271    }
272    let err = result.get("error").and_then(|v| v.as_str()).unwrap_or("unknown error");
273    // Two failures the sender can act on differently. A spawn-gate refusal is
274    // recoverable (the receiver signs in). srv now holds it (`needs_login`
275    // above); this answer remains for an older srv, a cron sender or a
276    // non-host tier, which are not held.
277    if err.starts_with("identity spawn gate") {
278        anyhow::bail!(
279            "Message delivery failed (needs_login): {to}'s agent cannot start because it is not \
280             signed in, and the message was NOT kept. Send it again after they sign in. ({err})"
281        )
282    }
283    if err.starts_with("agent not found") {
284        anyhow::bail!("Message delivery failed (not_found): {err}")
285    }
286    anyhow::bail!("Message delivery failed: {err}")
287}