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 /// Insert one capability. Duplicate IDs fail closed (no silent
441 /// replacement, no generation advance); generation exhaustion fails
442 /// closed (no entry change). On success the catalog generation advances
443 /// by exactly one. The record's factory is stored, never invoked.
444 pub fn insert(&mut self, record: CapabilityRecord) -> Result<(), CatalogError> {
445 if self.entries.contains_key(&record.id) {
446 return Err(CatalogError::DuplicateToolId(record.id.clone()));
447 }
448 let next = self.generation.checked_next()?;
449 self.entries.insert(record.id.clone(), record);
450 self.generation = next;
451 Ok(())
452 }
453
454 /// Insert-or-replace one capability as a single mutation, optionally
455 /// dropping the entry it shadows (registry replacement by runtime name
456 /// can change the capability identity; the stale identity must not
457 /// linger and keep validating old grants).
458 ///
459 /// Occupied-identity safety: if the incoming id is already cataloged and
460 /// it is NOT the unchanged identity of the same-runtime-name tool being
461 /// replaced (`replaced == Some(record.id)`), the mutation fails typed
462 /// with [`CatalogError::DuplicateToolId`] before any entry or generation
463 /// change — two distinct runtime names must never silently share one
464 /// capability identity, or live tools and catalog would diverge. Fails
465 /// closed without touching entries when no new generation is available.
466 pub fn upsert(
467 &mut self,
468 replaced: Option<&ToolId>,
469 record: CapabilityRecord,
470 ) -> Result<(), CatalogError> {
471 let same_identity_replacement = replaced == Some(&record.id);
472 if !same_identity_replacement && self.entries.contains_key(&record.id) {
473 return Err(CatalogError::DuplicateToolId(record.id.clone()));
474 }
475 let next = self.generation.checked_next()?;
476 if let Some(stale) = replaced {
477 if stale != &record.id {
478 self.entries.remove(stale);
479 }
480 }
481 self.entries.insert(record.id.clone(), record);
482 self.generation = next;
483 Ok(())
484 }
485
486 /// Advance strictly past both this catalog's generation and a prior
487 /// registry generation. Used when a mutation rebuilds the catalog from
488 /// scratch (e.g. `disable`): the rebuilt catalog must not reuse
489 /// generation values already observed, or stale grants could survive.
490 pub fn rebase_past(
491 &mut self,
492 prior: CatalogGeneration,
493 ) -> Result<(), CatalogGenerationOverflow> {
494 self.generation = self.generation.max(prior).checked_next()?;
495 Ok(())
496 }
497}
498
499// ── DiscoveryIndex (Task 15, spec §7.1) ─────────────────────────────────────
500
501/// Maximum UTF-8 byte length of a discovery query.
502pub const QUERY_MAX_BYTES: usize = 256;
503/// Hard cap on the per-search result count budget.
504pub const SEARCH_MAX_RESULTS_CAP: usize = 64;
505/// Hard cap on the per-search serialized byte budget (64 KiB).
506pub const SEARCH_MAX_RESULT_BYTES_CAP: usize = 64 * 1024;
507/// Minimum per-search serialized byte budget: the empty serialized result
508/// collection (`[]`) is 2 bytes, so no smaller budget can be honest.
509pub const SEARCH_MIN_RESULT_BYTES: usize = 2;
510
511/// Typed failure for boundary parsing of a [`DiscoveryQuery`].
512#[derive(Clone, Debug, Error, PartialEq, Eq)]
513pub enum DiscoveryQueryError {
514 #[error("discovery query is empty")]
515 Empty,
516 #[error("discovery query is oversized: {actual} bytes exceeds limit {limit}")]
517 Oversized { actual: usize, limit: usize },
518 #[error("discovery query contains control characters")]
519 ControlCharacters,
520}
521
522/// A validated, bounded, case-folded discovery query. Parse-at-boundary:
523/// empty (or whitespace-only), oversized, and control-character-bearing
524/// input fails typed before any search runs.
525#[derive(Clone, Debug, PartialEq, Eq)]
526pub struct DiscoveryQuery {
527 needle: String,
528}
529
530impl DiscoveryQuery {
531 pub fn parse(raw: &str) -> Result<Self, DiscoveryQueryError> {
532 if raw.len() > QUERY_MAX_BYTES {
533 return Err(DiscoveryQueryError::Oversized {
534 actual: raw.len(),
535 limit: QUERY_MAX_BYTES,
536 });
537 }
538 let trimmed = raw.trim();
539 if trimmed.is_empty() {
540 return Err(DiscoveryQueryError::Empty);
541 }
542 // Reject C0/C1/DEL control values (newline, ESC/ANSI, NUL, …) in the
543 // retained needle: the search haystack joins id/summary/tag fields
544 // with `\n`, so an embedded newline could match across field
545 // boundaries, and control bytes must never survive into logs.
546 if trimmed.chars().any(char::is_control) {
547 return Err(DiscoveryQueryError::ControlCharacters);
548 }
549 Ok(Self {
550 needle: trimmed.to_lowercase(),
551 })
552 }
553
554 /// The case-folded needle actually matched against descriptors.
555 pub fn needle(&self) -> &str {
556 &self.needle
557 }
558}
559
560/// Typed failure for constructing [`SearchLimits`]. Zero budgets would make
561/// every search vacuous; sub-minimum byte budgets could not even hold the
562/// empty serialized result collection; over-cap budgets would defeat the
563/// bound.
564#[derive(Clone, Debug, Error, PartialEq, Eq)]
565pub enum SearchLimitsError {
566 #[error("search result count budget must be positive")]
567 ZeroResults,
568 #[error("search byte budget must be positive")]
569 ZeroBytes,
570 #[error(
571 "search byte budget {actual} is below the serialized empty result collection size {min}"
572 )]
573 BytesBelowMinimum { actual: usize, min: usize },
574 #[error("search result count budget {actual} exceeds cap {cap}")]
575 ResultsOverCap { actual: usize, cap: usize },
576 #[error("search byte budget {actual} exceeds cap {cap}")]
577 BytesOverCap { actual: usize, cap: usize },
578}
579
580/// Validated positive, capped search budgets. The byte budget bounds the
581/// exact compact-JSON serialization of the returned hit collection as an
582/// array (entries plus `[`/`]` and comma overhead), so a caller can rely on
583/// `serde_json::to_vec(results.hits())` never exceeding it.
584#[derive(Clone, Copy, Debug, PartialEq, Eq)]
585pub struct SearchLimits {
586 max_results: usize,
587 max_result_bytes: usize,
588}
589
590impl SearchLimits {
591 pub fn new(max_results: usize, max_result_bytes: usize) -> Result<Self, SearchLimitsError> {
592 if max_results == 0 {
593 return Err(SearchLimitsError::ZeroResults);
594 }
595 if max_result_bytes == 0 {
596 return Err(SearchLimitsError::ZeroBytes);
597 }
598 if max_result_bytes < SEARCH_MIN_RESULT_BYTES {
599 return Err(SearchLimitsError::BytesBelowMinimum {
600 actual: max_result_bytes,
601 min: SEARCH_MIN_RESULT_BYTES,
602 });
603 }
604 if max_results > SEARCH_MAX_RESULTS_CAP {
605 return Err(SearchLimitsError::ResultsOverCap {
606 actual: max_results,
607 cap: SEARCH_MAX_RESULTS_CAP,
608 });
609 }
610 if max_result_bytes > SEARCH_MAX_RESULT_BYTES_CAP {
611 return Err(SearchLimitsError::BytesOverCap {
612 actual: max_result_bytes,
613 cap: SEARCH_MAX_RESULT_BYTES_CAP,
614 });
615 }
616 Ok(Self {
617 max_results,
618 max_result_bytes,
619 })
620 }
621
622 pub fn max_results(&self) -> usize {
623 self.max_results
624 }
625
626 pub fn max_result_bytes(&self) -> usize {
627 self.max_result_bytes
628 }
629}
630
631/// Serializable source-class label. A label only — no raw runtime identity
632/// strings, handlers, or process data.
633#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
634#[serde(rename_all = "snake_case")]
635pub enum SourceClass {
636 Builtin,
637 Extension,
638 Mcp,
639 Plugin,
640 Unknown,
641}
642
643impl SourceClass {
644 fn of(source: &CapabilitySource) -> Self {
645 match source {
646 CapabilitySource::Builtin => Self::Builtin,
647 CapabilitySource::Extension { .. } => Self::Extension,
648 CapabilitySource::Mcp { .. } => Self::Mcp,
649 CapabilitySource::Plugin { .. } => Self::Plugin,
650 CapabilitySource::Unknown { .. } => Self::Unknown,
651 }
652 }
653}
654
655/// One compact, serializable discovery descriptor. By construction it holds
656/// no schema locator, no factory, no extension/MCP handler or process data,
657/// and no raw unbounded source strings — only the stable id, bounded
658/// summary/tags (already bounded at catalog time), a source-class label, and
659/// digest metadata.
660#[derive(Clone, Debug, serde::Serialize)]
661pub struct DiscoveryEntry {
662 id: ToolId,
663 source_class: SourceClass,
664 summary: String,
665 tags: Vec<String>,
666 schema_digest: SchemaDigest,
667}
668
669impl DiscoveryEntry {
670 pub fn id(&self) -> &ToolId {
671 &self.id
672 }
673
674 pub fn source_class(&self) -> SourceClass {
675 self.source_class
676 }
677
678 pub fn summary(&self) -> &str {
679 &self.summary
680 }
681
682 pub fn tags(&self) -> &[String] {
683 &self.tags
684 }
685
686 pub fn schema_digest(&self) -> &SchemaDigest {
687 &self.schema_digest
688 }
689}
690
691/// Typed failure for building a [`DiscoveryIndex`].
692#[derive(Clone, Debug, Error, PartialEq, Eq)]
693pub enum DiscoveryIndexError {
694 #[error("failed to serialize compact descriptor for {id}: {detail}")]
695 DescriptorSerialization { id: ToolId, detail: String },
696}
697
698/// Prebuilt search row: the compact entry, its exact serialized byte cost
699/// (computed once at build so budget accounting includes JSON metadata
700/// overhead), and its case-folded haystack.
701#[derive(Clone, Debug)]
702struct IndexRow {
703 entry: DiscoveryEntry,
704 serialized_bytes: usize,
705 haystack: String,
706}
707
708/// Deterministic search outcome. `truncated` is true whenever any matching
709/// entry was withheld by the count or byte budget, so callers never mistake
710/// a bounded result for the complete one.
711#[derive(Debug)]
712pub struct SearchResults<'a> {
713 generation: CatalogGeneration,
714 hits: Vec<&'a DiscoveryEntry>,
715 serialized_bytes: usize,
716 truncated: bool,
717}
718
719impl<'a> SearchResults<'a> {
720 /// The catalog generation this index snapshot was built from.
721 pub fn generation(&self) -> CatalogGeneration {
722 self.generation
723 }
724
725 pub fn hits(&self) -> &[&'a DiscoveryEntry] {
726 &self.hits
727 }
728
729 /// Exact compact-JSON byte size of the returned hit collection serialized
730 /// as an array — equal to `serde_json::to_vec(self.hits()).len()`,
731 /// including the `[`/`]` container (2 bytes when empty) and separating
732 /// commas. Always ≤ the byte budget the search ran with.
733 pub fn serialized_bytes(&self) -> usize {
734 self.serialized_bytes
735 }
736
737 pub fn truncated(&self) -> bool {
738 self.truncated
739 }
740}
741
742/// Bounded searchable snapshot of catalog compact descriptors (spec §7.1).
743///
744/// Building and searching are local and pure: no implementation acquisition,
745/// no execution, no process start, no network, no full-schema read beyond
746/// the digest already computed at catalog time, and no registry exposure
747/// change. The snapshot pins the catalog generation; callers detect drift
748/// with [`DiscoveryIndex::is_stale`] and rebuild instead of silently serving
749/// stale records.
750#[derive(Clone, Debug)]
751pub struct DiscoveryIndex {
752 generation: CatalogGeneration,
753 rows: Vec<IndexRow>,
754}
755
756impl DiscoveryIndex {
757 /// Build the index from catalog compact descriptors only. Reads id,
758 /// source class, bounded summary/tags, and schema digest — never the
759 /// schema locator or the factory.
760 pub fn build(catalog: &ToolCatalog) -> Result<Self, DiscoveryIndexError> {
761 let mut rows = Vec::with_capacity(catalog.len());
762 // `catalog.iter()` is deterministic ToolId order; rows inherit it.
763 for record in catalog.iter() {
764 let entry = DiscoveryEntry {
765 id: record.id().clone(),
766 source_class: SourceClass::of(record.source()),
767 summary: record.summary().to_string(),
768 tags: record.tags().to_vec(),
769 schema_digest: record.schema_digest().clone(),
770 };
771 let serialized_bytes = serde_json::to_vec(&entry)
772 .map_err(|err| DiscoveryIndexError::DescriptorSerialization {
773 id: entry.id.clone(),
774 detail: err.to_string(),
775 })?
776 .len();
777 let mut haystack = entry.id.as_str().to_lowercase();
778 haystack.push('\n');
779 haystack.push_str(&entry.summary.to_lowercase());
780 for tag in &entry.tags {
781 haystack.push('\n');
782 haystack.push_str(&tag.to_lowercase());
783 }
784 rows.push(IndexRow {
785 entry,
786 serialized_bytes,
787 haystack,
788 });
789 }
790 Ok(Self {
791 generation: catalog.generation(),
792 rows,
793 })
794 }
795
796 /// The catalog generation this snapshot was built from.
797 pub fn generation(&self) -> CatalogGeneration {
798 self.generation
799 }
800
801 /// True when the catalog has mutated since this snapshot was built. A
802 /// stale index must be rebuilt, not silently served.
803 pub fn is_stale(&self, catalog: &ToolCatalog) -> bool {
804 self.generation != catalog.generation()
805 }
806
807 /// Deterministic, bounded, pure substring search over compact
808 /// descriptors. Entries are visited in ToolId order; a matching entry is
809 /// returned only if it fits both the remaining count budget and the
810 /// remaining serialized byte budget. The byte budget accounts the exact
811 /// compact-JSON array serialization of the returned hits — container
812 /// brackets (2 bytes even when empty) plus one comma per additional
813 /// entry — so `serde_json::to_vec` of the hit collection can never
814 /// exceed it. The first match that does not fit stops the scan
815 /// (documented bounded behavior; `truncated` reports it).
816 pub fn search(&self, query: &DiscoveryQuery, limits: &SearchLimits) -> SearchResults<'_> {
817 let mut hits = Vec::new();
818 // Serialized cost so far of `hits` as a compact JSON array: `[]` is
819 // 2 bytes; each entry adds its own bytes plus a comma when it is not
820 // the first.
821 let mut used_bytes = SEARCH_MIN_RESULT_BYTES;
822 let mut truncated = false;
823 for row in &self.rows {
824 if !row.haystack.contains(query.needle()) {
825 continue;
826 }
827 let separator = usize::from(!hits.is_empty());
828 let next_bytes = used_bytes
829 .checked_add(separator)
830 .and_then(|bytes| bytes.checked_add(row.serialized_bytes));
831 let fits = match next_bytes {
832 Some(bytes) => bytes <= limits.max_result_bytes(),
833 // Overflow can only mean "far beyond any budget".
834 None => false,
835 };
836 if hits.len() == limits.max_results() || !fits {
837 truncated = true;
838 break;
839 }
840 used_bytes = next_bytes.expect("fits implies no overflow");
841 hits.push(&row.entry);
842 }
843 SearchResults {
844 generation: self.generation,
845 hits,
846 serialized_bytes: used_bytes,
847 truncated,
848 }
849 }
850}