Skip to main content

lash_core/
tool_registry.rs

1#![allow(clippy::items_after_test_module)]
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::{Arc, RwLock};
5
6use serde::{Deserialize, Serialize};
7
8use crate::{
9    PreparedToolCall, ProgressSender, ToolCall, ToolContext, ToolContract, ToolManifest,
10    ToolPrepareCall, ToolProvider, ToolResult,
11};
12
13const PLUGIN_SOURCE_ID: &str = "plugins";
14
15#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct ToolSourceHandle {
18    id: String,
19}
20
21impl ToolSourceHandle {
22    pub(crate) fn new(id: impl Into<String>) -> Self {
23        Self { id: id.into() }
24    }
25
26    pub(crate) fn as_str(&self) -> &str {
27        &self.id
28    }
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize)]
32pub struct ToolStateEntry {
33    manifest: ToolManifest,
34    /// True when this tool was not resolvable from any registered source at
35    /// export time (e.g. a detached MCP server). Orphaned entries keep their
36    /// last-known manifest, surface as [`crate::ToolAvailability::Off`], and
37    /// rebind automatically when a source re-advertises the same (name, id).
38    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
39    orphaned: bool,
40}
41
42impl ToolStateEntry {
43    #[cfg(test)]
44    pub(crate) fn new(manifest: ToolManifest) -> Self {
45        Self {
46            manifest,
47            orphaned: false,
48        }
49    }
50
51    /// The manifest as exposed to callers. Orphaned entries are known to the
52    /// session but unavailable until their source returns, so their public
53    /// availability is always forced to `Off`.
54    pub fn manifest(&self) -> ToolManifest {
55        let mut manifest = self.manifest.clone();
56        if self.orphaned {
57            manifest.availability_override = Some(crate::ToolAvailability::Off);
58        }
59        manifest
60    }
61
62    fn stored_manifest(&self) -> &ToolManifest {
63        &self.manifest
64    }
65
66    pub fn is_orphaned(&self) -> bool {
67        self.orphaned
68    }
69}
70
71#[derive(Clone, Debug, Default)]
72pub struct ToolState {
73    generation: u64,
74    tools: Arc<BTreeMap<String, ToolStateEntry>>,
75}
76
77impl ToolState {
78    pub(crate) fn new(generation: u64, tools: BTreeMap<String, ToolStateEntry>) -> Self {
79        Self {
80            generation,
81            tools: Arc::new(tools),
82        }
83    }
84
85    pub fn generation(&self) -> u64 {
86        self.generation
87    }
88
89    pub fn with_generation(mut self, generation: u64) -> Self {
90        self.generation = generation;
91        self
92    }
93
94    pub fn tool_manifests(&self) -> Vec<ToolManifest> {
95        self.tools.values().map(ToolStateEntry::manifest).collect()
96    }
97
98    pub fn get(&self, name: &str) -> Option<&ToolStateEntry> {
99        self.tools.get(name)
100    }
101
102    pub fn manifest_mut(&mut self, name: &str) -> Option<&mut ToolManifest> {
103        Arc::make_mut(&mut self.tools)
104            .get_mut(name)
105            .map(|entry| &mut entry.manifest)
106    }
107
108    pub fn contains(&self, name: &str) -> bool {
109        self.tools.contains_key(name)
110    }
111
112    pub fn is_empty(&self) -> bool {
113        self.tools.is_empty()
114    }
115
116    pub fn len(&self) -> usize {
117        self.tools.len()
118    }
119
120    pub fn iter(&self) -> impl Iterator<Item = (&str, &ToolStateEntry)> {
121        self.tools
122            .iter()
123            .map(|(name, entry)| (name.as_str(), entry))
124    }
125
126    pub fn set_availability(
127        &mut self,
128        name: &str,
129        availability: Option<crate::ToolAvailability>,
130    ) -> Result<(), ReconfigureError> {
131        let Some(entry) = Arc::make_mut(&mut self.tools).get_mut(name) else {
132            return Err(ReconfigureError::Validation(format!(
133                "unknown tool `{name}`"
134            )));
135        };
136        entry.manifest.availability_override = availability;
137        Ok(())
138    }
139
140    pub fn retain(&mut self, mut keep: impl FnMut(&str, &ToolStateEntry) -> bool) {
141        Arc::make_mut(&mut self.tools).retain(|name, entry| keep(name, entry));
142    }
143
144    pub fn remove(&mut self, name: &str) -> Option<ToolStateEntry> {
145        Arc::make_mut(&mut self.tools).remove(name)
146    }
147
148    pub(crate) fn entries(&self) -> &BTreeMap<String, ToolStateEntry> {
149        self.tools.as_ref()
150    }
151}
152
153impl Serialize for ToolState {
154    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
155    where
156        S: serde::Serializer,
157    {
158        #[derive(Serialize)]
159        struct ToolStateRef<'a> {
160            generation: u64,
161            tools: &'a BTreeMap<String, ToolStateEntry>,
162        }
163
164        ToolStateRef {
165            generation: self.generation,
166            tools: self.tools.as_ref(),
167        }
168        .serialize(serializer)
169    }
170}
171
172impl<'de> Deserialize<'de> for ToolState {
173    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
174    where
175        D: serde::Deserializer<'de>,
176    {
177        #[derive(Deserialize)]
178        struct ToolStateOwned {
179            generation: u64,
180            tools: BTreeMap<String, ToolStateEntry>,
181        }
182
183        let owned = ToolStateOwned::deserialize(deserializer)?;
184        Ok(Self {
185            generation: owned.generation,
186            tools: Arc::new(owned.tools),
187        })
188    }
189}
190
191#[async_trait::async_trait]
192pub(crate) trait ToolSourceExecutor: Send + Sync + 'static {
193    fn id(&self) -> &str;
194    fn advertised_tools(&self) -> Vec<ToolManifest>;
195    fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
196        self.advertised_tools()
197            .into_iter()
198            .find(|manifest| manifest.name == name)
199    }
200    fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>>;
201    async fn prepare_tool_call(
202        &self,
203        call: ToolPrepareCall<'_>,
204    ) -> Result<PreparedToolCall, ToolResult> {
205        Ok(PreparedToolCall::identity(call.pending))
206    }
207    async fn execute(
208        &self,
209        tool: &str,
210        args: &serde_json::Value,
211        context: &ToolContext<'_>,
212        progress: Option<&ProgressSender>,
213    ) -> ToolResult;
214}
215
216struct ToolProviderSource {
217    id: String,
218    provider: Arc<dyn ToolProvider>,
219}
220
221impl ToolProviderSource {
222    fn new(id: impl Into<String>, provider: Arc<dyn ToolProvider>) -> Self {
223        Self {
224            id: id.into(),
225            provider,
226        }
227    }
228}
229
230struct ToolProviderGroupSource {
231    id: String,
232    tools: RwLock<BTreeMap<String, (ToolManifest, usize)>>,
233    providers: Vec<Arc<dyn ToolProvider>>,
234}
235
236impl ToolProviderGroupSource {
237    fn new(id: impl Into<String>, providers: Vec<Arc<dyn ToolProvider>>) -> Self {
238        let mut tools = BTreeMap::new();
239        for (provider_idx, provider) in providers.iter().enumerate() {
240            for manifest in provider.tool_manifests() {
241                tools.insert(manifest.name.clone(), (manifest, provider_idx));
242            }
243        }
244        Self {
245            id: id.into(),
246            tools: RwLock::new(tools),
247            providers,
248        }
249    }
250
251    fn read_advertised_tools(&self) -> Vec<ToolManifest> {
252        let mut tools = BTreeMap::new();
253        for (provider_idx, provider) in self.providers.iter().enumerate() {
254            for manifest in provider.tool_manifests() {
255                tools.insert(manifest.name.clone(), (manifest, provider_idx));
256            }
257        }
258        let manifests = tools
259            .values()
260            .map(|(manifest, _)| manifest.clone())
261            .collect::<Vec<_>>();
262        *self
263            .tools
264            .write()
265            .expect("tool provider group lock poisoned") = tools;
266        manifests
267    }
268
269    fn provider_index_for(&self, name: &str) -> Option<usize> {
270        self.resolve_manifest(name).and_then(|_| {
271            self.tools
272                .read()
273                .expect("tool provider group lock poisoned")
274                .get(name)
275                .map(|(_, provider_idx)| *provider_idx)
276        })
277    }
278}
279
280#[async_trait::async_trait]
281impl ToolSourceExecutor for ToolProviderGroupSource {
282    fn id(&self) -> &str {
283        &self.id
284    }
285
286    fn advertised_tools(&self) -> Vec<ToolManifest> {
287        self.read_advertised_tools()
288    }
289
290    fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
291        if let Some((manifest, _)) = self
292            .tools
293            .read()
294            .expect("tool provider group lock poisoned")
295            .get(name)
296        {
297            return Some(manifest.clone());
298        }
299        for (provider_idx, provider) in self.providers.iter().enumerate() {
300            if let Some(manifest) = provider.resolve_manifest(name) {
301                self.tools
302                    .write()
303                    .expect("tool provider group lock poisoned")
304                    .insert(name.to_string(), (manifest.clone(), provider_idx));
305                return Some(manifest);
306            }
307        }
308        None
309    }
310
311    fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
312        let provider_idx = self.provider_index_for(name)?;
313        self.providers[provider_idx].resolve_contract(name)
314    }
315
316    async fn prepare_tool_call(
317        &self,
318        call: ToolPrepareCall<'_>,
319    ) -> Result<PreparedToolCall, ToolResult> {
320        let name = call.pending.tool_name.clone();
321        let Some(provider_idx) = self.provider_index_for(&name) else {
322            return Err(ToolResult::err_fmt(format_args!("Unknown tool: {name}")));
323        };
324        self.providers[provider_idx].prepare_tool_call(call).await
325    }
326
327    async fn execute(
328        &self,
329        tool: &str,
330        args: &serde_json::Value,
331        context: &ToolContext<'_>,
332        progress: Option<&ProgressSender>,
333    ) -> ToolResult {
334        let Some(provider_idx) = self.provider_index_for(tool) else {
335            return ToolResult::err_fmt(format_args!("Unknown tool: {tool}"));
336        };
337        self.providers[provider_idx]
338            .execute(ToolCall {
339                name: tool,
340                args,
341                context,
342                progress,
343            })
344            .await
345    }
346}
347
348#[async_trait::async_trait]
349impl ToolSourceExecutor for ToolProviderSource {
350    fn id(&self) -> &str {
351        &self.id
352    }
353
354    fn advertised_tools(&self) -> Vec<ToolManifest> {
355        self.provider.tool_manifests()
356    }
357
358    fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
359        self.provider.resolve_manifest(name)
360    }
361
362    fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
363        self.provider.resolve_contract(name)
364    }
365
366    async fn prepare_tool_call(
367        &self,
368        call: ToolPrepareCall<'_>,
369    ) -> Result<PreparedToolCall, ToolResult> {
370        self.provider.prepare_tool_call(call).await
371    }
372
373    async fn execute(
374        &self,
375        tool: &str,
376        args: &serde_json::Value,
377        context: &ToolContext<'_>,
378        progress: Option<&ProgressSender>,
379    ) -> ToolResult {
380        self.provider
381            .execute(ToolCall {
382                name: tool,
383                args,
384                context,
385                progress,
386            })
387            .await
388    }
389}
390
391/// How a registry entry is connected to its tool source.
392#[derive(Clone, Debug, PartialEq, Eq)]
393enum ToolBinding {
394    /// Resolvable through the registered source with this id.
395    Bound(String),
396    /// Persisted in a session snapshot but not resolvable from any currently
397    /// registered source. Surfaces as `Off`; execution fails loudly; rebinds
398    /// when a source re-advertises the same (name, id).
399    Orphaned,
400}
401
402impl ToolBinding {
403    fn source_id(&self) -> Option<&str> {
404        match self {
405            Self::Bound(id) => Some(id),
406            Self::Orphaned => None,
407        }
408    }
409}
410
411#[derive(Clone)]
412struct ToolRegistryEntry {
413    manifest: ToolManifest,
414    binding: ToolBinding,
415}
416
417impl ToolRegistryEntry {
418    fn new(manifest: ToolManifest, source_id: impl Into<String>) -> Self {
419        Self {
420            manifest,
421            binding: ToolBinding::Bound(source_id.into()),
422        }
423    }
424
425    fn orphaned(manifest: ToolManifest) -> Self {
426        Self {
427            manifest,
428            binding: ToolBinding::Orphaned,
429        }
430    }
431
432    fn is_orphaned(&self) -> bool {
433        self.binding == ToolBinding::Orphaned
434    }
435
436    /// The manifest as exposed to surfaces, catalogs, and availability checks.
437    /// Orphaned entries are forced to `Off` in the view without mutating the
438    /// stored manifest, so the persisted override survives a later rebind and
439    /// export/restore round-trips stay byte-identical.
440    fn view_manifest(&self) -> ToolManifest {
441        let mut manifest = self.manifest.clone();
442        if self.is_orphaned() {
443            manifest.availability_override = Some(crate::ToolAvailability::Off);
444        }
445        manifest
446    }
447
448    fn export(&self) -> ToolStateEntry {
449        ToolStateEntry {
450            manifest: self.manifest.clone(),
451            orphaned: self.is_orphaned(),
452        }
453    }
454}
455
456#[derive(Clone)]
457struct ToolRegistryState {
458    generation: u64,
459    tools: BTreeMap<String, ToolRegistryEntry>,
460    next_live_source_id: u64,
461}
462
463/// Outcome of [`ToolRegistry::restore_state`]: the adopted generation plus the
464/// names of persisted tools that no registered source currently resolves.
465/// Hosts should surface a non-empty `orphaned` list to the user — the session
466/// opened, but those tools are `Off` until their source returns.
467#[derive(Clone, Debug, Default)]
468pub struct ToolRestoreReport {
469    pub generation: u64,
470    pub orphaned: Vec<String>,
471}
472
473#[derive(Debug, thiserror::Error)]
474pub enum ReconfigureError {
475    #[error("validation error: {0}")]
476    Validation(String),
477    #[error("unknown tool source: {0}")]
478    UnknownSource(String),
479    #[error("generation mismatch: expected {expected}, actual {actual}")]
480    GenerationMismatch { expected: u64, actual: u64 },
481}
482
483#[derive(Clone)]
484pub struct ToolRegistry {
485    sources: Arc<RwLock<BTreeMap<String, Arc<dyn ToolSourceExecutor>>>>,
486    state: Arc<RwLock<ToolRegistryState>>,
487}
488
489#[derive(Clone, Copy, Debug, PartialEq, Eq)]
490enum SourceReconcilePolicy {
491    RejectExternalConflicts,
492    OverlayReplacingConflicts,
493}
494
495impl ToolRegistry {
496    pub fn from_tool_provider(provider: Arc<dyn ToolProvider>) -> Result<Self, ReconfigureError> {
497        let registry = Self::empty();
498        registry.upsert_source(Arc::new(ToolProviderSource::new(
499            PLUGIN_SOURCE_ID,
500            provider,
501        )))?;
502        Ok(registry)
503    }
504
505    pub(crate) fn from_tool_providers(
506        providers: Vec<Arc<dyn ToolProvider>>,
507    ) -> Result<Self, ReconfigureError> {
508        let registry = Self::empty();
509        registry.upsert_source(Arc::new(ToolProviderGroupSource::new(
510            PLUGIN_SOURCE_ID,
511            providers,
512        )))?;
513        Ok(registry)
514    }
515
516    pub(crate) fn empty() -> Self {
517        Self {
518            sources: Arc::new(RwLock::new(BTreeMap::new())),
519            state: Arc::new(RwLock::new(ToolRegistryState {
520                generation: 0,
521                tools: BTreeMap::new(),
522                next_live_source_id: 0,
523            })),
524        }
525    }
526
527    pub fn generation(&self) -> u64 {
528        self.state
529            .read()
530            .expect("tool registry state lock poisoned")
531            .generation
532    }
533
534    pub fn export_state(&self) -> ToolState {
535        let state = self
536            .state
537            .read()
538            .expect("tool registry state lock poisoned");
539        ToolState::new(state.generation, export_tool_state_entries(&state.tools))
540    }
541
542    pub fn apply_state(&self, next: ToolState) -> Result<u64, ReconfigureError> {
543        let current_generation = self.generation();
544        if next.generation != current_generation {
545            return Err(ReconfigureError::GenerationMismatch {
546                expected: next.generation,
547                actual: current_generation,
548            });
549        }
550
551        validate_unique_manifest_entries(next.entries().values())?;
552        let rebound = {
553            let sources = self.sources.read().expect("tool source lock poisoned");
554            rebind_tool_state_entries(next.entries(), &sources, RebindMode::RejectUnresolved)?
555        };
556
557        let mut state = self
558            .state
559            .write()
560            .expect("tool registry state lock poisoned");
561        if state.generation != next.generation {
562            return Err(ReconfigureError::GenerationMismatch {
563                expected: next.generation,
564                actual: state.generation,
565            });
566        }
567        state.tools = rebound.tools;
568        state.generation += 1;
569        Ok(state.generation)
570    }
571
572    /// Restore a persisted [`ToolState`] snapshot onto a freshly-built registry,
573    /// adopting the snapshot's generation verbatim.
574    ///
575    /// Unlike [`apply_state`](Self::apply_state) — which applies an incremental
576    /// *delta* expected at the current generation and bumps it by one — a
577    /// restore reconstructs the exact persisted state regardless of the fresh
578    /// registry's base generation, and does **not** bump. This is idempotent: a
579    /// snapshot exported at generation `G` restores to generation `G`, so a
580    /// re-export round-trips. Cold rebuilds (the durable process worker, session
581    /// resume) restore a session whose tool surface reached generation `G ≥ 2`
582    /// onto a base registry at generation 1 — `apply_state` would reject that
583    /// (`expected G, actual 1`); `restore_state` adopts `G`. Entries are still
584    /// rebound to the live sources, so source identity is reconnected.
585    ///
586    /// Restore is tolerant of missing sources: a persisted tool that no current
587    /// source resolves becomes an orphaned entry (kept with its last-known
588    /// manifest, surfaced as `Off`, rebound when its source returns) instead of
589    /// failing the whole restore. Real conflicts — the name resolving with a
590    /// different id, or multiple sources claiming the same tool — still fail.
591    pub fn restore_state(
592        &self,
593        snapshot: ToolState,
594    ) -> Result<ToolRestoreReport, ReconfigureError> {
595        validate_unique_manifest_entries(snapshot.entries().values())?;
596        let rebound = {
597            let sources = self.sources.read().expect("tool source lock poisoned");
598            rebind_tool_state_entries(snapshot.entries(), &sources, RebindMode::OrphanUnresolved)?
599        };
600
601        let mut state = self
602            .state
603            .write()
604            .expect("tool registry state lock poisoned");
605        state.tools = rebound.tools;
606        state.generation = snapshot.generation();
607        Ok(ToolRestoreReport {
608            generation: state.generation,
609            orphaned: rebound.orphaned,
610        })
611    }
612
613    pub fn add_tool_provider(
614        &self,
615        provider: Arc<dyn ToolProvider>,
616    ) -> Result<ToolSourceHandle, ReconfigureError> {
617        let source_id = {
618            let mut state = self
619                .state
620                .write()
621                .expect("tool registry state lock poisoned");
622            state.next_live_source_id += 1;
623            format!("live:{}", state.next_live_source_id)
624        };
625        self.upsert_source(Arc::new(ToolProviderSource::new(
626            source_id.clone(),
627            provider,
628        )))?;
629        Ok(ToolSourceHandle::new(source_id))
630    }
631
632    pub(crate) fn compose_session_surface(
633        &self,
634        include_base_tools: bool,
635        context_providers: Vec<Arc<dyn ToolProvider>>,
636    ) -> Result<Self, ReconfigureError> {
637        let registry = if include_base_tools {
638            self.fork_with_state(self.export_state())?
639        } else {
640            Self::empty()
641        };
642        registry.upsert_overlay_source(Arc::new(ToolProviderGroupSource::new(
643            "context",
644            context_providers,
645        )))?;
646        Ok(registry)
647    }
648
649    pub(crate) fn upsert_source(
650        &self,
651        source: Arc<dyn ToolSourceExecutor>,
652    ) -> Result<u64, ReconfigureError> {
653        self.reconcile_source(source, SourceReconcilePolicy::RejectExternalConflicts)
654    }
655
656    fn upsert_overlay_source(
657        &self,
658        source: Arc<dyn ToolSourceExecutor>,
659    ) -> Result<u64, ReconfigureError> {
660        self.reconcile_source(source, SourceReconcilePolicy::OverlayReplacingConflicts)
661    }
662
663    fn reconcile_source(
664        &self,
665        source: Arc<dyn ToolSourceExecutor>,
666        policy: SourceReconcilePolicy,
667    ) -> Result<u64, ReconfigureError> {
668        let source_id = source.id().to_string();
669        let advertised_tools = source
670            .advertised_tools()
671            .into_iter()
672            .map(|manifest| manifest_with_compact_contract(source.as_ref(), manifest))
673            .collect::<Vec<_>>();
674        validate_unique_manifests(&advertised_tools)?;
675
676        let advertised_names = advertised_tools
677            .iter()
678            .map(|manifest| manifest.name.clone())
679            .collect::<BTreeSet<_>>();
680        let advertised_ids = advertised_tools
681            .iter()
682            .map(|manifest| manifest.id.clone())
683            .collect::<BTreeSet<_>>();
684        let mut state = self
685            .state
686            .write()
687            .expect("tool registry state lock poisoned");
688        let previous_overrides = state
689            .tools
690            .iter()
691            .map(|(name, entry)| (name.clone(), entry.manifest.availability_override))
692            .collect::<BTreeMap<_, _>>();
693        match policy {
694            SourceReconcilePolicy::RejectExternalConflicts => {
695                // Orphans never conflict: a live advertisement supersedes a
696                // tool that is currently unresolvable. Matching (name, id)
697                // means the source came back — the entry rebinds below, with
698                // its availability override preserved via `previous_overrides`.
699                for manifest in &advertised_tools {
700                    if let Some(existing) = state.tools.get(&manifest.name)
701                        && let Some(existing_source) = existing.binding.source_id()
702                        && existing_source != source_id
703                    {
704                        return Err(ReconfigureError::Validation(format!(
705                            "duplicate tool name `{}` from source `{}` conflicts with source `{}`",
706                            manifest.name, source_id, existing_source
707                        )));
708                    }
709                    if let Some((existing_name, existing_source)) =
710                        state.tools.iter().find_map(|(name, entry)| {
711                            let existing_source = entry.binding.source_id()?;
712                            (existing_source != source_id && entry.manifest.id == manifest.id)
713                                .then(|| (name.clone(), existing_source.to_string()))
714                        })
715                    {
716                        return Err(ReconfigureError::Validation(format!(
717                            "duplicate tool id `{}` from source `{}` conflicts with tool `{}` from source `{}`",
718                            manifest.id, source_id, existing_name, existing_source
719                        )));
720                    }
721                }
722                state.tools.retain(|name, entry| match &entry.binding {
723                    // Drop this source's previous surface; it is re-inserted
724                    // from the fresh advertisement below.
725                    ToolBinding::Bound(bound) => bound != &source_id,
726                    // Drop orphans the fresh advertisement supersedes.
727                    ToolBinding::Orphaned => {
728                        !advertised_names.contains(name)
729                            && !advertised_ids.contains(&entry.manifest.id)
730                    }
731                });
732            }
733            SourceReconcilePolicy::OverlayReplacingConflicts => {
734                state.tools.retain(|name, entry| {
735                    entry.binding.source_id() != Some(source_id.as_str())
736                        && !advertised_names.contains(name)
737                        && !advertised_ids.contains(&entry.manifest.id)
738                });
739            }
740        }
741        for mut manifest in advertised_tools {
742            let name = manifest.name.clone();
743            manifest.availability_override = previous_overrides
744                .get(&name)
745                .copied()
746                .flatten()
747                .or(manifest.availability_override);
748            state
749                .tools
750                .insert(name, ToolRegistryEntry::new(manifest, source_id.clone()));
751        }
752        self.sources
753            .write()
754            .expect("tool source lock poisoned")
755            .insert(source_id, source);
756        state.generation += 1;
757        Ok(state.generation)
758    }
759
760    pub fn remove_source(&self, handle: &ToolSourceHandle) -> Result<u64, ReconfigureError> {
761        self.remove_source_id(handle.as_str())
762    }
763
764    pub fn refresh_sources(&self) -> Result<u64, ReconfigureError> {
765        let sources = self
766            .sources
767            .read()
768            .expect("tool source lock poisoned")
769            .values()
770            .cloned()
771            .collect::<Vec<_>>();
772        let mut generation = self.generation();
773        for source in sources {
774            generation = self.upsert_source(source)?;
775        }
776        Ok(generation)
777    }
778
779    pub(crate) fn remove_source_id(&self, source_id: &str) -> Result<u64, ReconfigureError> {
780        {
781            let mut sources = self.sources.write().expect("tool source lock poisoned");
782            if sources.remove(source_id).is_none() {
783                return Err(ReconfigureError::UnknownSource(source_id.to_string()));
784            }
785        }
786        let mut state = self
787            .state
788            .write()
789            .expect("tool registry state lock poisoned");
790        state
791            .tools
792            .retain(|_, entry| entry.binding.source_id() != Some(source_id));
793        state.generation += 1;
794        Ok(state.generation)
795    }
796
797    pub(crate) fn fork_with_state(&self, snapshot: ToolState) -> Result<Self, ReconfigureError> {
798        let sources = self
799            .sources
800            .read()
801            .expect("tool source lock poisoned")
802            .iter()
803            .map(|(k, v)| (k.clone(), Arc::clone(v)))
804            .collect::<BTreeMap<_, _>>();
805        validate_unique_manifest_entries(snapshot.entries().values())?;
806        // Tolerant like `restore_state`: a fork mirrors whatever the parent
807        // registry holds, including orphans whose sources are still absent.
808        let rebound =
809            rebind_tool_state_entries(snapshot.entries(), &sources, RebindMode::OrphanUnresolved)?;
810        let generation = snapshot.generation.max(1);
811        Ok(Self {
812            sources: Arc::new(RwLock::new(sources)),
813            state: Arc::new(RwLock::new(ToolRegistryState {
814                generation,
815                tools: rebound.tools,
816                next_live_source_id: 0,
817            })),
818        })
819    }
820}
821
822impl ToolRegistry {
823    /// Try to rebind an orphaned entry to a source that now resolves the same
824    /// (name, id). Returns the live manifest on success. A source resolving
825    /// the name with a *different* id is a replaced implementation — the
826    /// orphan keeps the name and stays unavailable rather than silently
827    /// swapping semantics under the session.
828    fn try_rebind_orphan(&self, name: &str, orphan_id: &crate::ToolId) -> Option<ToolManifest> {
829        let sources = self
830            .sources
831            .read()
832            .expect("tool source lock poisoned")
833            .iter()
834            .map(|(source_id, source)| (source_id.clone(), Arc::clone(source)))
835            .collect::<Vec<_>>();
836        for (source_id, source) in sources {
837            let Some(manifest) = source.resolve_manifest(name) else {
838                continue;
839            };
840            if manifest.id != *orphan_id {
841                continue;
842            }
843            let mut manifest = manifest_with_compact_contract(source.as_ref(), manifest);
844            let mut state = self
845                .state
846                .write()
847                .expect("tool registry state lock poisoned");
848            let existing = state.tools.get(name)?;
849            if !existing.is_orphaned() {
850                return Some(existing.view_manifest());
851            }
852            manifest.availability_override = existing
853                .manifest
854                .availability_override
855                .or(manifest.availability_override);
856            state.tools.insert(
857                name.to_string(),
858                ToolRegistryEntry::new(manifest.clone(), source_id),
859            );
860            state.generation += 1;
861            return Some(manifest);
862        }
863        None
864    }
865
866    /// Resolve the source for a registry entry, distinguishing "unknown tool"
867    /// from "known but orphaned" so callers can fail with a precise error.
868    fn resolve_execution_source(
869        &self,
870        name: &str,
871    ) -> Result<Arc<dyn ToolSourceExecutor>, ToolResult> {
872        if self.resolve_manifest(name).is_none() {
873            return Err(ToolResult::err_fmt(format_args!("Unknown tool: {name}")));
874        }
875        let binding = {
876            let state = self
877                .state
878                .read()
879                .expect("tool registry state lock poisoned");
880            state.tools.get(name).map(|entry| entry.binding.clone())
881        };
882        let source_id = match binding {
883            Some(ToolBinding::Bound(source_id)) => source_id,
884            Some(ToolBinding::Orphaned) => {
885                return Err(ToolResult::err_fmt(format_args!(
886                    "Tool `{name}` is unavailable: it was restored from a persisted session \
887                     but its source is not currently registered"
888                )));
889            }
890            None => return Err(ToolResult::err_fmt(format_args!("Unknown tool: {name}"))),
891        };
892        let source = {
893            self.sources
894                .read()
895                .expect("tool source lock poisoned")
896                .get(&source_id)
897                .cloned()
898        };
899        source.ok_or_else(|| {
900            ToolResult::err_fmt(format_args!("Tool source missing for tool `{name}`"))
901        })
902    }
903}
904
905#[async_trait::async_trait]
906impl ToolProvider for ToolRegistry {
907    fn tool_manifests(&self) -> Vec<ToolManifest> {
908        let state = self
909            .state
910            .read()
911            .expect("tool registry state lock poisoned");
912        state
913            .tools
914            .values()
915            .map(ToolRegistryEntry::view_manifest)
916            .collect()
917    }
918
919    fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
920        enum Known {
921            Bound(ToolManifest),
922            Orphaned(ToolManifest, crate::ToolId),
923        }
924        let known = {
925            let state = self
926                .state
927                .read()
928                .expect("tool registry state lock poisoned");
929            state.tools.get(name).map(|entry| {
930                if entry.is_orphaned() {
931                    Known::Orphaned(entry.view_manifest(), entry.manifest.id.clone())
932                } else {
933                    Known::Bound(entry.manifest.clone())
934                }
935            })
936        };
937        match known {
938            Some(Known::Bound(manifest)) => return Some(manifest),
939            Some(Known::Orphaned(off_manifest, orphan_id)) => {
940                // A source may have come back since the restore: rebind on
941                // (name, id) match, otherwise keep serving the Off view.
942                return Some(
943                    self.try_rebind_orphan(name, &orphan_id)
944                        .unwrap_or(off_manifest),
945                );
946            }
947            None => {}
948        }
949
950        let sources = self
951            .sources
952            .read()
953            .expect("tool source lock poisoned")
954            .iter()
955            .map(|(source_id, source)| (source_id.clone(), Arc::clone(source)))
956            .collect::<Vec<_>>();
957        for (source_id, source) in sources {
958            let Some(manifest) = source.resolve_manifest(name) else {
959                continue;
960            };
961            let mut manifest = manifest_with_compact_contract(source.as_ref(), manifest);
962            let previous_override = {
963                let state = self
964                    .state
965                    .read()
966                    .expect("tool registry state lock poisoned");
967                state
968                    .tools
969                    .get(&manifest.name)
970                    .and_then(|entry| entry.manifest.availability_override)
971            };
972            manifest.availability_override = previous_override.or(manifest.availability_override);
973            let mut state = self
974                .state
975                .write()
976                .expect("tool registry state lock poisoned");
977            if let Some(existing) = state.tools.get(&manifest.name) {
978                return (existing.binding.source_id() == Some(source_id.as_str()))
979                    .then(|| existing.view_manifest());
980            }
981            if let Some((_, existing)) = state
982                .tools
983                .iter()
984                .find(|(_, entry)| entry.manifest.id == manifest.id)
985            {
986                return (existing.binding.source_id() == Some(source_id.as_str()))
987                    .then(|| existing.view_manifest());
988            }
989            state.tools.insert(
990                manifest.name.clone(),
991                ToolRegistryEntry::new(manifest.clone(), source_id),
992            );
993            state.generation += 1;
994            return Some(manifest);
995        }
996        None
997    }
998
999    fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1000        let source_id = self.resolve_manifest(name).and_then(|_| {
1001            let state = self
1002                .state
1003                .read()
1004                .expect("tool registry state lock poisoned");
1005            state
1006                .tools
1007                .get(name)
1008                .and_then(|entry| entry.binding.source_id().map(str::to_string))
1009        })?;
1010        self.sources
1011            .read()
1012            .expect("tool source lock poisoned")
1013            .get(&source_id)?
1014            .resolve_contract(name)
1015    }
1016
1017    async fn prepare_tool_call(
1018        &self,
1019        call: ToolPrepareCall<'_>,
1020    ) -> Result<PreparedToolCall, ToolResult> {
1021        let name = call.pending.tool_name.clone();
1022        let source = self.resolve_execution_source(&name)?;
1023        source.prepare_tool_call(call).await
1024    }
1025
1026    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
1027        let name = call.name;
1028        let source = match self.resolve_execution_source(name) {
1029            Ok(source) => source,
1030            Err(result) => return result,
1031        };
1032        source
1033            .execute(name, call.args, call.context, call.progress)
1034            .await
1035    }
1036}
1037
1038fn validate_unique_manifests(manifests: &[ToolManifest]) -> Result<(), ReconfigureError> {
1039    let mut names = BTreeSet::new();
1040    let mut ids = BTreeSet::new();
1041    for manifest in manifests {
1042        if manifest.id.as_str().trim().is_empty() {
1043            return Err(ReconfigureError::Validation(
1044                "tool id cannot be empty".to_string(),
1045            ));
1046        }
1047        if !ids.insert(manifest.id.clone()) {
1048            return Err(ReconfigureError::Validation(format!(
1049                "duplicate tool id `{}` in source",
1050                manifest.id
1051            )));
1052        }
1053        if manifest.name.trim().is_empty() {
1054            return Err(ReconfigureError::Validation(
1055                "tool name cannot be empty".to_string(),
1056            ));
1057        }
1058        if !names.insert(manifest.name.clone()) {
1059            return Err(ReconfigureError::Validation(format!(
1060                "duplicate tool name `{}` in source",
1061                manifest.name
1062            )));
1063        }
1064    }
1065    Ok(())
1066}
1067
1068fn manifest_with_compact_contract(
1069    source: &dyn ToolSourceExecutor,
1070    mut manifest: ToolManifest,
1071) -> ToolManifest {
1072    if manifest.compact_contract.is_none()
1073        && let Some(contract) = source.resolve_contract(&manifest.name)
1074    {
1075        manifest.compact_contract = Some(contract.compact_contract(&manifest));
1076    }
1077    manifest
1078}
1079
1080fn export_tool_state_entries(
1081    entries: &BTreeMap<String, ToolRegistryEntry>,
1082) -> BTreeMap<String, ToolStateEntry> {
1083    entries
1084        .iter()
1085        .map(|(name, entry)| (name.clone(), entry.export()))
1086        .collect()
1087}
1088
1089/// How [`rebind_tool_state_entries`] treats a persisted tool that no
1090/// registered source resolves by name.
1091#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1092enum RebindMode {
1093    /// Restore/fork: keep the entry as an orphan instead of failing. Sessions
1094    /// must stay openable when a dynamic source (e.g. an MCP server) is down.
1095    OrphanUnresolved,
1096    /// Explicit `apply_state`: fail on unresolved tools, except entries the
1097    /// snapshot already marks as orphaned — otherwise a host could never
1098    /// round-trip `export_state` → edit → `apply_state` while an orphan exists.
1099    RejectUnresolved,
1100}
1101
1102struct ReboundTools {
1103    tools: BTreeMap<String, ToolRegistryEntry>,
1104    orphaned: Vec<String>,
1105}
1106
1107fn rebind_tool_state_entries(
1108    entries: &BTreeMap<String, ToolStateEntry>,
1109    sources: &BTreeMap<String, Arc<dyn ToolSourceExecutor>>,
1110    mode: RebindMode,
1111) -> Result<ReboundTools, ReconfigureError> {
1112    let mut rebound = BTreeMap::new();
1113    let mut orphaned = Vec::new();
1114    for (name, entry) in entries {
1115        if name != &entry.manifest.name {
1116            return Err(ReconfigureError::Validation(format!(
1117                "tool state key `{}` does not match manifest name `{}`",
1118                name, entry.manifest.name
1119            )));
1120        }
1121
1122        let mut name_matches = Vec::new();
1123        for (source_id, source) in sources {
1124            let Some(manifest) = source.resolve_manifest(name) else {
1125                continue;
1126            };
1127            name_matches.push((
1128                source_id.clone(),
1129                manifest_with_compact_contract(source.as_ref(), manifest),
1130            ));
1131        }
1132
1133        if name_matches.is_empty() {
1134            if mode == RebindMode::RejectUnresolved && !entry.orphaned {
1135                return Err(ReconfigureError::Validation(format!(
1136                    "no registered tool source resolves tool `{name}`"
1137                )));
1138            }
1139            orphaned.push(name.clone());
1140            rebound.insert(
1141                name.clone(),
1142                ToolRegistryEntry::orphaned(entry.manifest.clone()),
1143            );
1144            continue;
1145        }
1146
1147        let matching_id = name_matches
1148            .iter()
1149            .filter(|(_, manifest)| manifest.id == entry.manifest.id)
1150            .collect::<Vec<_>>();
1151
1152        if matching_id.len() == 1 {
1153            let source_id = matching_id[0].0.clone();
1154            rebound.insert(
1155                name.clone(),
1156                ToolRegistryEntry::new(entry.manifest.clone(), source_id),
1157            );
1158        } else if matching_id.is_empty() {
1159            let resolved_ids = name_matches
1160                .iter()
1161                .map(|(_, manifest)| manifest.id.as_str())
1162                .collect::<Vec<_>>()
1163                .join(", ");
1164            return Err(ReconfigureError::Validation(format!(
1165                "tool `{name}` resolved with id(s) `{resolved_ids}`, expected `{}`",
1166                entry.manifest.id
1167            )));
1168        } else {
1169            return Err(ReconfigureError::Validation(format!(
1170                "tool `{name}` with id `{}` is resolved by multiple registered sources",
1171                entry.manifest.id
1172            )));
1173        }
1174    }
1175    Ok(ReboundTools {
1176        tools: rebound,
1177        orphaned,
1178    })
1179}
1180
1181fn validate_unique_manifest_entries<'a>(
1182    entries: impl IntoIterator<Item = &'a ToolStateEntry>,
1183) -> Result<(), ReconfigureError> {
1184    let manifests = entries
1185        .into_iter()
1186        .map(|entry| entry.stored_manifest().clone())
1187        .collect::<Vec<_>>();
1188    validate_unique_manifests(&manifests)
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194    use crate::ToolDefinition;
1195    use serde_json::json;
1196    use std::sync::atomic::{AtomicUsize, Ordering};
1197
1198    struct MockTool;
1199    struct MixedEnabledTool;
1200    struct ExternalMockSource;
1201    struct ExactResolvingSource {
1202        manifest_resolutions: Arc<AtomicUsize>,
1203        contract_resolutions: Arc<AtomicUsize>,
1204        executions: Arc<AtomicUsize>,
1205    }
1206    struct NamedExactSource {
1207        id: &'static str,
1208    }
1209    struct DynamicToolProvider {
1210        names: Arc<std::sync::Mutex<Vec<String>>>,
1211    }
1212
1213    fn test_tool(
1214        name: &str,
1215        description: &str,
1216        availability: crate::ToolAvailabilityConfig,
1217    ) -> ToolDefinition {
1218        ToolDefinition::raw_with_id(
1219            format!("tool:{name}"),
1220            name,
1221            description,
1222            ToolDefinition::default_input_schema(),
1223            json!({ "type": "string" }),
1224        )
1225        .with_availability(availability)
1226    }
1227
1228    fn manifests(definitions: Vec<ToolDefinition>) -> Vec<ToolManifest> {
1229        definitions
1230            .into_iter()
1231            .map(|tool| tool.manifest())
1232            .collect()
1233    }
1234
1235    fn contract_from(definitions: Vec<ToolDefinition>, name: &str) -> Option<Arc<ToolContract>> {
1236        definitions
1237            .into_iter()
1238            .find(|tool| tool.name() == name)
1239            .map(|tool| Arc::new(tool.contract()))
1240    }
1241
1242    fn dynamic_definition(name: &str) -> ToolDefinition {
1243        test_tool(name, "dynamic", crate::ToolAvailabilityConfig::callable())
1244    }
1245
1246    fn test_tool_context() -> crate::ToolContext<'static> {
1247        crate::ToolContext::builder(
1248            "registry-test".to_string(),
1249            Arc::new(crate::testing::MockSessionManager::default()),
1250            Arc::new(crate::testing::MockSessionManager::default()),
1251            Arc::new(crate::testing::MockSessionManager::default()),
1252            Arc::new(crate::UnavailableProcessService),
1253            Arc::new(crate::DefaultProcessCancelAbility),
1254            crate::runtime::RuntimeEffectControllerHandle::shared(Arc::new(
1255                crate::InlineRuntimeEffectController,
1256            )),
1257            Arc::new(crate::InMemoryAttachmentStore::new()),
1258            crate::DirectCompletionClient::unavailable(
1259                "direct completions are unavailable in this test context",
1260            ),
1261        )
1262        .build()
1263    }
1264
1265    #[async_trait::async_trait]
1266    impl ToolProvider for MockTool {
1267        fn tool_manifests(&self) -> Vec<ToolManifest> {
1268            manifests(vec![test_tool(
1269                "mock_tool",
1270                "mock",
1271                crate::ToolAvailabilityConfig::callable(),
1272            )])
1273        }
1274
1275        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1276            contract_from(
1277                vec![test_tool(
1278                    "mock_tool",
1279                    "mock",
1280                    crate::ToolAvailabilityConfig::callable(),
1281                )],
1282                name,
1283            )
1284        }
1285
1286        async fn execute(&self, _call: ToolCall<'_>) -> ToolResult {
1287            ToolResult::ok(serde_json::json!("ok"))
1288        }
1289    }
1290
1291    #[async_trait::async_trait]
1292    impl ToolProvider for MixedEnabledTool {
1293        fn tool_manifests(&self) -> Vec<ToolManifest> {
1294            manifests(vec![
1295                test_tool(
1296                    "enabled_tool",
1297                    "enabled",
1298                    crate::ToolAvailabilityConfig::callable(),
1299                ),
1300                test_tool(
1301                    "disabled_tool",
1302                    "disabled",
1303                    crate::ToolAvailabilityConfig::off(),
1304                ),
1305            ])
1306        }
1307
1308        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1309            contract_from(
1310                vec![
1311                    test_tool(
1312                        "enabled_tool",
1313                        "enabled",
1314                        crate::ToolAvailabilityConfig::callable(),
1315                    ),
1316                    test_tool(
1317                        "disabled_tool",
1318                        "disabled",
1319                        crate::ToolAvailabilityConfig::off(),
1320                    ),
1321                ],
1322                name,
1323            )
1324        }
1325
1326        async fn execute(&self, _call: ToolCall<'_>) -> ToolResult {
1327            ToolResult::ok(serde_json::json!("ok"))
1328        }
1329    }
1330
1331    #[async_trait::async_trait]
1332    impl ToolSourceExecutor for ExternalMockSource {
1333        fn id(&self) -> &str {
1334            "external"
1335        }
1336
1337        fn advertised_tools(&self) -> Vec<ToolManifest> {
1338            manifests(vec![ToolDefinition::raw_with_id(
1339                "tool:mcp__demo__search",
1340                "mcp__demo__search",
1341                "search",
1342                json!({
1343                    "type": "object",
1344                    "properties": {
1345                        "query": { "type": "string" }
1346                    },
1347                    "required": ["query"],
1348                    "additionalProperties": false
1349                }),
1350                json!({ "type": "object", "additionalProperties": true }),
1351            )])
1352        }
1353
1354        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1355            contract_from(
1356                vec![ToolDefinition::raw_with_id(
1357                    "tool:mcp__demo__search",
1358                    "mcp__demo__search",
1359                    "search",
1360                    json!({
1361                        "type": "object",
1362                        "properties": {
1363                            "query": { "type": "string" }
1364                        },
1365                        "required": ["query"],
1366                        "additionalProperties": false
1367                    }),
1368                    json!({ "type": "object", "additionalProperties": true }),
1369                )],
1370                name,
1371            )
1372        }
1373
1374        async fn execute(
1375            &self,
1376            tool: &str,
1377            args: &serde_json::Value,
1378            _context: &ToolContext<'_>,
1379            _progress: Option<&ProgressSender>,
1380        ) -> ToolResult {
1381            ToolResult::ok(json!({
1382                "tool": tool,
1383                "args": args
1384            }))
1385        }
1386    }
1387
1388    #[async_trait::async_trait]
1389    impl ToolSourceExecutor for ExactResolvingSource {
1390        fn id(&self) -> &str {
1391            "exact"
1392        }
1393
1394        fn advertised_tools(&self) -> Vec<ToolManifest> {
1395            Vec::new()
1396        }
1397
1398        fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
1399            self.manifest_resolutions.fetch_add(1, Ordering::SeqCst);
1400            (name == "host_only").then(|| {
1401                test_tool(
1402                    "host_only",
1403                    "host-only",
1404                    crate::ToolAvailabilityConfig::callable(),
1405                )
1406                .manifest()
1407            })
1408        }
1409
1410        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1411            self.contract_resolutions.fetch_add(1, Ordering::SeqCst);
1412            contract_from(
1413                vec![test_tool(
1414                    "host_only",
1415                    "host-only",
1416                    crate::ToolAvailabilityConfig::callable(),
1417                )],
1418                name,
1419            )
1420        }
1421
1422        async fn execute(
1423            &self,
1424            tool: &str,
1425            _args: &serde_json::Value,
1426            _context: &ToolContext<'_>,
1427            _progress: Option<&ProgressSender>,
1428        ) -> ToolResult {
1429            self.executions.fetch_add(1, Ordering::SeqCst);
1430            ToolResult::ok(json!(tool))
1431        }
1432    }
1433
1434    #[async_trait::async_trait]
1435    impl ToolSourceExecutor for NamedExactSource {
1436        fn id(&self) -> &str {
1437            self.id
1438        }
1439
1440        fn advertised_tools(&self) -> Vec<ToolManifest> {
1441            Vec::new()
1442        }
1443
1444        fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
1445            (name == "host_only").then(|| {
1446                test_tool(
1447                    "host_only",
1448                    "host-only",
1449                    crate::ToolAvailabilityConfig::callable(),
1450                )
1451                .manifest()
1452            })
1453        }
1454
1455        fn resolve_contract(&self, _name: &str) -> Option<Arc<ToolContract>> {
1456            None
1457        }
1458
1459        async fn execute(
1460            &self,
1461            tool: &str,
1462            _args: &serde_json::Value,
1463            _context: &ToolContext<'_>,
1464            _progress: Option<&ProgressSender>,
1465        ) -> ToolResult {
1466            ToolResult::ok(json!(tool))
1467        }
1468    }
1469
1470    #[async_trait::async_trait]
1471    impl ToolProvider for DynamicToolProvider {
1472        fn tool_manifests(&self) -> Vec<ToolManifest> {
1473            self.names
1474                .lock()
1475                .expect("dynamic tool names lock")
1476                .iter()
1477                .map(|name| dynamic_definition(name).manifest())
1478                .collect()
1479        }
1480
1481        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1482            self.names
1483                .lock()
1484                .expect("dynamic tool names lock")
1485                .iter()
1486                .any(|tool_name| tool_name == name)
1487                .then(|| Arc::new(dynamic_definition(name).contract()))
1488        }
1489
1490        async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
1491            ToolResult::ok(json!(call.name))
1492        }
1493    }
1494
1495    #[test]
1496    fn registry_preserves_initial_availability_state() {
1497        let registry =
1498            ToolRegistry::from_tool_provider(Arc::new(MixedEnabledTool)).expect("registry");
1499        let snapshot = registry.export_state();
1500        assert_eq!(
1501            snapshot
1502                .get("enabled_tool")
1503                .unwrap()
1504                .manifest()
1505                .effective_availability(),
1506            crate::ToolAvailability::Callable
1507        );
1508        assert_eq!(
1509            snapshot
1510                .get("disabled_tool")
1511                .unwrap()
1512                .manifest()
1513                .effective_availability(),
1514            crate::ToolAvailability::Off
1515        );
1516    }
1517
1518    #[test]
1519    fn exported_tool_state_is_source_free() {
1520        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1521        registry
1522            .add_tool_provider(Arc::new(MixedEnabledTool))
1523            .expect("live provider registered");
1524
1525        let value = serde_json::to_value(registry.export_state()).expect("serialized tool state");
1526        let serialized = value.to_string();
1527
1528        assert!(!serialized.contains("source_id"));
1529        assert!(!serialized.contains(PLUGIN_SOURCE_ID));
1530        assert!(!serialized.contains("live:"));
1531    }
1532
1533    #[test]
1534    fn apply_state_rebinds_source_free_snapshot_to_current_sources() {
1535        let source_registry =
1536            ToolRegistry::from_tool_provider(Arc::new(MixedEnabledTool)).expect("source registry");
1537        let snapshot = source_registry.export_state();
1538
1539        let target_registry =
1540            ToolRegistry::from_tool_provider(Arc::new(MixedEnabledTool)).expect("target registry");
1541        let next_generation = target_registry
1542            .apply_state(snapshot.with_generation(target_registry.generation()))
1543            .expect("state rebound");
1544
1545        assert_eq!(next_generation, target_registry.generation());
1546        assert!(target_registry.resolve_contract("enabled_tool").is_some());
1547    }
1548
1549    #[test]
1550    fn apply_state_rejects_tools_not_advertised_by_source() {
1551        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1552        let snapshot = registry.export_state();
1553        let generation = snapshot.generation();
1554        let mut tools = snapshot.entries().clone();
1555        tools.insert(
1556            "missing".to_string(),
1557            ToolStateEntry::new(
1558                test_tool(
1559                    "missing",
1560                    "missing",
1561                    crate::ToolAvailabilityConfig::callable(),
1562                )
1563                .manifest(),
1564            ),
1565        );
1566        let snapshot = ToolState::new(generation, tools);
1567        assert!(matches!(
1568            registry.apply_state(snapshot),
1569            Err(ReconfigureError::Validation(_))
1570        ));
1571    }
1572
1573    #[test]
1574    fn apply_state_rejects_snapshot_when_provider_is_absent() {
1575        let source_registry =
1576            ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("source registry");
1577        source_registry
1578            .upsert_source(Arc::new(ExternalMockSource))
1579            .expect("source registered");
1580        let snapshot = source_registry.export_state();
1581
1582        let target_registry =
1583            ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target registry");
1584        let err = target_registry
1585            .apply_state(snapshot.with_generation(target_registry.generation()))
1586            .expect_err("missing provider should fail");
1587
1588        assert!(matches!(err, ReconfigureError::Validation(_)));
1589    }
1590
1591    #[test]
1592    fn apply_state_rejects_ambiguous_current_source_binding() {
1593        let registry = ToolRegistry::empty();
1594        registry
1595            .upsert_source(Arc::new(NamedExactSource { id: "exact-a" }))
1596            .expect("source a registered");
1597        registry
1598            .upsert_source(Arc::new(NamedExactSource { id: "exact-b" }))
1599            .expect("source b registered");
1600
1601        let mut tools = BTreeMap::new();
1602        tools.insert(
1603            "host_only".to_string(),
1604            ToolStateEntry::new(
1605                test_tool(
1606                    "host_only",
1607                    "host-only",
1608                    crate::ToolAvailabilityConfig::callable(),
1609                )
1610                .manifest(),
1611            ),
1612        );
1613
1614        let err = registry
1615            .apply_state(ToolState::new(registry.generation(), tools))
1616            .expect_err("ambiguous source binding should fail");
1617
1618        assert!(matches!(err, ReconfigureError::Validation(_)));
1619    }
1620
1621    #[test]
1622    fn advertised_manifest_resolves_without_exact_host_lookup() {
1623        let manifest_resolutions = Arc::new(AtomicUsize::new(0));
1624        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1625        registry
1626            .upsert_source(Arc::new(ExactResolvingSource {
1627                manifest_resolutions: Arc::clone(&manifest_resolutions),
1628                contract_resolutions: Arc::new(AtomicUsize::new(0)),
1629                executions: Arc::new(AtomicUsize::new(0)),
1630            }))
1631            .expect("source registered");
1632
1633        assert_eq!(
1634            registry
1635                .resolve_manifest("mock_tool")
1636                .map(|manifest| manifest.name),
1637            Some("mock_tool".to_string())
1638        );
1639        assert_eq!(manifest_resolutions.load(Ordering::SeqCst), 0);
1640    }
1641
1642    #[test]
1643    fn refresh_sources_re_reads_group_provider_manifests() {
1644        let names = Arc::new(std::sync::Mutex::new(vec!["dynamic_one".to_string()]));
1645        let provider: Arc<dyn ToolProvider> = Arc::new(DynamicToolProvider {
1646            names: Arc::clone(&names),
1647        });
1648        let registry = ToolRegistry::from_tool_providers(vec![provider]).expect("registry");
1649
1650        let tool_names = || {
1651            registry
1652                .tool_manifests()
1653                .into_iter()
1654                .map(|manifest| manifest.name)
1655                .collect::<BTreeSet<_>>()
1656        };
1657
1658        assert!(tool_names().contains("dynamic_one"));
1659        assert!(!tool_names().contains("dynamic_two"));
1660
1661        names
1662            .lock()
1663            .expect("dynamic tool names lock")
1664            .push("dynamic_two".to_string());
1665        registry.refresh_sources().expect("refresh sources");
1666        let refreshed = tool_names();
1667        assert!(refreshed.contains("dynamic_one"));
1668        assert!(refreshed.contains("dynamic_two"));
1669
1670        names
1671            .lock()
1672            .expect("dynamic tool names lock")
1673            .retain(|name| name != "dynamic_one");
1674        registry.refresh_sources().expect("refresh sources");
1675        let refreshed = tool_names();
1676        assert!(!refreshed.contains("dynamic_one"));
1677        assert!(refreshed.contains("dynamic_two"));
1678    }
1679
1680    #[tokio::test]
1681    async fn unknown_manifest_exact_resolves_and_routes_to_owner() {
1682        let manifest_resolutions = Arc::new(AtomicUsize::new(0));
1683        let contract_resolutions = Arc::new(AtomicUsize::new(0));
1684        let executions = Arc::new(AtomicUsize::new(0));
1685        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1686        registry
1687            .upsert_source(Arc::new(ExactResolvingSource {
1688                manifest_resolutions: Arc::clone(&manifest_resolutions),
1689                contract_resolutions: Arc::clone(&contract_resolutions),
1690                executions: Arc::clone(&executions),
1691            }))
1692            .expect("source registered");
1693
1694        assert_eq!(
1695            registry
1696                .resolve_manifest("host_only")
1697                .map(|manifest| manifest.name),
1698            Some("host_only".to_string())
1699        );
1700        assert_eq!(manifest_resolutions.load(Ordering::SeqCst), 1);
1701
1702        let contract = registry.resolve_contract("host_only");
1703        assert!(contract.is_some());
1704        assert_eq!(manifest_resolutions.load(Ordering::SeqCst), 1);
1705        assert_eq!(contract_resolutions.load(Ordering::SeqCst), 1);
1706
1707        let context = test_tool_context();
1708        let args = json!({});
1709        let result = registry
1710            .execute(crate::ToolCall {
1711                name: "host_only",
1712                args: &args,
1713                context: &context,
1714                progress: None,
1715            })
1716            .await;
1717        assert!(result.is_success());
1718        assert_eq!(result.value_for_projection(), json!("host_only"));
1719        assert_eq!(executions.load(Ordering::SeqCst), 1);
1720    }
1721
1722    #[test]
1723    fn unknown_manifest_without_host_resolver_is_unavailable() {
1724        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1725
1726        assert!(registry.resolve_manifest("missing").is_none());
1727        assert!(registry.resolve_contract("missing").is_none());
1728    }
1729
1730    #[tokio::test]
1731    async fn upsert_source_registers_and_executes_external_tools() {
1732        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1733        registry
1734            .upsert_source(Arc::new(ExternalMockSource))
1735            .expect("source registered");
1736
1737        let defs = registry.tool_manifests();
1738        assert!(defs.iter().any(|def| def.name == "mcp__demo__search"));
1739
1740        let context = test_tool_context();
1741        let args = json!({ "query": "hello" });
1742        let result = registry
1743            .execute(crate::ToolCall {
1744                name: "mcp__demo__search",
1745                args: &args,
1746                context: &context,
1747                progress: None,
1748            })
1749            .await;
1750        assert!(result.is_success());
1751        assert_eq!(
1752            result.value_for_projection()["tool"],
1753            json!("mcp__demo__search")
1754        );
1755        assert_eq!(
1756            result.value_for_projection()["args"]["query"],
1757            json!("hello")
1758        );
1759    }
1760
1761    #[test]
1762    fn upsert_source_preserves_availability_override_on_refresh() {
1763        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1764        registry
1765            .upsert_source(Arc::new(ExternalMockSource))
1766            .expect("source registered");
1767        let mut snapshot = registry.export_state();
1768        snapshot
1769            .set_availability("mcp__demo__search", Some(crate::ToolAvailability::Off))
1770            .unwrap();
1771        registry.apply_state(snapshot).unwrap();
1772        registry
1773            .upsert_source(Arc::new(ExternalMockSource))
1774            .expect("source refreshed");
1775        let snapshot = registry.export_state();
1776        assert_eq!(
1777            snapshot
1778                .get("mcp__demo__search")
1779                .unwrap()
1780                .manifest()
1781                .effective_availability(),
1782            crate::ToolAvailability::Off
1783        );
1784    }
1785
1786    #[test]
1787    fn restore_state_adopts_generation_at_or_above_three() {
1788        // Cold rebuild ratchet: a session whose tool surface advanced to
1789        // generation >= 3 restores onto a fresh base-1 registry. `restore_state`
1790        // adopts the snapshot's generation verbatim; `apply_state` (a gen-matched
1791        // delta) rejects it. This is the exact divergence the durable worker /
1792        // session resume rebuild relies on `restore_state` to absorb.
1793        let source = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("source registry");
1794        let snapshot = source.export_state().with_generation(3);
1795
1796        let target = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target registry");
1797        assert_eq!(
1798            target.generation(),
1799            1,
1800            "a fresh registry starts at generation 1"
1801        );
1802        let restored = target
1803            .restore_state(snapshot.clone())
1804            .expect("restore adopts the snapshot generation");
1805        assert_eq!(
1806            restored.generation, 3,
1807            "restore returns the adopted generation"
1808        );
1809        assert!(
1810            restored.orphaned.is_empty(),
1811            "all tools resolve, so nothing orphans"
1812        );
1813        assert_eq!(
1814            target.generation(),
1815            3,
1816            "restore adopts gen 3 onto a base-1 registry without bumping"
1817        );
1818        // A re-export round-trips at the same generation (idempotent).
1819        assert_eq!(target.export_state().generation(), 3);
1820
1821        // apply_state on the same high-generation snapshot is rejected — proving
1822        // the rebuild would have failed without restore_state.
1823        let fresh = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("fresh registry");
1824        assert!(
1825            matches!(
1826                fresh.apply_state(snapshot),
1827                Err(ReconfigureError::GenerationMismatch {
1828                    expected: 3,
1829                    actual: 1
1830                })
1831            ),
1832            "apply_state must reject a gen-3 snapshot on a base-1 registry"
1833        );
1834    }
1835
1836    /// Build a snapshot whose `mcp__demo__search` entry only resolves while
1837    /// `ExternalMockSource` is registered — restoring it elsewhere orphans it.
1838    fn snapshot_with_external_tool() -> ToolState {
1839        let source = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("source registry");
1840        source
1841            .upsert_source(Arc::new(ExternalMockSource))
1842            .expect("source registered");
1843        source.export_state()
1844    }
1845
1846    #[tokio::test]
1847    async fn restore_orphans_unresolved_tools_instead_of_failing() {
1848        let mut snapshot = snapshot_with_external_tool();
1849        snapshot
1850            .set_availability(
1851                "mcp__demo__search",
1852                Some(crate::ToolAvailability::Showcased),
1853            )
1854            .expect("override set");
1855
1856        let target = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target");
1857        let report = target
1858            .restore_state(snapshot)
1859            .expect("restore tolerates the missing source");
1860        assert_eq!(report.orphaned, vec!["mcp__demo__search".to_string()]);
1861
1862        // The orphan surfaces as Off without mutating the stored manifest.
1863        let view = target
1864            .tool_manifests()
1865            .into_iter()
1866            .find(|manifest| manifest.name == "mcp__demo__search")
1867            .expect("orphan stays in the surface listing");
1868        assert_eq!(
1869            view.effective_availability(),
1870            crate::ToolAvailability::Off,
1871            "orphans are forced Off in the view"
1872        );
1873        let exported = target.export_state();
1874        let exported_view = exported
1875            .tool_manifests()
1876            .into_iter()
1877            .find(|manifest| manifest.name == "mcp__demo__search")
1878            .expect("orphan is visible in exported tool state");
1879        assert_eq!(
1880            exported_view.effective_availability(),
1881            crate::ToolAvailability::Off,
1882            "exported ToolState exposes the same forced-Off orphan view"
1883        );
1884        let entry = exported.get("mcp__demo__search").expect("orphan exported");
1885        assert!(entry.is_orphaned());
1886        assert_eq!(
1887            entry.manifest().effective_availability(),
1888            crate::ToolAvailability::Off,
1889            "entry manifest is also the public forced-Off view"
1890        );
1891        assert_eq!(
1892            entry.stored_manifest().availability_override,
1893            Some(crate::ToolAvailability::Showcased),
1894            "the persisted override survives orphaning"
1895        );
1896
1897        // Execution fails loudly with a precise error.
1898        let context = test_tool_context();
1899        let args = json!({ "query": "hello" });
1900        let result = target
1901            .execute(crate::ToolCall {
1902                name: "mcp__demo__search",
1903                args: &args,
1904                context: &context,
1905                progress: None,
1906            })
1907            .await;
1908        assert!(!result.is_success());
1909        assert!(
1910            format!("{result:?}").contains("unavailable"),
1911            "orphan execution error names the condition: {result:?}"
1912        );
1913
1914        // Bound tools are unaffected.
1915        assert!(target.resolve_contract("mock_tool").is_some());
1916    }
1917
1918    #[tokio::test]
1919    async fn orphan_rebinds_when_source_is_upserted_again() {
1920        let mut snapshot = snapshot_with_external_tool();
1921        snapshot
1922            .set_availability(
1923                "mcp__demo__search",
1924                Some(crate::ToolAvailability::Showcased),
1925            )
1926            .expect("override set");
1927        let target = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target");
1928        target.restore_state(snapshot).expect("restore");
1929        let orphaned_generation = target.generation();
1930
1931        target
1932            .upsert_source(Arc::new(ExternalMockSource))
1933            .expect("the returning source must not conflict with its own orphan");
1934        assert!(
1935            target.generation() > orphaned_generation,
1936            "rebinding bumps the generation"
1937        );
1938
1939        let exported = target.export_state();
1940        let entry = exported.get("mcp__demo__search").expect("entry kept");
1941        assert!(
1942            !entry.is_orphaned(),
1943            "the orphan rebound to the live source"
1944        );
1945        assert_eq!(
1946            entry.manifest().availability_override,
1947            Some(crate::ToolAvailability::Showcased),
1948            "rebinding preserves the persisted override"
1949        );
1950
1951        let context = test_tool_context();
1952        let args = json!({ "query": "hello" });
1953        let result = target
1954            .execute(crate::ToolCall {
1955                name: "mcp__demo__search",
1956                args: &args,
1957                context: &context,
1958                progress: None,
1959            })
1960            .await;
1961        assert!(result.is_success(), "rebound tool executes: {result:?}");
1962    }
1963
1964    #[tokio::test]
1965    async fn orphan_rebinds_lazily_via_resolve_manifest() {
1966        // `NamedExactSource` advertises nothing, so reconcile-on-upsert cannot
1967        // rebind; only the lazy `resolve_manifest` path can.
1968        let source_registry = ToolRegistry::empty();
1969        source_registry
1970            .upsert_source(Arc::new(NamedExactSource { id: "exact-a" }))
1971            .expect("source registered");
1972        assert!(source_registry.resolve_manifest("host_only").is_some());
1973        let snapshot = source_registry.export_state();
1974
1975        let target = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target");
1976        let report = target.restore_state(snapshot).expect("restore");
1977        assert_eq!(report.orphaned, vec!["host_only".to_string()]);
1978
1979        target
1980            .upsert_source(Arc::new(NamedExactSource { id: "exact-a" }))
1981            .expect("source returns");
1982        let manifest = target
1983            .resolve_manifest("host_only")
1984            .expect("resolves after the source returned");
1985        assert_eq!(
1986            manifest.effective_availability(),
1987            crate::ToolAvailability::Callable,
1988            "lazy rebind drops the forced-Off orphan view"
1989        );
1990        assert!(
1991            !target
1992                .export_state()
1993                .get("host_only")
1994                .expect("entry kept")
1995                .is_orphaned()
1996        );
1997    }
1998
1999    #[test]
2000    fn restore_still_fails_when_name_resolves_with_different_id() {
2001        struct ReplacedSearchTool;
2002        #[async_trait::async_trait]
2003        impl ToolProvider for ReplacedSearchTool {
2004            fn tool_manifests(&self) -> Vec<ToolManifest> {
2005                manifests(vec![ToolDefinition::raw_with_id(
2006                    "tool:replaced",
2007                    "mcp__demo__search",
2008                    "a different implementation under the same name",
2009                    ToolDefinition::default_input_schema(),
2010                    json!({}),
2011                )])
2012            }
2013            fn resolve_contract(&self, _name: &str) -> Option<Arc<ToolContract>> {
2014                None
2015            }
2016            async fn execute(&self, _call: ToolCall<'_>) -> ToolResult {
2017                ToolResult::ok(json!("ok"))
2018            }
2019        }
2020
2021        let snapshot = snapshot_with_external_tool();
2022        let target = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target");
2023        target
2024            .add_tool_provider(Arc::new(ReplacedSearchTool))
2025            .expect("replacement registered");
2026        let err = target
2027            .restore_state(snapshot)
2028            .expect_err("same name with a different id is a real conflict");
2029        assert!(matches!(err, ReconfigureError::Validation(_)));
2030    }
2031
2032    #[test]
2033    fn apply_state_round_trips_while_orphans_exist() {
2034        // `export_state` → edit → `apply_state` must work with an orphan in
2035        // the snapshot: the exported orphan flag exempts it from strictness.
2036        let target = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target");
2037        target
2038            .restore_state(snapshot_with_external_tool())
2039            .expect("restore");
2040
2041        let mut edited = target.export_state();
2042        edited
2043            .set_availability("mock_tool", Some(crate::ToolAvailability::Searchable))
2044            .expect("edit bound tool");
2045        target
2046            .apply_state(edited)
2047            .expect("apply accepts the snapshot it exported");
2048        let exported = target.export_state();
2049        assert!(exported.get("mcp__demo__search").unwrap().is_orphaned());
2050        assert_eq!(
2051            exported
2052                .get("mock_tool")
2053                .unwrap()
2054                .manifest()
2055                .effective_availability(),
2056            crate::ToolAvailability::Searchable
2057        );
2058
2059        // But a snapshot that does NOT mark the tool orphaned still fails —
2060        // strictness is preserved for entries that were bound at export.
2061        let strict = snapshot_with_external_tool().with_generation(target.generation());
2062        assert!(matches!(
2063            target.apply_state(strict),
2064            Err(ReconfigureError::Validation(_))
2065        ));
2066    }
2067
2068    #[test]
2069    fn orphan_flag_serializes_and_legacy_snapshots_deserialize_as_bound() {
2070        let target = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target");
2071        target
2072            .restore_state(snapshot_with_external_tool())
2073            .expect("restore");
2074        let value = serde_json::to_value(target.export_state()).expect("serializes");
2075        assert_eq!(value["tools"]["mcp__demo__search"]["orphaned"], json!(true));
2076        assert!(
2077            value["tools"]["mock_tool"].get("orphaned").is_none(),
2078            "bound entries omit the flag, keeping old and new snapshots byte-compatible"
2079        );
2080
2081        let legacy: ToolStateEntry = serde_json::from_value(json!({
2082            "manifest": value["tools"]["mock_tool"]["manifest"]
2083        }))
2084        .expect("legacy entry without the flag deserializes");
2085        assert!(!legacy.is_orphaned());
2086    }
2087
2088    #[test]
2089    fn remove_source_removes_all_source_tools() {
2090        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
2091        registry
2092            .upsert_source(Arc::new(ExternalMockSource))
2093            .expect("source registered");
2094        registry
2095            .remove_source_id("external")
2096            .expect("source removed");
2097        let defs = registry.tool_manifests();
2098        assert!(!defs.iter().any(|def| def.name == "mcp__demo__search"));
2099    }
2100
2101    #[test]
2102    fn project_tool_catalog_keeps_searchable_tools_with_surface_metadata() {
2103        fn dummy_tool(name: &str) -> crate::ToolDefinition {
2104            let tool = crate::ToolDefinition::raw_with_id(
2105                format!("tool:{name}"),
2106                name,
2107                format!("desc for {name}"),
2108                crate::ToolDefinition::default_input_schema(),
2109                serde_json::json!({}),
2110            );
2111            match name {
2112                "read_file" => {
2113                    tool.with_agent_surface(crate::ToolAgentSurface::new(["files"], "read"))
2114                }
2115                "search_tools" => {
2116                    tool.with_agent_surface(crate::ToolAgentSurface::new(["tools"], "search"))
2117                }
2118                _ => tool,
2119            }
2120        }
2121        let catalog = project_tool_catalog([
2122            crate::ToolSurfaceEntry {
2123                manifest: dummy_tool("read_file").manifest(),
2124                availability: crate::ToolAvailability::Showcased,
2125            },
2126            crate::ToolSurfaceEntry {
2127                manifest: dummy_tool("search_tools").manifest(),
2128                availability: crate::ToolAvailability::Callable,
2129            },
2130        ]);
2131        assert_eq!(catalog.len(), 2);
2132        assert_eq!(catalog[0]["name"], serde_json::json!("read_file"));
2133        assert_eq!(
2134            catalog[0]["contract"]["signature"],
2135            serde_json::json!("await files.read({})?")
2136        );
2137        assert_eq!(catalog[0]["showcased"], serde_json::json!(true));
2138        assert_eq!(catalog[1]["callable"], serde_json::json!(true));
2139    }
2140
2141    #[test]
2142    fn project_tool_catalog_preserves_dynamic_output_contracts() {
2143        fn dummy_tool(name: &str) -> crate::ToolDefinition {
2144            crate::ToolDefinition::raw_with_id(
2145                format!("tool:{name}"),
2146                name,
2147                format!("desc for {name}"),
2148                crate::ToolDefinition::default_input_schema(),
2149                serde_json::json!({}),
2150            )
2151            .with_agent_surface(crate::ToolAgentSurface::new(["llm"], "query"))
2152        }
2153        let catalog = project_tool_catalog([crate::ToolSurfaceEntry {
2154            manifest: dummy_tool("llm_query")
2155                .with_output_from_input_schema(
2156                    "output",
2157                    Some(serde_json::json!({ "type": "string" })),
2158                )
2159                .manifest(),
2160            availability: crate::ToolAvailability::Searchable,
2161        }]);
2162
2163        assert_eq!(
2164            catalog[0]["contract"]["signature"],
2165            serde_json::json!("await llm.query<T = str>({})?")
2166        );
2167        assert_eq!(catalog[0]["contract"]["returns"], serde_json::json!("T"));
2168    }
2169}
2170
2171pub(crate) fn project_tool_catalog<I>(entries: I) -> Vec<serde_json::Value>
2172where
2173    I: IntoIterator<Item = crate::ToolSurfaceEntry>,
2174{
2175    entries
2176        .into_iter()
2177        .filter(|entry| entry.availability.is_searchable())
2178        .map(|entry| {
2179            let manifest = entry.manifest;
2180            let availability = entry.availability;
2181            let agent_surface = manifest.agent_surface.executable_for(&manifest.name);
2182            let call = agent_surface.call_path();
2183            let mut projected = serde_json::json!({
2184                "id": manifest.id,
2185                "name": manifest.name,
2186                "module_path": agent_surface.module_path,
2187                "operation": agent_surface.operation,
2188                "authority_type": agent_surface.authority_type,
2189                "call": call,
2190                "description": manifest.description,
2191                "aliases": agent_surface.aliases,
2192                "availability": availability,
2193                "callable": availability.is_callable(),
2194                "showcased": availability.is_showcased(),
2195                "searchable": availability.is_searchable(),
2196                "activation": manifest.activation,
2197            });
2198            if let Some(contract) = manifest.compact_contract {
2199                projected
2200                    .as_object_mut()
2201                    .expect("projected tool catalog entry is an object")
2202                    .insert("contract".to_string(), serde_json::json!(contract));
2203            }
2204            projected
2205        })
2206        .collect()
2207}