agentmux_mcp/
tool_helpers.rs1use 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
31pub(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) };
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
59pub(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
72pub(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
85pub(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 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
115pub(crate) fn close_pane_outcome(block: &str, now: &Value) -> anyhow::Result<String> {
118 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 "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
143pub(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
169pub(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
183pub(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
195pub(crate) fn send_message_outcome(to: &str, result: &Value) -> Result<String> {
201 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 if result.get("block_id").and_then(|v| v.as_str()).is_some() {
224 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 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 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 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}