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}
35
36impl ToolStateEntry {
37    pub(crate) fn new(manifest: ToolManifest) -> Self {
38        Self { manifest }
39    }
40
41    pub fn manifest(&self) -> &ToolManifest {
42        &self.manifest
43    }
44
45    pub fn manifest_mut(&mut self) -> &mut ToolManifest {
46        &mut self.manifest
47    }
48}
49
50#[derive(Clone, Debug, Default)]
51pub struct ToolState {
52    generation: u64,
53    tools: Arc<BTreeMap<String, ToolStateEntry>>,
54}
55
56impl ToolState {
57    pub(crate) fn new(generation: u64, tools: BTreeMap<String, ToolStateEntry>) -> Self {
58        Self {
59            generation,
60            tools: Arc::new(tools),
61        }
62    }
63
64    pub fn generation(&self) -> u64 {
65        self.generation
66    }
67
68    pub fn with_generation(mut self, generation: u64) -> Self {
69        self.generation = generation;
70        self
71    }
72
73    pub fn tool_manifests(&self) -> Vec<ToolManifest> {
74        self.tools
75            .values()
76            .map(|entry| entry.manifest.clone())
77            .collect()
78    }
79
80    pub fn get(&self, name: &str) -> Option<&ToolStateEntry> {
81        self.tools.get(name)
82    }
83
84    pub fn manifest_mut(&mut self, name: &str) -> Option<&mut ToolManifest> {
85        Arc::make_mut(&mut self.tools)
86            .get_mut(name)
87            .map(|entry| &mut entry.manifest)
88    }
89
90    pub fn contains(&self, name: &str) -> bool {
91        self.tools.contains_key(name)
92    }
93
94    pub fn is_empty(&self) -> bool {
95        self.tools.is_empty()
96    }
97
98    pub fn len(&self) -> usize {
99        self.tools.len()
100    }
101
102    pub fn iter(&self) -> impl Iterator<Item = (&str, &ToolStateEntry)> {
103        self.tools
104            .iter()
105            .map(|(name, entry)| (name.as_str(), entry))
106    }
107
108    pub fn set_availability(
109        &mut self,
110        name: &str,
111        availability: Option<crate::ToolAvailability>,
112    ) -> Result<(), ReconfigureError> {
113        let Some(entry) = Arc::make_mut(&mut self.tools).get_mut(name) else {
114            return Err(ReconfigureError::Validation(format!(
115                "unknown tool `{name}`"
116            )));
117        };
118        entry.manifest.availability_override = availability;
119        Ok(())
120    }
121
122    pub fn retain(&mut self, mut keep: impl FnMut(&str, &ToolStateEntry) -> bool) {
123        Arc::make_mut(&mut self.tools).retain(|name, entry| keep(name, entry));
124    }
125
126    pub fn remove(&mut self, name: &str) -> Option<ToolStateEntry> {
127        Arc::make_mut(&mut self.tools).remove(name)
128    }
129
130    pub(crate) fn entries(&self) -> &BTreeMap<String, ToolStateEntry> {
131        self.tools.as_ref()
132    }
133}
134
135impl Serialize for ToolState {
136    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
137    where
138        S: serde::Serializer,
139    {
140        #[derive(Serialize)]
141        struct ToolStateRef<'a> {
142            generation: u64,
143            tools: &'a BTreeMap<String, ToolStateEntry>,
144        }
145
146        ToolStateRef {
147            generation: self.generation,
148            tools: self.tools.as_ref(),
149        }
150        .serialize(serializer)
151    }
152}
153
154impl<'de> Deserialize<'de> for ToolState {
155    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
156    where
157        D: serde::Deserializer<'de>,
158    {
159        #[derive(Deserialize)]
160        struct ToolStateOwned {
161            generation: u64,
162            tools: BTreeMap<String, ToolStateEntry>,
163        }
164
165        let owned = ToolStateOwned::deserialize(deserializer)?;
166        Ok(Self {
167            generation: owned.generation,
168            tools: Arc::new(owned.tools),
169        })
170    }
171}
172
173#[async_trait::async_trait]
174pub(crate) trait ToolSourceExecutor: Send + Sync + 'static {
175    fn id(&self) -> &str;
176    fn advertised_tools(&self) -> Vec<ToolManifest>;
177    fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
178        self.advertised_tools()
179            .into_iter()
180            .find(|manifest| manifest.name == name)
181    }
182    fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>>;
183    async fn prepare_tool_call(
184        &self,
185        call: ToolPrepareCall<'_>,
186    ) -> Result<PreparedToolCall, ToolResult> {
187        Ok(PreparedToolCall::identity(call.pending))
188    }
189    async fn execute(
190        &self,
191        tool: &str,
192        args: &serde_json::Value,
193        context: &ToolContext<'_>,
194        progress: Option<&ProgressSender>,
195    ) -> ToolResult;
196}
197
198struct ToolProviderSource {
199    id: String,
200    provider: Arc<dyn ToolProvider>,
201}
202
203impl ToolProviderSource {
204    fn new(id: impl Into<String>, provider: Arc<dyn ToolProvider>) -> Self {
205        Self {
206            id: id.into(),
207            provider,
208        }
209    }
210}
211
212struct ToolProviderGroupSource {
213    id: String,
214    tools: RwLock<BTreeMap<String, (ToolManifest, usize)>>,
215    providers: Vec<Arc<dyn ToolProvider>>,
216}
217
218impl ToolProviderGroupSource {
219    fn new(id: impl Into<String>, providers: Vec<Arc<dyn ToolProvider>>) -> Self {
220        let mut tools = BTreeMap::new();
221        for (provider_idx, provider) in providers.iter().enumerate() {
222            for manifest in provider.tool_manifests() {
223                tools.insert(manifest.name.clone(), (manifest, provider_idx));
224            }
225        }
226        Self {
227            id: id.into(),
228            tools: RwLock::new(tools),
229            providers,
230        }
231    }
232
233    fn read_advertised_tools(&self) -> Vec<ToolManifest> {
234        let mut tools = BTreeMap::new();
235        for (provider_idx, provider) in self.providers.iter().enumerate() {
236            for manifest in provider.tool_manifests() {
237                tools.insert(manifest.name.clone(), (manifest, provider_idx));
238            }
239        }
240        let manifests = tools
241            .values()
242            .map(|(manifest, _)| manifest.clone())
243            .collect::<Vec<_>>();
244        *self
245            .tools
246            .write()
247            .expect("tool provider group lock poisoned") = tools;
248        manifests
249    }
250
251    fn provider_index_for(&self, name: &str) -> Option<usize> {
252        self.resolve_manifest(name).and_then(|_| {
253            self.tools
254                .read()
255                .expect("tool provider group lock poisoned")
256                .get(name)
257                .map(|(_, provider_idx)| *provider_idx)
258        })
259    }
260}
261
262#[async_trait::async_trait]
263impl ToolSourceExecutor for ToolProviderGroupSource {
264    fn id(&self) -> &str {
265        &self.id
266    }
267
268    fn advertised_tools(&self) -> Vec<ToolManifest> {
269        self.read_advertised_tools()
270    }
271
272    fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
273        if let Some((manifest, _)) = self
274            .tools
275            .read()
276            .expect("tool provider group lock poisoned")
277            .get(name)
278        {
279            return Some(manifest.clone());
280        }
281        for (provider_idx, provider) in self.providers.iter().enumerate() {
282            if let Some(manifest) = provider.resolve_manifest(name) {
283                self.tools
284                    .write()
285                    .expect("tool provider group lock poisoned")
286                    .insert(name.to_string(), (manifest.clone(), provider_idx));
287                return Some(manifest);
288            }
289        }
290        None
291    }
292
293    fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
294        let provider_idx = self.provider_index_for(name)?;
295        self.providers[provider_idx].resolve_contract(name)
296    }
297
298    async fn prepare_tool_call(
299        &self,
300        call: ToolPrepareCall<'_>,
301    ) -> Result<PreparedToolCall, ToolResult> {
302        let name = call.pending.tool_name.clone();
303        let Some(provider_idx) = self.provider_index_for(&name) else {
304            return Err(ToolResult::err_fmt(format_args!("Unknown tool: {name}")));
305        };
306        self.providers[provider_idx].prepare_tool_call(call).await
307    }
308
309    async fn execute(
310        &self,
311        tool: &str,
312        args: &serde_json::Value,
313        context: &ToolContext<'_>,
314        progress: Option<&ProgressSender>,
315    ) -> ToolResult {
316        let Some(provider_idx) = self.provider_index_for(tool) else {
317            return ToolResult::err_fmt(format_args!("Unknown tool: {tool}"));
318        };
319        self.providers[provider_idx]
320            .execute(ToolCall {
321                name: tool,
322                args,
323                context,
324                progress,
325            })
326            .await
327    }
328}
329
330#[async_trait::async_trait]
331impl ToolSourceExecutor for ToolProviderSource {
332    fn id(&self) -> &str {
333        &self.id
334    }
335
336    fn advertised_tools(&self) -> Vec<ToolManifest> {
337        self.provider.tool_manifests()
338    }
339
340    fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
341        self.provider.resolve_manifest(name)
342    }
343
344    fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
345        self.provider.resolve_contract(name)
346    }
347
348    async fn prepare_tool_call(
349        &self,
350        call: ToolPrepareCall<'_>,
351    ) -> Result<PreparedToolCall, ToolResult> {
352        self.provider.prepare_tool_call(call).await
353    }
354
355    async fn execute(
356        &self,
357        tool: &str,
358        args: &serde_json::Value,
359        context: &ToolContext<'_>,
360        progress: Option<&ProgressSender>,
361    ) -> ToolResult {
362        self.provider
363            .execute(ToolCall {
364                name: tool,
365                args,
366                context,
367                progress,
368            })
369            .await
370    }
371}
372
373#[derive(Clone)]
374struct ToolRegistryEntry {
375    manifest: ToolManifest,
376    source_id: String,
377}
378
379impl ToolRegistryEntry {
380    fn new(manifest: ToolManifest, source_id: impl Into<String>) -> Self {
381        Self {
382            manifest,
383            source_id: source_id.into(),
384        }
385    }
386
387    fn export(&self) -> ToolStateEntry {
388        ToolStateEntry::new(self.manifest.clone())
389    }
390}
391
392#[derive(Clone)]
393struct ToolRegistryState {
394    generation: u64,
395    tools: BTreeMap<String, ToolRegistryEntry>,
396    next_live_source_id: u64,
397}
398
399#[derive(Debug, thiserror::Error)]
400pub enum ReconfigureError {
401    #[error("validation error: {0}")]
402    Validation(String),
403    #[error("unknown tool source: {0}")]
404    UnknownSource(String),
405    #[error("generation mismatch: expected {expected}, actual {actual}")]
406    GenerationMismatch { expected: u64, actual: u64 },
407}
408
409#[derive(Clone)]
410pub struct ToolRegistry {
411    sources: Arc<RwLock<BTreeMap<String, Arc<dyn ToolSourceExecutor>>>>,
412    state: Arc<RwLock<ToolRegistryState>>,
413}
414
415#[derive(Clone, Copy, Debug, PartialEq, Eq)]
416enum SourceReconcilePolicy {
417    RejectExternalConflicts,
418    OverlayReplacingConflicts,
419}
420
421impl ToolRegistry {
422    pub fn from_tool_provider(provider: Arc<dyn ToolProvider>) -> Result<Self, ReconfigureError> {
423        let registry = Self::empty();
424        registry.upsert_source(Arc::new(ToolProviderSource::new(
425            PLUGIN_SOURCE_ID,
426            provider,
427        )))?;
428        Ok(registry)
429    }
430
431    pub(crate) fn from_tool_providers(
432        providers: Vec<Arc<dyn ToolProvider>>,
433    ) -> Result<Self, ReconfigureError> {
434        let registry = Self::empty();
435        registry.upsert_source(Arc::new(ToolProviderGroupSource::new(
436            PLUGIN_SOURCE_ID,
437            providers,
438        )))?;
439        Ok(registry)
440    }
441
442    pub(crate) fn empty() -> Self {
443        Self {
444            sources: Arc::new(RwLock::new(BTreeMap::new())),
445            state: Arc::new(RwLock::new(ToolRegistryState {
446                generation: 0,
447                tools: BTreeMap::new(),
448                next_live_source_id: 0,
449            })),
450        }
451    }
452
453    pub fn generation(&self) -> u64 {
454        self.state
455            .read()
456            .expect("tool registry state lock poisoned")
457            .generation
458    }
459
460    pub fn export_state(&self) -> ToolState {
461        let state = self
462            .state
463            .read()
464            .expect("tool registry state lock poisoned");
465        ToolState::new(state.generation, export_tool_state_entries(&state.tools))
466    }
467
468    pub fn apply_state(&self, next: ToolState) -> Result<u64, ReconfigureError> {
469        let current_generation = self.generation();
470        if next.generation != current_generation {
471            return Err(ReconfigureError::GenerationMismatch {
472                expected: next.generation,
473                actual: current_generation,
474            });
475        }
476
477        validate_unique_manifest_entries(next.entries().values())?;
478        let rebound_tools = {
479            let sources = self.sources.read().expect("tool source lock poisoned");
480            rebind_tool_state_entries(next.entries(), &sources)?
481        };
482
483        let mut state = self
484            .state
485            .write()
486            .expect("tool registry state lock poisoned");
487        if state.generation != next.generation {
488            return Err(ReconfigureError::GenerationMismatch {
489                expected: next.generation,
490                actual: state.generation,
491            });
492        }
493        state.tools = rebound_tools;
494        state.generation += 1;
495        Ok(state.generation)
496    }
497
498    /// Restore a persisted [`ToolState`] snapshot onto a freshly-built registry,
499    /// adopting the snapshot's generation verbatim.
500    ///
501    /// Unlike [`apply_state`](Self::apply_state) — which applies an incremental
502    /// *delta* expected at the current generation and bumps it by one — a
503    /// restore reconstructs the exact persisted state regardless of the fresh
504    /// registry's base generation, and does **not** bump. This is idempotent: a
505    /// snapshot exported at generation `G` restores to generation `G`, so a
506    /// re-export round-trips. Cold rebuilds (the durable process worker, session
507    /// resume) restore a session whose tool surface reached generation `G ≥ 2`
508    /// onto a base registry at generation 1 — `apply_state` would reject that
509    /// (`expected G, actual 1`); `restore_state` adopts `G`. Entries are still
510    /// rebound to the live sources, so source identity is reconnected.
511    pub fn restore_state(&self, snapshot: ToolState) -> Result<u64, ReconfigureError> {
512        validate_unique_manifest_entries(snapshot.entries().values())?;
513        let rebound_tools = {
514            let sources = self.sources.read().expect("tool source lock poisoned");
515            rebind_tool_state_entries(snapshot.entries(), &sources)?
516        };
517
518        let mut state = self
519            .state
520            .write()
521            .expect("tool registry state lock poisoned");
522        state.tools = rebound_tools;
523        state.generation = snapshot.generation();
524        Ok(state.generation)
525    }
526
527    pub fn add_tool_provider(
528        &self,
529        provider: Arc<dyn ToolProvider>,
530    ) -> Result<ToolSourceHandle, ReconfigureError> {
531        let source_id = {
532            let mut state = self
533                .state
534                .write()
535                .expect("tool registry state lock poisoned");
536            state.next_live_source_id += 1;
537            format!("live:{}", state.next_live_source_id)
538        };
539        self.upsert_source(Arc::new(ToolProviderSource::new(
540            source_id.clone(),
541            provider,
542        )))?;
543        Ok(ToolSourceHandle::new(source_id))
544    }
545
546    pub(crate) fn compose_session_surface(
547        &self,
548        include_base_tools: bool,
549        context_providers: Vec<Arc<dyn ToolProvider>>,
550    ) -> Result<Self, ReconfigureError> {
551        let registry = if include_base_tools {
552            self.fork_with_state(self.export_state())?
553        } else {
554            Self::empty()
555        };
556        registry.upsert_overlay_source(Arc::new(ToolProviderGroupSource::new(
557            "context",
558            context_providers,
559        )))?;
560        Ok(registry)
561    }
562
563    pub(crate) fn upsert_source(
564        &self,
565        source: Arc<dyn ToolSourceExecutor>,
566    ) -> Result<u64, ReconfigureError> {
567        self.reconcile_source(source, SourceReconcilePolicy::RejectExternalConflicts)
568    }
569
570    fn upsert_overlay_source(
571        &self,
572        source: Arc<dyn ToolSourceExecutor>,
573    ) -> Result<u64, ReconfigureError> {
574        self.reconcile_source(source, SourceReconcilePolicy::OverlayReplacingConflicts)
575    }
576
577    fn reconcile_source(
578        &self,
579        source: Arc<dyn ToolSourceExecutor>,
580        policy: SourceReconcilePolicy,
581    ) -> Result<u64, ReconfigureError> {
582        let source_id = source.id().to_string();
583        let advertised_tools = source
584            .advertised_tools()
585            .into_iter()
586            .map(|manifest| manifest_with_compact_contract(source.as_ref(), manifest))
587            .collect::<Vec<_>>();
588        validate_unique_manifests(&advertised_tools)?;
589
590        let advertised_names = advertised_tools
591            .iter()
592            .map(|manifest| manifest.name.clone())
593            .collect::<BTreeSet<_>>();
594        let advertised_ids = advertised_tools
595            .iter()
596            .map(|manifest| manifest.id.clone())
597            .collect::<BTreeSet<_>>();
598        let mut state = self
599            .state
600            .write()
601            .expect("tool registry state lock poisoned");
602        let same_source_names = state
603            .tools
604            .iter()
605            .filter_map(|(name, entry)| (entry.source_id == source_id).then_some(name.clone()))
606            .collect::<BTreeSet<_>>();
607        let previous_overrides = state
608            .tools
609            .iter()
610            .map(|(name, entry)| (name.clone(), entry.manifest.availability_override))
611            .collect::<BTreeMap<_, _>>();
612        match policy {
613            SourceReconcilePolicy::RejectExternalConflicts => {
614                for manifest in &advertised_tools {
615                    if let Some(existing) = state.tools.get(&manifest.name)
616                        && existing.source_id != source_id
617                    {
618                        return Err(ReconfigureError::Validation(format!(
619                            "duplicate tool name `{}` from source `{}` conflicts with source `{}`",
620                            manifest.name, source_id, existing.source_id
621                        )));
622                    }
623                    if let Some((existing_name, existing)) =
624                        state.tools.iter().find(|(_, entry)| {
625                            entry.source_id != source_id && entry.manifest.id == manifest.id
626                        })
627                    {
628                        return Err(ReconfigureError::Validation(format!(
629                            "duplicate tool id `{}` from source `{}` conflicts with tool `{}` from source `{}`",
630                            manifest.id, source_id, existing_name, existing.source_id
631                        )));
632                    }
633                }
634                state.tools.retain(|name, entry| {
635                    entry.source_id != source_id || !same_source_names.contains(name)
636                });
637            }
638            SourceReconcilePolicy::OverlayReplacingConflicts => {
639                state.tools.retain(|name, entry| {
640                    entry.source_id != source_id
641                        && !advertised_names.contains(name)
642                        && !advertised_ids.contains(&entry.manifest.id)
643                });
644            }
645        }
646        for mut manifest in advertised_tools {
647            let name = manifest.name.clone();
648            manifest.availability_override = previous_overrides
649                .get(&name)
650                .copied()
651                .flatten()
652                .or(manifest.availability_override);
653            state
654                .tools
655                .insert(name, ToolRegistryEntry::new(manifest, source_id.clone()));
656        }
657        self.sources
658            .write()
659            .expect("tool source lock poisoned")
660            .insert(source_id, source);
661        state.generation += 1;
662        Ok(state.generation)
663    }
664
665    pub fn remove_source(&self, handle: &ToolSourceHandle) -> Result<u64, ReconfigureError> {
666        self.remove_source_id(handle.as_str())
667    }
668
669    pub fn refresh_sources(&self) -> Result<u64, ReconfigureError> {
670        let sources = self
671            .sources
672            .read()
673            .expect("tool source lock poisoned")
674            .values()
675            .cloned()
676            .collect::<Vec<_>>();
677        let mut generation = self.generation();
678        for source in sources {
679            generation = self.upsert_source(source)?;
680        }
681        Ok(generation)
682    }
683
684    pub(crate) fn remove_source_id(&self, source_id: &str) -> Result<u64, ReconfigureError> {
685        {
686            let mut sources = self.sources.write().expect("tool source lock poisoned");
687            if sources.remove(source_id).is_none() {
688                return Err(ReconfigureError::UnknownSource(source_id.to_string()));
689            }
690        }
691        let mut state = self
692            .state
693            .write()
694            .expect("tool registry state lock poisoned");
695        state.tools.retain(|_, entry| entry.source_id != source_id);
696        state.generation += 1;
697        Ok(state.generation)
698    }
699
700    pub(crate) fn fork_with_state(&self, snapshot: ToolState) -> Result<Self, ReconfigureError> {
701        let sources = self
702            .sources
703            .read()
704            .expect("tool source lock poisoned")
705            .iter()
706            .map(|(k, v)| (k.clone(), Arc::clone(v)))
707            .collect::<BTreeMap<_, _>>();
708        validate_unique_manifest_entries(snapshot.entries().values())?;
709        let tools = rebind_tool_state_entries(snapshot.entries(), &sources)?;
710        let generation = snapshot.generation.max(1);
711        Ok(Self {
712            sources: Arc::new(RwLock::new(sources)),
713            state: Arc::new(RwLock::new(ToolRegistryState {
714                generation,
715                tools,
716                next_live_source_id: 0,
717            })),
718        })
719    }
720}
721
722#[async_trait::async_trait]
723impl ToolProvider for ToolRegistry {
724    fn tool_manifests(&self) -> Vec<ToolManifest> {
725        let state = self
726            .state
727            .read()
728            .expect("tool registry state lock poisoned");
729        state
730            .tools
731            .values()
732            .map(|entry| entry.manifest.clone())
733            .collect()
734    }
735
736    fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
737        if let Some(manifest) = {
738            let state = self
739                .state
740                .read()
741                .expect("tool registry state lock poisoned");
742            state.tools.get(name).map(|entry| entry.manifest.clone())
743        } {
744            return Some(manifest);
745        }
746
747        let sources = self
748            .sources
749            .read()
750            .expect("tool source lock poisoned")
751            .iter()
752            .map(|(source_id, source)| (source_id.clone(), Arc::clone(source)))
753            .collect::<Vec<_>>();
754        for (source_id, source) in sources {
755            let Some(manifest) = source.resolve_manifest(name) else {
756                continue;
757            };
758            let mut manifest = manifest_with_compact_contract(source.as_ref(), manifest);
759            let previous_override = {
760                let state = self
761                    .state
762                    .read()
763                    .expect("tool registry state lock poisoned");
764                state
765                    .tools
766                    .get(&manifest.name)
767                    .and_then(|entry| entry.manifest.availability_override)
768            };
769            manifest.availability_override = previous_override.or(manifest.availability_override);
770            let mut state = self
771                .state
772                .write()
773                .expect("tool registry state lock poisoned");
774            if let Some(existing) = state.tools.get(&manifest.name) {
775                return (existing.source_id == source_id).then(|| existing.manifest.clone());
776            }
777            if let Some((_, existing)) = state
778                .tools
779                .iter()
780                .find(|(_, entry)| entry.manifest.id == manifest.id)
781            {
782                return (existing.source_id == source_id).then(|| existing.manifest.clone());
783            }
784            state.tools.insert(
785                manifest.name.clone(),
786                ToolRegistryEntry::new(manifest.clone(), source_id),
787            );
788            state.generation += 1;
789            return Some(manifest);
790        }
791        None
792    }
793
794    fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
795        let source_id = self.resolve_manifest(name).and_then(|_| {
796            let state = self
797                .state
798                .read()
799                .expect("tool registry state lock poisoned");
800            state.tools.get(name).map(|entry| entry.source_id.clone())
801        })?;
802        self.sources
803            .read()
804            .expect("tool source lock poisoned")
805            .get(&source_id)?
806            .resolve_contract(name)
807    }
808
809    async fn prepare_tool_call(
810        &self,
811        call: ToolPrepareCall<'_>,
812    ) -> Result<PreparedToolCall, ToolResult> {
813        let name = call.pending.tool_name.clone();
814        let source_id = self.resolve_manifest(&name).and_then(|_| {
815            let state = self
816                .state
817                .read()
818                .expect("tool registry state lock poisoned");
819            state.tools.get(&name).map(|entry| entry.source_id.clone())
820        });
821        let Some(source_id) = source_id else {
822            return Err(ToolResult::err_fmt(format_args!("Unknown tool: {name}")));
823        };
824        let source = {
825            self.sources
826                .read()
827                .expect("tool source lock poisoned")
828                .get(&source_id)
829                .cloned()
830        };
831        let Some(source) = source else {
832            return Err(ToolResult::err_fmt(format_args!(
833                "Tool source missing for tool `{name}`"
834            )));
835        };
836        source.prepare_tool_call(call).await
837    }
838
839    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
840        let name = call.name;
841        let source_id = self.resolve_manifest(name).and_then(|_| {
842            let state = self
843                .state
844                .read()
845                .expect("tool registry state lock poisoned");
846            state.tools.get(name).map(|entry| entry.source_id.clone())
847        });
848        let Some(source_id) = source_id else {
849            return ToolResult::err_fmt(format_args!("Unknown tool: {name}"));
850        };
851        let source = {
852            self.sources
853                .read()
854                .expect("tool source lock poisoned")
855                .get(&source_id)
856                .cloned()
857        };
858        let Some(source) = source else {
859            return ToolResult::err_fmt(format_args!("Tool source missing for tool `{name}`"));
860        };
861        source
862            .execute(name, call.args, call.context, call.progress)
863            .await
864    }
865}
866
867fn validate_unique_manifests(manifests: &[ToolManifest]) -> Result<(), ReconfigureError> {
868    let mut names = BTreeSet::new();
869    let mut ids = BTreeSet::new();
870    for manifest in manifests {
871        if manifest.id.as_str().trim().is_empty() {
872            return Err(ReconfigureError::Validation(
873                "tool id cannot be empty".to_string(),
874            ));
875        }
876        if !ids.insert(manifest.id.clone()) {
877            return Err(ReconfigureError::Validation(format!(
878                "duplicate tool id `{}` in source",
879                manifest.id
880            )));
881        }
882        if manifest.name.trim().is_empty() {
883            return Err(ReconfigureError::Validation(
884                "tool name cannot be empty".to_string(),
885            ));
886        }
887        if !names.insert(manifest.name.clone()) {
888            return Err(ReconfigureError::Validation(format!(
889                "duplicate tool name `{}` in source",
890                manifest.name
891            )));
892        }
893    }
894    Ok(())
895}
896
897fn manifest_with_compact_contract(
898    source: &dyn ToolSourceExecutor,
899    mut manifest: ToolManifest,
900) -> ToolManifest {
901    if manifest.compact_contract.is_none()
902        && let Some(contract) = source.resolve_contract(&manifest.name)
903    {
904        manifest.compact_contract = Some(contract.compact_contract(&manifest));
905    }
906    manifest
907}
908
909fn export_tool_state_entries(
910    entries: &BTreeMap<String, ToolRegistryEntry>,
911) -> BTreeMap<String, ToolStateEntry> {
912    entries
913        .iter()
914        .map(|(name, entry)| (name.clone(), entry.export()))
915        .collect()
916}
917
918fn rebind_tool_state_entries(
919    entries: &BTreeMap<String, ToolStateEntry>,
920    sources: &BTreeMap<String, Arc<dyn ToolSourceExecutor>>,
921) -> Result<BTreeMap<String, ToolRegistryEntry>, ReconfigureError> {
922    let mut rebound = BTreeMap::new();
923    for (name, entry) in entries {
924        if name != &entry.manifest.name {
925            return Err(ReconfigureError::Validation(format!(
926                "tool state key `{}` does not match manifest name `{}`",
927                name, entry.manifest.name
928            )));
929        }
930
931        let mut name_matches = Vec::new();
932        for (source_id, source) in sources {
933            let Some(manifest) = source.resolve_manifest(name) else {
934                continue;
935            };
936            name_matches.push((
937                source_id.clone(),
938                manifest_with_compact_contract(source.as_ref(), manifest),
939            ));
940        }
941
942        if name_matches.is_empty() {
943            return Err(ReconfigureError::Validation(format!(
944                "no registered tool source resolves tool `{name}`"
945            )));
946        }
947
948        let matching_id = name_matches
949            .iter()
950            .filter(|(_, manifest)| manifest.id == entry.manifest.id)
951            .collect::<Vec<_>>();
952
953        if matching_id.len() == 1 {
954            let source_id = matching_id[0].0.clone();
955            rebound.insert(
956                name.clone(),
957                ToolRegistryEntry::new(entry.manifest.clone(), source_id),
958            );
959        } else if matching_id.is_empty() {
960            let resolved_ids = name_matches
961                .iter()
962                .map(|(_, manifest)| manifest.id.as_str())
963                .collect::<Vec<_>>()
964                .join(", ");
965            return Err(ReconfigureError::Validation(format!(
966                "tool `{name}` resolved with id(s) `{resolved_ids}`, expected `{}`",
967                entry.manifest.id
968            )));
969        } else {
970            return Err(ReconfigureError::Validation(format!(
971                "tool `{name}` with id `{}` is resolved by multiple registered sources",
972                entry.manifest.id
973            )));
974        }
975    }
976    Ok(rebound)
977}
978
979fn validate_unique_manifest_entries<'a>(
980    entries: impl IntoIterator<Item = &'a ToolStateEntry>,
981) -> Result<(), ReconfigureError> {
982    let manifests = entries
983        .into_iter()
984        .map(|entry| entry.manifest.clone())
985        .collect::<Vec<_>>();
986    validate_unique_manifests(&manifests)
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992    use crate::ToolDefinition;
993    use serde_json::json;
994    use std::sync::atomic::{AtomicUsize, Ordering};
995
996    struct MockTool;
997    struct MixedEnabledTool;
998    struct ExternalMockSource;
999    struct ExactResolvingSource {
1000        manifest_resolutions: Arc<AtomicUsize>,
1001        contract_resolutions: Arc<AtomicUsize>,
1002        executions: Arc<AtomicUsize>,
1003    }
1004    struct NamedExactSource {
1005        id: &'static str,
1006    }
1007    struct DynamicToolProvider {
1008        names: Arc<std::sync::Mutex<Vec<String>>>,
1009    }
1010
1011    fn test_tool(
1012        name: &str,
1013        description: &str,
1014        availability: crate::ToolAvailabilityConfig,
1015    ) -> ToolDefinition {
1016        ToolDefinition::raw_with_id(
1017            format!("tool:{name}"),
1018            name,
1019            description,
1020            ToolDefinition::default_input_schema(),
1021            json!({ "type": "string" }),
1022        )
1023        .with_availability(availability)
1024    }
1025
1026    fn manifests(definitions: Vec<ToolDefinition>) -> Vec<ToolManifest> {
1027        definitions
1028            .into_iter()
1029            .map(|tool| tool.manifest())
1030            .collect()
1031    }
1032
1033    fn contract_from(definitions: Vec<ToolDefinition>, name: &str) -> Option<Arc<ToolContract>> {
1034        definitions
1035            .into_iter()
1036            .find(|tool| tool.name() == name)
1037            .map(|tool| Arc::new(tool.contract()))
1038    }
1039
1040    fn dynamic_definition(name: &str) -> ToolDefinition {
1041        test_tool(name, "dynamic", crate::ToolAvailabilityConfig::callable())
1042    }
1043
1044    fn test_tool_context() -> crate::ToolContext<'static> {
1045        crate::ToolContext::builder(
1046            "registry-test".to_string(),
1047            Arc::new(crate::testing::MockSessionManager::default()),
1048            Arc::new(crate::testing::MockSessionManager::default()),
1049            Arc::new(crate::testing::MockSessionManager::default()),
1050            Arc::new(crate::UnavailableProcessService),
1051            Arc::new(crate::DefaultProcessCancelAbility),
1052            crate::runtime::RuntimeEffectControllerHandle::shared(Arc::new(
1053                crate::InlineRuntimeEffectController,
1054            )),
1055            Arc::new(crate::InMemoryAttachmentStore::new()),
1056            crate::DirectCompletionClient::unavailable(
1057                "direct completions are unavailable in this test context",
1058            ),
1059        )
1060        .build()
1061    }
1062
1063    #[async_trait::async_trait]
1064    impl ToolProvider for MockTool {
1065        fn tool_manifests(&self) -> Vec<ToolManifest> {
1066            manifests(vec![test_tool(
1067                "mock_tool",
1068                "mock",
1069                crate::ToolAvailabilityConfig::callable(),
1070            )])
1071        }
1072
1073        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1074            contract_from(
1075                vec![test_tool(
1076                    "mock_tool",
1077                    "mock",
1078                    crate::ToolAvailabilityConfig::callable(),
1079                )],
1080                name,
1081            )
1082        }
1083
1084        async fn execute(&self, _call: ToolCall<'_>) -> ToolResult {
1085            ToolResult::ok(serde_json::json!("ok"))
1086        }
1087    }
1088
1089    #[async_trait::async_trait]
1090    impl ToolProvider for MixedEnabledTool {
1091        fn tool_manifests(&self) -> Vec<ToolManifest> {
1092            manifests(vec![
1093                test_tool(
1094                    "enabled_tool",
1095                    "enabled",
1096                    crate::ToolAvailabilityConfig::callable(),
1097                ),
1098                test_tool(
1099                    "disabled_tool",
1100                    "disabled",
1101                    crate::ToolAvailabilityConfig::off(),
1102                ),
1103            ])
1104        }
1105
1106        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1107            contract_from(
1108                vec![
1109                    test_tool(
1110                        "enabled_tool",
1111                        "enabled",
1112                        crate::ToolAvailabilityConfig::callable(),
1113                    ),
1114                    test_tool(
1115                        "disabled_tool",
1116                        "disabled",
1117                        crate::ToolAvailabilityConfig::off(),
1118                    ),
1119                ],
1120                name,
1121            )
1122        }
1123
1124        async fn execute(&self, _call: ToolCall<'_>) -> ToolResult {
1125            ToolResult::ok(serde_json::json!("ok"))
1126        }
1127    }
1128
1129    #[async_trait::async_trait]
1130    impl ToolSourceExecutor for ExternalMockSource {
1131        fn id(&self) -> &str {
1132            "external"
1133        }
1134
1135        fn advertised_tools(&self) -> Vec<ToolManifest> {
1136            manifests(vec![ToolDefinition::raw_with_id(
1137                "tool:mcp__demo__search",
1138                "mcp__demo__search",
1139                "search",
1140                json!({
1141                    "type": "object",
1142                    "properties": {
1143                        "query": { "type": "string" }
1144                    },
1145                    "required": ["query"],
1146                    "additionalProperties": false
1147                }),
1148                json!({ "type": "object", "additionalProperties": true }),
1149            )])
1150        }
1151
1152        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1153            contract_from(
1154                vec![ToolDefinition::raw_with_id(
1155                    "tool:mcp__demo__search",
1156                    "mcp__demo__search",
1157                    "search",
1158                    json!({
1159                        "type": "object",
1160                        "properties": {
1161                            "query": { "type": "string" }
1162                        },
1163                        "required": ["query"],
1164                        "additionalProperties": false
1165                    }),
1166                    json!({ "type": "object", "additionalProperties": true }),
1167                )],
1168                name,
1169            )
1170        }
1171
1172        async fn execute(
1173            &self,
1174            tool: &str,
1175            args: &serde_json::Value,
1176            _context: &ToolContext<'_>,
1177            _progress: Option<&ProgressSender>,
1178        ) -> ToolResult {
1179            ToolResult::ok(json!({
1180                "tool": tool,
1181                "args": args
1182            }))
1183        }
1184    }
1185
1186    #[async_trait::async_trait]
1187    impl ToolSourceExecutor for ExactResolvingSource {
1188        fn id(&self) -> &str {
1189            "exact"
1190        }
1191
1192        fn advertised_tools(&self) -> Vec<ToolManifest> {
1193            Vec::new()
1194        }
1195
1196        fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
1197            self.manifest_resolutions.fetch_add(1, Ordering::SeqCst);
1198            (name == "host_only").then(|| {
1199                test_tool(
1200                    "host_only",
1201                    "host-only",
1202                    crate::ToolAvailabilityConfig::callable(),
1203                )
1204                .manifest()
1205            })
1206        }
1207
1208        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1209            self.contract_resolutions.fetch_add(1, Ordering::SeqCst);
1210            contract_from(
1211                vec![test_tool(
1212                    "host_only",
1213                    "host-only",
1214                    crate::ToolAvailabilityConfig::callable(),
1215                )],
1216                name,
1217            )
1218        }
1219
1220        async fn execute(
1221            &self,
1222            tool: &str,
1223            _args: &serde_json::Value,
1224            _context: &ToolContext<'_>,
1225            _progress: Option<&ProgressSender>,
1226        ) -> ToolResult {
1227            self.executions.fetch_add(1, Ordering::SeqCst);
1228            ToolResult::ok(json!(tool))
1229        }
1230    }
1231
1232    #[async_trait::async_trait]
1233    impl ToolSourceExecutor for NamedExactSource {
1234        fn id(&self) -> &str {
1235            self.id
1236        }
1237
1238        fn advertised_tools(&self) -> Vec<ToolManifest> {
1239            Vec::new()
1240        }
1241
1242        fn resolve_manifest(&self, name: &str) -> Option<ToolManifest> {
1243            (name == "host_only").then(|| {
1244                test_tool(
1245                    "host_only",
1246                    "host-only",
1247                    crate::ToolAvailabilityConfig::callable(),
1248                )
1249                .manifest()
1250            })
1251        }
1252
1253        fn resolve_contract(&self, _name: &str) -> Option<Arc<ToolContract>> {
1254            None
1255        }
1256
1257        async fn execute(
1258            &self,
1259            tool: &str,
1260            _args: &serde_json::Value,
1261            _context: &ToolContext<'_>,
1262            _progress: Option<&ProgressSender>,
1263        ) -> ToolResult {
1264            ToolResult::ok(json!(tool))
1265        }
1266    }
1267
1268    #[async_trait::async_trait]
1269    impl ToolProvider for DynamicToolProvider {
1270        fn tool_manifests(&self) -> Vec<ToolManifest> {
1271            self.names
1272                .lock()
1273                .expect("dynamic tool names lock")
1274                .iter()
1275                .map(|name| dynamic_definition(name).manifest())
1276                .collect()
1277        }
1278
1279        fn resolve_contract(&self, name: &str) -> Option<Arc<ToolContract>> {
1280            self.names
1281                .lock()
1282                .expect("dynamic tool names lock")
1283                .iter()
1284                .any(|tool_name| tool_name == name)
1285                .then(|| Arc::new(dynamic_definition(name).contract()))
1286        }
1287
1288        async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
1289            ToolResult::ok(json!(call.name))
1290        }
1291    }
1292
1293    #[test]
1294    fn registry_preserves_initial_availability_state() {
1295        let registry =
1296            ToolRegistry::from_tool_provider(Arc::new(MixedEnabledTool)).expect("registry");
1297        let snapshot = registry.export_state();
1298        assert_eq!(
1299            snapshot
1300                .get("enabled_tool")
1301                .unwrap()
1302                .manifest()
1303                .effective_availability(),
1304            crate::ToolAvailability::Callable
1305        );
1306        assert_eq!(
1307            snapshot
1308                .get("disabled_tool")
1309                .unwrap()
1310                .manifest()
1311                .effective_availability(),
1312            crate::ToolAvailability::Off
1313        );
1314    }
1315
1316    #[test]
1317    fn exported_tool_state_is_source_free() {
1318        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1319        registry
1320            .add_tool_provider(Arc::new(MixedEnabledTool))
1321            .expect("live provider registered");
1322
1323        let value = serde_json::to_value(registry.export_state()).expect("serialized tool state");
1324        let serialized = value.to_string();
1325
1326        assert!(!serialized.contains("source_id"));
1327        assert!(!serialized.contains(PLUGIN_SOURCE_ID));
1328        assert!(!serialized.contains("live:"));
1329    }
1330
1331    #[test]
1332    fn apply_state_rebinds_source_free_snapshot_to_current_sources() {
1333        let source_registry =
1334            ToolRegistry::from_tool_provider(Arc::new(MixedEnabledTool)).expect("source registry");
1335        let snapshot = source_registry.export_state();
1336
1337        let target_registry =
1338            ToolRegistry::from_tool_provider(Arc::new(MixedEnabledTool)).expect("target registry");
1339        let next_generation = target_registry
1340            .apply_state(snapshot.with_generation(target_registry.generation()))
1341            .expect("state rebound");
1342
1343        assert_eq!(next_generation, target_registry.generation());
1344        assert!(target_registry.resolve_contract("enabled_tool").is_some());
1345    }
1346
1347    #[test]
1348    fn apply_state_rejects_tools_not_advertised_by_source() {
1349        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1350        let snapshot = registry.export_state();
1351        let generation = snapshot.generation();
1352        let mut tools = snapshot.entries().clone();
1353        tools.insert(
1354            "missing".to_string(),
1355            ToolStateEntry::new(
1356                test_tool(
1357                    "missing",
1358                    "missing",
1359                    crate::ToolAvailabilityConfig::callable(),
1360                )
1361                .manifest(),
1362            ),
1363        );
1364        let snapshot = ToolState::new(generation, tools);
1365        assert!(matches!(
1366            registry.apply_state(snapshot),
1367            Err(ReconfigureError::Validation(_))
1368        ));
1369    }
1370
1371    #[test]
1372    fn apply_state_rejects_snapshot_when_provider_is_absent() {
1373        let source_registry =
1374            ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("source registry");
1375        source_registry
1376            .upsert_source(Arc::new(ExternalMockSource))
1377            .expect("source registered");
1378        let snapshot = source_registry.export_state();
1379
1380        let target_registry =
1381            ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target registry");
1382        let err = target_registry
1383            .apply_state(snapshot.with_generation(target_registry.generation()))
1384            .expect_err("missing provider should fail");
1385
1386        assert!(matches!(err, ReconfigureError::Validation(_)));
1387    }
1388
1389    #[test]
1390    fn apply_state_rejects_ambiguous_current_source_binding() {
1391        let registry = ToolRegistry::empty();
1392        registry
1393            .upsert_source(Arc::new(NamedExactSource { id: "exact-a" }))
1394            .expect("source a registered");
1395        registry
1396            .upsert_source(Arc::new(NamedExactSource { id: "exact-b" }))
1397            .expect("source b registered");
1398
1399        let mut tools = BTreeMap::new();
1400        tools.insert(
1401            "host_only".to_string(),
1402            ToolStateEntry::new(
1403                test_tool(
1404                    "host_only",
1405                    "host-only",
1406                    crate::ToolAvailabilityConfig::callable(),
1407                )
1408                .manifest(),
1409            ),
1410        );
1411
1412        let err = registry
1413            .apply_state(ToolState::new(registry.generation(), tools))
1414            .expect_err("ambiguous source binding should fail");
1415
1416        assert!(matches!(err, ReconfigureError::Validation(_)));
1417    }
1418
1419    #[test]
1420    fn advertised_manifest_resolves_without_exact_host_lookup() {
1421        let manifest_resolutions = Arc::new(AtomicUsize::new(0));
1422        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1423        registry
1424            .upsert_source(Arc::new(ExactResolvingSource {
1425                manifest_resolutions: Arc::clone(&manifest_resolutions),
1426                contract_resolutions: Arc::new(AtomicUsize::new(0)),
1427                executions: Arc::new(AtomicUsize::new(0)),
1428            }))
1429            .expect("source registered");
1430
1431        assert_eq!(
1432            registry
1433                .resolve_manifest("mock_tool")
1434                .map(|manifest| manifest.name),
1435            Some("mock_tool".to_string())
1436        );
1437        assert_eq!(manifest_resolutions.load(Ordering::SeqCst), 0);
1438    }
1439
1440    #[test]
1441    fn refresh_sources_re_reads_group_provider_manifests() {
1442        let names = Arc::new(std::sync::Mutex::new(vec!["dynamic_one".to_string()]));
1443        let provider: Arc<dyn ToolProvider> = Arc::new(DynamicToolProvider {
1444            names: Arc::clone(&names),
1445        });
1446        let registry = ToolRegistry::from_tool_providers(vec![provider]).expect("registry");
1447
1448        let tool_names = || {
1449            registry
1450                .tool_manifests()
1451                .into_iter()
1452                .map(|manifest| manifest.name)
1453                .collect::<BTreeSet<_>>()
1454        };
1455
1456        assert!(tool_names().contains("dynamic_one"));
1457        assert!(!tool_names().contains("dynamic_two"));
1458
1459        names
1460            .lock()
1461            .expect("dynamic tool names lock")
1462            .push("dynamic_two".to_string());
1463        registry.refresh_sources().expect("refresh sources");
1464        let refreshed = tool_names();
1465        assert!(refreshed.contains("dynamic_one"));
1466        assert!(refreshed.contains("dynamic_two"));
1467
1468        names
1469            .lock()
1470            .expect("dynamic tool names lock")
1471            .retain(|name| name != "dynamic_one");
1472        registry.refresh_sources().expect("refresh sources");
1473        let refreshed = tool_names();
1474        assert!(!refreshed.contains("dynamic_one"));
1475        assert!(refreshed.contains("dynamic_two"));
1476    }
1477
1478    #[tokio::test]
1479    async fn unknown_manifest_exact_resolves_and_routes_to_owner() {
1480        let manifest_resolutions = Arc::new(AtomicUsize::new(0));
1481        let contract_resolutions = Arc::new(AtomicUsize::new(0));
1482        let executions = Arc::new(AtomicUsize::new(0));
1483        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1484        registry
1485            .upsert_source(Arc::new(ExactResolvingSource {
1486                manifest_resolutions: Arc::clone(&manifest_resolutions),
1487                contract_resolutions: Arc::clone(&contract_resolutions),
1488                executions: Arc::clone(&executions),
1489            }))
1490            .expect("source registered");
1491
1492        assert_eq!(
1493            registry
1494                .resolve_manifest("host_only")
1495                .map(|manifest| manifest.name),
1496            Some("host_only".to_string())
1497        );
1498        assert_eq!(manifest_resolutions.load(Ordering::SeqCst), 1);
1499
1500        let contract = registry.resolve_contract("host_only");
1501        assert!(contract.is_some());
1502        assert_eq!(manifest_resolutions.load(Ordering::SeqCst), 1);
1503        assert_eq!(contract_resolutions.load(Ordering::SeqCst), 1);
1504
1505        let context = test_tool_context();
1506        let args = json!({});
1507        let result = registry
1508            .execute(crate::ToolCall {
1509                name: "host_only",
1510                args: &args,
1511                context: &context,
1512                progress: None,
1513            })
1514            .await;
1515        assert!(result.is_success());
1516        assert_eq!(result.value_for_projection(), json!("host_only"));
1517        assert_eq!(executions.load(Ordering::SeqCst), 1);
1518    }
1519
1520    #[test]
1521    fn unknown_manifest_without_host_resolver_is_unavailable() {
1522        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1523
1524        assert!(registry.resolve_manifest("missing").is_none());
1525        assert!(registry.resolve_contract("missing").is_none());
1526    }
1527
1528    #[tokio::test]
1529    async fn upsert_source_registers_and_executes_external_tools() {
1530        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1531        registry
1532            .upsert_source(Arc::new(ExternalMockSource))
1533            .expect("source registered");
1534
1535        let defs = registry.tool_manifests();
1536        assert!(defs.iter().any(|def| def.name == "mcp__demo__search"));
1537
1538        let context = test_tool_context();
1539        let args = json!({ "query": "hello" });
1540        let result = registry
1541            .execute(crate::ToolCall {
1542                name: "mcp__demo__search",
1543                args: &args,
1544                context: &context,
1545                progress: None,
1546            })
1547            .await;
1548        assert!(result.is_success());
1549        assert_eq!(
1550            result.value_for_projection()["tool"],
1551            json!("mcp__demo__search")
1552        );
1553        assert_eq!(
1554            result.value_for_projection()["args"]["query"],
1555            json!("hello")
1556        );
1557    }
1558
1559    #[test]
1560    fn upsert_source_preserves_availability_override_on_refresh() {
1561        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1562        registry
1563            .upsert_source(Arc::new(ExternalMockSource))
1564            .expect("source registered");
1565        let mut snapshot = registry.export_state();
1566        snapshot
1567            .set_availability("mcp__demo__search", Some(crate::ToolAvailability::Off))
1568            .unwrap();
1569        registry.apply_state(snapshot).unwrap();
1570        registry
1571            .upsert_source(Arc::new(ExternalMockSource))
1572            .expect("source refreshed");
1573        let snapshot = registry.export_state();
1574        assert_eq!(
1575            snapshot
1576                .get("mcp__demo__search")
1577                .unwrap()
1578                .manifest()
1579                .effective_availability(),
1580            crate::ToolAvailability::Off
1581        );
1582    }
1583
1584    #[test]
1585    fn restore_state_adopts_generation_at_or_above_three() {
1586        // Cold rebuild ratchet: a session whose tool surface advanced to
1587        // generation >= 3 restores onto a fresh base-1 registry. `restore_state`
1588        // adopts the snapshot's generation verbatim; `apply_state` (a gen-matched
1589        // delta) rejects it. This is the exact divergence the durable worker /
1590        // session resume rebuild relies on `restore_state` to absorb.
1591        let source = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("source registry");
1592        let snapshot = source.export_state().with_generation(3);
1593
1594        let target = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("target registry");
1595        assert_eq!(
1596            target.generation(),
1597            1,
1598            "a fresh registry starts at generation 1"
1599        );
1600        let restored = target
1601            .restore_state(snapshot.clone())
1602            .expect("restore adopts the snapshot generation");
1603        assert_eq!(restored, 3, "restore returns the adopted generation");
1604        assert_eq!(
1605            target.generation(),
1606            3,
1607            "restore adopts gen 3 onto a base-1 registry without bumping"
1608        );
1609        // A re-export round-trips at the same generation (idempotent).
1610        assert_eq!(target.export_state().generation(), 3);
1611
1612        // apply_state on the same high-generation snapshot is rejected — proving
1613        // the rebuild would have failed without restore_state.
1614        let fresh = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("fresh registry");
1615        assert!(
1616            matches!(
1617                fresh.apply_state(snapshot),
1618                Err(ReconfigureError::GenerationMismatch {
1619                    expected: 3,
1620                    actual: 1
1621                })
1622            ),
1623            "apply_state must reject a gen-3 snapshot on a base-1 registry"
1624        );
1625    }
1626
1627    #[test]
1628    fn remove_source_removes_all_source_tools() {
1629        let registry = ToolRegistry::from_tool_provider(Arc::new(MockTool)).expect("registry");
1630        registry
1631            .upsert_source(Arc::new(ExternalMockSource))
1632            .expect("source registered");
1633        registry
1634            .remove_source_id("external")
1635            .expect("source removed");
1636        let defs = registry.tool_manifests();
1637        assert!(!defs.iter().any(|def| def.name == "mcp__demo__search"));
1638    }
1639
1640    #[test]
1641    fn project_tool_catalog_keeps_searchable_tools_with_surface_metadata() {
1642        fn dummy_tool(name: &str) -> crate::ToolDefinition {
1643            let tool = crate::ToolDefinition::raw_with_id(
1644                format!("tool:{name}"),
1645                name,
1646                format!("desc for {name}"),
1647                crate::ToolDefinition::default_input_schema(),
1648                serde_json::json!({}),
1649            );
1650            match name {
1651                "read_file" => {
1652                    tool.with_agent_surface(crate::ToolAgentSurface::new(["files"], "read"))
1653                }
1654                "search_tools" => {
1655                    tool.with_agent_surface(crate::ToolAgentSurface::new(["tools"], "search"))
1656                }
1657                _ => tool,
1658            }
1659        }
1660        let catalog = project_tool_catalog([
1661            crate::ToolSurfaceEntry {
1662                manifest: dummy_tool("read_file").manifest(),
1663                availability: crate::ToolAvailability::Showcased,
1664            },
1665            crate::ToolSurfaceEntry {
1666                manifest: dummy_tool("search_tools").manifest(),
1667                availability: crate::ToolAvailability::Callable,
1668            },
1669        ]);
1670        assert_eq!(catalog.len(), 2);
1671        assert_eq!(catalog[0]["name"], serde_json::json!("read_file"));
1672        assert_eq!(
1673            catalog[0]["contract"]["signature"],
1674            serde_json::json!("await files.read({})?")
1675        );
1676        assert_eq!(catalog[0]["showcased"], serde_json::json!(true));
1677        assert_eq!(catalog[1]["callable"], serde_json::json!(true));
1678    }
1679
1680    #[test]
1681    fn project_tool_catalog_preserves_dynamic_output_contracts() {
1682        fn dummy_tool(name: &str) -> crate::ToolDefinition {
1683            crate::ToolDefinition::raw_with_id(
1684                format!("tool:{name}"),
1685                name,
1686                format!("desc for {name}"),
1687                crate::ToolDefinition::default_input_schema(),
1688                serde_json::json!({}),
1689            )
1690            .with_agent_surface(crate::ToolAgentSurface::new(["llm"], "query"))
1691        }
1692        let catalog = project_tool_catalog([crate::ToolSurfaceEntry {
1693            manifest: dummy_tool("llm_query")
1694                .with_output_from_input_schema(
1695                    "output",
1696                    Some(serde_json::json!({ "type": "string" })),
1697                )
1698                .manifest(),
1699            availability: crate::ToolAvailability::Searchable,
1700        }]);
1701
1702        assert_eq!(
1703            catalog[0]["contract"]["signature"],
1704            serde_json::json!("await llm.query<T = str>({})?")
1705        );
1706        assert_eq!(catalog[0]["contract"]["returns"], serde_json::json!("T"));
1707    }
1708}
1709
1710pub(crate) fn project_tool_catalog<I>(entries: I) -> Vec<serde_json::Value>
1711where
1712    I: IntoIterator<Item = crate::ToolSurfaceEntry>,
1713{
1714    entries
1715        .into_iter()
1716        .filter(|entry| entry.availability.is_searchable())
1717        .map(|entry| {
1718            let manifest = entry.manifest;
1719            let availability = entry.availability;
1720            let agent_surface = manifest.agent_surface.executable_for(&manifest.name);
1721            let call = agent_surface.call_path();
1722            let mut projected = serde_json::json!({
1723                "id": manifest.id,
1724                "name": manifest.name,
1725                "module_path": agent_surface.module_path,
1726                "operation": agent_surface.operation,
1727                "authority_type": agent_surface.authority_type,
1728                "call": call,
1729                "description": manifest.description,
1730                "aliases": agent_surface.aliases,
1731                "availability": availability,
1732                "callable": availability.is_callable(),
1733                "showcased": availability.is_showcased(),
1734                "searchable": availability.is_searchable(),
1735                "activation": manifest.activation,
1736            });
1737            if let Some(contract) = manifest.compact_contract {
1738                projected
1739                    .as_object_mut()
1740                    .expect("projected tool catalog entry is an object")
1741                    .insert("contract".to_string(), serde_json::json!(contract));
1742            }
1743            projected
1744        })
1745        .collect()
1746}