Skip to main content

byob_plugins/
names.rs

1//! Tool names every OpenAI-compatible server accepts: `[a-zA-Z0-9_-]{1,64}`, unique.
2
3use std::collections::BTreeSet;
4
5pub const MAX_TOOL_NAME: usize = 64;
6
7/// Replace characters outside `[a-zA-Z0-9_-]` with `_`; `tool` if nothing is left.
8pub fn sanitize(name: &str) -> String {
9    let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' }).collect();
10    if s.is_empty() { "tool".into() } else { s }
11}
12
13/// FNV-1a, for short stable suffixes.
14fn fnv1a(s: &str) -> u32 {
15    s.bytes().fold(0x811c_9dc5u32, |h, b| (h ^ u32::from(b)).wrapping_mul(0x0100_0193))
16}
17
18/// Fit `name` (already sanitized, ASCII) into 64 characters: long names keep their start
19/// and get a hash of the whole name, so different long names stay different.
20pub fn fit(name: &str) -> String {
21    if name.len() <= MAX_TOOL_NAME {
22        return name.to_string();
23    }
24    let hash = format!("_{:08x}", fnv1a(name));
25    let keep = MAX_TOOL_NAME - hash.len();
26    format!("{}{hash}", &name[..keep])
27}
28
29/// `<plugin>__<tool>`, sanitized and fitted. E.g. `linear__create_issue`.
30pub fn namespaced(plugin_id: &str, tool: &str) -> String {
31    fit(&format!("{}__{}", sanitize(plugin_id), sanitize(tool)))
32}
33
34/// Hands out unique tool names. Reserve the built-in tools' names first.
35#[derive(Debug, Clone, Default)]
36pub struct ToolNames {
37    taken: BTreeSet<String>,
38}
39
40impl ToolNames {
41    pub fn new<I: IntoIterator<Item = S>, S: Into<String>>(reserved: I) -> Self {
42        Self { taken: reserved.into_iter().map(Into::into).collect() }
43    }
44
45    pub fn reserve(&mut self, name: &str) {
46        self.taken.insert(name.to_string());
47    }
48
49    pub fn is_taken(&self, name: &str) -> bool {
50        self.taken.contains(name)
51    }
52
53    /// `preferred` if free, else `fallback`, else `fallback_2`, `_3`, … (all fitted).
54    pub fn claim(&mut self, preferred: &str, fallback: &str) -> String {
55        let preferred = fit(&sanitize(preferred));
56        let name = if self.taken.contains(&preferred) {
57            let base = fit(&sanitize(fallback));
58            let mut name = base.clone();
59            let mut i = 2u32;
60            while self.taken.contains(&name) {
61                let suffix = format!("_{i}");
62                let keep = base.len().min(MAX_TOOL_NAME - suffix.len());
63                name = format!("{}{suffix}", &base[..keep]);
64                i = i.saturating_add(1);
65            }
66            name
67        } else {
68            preferred
69        };
70        self.taken.insert(name.clone());
71        name
72    }
73}
74
75/// Whether `name` is valid for OpenAI-compatible servers.
76pub fn valid(name: &str) -> bool {
77    !name.is_empty() && name.len() <= MAX_TOOL_NAME && name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-')
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn names_are_valid_and_unique() {
86        assert_eq!(namespaced("linear", "create.issue"), "linear__create_issue");
87        assert_eq!(sanitize("ünï"), "_n_");
88        assert_eq!(sanitize(""), "tool");
89        let long_a = namespaced("some-plugin", &"a".repeat(80));
90        let long_b = namespaced("some-plugin", &format!("{}b", "a".repeat(79)));
91        assert!(valid(&long_a) && valid(&long_b) && long_a.len() == 64);
92        assert_ne!(long_a, long_b);
93
94        let mut names = ToolNames::new(["read_file"]);
95        assert_eq!(names.claim("count", "p__count"), "count");
96        assert_eq!(names.claim("count", "p__count"), "p__count");
97        assert_eq!(names.claim("count", "p__count"), "p__count_2");
98        assert_eq!(names.claim("read_file", "p__read_file"), "p__read_file");
99        let long = "x".repeat(64);
100        assert_eq!(names.claim(&long, &long), long);
101        let next = names.claim(&long, &long);
102        assert!(valid(&next) && next.ends_with("_2"));
103    }
104}