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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct ToolName(String);
31
32impl ToolName {
33 #[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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
87#[serde(transparent)]
88pub struct ToolVersion(Version);
89
90impl ToolVersion {
91 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
118pub enum ToolIdentityParseError {
119 #[error("tool name is not canonical")]
121 InvalidName,
122 #[error("tool version is not canonical semantic version text")]
124 InvalidVersion,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum ToolRetrySafety {
130 Never,
132 ExplicitOnly,
134 Automatic,
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub enum ToolConcurrency {
141 Parallel,
143 Serial,
145 Exclusive,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
151pub struct ToolTimeout(u64);
152
153impl ToolTimeout {
154 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 #[must_use]
169 pub const fn as_millis(self) -> u64 {
170 self.0
171 }
172}
173
174#[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 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 #[must_use]
210 pub const fn idempotency(self) -> ToolIdempotency {
211 self.idempotency
212 }
213
214 #[must_use]
216 pub const fn retry_safety(self) -> ToolRetrySafety {
217 self.retry_safety
218 }
219
220 #[must_use]
222 pub const fn concurrency(self) -> ToolConcurrency {
223 self.concurrency
224 }
225
226 #[must_use]
228 pub const fn timeout(self) -> ToolTimeout {
229 self.timeout
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum SchedulerClass {
236 ParallelReadOnly,
238 ParallelRetrySafe,
240 Serial,
242 Exclusive,
244 PolicyRequired,
246}
247
248impl SchedulerClass {
249 #[must_use]
251 pub const fn requires_policy(self) -> bool {
252 matches!(self, Self::PolicyRequired)
253 }
254
255 #[must_use]
257 pub const fn allows_parallel_execution(self) -> bool {
258 matches!(self, Self::ParallelReadOnly | Self::ParallelRetrySafe)
259 }
260}
261
262#[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 #[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 #[must_use]
326 pub fn with_source(mut self, source: ToolSource) -> Self {
327 self.source = source;
328 self
329 }
330
331 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 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 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 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 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 #[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 #[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 #[must_use]
463 pub const fn name(&self) -> &ToolName {
464 &self.name
465 }
466
467 #[must_use]
469 pub const fn version(&self) -> &ToolVersion {
470 &self.version
471 }
472
473 #[must_use]
475 pub fn label(&self) -> Option<&str> {
476 self.label.as_deref()
477 }
478
479 #[must_use]
481 pub fn description(&self) -> &str {
482 &self.description
483 }
484
485 #[must_use]
487 pub const fn input_schema(&self) -> &Value {
488 &self.input_schema
489 }
490
491 #[must_use]
493 pub const fn output_schema(&self) -> &Value {
494 &self.output_schema
495 }
496
497 #[must_use]
499 pub fn effects(&self) -> &[ToolEffect] {
500 &self.effects
501 }
502
503 #[must_use]
505 pub const fn source(&self) -> &ToolSource {
506 &self.source
507 }
508
509 #[must_use]
511 pub const fn execution(&self) -> ToolExecutionSemantics {
512 self.execution
513 }
514
515 #[must_use]
517 pub fn prompt_hint(&self) -> Option<&str> {
518 self.prompt_snippet()
519 }
520
521 #[must_use]
523 pub fn prompt_snippet(&self) -> Option<&str> {
524 self.prompt_snippet.as_deref()
525 }
526
527 #[must_use]
529 pub fn prompt_guidelines(&self) -> &[String] {
530 &self.prompt_guidelines
531 }
532
533 #[must_use]
535 pub fn ui_renderer(&self) -> Option<&str> {
536 self.ui_renderer.as_deref()
537 }
538}
539
540#[derive(Debug, Clone, PartialEq, Eq, Error)]
542pub enum ToolSpecError {
543 #[error("tool description is invalid")]
545 InvalidDescription,
546 #[error("tool schema must be a bounded JSON object schema")]
548 InvalidSchema,
549 #[error("tool must declare at least one effect")]
551 MissingEffects,
552 #[error("tool declares too many effects")]
554 TooManyEffects,
555 #[error("tool timeout is outside supported bounds")]
557 InvalidTimeout,
558 #[error("non-idempotent tool cannot allow automatic retry")]
560 UnsafeAutomaticRetry,
561 #[error("tool prompt hint is invalid")]
563 InvalidPromptHint,
564 #[error("tool label is invalid")]
566 InvalidLabel,
567 #[error("tool prompt snippet is invalid")]
569 InvalidPromptSnippet,
570 #[error("tool prompt guideline is invalid")]
572 InvalidPromptGuideline,
573 #[error("tool has too many prompt guidelines")]
575 TooManyPromptGuidelines,
576 #[error("tool renderer selector is invalid")]
578 InvalidRenderer,
579 #[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}