Skip to main content

tea_tools/
registry.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use futures_util::{StreamExt, stream};
5use tea_control::CancellationScope;
6#[cfg(feature = "model-projection")]
7use tea_model::{HostedToolOptions, ModelRequestError, ModelSpec, ModelToolDefinition};
8#[cfg(feature = "model-projection")]
9use tea_protocol::ModelId;
10use thiserror::Error;
11
12use crate::{
13    BoxToolExecutionStream, CompiledToolSchema, SchemaCompilationError, SchemaValidationFailure,
14    ToolExecutionEvent, ToolExecutionFailure, ToolExecutor, ToolInvocation, ToolName,
15    ToolResourceError, ToolResourceResolver, ToolSpec, ValidatedToolInvocation,
16};
17use tea_protocol::ToolPresentation;
18
19#[derive(Debug)]
20struct RegisteredTool {
21    spec: Arc<ToolSpec>,
22    input: CompiledToolSchema,
23    output: CompiledToolSchema,
24    binding: ToolBinding,
25}
26
27type ClientBindingRefs<'a> = (&'a Arc<dyn ToolResourceResolver>, &'a Arc<dyn ToolExecutor>);
28
29/// Preferred route for a hybrid tool with hosted and client implementations.
30#[cfg(feature = "model-projection")]
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum ToolRoutePreference {
33    /// Use provider-hosted execution when supported, otherwise use the client route.
34    PreferHosted,
35    /// Require the client route even when hosted execution is available.
36    ForceClient,
37}
38
39/// Complete executable binding associated with one portable [`ToolSpec`].
40#[derive(Debug, Clone)]
41pub enum ToolBinding {
42    /// A function tool resolved and executed by the client runtime.
43    Client {
44        /// Resource resolver used before policy evaluation.
45        resolver: Arc<dyn ToolResourceResolver>,
46        /// Local executor invoked after policy approval.
47        executor: Arc<dyn ToolExecutor>,
48    },
49    /// A tool executed entirely inside a capable model provider.
50    #[cfg(feature = "model-projection")]
51    Hosted {
52        /// Provider-neutral hosted tool kind and common policy.
53        options: HostedToolOptions,
54    },
55    /// A tool with both provider-hosted and real client execution routes.
56    #[cfg(feature = "model-projection")]
57    Hybrid {
58        /// Provider-neutral hosted tool kind and common policy.
59        options: HostedToolOptions,
60        /// Resource resolver for the client fallback.
61        resolver: Arc<dyn ToolResourceResolver>,
62        /// Executor for the client fallback.
63        executor: Arc<dyn ToolExecutor>,
64        /// Route preference applied when freezing a model request.
65        preference: ToolRoutePreference,
66    },
67}
68
69impl ToolBinding {
70    /// Creates a client-only binding.
71    #[must_use]
72    pub fn client(
73        resolver: Arc<dyn ToolResourceResolver>,
74        executor: Arc<dyn ToolExecutor>,
75    ) -> Self {
76        Self::Client { resolver, executor }
77    }
78
79    /// Creates a provider-hosted-only binding.
80    #[cfg(feature = "model-projection")]
81    #[must_use]
82    pub const fn hosted(options: HostedToolOptions) -> Self {
83        Self::Hosted { options }
84    }
85
86    /// Creates a binding with hosted and client routes.
87    #[cfg(feature = "model-projection")]
88    #[must_use]
89    pub fn hybrid(
90        options: HostedToolOptions,
91        preference: ToolRoutePreference,
92        resolver: Arc<dyn ToolResourceResolver>,
93        executor: Arc<dyn ToolExecutor>,
94    ) -> Self {
95        Self::Hybrid {
96            options,
97            resolver,
98            executor,
99            preference,
100        }
101    }
102
103    /// Returns whether this binding has a real client execution route.
104    #[must_use]
105    pub const fn has_client_execution(&self) -> bool {
106        match self {
107            Self::Client { .. } => true,
108            #[cfg(feature = "model-projection")]
109            Self::Hosted { .. } => false,
110            #[cfg(feature = "model-projection")]
111            Self::Hybrid { .. } => true,
112        }
113    }
114
115    fn client_parts(&self) -> Option<ClientBindingRefs<'_>> {
116        match self {
117            Self::Client { resolver, executor } => Some((resolver, executor)),
118            #[cfg(feature = "model-projection")]
119            Self::Hosted { .. } => None,
120            #[cfg(feature = "model-projection")]
121            Self::Hybrid {
122                resolver, executor, ..
123            } => Some((resolver, executor)),
124        }
125    }
126
127    #[cfg(feature = "model-projection")]
128    const fn hosted_options(&self) -> Option<&HostedToolOptions> {
129        match self {
130            Self::Client { .. } => None,
131            Self::Hosted { options } | Self::Hybrid { options, .. } => Some(options),
132        }
133    }
134}
135
136/// Deterministic active tool registry.
137#[derive(Debug, Default)]
138pub struct ToolRegistry {
139    tools: BTreeMap<ToolName, RegisteredTool>,
140}
141
142impl ToolRegistry {
143    /// Creates an empty registry.
144    #[must_use]
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    /// Registers one complete tool contract atomically.
150    ///
151    /// # Errors
152    ///
153    /// Returns an error for duplicate/conflicting names or invalid schemas.
154    pub fn register(
155        &mut self,
156        spec: ToolSpec,
157        resolver: Arc<dyn ToolResourceResolver>,
158        executor: Arc<dyn ToolExecutor>,
159    ) -> Result<(), ToolRegistryError> {
160        self.register_binding(spec, ToolBinding::client(resolver, executor))
161    }
162
163    /// Registers one complete tool specification and execution binding atomically.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error for duplicate/conflicting names, invalid schemas, or a
168    /// hosted binding whose stable name differs from the specification.
169    pub fn register_binding(
170        &mut self,
171        spec: ToolSpec,
172        binding: ToolBinding,
173    ) -> Result<(), ToolRegistryError> {
174        if let Some(existing) = self.tools.get(spec.name()) {
175            return if existing.spec.version() == spec.version() {
176                Err(ToolRegistryError::DuplicateTool)
177            } else {
178                Err(ToolRegistryError::VersionConflict)
179            };
180        }
181        #[cfg(feature = "model-projection")]
182        if binding
183            .hosted_options()
184            .is_some_and(|options| spec.name().as_str() != options.kind().name())
185        {
186            return Err(ToolRegistryError::HostedToolNameMismatch);
187        }
188        let input = CompiledToolSchema::compile(spec.input_schema().clone())?;
189        let output = CompiledToolSchema::compile(spec.output_schema().clone())?;
190        let name = spec.name().clone();
191        self.tools.insert(
192            name,
193            RegisteredTool {
194                spec: Arc::new(spec),
195                input,
196                output,
197                binding,
198            },
199        );
200        Ok(())
201    }
202
203    /// Registers a provider-hosted-only tool.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error under the same conditions as [`Self::register_binding`].
208    #[cfg(feature = "model-projection")]
209    pub fn register_hosted(
210        &mut self,
211        spec: ToolSpec,
212        options: HostedToolOptions,
213    ) -> Result<(), ToolRegistryError> {
214        self.register_binding(spec, ToolBinding::hosted(options))
215    }
216
217    /// Registers a tool with hosted and client execution routes.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error under the same conditions as [`Self::register_binding`].
222    #[cfg(feature = "model-projection")]
223    pub fn register_hybrid(
224        &mut self,
225        spec: ToolSpec,
226        options: HostedToolOptions,
227        preference: ToolRoutePreference,
228        resolver: Arc<dyn ToolResourceResolver>,
229        executor: Arc<dyn ToolExecutor>,
230    ) -> Result<(), ToolRegistryError> {
231        self.register_binding(
232            spec,
233            ToolBinding::hybrid(options, preference, resolver, executor),
234        )
235    }
236
237    /// Returns registered names in canonical sorted order.
238    pub fn names(&self) -> impl Iterator<Item = &ToolName> {
239        self.tools.keys()
240    }
241
242    /// Returns registered specifications in canonical tool-name order.
243    pub fn specs(&self) -> impl Iterator<Item = &ToolSpec> {
244        self.tools.values().map(|tool| tool.spec.as_ref())
245    }
246
247    /// Projects active tools for one selected model in canonical name order.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error when an active tool has no route supported by the model
252    /// or a model-layer definition violates stricter bounds.
253    #[cfg(feature = "model-projection")]
254    pub fn model_definitions(
255        &self,
256        model: &ModelSpec,
257    ) -> Result<Vec<ModelToolDefinition>, ToolRegistryError> {
258        self.tools
259            .values()
260            .map(|tool| project_model_definition(tool, model))
261            .collect()
262    }
263
264    /// Validates arguments and resolves resources without executing side effects.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error for unknown tools, invalid arguments, or resource failures.
269    pub fn validate(
270        &self,
271        invocation: ToolInvocation,
272    ) -> Result<ValidatedToolInvocation, ToolRegistryError> {
273        let registered = self
274            .tools
275            .get(invocation.name())
276            .ok_or(ToolRegistryError::UnknownTool)?;
277        let (resolver, _) = registered
278            .binding
279            .client_parts()
280            .ok_or(ToolRegistryError::HostedToolNotClientExecutable)?;
281        registered
282            .input
283            .validate(invocation.arguments())
284            .map_err(ToolRegistryError::InvalidArguments)?;
285        let mut resources = resolver.resolve(invocation.name(), invocation.arguments())?;
286        resources.sort();
287        resources.dedup();
288        if resources.len() > crate::MAX_TOOL_RESOURCES {
289            return Err(ToolRegistryError::Resources(
290                ToolResourceError::TooManyResources,
291            ));
292        }
293        Ok(ValidatedToolInvocation::new(
294            invocation,
295            Arc::clone(&registered.spec),
296            resources,
297        ))
298    }
299
300    /// Validates, resolves, executes, and enforces terminal output schema.
301    ///
302    /// # Errors
303    ///
304    /// Returns an error before execution for unknown tools, invalid arguments,
305    /// or resource failures. In-stream executor failures remain terminal events.
306    pub fn execute(
307        &self,
308        invocation: ToolInvocation,
309        cancellation: CancellationScope,
310    ) -> Result<BoxToolExecutionStream, ToolRegistryError> {
311        let validated = self.validate(invocation)?;
312        self.execute_validated(validated, cancellation)
313    }
314
315    /// Executes an invocation already validated by this registry.
316    ///
317    /// This is the policy-safe boundary: callers can evaluate one immutable
318    /// [`ValidatedToolInvocation`] and execute that exact value without running
319    /// schema validation or resource resolution a second time.
320    ///
321    /// # Errors
322    ///
323    /// Returns an error when the validated tool is no longer registered with
324    /// the same specification.
325    pub fn execute_validated(
326        &self,
327        invocation: ValidatedToolInvocation,
328        cancellation: CancellationScope,
329    ) -> Result<BoxToolExecutionStream, ToolRegistryError> {
330        let registered = self
331            .tools
332            .get(invocation.name())
333            .filter(|tool| tool.spec.as_ref() == invocation.spec())
334            .ok_or(ToolRegistryError::UnknownTool)?;
335        let (_, executor) = registered
336            .binding
337            .client_parts()
338            .ok_or(ToolRegistryError::HostedToolNotClientExecutable)?;
339        let upstream = executor.execute(invocation, cancellation);
340        let output = registered.output.clone();
341        let state = ExecutionValidationState {
342            upstream,
343            output,
344            done: false,
345        };
346        Ok(Box::pin(stream::unfold(state, |mut state| async move {
347            if state.done {
348                return None;
349            }
350            let Some(event) = state.upstream.next().await else {
351                state.done = true;
352                return Some((
353                    ToolExecutionEvent::Failed(ToolExecutionFailure::internal_contract()),
354                    state,
355                ));
356            };
357            let event = match event {
358                ToolExecutionEvent::Finished(result) => {
359                    state.done = true;
360                    if state.output.validate(result.output()).is_ok() {
361                        ToolExecutionEvent::Finished(result)
362                    } else {
363                        ToolExecutionEvent::Failed(ToolExecutionFailure::invalid_output())
364                    }
365                }
366                ToolExecutionEvent::Failed(failure) => {
367                    state.done = true;
368                    ToolExecutionEvent::Failed(failure)
369                }
370                ToolExecutionEvent::Progress(progress) => ToolExecutionEvent::Progress(progress),
371            };
372            Some((event, state))
373        })))
374    }
375
376    /// Produces a non-durable preview for an invocation already validated by
377    /// this registry.
378    ///
379    /// The registry identity check keeps preview generation on the same frozen
380    /// tool contract that policy evaluated. Preview failures are represented by
381    /// `None` at the executor boundary and never alter execution semantics.
382    #[must_use]
383    pub fn preview_validated(
384        &self,
385        invocation: &ValidatedToolInvocation,
386    ) -> Option<ToolPresentation> {
387        self.tools
388            .get(invocation.name())
389            .filter(|tool| tool.spec.as_ref() == invocation.spec())
390            .and_then(|tool| tool.binding.client_parts())
391            .and_then(|(_, executor)| executor.preview(invocation))
392    }
393}
394
395#[cfg(feature = "model-projection")]
396fn project_model_definition(
397    tool: &RegisteredTool,
398    model: &ModelSpec,
399) -> Result<ModelToolDefinition, ToolRegistryError> {
400    let capabilities = model.capabilities();
401    match &tool.binding {
402        ToolBinding::Client { .. } => {
403            if capabilities.supports_tools() {
404                function_definition(tool)
405            } else {
406                Err(no_supported_tool_route(tool, model))
407            }
408        }
409        ToolBinding::Hosted { options } => {
410            if capabilities.supports_hosted_tool(options.kind()) {
411                hosted_definition(tool, options)
412            } else {
413                Err(no_supported_tool_route(tool, model))
414            }
415        }
416        ToolBinding::Hybrid {
417            options,
418            preference,
419            ..
420        } => {
421            let select_hosted = matches!(preference, ToolRoutePreference::PreferHosted)
422                && capabilities.supports_hosted_tool(options.kind());
423            if select_hosted {
424                hosted_definition(tool, options)
425            } else if capabilities.supports_tools() {
426                function_definition(tool)
427            } else {
428                Err(no_supported_tool_route(tool, model))
429            }
430        }
431    }
432}
433
434#[cfg(feature = "model-projection")]
435fn no_supported_tool_route(tool: &RegisteredTool, model: &ModelSpec) -> ToolRegistryError {
436    ToolRegistryError::NoSupportedToolRoute {
437        tool: tool.spec.name().clone(),
438        model: model.model_id().clone(),
439    }
440}
441
442#[cfg(feature = "model-projection")]
443fn function_definition(tool: &RegisteredTool) -> Result<ModelToolDefinition, ToolRegistryError> {
444    ModelToolDefinition::new(
445        tool.spec.name().as_str(),
446        tool.spec.description(),
447        tool.spec.input_schema().clone(),
448    )
449    .map_err(ToolRegistryError::ModelProjection)
450}
451
452#[cfg(feature = "model-projection")]
453fn hosted_definition(
454    tool: &RegisteredTool,
455    options: &HostedToolOptions,
456) -> Result<ModelToolDefinition, ToolRegistryError> {
457    ModelToolDefinition::hosted(
458        tool.spec.description(),
459        tool.spec.input_schema().clone(),
460        options.clone(),
461    )
462    .map_err(ToolRegistryError::ModelProjection)
463}
464
465struct ExecutionValidationState {
466    upstream: BoxToolExecutionStream,
467    output: CompiledToolSchema,
468    done: bool,
469}
470
471/// Registry validation, conflict, or resource failure.
472#[derive(Debug, PartialEq, Eq, Error)]
473pub enum ToolRegistryError {
474    /// No active tool has this name.
475    #[error("tool is not registered")]
476    UnknownTool,
477    /// Same name/version is already registered.
478    #[error("tool is already registered")]
479    DuplicateTool,
480    /// Same name has another active version.
481    #[error("tool name has a version conflict")]
482    VersionConflict,
483    /// A hosted binding does not use its kind's stable tool name.
484    #[cfg(feature = "model-projection")]
485    #[error("hosted tool name does not match its capability kind")]
486    HostedToolNameMismatch,
487    /// The selected model supports neither the hosted nor client route.
488    #[cfg(feature = "model-projection")]
489    #[error(
490        "active tool {tool} has no execution route supported by selected model {model}; declare the model capability or configure a supported client route"
491    )]
492    NoSupportedToolRoute {
493        /// Active tool that could not be projected.
494        tool: ToolName,
495        /// Selected model that lacks a usable route.
496        model: ModelId,
497    },
498    /// A provider-hosted-only tool was sent to the client execution boundary.
499    #[error("hosted tool has no client execution route")]
500    HostedToolNotClientExecutable,
501    /// A projected model definition violates model request bounds.
502    #[cfg(feature = "model-projection")]
503    #[error("tool cannot be projected into the model request: {0}")]
504    ModelProjection(ModelRequestError),
505    /// Input/output schema cannot compile.
506    #[error("tool schema cannot compile: {0}")]
507    Schema(#[from] SchemaCompilationError),
508    /// Arguments violate the registered input schema.
509    #[error("tool arguments are invalid: {0}")]
510    InvalidArguments(SchemaValidationFailure),
511    /// Resource resolution failed.
512    #[error("tool resource resolution failed: {0}")]
513    Resources(#[from] ToolResourceError),
514}