agentmux_mcp/tools/
work.rs1use super::*;
7
8pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
9 let ToolCtx { local_url, auth_key, block_id, client, .. } = *cx;
10 match name {
11 "WorkEnqueue" => {
15 require_agent_env(local_url, auth_key, block_id)?;
16 let title = arguments.get("title").and_then(|v| v.as_str()).filter(|s| !s.is_empty())
17 .ok_or_else(|| anyhow::anyhow!("missing required parameter: title"))?;
18 let payload = arguments.get("payload").and_then(|v| v.as_str()).filter(|s| !s.is_empty())
19 .ok_or_else(|| anyhow::anyhow!("missing required parameter: payload"))?;
20 let self_id = std::env::var("AGENTMUX_AGENT_ID").ok().filter(|s| !s.is_empty()).unwrap_or_default();
21 let target_agent = arguments.get("target_agent").and_then(|v| v.as_str()).unwrap_or("");
22 let target_agent_uid = if target_agent.is_empty() {
25 None
26 } else {
27 resolve_agent_name_at_boundary(&client, local_url, auth_key, target_agent).await?
28 };
29
30 let url = format!("{}/agentmux/work", local_url.trim_end_matches('/'));
31 let body = serde_json::json!({
32 "title": title,
33 "payload": payload,
34 "kind": arguments.get("kind").and_then(|v| v.as_str()).unwrap_or(""),
35 "target_agent": target_agent,
36 "target_agent_uid": target_agent_uid.unwrap_or_default(),
37 "target_group": arguments.get("target_group").and_then(|v| v.as_str()).unwrap_or(""),
38 "priority": arguments.get("priority").and_then(|v| v.as_i64()).unwrap_or(0),
39 "not_before": arguments.get("not_before").and_then(|v| v.as_i64()),
40 "max_attempts": arguments.get("max_attempts").and_then(|v| v.as_i64()).filter(|&n| n > 0),
41 "created_by": self_id,
42 });
43 let resp = client.post(&url).header(AUTH_KEY_HEADER, auth_key).json(&body).send().await
44 .map_err(|e| anyhow::anyhow!("work enqueue request failed: {e}"))?;
45 let status = resp.status();
46 let text = resp.text().await.unwrap_or_default();
47 if !status.is_success() {
48 anyhow::bail!("WorkEnqueue failed: HTTP {status} — {text}");
49 }
50 let id = serde_json::from_str::<Value>(&text).ok()
51 .and_then(|v| v.get("id").and_then(|x| x.as_str()).map(|s| s.to_string()))
52 .unwrap_or_default();
53 Ok(format!("Enqueued work item {id}: {title}"))
54 }
55 "WorkClaim" => {
56 require_agent_env(local_url, auth_key, block_id)?;
57 let self_id = std::env::var("AGENTMUX_AGENT_ID").ok().filter(|s| !s.is_empty())
58 .ok_or_else(|| anyhow::anyhow!("AGENTMUX_AGENT_ID is not set — cannot claim work without an agent identity"))?;
59
60 let self_uid = std::env::var("AGENTMUX_AGENT_UID").ok().filter(|s| !s.is_empty()).unwrap_or_default();
66
67 let url = format!("{}/agentmux/work/claim", local_url.trim_end_matches('/'));
68 let body = serde_json::json!({
69 "agent_id": self_id,
70 "agent_uid": self_uid,
71 "block_id": block_id,
74 "kind": arguments.get("kind").and_then(|v| v.as_str()),
75 "lease_ms": arguments.get("lease_ms").and_then(|v| v.as_i64()).filter(|&n| n > 0),
76 "groups": [],
83 });
84 let resp = client.post(&url).header(AUTH_KEY_HEADER, auth_key).json(&body).send().await
85 .map_err(|e| anyhow::anyhow!("work claim request failed: {e}"))?;
86 let status = resp.status();
87 let text = resp.text().await.unwrap_or_default();
88 if !status.is_success() {
89 anyhow::bail!("WorkClaim failed: HTTP {status} — {text}");
90 }
91 let v: Value = serde_json::from_str(&text).unwrap_or(serde_json::json!({}));
92 if !v.get("claimed").and_then(|x| x.as_bool()).unwrap_or(false) {
93 return Ok("No work available on the queue right now.".to_string());
94 }
95 let attempt = v.get("attempt").and_then(|x| x.as_i64()).unwrap_or(0);
96 let item = v.get("item").cloned().unwrap_or(serde_json::json!({}));
97 let id = item.get("id").and_then(|x| x.as_str()).unwrap_or("");
98 let title = item.get("title").and_then(|x| x.as_str()).unwrap_or("");
99 let payload = item.get("payload").and_then(|x| x.as_str()).unwrap_or("");
100 let prior = item.get("result").and_then(|x| x.as_str()).unwrap_or("");
106 let handback = if attempt > 1 && !prior.is_empty() {
107 format!(
108 "\n\nNOTE — a previous agent held this and handed it back \
109 (attempt {} of this item). Their reason: {prior}\n\
110 Read that before repeating their approach.",
111 attempt - 1
112 )
113 } else {
114 String::new()
115 };
116 Ok(format!(
117 "Claimed work item {id} (attempt {attempt}) — pass attempt={attempt} to \
118 WorkHeartbeat/WorkComplete/WorkRelease for this item.\n\n\
119 Title: {title}\n\n{payload}{handback}"
120 ))
121 }
122 "WorkHeartbeat" | "WorkComplete" | "WorkRelease" => {
123 require_agent_env(local_url, auth_key, block_id)?;
124 let self_id = std::env::var("AGENTMUX_AGENT_ID").ok().filter(|s| !s.is_empty())
125 .ok_or_else(|| anyhow::anyhow!("AGENTMUX_AGENT_ID is not set"))?;
126 let id = arguments.get("id").and_then(|v| v.as_str()).filter(|s| !s.is_empty())
127 .ok_or_else(|| anyhow::anyhow!("missing required parameter: id"))?;
128 let attempt = arguments.get("attempt").and_then(|v| v.as_i64())
129 .ok_or_else(|| anyhow::anyhow!("missing required parameter: attempt (from the WorkClaim response)"))?;
130
131 let (segment, result_text) = match name {
132 "WorkHeartbeat" => ("heartbeat", String::new()),
133 "WorkComplete" => (
134 "complete",
135 arguments
141 .get("result")
142 .and_then(|v| v.as_str())
143 .map(str::trim)
144 .filter(|s| !s.is_empty())
145 .ok_or_else(|| {
146 anyhow::anyhow!(
147 "WorkComplete requires a non-empty 'result' — it is the only \
148 record of what this item accomplished once it is marked done"
149 )
150 })?
151 .to_string(),
152 ),
153 _ => (
154 "release",
155 arguments.get("reason").and_then(|v| v.as_str()).unwrap_or("").to_string(),
156 ),
157 };
158
159 let url = format!("{}/agentmux/work/{}/{}", local_url.trim_end_matches('/'), id, segment);
160 let body = serde_json::json!({
161 "agent_id": self_id,
162 "attempt": attempt,
163 "result": result_text,
164 "lease_ms": arguments.get("lease_ms").and_then(|v| v.as_i64()).filter(|&n| n > 0),
165 });
166 let resp = client.post(&url).header(AUTH_KEY_HEADER, auth_key).json(&body).send().await
167 .map_err(|e| anyhow::anyhow!("work {segment} request failed: {e}"))?;
168 let status = resp.status();
169 let text = resp.text().await.unwrap_or_default();
170 if status == reqwest::StatusCode::CONFLICT {
171 anyhow::bail!(
175 "This claim is no longer yours — the lease expired and the item was \
176 reclaimed, or another agent holds it now. Call WorkClaim again if you \
177 still want work; do NOT retry with the old attempt number."
178 );
179 }
180 if !status.is_success() {
181 anyhow::bail!("{name} failed: HTTP {status} — {text}");
182 }
183 Ok(match segment {
184 "heartbeat" => format!("Lease extended on {id}."),
185 "complete" => format!("Completed {id}."),
186 _ => {
187 let resulting = serde_json::from_str::<Value>(&text)
194 .ok()
195 .and_then(|v| v.get("state").and_then(|s| s.as_str()).map(str::to_string))
196 .unwrap_or_default();
197 if resulting == "failed" {
198 format!(
199 "Released {id}, and it has now used its final attempt — the item is \
200 parked as FAILED and will not be offered to any agent again. If it \
201 still needs doing, enqueue a fresh item (ideally with what you \
202 learned about why it kept failing)."
203 )
204 } else {
205 format!("Released {id} back to the queue for another agent.")
206 }
207 }
208 })
209 }
210 "WorkList" => {
211 require_agent_env(local_url, auth_key, block_id)?;
212 let url = format!("{}/agentmux/work", local_url.trim_end_matches('/'));
213 let state = arguments.get("state").and_then(|v| v.as_str()).unwrap_or("");
214 let limit = arguments.get("limit").and_then(|v| v.as_i64()).unwrap_or(50);
215 let resp = client
221 .get(&url)
222 .query(&[("state", state), ("limit", &limit.to_string())])
223 .header(AUTH_KEY_HEADER, auth_key)
224 .send()
225 .await
226 .map_err(|e| anyhow::anyhow!("work list request failed: {e}"))?;
227 let status = resp.status();
228 let text = resp.text().await.unwrap_or_default();
229 if !status.is_success() {
230 anyhow::bail!("WorkList failed: HTTP {status} — {text}");
231 }
232 let v: Value = serde_json::from_str(&text).unwrap_or(serde_json::json!({}));
233 let items = v.get("items").and_then(|x| x.as_array()).cloned().unwrap_or_default();
234 if items.is_empty() {
235 return Ok("Queue is empty.".to_string());
236 }
237 let mut out = format!("{} work item(s):\n", items.len());
238 for it in &items {
239 let id = it.get("id").and_then(|x| x.as_str()).unwrap_or("");
240 let st = it.get("state").and_then(|x| x.as_str()).unwrap_or("");
241 let title = it.get("title").and_then(|x| x.as_str()).unwrap_or("");
242 let holder = it.get("claimed_by").and_then(|x| x.as_str()).unwrap_or("");
243 let who = if holder.is_empty() { String::new() } else { format!(" [{holder}]") };
244 out.push_str(&format!(" {id} {st}{who} {title}\n"));
245 let result = it.get("result").and_then(|x| x.as_str()).unwrap_or("");
251 if !result.is_empty() {
252 out.push_str(&format!(" -> {result}\n"));
253 }
254 }
255 Ok(out)
256 }
257 _ => Err(not_in_family()),
258 }
259}