Skip to main content

agentmux_mcp/tools/
overlap.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Who else is working on the same thing
5//! (docs/specs/SPEC_AGENT_OVERLAP_AWARENESS_2026_10_10.md §3.2), and saying
6//! what you are working on (`ClaimWork`, `ReleaseWork`, §3.4).
7
8use super::*;
9
10/// The query srv's `/api/v1/work-facts/who` takes for these arguments, plus
11/// who is asking (srv prefers the request's agent token to both).
12fn who_query(arguments: &Value, agent: &str, block_id: &str) -> Vec<(&'static str, String)> {
13    let mut q: Vec<(&'static str, String)> = Vec::new();
14    for key in ["path", "repo", "branch", "query"] {
15        if let Some(v) = arguments.get(key).and_then(|v| v.as_str()).map(str::trim).filter(|v| !v.is_empty()) {
16            q.push((key, v.to_string()));
17        }
18    }
19    if !agent.trim().is_empty() {
20        q.push(("agent", agent.trim().to_string()));
21    }
22    if !block_id.trim().is_empty() {
23        q.push(("block", block_id.trim().to_string()));
24    }
25    q
26}
27
28pub(super) async fn call(name: &str, arguments: &Value, cx: &ToolCtx<'_>) -> Result<String> {
29    let ToolCtx { local_url, auth_key, block_id, client, .. } = *cx;
30    match name {
31        "WhoIsWorkingOn" => {
32            if local_url.is_empty() || auth_key.is_empty() {
33                anyhow::bail!(
34                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
35                     Is this agent pane opened via AgentMux?"
36                );
37            }
38            let agent = std::env::var("AGENTMUX_AGENT_ID").unwrap_or_default();
39            let (pr_repo, pr) = match arguments.get("pr").filter(|v| !v.is_null()) {
40                Some(v) => {
41                    let (repo, n) = pr_ref(v)
42                        .ok_or_else(|| anyhow::anyhow!("pr must be a PR number (12, \"#12\") or its GitHub URL"))?;
43                    (repo, Some(n))
44                }
45                None => (None, None),
46            };
47            let mut query = who_query(arguments, &agent, block_id);
48            // A PR's URL names its repository: ask about that one.
49            if let Some(url_repo) = pr_repo {
50                match arg(arguments, "repo") {
51                    Some(r) if normalize_repo(r) != normalize_repo(&url_repo) => {
52                        anyhow::bail!("pr is a PR of {url_repo}, but repo is {r}: pass one or the other");
53                    }
54                    Some(_) => {}
55                    None => query.push(("repo", url_repo)),
56                }
57            }
58            let url = format!("{}/api/v1/work-facts/who", local_url.trim_end_matches('/'));
59            let mut body = srv_get_json(client, &url, auth_key, &query, "WhoIsWorkingOn").await?;
60            add_open_prs(&mut body, arguments, pr, &agent, |path| {
61                // Only the file, its repository and who is asking: srv
62                // matches each criterion on its own, so a branch or query
63                // here would list agents that touch none of the PR's files.
64                let mut q = query.clone();
65                q.retain(|(k, _)| matches!(*k, "repo" | "agent" | "block"));
66                q.push(("path", path));
67                let url = url.clone();
68                async move { srv_get_json(client, &url, auth_key, &q, "WhoIsWorkingOn").await.ok() }
69            })
70            .await;
71            Ok(serde_json::to_string_pretty(&body).unwrap_or_else(|_| body.to_string()))
72        }
73        "ClaimWork" | "ReleaseWork" => {
74            if local_url.is_empty() || auth_key.is_empty() {
75                anyhow::bail!(
76                    "AGENTMUX_LOCAL_URL and AGENTMUX_AUTH_KEY must be set. \
77                     Is this agent pane opened via AgentMux?"
78                );
79            }
80            let agent = std::env::var("AGENTMUX_AGENT_ID").unwrap_or_default();
81            let base = local_url.trim_end_matches('/');
82            let (url, body) = if name == "ClaimWork" {
83                (format!("{base}/api/v1/work-claims"), claim_body(arguments, &agent, block_id))
84            } else {
85                (format!("{base}/api/v1/work-claims/release"), release_body(arguments, &agent, block_id))
86            };
87            let answer = srv_post_json(client, &url, auth_key, &body, name).await?;
88            Ok(serde_json::to_string_pretty(&answer).unwrap_or_else(|_| answer.to_string()))
89        }
90        _ => Err(not_in_family()),
91    }
92}
93
94/// A PR: `12`, `"12"`, `"#12"`, or its URL
95/// (`https://github.com/<owner>/<repo>/pull/12`, with anything after the
96/// number, like `/files` or `#issuecomment-…`), which also names its
97/// repository: `(Some("owner/repo"), 12)`.
98fn pr_ref(v: &Value) -> Option<(Option<String>, u64)> {
99    if let Some(n) = v.as_u64() {
100        return (n > 0).then_some((None, n));
101    }
102    let s = v.as_str()?.trim();
103    let number = |t: &str| t.parse::<u64>().ok().filter(|n| *n > 0);
104    let Some((before, after)) = s.split_once("/pull/") else {
105        return number(s.trim_start_matches('#')).map(|n| (None, n));
106    };
107    let digits: String = after.chars().take_while(char::is_ascii_digit).collect();
108    let rest = &after[digits.len()..];
109    if !(rest.is_empty() || rest.starts_with(['/', '#', '?'])) {
110        return None;
111    }
112    let mut parts = before.rsplit('/');
113    let (repo, owner) = (parts.next()?, parts.next()?);
114    if repo.is_empty() || owner.is_empty() || owner.contains(':') {
115        return None;
116    }
117    Some((Some(format!("{owner}/{repo}")), number(&digits)?))
118}
119
120/// `owner/repo`, lowercased, from `owner/repo`, a remote URL
121/// (`https://github.com/o/r.git`) or an ssh remote (`git@github.com:o/r`).
122fn normalize_repo(repo: &str) -> String {
123    let s = repo.trim().trim_end_matches('/').trim_end_matches(".git").replace(':', "/");
124    let mut parts = s.rsplit('/').filter(|p| !p.is_empty());
125    match (parts.next(), parts.next()) {
126        (Some(name), Some(owner)) => format!("{owner}/{name}").to_ascii_lowercase(),
127        _ => s.to_ascii_lowercase(),
128    }
129}
130
131/// Files of the asked PR whose local facts are fetched, at most.
132const PR_FILES_CHECKED: usize = 10;
133
134/// Add the open PRs that touch the question (`open_prs.rs`) to srv's answer,
135/// for the repository srv resolved. With `pr`, the agents on this computer
136/// are the ones with that PR's files changed or edited (`ask_path`, srv's
137/// answer for one file), not everyone in the repository. A failure is
138/// reported in the answer (`pr_error`) and never fails the call.
139async fn add_open_prs<F, Fut>(body: &mut Value, arguments: &Value, pr: Option<u64>, me: &str, ask_path: F)
140where
141    F: Fn(String) -> Fut,
142    Fut: std::future::Future<Output = Option<Value>>,
143{
144    let repo = body.pointer("/asked/repo").and_then(Value::as_str).map(str::to_string);
145    let Some(repo) = repo.filter(|r| r.contains('/') && !r.starts_with("local:")) else {
146        if pr.is_some() {
147            body["pr_error"] = json!("pr needs a repository on GitHub (owner/repo)");
148        }
149        return;
150    };
151    let mut prs = match open_prs::open_prs(&repo).await {
152        Ok(prs) => prs,
153        Err(e) => {
154            body["pr_error"] = json!(e);
155            return;
156        }
157    };
158    // An asked PR the list didn't hold (opened since it was cached): read it.
159    if let Some(n) = pr.filter(|n| !prs.iter().any(|p| p.number == *n)) {
160        match open_prs::open_pr(&repo, n).await {
161            Ok(Some(p)) => prs.push(p),
162            Ok(None) => {}
163            Err(e) => {
164                body["pr_error"] = json!(e);
165                return;
166            }
167        }
168    }
169    let host = open_prs::this_host();
170    let path = body.pointer("/asked/path").and_then(Value::as_str).map(str::to_string);
171    let asked = open_prs::Asked {
172        path: path.as_deref(),
173        branch: arg(arguments, "branch"),
174        query: arg(arguments, "query"),
175        pr,
176    };
177    let matches = match open_prs::pr_matches(&prs, &asked, me, &host) {
178        Ok(m) => m,
179        Err(e) => {
180            body["pr_error"] = json!(e);
181            return;
182        }
183    };
184    if let Some(files) = pr.and_then(|n| prs.iter().find(|p| p.number == n)).map(|p| p.files.clone()) {
185        let mut agents: Vec<Value> = Vec::new();
186        for file in files.iter().take(PR_FILES_CHECKED) {
187            if let Some(answer) = ask_path(file.clone()).await {
188                merge_agents(&mut agents, answer.get("agents").and_then(Value::as_array).cloned().unwrap_or_default());
189            }
190        }
191        body["agents"] = Value::Array(agents);
192        if files.len() > PR_FILES_CHECKED {
193            body["pr_note"] = json!(format!(
194                "the PR changes {} files; agents on this computer were checked against the first {PR_FILES_CHECKED}",
195                files.len()
196            ));
197        }
198    }
199    open_prs::merge_into(body, matches, &host, &repo);
200}
201
202/// Merge srv answers for several files: one entry per agent and channel,
203/// with every distinct match.
204fn merge_agents(into: &mut Vec<Value>, from: Vec<Value>) {
205    for agent in from {
206        let key = |a: &Value| (a.get("agent").cloned(), a.get("channel").cloned());
207        match into.iter_mut().find(|a| key(a) == key(&agent)) {
208            Some(existing) => {
209                let new = agent.get("matches").and_then(Value::as_array).cloned().unwrap_or_default();
210                if let Some(list) = existing.get_mut("matches").and_then(Value::as_array_mut) {
211                    for m in new {
212                        if !list.contains(&m) {
213                            list.push(m);
214                        }
215                    }
216                }
217            }
218            None => into.push(agent),
219        }
220    }
221}
222
223/// The trimmed, non-empty string argument `key`.
224fn arg<'a>(arguments: &'a Value, key: &str) -> Option<&'a str> {
225    arguments.get(key).and_then(|v| v.as_str()).map(str::trim).filter(|v| !v.is_empty())
226}
227
228/// What srv's `/api/v1/work-claims` takes: the claimed target, note and
229/// lifetime, plus who is asking (srv prefers the request's agent token).
230fn claim_body(arguments: &Value, agent: &str, block_id: &str) -> Value {
231    let mut body = serde_json::Map::new();
232    for key in ["path", "repo", "branch", "topic", "note"] {
233        if let Some(v) = arg(arguments, key) {
234            body.insert(key.into(), json!(v));
235        }
236    }
237    if let Some(m) = arguments.get("ttl_minutes").and_then(|v| v.as_u64()) {
238        body.insert("ttl_minutes".into(), json!(m));
239    }
240    body.insert("agent".into(), json!(agent.trim()));
241    body.insert("block".into(), json!(block_id.trim()));
242    Value::Object(body)
243}
244
245/// What srv's `/api/v1/work-claims/release` takes: a claim id, or none for all.
246fn release_body(arguments: &Value, agent: &str, block_id: &str) -> Value {
247    json!({ "id": arg(arguments, "id"), "agent": agent.trim(), "block": block_id.trim() })
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn a_pr_is_a_number_a_hash_number_or_a_url_naming_its_repository() {
256        assert_eq!(pr_ref(&json!(12)), Some((None, 12)));
257        assert_eq!(pr_ref(&json!("12")), Some((None, 12)));
258        assert_eq!(pr_ref(&json!("#12")), Some((None, 12)));
259        let url = |u: &str| pr_ref(&json!(u));
260        assert_eq!(url("https://github.com/o/r/pull/4683"), Some((Some("o/r".into()), 4683)));
261        assert_eq!(url("https://github.com/o/r/pull/4683/"), Some((Some("o/r".into()), 4683)));
262        assert_eq!(url("https://github.com/o/r/pull/4683/files"), Some((Some("o/r".into()), 4683)));
263        assert_eq!(url("https://github.com/o/r/pull/4683#issuecomment-1"), Some((Some("o/r".into()), 4683)));
264        assert_eq!(url("https://github.com/o/r/pull/4683x"), None);
265        assert_eq!(url("https://github.com/o/r/issues/12"), None);
266        assert_eq!(url("main"), None);
267        assert_eq!(pr_ref(&json!(0)), None);
268    }
269
270    #[test]
271    fn repositories_are_compared_whole_not_by_suffix() {
272        assert_eq!(normalize_repo("Foo/Bar"), "foo/bar");
273        assert_eq!(normalize_repo("https://github.com/foo/bar.git"), "foo/bar");
274        assert_eq!(normalize_repo("git@github.com:foo/bar"), "foo/bar");
275        assert_ne!(normalize_repo("notfoo/bar"), normalize_repo("foo/bar"));
276    }
277
278    #[test]
279    fn answers_for_several_files_merge_by_agent() {
280        let mut agents = Vec::new();
281        merge_agents(&mut agents, vec![json!({ "agent": "A", "channel": "c", "matches": [{ "kind": "dirty", "detail": "x" }] })]);
282        merge_agents(&mut agents, vec![
283            json!({ "agent": "A", "channel": "c", "matches": [{ "kind": "dirty", "detail": "x" }, { "kind": "edited", "detail": "y" }] }),
284            json!({ "agent": "B", "channel": "c", "matches": [] }),
285        ]);
286        assert_eq!(agents.len(), 2);
287        assert_eq!(agents[0]["matches"].as_array().unwrap().len(), 2);
288    }
289
290    #[test]
291    fn only_given_arguments_and_the_callers_identity_are_sent() {
292        let q = who_query(&json!({ "path": " crates/srv ", "query": "", "repo": null, "pr": 12 }), "AgentY", "blk");
293        assert_eq!(
294            q,
295            vec![("path", "crates/srv".to_string()), ("agent", "AgentY".to_string()), ("block", "blk".to_string())]
296        );
297        assert!(who_query(&json!({}), "", "").is_empty());
298    }
299
300    #[test]
301    fn claims_send_only_what_was_given_plus_the_callers_identity() {
302        let body = claim_body(
303            &json!({ "path": " crates/srv ", "topic": "", "note": "presence work", "ttl_minutes": 45, "repo": null }),
304            "AgentY",
305            "blk",
306        );
307        assert_eq!(
308            body,
309            json!({ "path": "crates/srv", "note": "presence work", "ttl_minutes": 45, "agent": "AgentY", "block": "blk" })
310        );
311        assert_eq!(release_body(&json!({}), "AgentY", "blk"), json!({ "id": null, "agent": "AgentY", "block": "blk" }));
312        assert_eq!(release_body(&json!({ "id": "c1" }), "AgentY", "")["id"], "c1");
313    }
314
315    #[tokio::test]
316    async fn without_agentmux_env_it_says_so() {
317        let client = reqwest::Client::new();
318        let loops = LoopRegistry::default();
319        let counter = std::sync::atomic::AtomicU64::new(0);
320        let cx = ToolCtx { local_url: "", auth_key: "", block_id: "", client: &client, loops: &loops, loop_counter: &counter };
321        for tool in ["WhoIsWorkingOn", "ClaimWork", "ReleaseWork"] {
322            let err = call(tool, &json!({ "path": "a" }), &cx).await.unwrap_err();
323            assert!(err.to_string().contains("AGENTMUX_LOCAL_URL"), "{tool}");
324        }
325    }
326}