Skip to main content

capsule_runtime/
tool_names.rs

1//! The tool name of an artifact inside one staged session.
2//!
3//! A capsule declares artifacts by full name, `<scope>/<name>`, and a model is offered each by its
4//! tool name, the bare part: the tool it calls, its `<runtime dir>/tools/<name>/` directory and
5//! every key the session dispatches on. Staging refuses two artifacts that would share a tool name,
6//! then renames the request once ([`localize_artifact_names`]) so the rest of the session works in
7//! tool names alone. What leaves the session — the store, the lock, trace identity, `manage.*` and
8//! roost requests — goes back to the full name through the map that rename returns.
9
10use std::collections::HashMap;
11
12use murmur_artifact::tool_name_of;
13
14use crate::errors::RuntimeError;
15use crate::types::StageRequest;
16
17/// Refuses the first two declared names that share a tool name.
18///
19/// `names` are the names as declared: full names, and the bare names of `source:` entries. Called
20/// from `stage_session` ahead of every resolve, and from `mur run`'s pre-flight beside
21/// [`crate::runtime::check_no_reserved_tool_names`].
22pub fn check_unique_tool_names<'a, I>(names: I) -> Result<(), RuntimeError>
23where
24    I: IntoIterator<Item = &'a str>,
25{
26    let mut seen: HashMap<&str, &str> = HashMap::new();
27    for name in names {
28        let tool = tool_name_of(name);
29        if let Some(first) = seen.insert(tool, name) {
30            return Err(RuntimeError::DuplicateToolName {
31                first: first.to_string(),
32                second: name.to_string(),
33                tool: tool.to_string(),
34            });
35        }
36    }
37    Ok(())
38}
39
40/// Rename every artifact reference in `request` to its tool name — the artifact entries, the tool
41/// allowlist, and the inference block's driver, alternate drivers and system-prompt artifact — and
42/// return each tool name's full name. Run after [`check_unique_tool_names`], which makes the map
43/// one-to-one.
44pub(crate) fn localize_artifact_names(request: &mut StageRequest) -> HashMap<String, String> {
45    let mut full_names = HashMap::with_capacity(request.artifacts.len());
46    for artifact in &mut request.artifacts {
47        let tool = tool_name_of(&artifact.name).to_string();
48        full_names.insert(tool.clone(), std::mem::replace(&mut artifact.name, tool));
49    }
50    request.allowlisted_tools = request
51        .allowlisted_tools
52        .iter()
53        .map(|name| tool_name_of(name).to_string())
54        .collect();
55    if let Some(inference) = request.inference.as_mut() {
56        if let Some(driver) = inference.driver.as_mut() {
57            driver.artifact = tool_name_of(&driver.artifact).to_string();
58        }
59        for alternate in &mut inference.alternates {
60            alternate.driver = tool_name_of(&alternate.driver).to_string();
61        }
62        if let Some(artifact) = inference.system_prompt_artifact.as_mut() {
63            *artifact = tool_name_of(artifact).to_string();
64        }
65    }
66    full_names
67}
68
69/// The `capabilities.spawn.allow` entry a `delegate-task` call or a plan's `capsule` step names by
70/// its bare part. A name no entry has is returned as given, so mur-roost's referee refuses it in
71/// its own words; the manifest parser keeps the bare parts of the entries unique.
72pub fn spawn_target(spawn_allow: &[String], capsule: &str) -> String {
73    spawn_allow
74        .iter()
75        .find(|entry| tool_name_of(entry) == capsule)
76        .cloned()
77        .unwrap_or_else(|| capsule.to_string())
78}
79
80/// The full name of the artifact a session knows as `tool`, or `tool` itself for one with no full
81/// name: a `source:` skill, or a runtime-provided tool.
82pub(crate) fn full_name_of<'a>(full_names: &'a HashMap<String, String>, tool: &'a str) -> &'a str {
83    full_names.get(tool).map_or(tool, String::as_str)
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn unique_tool_names_refuses_two_scopes_with_one_name() {
92        let error = check_unique_tool_names(["murmur/tool-editor", "ana/tool-editor"]).unwrap_err();
93        let RuntimeError::DuplicateToolName {
94            first,
95            second,
96            tool,
97        } = &error
98        else {
99            panic!("expected DuplicateToolName, got {error:?}");
100        };
101        assert_eq!(first, "murmur/tool-editor");
102        assert_eq!(second, "ana/tool-editor");
103        assert_eq!(tool, "tool-editor");
104        let message = error.to_string();
105        for part in ["murmur/tool-editor", "ana/tool-editor", "'tool-editor'"] {
106            assert!(message.contains(part), "{message}");
107        }
108    }
109
110    #[test]
111    fn unique_tool_names_refuses_a_source_skill_beside_a_resolved_artifact() {
112        let error = check_unique_tool_names(["tool-editor", "murmur/tool-editor"]).unwrap_err();
113        assert!(matches!(error, RuntimeError::DuplicateToolName { .. }));
114    }
115
116    #[test]
117    fn delegate_task_spawn_target_maps_a_bare_name_to_its_entry() {
118        let allow = vec!["local/worker".to_string(), "ana/reviewer".to_string()];
119        assert_eq!(spawn_target(&allow, "worker"), "local/worker");
120        assert_eq!(spawn_target(&allow, "reviewer"), "ana/reviewer");
121        assert_eq!(spawn_target(&allow, "stranger"), "stranger");
122    }
123
124    #[test]
125    fn unique_tool_names_accepts_distinct_tool_names() {
126        check_unique_tool_names(["murmur/tool-editor", "ana/tool-x", "local-skill"]).unwrap();
127    }
128}