Skip to main content

perspt_sdk/toolset/
catalog.rs

1//! Catalog assembly and capability filtering (PSP-9 system 5).
2//!
3//! The catalog offered to the model is derived from the actor's *held
4//! capabilities* via [`ToolCatalog::specs_for`]: a model holding no
5//! `RunShell` capability never sees `run_shell` in its tool list. This is
6//! defence in depth, not the security boundary — the kernel is. It also
7//! normalizes across providers: the same capability set yields the same tool
8//! list whichever vendor serves the turn, which is half of Gate S.
9
10use super::entry::ToolEntry;
11use crate::capability::Capability;
12use crate::error::{Result, SdkError};
13use crate::model::ToolSpec;
14
15/// The catalog port.
16pub trait ToolCatalog: Send + Sync {
17    /// Every registered entry.
18    fn entries(&self) -> &[ToolEntry];
19
20    /// Look up one entry by tool name.
21    fn lookup(&self, name: &str) -> Option<&ToolEntry> {
22        self.entries().iter().find(|e| e.name == name)
23    }
24
25    /// The wire-neutral specs an actor holding `capabilities` may see.
26    ///
27    /// An entry is offered when some capability grants its effect. `strict`
28    /// asks providers with strict-schema support to enforce arguments;
29    /// routes without it degrade to local validation.
30    fn specs_for(&self, capabilities: &[Capability], strict: bool) -> Vec<ToolSpec> {
31        self.entries()
32            .iter()
33            .filter(|entry| {
34                capabilities
35                    .iter()
36                    .any(|c| c.effects.contains(&entry.effect))
37            })
38            .map(|entry| entry.to_spec(strict))
39            .collect()
40    }
41
42    /// Specs for the hot set — entries the catalog marks visible without
43    /// discovery, which the domain's common operating loop always is — plus
44    /// tools activated by a previous host-side search. Names without
45    /// authority are omitted. This is a context optimization, not an
46    /// authority boundary; the kernel still checks every call against the
47    /// complete catalog and held capabilities.
48    fn deferred_specs_for(
49        &self,
50        capabilities: &[Capability],
51        activated: &std::collections::BTreeSet<String>,
52        strict: bool,
53    ) -> Vec<ToolSpec> {
54        self.entries()
55            .iter()
56            .filter(|entry| {
57                (entry.hot || activated.contains(&entry.name))
58                    && capabilities
59                        .iter()
60                        .any(|capability| capability.effects.contains(&entry.effect))
61            })
62            .map(|entry| entry.to_spec(strict))
63            .collect()
64    }
65
66    /// Deterministic lexical search over entries the actor is authorized to
67    /// discover. External schemas stay deferred until this method selects
68    /// them, avoiding eager MCP/schema token cost.
69    fn search_specs(
70        &self,
71        capabilities: &[Capability],
72        query: &str,
73        limit: usize,
74        strict: bool,
75    ) -> Vec<ToolSpec> {
76        let terms: Vec<String> = query
77            .split(|character: char| !character.is_alphanumeric() && character != '_')
78            .filter(|term| !term.is_empty())
79            .map(str::to_ascii_lowercase)
80            .collect();
81        let mut matches: Vec<(usize, &ToolEntry)> = self
82            .entries()
83            .iter()
84            .filter(|entry| {
85                entry.name != "tool_search"
86                    && capabilities
87                        .iter()
88                        .any(|capability| capability.effects.contains(&entry.effect))
89            })
90            .filter_map(|entry| {
91                let name = entry.name.to_ascii_lowercase();
92                // Discovery ranks over the trusted discovery text (PSP-10
93                // system 25): the summary when declared, else the
94                // route-neutral description.
95                let haystack = format!("{} {}", name, entry.discovery_text().to_ascii_lowercase());
96                let score = terms.iter().fold(0usize, |score, term| {
97                    score
98                        + usize::from(name == *term) * 8
99                        + usize::from(name.contains(term)) * 4
100                        + usize::from(haystack.contains(term))
101                });
102                (score > 0 || terms.is_empty()).then_some((score, entry))
103            })
104            .collect();
105        matches.sort_by(|(left_score, left), (right_score, right)| {
106            right_score
107                .cmp(left_score)
108                .then_with(|| left.name.cmp(&right.name))
109        });
110        matches
111            .into_iter()
112            .take(limit.clamp(1, 12))
113            .map(|(_, entry)| entry.to_spec(strict))
114            .collect()
115    }
116}
117
118/// An assembled, validated catalog.
119#[derive(Debug, Default)]
120pub struct StaticCatalog {
121    entries: Vec<ToolEntry>,
122}
123
124impl StaticCatalog {
125    /// Assemble a catalog, validating every entry (Gates J and P) and
126    /// rejecting duplicate names.
127    pub fn assemble(entries: Vec<ToolEntry>) -> Result<Self> {
128        let mut seen = std::collections::BTreeSet::new();
129        for entry in &entries {
130            entry.validate()?;
131            if !seen.insert(entry.name.clone()) {
132                return Err(SdkError::Domain(format!(
133                    "duplicate tool name {:?} in catalog",
134                    entry.name
135                )));
136            }
137        }
138        Ok(Self { entries })
139    }
140
141    /// The SDK's base catalog plus `extra` entries (domain or external).
142    pub fn with_base(extra: Vec<ToolEntry>) -> Result<Self> {
143        let mut entries = super::base::base_entries();
144        entries.extend(extra);
145        Self::assemble(entries)
146    }
147}
148
149impl ToolCatalog for StaticCatalog {
150    fn entries(&self) -> &[ToolEntry] {
151        &self.entries
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::capability::{ActorId, EffectKind};
159
160    fn actor() -> ActorId {
161        ActorId("tester".into())
162    }
163
164    #[test]
165    fn base_catalog_assembles() {
166        let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
167        assert!(catalog.lookup("edit_file").is_some());
168        assert!(catalog.lookup("sed_replace").is_none());
169    }
170
171    #[test]
172    fn specs_follow_held_capabilities_not_the_full_catalog() {
173        let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
174        let read_only = Capability::new(
175            actor(),
176            vec![EffectKind::ReadFile, EffectKind::Search, EffectKind::List],
177        );
178        let specs = catalog.specs_for(&[read_only], false);
179        let names: Vec<&str> = specs.iter().map(|s| s.name.as_str()).collect();
180        assert!(names.contains(&"read_file"));
181        assert!(names.contains(&"grep"));
182        assert!(!names.contains(&"run_shell"), "no RunShell capability held");
183        assert!(!names.contains(&"edit_file"));
184    }
185
186    #[test]
187    fn no_capabilities_means_no_tools() {
188        let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
189        assert!(catalog.specs_for(&[], false).is_empty());
190    }
191
192    #[test]
193    fn duplicate_names_are_rejected_at_assembly() {
194        let mut entries = super::super::base::base_entries();
195        entries.push(entries[0].clone());
196        assert!(StaticCatalog::assemble(entries).is_err());
197    }
198
199    #[test]
200    fn the_catalog_is_provider_invariant_for_a_fixed_capability_set() {
201        // Gate S: nothing about spec derivation may read a provider identity;
202        // the same capability set yields the same list, and strictness only
203        // toggles enforcement, never membership.
204        let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
205        let capability = Capability::new(actor(), vec![EffectKind::ReadFile]);
206        let strict = catalog.specs_for(std::slice::from_ref(&capability), true);
207        let lax = catalog.specs_for(std::slice::from_ref(&capability), false);
208        let strict_names: Vec<&str> = strict.iter().map(|s| s.name.as_str()).collect();
209        let lax_names: Vec<&str> = lax.iter().map(|s| s.name.as_str()).collect();
210        assert_eq!(strict_names, lax_names);
211    }
212
213    #[test]
214    fn the_hot_set_includes_the_verifier_loop_and_rare_tools_stay_deferred() {
215        let catalog = StaticCatalog::with_base(Vec::new()).unwrap();
216        let capability = Capability::new(
217            actor(),
218            vec![
219                EffectKind::ToolSearch,
220                EffectKind::ReadFile,
221                EffectKind::RunTest,
222                EffectKind::MoveFile,
223            ],
224        );
225        let activated = std::collections::BTreeSet::new();
226        let initial =
227            catalog.deferred_specs_for(std::slice::from_ref(&capability), &activated, false);
228        assert!(initial.iter().any(|spec| spec.name == "tool_search"));
229        // The coding loop's verifier tools are hot: no discovery detour
230        // before the first test run.
231        assert!(initial.iter().any(|spec| spec.name == "run_test"));
232        // Rarer tools still defer until discovered.
233        assert!(!initial.iter().any(|spec| spec.name == "move_file"));
234        let mut discovered = activated;
235        discovered.insert("move_file".into());
236        let after =
237            catalog.deferred_specs_for(std::slice::from_ref(&capability), &discovered, false);
238        assert!(after.iter().any(|spec| spec.name == "move_file"));
239    }
240}