Skip to main content

tea_tools/
spec.rs

1use std::collections::BTreeSet;
2use std::fmt;
3use std::str::FromStr;
4
5use semver::Version;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use serde_json::Value;
8#[cfg(feature = "model-projection")]
9use tea_model::{ModelRequestError, ModelToolDefinition};
10use tea_protocol::ToolIdempotency;
11use thiserror::Error;
12
13use crate::{ToolEffect, ToolSource};
14
15const MAX_TOOL_NAME_BYTES: usize = 128;
16const MAX_TOOL_LABEL_BYTES: usize = 256;
17const MAX_TOOL_DESCRIPTION_BYTES: usize = 16 * 1024;
18const MAX_TOOL_HINT_BYTES: usize = 16 * 1024;
19const MAX_TOOL_PROMPT_GUIDELINES: usize = 16;
20const MAX_TOOL_PROMPT_GUIDELINE_BYTES: usize = 1024;
21const MAX_TOOL_PROMPT_GUIDELINES_BYTES: usize = 16 * 1024;
22const MAX_RENDERER_ID_BYTES: usize = 128;
23const MAX_TOOL_SCHEMA_BYTES: usize = 256 * 1024;
24const MAX_TOOL_SCHEMA_DEPTH: usize = 32;
25const MAX_TOOL_EFFECTS: usize = 64;
26const MAX_TOOL_TIMEOUT_MILLIS: u64 = 86_400_000;
27
28/// Stable canonical tool name used for registry lookup.
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct ToolName(String);
31
32impl ToolName {
33    /// Returns the canonical name.
34    #[must_use]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl FromStr for ToolName {
41    type Err = ToolIdentityParseError;
42
43    fn from_str(value: &str) -> Result<Self, Self::Err> {
44        let mut bytes = value.bytes();
45        if value.len() > MAX_TOOL_NAME_BYTES
46            || !bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
47            || !bytes.all(|byte| {
48                byte.is_ascii_lowercase()
49                    || byte.is_ascii_digit()
50                    || matches!(byte, b'_' | b'-' | b'.')
51            })
52        {
53            return Err(ToolIdentityParseError::InvalidName);
54        }
55        Ok(Self(value.to_owned()))
56    }
57}
58
59impl Serialize for ToolName {
60    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
61    where
62        S: Serializer,
63    {
64        serializer.serialize_str(&self.0)
65    }
66}
67
68impl<'de> Deserialize<'de> for ToolName {
69    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
70    where
71        D: Deserializer<'de>,
72    {
73        String::deserialize(deserializer)?
74            .parse()
75            .map_err(serde::de::Error::custom)
76    }
77}
78
79impl fmt::Display for ToolName {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        formatter.write_str(&self.0)
82    }
83}
84
85/// Semantic version of one tool contract.
86#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
87#[serde(transparent)]
88pub struct ToolVersion(Version);
89
90impl ToolVersion {
91    /// Returns the parsed semantic version.
92    #[must_use]
93    pub const fn as_semver(&self) -> &Version {
94        &self.0
95    }
96}
97
98impl FromStr for ToolVersion {
99    type Err = ToolIdentityParseError;
100
101    fn from_str(value: &str) -> Result<Self, Self::Err> {
102        let version = Version::parse(value).map_err(|_| ToolIdentityParseError::InvalidVersion)?;
103        if version.to_string() != value {
104            return Err(ToolIdentityParseError::InvalidVersion);
105        }
106        Ok(Self(version))
107    }
108}
109
110impl fmt::Display for ToolVersion {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        self.0.fmt(formatter)
113    }
114}
115
116/// Error returned when parsing tool identity values.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
118pub enum ToolIdentityParseError {
119    /// Tool name is empty, oversized, or not canonical lowercase ASCII.
120    #[error("tool name is not canonical")]
121    InvalidName,
122    /// Tool version is not canonical semantic version text.
123    #[error("tool version is not canonical semantic version text")]
124    InvalidVersion,
125}
126
127/// Whether and by whom a failed or interrupted invocation may be retried.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum ToolRetrySafety {
130    /// The invocation must never be retried automatically or explicitly.
131    Never,
132    /// A higher layer may retry only after an explicit informed decision.
133    ExplicitOnly,
134    /// The runtime may automatically retry a known-safe failure boundary.
135    Automatic,
136}
137
138/// Declared concurrency constraint for one tool.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum ToolConcurrency {
141    /// Independent invocations may execute concurrently.
142    Parallel,
143    /// Invocations execute serially with mutation/unknown work.
144    Serial,
145    /// Invocation requires an exclusive scheduler lane.
146    Exclusive,
147}
148
149/// Bounded tool timeout metadata consumed by a future kernel.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
151pub struct ToolTimeout(u64);
152
153impl ToolTimeout {
154    /// Creates timeout metadata from milliseconds.
155    ///
156    /// # Errors
157    ///
158    /// Returns an error for zero or values above 24 hours.
159    pub const fn from_millis(value: u64) -> Result<Self, ToolSpecError> {
160        if value == 0 || value > MAX_TOOL_TIMEOUT_MILLIS {
161            Err(ToolSpecError::InvalidTimeout)
162        } else {
163            Ok(Self(value))
164        }
165    }
166
167    /// Returns timeout milliseconds.
168    #[must_use]
169    pub const fn as_millis(self) -> u64 {
170        self.0
171    }
172}
173
174/// Recovery, retry, concurrency, and timeout semantics for a tool.
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub struct ToolExecutionSemantics {
177    idempotency: ToolIdempotency,
178    retry_safety: ToolRetrySafety,
179    concurrency: ToolConcurrency,
180    timeout: ToolTimeout,
181}
182
183impl ToolExecutionSemantics {
184    /// Creates validated execution semantics.
185    ///
186    /// # Errors
187    ///
188    /// Rejects automatic retry for non-idempotent invocations.
189    pub const fn new(
190        idempotency: ToolIdempotency,
191        retry_safety: ToolRetrySafety,
192        concurrency: ToolConcurrency,
193        timeout: ToolTimeout,
194    ) -> Result<Self, ToolSpecError> {
195        if matches!(idempotency, ToolIdempotency::NonIdempotent)
196            && matches!(retry_safety, ToolRetrySafety::Automatic)
197        {
198            return Err(ToolSpecError::UnsafeAutomaticRetry);
199        }
200        Ok(Self {
201            idempotency,
202            retry_safety,
203            concurrency,
204            timeout,
205        })
206    }
207
208    /// Returns idempotency/reconciliation semantics.
209    #[must_use]
210    pub const fn idempotency(self) -> ToolIdempotency {
211        self.idempotency
212    }
213
214    /// Returns retry safety.
215    #[must_use]
216    pub const fn retry_safety(self) -> ToolRetrySafety {
217        self.retry_safety
218    }
219
220    /// Returns concurrency constraint.
221    #[must_use]
222    pub const fn concurrency(self) -> ToolConcurrency {
223        self.concurrency
224    }
225
226    /// Returns timeout metadata.
227    #[must_use]
228    pub const fn timeout(self) -> ToolTimeout {
229        self.timeout
230    }
231}
232
233/// Conservative scheduler lane derived only from declared tool metadata.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum SchedulerClass {
236    /// Known read-only, parallel-safe work.
237    ParallelReadOnly,
238    /// Known mutation that is idempotent/reconciled and automatically retry-safe.
239    ParallelRetrySafe,
240    /// Work must share a serial lane.
241    Serial,
242    /// Work requires an exclusive lane.
243    Exclusive,
244    /// Unknown effects require policy and serial scheduling.
245    PolicyRequired,
246}
247
248impl SchedulerClass {
249    /// Returns whether explicit policy evaluation is mandatory.
250    #[must_use]
251    pub const fn requires_policy(self) -> bool {
252        matches!(self, Self::PolicyRequired)
253    }
254
255    /// Returns whether this class permits concurrent execution.
256    #[must_use]
257    pub const fn allows_parallel_execution(self) -> bool {
258        matches!(self, Self::ParallelReadOnly | Self::ParallelRetrySafe)
259    }
260}
261
262/// Portable specification separated from tool executor behavior.
263#[derive(Debug, Clone, PartialEq)]
264pub struct ToolSpec {
265    name: ToolName,
266    version: ToolVersion,
267    label: Option<String>,
268    description: String,
269    input_schema: Value,
270    output_schema: Value,
271    effects: Vec<ToolEffect>,
272    source: ToolSource,
273    execution: ToolExecutionSemantics,
274    prompt_snippet: Option<String>,
275    prompt_guidelines: Vec<String>,
276    ui_renderer: Option<String>,
277}
278
279impl ToolSpec {
280    /// Creates a validated portable tool specification.
281    ///
282    /// # Errors
283    ///
284    /// Returns an error for invalid text, schemas, effects, or unsafe execution
285    /// semantics.
286    #[allow(clippy::too_many_arguments)]
287    pub fn new(
288        name: ToolName,
289        version: ToolVersion,
290        description: impl Into<String>,
291        input_schema: Value,
292        output_schema: Value,
293        effects: impl IntoIterator<Item = ToolEffect>,
294        execution: ToolExecutionSemantics,
295    ) -> Result<Self, ToolSpecError> {
296        let description = description.into();
297        validate_text(&description, MAX_TOOL_DESCRIPTION_BYTES)
298            .map_err(|()| ToolSpecError::InvalidDescription)?;
299        validate_object_schema(&input_schema)?;
300        validate_object_schema(&output_schema)?;
301        let effects = effects.into_iter().collect::<BTreeSet<_>>();
302        if effects.is_empty() {
303            return Err(ToolSpecError::MissingEffects);
304        }
305        if effects.len() > MAX_TOOL_EFFECTS {
306            return Err(ToolSpecError::TooManyEffects);
307        }
308        Ok(Self {
309            name,
310            version,
311            label: None,
312            description,
313            input_schema,
314            output_schema,
315            effects: effects.into_iter().collect(),
316            source: ToolSource::native_product(),
317            execution,
318            prompt_snippet: None,
319            prompt_guidelines: Vec::new(),
320            ui_renderer: None,
321        })
322    }
323
324    /// Replaces the default native provenance with one validated frozen source.
325    #[must_use]
326    pub fn with_source(mut self, source: ToolSource) -> Self {
327        self.source = source;
328        self
329    }
330
331    /// Adds a bounded human-readable label kept out of model tool definitions.
332    ///
333    /// # Errors
334    ///
335    /// Returns an error for empty, oversized, or null-containing text.
336    pub fn with_label(mut self, label: impl Into<String>) -> Result<Self, ToolSpecError> {
337        let label = label.into();
338        validate_text(&label, MAX_TOOL_LABEL_BYTES).map_err(|()| ToolSpecError::InvalidLabel)?;
339        self.label = Some(label);
340        Ok(self)
341    }
342
343    /// Adds a bounded model prompt hint.
344    ///
345    /// # Errors
346    ///
347    /// Returns an error for empty, oversized, or null-containing text.
348    pub fn with_prompt_hint(mut self, hint: impl Into<String>) -> Result<Self, ToolSpecError> {
349        let hint = hint.into();
350        validate_text(&hint, MAX_TOOL_HINT_BYTES).map_err(|()| ToolSpecError::InvalidPromptHint)?;
351        self.prompt_snippet = Some(hint);
352        Ok(self)
353    }
354
355    /// Adds one bounded model prompt snippet.
356    ///
357    /// # Errors
358    ///
359    /// Returns an error for empty, oversized, or null-containing text.
360    pub fn with_prompt_snippet(
361        mut self,
362        snippet: impl Into<String>,
363    ) -> Result<Self, ToolSpecError> {
364        let snippet = snippet.into();
365        validate_text(&snippet, MAX_TOOL_HINT_BYTES)
366            .map_err(|()| ToolSpecError::InvalidPromptSnippet)?;
367        self.prompt_snippet = Some(snippet);
368        Ok(self)
369    }
370
371    /// Adds bounded model prompt guidelines.
372    ///
373    /// # Errors
374    ///
375    /// Returns an error when a guideline is invalid or the collection exceeds
376    /// its documented bounds.
377    pub fn with_prompt_guidelines<I, S>(mut self, guidelines: I) -> Result<Self, ToolSpecError>
378    where
379        I: IntoIterator<Item = S>,
380        S: Into<String>,
381    {
382        let mut bounded = Vec::new();
383        let mut total_bytes = 0;
384        for guideline in guidelines {
385            if bounded.len() == MAX_TOOL_PROMPT_GUIDELINES {
386                return Err(ToolSpecError::TooManyPromptGuidelines);
387            }
388            let guideline = guideline.into();
389            validate_text(&guideline, MAX_TOOL_PROMPT_GUIDELINE_BYTES)
390                .map_err(|()| ToolSpecError::InvalidPromptGuideline)?;
391            total_bytes += guideline.len();
392            if total_bytes > MAX_TOOL_PROMPT_GUIDELINES_BYTES {
393                return Err(ToolSpecError::TooManyPromptGuidelines);
394            }
395            bounded.push(guideline);
396        }
397        self.prompt_guidelines = bounded;
398        Ok(self)
399    }
400
401    /// Adds a bounded renderer selector with no UI implementation dependency.
402    ///
403    /// # Errors
404    ///
405    /// Returns an error when the selector is not canonical.
406    pub fn with_ui_renderer(mut self, renderer: impl Into<String>) -> Result<Self, ToolSpecError> {
407        let renderer = renderer.into();
408        if renderer.is_empty()
409            || renderer.len() > MAX_RENDERER_ID_BYTES
410            || !renderer.bytes().all(|byte| {
411                byte.is_ascii_lowercase()
412                    || byte.is_ascii_digit()
413                    || matches!(byte, b'-' | b'_' | b'.')
414            })
415        {
416            return Err(ToolSpecError::InvalidRenderer);
417        }
418        self.ui_renderer = Some(renderer);
419        Ok(self)
420    }
421
422    /// Projects this specification into the model-facing tool contract.
423    ///
424    /// # Errors
425    ///
426    /// Returns an error if model-layer bounds are stricter than this contract.
427    #[cfg(feature = "model-projection")]
428    pub fn to_model_definition(&self) -> Result<ModelToolDefinition, ToolSpecError> {
429        ModelToolDefinition::new(
430            self.name.as_str(),
431            self.description.clone(),
432            self.input_schema.clone(),
433        )
434        .map_err(ToolSpecError::ModelProjection)
435    }
436
437    /// Derives conservative scheduler behavior from metadata only.
438    #[must_use]
439    pub fn scheduler_class(&self) -> SchedulerClass {
440        if self.effects.iter().any(ToolEffect::is_unknown) {
441            return SchedulerClass::PolicyRequired;
442        }
443        if matches!(self.execution.concurrency, ToolConcurrency::Exclusive) {
444            return SchedulerClass::Exclusive;
445        }
446        if matches!(self.execution.concurrency, ToolConcurrency::Serial)
447            || matches!(self.execution.idempotency, ToolIdempotency::NonIdempotent)
448        {
449            return SchedulerClass::Serial;
450        }
451        if self.effects.iter().all(ToolEffect::is_read_only) {
452            return SchedulerClass::ParallelReadOnly;
453        }
454        if matches!(self.execution.retry_safety, ToolRetrySafety::Automatic) {
455            SchedulerClass::ParallelRetrySafe
456        } else {
457            SchedulerClass::Serial
458        }
459    }
460
461    /// Returns the stable tool name.
462    #[must_use]
463    pub const fn name(&self) -> &ToolName {
464        &self.name
465    }
466
467    /// Returns the semantic contract version.
468    #[must_use]
469    pub const fn version(&self) -> &ToolVersion {
470        &self.version
471    }
472
473    /// Returns the optional human-readable label.
474    #[must_use]
475    pub fn label(&self) -> Option<&str> {
476        self.label.as_deref()
477    }
478
479    /// Returns the model-visible description.
480    #[must_use]
481    pub fn description(&self) -> &str {
482        &self.description
483    }
484
485    /// Returns the input object schema.
486    #[must_use]
487    pub const fn input_schema(&self) -> &Value {
488        &self.input_schema
489    }
490
491    /// Returns the output object schema.
492    #[must_use]
493    pub const fn output_schema(&self) -> &Value {
494        &self.output_schema
495    }
496
497    /// Returns sorted, deduplicated effects.
498    #[must_use]
499    pub fn effects(&self) -> &[ToolEffect] {
500        &self.effects
501    }
502
503    /// Returns frozen provider-neutral tool provenance.
504    #[must_use]
505    pub const fn source(&self) -> &ToolSource {
506        &self.source
507    }
508
509    /// Returns execution semantics.
510    #[must_use]
511    pub const fn execution(&self) -> ToolExecutionSemantics {
512        self.execution
513    }
514
515    /// Returns the optional model prompt hint.
516    #[must_use]
517    pub fn prompt_hint(&self) -> Option<&str> {
518        self.prompt_snippet()
519    }
520
521    /// Returns the optional model prompt snippet.
522    #[must_use]
523    pub fn prompt_snippet(&self) -> Option<&str> {
524        self.prompt_snippet.as_deref()
525    }
526
527    /// Returns bounded model prompt guidelines in declaration order.
528    #[must_use]
529    pub fn prompt_guidelines(&self) -> &[String] {
530        &self.prompt_guidelines
531    }
532
533    /// Returns the optional UI renderer selector.
534    #[must_use]
535    pub fn ui_renderer(&self) -> Option<&str> {
536        self.ui_renderer.as_deref()
537    }
538}
539
540/// Error returned when constructing tool specifications.
541#[derive(Debug, Clone, PartialEq, Eq, Error)]
542pub enum ToolSpecError {
543    /// Description is empty, oversized, or contains a null character.
544    #[error("tool description is invalid")]
545    InvalidDescription,
546    /// Input or output schema must be a bounded object schema.
547    #[error("tool schema must be a bounded JSON object schema")]
548    InvalidSchema,
549    /// Tool must declare at least one effect.
550    #[error("tool must declare at least one effect")]
551    MissingEffects,
552    /// Tool declares too many effects.
553    #[error("tool declares too many effects")]
554    TooManyEffects,
555    /// Timeout is zero or above 24 hours.
556    #[error("tool timeout is outside supported bounds")]
557    InvalidTimeout,
558    /// Automatic retry is unsafe for non-idempotent execution.
559    #[error("non-idempotent tool cannot allow automatic retry")]
560    UnsafeAutomaticRetry,
561    /// Prompt hint is invalid.
562    #[error("tool prompt hint is invalid")]
563    InvalidPromptHint,
564    /// Human-readable tool label is invalid.
565    #[error("tool label is invalid")]
566    InvalidLabel,
567    /// Prompt snippet is invalid.
568    #[error("tool prompt snippet is invalid")]
569    InvalidPromptSnippet,
570    /// One prompt guideline is invalid.
571    #[error("tool prompt guideline is invalid")]
572    InvalidPromptGuideline,
573    /// Prompt guideline collection exceeds documented bounds.
574    #[error("tool has too many prompt guidelines")]
575    TooManyPromptGuidelines,
576    /// Renderer selector is invalid.
577    #[error("tool renderer selector is invalid")]
578    InvalidRenderer,
579    /// Model projection rejected the tool definition.
580    #[cfg(feature = "model-projection")]
581    #[error("tool cannot be projected to model definition: {0}")]
582    ModelProjection(ModelRequestError),
583}
584
585fn validate_text(value: &str, max_bytes: usize) -> Result<(), ()> {
586    if value.is_empty() || value.len() > max_bytes || value.contains('\0') {
587        Err(())
588    } else {
589        Ok(())
590    }
591}
592
593fn validate_object_schema(value: &Value) -> Result<(), ToolSpecError> {
594    let object = value.as_object().ok_or(ToolSpecError::InvalidSchema)?;
595    if object.get("type").and_then(Value::as_str) != Some("object")
596        || serde_json::to_vec(value)
597            .map_err(|_| ToolSpecError::InvalidSchema)?
598            .len()
599            > MAX_TOOL_SCHEMA_BYTES
600        || json_depth(value) > MAX_TOOL_SCHEMA_DEPTH
601    {
602        return Err(ToolSpecError::InvalidSchema);
603    }
604    Ok(())
605}
606
607fn json_depth(value: &Value) -> usize {
608    match value {
609        Value::Array(values) => 1 + values.iter().map(json_depth).max().unwrap_or(0),
610        Value::Object(values) => 1 + values.values().map(json_depth).max().unwrap_or(0),
611        _ => 1,
612    }
613}