Skip to main content

cookie_agent_engine/runtime/
handles.rs

1//! Short, tree-unique model-facing subagent handles.
2//!
3//! A handle is `<agent_type slug (max 16)>_<8 lowercase hex>`, e.g.
4//! `explore_1a2b3c4d`. It is generated at `SessionCreated` time, recorded on the
5//! session's first event, and resolved only within the caller's own delegation
6//! tree. See `docs/site/specs/subagent-handles.md`.
7
8use std::collections::HashSet;
9
10use cookie_agent_protocol::{SessionId, SessionMeta, SessionOrigin, SessionTree};
11
12use super::{Engine, EngineError, delegation::session_status_name};
13
14pub(crate) const HANDLE_HEX_LEN: usize = 8;
15pub(crate) const HANDLE_SLUG_MAX: usize = 16;
16pub(crate) const HANDLE_ATTEMPTS: usize = 16;
17
18/// The addressable set a reference resolves against. `send_message` reaches
19/// every peer in the tree; the subagent-result/cancel/resume tools only reach
20/// the caller's own direct children, matching their ownership checks.
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum SubagentScope {
23    DirectChildren,
24    TreePeers,
25}
26
27/// The slug portion of a handle: the agent type truncated to
28/// [`HANDLE_SLUG_MAX`] characters with any trailing `-` trimmed so the result
29/// still satisfies the handle grammar.
30#[must_use]
31pub(crate) fn handle_slug(agent_type: &str) -> String {
32    let mut slug = agent_type.chars().take(HANDLE_SLUG_MAX).collect::<String>();
33    while slug.ends_with('-') {
34        slug.pop();
35    }
36    if slug.is_empty() {
37        slug.push('a');
38    }
39    slug
40}
41
42/// Grammar check for a complete handle: `^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,15}_[0-9a-f]{8}$`.
43#[must_use]
44pub(crate) fn is_valid_handle(value: &str) -> bool {
45    let Some((slug, hex)) = value.rsplit_once('_') else {
46        return false;
47    };
48    if hex.len() != HANDLE_HEX_LEN
49        || !hex
50            .bytes()
51            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
52    {
53        return false;
54    }
55    if slug.is_empty() || slug.len() > HANDLE_SLUG_MAX {
56        return false;
57    }
58    let bytes = slug.as_bytes();
59    if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
60        return false;
61    }
62    bytes.iter().enumerate().all(|(index, byte)| {
63        byte.is_ascii_lowercase() || byte.is_ascii_digit() || {
64            *byte == b'-' && bytes.get(index + 1).is_some_and(u8::is_ascii_alphanumeric)
65        }
66    })
67}
68
69/// Generates a handle for `agent_type`, regenerating on collision against the
70/// existing tree handles. Caps the loop at [`HANDLE_ATTEMPTS`] and fails loudly:
71/// reaching the cap means the uniqueness check itself is broken.
72pub(crate) fn generate_handle(
73    agent_type: &str,
74    existing: &HashSet<String>,
75    mut next_hex: impl FnMut() -> u32,
76) -> Result<String, EngineError> {
77    let slug = handle_slug(agent_type);
78    for _ in 0..HANDLE_ATTEMPTS {
79        let candidate = format!("{slug}_{:08x}", next_hex());
80        if !existing.contains(&candidate) {
81            return Ok(candidate);
82        }
83    }
84    Err(EngineError::ToolFailed(format!(
85        "subagent handle generation exhausted {HANDLE_ATTEMPTS} attempts; the tree uniqueness check is broken"
86    )))
87}
88
89/// Production entropy source: four random bytes rendered as eight hex digits.
90/// `RandomState` draws its keys from the OS, so no extra dependency or feature
91/// flag is needed for the non-cryptographic 32-bit collision space.
92pub(crate) fn random_hex() -> u32 {
93    use std::hash::{BuildHasher, Hasher};
94    let mut hasher = std::collections::hash_map::RandomState::new().build_hasher();
95    hasher.write_u64(0);
96    (hasher.finish() & 0xffff_ffff) as u32
97}
98
99fn push_tree(tree: &SessionTree, out: &mut Vec<SessionMeta>) {
100    out.push(tree.session.clone());
101    for child in &tree.children {
102        push_tree(child, out);
103    }
104}
105
106fn session_handles(sessions: &[SessionMeta]) -> HashSet<String> {
107    sessions
108        .iter()
109        .filter_map(|meta| meta.short_id.clone())
110        .collect()
111}
112
113fn describe(meta: &SessionMeta) -> String {
114    meta.title.as_ref().map_or_else(
115        || meta.creation_selection.agent.to_string(),
116        ToString::to_string,
117    )
118}
119
120impl Engine {
121    /// Every session in the caller's delegation tree, root first.
122    fn tree_sessions(&self, session: SessionId) -> Result<Vec<SessionMeta>, EngineError> {
123        let root = match self.inner.store.get(session)?.meta.origin {
124            SessionOrigin::Root => session,
125            SessionOrigin::Delegated {
126                root_session_id, ..
127            } => root_session_id,
128        };
129        let tree = self.tree(root)?;
130        let mut sessions = Vec::new();
131        push_tree(&tree, &mut sessions);
132        Ok(sessions)
133    }
134
135    /// The handle recorded for one session, when it has one.
136    pub(crate) fn session_short_id(&self, session: SessionId) -> Option<String> {
137        self.inner
138            .store
139            .get(session)
140            .ok()
141            .and_then(|projection| projection.meta.short_id)
142    }
143
144    /// Existing handles across a whole tree, used as the generation collision
145    /// set.
146    pub(crate) fn existing_tree_handles(
147        &self,
148        root: SessionId,
149    ) -> Result<HashSet<String>, EngineError> {
150        let tree = self.tree(root)?;
151        let mut sessions = Vec::new();
152        push_tree(&tree, &mut sessions);
153        Ok(session_handles(&sessions))
154    }
155
156    /// Resolves a full UUID or a complete handle within `caller`'s addressable
157    /// set (its direct children, or every tree peer).
158    ///
159    /// There is deliberately no prefix matching: a partial handle is an error.
160    /// An unresolvable reference never falls through to creating a subagent.
161    pub fn resolve_subagent_target(
162        &self,
163        caller: SessionId,
164        reference: impl std::fmt::Display,
165        scope: SubagentScope,
166    ) -> Result<SessionId, EngineError> {
167        let reference = reference.to_string();
168        let sessions = self.tree_sessions(caller)?;
169        let candidates = scoped_candidates(&sessions, caller, scope);
170        match_reference(&candidates, &reference)
171            .ok_or_else(|| subagent_reference_error(&candidates, &reference))
172    }
173}
174
175/// Narrows a caller's tree to the sessions an operation may address. The
176/// caller itself is never a candidate.
177pub(crate) fn scoped_candidates(
178    sessions: &[SessionMeta],
179    caller: SessionId,
180    scope: SubagentScope,
181) -> Vec<SessionMeta> {
182    sessions
183        .iter()
184        .filter(|meta| meta.session_id != caller)
185        .filter(|meta| match scope {
186            SubagentScope::DirectChildren => matches!(
187                &meta.origin,
188                SessionOrigin::Delegated {
189                    parent_session_id,
190                    ..
191                } if *parent_session_id == caller
192            ),
193            SubagentScope::TreePeers => true,
194        })
195        .cloned()
196        .collect()
197}
198
199/// The two accepted reference forms, in order: a full UUID parse that names a
200/// session in `sessions`, then a complete handle exact match. Partial handles
201/// and fabricated values match nothing.
202pub(crate) fn match_reference(sessions: &[SessionMeta], reference: &str) -> Option<SessionId> {
203    if let Ok(id) = reference.parse::<SessionId>()
204        && sessions.iter().any(|meta| meta.session_id == id)
205    {
206        return Some(id);
207    }
208    if is_valid_handle(reference)
209        && let Some(found) = sessions
210            .iter()
211            .find(|meta| meta.short_id.as_deref() == Some(reference))
212    {
213        return Some(found.session_id);
214    }
215    None
216}
217
218fn subagent_reference_error(sessions: &[SessionMeta], reference: &str) -> EngineError {
219    let mut candidates = sessions
220        .iter()
221        .map(|meta| match &meta.short_id {
222            Some(handle) => format!(
223                "{handle} — {} — {} (session_id {})",
224                describe(meta),
225                session_status_name(meta.status),
226                meta.session_id
227            ),
228            // Pre-handle sessions have no second reference to show: print the
229            // UUID once instead of repeating it in a `session_id` suffix.
230            None => format!(
231                "{} — {} — {}",
232                meta.session_id,
233                describe(meta),
234                session_status_name(meta.status),
235            ),
236        })
237        .collect::<Vec<_>>();
238    candidates.sort();
239    let list = if candidates.is_empty() {
240        "(none)".to_owned()
241    } else {
242        candidates.join("\n")
243    };
244    EngineError::ToolFailed(format!(
245        "unknown subagent reference {reference:?}; live subagents in your tree:\n{list}"
246    ))
247}
248
249#[cfg(test)]
250mod tests {
251    use std::collections::HashSet;
252
253    use cookie_agent_protocol::{
254        AgentRevision, CatalogRevision, CwdIdentity, ModelRevision, ModelSelection,
255        ModelSnapshotRevision, ProviderStateRevision, RecipeRegistryRevision, RunSelection,
256        RuntimeRevision, SessionStatus,
257    };
258
259    use super::*;
260
261    fn revision(label: char) -> String {
262        format!("sha256:{}", label.to_string().repeat(64))
263    }
264
265    fn meta(id: SessionId, short_id: Option<&str>) -> SessionMeta {
266        SessionMeta {
267            session_id: id,
268            origin: SessionOrigin::Root,
269            short_id: short_id.map(str::to_owned),
270            cwd_identity: CwdIdentity::new("workspace:test").expect("cwd"),
271            creation_selection: RunSelection {
272                agent: cookie_agent_protocol::AgentId::new("explore").expect("agent"),
273                model: ModelSelection {
274                    model: "provider/model".parse().expect("model key"),
275                    variant: None,
276                },
277                preset: None,
278            },
279            runtime_revision: RuntimeRevision::new(revision('1')).expect("runtime"),
280            catalog_revision: CatalogRevision::new(revision('2')).expect("catalog"),
281            provider_state_revision: ProviderStateRevision::new(revision('3')).expect("provider"),
282            model_revision: ModelRevision::new(revision('4')).expect("model"),
283            agent_revision: AgentRevision::new(revision('5')).expect("agent"),
284            recipe_registry_revision: RecipeRegistryRevision::new(revision('6')).expect("recipes"),
285            manifest_revision: ModelSnapshotRevision::new(revision('7')).expect("manifest"),
286            title: None,
287            title_updated_seq: 0,
288            last_event_seq: 1,
289            last_activity: "2026-08-06T12:00:00Z".parse().expect("timestamp"),
290            status: SessionStatus::Completed,
291            skipped_events: Vec::new(),
292        }
293    }
294
295    #[test]
296    fn handle_slug_caps_at_sixteen_and_trims_trailing_hyphens() {
297        assert_eq!(handle_slug("explore"), "explore");
298        assert_eq!(handle_slug("sub-debugger"), "sub-debugger");
299        assert_eq!(handle_slug("abcdefghijklmnopq-rst"), "abcdefghijklmnop");
300        assert_eq!(handle_slug("abc-"), "abc");
301    }
302
303    #[test]
304    fn valid_handle_grammar_accepts_complete_handles_only() {
305        assert!(is_valid_handle("explore_1a2b3c4d"));
306        assert!(is_valid_handle("a_00000000"));
307        assert!(!is_valid_handle("explore_1a2b3c4"));
308        assert!(!is_valid_handle("explore_1A2B3C4D"));
309        assert!(!is_valid_handle("explore"));
310        assert!(!is_valid_handle("-explore_1a2b3c4d"));
311        assert!(!is_valid_handle("explore-_1a2b3c4d"));
312        assert!(!is_valid_handle("abcdefghijklmnopq_1a2b3c4d"));
313    }
314
315    #[test]
316    fn generation_regenerates_on_collision_then_succeeds() {
317        let mut existing = HashSet::new();
318        existing.insert("explore_00000001".to_owned());
319        let mut values = [1_u32, 2_u32].into_iter();
320        let handle = generate_handle("explore", &existing, || values.next().unwrap())
321            .expect("second candidate is free");
322        assert_eq!(handle, "explore_00000002");
323    }
324
325    #[test]
326    fn generation_fails_loudly_after_sixteen_attempts() {
327        let existing = HashSet::from(["explore_00000001".to_owned()]);
328        let error = generate_handle("explore", &existing, || 1).expect_err("cap reached");
329        assert!(error.to_string().contains("16 attempts"));
330    }
331
332    #[test]
333    fn reference_resolution_prefers_uuid_then_exact_handle() {
334        let first = SessionId::new_v7();
335        let second = SessionId::new_v7();
336        let sessions = vec![
337            meta(first, Some("explore_1a2b3c4d")),
338            meta(second, Some("coder_9f8e7d6b")),
339        ];
340        assert_eq!(match_reference(&sessions, &first.to_string()), Some(first));
341        assert_eq!(match_reference(&sessions, "coder_9f8e7d6b"), Some(second));
342    }
343
344    #[test]
345    fn reference_resolution_rejects_prefixes_and_fabrications() {
346        let id = SessionId::new_v7();
347        let sessions = vec![meta(id, Some("explore_1a2b3c4d"))];
348        assert_eq!(match_reference(&sessions, "explore_1a2b3c4"), None);
349        assert_eq!(match_reference(&sessions, "explore"), None);
350        assert_eq!(match_reference(&sessions, "explore_00000000"), None);
351        assert_eq!(match_reference(&sessions, "not-a-handle"), None);
352        assert_eq!(
353            match_reference(&sessions, &SessionId::new_v7().to_string()),
354            None
355        );
356    }
357
358    fn delegated_meta(id: SessionId, parent: SessionId, short_id: Option<&str>) -> SessionMeta {
359        let mut meta = meta(id, short_id);
360        meta.origin = SessionOrigin::Delegated {
361            root_session_id: parent,
362            parent_session_id: parent,
363            parent_run_id: cookie_agent_protocol::RunId::new_v7(),
364            parent_tool_call_id: cookie_agent_protocol::ToolCallId::new_v7(),
365            invocation_id: cookie_agent_protocol::InvocationId::new_v7(),
366            depth: 1,
367        };
368        meta
369    }
370
371    #[test]
372    fn candidate_scope_separates_direct_children_from_tree_peers() {
373        // root -> child -> grandchild, plus root -> sibling.
374        let root = SessionId::new_v7();
375        let child = SessionId::new_v7();
376        let grandchild = SessionId::new_v7();
377        let sibling = SessionId::new_v7();
378        let sessions = vec![
379            meta(root, Some("root_00000000")),
380            delegated_meta(child, root, Some("explore_1a2b3c4d")),
381            delegated_meta(grandchild, child, Some("coder_9f8e7d6b")),
382            delegated_meta(sibling, root, Some("writer_12345678")),
383        ];
384        let direct = scoped_candidates(&sessions, root, SubagentScope::DirectChildren);
385        assert_eq!(
386            direct
387                .iter()
388                .map(|meta| meta.session_id)
389                .collect::<Vec<_>>(),
390            vec![child, sibling]
391        );
392        // A grandchild handle is not addressable by the direct-child tools.
393        assert_eq!(match_reference(&direct, "coder_9f8e7d6b"), None);
394        let peers = scoped_candidates(&sessions, root, SubagentScope::TreePeers);
395        assert_eq!(peers.len(), 3);
396        assert_eq!(match_reference(&peers, "coder_9f8e7d6b"), Some(grandchild));
397    }
398
399    #[test]
400    fn old_sessions_without_handles_resolve_by_uuid_only() {
401        let id = SessionId::new_v7();
402        let sessions = vec![meta(id, None)];
403        assert_eq!(match_reference(&sessions, &id.to_string()), Some(id));
404        assert_eq!(match_reference(&sessions, "explore_1a2b3c4d"), None);
405    }
406
407    #[test]
408    fn reference_error_lists_live_candidates_with_handle_description_and_status() {
409        let child = SessionId::new_v7();
410        let mut child_meta = meta(child, Some("explore_1a2b3c4d"));
411        child_meta.title =
412            Some(cookie_agent_protocol::SessionTitle::new("Review API").expect("title"));
413        child_meta.status = SessionStatus::Running;
414        let error = subagent_reference_error(&[child_meta], "bogus");
415        let message = error.to_string();
416        assert!(message.contains("unknown subagent reference \"bogus\""));
417        assert!(message.contains("explore_1a2b3c4d — Review API — running"));
418        assert!(message.contains(&child.to_string()));
419    }
420
421    #[test]
422    fn reference_error_does_not_repeat_the_uuid_for_pre_handle_sessions() {
423        let child = SessionId::new_v7();
424        let mut child_meta = meta(child, None);
425        child_meta.status = SessionStatus::Running;
426        let message = subagent_reference_error(&[child_meta], "bogus").to_string();
427        let row = format!("{child} — explore — running");
428        assert!(message.contains(&row));
429        assert_eq!(message.matches(&child.to_string()).count(), 1);
430    }
431}