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}