Skip to main content

agent_engine/tools/
catalog.rs

1//! `ToolCatalog` — all locally known capabilities (spec §7.1, Task 14).
2//!
3//! The catalog is a passive inventory integrated into [`super::ToolRegistry`]:
4//! every registry construction and mutation path (initial construction,
5//! subagent variants, dynamic registration, replacement, extension merge,
6//! disable) keeps exactly one truthful catalog alongside the exposed schema
7//! without changing what is exposed or executable. The registry remains the
8//! active behavior projection until later tasks introduce `DiscoveryIndex`,
9//! `SessionToolSet`, and `ExecutionGate`.
10//!
11//! Insertion invariants (spec §4.2):
12//! - no implementation is constructed (factories are stored, never invoked);
13//! - no process is started and no network is touched;
14//! - no extension handler or MCP connection method is called;
15//! - no schema is exposed to the model;
16//! - no execution grant is issued.
17//!
18//! Mutation invariants:
19//! - every successful mutation advances the generation by checked (never
20//!   saturating) arithmetic — a mutation cannot succeed without a new
21//!   generation, so stale activation grants can never keep validating;
22//! - duplicate and no-op failures do not advance the generation.
23
24use crate::tools::{Tool, ToolOrigin};
25use agent_core::BoundedText;
26use serde_json::Value;
27use sha2::{Digest, Sha256};
28use std::collections::BTreeMap;
29use std::sync::Arc;
30use thiserror::Error;
31
32pub use agent_core::orchestration::capability::{
33    CatalogGeneration, CatalogGenerationOverflow, SchemaDigest, SessionActivationGrant, ToolId,
34    ToolIdError,
35};
36
37/// Deferred implementation constructor. Stored at insertion, invoked only by
38/// explicit later acquisition (execution-gate territory, not catalog).
39pub type ToolFactory = Arc<dyn Fn() -> Arc<dyn Tool> + Send + Sync>;
40
41/// Byte budget for raw identity fragments retained inside
42/// [`CapabilitySource`] (extension/plugin/server ids and tool names).
43const SOURCE_IDENTITY_MAX_BYTES: usize = 256;
44
45/// Hex characters of SHA-256 appended to a truncated identity fragment
46/// (160 bits, matching the `ToolId` digest-segment strength).
47const SOURCE_TRUNCATION_DIGEST_HEX_LEN: usize = 40;
48
49/// Bound a trust-relevant identity fragment without letting two distinct
50/// oversized identities collapse into one displayed value: when truncation
51/// occurs, an explicit `…#sha-<hex>` marker of the FULL raw bytes is
52/// appended, so the result both records that it was cut and stays distinct
53/// per original identity (up to SHA-256 collision). The source-aware
54/// [`ToolId`] is always derived from the raw identity, never this bounded
55/// form, so id collision resistance is unaffected.
56fn bounded_identity(raw: &str) -> String {
57    let bounded = BoundedText::new(raw, SOURCE_IDENTITY_MAX_BYTES);
58    if !bounded.truncated {
59        return bounded.text;
60    }
61    let digest = Sha256::digest(raw.as_bytes());
62    let mut hex = String::with_capacity(SOURCE_TRUNCATION_DIGEST_HEX_LEN);
63    for byte in digest.iter().take(SOURCE_TRUNCATION_DIGEST_HEX_LEN / 2) {
64        use std::fmt::Write as _;
65        let _ = write!(hex, "{byte:02x}");
66    }
67    let marker = format!("\u{2026}#sha-{hex}");
68    let keep = SOURCE_IDENTITY_MAX_BYTES.saturating_sub(marker.len());
69    format!("{}{marker}", BoundedText::new(raw, keep).text)
70}
71
72/// Where a capability comes from, carrying the exact runtime identities
73/// (extension/plugin/server ids and per-source tool names). Drives the ID
74/// namespace and, later, per-source permission policy.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum CapabilitySource {
77    Builtin,
78    Extension {
79        extension_id: String,
80        tool_name: String,
81    },
82    Mcp {
83        server_id: String,
84        server_tool_name: String,
85    },
86    Plugin {
87        plugin_id: String,
88        tool_name: String,
89    },
90    /// Dynamically registered with no declared origin. Kept explicit and
91    /// conservative — never classified as builtin.
92    Unknown {
93        runtime_name: String,
94    },
95}
96
97/// Permission/trust provenance recorded at catalog time. Evaluated (not
98/// merely trusted) by the future execution gate.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub enum TrustProvenance {
101    /// Compiled into this runtime; trusted as the binary itself.
102    BuiltinRuntime,
103    /// Declared by a locally installed extension manifest.
104    ExtensionManifest { extension_id: String },
105    /// Declared by local MCP server configuration.
106    McpConfig { server_id: String },
107    /// Declared by a plugin definition.
108    PluginConfig { plugin_id: String },
109    /// No verifiable provenance. Conservative fail-closed default for
110    /// unclassified dynamic registrations.
111    Unverified,
112}
113
114/// Conservative tool effect classes (Task 24, spec §8.2). Declared by the
115/// implementation, recorded in the catalog at registration, and consumed
116/// by the stream scheduler: only `ReadOnly` (or proven non-conflicting
117/// keyed writes) run concurrently; everything else serializes. Unknown/
118/// dynamic tools default to `NonIdempotent` — the safest class.
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
120pub enum ToolEffect {
121    /// No observable side effects; safe to run concurrently.
122    ReadOnly,
123    /// Repeatable write with a stable target; conflicts are detected via
124    /// the concurrency key (same key => model-order serial execution).
125    IdempotentWrite,
126    /// Arbitrary side effects; serialized by default.
127    NonIdempotent,
128}
129
130/// Where the full JSON schema for a capability lives. The catalog may hold
131/// the schema locally, but never exposes it to the model.
132#[derive(Clone, Debug)]
133pub enum SchemaLocator {
134    /// Full schema already known in-process (builtins, loaded extensions).
135    Inline(Value),
136}
137
138impl SchemaLocator {
139    fn schema(&self) -> &Value {
140        match self {
141            Self::Inline(schema) => schema,
142        }
143    }
144}
145
146/// One locally known capability. Construction is pure: it computes the
147/// deterministic schema digest and bounds the compact descriptor, but never
148/// invokes the implementation factory.
149#[derive(Clone)]
150pub struct CapabilityRecord {
151    id: ToolId,
152    source: CapabilitySource,
153    summary: String,
154    tags: Vec<String>,
155    schema: SchemaLocator,
156    schema_digest: SchemaDigest,
157    factory: ToolFactory,
158    provenance: TrustProvenance,
159    effect: ToolEffect,
160}
161
162impl CapabilityRecord {
163    /// Byte budget for the compact summary (descriptor, not full schema).
164    pub const SUMMARY_MAX_BYTES: usize = 256;
165    /// Maximum number of retained tags.
166    pub const MAX_TAGS: usize = 8;
167    /// Byte budget per retained tag.
168    pub const TAG_MAX_BYTES: usize = 32;
169
170    pub fn new(
171        id: ToolId,
172        source: CapabilitySource,
173        summary: &str,
174        tags: Vec<String>,
175        schema: SchemaLocator,
176        factory: ToolFactory,
177        provenance: TrustProvenance,
178    ) -> Self {
179        let schema_digest = SchemaDigest::of_schema(schema.schema());
180        let summary = BoundedText::new(summary, Self::SUMMARY_MAX_BYTES).text;
181        let tags = tags
182            .into_iter()
183            .take(Self::MAX_TAGS)
184            .map(|tag| BoundedText::new(&tag, Self::TAG_MAX_BYTES).text)
185            .collect();
186        Self {
187            id,
188            source,
189            summary,
190            tags,
191            schema,
192            schema_digest,
193            factory,
194            provenance,
195            effect: ToolEffect::NonIdempotent,
196        }
197    }
198
199    /// Catalog an already-constructed registry tool passively from its
200    /// declared [`ToolOrigin`]. Reads only in-process metadata (`name`,
201    /// `description`, `parameters`, `origin`): no execution, no extension
202    /// handler call, no MCP connection/process/network activity, no grant.
203    ///
204    /// Runtime identities are encoded through the source-aware [`ToolId`]
205    /// constructors, so existing uppercase/Unicode/colon-bearing names are
206    /// represented exactly rather than rejected, and undeclared origins are
207    /// cataloged conservatively as unknown — never invented as builtin.
208    pub fn for_registered_tool(tool: &Arc<dyn Tool>) -> Self {
209        let (id, source, provenance) = identity_for_tool(tool.as_ref());
210        let implementation = Arc::clone(tool);
211        let factory: ToolFactory = Arc::new(move || Arc::clone(&implementation));
212        let mut record = Self::new(
213            id,
214            source,
215            tool.description(),
216            Vec::new(),
217            SchemaLocator::Inline(tool.parameters()),
218            factory,
219            provenance,
220        );
221        // Task 24: record the implementation-declared effect class; the
222        // trait default keeps unknown/dynamic tools NonIdempotent.
223        record.effect = tool.effect();
224        record
225    }
226
227    pub fn id(&self) -> &ToolId {
228        &self.id
229    }
230
231    pub fn source(&self) -> &CapabilitySource {
232        &self.source
233    }
234
235    pub fn summary(&self) -> &str {
236        &self.summary
237    }
238
239    pub fn tags(&self) -> &[String] {
240        &self.tags
241    }
242
243    pub fn schema_locator(&self) -> &SchemaLocator {
244        &self.schema
245    }
246
247    pub fn schema_digest(&self) -> &SchemaDigest {
248        &self.schema_digest
249    }
250
251    pub fn provenance(&self) -> &TrustProvenance {
252        &self.provenance
253    }
254
255    /// The recorded conservative effect class (Task 24, spec §8.2).
256    pub fn effect(&self) -> ToolEffect {
257        self.effect
258    }
259
260    /// Construct the implementation. This is the ONLY place the stored
261    /// factory is invoked; the catalog itself never calls it.
262    pub fn implementation(&self) -> Arc<dyn Tool> {
263        (self.factory)()
264    }
265}
266
267impl std::fmt::Debug for CapabilityRecord {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        f.debug_struct("CapabilityRecord")
270            .field("id", &self.id)
271            .field("source", &self.source)
272            .field("summary", &self.summary)
273            .field("tags", &self.tags)
274            .field("schema_digest", &self.schema_digest)
275            .field("provenance", &self.provenance)
276            .field("effect", &self.effect)
277            .finish_non_exhaustive()
278    }
279}
280
281/// Catalog identity of a registered tool: its exact runtime name for
282/// extension tools is `<extension_id>:<tool_name>`, so the extension prefix
283/// is stripped before encoding to keep the id keyed on (extension, tool).
284pub(crate) fn tool_id_for(tool: &dyn Tool) -> ToolId {
285    identity_for_tool(tool).0
286}
287
288fn identity_for_tool(tool: &dyn Tool) -> (ToolId, CapabilitySource, TrustProvenance) {
289    match tool.origin() {
290        ToolOrigin::Builtin => (
291            ToolId::builtin(tool.name()),
292            CapabilitySource::Builtin,
293            TrustProvenance::BuiltinRuntime,
294        ),
295        ToolOrigin::Extension { extension_id } => {
296            let runtime_name = tool.name();
297            let tool_name = runtime_name
298                .strip_prefix(&format!("{extension_id}:"))
299                .unwrap_or(runtime_name);
300            (
301                ToolId::extension(&extension_id, tool_name),
302                CapabilitySource::Extension {
303                    extension_id: bounded_identity(&extension_id),
304                    tool_name: bounded_identity(tool_name),
305                },
306                TrustProvenance::ExtensionManifest {
307                    extension_id: bounded_identity(&extension_id),
308                },
309            )
310        }
311        ToolOrigin::Mcp {
312            server_id,
313            server_tool_name,
314        } => (
315            ToolId::mcp(&server_id, &server_tool_name),
316            CapabilitySource::Mcp {
317                server_id: bounded_identity(&server_id),
318                server_tool_name: bounded_identity(&server_tool_name),
319            },
320            TrustProvenance::McpConfig {
321                server_id: bounded_identity(&server_id),
322            },
323        ),
324        ToolOrigin::Plugin {
325            plugin_id,
326            tool_name,
327        } => (
328            ToolId::plugin(&plugin_id, &tool_name),
329            CapabilitySource::Plugin {
330                plugin_id: bounded_identity(&plugin_id),
331                tool_name: bounded_identity(&tool_name),
332            },
333            TrustProvenance::PluginConfig {
334                plugin_id: bounded_identity(&plugin_id),
335            },
336        ),
337        ToolOrigin::Unknown => (
338            ToolId::unclassified(tool.name()),
339            CapabilitySource::Unknown {
340                runtime_name: bounded_identity(tool.name()),
341            },
342            TrustProvenance::Unverified,
343        ),
344    }
345}
346
347/// Typed catalog mutation failures. All fail closed without advancing the
348/// catalog generation or partially applying the mutation.
349#[derive(Clone, Debug, Error, PartialEq, Eq)]
350pub enum CatalogError {
351    #[error("duplicate tool id in catalog: {0}")]
352    DuplicateToolId(ToolId),
353    /// A batch registration collided on a RUNTIME name — against a tool
354    /// already live in the registry or another batch member. Boot batches
355    /// are strictly additive: replacement is never permitted through them.
356    /// The echoed name is bounded, never raw hostile input.
357    #[error("duplicate runtime tool name in additive batch: {0}")]
358    DuplicateRuntimeName(String),
359    #[error("invalid tool id: {0}")]
360    InvalidToolId(#[from] ToolIdError),
361    #[error("catalog mutation rejected: {0}")]
362    GenerationExhausted(#[from] CatalogGenerationOverflow),
363}
364
365/// Inventory of all locally known capabilities, keyed by stable [`ToolId`]
366/// in deterministic order, with a generation that advances on every mutation.
367#[derive(Clone, Debug)]
368pub struct ToolCatalog {
369    entries: BTreeMap<ToolId, CapabilityRecord>,
370    generation: CatalogGeneration,
371}
372
373impl ToolCatalog {
374    pub fn empty() -> Self {
375        Self {
376            entries: BTreeMap::new(),
377            generation: CatalogGeneration::initial(),
378        }
379    }
380
381    /// Boundary-test support: an empty catalog resumed at an explicit
382    /// generation. Not used by production paths.
383    ///
384    /// Deliberately NOT gated behind `#[cfg(any(test, feature = "testing"))]`:
385    /// this crate's integration tests (e.g. `tests/registry_catalog.rs`)
386    /// compile the library without `cfg(test)` and are run without the
387    /// `testing` feature (it is enabled only through `synaps-tui`/workspace
388    /// wiring), so cfg-gating would break their imports. `#[doc(hidden)]`
389    /// keeps it out of the public API surface instead.
390    #[doc(hidden)]
391    pub fn resume_at_generation_for_tests(generation: CatalogGeneration) -> Self {
392        Self {
393            entries: BTreeMap::new(),
394            generation,
395        }
396    }
397
398    /// Boundary-test support: overwrite the generation counter in place.
399    /// Grants nothing, exposes nothing, and mutates no entries; only the
400    /// counter used by fail-closed advancement checks is changed. Doc-hidden
401    /// instead of cfg-gated for the same reason as
402    /// [`Self::resume_at_generation_for_tests`] (plain integration tests
403    /// must keep compiling without the `testing` feature).
404    #[doc(hidden)]
405    pub fn set_generation_for_tests(&mut self, generation: CatalogGeneration) {
406        self.generation = generation;
407    }
408
409    /// Read-only snapshot of the catalog integrated into a live registry.
410    ///
411    /// Kept as a compatibility shim over [`super::ToolRegistry::catalog`];
412    /// the registry maintains its catalog through every construction and
413    /// mutation path, so this is a pure read with no process start, network
414    /// access, schema exposure, or execution grant.
415    pub fn from_registry(registry: &super::ToolRegistry) -> Result<Self, CatalogError> {
416        Ok(registry.catalog().clone())
417    }
418
419    pub fn generation(&self) -> CatalogGeneration {
420        self.generation
421    }
422
423    pub fn len(&self) -> usize {
424        self.entries.len()
425    }
426
427    pub fn is_empty(&self) -> bool {
428        self.entries.is_empty()
429    }
430
431    pub fn get(&self, id: &ToolId) -> Option<&CapabilityRecord> {
432        self.entries.get(id)
433    }
434
435    /// Iterate records in deterministic `ToolId` order.
436    pub fn iter(&self) -> impl Iterator<Item = &CapabilityRecord> {
437        self.entries.values()
438    }
439
440    /// A copy of this catalog without `ids`, at the same generation: the
441    /// catalog as a session that is not offered those tools sees it
442    /// ([`super::activation::SessionToolSet::withholding`]). A view for
443    /// reading; the registry's own catalog is unchanged.
444    pub fn without<'a>(&self, ids: impl IntoIterator<Item = &'a ToolId>) -> Self {
445        let mut view = self.clone();
446        for id in ids {
447            view.entries.remove(id);
448        }
449        view
450    }
451
452    /// Insert one capability. Duplicate IDs fail closed (no silent
453    /// replacement, no generation advance); generation exhaustion fails
454    /// closed (no entry change). On success the catalog generation advances
455    /// by exactly one. The record's factory is stored, never invoked.
456    pub fn insert(&mut self, record: CapabilityRecord) -> Result<(), CatalogError> {
457        if self.entries.contains_key(&record.id) {
458            return Err(CatalogError::DuplicateToolId(record.id.clone()));
459        }
460        let next = self.generation.checked_next()?;
461        self.entries.insert(record.id.clone(), record);
462        self.generation = next;
463        Ok(())
464    }
465
466    /// Insert-or-replace one capability as a single mutation, optionally
467    /// dropping the entry it shadows (registry replacement by runtime name
468    /// can change the capability identity; the stale identity must not
469    /// linger and keep validating old grants).
470    ///
471    /// Occupied-identity safety: if the incoming id is already cataloged and
472    /// it is NOT the unchanged identity of the same-runtime-name tool being
473    /// replaced (`replaced == Some(record.id)`), the mutation fails typed
474    /// with [`CatalogError::DuplicateToolId`] before any entry or generation
475    /// change — two distinct runtime names must never silently share one
476    /// capability identity, or live tools and catalog would diverge. Fails
477    /// closed without touching entries when no new generation is available.
478    pub fn upsert(
479        &mut self,
480        replaced: Option<&ToolId>,
481        record: CapabilityRecord,
482    ) -> Result<(), CatalogError> {
483        let same_identity_replacement = replaced == Some(&record.id);
484        if !same_identity_replacement && self.entries.contains_key(&record.id) {
485            return Err(CatalogError::DuplicateToolId(record.id.clone()));
486        }
487        let next = self.generation.checked_next()?;
488        if let Some(stale) = replaced {
489            if stale != &record.id {
490                self.entries.remove(stale);
491            }
492        }
493        self.entries.insert(record.id.clone(), record);
494        self.generation = next;
495        Ok(())
496    }
497
498    /// Advance strictly past both this catalog's generation and a prior
499    /// registry generation. Used when a mutation rebuilds the catalog from
500    /// scratch (e.g. `disable`): the rebuilt catalog must not reuse
501    /// generation values already observed, or stale grants could survive.
502    pub fn rebase_past(
503        &mut self,
504        prior: CatalogGeneration,
505    ) -> Result<(), CatalogGenerationOverflow> {
506        self.generation = self.generation.max(prior).checked_next()?;
507        Ok(())
508    }
509}
510
511// ── DiscoveryIndex (Task 15, spec §7.1) ─────────────────────────────────────
512
513/// Maximum UTF-8 byte length of a discovery query.
514pub const QUERY_MAX_BYTES: usize = 256;
515/// Hard cap on the per-search result count budget.
516pub const SEARCH_MAX_RESULTS_CAP: usize = 64;
517/// Hard cap on the per-search serialized byte budget (64 KiB).
518pub const SEARCH_MAX_RESULT_BYTES_CAP: usize = 64 * 1024;
519/// Minimum per-search serialized byte budget: the empty serialized result
520/// collection (`[]`) is 2 bytes, so no smaller budget can be honest.
521pub const SEARCH_MIN_RESULT_BYTES: usize = 2;
522
523/// Typed failure for boundary parsing of a [`DiscoveryQuery`].
524#[derive(Clone, Debug, Error, PartialEq, Eq)]
525pub enum DiscoveryQueryError {
526    #[error("discovery query is empty")]
527    Empty,
528    #[error("discovery query is oversized: {actual} bytes exceeds limit {limit}")]
529    Oversized { actual: usize, limit: usize },
530    #[error("discovery query contains control characters")]
531    ControlCharacters,
532}
533
534/// Characters that split query keywords besides whitespace. Tool ids and
535/// names join words with them (`web_fetch`, `web-fetch`,
536/// `ext.web-tools:fetch`), so `web fetch` and `web_fetch` search alike.
537const TERM_SEPARATORS: [char; 4] = ['_', '-', ':', '.'];
538
539/// The words of `text`, split at whitespace and [`TERM_SEPARATORS`].
540fn words(text: &str) -> impl Iterator<Item = &str> {
541    text.split(|c: char| c.is_whitespace() || TERM_SEPARATORS.contains(&c))
542        .filter(|word| !word.is_empty())
543}
544
545/// Words too short or too common to select a tool on their own.
546const FILLER_WORDS: [&str; 14] = [
547    "an", "and", "by", "for", "from", "in", "into", "of", "on", "or", "the", "to", "tool", "with",
548];
549
550fn is_filler(term: &str) -> bool {
551    term.chars().count() < 2 || FILLER_WORDS.contains(&term)
552}
553
554/// A validated, bounded, case-folded discovery query. Parse-at-boundary:
555/// empty (or whitespace-only), oversized, and control-character-bearing
556/// input fails typed before any search runs.
557#[derive(Clone, Debug, PartialEq, Eq)]
558pub struct DiscoveryQuery {
559    needle: String,
560    /// Distinct keywords of `needle` in query order (see [`words`]).
561    terms: Vec<String>,
562}
563
564impl DiscoveryQuery {
565    pub fn parse(raw: &str) -> Result<Self, DiscoveryQueryError> {
566        if raw.len() > QUERY_MAX_BYTES {
567            return Err(DiscoveryQueryError::Oversized {
568                actual: raw.len(),
569                limit: QUERY_MAX_BYTES,
570            });
571        }
572        let trimmed = raw.trim();
573        if trimmed.is_empty() {
574            return Err(DiscoveryQueryError::Empty);
575        }
576        // Reject C0/C1/DEL control values (newline, ESC/ANSI, NUL, …) in the
577        // retained needle: the search haystack joins id/summary/tag fields
578        // with `\n`, so an embedded newline could match across field
579        // boundaries, and control bytes must never survive into logs.
580        if trimmed.chars().any(char::is_control) {
581            return Err(DiscoveryQueryError::ControlCharacters);
582        }
583        let needle = trimmed.to_lowercase();
584        let mut terms: Vec<String> = Vec::new();
585        for word in words(&needle) {
586            if !terms.iter().any(|term| term == word) {
587                terms.push(word.to_string());
588            }
589        }
590        // In a multi-word query, one-letter words and filler (`read a file`,
591        // `tool for web search`) would match nearly every summary and fill
592        // the result budget with noise; drop them while a real keyword is
593        // left. A query made only of them keeps its words.
594        if terms.len() > 1 && terms.iter().any(|term| !is_filler(term)) {
595            terms.retain(|term| !is_filler(term));
596        }
597        Ok(Self { needle, terms })
598    }
599
600    /// The whole trimmed, case-folded query. [`DiscoveryIndex::search`]
601    /// ranks descriptors containing it as one substring above keyword-only
602    /// hits; `search_skills` matches it literally.
603    pub fn needle(&self) -> &str {
604        &self.needle
605    }
606
607    /// The distinct case-folded keywords of the query: split at whitespace
608    /// and at `_`, `-`, `:` and `.`, empty pieces dropped, and one-letter or
609    /// filler words dropped while another keyword remains. Empty when the
610    /// query is all separators.
611    pub fn terms(&self) -> &[String] {
612        &self.terms
613    }
614}
615
616/// Typed failure for constructing [`SearchLimits`]. Zero budgets would make
617/// every search vacuous; sub-minimum byte budgets could not even hold the
618/// empty serialized result collection; over-cap budgets would defeat the
619/// bound.
620#[derive(Clone, Debug, Error, PartialEq, Eq)]
621pub enum SearchLimitsError {
622    #[error("search result count budget must be positive")]
623    ZeroResults,
624    #[error("search byte budget must be positive")]
625    ZeroBytes,
626    #[error(
627        "search byte budget {actual} is below the serialized empty result collection size {min}"
628    )]
629    BytesBelowMinimum { actual: usize, min: usize },
630    #[error("search result count budget {actual} exceeds cap {cap}")]
631    ResultsOverCap { actual: usize, cap: usize },
632    #[error("search byte budget {actual} exceeds cap {cap}")]
633    BytesOverCap { actual: usize, cap: usize },
634}
635
636/// Validated positive, capped search budgets. The byte budget bounds the
637/// exact compact-JSON serialization of the returned hit collection as an
638/// array (entries plus `[`/`]` and comma overhead), so a caller can rely on
639/// `serde_json::to_vec(results.hits())` never exceeding it.
640#[derive(Clone, Copy, Debug, PartialEq, Eq)]
641pub struct SearchLimits {
642    max_results: usize,
643    max_result_bytes: usize,
644}
645
646impl SearchLimits {
647    pub fn new(max_results: usize, max_result_bytes: usize) -> Result<Self, SearchLimitsError> {
648        if max_results == 0 {
649            return Err(SearchLimitsError::ZeroResults);
650        }
651        if max_result_bytes == 0 {
652            return Err(SearchLimitsError::ZeroBytes);
653        }
654        if max_result_bytes < SEARCH_MIN_RESULT_BYTES {
655            return Err(SearchLimitsError::BytesBelowMinimum {
656                actual: max_result_bytes,
657                min: SEARCH_MIN_RESULT_BYTES,
658            });
659        }
660        if max_results > SEARCH_MAX_RESULTS_CAP {
661            return Err(SearchLimitsError::ResultsOverCap {
662                actual: max_results,
663                cap: SEARCH_MAX_RESULTS_CAP,
664            });
665        }
666        if max_result_bytes > SEARCH_MAX_RESULT_BYTES_CAP {
667            return Err(SearchLimitsError::BytesOverCap {
668                actual: max_result_bytes,
669                cap: SEARCH_MAX_RESULT_BYTES_CAP,
670            });
671        }
672        Ok(Self {
673            max_results,
674            max_result_bytes,
675        })
676    }
677
678    pub fn max_results(&self) -> usize {
679        self.max_results
680    }
681
682    pub fn max_result_bytes(&self) -> usize {
683        self.max_result_bytes
684    }
685}
686
687/// Serializable source-class label. A label only — no raw runtime identity
688/// strings, handlers, or process data.
689#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
690#[serde(rename_all = "snake_case")]
691pub enum SourceClass {
692    Builtin,
693    Extension,
694    Mcp,
695    Plugin,
696    Unknown,
697}
698
699impl SourceClass {
700    fn of(source: &CapabilitySource) -> Self {
701        match source {
702            CapabilitySource::Builtin => Self::Builtin,
703            CapabilitySource::Extension { .. } => Self::Extension,
704            CapabilitySource::Mcp { .. } => Self::Mcp,
705            CapabilitySource::Plugin { .. } => Self::Plugin,
706            CapabilitySource::Unknown { .. } => Self::Unknown,
707        }
708    }
709}
710
711/// One compact, serializable discovery descriptor. By construction it holds
712/// no schema locator, no factory, no extension/MCP handler or process data,
713/// and no raw unbounded source strings — only the stable id, bounded
714/// summary/tags (already bounded at catalog time), a source-class label, and
715/// digest metadata.
716#[derive(Clone, Debug, serde::Serialize)]
717pub struct DiscoveryEntry {
718    id: ToolId,
719    source_class: SourceClass,
720    summary: String,
721    tags: Vec<String>,
722    schema_digest: SchemaDigest,
723}
724
725impl DiscoveryEntry {
726    pub fn id(&self) -> &ToolId {
727        &self.id
728    }
729
730    pub fn source_class(&self) -> SourceClass {
731        self.source_class
732    }
733
734    pub fn summary(&self) -> &str {
735        &self.summary
736    }
737
738    pub fn tags(&self) -> &[String] {
739        &self.tags
740    }
741
742    pub fn schema_digest(&self) -> &SchemaDigest {
743        &self.schema_digest
744    }
745}
746
747/// Typed failure for building a [`DiscoveryIndex`].
748#[derive(Clone, Debug, Error, PartialEq, Eq)]
749pub enum DiscoveryIndexError {
750    #[error("failed to serialize compact descriptor for {id}: {detail}")]
751    DescriptorSerialization { id: ToolId, detail: String },
752}
753
754/// Prebuilt search row: the compact entry, its exact serialized byte cost
755/// (computed once at build so budget accounting includes JSON metadata
756/// overhead), and its case-folded haystack.
757#[derive(Clone, Debug)]
758struct IndexRow {
759    entry: DiscoveryEntry,
760    serialized_bytes: usize,
761    haystack: String,
762}
763
764/// Deterministic search outcome. `truncated` is true whenever any matching
765/// entry was withheld by the count or byte budget, so callers never mistake
766/// a bounded result for the complete one.
767#[derive(Debug)]
768pub struct SearchResults<'a> {
769    generation: CatalogGeneration,
770    hits: Vec<&'a DiscoveryEntry>,
771    serialized_bytes: usize,
772    truncated: bool,
773}
774
775impl<'a> SearchResults<'a> {
776    /// The catalog generation this index snapshot was built from.
777    pub fn generation(&self) -> CatalogGeneration {
778        self.generation
779    }
780
781    pub fn hits(&self) -> &[&'a DiscoveryEntry] {
782        &self.hits
783    }
784
785    /// Exact compact-JSON byte size of the returned hit collection serialized
786    /// as an array — equal to `serde_json::to_vec(self.hits()).len()`,
787    /// including the `[`/`]` container (2 bytes when empty) and separating
788    /// commas. Always ≤ the byte budget the search ran with.
789    pub fn serialized_bytes(&self) -> usize {
790        self.serialized_bytes
791    }
792
793    pub fn truncated(&self) -> bool {
794        self.truncated
795    }
796}
797
798/// Bounded searchable snapshot of catalog compact descriptors (spec §7.1).
799///
800/// Building and searching are local and pure: no implementation acquisition,
801/// no execution, no process start, no network, no full-schema read beyond
802/// the digest already computed at catalog time, and no registry exposure
803/// change. The snapshot pins the catalog generation; callers detect drift
804/// with [`DiscoveryIndex::is_stale`] and rebuild instead of silently serving
805/// stale records.
806#[derive(Clone, Debug)]
807pub struct DiscoveryIndex {
808    generation: CatalogGeneration,
809    rows: Vec<IndexRow>,
810}
811
812impl DiscoveryIndex {
813    /// Build the index from catalog compact descriptors only. Reads id,
814    /// source class, bounded summary/tags, and schema digest — never the
815    /// schema locator or the factory.
816    pub fn build(catalog: &ToolCatalog) -> Result<Self, DiscoveryIndexError> {
817        let mut rows = Vec::with_capacity(catalog.len());
818        // `catalog.iter()` is deterministic ToolId order; rows inherit it.
819        for record in catalog.iter() {
820            let entry = DiscoveryEntry {
821                id: record.id().clone(),
822                source_class: SourceClass::of(record.source()),
823                summary: record.summary().to_string(),
824                tags: record.tags().to_vec(),
825                schema_digest: record.schema_digest().clone(),
826            };
827            let serialized_bytes = serde_json::to_vec(&entry)
828                .map_err(|err| DiscoveryIndexError::DescriptorSerialization {
829                    id: entry.id.clone(),
830                    detail: err.to_string(),
831                })?
832                .len();
833            let mut haystack = entry.id.as_str().to_lowercase();
834            haystack.push('\n');
835            haystack.push_str(&entry.summary.to_lowercase());
836            for tag in &entry.tags {
837                haystack.push('\n');
838                haystack.push_str(&tag.to_lowercase());
839            }
840            rows.push(IndexRow {
841                entry,
842                serialized_bytes,
843                haystack,
844            });
845        }
846        Ok(Self {
847            generation: catalog.generation(),
848            rows,
849        })
850    }
851
852    /// The catalog generation this snapshot was built from.
853    pub fn generation(&self) -> CatalogGeneration {
854        self.generation
855    }
856
857    /// True when the catalog has mutated since this snapshot was built. A
858    /// stale index must be rebuilt, not silently served.
859    pub fn is_stale(&self, catalog: &ToolCatalog) -> bool {
860        self.generation != catalog.generation()
861    }
862
863    /// Deterministic, bounded, pure keyword search over compact descriptors.
864    ///
865    /// A descriptor matches when its id, summary or tags contain the whole
866    /// query or any of its [`DiscoveryQuery::terms`] (case-insensitive
867    /// substrings, any order). Matches are ranked:
868    ///
869    /// 1. exact id or name: the query equals the id, the name segment, or
870    ///    spells the name's words (`web fetch` for `web_fetch`);
871    /// 2. the whole query as one substring (the pre-keyword behaviour, so a
872    ///    query that matched before still matches, ahead of partial hits);
873    /// 3. keyword hits only.
874    ///
875    /// Within a rank, more distinct terms matched comes first, then the
876    /// higher weight (a term found in the id counts 2, one found only in the
877    /// summary or tags counts 1), then ToolId order.
878    ///
879    /// Ranked matches are returned while they fit both the remaining count
880    /// budget and the remaining serialized byte budget. The byte budget
881    /// accounts the exact compact-JSON array serialization of the returned
882    /// hits — container brackets (2 bytes even when empty) plus one comma
883    /// per additional entry — so `serde_json::to_vec` of the hit collection
884    /// can never exceed it. The first ranked match that does not fit stops
885    /// the walk (documented bounded behavior; `truncated` reports it).
886    pub fn search(&self, query: &DiscoveryQuery, limits: &SearchLimits) -> SearchResults<'_> {
887        // Rows are in ToolId order and the sort is stable: ties keep it.
888        let mut ranked: Vec<(SearchRank, &IndexRow)> = self
889            .rows
890            .iter()
891            .filter_map(|row| SearchRank::of(row, query).map(|rank| (rank, row)))
892            .collect();
893        ranked.sort_by_key(|(rank, _)| *rank);
894        let mut hits = Vec::new();
895        // Serialized cost so far of `hits` as a compact JSON array: `[]` is
896        // 2 bytes; each entry adds its own bytes plus a comma when it is not
897        // the first.
898        let mut used_bytes = SEARCH_MIN_RESULT_BYTES;
899        let mut truncated = false;
900        for (_, row) in ranked {
901            let separator = usize::from(!hits.is_empty());
902            let next_bytes = used_bytes
903                .checked_add(separator)
904                .and_then(|bytes| bytes.checked_add(row.serialized_bytes));
905            let fits = match next_bytes {
906                Some(bytes) => bytes <= limits.max_result_bytes(),
907                // Overflow can only mean "far beyond any budget".
908                None => false,
909            };
910            if hits.len() == limits.max_results() || !fits {
911                truncated = true;
912                break;
913            }
914            used_bytes = next_bytes.expect("fits implies no overflow");
915            hits.push(&row.entry);
916        }
917        SearchResults {
918            generation: self.generation,
919            hits,
920            serialized_bytes: used_bytes,
921            truncated,
922        }
923    }
924}
925
926/// Sort key of one matching row (ascending = better), see
927/// [`DiscoveryIndex::search`].
928#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
929struct SearchRank {
930    /// 0 exact id/name, 1 whole-query substring, 2 keyword hits only.
931    tier: u8,
932    terms_matched: std::cmp::Reverse<usize>,
933    weight: std::cmp::Reverse<usize>,
934}
935
936impl SearchRank {
937    fn of(row: &IndexRow, query: &DiscoveryQuery) -> Option<Self> {
938        // The query is case-folded; fold the id and name the same way the
939        // haystack is (MCP and extension names may carry capitals).
940        let id = row.entry.id.as_str().to_lowercase();
941        let needle = query.needle();
942        let name = row.entry.id.name().to_lowercase();
943        let exact = needle == id || needle == name || words(needle).eq(words(&name));
944        let mut terms_matched = 0;
945        let mut weight = 0;
946        // Terms hold no whitespace or control characters, so a hit in the
947        // `\n`-joined haystack is a hit inside one field.
948        for term in query.terms() {
949            if id.contains(term.as_str()) {
950                terms_matched += 1;
951                weight += 2;
952            } else if row.haystack.contains(term.as_str()) {
953                terms_matched += 1;
954                weight += 1;
955            }
956        }
957        let tier = if exact {
958            0
959        } else if row.haystack.contains(needle) {
960            1
961        } else if terms_matched > 0 {
962            2
963        } else {
964            return None;
965        };
966        Some(Self {
967            tier,
968            terms_matched: std::cmp::Reverse(terms_matched),
969            weight: std::cmp::Reverse(weight),
970        })
971    }
972}