Skip to main content

agentmux_srv/backend/
name_resolution.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The one place a typed agent name is interpreted (identity M3,
5//! `docs/specs/SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md` §5).
6//!
7//! **Callable only from surfaces where a human or a model typed the name** —
8//! today the `POST /agentmux/agents/resolve` endpoint the MCP server calls
9//! before it enqueues work or schedules a cron job. No internal code path may
10//! call this: `scripts/check-name-resolver-callers.sh` is the CI grep gate
11//! that enforces it (§9.3), so §2 rule 1 is mechanical rather than a
12//! convention that erodes.
13//!
14//! **Ambiguity is surfaced, never guessed** (§5.2). A name held by two agents
15//! is returned as `Ambiguous` with enough about each candidate for a model to
16//! retry by UID and for a human to act on. This is deliberately the opposite
17//! of `agent_resolve::resolve_agent_id`, which collapses "several" into
18//! "none" because its callers are internal and have nobody to ask.
19//!
20//! **What is consulted, in order.** (1) A typed value that *is* a UID. (2) The
21//! live registry, by name — every registered block whose display or stable
22//! name matches, with its UID when it has one. (3) The store, by slug and by
23//! display name (`name`, `instance_name`, case-insensitively) over
24//! non-template, non-hidden rows — the agents that exist but are not running
25//! right now. Live and stored candidates that share a UID are one candidate.
26//!
27//! Spec §1.1 measured that a display name never resolves through the
28//! exact-case slug tier. That finding is about a resolver that has to *pick*;
29//! this one *lists*, so it can afford to match display names: a single match
30//! resolves, a collision is refused with the candidates, and nothing is ever
31//! misrouted.
32
33use serde::Serialize;
34
35use crate::backend::reactive::handler::{LookupOutcome, ReactiveHandler};
36use crate::backend::storage::store::Store;
37
38/// One agent that a typed name could mean.
39#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
40pub struct AgentCandidate {
41    /// `db_agents.id` when the agent has a row; `None` for a live block with
42    /// no row (a quick-launch pane, a PTY shell) — addressable by name only.
43    pub uid: Option<String>,
44    /// What a human would recognise it by.
45    pub name: String,
46    #[serde(skip_serializing_if = "String::is_empty")]
47    pub slug: String,
48    /// Set when the candidate is registered right now.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub block_id: Option<String>,
51    pub live: bool,
52}
53
54/// The answer to "which agent did they mean?" (§5.1).
55#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
56#[serde(tag = "resolution", rename_all = "snake_case")]
57pub enum NameResolution {
58    /// Exactly one agent matches, and it has a UID.
59    One {
60        uid: String,
61        candidate: AgentCandidate,
62    },
63    /// Several do. Carries enough to ask the human which.
64    Ambiguous {
65        candidates: Vec<AgentCandidate>,
66    },
67    /// Exactly one agent matches, but it has no UID (no `db_agents` row):
68    /// reachable by name, not storable as an identity. Not in the spec's
69    /// three-variant enum — added because such agents exist (M0's finding)
70    /// and collapsing them into `None` would tell the caller the agent does
71    /// not exist when it does.
72    Unidentified {
73        candidate: AgentCandidate,
74    },
75    None,
76}
77
78/// Resolve `typed` to a UID, or to the reasons it cannot be resolved.
79///
80/// `registry` is the live in-memory registry (`get_global_handler()` in
81/// production; a fresh `ReactiveHandler` in tests). `store` is the
82/// per-channel object store that holds `db_agents`.
83///
84/// `Err` means the store could not be read, and the caller must refuse
85/// rather than proceed: `None` would send the bare name on, and a
86/// same-named live agent could then take work or a cron fire meant for a
87/// stored agent the fault hid (Codex P1 on #3563).
88pub(crate) fn resolve_name_to_uid(
89    store: &Store,
90    registry: &ReactiveHandler,
91    typed: &str,
92) -> Result<NameResolution, String> {
93    let typed = typed.trim();
94    if typed.is_empty() {
95        return Ok(NameResolution::None);
96    }
97
98    // (1) The typed value is itself a UID.
99    let as_uid = store
100        .agent_def_get_strict(typed)
101        .map_err(|e| store_fault(typed, e))?;
102    if let Some(def) = as_uid {
103        if def.is_seeded == 0 {
104            let candidate = AgentCandidate {
105                uid: Some(def.id.clone()),
106                name: if def.name.is_empty() {
107                    def.slug.clone()
108                } else {
109                    def.name.clone()
110                },
111                slug: def.slug.clone(),
112                block_id: registry.get_agent(typed).map(|r| r.block_id),
113                live: registry.get_agent(typed).is_some(),
114            };
115            return Ok(NameResolution::One {
116                uid: def.id,
117                candidate,
118            });
119        }
120    }
121
122    let mut candidates: Vec<AgentCandidate> = Vec::new();
123
124    // (2) Live registry, by name.
125    let live: Vec<crate::backend::reactive::AgentRegistration> =
126        match registry.lookup_by_name(typed) {
127            LookupOutcome::One(reg) => vec![reg],
128            LookupOutcome::Ambiguous(regs) => regs,
129            LookupOutcome::NotFound => Vec::new(),
130        };
131    for reg in live {
132        // A registration made before its row existed carries no UID (spec
133        // Q1); take the one on its block's row, so the agent and its own row
134        // are one candidate — not an ambiguity with itself (review on #3563).
135        // Identity by block, not by name.
136        let uid = reg
137            .uid
138            .clone()
139            .or_else(|| crate::backend::agent_resolve::uid_for_block(store, &reg.block_id));
140        candidates.push(AgentCandidate {
141            uid,
142            name: reg.agent_id.clone(),
143            slug: String::new(),
144            block_id: Some(reg.block_id.clone()),
145            live: true,
146        });
147    }
148
149    // (3) The store, by slug and by display name.
150    let rows = store
151        .agents_matching_name(typed)
152        .map_err(|e| store_fault(typed, e))?;
153    for row in rows {
154        if let Some(existing) = candidates
155            .iter_mut()
156            .find(|c| c.uid.as_deref() == Some(row.id.as_str()))
157        {
158            // Same agent, seen live already: enrich, don't duplicate.
159            if existing.slug.is_empty() {
160                existing.slug = row.slug.clone();
161            }
162            continue;
163        }
164        candidates.push(AgentCandidate {
165            uid: Some(row.id),
166            name: if row.instance_name.is_empty() {
167                row.name
168            } else {
169                row.instance_name
170            },
171            slug: row.slug,
172            block_id: None,
173            live: false,
174        });
175    }
176
177    match candidates.len() {
178        // Only the outcomes that leave a row on the name path are counted
179        // (§9.2 reads these as "must reach zero"); `One`, `None` and
180        // `Ambiguous` are answers, not fallbacks.
181        0 => Ok(NameResolution::None),
182        1 => {
183            let candidate = candidates.remove(0);
184            match candidate.uid.clone() {
185                Some(uid) => Ok(NameResolution::One { uid, candidate }),
186                None => {
187                    record("resolve.unidentified");
188                    Ok(NameResolution::Unidentified { candidate })
189                }
190            }
191        }
192        _ => Ok(NameResolution::Ambiguous { candidates }),
193    }
194}
195
196fn store_fault(typed: &str, e: impl std::fmt::Display) -> String {
197    tracing::warn!(name = %typed, error = %e, "resolve_name_to_uid: store read failed — refusing to resolve");
198    record("resolve.store_error");
199    format!("agent store unavailable: {e}")
200}
201
202fn record(site: &'static str) {
203    crate::backend::agent_resolve::record_uid_fallback(site);
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::backend::storage::agents::test_agent_def;
210
211    const UID_Y: &str = "4f3c0000-0000-4000-8000-000000000a91";
212    const UID_UP: &str = "9b2e0000-0000-4000-8000-0000000007d4";
213
214    fn store_with(rows: &[(&str, &str, &str)]) -> Store {
215        let store = Store::open_in_memory().unwrap();
216        for (id, name, slug) in rows {
217            let mut def = test_agent_def(id, name, "claude", "agent", 1, "");
218            def.slug = slug.to_string();
219            store.agent_def_insert(&mut def).unwrap();
220        }
221        store
222    }
223
224    fn registry() -> ReactiveHandler {
225        ReactiveHandler::new()
226    }
227
228    #[test]
229    fn a_uid_resolves_to_itself() {
230        let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
231        let r = resolve_name_to_uid(&store, &registry(), UID_Y).unwrap();
232        assert!(
233            matches!(r, NameResolution::One { ref uid, .. } if uid == UID_Y),
234            "{r:?}"
235        );
236    }
237
238    /// The §1.1 finding, inverted for a resolver that LISTS: a display name
239    /// that the exact-case slug tier could never resolve resolves here when
240    /// exactly one agent bears it, because there is nothing to misroute.
241    #[test]
242    fn a_unique_display_name_resolves_to_the_rows_uid() {
243        let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
244        for typed in ["AgentY", "agenty", "AGENTY"] {
245            let r = resolve_name_to_uid(&store, &registry(), typed).unwrap();
246            assert!(
247                matches!(r, NameResolution::One { ref uid, .. } if uid == UID_Y),
248                "{typed}: {r:?}"
249            );
250        }
251    }
252
253    /// Two agents whose names collide are refused WITH both candidates.
254    #[test]
255    fn colliding_names_are_ambiguous_with_both_candidates_listed() {
256        let store = store_with(&[(UID_Y, "AgentY", "agenty"), (UID_UP, "AGENTY", "agenty-2")]);
257        let r = resolve_name_to_uid(&store, &registry(), "agenty").unwrap();
258        let NameResolution::Ambiguous { candidates } = r else {
259            panic!("expected Ambiguous, got {r:?}");
260        };
261        let uids: Vec<&str> = candidates.iter().filter_map(|c| c.uid.as_deref()).collect();
262        assert!(
263            uids.contains(&UID_Y) && uids.contains(&UID_UP),
264            "{candidates:?}"
265        );
266        // …while the collision-resolved slug alone is exact.
267        let r = resolve_name_to_uid(&store, &registry(), "agenty-2").unwrap();
268        assert!(
269            matches!(r, NameResolution::One { ref uid, .. } if uid == UID_UP),
270            "{r:?}"
271        );
272    }
273
274    /// A live registration and its stored row are ONE candidate, marked live
275    /// with its block — not two.
276    #[test]
277    fn a_live_agent_and_its_row_merge_into_one_live_candidate() {
278        let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
279        let reg = registry();
280        reg.register_agent_full("AgentY", "block-y", None, 0, None, Some(UID_Y), "test.m3")
281            .unwrap();
282        let r = resolve_name_to_uid(&store, &reg, "AgentY").unwrap();
283        let NameResolution::One { uid, candidate } = r else {
284            panic!("expected One, got {r:?}");
285        };
286        assert_eq!(uid, UID_Y);
287        assert!(candidate.live);
288        assert_eq!(candidate.block_id.as_deref(), Some("block-y"));
289        assert_eq!(candidate.slug, "agenty");
290    }
291
292    fn bind_block(store: &Store, block_id: &str, agent_uid: &str) {
293        let mut block = crate::backend::obj::Block {
294            oid: block_id.to_string(),
295            parentoref: String::new(),
296            version: 0,
297            runtimeopts: None,
298            stickers: None,
299            meta: {
300                let mut m = crate::backend::obj::MetaMapType::new();
301                m.insert("view".to_string(), serde_json::json!("agent"));
302                m.insert("agentId".to_string(), serde_json::json!(agent_uid));
303                m
304            },
305            subblockids: None,
306        };
307        store.insert(&mut block).unwrap();
308    }
309
310    /// An agent that registered before its row existed carries no UID (spec
311    /// Q1). It must still be ONE candidate with its own row — its UID taken
312    /// from the row on its block — not ambiguous with itself (review on
313    /// #3563: an agent scheduling cron to itself by name was refused).
314    #[test]
315    fn a_live_agent_registered_without_a_uid_merges_with_its_own_row_by_block() {
316        let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
317        bind_block(&store, "block-y", UID_Y);
318        let reg = registry();
319        reg.register_agent_full("AgentY", "block-y", None, 0, None, None, "test.m3")
320            .unwrap();
321        let r = resolve_name_to_uid(&store, &reg, "AgentY").unwrap();
322        let NameResolution::One { uid, candidate } = r else {
323            panic!("expected One, got {r:?}");
324        };
325        assert_eq!(uid, UID_Y);
326        assert!(candidate.live);
327        assert_eq!(candidate.block_id.as_deref(), Some("block-y"));
328    }
329
330    /// …but a uid-less live block that is NOT that row's block (a
331    /// quick-launch pane that happens to share the name) stays a separate
332    /// candidate: the merge is by block identity, never by name.
333    #[test]
334    fn a_same_named_pane_on_another_block_is_still_ambiguous_with_the_row() {
335        let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
336        let reg = registry();
337        reg.register_agent_full("AgentY", "block-pane", None, 0, None, None, "test.m3")
338            .unwrap();
339        let r = resolve_name_to_uid(&store, &reg, "AgentY").unwrap();
340        let NameResolution::Ambiguous { candidates } = r else {
341            panic!("expected Ambiguous, got {r:?}");
342        };
343        assert_eq!(candidates.len(), 2, "{candidates:?}");
344    }
345
346    /// A live block with no row (quick-launch pane) is `Unidentified`: it
347    /// exists and is reachable by name, but has no UID to store.
348    #[test]
349    fn a_live_block_without_a_row_is_unidentified_not_none() {
350        let store = store_with(&[]);
351        let reg = registry();
352        reg.register_agent_full("Scratch", "block-s", None, 0, None, None, "test.m3")
353            .unwrap();
354        let r = resolve_name_to_uid(&store, &reg, "scratch").unwrap();
355        assert!(
356            matches!(r, NameResolution::Unidentified { ref candidate } if candidate.block_id.as_deref() == Some("block-s")),
357            "{r:?}"
358        );
359    }
360
361    #[test]
362    fn unknown_and_empty_names_are_none() {
363        let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
364        assert_eq!(
365            resolve_name_to_uid(&store, &registry(), "nobody").unwrap(),
366            NameResolution::None
367        );
368        assert_eq!(
369            resolve_name_to_uid(&store, &registry(), "   ").unwrap(),
370            NameResolution::None
371        );
372    }
373
374    /// A store fault is an error, never `None`: `None` would send the bare
375    /// name on, and a same-named live agent could take what was meant for
376    /// the stored agent the fault hid (Codex P1 on #3563).
377    #[test]
378    fn a_store_fault_is_refused_not_answered_as_none() {
379        let store = store_with(&[(UID_Y, "AgentY", "agenty")]);
380        let reg = registry();
381        reg.register_agent_full("AgentY", "block-other", None, 0, None, None, "test.m3")
382            .unwrap();
383        store.test_break_agents_table().unwrap();
384        for typed in ["AgentY", UID_Y] {
385            let r = resolve_name_to_uid(&store, &reg, typed);
386            assert!(r.is_err(), "{typed}: {r:?}");
387        }
388    }
389
390    /// A typed UID whose cross-channel definition cannot be read is refused
391    /// too — "not found" there would send the UID string on as a name, and
392    /// the work could never be claimed (Codex P2 on #3568).
393    #[test]
394    fn an_unreadable_global_definition_is_refused_not_answered_as_none() {
395        let store = store_with(&[]);
396        let tmp = tempfile::tempdir().unwrap();
397        let defs = crate::registry::DefinitionStore::open(tmp.path().to_path_buf()).unwrap();
398        std::fs::write(tmp.path().join(format!("{UID_UP}.json")), b"{ not json").unwrap();
399        store.set_def_registry(std::sync::Arc::new(defs));
400        let r = resolve_name_to_uid(&store, &registry(), UID_UP);
401        assert!(r.is_err(), "{r:?}");
402    }
403
404    /// Templates are prototypes, never targets (spec §1.2): a provider key
405    /// typed as a name must not resolve to the template row.
406    #[test]
407    fn a_template_row_is_not_a_candidate() {
408        let store = Store::open_in_memory().unwrap();
409        let mut tpl = test_agent_def("claude", "Claude", "claude", "agent", 1, "");
410        tpl.is_seeded = 1;
411        store.agent_def_insert(&mut tpl).unwrap();
412        assert_eq!(
413            resolve_name_to_uid(&store, &registry(), "claude").unwrap(),
414            NameResolution::None
415        );
416    }
417}