Skip to main content

lash_sansio/
tool_contract.rs

1/// How a tool's invocations should be scheduled relative to other tools in
2/// the same batch of model-produced tool calls.
3///
4/// Tools that only *read* state (`read_file`, `grep`, `glob`, ...) can run
5/// in parallel safely and should use the default [`ToolScheduling::Parallel`].
6/// Tools that *mutate* shared state (`apply_patch`, `exec_command`,
7/// `write_stdin`) should declare
8/// [`ToolScheduling::Serial`] so the dispatcher runs them one-at-a-time
9/// and avoids interleaving with each other.
10///
11/// This controls scheduling within a batch of tool calls; protocol ownership
12/// is selected by the host plugin stack.
13#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum ToolScheduling {
16    /// Safe to run concurrently with other parallel tools in the same batch.
17    #[default]
18    Parallel,
19    /// Must run one-at-a-time relative to other serial tools in the batch.
20    Serial,
21}
22
23fn default_tool_scheduling() -> ToolScheduling {
24    ToolScheduling::default()
25}
26
27fn is_default_tool_scheduling(mode: &ToolScheduling) -> bool {
28    *mode == ToolScheduling::default()
29}
30
31/// Automatic retry policy for a tool's execution.
32///
33/// This is intentionally separate from [`ToolScheduling`]: scheduling
34/// decides whether different tool calls may run together, while retry policy
35/// decides whether one failed call may be attempted again inside its scheduled
36/// slot.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39pub enum ToolRetryPolicy {
40    /// Never retry automatically. This is the default for every tool.
41    #[default]
42    Never,
43    /// Retry only failures that explicitly report a safe retry disposition.
44    Safe {
45        max_attempts: u32,
46        base_delay_ms: u64,
47        max_delay_ms: u64,
48    },
49    /// Retry only failures that explicitly report a safe retry disposition,
50    /// and only when the runtime can provide a stable replay key.
51    Idempotent {
52        max_attempts: u32,
53        base_delay_ms: u64,
54        max_delay_ms: u64,
55    },
56}
57
58impl ToolRetryPolicy {
59    pub fn safe(max_attempts: u32, base_delay_ms: u64, max_delay_ms: u64) -> Self {
60        Self::Safe {
61            max_attempts,
62            base_delay_ms,
63            max_delay_ms,
64        }
65    }
66
67    pub fn idempotent(max_attempts: u32, base_delay_ms: u64, max_delay_ms: u64) -> Self {
68        Self::Idempotent {
69            max_attempts,
70            base_delay_ms,
71            max_delay_ms,
72        }
73    }
74
75    pub fn max_attempts(self) -> u32 {
76        match self {
77            Self::Never => 1,
78            Self::Safe { max_attempts, .. } | Self::Idempotent { max_attempts, .. } => {
79                max_attempts.max(1)
80            }
81        }
82    }
83
84    pub fn delay_ms_for_retry(self, retry_index: u32, requested_after_ms: Option<u64>) -> u64 {
85        let (base_delay_ms, max_delay_ms) = match self {
86            Self::Never => return 0,
87            Self::Safe {
88                base_delay_ms,
89                max_delay_ms,
90                ..
91            }
92            | Self::Idempotent {
93                base_delay_ms,
94                max_delay_ms,
95                ..
96            } => (base_delay_ms, max_delay_ms),
97        };
98        let multiplier = 1_u64.checked_shl(retry_index).unwrap_or(u64::MAX);
99        let backoff = base_delay_ms.saturating_mul(multiplier);
100        let delay = requested_after_ms.unwrap_or(backoff);
101        if max_delay_ms == 0 {
102            delay
103        } else {
104            delay.min(max_delay_ms)
105        }
106    }
107
108    pub fn requires_replay_key(self) -> bool {
109        matches!(self, Self::Idempotent { .. })
110    }
111}
112
113fn default_tool_retry_policy() -> ToolRetryPolicy {
114    ToolRetryPolicy::default()
115}
116
117fn is_default_tool_retry_policy(policy: &ToolRetryPolicy) -> bool {
118    *policy == ToolRetryPolicy::default()
119}
120
121#[derive(
122    Clone,
123    Copy,
124    Debug,
125    Default,
126    PartialEq,
127    Eq,
128    PartialOrd,
129    Ord,
130    serde::Serialize,
131    serde::Deserialize,
132)]
133#[serde(rename_all = "snake_case")]
134pub enum ToolAvailability {
135    /// Keep the tool out of the current surface entirely.
136    ///
137    /// The definition can remain in registry state so host or authority
138    /// overrides survive refreshes, but the model cannot search, see, or call
139    /// the tool.
140    #[default]
141    Off,
142    /// Include the tool in the searchable catalog, but not in the model's
143    /// callable tool list.
144    Searchable,
145    /// Include the tool in the model's callable tool list, without featuring
146    /// it in prompt-side tool documentation.
147    Callable,
148    /// Include the tool in the callable list and feature it in prompt-side
149    /// tool documentation.
150    Showcased,
151}
152
153impl ToolAvailability {
154    pub fn is_searchable(self) -> bool {
155        self >= Self::Searchable
156    }
157
158    pub fn is_callable(self) -> bool {
159        self >= Self::Callable
160    }
161
162    pub fn is_showcased(self) -> bool {
163        self >= Self::Showcased
164    }
165}
166
167#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
168pub struct ToolAvailabilityConfig {
169    pub base: ToolAvailability,
170}
171
172impl ToolAvailabilityConfig {
173    pub fn same(availability: ToolAvailability) -> Self {
174        Self { base: availability }
175    }
176
177    pub fn showcased() -> Self {
178        Self::same(ToolAvailability::Showcased)
179    }
180
181    pub fn callable() -> Self {
182        Self::same(ToolAvailability::Callable)
183    }
184
185    pub fn off() -> Self {
186        Self::same(ToolAvailability::Off)
187    }
188
189    pub fn base(&self) -> ToolAvailability {
190        self.base
191    }
192}
193
194impl Default for ToolAvailabilityConfig {
195    fn default() -> Self {
196        Self::showcased()
197    }
198}
199
200#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum ToolActivation {
203    #[default]
204    Always,
205    Internal,
206}
207
208fn is_default_tool_availability_config(config: &ToolAvailabilityConfig) -> bool {
209    *config == ToolAvailabilityConfig::default()
210}
211
212fn is_default_tool_activation(activation: &ToolActivation) -> bool {
213    *activation == ToolActivation::default()
214}
215
216#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
217pub struct ToolAgentSurface {
218    #[serde(default, skip_serializing_if = "Vec::is_empty")]
219    pub module_path: Vec<String>,
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub operation: Option<String>,
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub authority_type: Option<String>,
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub aliases: Vec<String>,
226}
227
228impl ToolAgentSurface {
229    pub fn new(
230        module_path: impl IntoIterator<Item = impl Into<String>>,
231        operation: impl Into<String>,
232    ) -> Self {
233        Self {
234            module_path: module_path.into_iter().map(Into::into).collect(),
235            operation: Some(operation.into()),
236            authority_type: None,
237            aliases: Vec::new(),
238        }
239    }
240
241    pub fn with_authority_type(mut self, authority_type: impl Into<String>) -> Self {
242        self.authority_type = Some(authority_type.into());
243        self
244    }
245
246    pub fn with_aliases(mut self, aliases: impl IntoIterator<Item = impl Into<String>>) -> Self {
247        self.aliases = aliases.into_iter().map(Into::into).collect();
248        self
249    }
250
251    pub fn executable_for(&self, tool_name: &str) -> ToolAgentExecutableSurface {
252        let module_path = if self.module_path.is_empty() {
253            vec!["tools".to_string()]
254        } else {
255            self.module_path.clone()
256        };
257        let operation = self
258            .operation
259            .as_deref()
260            .filter(|operation| !operation.trim().is_empty())
261            .unwrap_or(tool_name)
262            .to_string();
263        let authority_type = self
264            .authority_type
265            .as_deref()
266            .filter(|authority_type| !authority_type.trim().is_empty())
267            .map(ToOwned::to_owned)
268            .unwrap_or_else(|| default_authority_type(&module_path));
269        ToolAgentExecutableSurface {
270            module_path,
271            operation,
272            authority_type,
273            aliases: self.aliases.clone(),
274        }
275    }
276
277    /// Resolve a remote-callable surface without applying local prompt fallbacks.
278    ///
279    /// Remote hosts must provide an explicit module path and operation so
280    /// serialized tool grants have one canonical call path. This deliberately
281    /// rejects the prompt-only conveniences used by [`Self::executable_for`],
282    /// where an empty module path falls back to `tools` and an empty operation
283    /// falls back to the flat tool name.
284    pub fn required_for_remote(
285        manifest: &ToolManifest,
286    ) -> Result<ToolAgentExecutableSurface, String> {
287        manifest
288            .agent_surface
289            .required_executable_for_remote(&manifest.name)
290    }
291
292    pub fn required_executable_for_remote(
293        &self,
294        tool_name: &str,
295    ) -> Result<ToolAgentExecutableSurface, String> {
296        if self.module_path.is_empty() {
297            return Err(format!(
298                "tool `{tool_name}` is missing an explicit remote module path"
299            ));
300        }
301        if let Some(empty) = self.module_path.iter().find(|part| part.trim().is_empty()) {
302            return Err(format!(
303                "tool `{tool_name}` has an empty remote module path segment `{empty}`"
304            ));
305        }
306        let Some(operation) = self
307            .operation
308            .as_deref()
309            .map(str::trim)
310            .filter(|operation| !operation.is_empty())
311        else {
312            return Err(format!(
313                "tool `{tool_name}` is missing an explicit remote operation"
314            ));
315        };
316        let authority_type = self
317            .authority_type
318            .as_deref()
319            .filter(|authority_type| !authority_type.trim().is_empty())
320            .map(ToOwned::to_owned)
321            .unwrap_or_else(|| default_authority_type(&self.module_path));
322        Ok(ToolAgentExecutableSurface {
323            module_path: self.module_path.clone(),
324            operation: operation.to_string(),
325            authority_type,
326            aliases: self.aliases.clone(),
327        })
328    }
329
330    pub fn is_empty(&self) -> bool {
331        self.module_path.is_empty()
332            && self.operation.is_none()
333            && self.authority_type.is_none()
334            && self.aliases.is_empty()
335    }
336}
337
338#[derive(Clone, Debug, PartialEq, Eq)]
339pub struct ToolAgentExecutableSurface {
340    pub module_path: Vec<String>,
341    pub operation: String,
342    pub authority_type: String,
343    pub aliases: Vec<String>,
344}
345
346impl ToolAgentExecutableSurface {
347    pub fn module_path_string(&self) -> String {
348        self.module_path.join(".")
349    }
350
351    pub fn call_path(&self) -> String {
352        format!("{}.{}", self.module_path_string(), self.operation)
353    }
354}
355
356fn default_authority_type(module_path: &[String]) -> String {
357    let base = module_path
358        .first()
359        .map(String::as_str)
360        .unwrap_or("tools")
361        .trim_matches('_');
362    let mut out = String::new();
363    for part in base.split('_').filter(|part| !part.is_empty()) {
364        let mut chars = part.chars();
365        if let Some(first) = chars.next() {
366            out.extend(first.to_uppercase());
367            out.extend(chars);
368        }
369    }
370    if out.is_empty() {
371        "Tools".to_string()
372    } else {
373        out
374    }
375}
376
377#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
378#[serde(tag = "kind", rename_all = "snake_case")]
379pub enum ToolOutputContract {
380    #[default]
381    Static,
382    FromInputSchema {
383        input_field: String,
384        #[serde(default, skip_serializing_if = "Option::is_none")]
385        default_schema: Option<serde_json::Value>,
386    },
387}
388
389impl ToolOutputContract {
390    pub fn from_input_schema(
391        input_field: impl Into<String>,
392        default_schema: Option<serde_json::Value>,
393    ) -> Self {
394        Self::FromInputSchema {
395            input_field: input_field.into(),
396            default_schema,
397        }
398    }
399
400    pub fn is_static(&self) -> bool {
401        matches!(self, Self::Static)
402    }
403
404    fn return_type_label(&self, static_schema: &serde_json::Value) -> String {
405        match self {
406            Self::Static => compact_schema_label(static_schema),
407            Self::FromInputSchema { .. } => "T".to_string(),
408        }
409    }
410
411    fn type_parameter_suffix(&self) -> Option<String> {
412        match self {
413            Self::Static => None,
414            Self::FromInputSchema { default_schema, .. } => {
415                let default = default_schema
416                    .as_ref()
417                    .map(compact_schema_label)
418                    .unwrap_or_else(|| "any".to_string());
419                Some(format!("<T = {default}>"))
420            }
421        }
422    }
423
424    fn apply_type_witness_parameter(&self, params: &mut [ParameterDoc]) {
425        let Self::FromInputSchema { input_field, .. } = self else {
426            return;
427        };
428        if let Some(param) = params.iter_mut().find(|param| param.name == *input_field) {
429            param.type_label = "TypeSpec<T>".to_string();
430            param.nullable = false;
431            param.default_value = None;
432            param.enum_values.clear();
433            param.minimum = None;
434            param.maximum = None;
435            param.min_length = None;
436            param.max_length = None;
437            param.min_items = None;
438            param.max_items = None;
439            param.item_type = None;
440        }
441    }
442
443    fn return_fields(&self, static_schema: &serde_json::Value) -> Vec<serde_json::Value> {
444        match self {
445            Self::Static => return_field_metadata(static_schema),
446            Self::FromInputSchema { .. } => Vec::new(),
447        }
448    }
449}
450
451#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
452#[serde(tag = "kind", rename_all = "snake_case")]
453pub enum ToolArgumentProjectionPolicy {
454    #[default]
455    MaterializeProjectedValues,
456    PreserveProjectedRefsInField {
457        field: String,
458    },
459}
460
461impl ToolArgumentProjectionPolicy {
462    pub fn preserve_projected_refs_in_field(field: impl Into<String>) -> Self {
463        Self::PreserveProjectedRefsInField {
464            field: field.into(),
465        }
466    }
467
468    pub fn is_materialize_projected_values(&self) -> bool {
469        matches!(self, Self::MaterializeProjectedValues)
470    }
471}
472
473fn is_default_tool_argument_projection_policy(policy: &ToolArgumentProjectionPolicy) -> bool {
474    policy.is_materialize_projected_values()
475}
476
477#[derive(
478    Clone,
479    Debug,
480    Default,
481    PartialEq,
482    Eq,
483    PartialOrd,
484    Ord,
485    Hash,
486    serde::Serialize,
487    serde::Deserialize,
488)]
489#[serde(transparent)]
490pub struct ToolId(String);
491
492impl ToolId {
493    pub fn new(id: impl Into<String>) -> Self {
494        let id = id.into();
495        assert!(!id.trim().is_empty(), "tool id must not be empty");
496        Self(id)
497    }
498
499    pub fn default_for_name(name: &str) -> Self {
500        Self::new(format!("tool:{name}"))
501    }
502
503    pub fn as_str(&self) -> &str {
504        &self.0
505    }
506}
507
508impl From<String> for ToolId {
509    fn from(id: String) -> Self {
510        Self::new(id)
511    }
512}
513
514impl From<&str> for ToolId {
515    fn from(id: &str) -> Self {
516        Self::new(id)
517    }
518}
519
520impl std::fmt::Display for ToolId {
521    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
522        f.write_str(&self.0)
523    }
524}
525
526/// Tool metadata exposed to prompts, catalogs, UI, and availability checks.
527/// The optional compact contract is the catalog-facing projection of the
528/// resolved contract; full schemas stay in [`ToolContract`].
529#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
530pub struct ToolManifest {
531    pub id: ToolId,
532    pub name: String,
533    #[serde(default, skip_serializing_if = "String::is_empty")]
534    pub description: String,
535    #[serde(default, skip_serializing_if = "Option::is_none")]
536    pub compact_contract: Option<CompactToolContract>,
537    #[serde(default, skip_serializing_if = "is_default_tool_availability_config")]
538    pub availability: ToolAvailabilityConfig,
539    #[serde(default, skip_serializing_if = "is_default_tool_activation")]
540    pub activation: ToolActivation,
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub availability_override: Option<ToolAvailability>,
543    #[serde(default, skip_serializing_if = "ToolAgentSurface::is_empty")]
544    pub agent_surface: ToolAgentSurface,
545    #[serde(
546        default,
547        skip_serializing_if = "is_default_tool_argument_projection_policy"
548    )]
549    pub argument_projection: ToolArgumentProjectionPolicy,
550    #[serde(
551        default = "default_tool_scheduling",
552        skip_serializing_if = "is_default_tool_scheduling"
553    )]
554    pub scheduling: ToolScheduling,
555    #[serde(
556        default = "default_tool_retry_policy",
557        skip_serializing_if = "is_default_tool_retry_policy"
558    )]
559    pub retry_policy: ToolRetryPolicy,
560}
561
562impl ToolManifest {
563    pub fn effective_availability(&self) -> ToolAvailability {
564        self.availability_override
565            .unwrap_or_else(|| self.availability.base())
566    }
567}
568
569/// Heavy tool contract resolved only when a prompt or call needs schemas/docs.
570#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
571pub struct ToolContract {
572    #[serde(default = "ToolContract::default_input_schema")]
573    pub input_schema: serde_json::Value,
574    #[serde(default)]
575    pub output_schema: serde_json::Value,
576    #[serde(default, skip_serializing_if = "Vec::is_empty")]
577    pub input_schema_projections: Vec<SchemaProjectionOverride>,
578    #[serde(default, skip_serializing_if = "Vec::is_empty")]
579    pub output_schema_projections: Vec<SchemaProjectionOverride>,
580    #[serde(default, skip_serializing_if = "ToolOutputContract::is_static")]
581    pub output_contract: ToolOutputContract,
582    #[serde(default, skip_serializing_if = "Vec::is_empty")]
583    pub examples: Vec<String>,
584}
585
586impl Default for ToolContract {
587    fn default() -> Self {
588        Self {
589            input_schema: Self::default_input_schema(),
590            output_schema: serde_json::Value::Null,
591            input_schema_projections: Vec::new(),
592            output_schema_projections: Vec::new(),
593            output_contract: ToolOutputContract::Static,
594            examples: Vec::new(),
595        }
596    }
597}
598
599impl ToolContract {
600    pub fn default_input_schema() -> serde_json::Value {
601        serde_json::json!({
602            "type": "object",
603            "properties": {},
604            "additionalProperties": true
605        })
606    }
607
608    pub fn compact_contract(&self, manifest: &ToolManifest) -> CompactToolContract {
609        self.compact_contract_with_example_limit(manifest, COMPACT_TOOL_EXAMPLE_LIMIT)
610    }
611
612    pub fn compact_contract_with_example_limit(
613        &self,
614        manifest: &ToolManifest,
615        example_limit: usize,
616    ) -> CompactToolContract {
617        let agent_surface = manifest.agent_surface.executable_for(&manifest.name);
618        CompactToolContract {
619            name: agent_surface.call_path(),
620            signature: self.input_signature(manifest),
621            returns: self.output_summary(),
622            parameters: self.parameter_metadata(),
623            return_fields: self.output_contract.return_fields(&self.output_schema),
624            description: manifest.description.trim().to_string(),
625            examples: compact_examples(&self.examples, example_limit),
626        }
627    }
628
629    pub fn input_signature(&self, manifest: &ToolManifest) -> String {
630        let agent_surface = manifest.agent_surface.executable_for(&manifest.name);
631        let params = self
632            .parameter_docs()
633            .into_iter()
634            .map(|p| p.signature_fragment())
635            .collect::<Vec<_>>();
636        let body = if params.is_empty() {
637            "{}".to_string()
638        } else {
639            format!("{{ {} }}", params.join(", "))
640        };
641        format!(
642            "await {}{}({})?",
643            agent_surface.call_path(),
644            self.output_contract
645                .type_parameter_suffix()
646                .unwrap_or_default(),
647            body
648        )
649    }
650
651    pub fn output_summary(&self) -> String {
652        self.output_contract.return_type_label(&self.output_schema)
653    }
654
655    pub fn parameter_metadata(&self) -> Vec<serde_json::Value> {
656        self.parameter_docs()
657            .into_iter()
658            .map(|param| param.into_value())
659            .collect()
660    }
661
662    pub fn model_tool(&self, manifest: &ToolManifest) -> ModelTool {
663        ModelTool {
664            name: manifest.name.clone(),
665            description: manifest.description.clone(),
666            input_schema: self.input_schema.clone(),
667            output_schema: self.output_schema.clone(),
668            input_schema_projections: self.input_schema_projections.clone(),
669            output_schema_projections: self.output_schema_projections.clone(),
670        }
671    }
672
673    fn parameter_docs(&self) -> Vec<ParameterDoc> {
674        let mut params = schema_parameter_docs(&self.input_schema);
675        self.output_contract
676            .apply_type_witness_parameter(&mut params);
677        params
678    }
679}
680
681/// Static authoring helper for tools.
682///
683/// Composes the runtime [`ToolManifest`] and [`ToolContract`] projections. Both
684/// are `#[serde(flatten)]`ed so the serialized JSON shape stays flat (and wire/
685/// persistence compatible); the two structs have disjoint field names.
686#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
687pub struct ToolDefinition {
688    #[serde(flatten)]
689    pub manifest: ToolManifest,
690    #[serde(flatten)]
691    pub contract: ToolContract,
692}
693
694#[derive(Clone, Debug, PartialEq, Eq)]
695pub struct ModelTool {
696    pub name: String,
697    pub description: String,
698    pub input_schema: serde_json::Value,
699    pub output_schema: serde_json::Value,
700    pub input_schema_projections: Vec<SchemaProjectionOverride>,
701    pub output_schema_projections: Vec<SchemaProjectionOverride>,
702}
703
704#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
705pub struct SchemaProjectionOverride {
706    pub profile: String,
707    pub schema: serde_json::Value,
708}
709
710const COMPACT_TOOL_EXAMPLE_LIMIT: usize = 2;
711const COMPACT_TOOL_EXAMPLE_CHAR_LIMIT: usize = 240;
712
713#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
714pub struct CompactToolContract {
715    pub name: String,
716    pub signature: String,
717    pub returns: String,
718    #[serde(default, skip_serializing_if = "Vec::is_empty")]
719    pub parameters: Vec<serde_json::Value>,
720    #[serde(default, skip_serializing_if = "Vec::is_empty")]
721    pub return_fields: Vec<serde_json::Value>,
722    #[serde(default, skip_serializing_if = "String::is_empty")]
723    pub description: String,
724    #[serde(default, skip_serializing_if = "Vec::is_empty")]
725    pub examples: Vec<String>,
726}
727
728impl CompactToolContract {
729    pub fn render_signature_head(&self) -> String {
730        format!("{} -> {}", self.signature.trim(), self.returns.trim())
731    }
732
733    pub fn render_signature(&self) -> String {
734        let mut sections = vec![self.render_signature_head()];
735        let parameter_lines = self
736            .parameters
737            .iter()
738            .filter_map(compact_doc_line)
739            .collect::<Vec<_>>();
740        if !parameter_lines.is_empty() {
741            sections.push(format!("Parameters:\n{}", parameter_lines.join("\n")));
742        }
743        let return_field_lines = self
744            .return_fields
745            .iter()
746            .filter_map(compact_doc_line)
747            .collect::<Vec<_>>();
748        if !return_field_lines.is_empty() {
749            sections.push(format!("Return fields:\n{}", return_field_lines.join("\n")));
750        }
751        sections.join("\n")
752    }
753
754    pub fn render_returns(&self) -> String {
755        let mut sections = Vec::new();
756        let return_field_lines = self
757            .return_fields
758            .iter()
759            .filter_map(compact_doc_line)
760            .collect::<Vec<_>>();
761        if !return_field_lines.is_empty() {
762            sections.push(format!("Return fields:\n{}", return_field_lines.join("\n")));
763        }
764        sections.join("\n")
765    }
766
767    pub fn render_markdown(&self) -> String {
768        let mut sections = vec![format!("### {}", self.render_signature_head())];
769        if !self.description.trim().is_empty() {
770            sections.push(self.description.trim().to_string());
771        }
772        if !self.parameters.is_empty() {
773            sections.push(format!(
774                "Parameters:\n{}",
775                self.parameters
776                    .iter()
777                    .filter_map(compact_doc_line)
778                    .collect::<Vec<_>>()
779                    .join("\n")
780            ));
781        }
782        if !self.return_fields.is_empty() {
783            sections.push(format!(
784                "Return fields:\n{}",
785                self.return_fields
786                    .iter()
787                    .filter_map(compact_doc_line)
788                    .collect::<Vec<_>>()
789                    .join("\n")
790            ));
791        }
792        if !self.examples.is_empty() {
793            sections.push(format!("Examples: {}", self.examples.join("; ")));
794        }
795        sections.join("\n")
796    }
797}
798
799impl ToolDefinition {
800    pub fn raw_with_id(
801        id: impl Into<ToolId>,
802        name: impl Into<String>,
803        description: impl Into<String>,
804        input_schema: serde_json::Value,
805        output_schema: serde_json::Value,
806    ) -> Self {
807        Self {
808            manifest: ToolManifest {
809                id: id.into(),
810                name: name.into(),
811                description: description.into(),
812                compact_contract: None,
813                ..ToolManifest::default()
814            },
815            contract: ToolContract {
816                input_schema,
817                output_schema,
818                ..ToolContract::default()
819            },
820        }
821    }
822
823    pub fn raw_named(
824        name: impl Into<String>,
825        description: impl Into<String>,
826        input_schema: serde_json::Value,
827        output_schema: serde_json::Value,
828    ) -> Self {
829        let name = name.into();
830        Self::raw_with_id(
831            ToolId::default_for_name(&name),
832            name,
833            description,
834            input_schema,
835            output_schema,
836        )
837    }
838
839    pub fn typed_with_id<Args, Output>(
840        id: impl Into<ToolId>,
841        name: impl Into<String>,
842        description: impl Into<String>,
843    ) -> Self
844    where
845        Args: schemars::JsonSchema,
846        Output: schemars::JsonSchema,
847    {
848        Self::raw_with_id(
849            id,
850            name,
851            description,
852            schema_for::<Args>(),
853            schema_for::<Output>(),
854        )
855    }
856
857    pub fn typed<Args, Output>(name: impl Into<String>, description: impl Into<String>) -> Self
858    where
859        Args: schemars::JsonSchema,
860        Output: schemars::JsonSchema,
861    {
862        let name = name.into();
863        Self::typed_with_id::<Args, Output>(ToolId::default_for_name(&name), name, description)
864    }
865
866    pub fn raw(
867        id: impl Into<ToolId>,
868        name: impl Into<String>,
869        description: impl Into<String>,
870        input_schema: serde_json::Value,
871        output_schema: serde_json::Value,
872    ) -> Self {
873        Self::raw_with_id(id, name, description, input_schema, output_schema)
874    }
875
876    pub fn with_examples(mut self, examples: Vec<String>) -> Self {
877        self.contract.examples = examples;
878        self
879    }
880
881    pub fn with_availability(mut self, availability: ToolAvailabilityConfig) -> Self {
882        self.manifest.availability = availability;
883        self
884    }
885
886    pub fn with_activation(mut self, activation: ToolActivation) -> Self {
887        self.manifest.activation = activation;
888        self
889    }
890
891    pub fn with_agent_surface(mut self, agent_surface: ToolAgentSurface) -> Self {
892        self.manifest.agent_surface = agent_surface;
893        self
894    }
895
896    pub fn with_argument_projection(
897        mut self,
898        argument_projection: ToolArgumentProjectionPolicy,
899    ) -> Self {
900        self.manifest.argument_projection = argument_projection;
901        self
902    }
903
904    pub fn with_scheduling(mut self, scheduling: ToolScheduling) -> Self {
905        self.manifest.scheduling = scheduling;
906        self
907    }
908
909    pub fn with_retry_policy(mut self, retry_policy: ToolRetryPolicy) -> Self {
910        self.manifest.retry_policy = retry_policy;
911        self
912    }
913
914    pub fn with_output_contract(mut self, output_contract: ToolOutputContract) -> Self {
915        self.contract.output_contract = output_contract;
916        self
917    }
918
919    pub fn with_input_schema_projection(
920        mut self,
921        profile: impl Into<String>,
922        schema: serde_json::Value,
923    ) -> Self {
924        let profile = profile.into();
925        self.contract
926            .input_schema_projections
927            .retain(|projection| projection.profile != profile);
928        self.contract
929            .input_schema_projections
930            .push(SchemaProjectionOverride { profile, schema });
931        self
932    }
933
934    pub fn with_output_schema_projection(
935        mut self,
936        profile: impl Into<String>,
937        schema: serde_json::Value,
938    ) -> Self {
939        let profile = profile.into();
940        self.contract
941            .output_schema_projections
942            .retain(|projection| projection.profile != profile);
943        self.contract
944            .output_schema_projections
945            .push(SchemaProjectionOverride { profile, schema });
946        self
947    }
948
949    pub fn with_output_from_input_schema(
950        self,
951        input_field: impl Into<String>,
952        default_schema: Option<serde_json::Value>,
953    ) -> Self {
954        self.with_output_contract(ToolOutputContract::from_input_schema(
955            input_field,
956            default_schema,
957        ))
958    }
959
960    pub fn default_input_schema() -> serde_json::Value {
961        ToolContract::default_input_schema()
962    }
963
964    /// Tool identity. Read very widely, so exposed as a thin accessor over the
965    /// composed [`ToolManifest`].
966    pub fn id(&self) -> &ToolId {
967        &self.manifest.id
968    }
969
970    /// Tool name. Read very widely, so exposed as a thin accessor.
971    pub fn name(&self) -> &str {
972        &self.manifest.name
973    }
974
975    /// Tool description. Read very widely, so exposed as a thin accessor.
976    pub fn description(&self) -> &str {
977        &self.manifest.description
978    }
979
980    pub fn input_signature(&self) -> String {
981        self.contract.input_signature(&self.manifest)
982    }
983
984    pub fn output_summary(&self) -> String {
985        self.contract.output_summary()
986    }
987
988    pub fn signature(&self) -> String {
989        format!("{} -> {}", self.input_signature(), self.output_summary())
990    }
991
992    pub fn compact_contract(&self) -> CompactToolContract {
993        self.compact_contract_with_example_limit(COMPACT_TOOL_EXAMPLE_LIMIT)
994    }
995
996    pub fn compact_contract_with_example_limit(&self, example_limit: usize) -> CompactToolContract {
997        self.contract
998            .compact_contract_with_example_limit(&self.manifest, example_limit)
999    }
1000
1001    pub fn effective_availability(&self) -> ToolAvailability {
1002        self.manifest.effective_availability()
1003    }
1004
1005    pub fn model_tool(&self) -> ModelTool {
1006        self.contract.model_tool(&self.manifest)
1007    }
1008
1009    /// Project the manifest, computing the catalog-facing compact contract from
1010    /// the resolved [`ToolContract`].
1011    pub fn manifest(&self) -> ToolManifest {
1012        let mut manifest = self.manifest.clone();
1013        manifest.compact_contract = Some(self.contract.compact_contract(&manifest));
1014        manifest
1015    }
1016
1017    pub fn contract(&self) -> ToolContract {
1018        self.contract.clone()
1019    }
1020
1021    /// Recompose a definition from its [`ToolManifest`] and [`ToolContract`]
1022    /// projections — the inverse of [`ToolDefinition::manifest`]/[`ToolDefinition::contract`].
1023    pub fn from_parts(manifest: ToolManifest, contract: ToolContract) -> Self {
1024        Self { manifest, contract }
1025    }
1026
1027    pub fn format_tool_docs(tools: &[ToolDefinition]) -> String {
1028        Self::format_tool_docs_iter(tools.iter())
1029    }
1030
1031    pub fn format_tool_docs_iter<'a>(
1032        tools: impl IntoIterator<Item = &'a ToolDefinition>,
1033    ) -> String {
1034        tools
1035            .into_iter()
1036            .map(|tool| tool.compact_contract().render_markdown())
1037            .collect::<Vec<_>>()
1038            .join("\n\n")
1039    }
1040
1041    pub fn parameter_metadata(&self) -> Vec<serde_json::Value> {
1042        self.parameter_docs()
1043            .into_iter()
1044            .map(|param| param.into_value())
1045            .collect()
1046    }
1047
1048    fn parameter_docs(&self) -> Vec<ParameterDoc> {
1049        let mut params = schema_parameter_docs(&self.contract.input_schema);
1050        self.contract
1051            .output_contract
1052            .apply_type_witness_parameter(&mut params);
1053        params
1054    }
1055}
1056
1057mod schema_docs;
1058pub use schema_docs::schema_for;
1059use schema_docs::{
1060    ParameterDoc, compact_doc_line, compact_examples, compact_schema_label, return_field_metadata,
1061    schema_parameter_docs,
1062};
1063
1064mod schema_validation;
1065pub use schema_validation::{LashSchema, validate_tool_input};
1066
1067#[cfg(test)]
1068mod tests {
1069    use super::*;
1070    #[test]
1071    fn tool_definition_uses_canonical_model_schemas() {
1072        let tool = ToolDefinition::raw_with_id(
1073            "tool:mcp__demo__search",
1074            "mcp__demo__search",
1075            "Search demo server",
1076            serde_json::json!({
1077                "type": "object",
1078                "properties": {
1079                    "query": { "type": "string" },
1080                    "limit": { "type": "integer" }
1081                },
1082                "required": ["query"],
1083                "additionalProperties": false
1084            }),
1085            serde_json::json!({
1086                "type": "object",
1087                "properties": {
1088                    "hits": { "type": "array", "items": { "type": "string" } }
1089                },
1090                "required": ["hits"],
1091                "additionalProperties": false
1092            }),
1093        );
1094
1095        let model_tool = tool.model_tool();
1096        assert_eq!(
1097            model_tool.input_schema["properties"]["limit"]["type"],
1098            serde_json::json!("integer")
1099        );
1100        assert_eq!(
1101            model_tool.output_schema["properties"]["hits"]["type"],
1102            serde_json::json!("array")
1103        );
1104    }
1105
1106    #[test]
1107    fn tool_retry_policy_defaults_to_never_and_is_omitted_from_manifest_json() {
1108        let tool = ToolDefinition::raw_with_id(
1109            "tool:demo",
1110            "demo",
1111            "Demo",
1112            ToolDefinition::default_input_schema(),
1113            serde_json::json!({ "type": "string" }),
1114        );
1115
1116        assert_eq!(tool.manifest.retry_policy, ToolRetryPolicy::Never);
1117        let manifest = tool.manifest();
1118        assert_eq!(manifest.retry_policy, ToolRetryPolicy::Never);
1119        let encoded = serde_json::to_value(&manifest).expect("manifest json");
1120        assert!(encoded.get("retry_policy").is_none());
1121    }
1122
1123    #[test]
1124    fn tool_retry_policy_propagates_through_manifest_and_definition_roundtrip() {
1125        let tool = ToolDefinition::raw_with_id(
1126            "tool:demo",
1127            "demo",
1128            "Demo",
1129            ToolDefinition::default_input_schema(),
1130            serde_json::json!({ "type": "string" }),
1131        )
1132        .with_retry_policy(ToolRetryPolicy::safe(3, 10, 100));
1133
1134        let manifest = tool.manifest();
1135        assert_eq!(
1136            manifest.retry_policy,
1137            ToolRetryPolicy::Safe {
1138                max_attempts: 3,
1139                base_delay_ms: 10,
1140                max_delay_ms: 100,
1141            }
1142        );
1143
1144        let roundtrip = ToolDefinition::from_parts(manifest, tool.contract());
1145        assert_eq!(roundtrip.manifest.retry_policy, tool.manifest.retry_policy);
1146        let encoded = serde_json::to_value(roundtrip.manifest()).expect("manifest json");
1147        assert_eq!(encoded["retry_policy"]["type"], serde_json::json!("safe"));
1148    }
1149
1150    #[test]
1151    fn tool_argument_projection_defaults_to_materialize_and_is_omitted_from_manifest_json() {
1152        let tool = ToolDefinition::raw_with_id(
1153            "tool:demo",
1154            "demo",
1155            "Demo",
1156            ToolDefinition::default_input_schema(),
1157            serde_json::json!({ "type": "string" }),
1158        );
1159
1160        assert_eq!(
1161            tool.manifest.argument_projection,
1162            ToolArgumentProjectionPolicy::MaterializeProjectedValues
1163        );
1164        let manifest = tool.manifest();
1165        assert_eq!(
1166            manifest.argument_projection,
1167            ToolArgumentProjectionPolicy::MaterializeProjectedValues
1168        );
1169        let encoded = serde_json::to_value(&manifest).expect("manifest json");
1170        assert!(encoded.get("argument_projection").is_none());
1171    }
1172
1173    #[test]
1174    fn tool_argument_projection_propagates_through_manifest_and_definition_roundtrip() {
1175        let tool = ToolDefinition::raw_with_id(
1176            "tool:demo",
1177            "demo",
1178            "Demo",
1179            ToolDefinition::default_input_schema(),
1180            serde_json::json!({ "type": "string" }),
1181        )
1182        .with_argument_projection(
1183            ToolArgumentProjectionPolicy::preserve_projected_refs_in_field("seed"),
1184        );
1185
1186        let manifest = tool.manifest();
1187        assert_eq!(
1188            manifest.argument_projection,
1189            tool.manifest.argument_projection
1190        );
1191
1192        let roundtrip = ToolDefinition::from_parts(manifest, tool.contract());
1193        assert_eq!(
1194            roundtrip.manifest.argument_projection,
1195            tool.manifest.argument_projection
1196        );
1197        let encoded = serde_json::to_value(roundtrip.manifest()).expect("manifest json");
1198        assert_eq!(
1199            encoded["argument_projection"],
1200            serde_json::json!({
1201                "kind": "preserve_projected_refs_in_field",
1202                "field": "seed"
1203            })
1204        );
1205    }
1206
1207    #[test]
1208    fn model_tool_preserves_schema_projection_overrides() {
1209        let tool = ToolDefinition::raw_with_id(
1210            "tool:demo",
1211            "demo",
1212            "Demo",
1213            serde_json::json!({
1214                "type": "object",
1215                "properties": { "raw": { "const": "x" } }
1216            }),
1217            serde_json::json!({ "type": "object" }),
1218        )
1219        .with_input_schema_projection(
1220            "provider.tool_parameters",
1221            serde_json::json!({
1222                "type": "object",
1223                "properties": { "raw": { "type": "string", "enum": ["x"] } }
1224            }),
1225        )
1226        .with_output_schema_projection(
1227            "provider.structured_output",
1228            serde_json::json!({
1229                "type": "object",
1230                "properties": {},
1231                "required": [],
1232                "additionalProperties": false
1233            }),
1234        );
1235
1236        let model_tool = tool.model_tool();
1237        assert_eq!(model_tool.input_schema["properties"]["raw"]["const"], "x");
1238        assert_eq!(
1239            model_tool.input_schema_projections[0].schema["properties"]["raw"]["enum"],
1240            serde_json::json!(["x"])
1241        );
1242        assert_eq!(
1243            model_tool.output_schema_projections[0].profile,
1244            "provider.structured_output"
1245        );
1246    }
1247
1248    #[test]
1249    fn typed_tool_definition_generates_input_and_output_schema() {
1250        #[derive(schemars::JsonSchema)]
1251        #[allow(dead_code)]
1252        enum Mode {
1253            Fast,
1254            Slow,
1255        }
1256
1257        #[derive(schemars::JsonSchema)]
1258        #[allow(dead_code)]
1259        struct Args {
1260            query: String,
1261            #[schemars(range(max = 20))]
1262            page_limit: u8,
1263            #[schemars(length(min = 1, max = 3))]
1264            tags: Vec<String>,
1265            mode: Option<Mode>,
1266        }
1267
1268        #[derive(schemars::JsonSchema)]
1269        #[allow(dead_code)]
1270        struct Output {
1271            answer: String,
1272            #[schemars(range(min = 0))]
1273            confidence: f32,
1274        }
1275
1276        let tool = ToolDefinition::typed::<Args, Output>("demo", "Demo");
1277        let metadata = tool.parameter_metadata();
1278        assert!(metadata.iter().any(|param| {
1279            param["name"] == "page_limit"
1280                && param["type"] == "int"
1281                && param["maximum"].as_f64() == Some(20.0)
1282        }));
1283        assert!(metadata.iter().any(|param| {
1284            param["name"] == "tags"
1285                && param["type"] == "list[str]"
1286                && param["min_items"] == 1
1287                && param["max_items"] == 3
1288        }));
1289        assert!(
1290            metadata
1291                .iter()
1292                .any(|param| { param["name"] == "mode" && param["nullable"] == true })
1293        );
1294        assert_eq!(
1295            tool.contract.output_schema["properties"]["answer"]["type"],
1296            "string"
1297        );
1298        assert_eq!(
1299            tool.contract.output_schema["properties"]["confidence"]["minimum"].as_f64(),
1300            Some(0.0)
1301        );
1302    }
1303
1304    #[test]
1305    fn raw_tool_definition_preserves_caller_provided_schemas() {
1306        let input_schema = serde_json::json!({
1307            "type": "object",
1308            "properties": {
1309                "query": { "type": "string", "minLength": 3 }
1310            },
1311            "required": ["query"],
1312            "x-custom": { "keep": true }
1313        });
1314        let output_schema = serde_json::json!({
1315            "type": "object",
1316            "properties": {
1317                "ok": { "type": "boolean" }
1318            },
1319            "required": ["ok"],
1320            "x-result": ["exact"]
1321        });
1322
1323        let tool = ToolDefinition::raw_with_id(
1324            "tool:raw_demo",
1325            "raw_demo",
1326            "Raw demo",
1327            input_schema.clone(),
1328            output_schema.clone(),
1329        );
1330
1331        assert_eq!(tool.contract.input_schema, input_schema);
1332        assert_eq!(tool.contract.output_schema, output_schema);
1333    }
1334
1335    #[test]
1336    fn compact_tool_contract_renders_prompt_and_search_shape_from_schemas() {
1337        let tool = ToolDefinition::raw_with_id(
1338            "tool:search_docs",
1339            "search_docs",
1340            "Search indexed docs",
1341            serde_json::json!({
1342                "type": "object",
1343                "properties": {
1344                    "query": { "type": "string" },
1345                    "limit": { "type": "integer", "maximum": 10, "default": 5 }
1346                },
1347                "required": ["query"]
1348            }),
1349            serde_json::json!({
1350                "type": "object",
1351                "properties": {
1352                    "matches": {
1353                        "type": "array",
1354                        "items": { "type": "string" }
1355                    },
1356                    "next_page": { "type": ["string", "null"] }
1357                },
1358                "required": ["matches"]
1359            }),
1360        )
1361        .with_examples(vec![
1362            "await tools.search_docs({ query: \"rust\" })?".to_string(),
1363            "await tools.search_docs({ query: \"rust\", limit: 3 })?".to_string(),
1364            "await tools.search_docs({ query: \"ignored\" })?".to_string(),
1365        ]);
1366
1367        let contract = tool.compact_contract();
1368        assert_eq!(
1369            contract.signature,
1370            "await tools.search_docs({ query: str, limit?: int <= 10 = 5 })?"
1371        );
1372        assert_eq!(
1373            contract.returns,
1374            "record{matches: list[str], next_page?: str | null}"
1375        );
1376        assert_eq!(
1377            contract.parameters,
1378            vec![
1379                serde_json::json!({
1380                    "name": "query",
1381                    "type": "str",
1382                    "required": true,
1383                    "signature": "query: str"
1384                }),
1385                serde_json::json!({
1386                    "name": "limit",
1387                    "type": "int",
1388                    "required": false,
1389                    "default": 5,
1390                    "maximum": 10,
1391                    "signature": "limit?: int <= 10 = 5"
1392                }),
1393            ]
1394        );
1395        assert_eq!(contract.examples.len(), 2);
1396
1397        let docs = ToolDefinition::format_tool_docs(&[tool]);
1398        assert!(docs.contains(
1399            "### await tools.search_docs({ query: str, limit?: int <= 10 = 5 })? -> record{matches: list[str], next_page?: str | null}"
1400        ));
1401        assert!(!docs.contains("Returns:"));
1402        assert!(docs.contains("Parameters:\n- `query: str`\n- `limit?: int <= 10 = 5`"));
1403        assert!(docs.contains(
1404            "Examples: await tools.search_docs({ query: \"rust\" })?; await tools.search_docs({ query: \"rust\", limit: 3 })?"
1405        ));
1406    }
1407
1408    #[test]
1409    fn compact_tool_contract_resolves_local_refs_in_string_or_list_parameters() {
1410        let tool = ToolDefinition::raw_with_id(
1411            "tool:search_tools",
1412            "search_tools",
1413            "Search tools",
1414            serde_json::json!({
1415                "$schema": "https://json-schema.org/draft/2020-12/schema",
1416                "$defs": {
1417                    "ModuleFilter": {
1418                        "anyOf": [
1419                            { "type": "string" },
1420                            {
1421                                "type": "array",
1422                                "items": { "type": "string" }
1423                            }
1424                        ]
1425                    }
1426                },
1427                "type": "object",
1428                "properties": {
1429                    "query": { "type": "string" },
1430                    "module": {
1431                        "anyOf": [
1432                            { "$ref": "#/$defs/ModuleFilter" },
1433                            { "type": "null" }
1434                        ]
1435                    }
1436                },
1437                "required": ["query"]
1438            }),
1439            serde_json::json!({
1440                "type": "array",
1441                "items": { "type": "object" }
1442            }),
1443        );
1444
1445        let signature = tool.compact_contract().render_signature();
1446
1447        assert!(
1448            signature.contains("module?: str | list[str] | null"),
1449            "{signature}"
1450        );
1451        assert!(!signature.contains("module?: any"), "{signature}");
1452    }
1453
1454    #[test]
1455    fn static_output_contract_keeps_existing_compact_docs_and_serde_shape() {
1456        let tool = ToolDefinition::raw_with_id(
1457            "tool:read_text",
1458            "read_text",
1459            "Read text",
1460            ToolDefinition::default_input_schema(),
1461            serde_json::json!({ "type": "string" }),
1462        );
1463        let explicit_static = tool
1464            .clone()
1465            .with_output_contract(ToolOutputContract::Static);
1466
1467        assert_eq!(
1468            ToolDefinition::format_tool_docs(std::slice::from_ref(&tool)),
1469            ToolDefinition::format_tool_docs(&[explicit_static])
1470        );
1471        assert_eq!(tool.compact_contract().returns, "str");
1472
1473        let serialized = serde_json::to_value(&tool).expect("serialize");
1474        assert!(serialized.get("output_contract").is_none());
1475        let deserialized: ToolDefinition = serde_json::from_value(serialized).expect("deserialize");
1476        assert!(deserialized.contract.output_contract.is_static());
1477    }
1478
1479    #[test]
1480    fn dynamic_output_contract_renders_schema_from_input_without_return_fields() {
1481        let tool = ToolDefinition::raw_with_id(
1482            "tool:spawn_agent",
1483            "spawn_agent",
1484            "Run a subagent",
1485            serde_json::json!({
1486                "type": "object",
1487                "properties": {
1488                    "output": { "type": "object", "additionalProperties": true }
1489                }
1490            }),
1491            serde_json::json!({ "type": "object", "additionalProperties": true }),
1492        )
1493        .with_agent_surface(ToolAgentSurface::new(["agents"], "spawn"))
1494        .with_output_from_input_schema("output", None);
1495
1496        let contract = tool.compact_contract();
1497        assert_eq!(
1498            contract.signature,
1499            "await agents.spawn<T = any>({ output?: TypeSpec<T> })?"
1500        );
1501        assert_eq!(contract.returns, "T");
1502        assert!(contract.return_fields.is_empty());
1503        assert_eq!(contract.render_returns(), "");
1504        assert_eq!(
1505            ToolDefinition::format_tool_docs(&[tool]),
1506            "### await agents.spawn<T = any>({ output?: TypeSpec<T> })? -> T\nRun a subagent\nParameters:\n- `output?: TypeSpec<T>`"
1507        );
1508    }
1509
1510    #[test]
1511    fn dynamic_output_contract_renders_default_schema() {
1512        let tool = ToolDefinition::raw_with_id(
1513            "tool:llm_query",
1514            "llm_query",
1515            "Run a lightweight LLM query",
1516            serde_json::json!({
1517                "type": "object",
1518                "properties": {
1519                    "task": { "type": "string" },
1520                    "output": { "type": "object", "additionalProperties": true }
1521                },
1522                "required": ["task"]
1523            }),
1524            serde_json::json!({ "type": "object", "additionalProperties": true }),
1525        )
1526        .with_agent_surface(ToolAgentSurface::new(["llm"], "query"))
1527        .with_output_from_input_schema("output", Some(serde_json::json!({ "type": "string" })));
1528
1529        let contract = tool.compact_contract();
1530        assert_eq!(
1531            contract.signature,
1532            "await llm.query<T = str>({ task: str, output?: TypeSpec<T> })?"
1533        );
1534        assert_eq!(contract.returns, "T");
1535        assert!(contract.return_fields.is_empty());
1536        assert_eq!(contract.render_returns(), "");
1537    }
1538
1539    #[test]
1540    fn json_schema_loaded_contract_matches_hardcoded_renderer() {
1541        let tool: ToolDefinition = serde_json::from_value(serde_json::json!({
1542            "id": "tool:mcp__appworld__spotify_search_songs",
1543            "name": "mcp__appworld__spotify_search_songs",
1544            "description": "[MCP appworld] Search for songs with a query.",
1545            "examples": ["search songs by genre"],
1546            "input_schema": {
1547                "type": "object",
1548                "properties": {
1549                    "access_token": {
1550                        "type": "string",
1551                        "description": "Access token obtained from spotify app login."
1552                    },
1553                    "genre": {
1554                        "type": ["string", "null"],
1555                        "description": "Only include songs from this genre.",
1556                        "default": null
1557                    },
1558                    "page_limit": {
1559                        "type": "integer",
1560                        "description": "Maximum number of songs to return.",
1561                        "minimum": 1,
1562                        "maximum": 20,
1563                        "default": 5
1564                    },
1565                    "sort_by": {
1566                        "type": ["string", "null"],
1567                        "description": "Field to sort by. Prefix with '-' for descending order.",
1568                        "default": null
1569                    }
1570                },
1571                "required": ["access_token"],
1572                "additionalProperties": false
1573            },
1574            "output_schema": {
1575                "anyOf": [
1576                    {
1577                        "type": "object",
1578                        "properties": {
1579                            "response": {
1580                                "type": "array",
1581                                "description": "Matched songs.",
1582                                "items": {
1583                                    "type": "object",
1584                                    "properties": {
1585                                        "album_id": {
1586                                            "type": ["integer", "null"],
1587                                            "description": "Album identifier when the song belongs to an album."
1588                                        },
1589                                        "album_title": { "type": ["string", "null"] },
1590                                        "artists": {
1591                                            "type": "array",
1592                                            "items": {
1593                                                "type": "object",
1594                                                "properties": {
1595                                                    "id": { "type": "integer" },
1596                                                    "name": { "type": "string" }
1597                                                },
1598                                                "required": ["id", "name"]
1599                                            }
1600                                        },
1601                                        "duration": { "type": "integer" },
1602                                        "genre": { "type": "string" },
1603                                        "like_count": { "type": "integer" },
1604                                        "play_count": {
1605                                            "type": "integer",
1606                                            "description": "Number of times the song was played.",
1607                                            "minimum": 0
1608                                        },
1609                                        "rating": { "type": "number" },
1610                                        "release_date": {
1611                                            "type": "string",
1612                                            "description": "Song release date in YYYY-MM-DD format."
1613                                        },
1614                                        "song_id": {
1615                                            "type": "integer",
1616                                            "description": "Stable song identifier."
1617                                        },
1618                                        "title": {
1619                                            "type": "string",
1620                                            "description": "Song title."
1621                                        }
1622                                    },
1623                                    "required": [
1624                                        "album_id",
1625                                        "album_title",
1626                                        "artists",
1627                                        "duration",
1628                                        "genre",
1629                                        "like_count",
1630                                        "play_count",
1631                                        "rating",
1632                                        "release_date",
1633                                        "song_id",
1634                                        "title"
1635                                    ]
1636                                }
1637                            }
1638                        },
1639                        "required": ["response"]
1640                    },
1641                    {
1642                        "type": "object",
1643                        "properties": {
1644                            "response": {
1645                                "type": "object",
1646                                "properties": {
1647                                    "message": {
1648                                        "type": "string",
1649                                        "description": "Failure or status message."
1650                                    }
1651                                },
1652                                "required": ["message"]
1653                            }
1654                        },
1655                        "required": ["response"]
1656                    }
1657                ]
1658            }
1659        }))
1660        .unwrap();
1661
1662        let contract = tool.compact_contract();
1663        assert_eq!(
1664            serde_json::to_value(&contract).unwrap(),
1665            serde_json::json!({
1666                "name": "tools.mcp__appworld__spotify_search_songs",
1667                "signature": "await tools.mcp__appworld__spotify_search_songs({ access_token: str, genre?: str | null = null, page_limit?: int >= 1 <= 20 = 5, sort_by?: str | null = null })?",
1668                "returns": "record{response: list[record{album_id: int | null, album_title: str | null, artists: list[record{id: int, name: str}], duration: int, genre: str, like_count: int, play_count: int, rating: float, release_date: str, song_id: int, title: str}]} | record{response: record{message: str}}",
1669                "parameters": [
1670                    {
1671                        "name": "access_token",
1672                        "type": "str",
1673                        "required": true,
1674                        "description": "Access token obtained from spotify app login.",
1675                        "signature": "access_token: str"
1676                    },
1677                    {
1678                        "name": "genre",
1679                        "type": "str | null",
1680                        "required": false,
1681                        "nullable": true,
1682                        "description": "Only include songs from this genre.",
1683                        "default": null,
1684                        "signature": "genre?: str | null = null"
1685                    },
1686                    {
1687                        "name": "page_limit",
1688                        "type": "int",
1689                        "required": false,
1690                        "description": "Maximum number of songs to return.",
1691                        "default": 5,
1692                        "minimum": 1,
1693                        "maximum": 20,
1694                        "signature": "page_limit?: int >= 1 <= 20 = 5"
1695                    },
1696                    {
1697                        "name": "sort_by",
1698                        "type": "str | null",
1699                        "required": false,
1700                        "nullable": true,
1701                        "description": "Field to sort by. Prefix with '-' for descending order.",
1702                        "default": null,
1703                        "signature": "sort_by?: str | null = null"
1704                    }
1705                ],
1706                "return_fields": [
1707                    {
1708                        "path": "response",
1709                        "type": "list[record]",
1710                        "required": true,
1711                        "description": "Matched songs.",
1712                        "items": "record",
1713                        "signature": "response: list[record]"
1714                    },
1715                    {
1716                        "path": "response[].album_id",
1717                        "type": "int | null",
1718                        "required": true,
1719                        "nullable": true,
1720                        "description": "Album identifier when the song belongs to an album.",
1721                        "signature": "response[].album_id: int | null"
1722                    },
1723                    {
1724                        "path": "response[].album_title",
1725                        "type": "str | null",
1726                        "required": true,
1727                        "nullable": true,
1728                        "signature": "response[].album_title: str | null"
1729                    },
1730                    {
1731                        "path": "response[].artists[].id",
1732                        "type": "int",
1733                        "required": true,
1734                        "signature": "response[].artists[].id: int"
1735                    },
1736                    {
1737                        "path": "response[].artists[].name",
1738                        "type": "str",
1739                        "required": true,
1740                        "signature": "response[].artists[].name: str"
1741                    },
1742                    {
1743                        "path": "response[].duration",
1744                        "type": "int",
1745                        "required": true,
1746                        "signature": "response[].duration: int"
1747                    },
1748                    {
1749                        "path": "response[].genre",
1750                        "type": "str",
1751                        "required": true,
1752                        "signature": "response[].genre: str"
1753                    },
1754                    {
1755                        "path": "response[].like_count",
1756                        "type": "int",
1757                        "required": true,
1758                        "signature": "response[].like_count: int"
1759                    },
1760                    {
1761                        "path": "response[].play_count",
1762                        "type": "int",
1763                        "required": true,
1764                        "description": "Number of times the song was played.",
1765                        "minimum": 0,
1766                        "signature": "response[].play_count: int >= 0"
1767                    },
1768                    {
1769                        "path": "response[].rating",
1770                        "type": "float",
1771                        "required": true,
1772                        "signature": "response[].rating: float"
1773                    },
1774                    {
1775                        "path": "response[].release_date",
1776                        "type": "str",
1777                        "required": true,
1778                        "description": "Song release date in YYYY-MM-DD format.",
1779                        "signature": "response[].release_date: str"
1780                    },
1781                    {
1782                        "path": "response[].song_id",
1783                        "type": "int",
1784                        "required": true,
1785                        "description": "Stable song identifier.",
1786                        "signature": "response[].song_id: int"
1787                    },
1788                    {
1789                        "path": "response[].title",
1790                        "type": "str",
1791                        "required": true,
1792                        "description": "Song title.",
1793                        "signature": "response[].title: str"
1794                    },
1795                    {
1796                        "path": "response.message",
1797                        "type": "str",
1798                        "required": true,
1799                        "description": "Failure or status message.",
1800                        "signature": "response.message: str"
1801                    }
1802                ],
1803                "description": "[MCP appworld] Search for songs with a query.",
1804                "examples": ["search songs by genre"]
1805            })
1806        );
1807
1808        assert_eq!(
1809            contract.render_markdown(),
1810            "### await tools.mcp__appworld__spotify_search_songs({ access_token: str, genre?: str | null = null, page_limit?: int >= 1 <= 20 = 5, sort_by?: str | null = null })? -> record{response: list[record{album_id: int | null, album_title: str | null, artists: list[record{id: int, name: str}], duration: int, genre: str, like_count: int, play_count: int, rating: float, release_date: str, song_id: int, title: str}]} | record{response: record{message: str}}\n[MCP appworld] Search for songs with a query.\nParameters:\n- `access_token: str` — Access token obtained from spotify app login.\n- `genre?: str | null = null` — Only include songs from this genre.\n- `page_limit?: int >= 1 <= 20 = 5` — Maximum number of songs to return.\n- `sort_by?: str | null = null` — Field to sort by. Prefix with '-' for descending order.\nReturn fields:\n- `response: list[record]` — Matched songs.\n- `response[].album_id: int | null` — Album identifier when the song belongs to an album.\n- `response[].album_title: str | null`\n- `response[].artists[].id: int`\n- `response[].artists[].name: str`\n- `response[].duration: int`\n- `response[].genre: str`\n- `response[].like_count: int`\n- `response[].play_count: int >= 0` — Number of times the song was played.\n- `response[].rating: float`\n- `response[].release_date: str` — Song release date in YYYY-MM-DD format.\n- `response[].song_id: int` — Stable song identifier.\n- `response[].title: str` — Song title.\n- `response.message: str` — Failure or status message.\nExamples: search songs by genre"
1811        );
1812        assert_eq!(
1813            contract.render_signature(),
1814            "await tools.mcp__appworld__spotify_search_songs({ access_token: str, genre?: str | null = null, page_limit?: int >= 1 <= 20 = 5, sort_by?: str | null = null })? -> record{response: list[record{album_id: int | null, album_title: str | null, artists: list[record{id: int, name: str}], duration: int, genre: str, like_count: int, play_count: int, rating: float, release_date: str, song_id: int, title: str}]} | record{response: record{message: str}}\nParameters:\n- `access_token: str` — Access token obtained from spotify app login.\n- `genre?: str | null = null` — Only include songs from this genre.\n- `page_limit?: int >= 1 <= 20 = 5` — Maximum number of songs to return.\n- `sort_by?: str | null = null` — Field to sort by. Prefix with '-' for descending order.\nReturn fields:\n- `response: list[record]` — Matched songs.\n- `response[].album_id: int | null` — Album identifier when the song belongs to an album.\n- `response[].album_title: str | null`\n- `response[].artists[].id: int`\n- `response[].artists[].name: str`\n- `response[].duration: int`\n- `response[].genre: str`\n- `response[].like_count: int`\n- `response[].play_count: int >= 0` — Number of times the song was played.\n- `response[].rating: float`\n- `response[].release_date: str` — Song release date in YYYY-MM-DD format.\n- `response[].song_id: int` — Stable song identifier.\n- `response[].title: str` — Song title.\n- `response.message: str` — Failure or status message."
1815        );
1816        assert_eq!(
1817            contract.render_returns(),
1818            "Return fields:\n- `response: list[record]` — Matched songs.\n- `response[].album_id: int | null` — Album identifier when the song belongs to an album.\n- `response[].album_title: str | null`\n- `response[].artists[].id: int`\n- `response[].artists[].name: str`\n- `response[].duration: int`\n- `response[].genre: str`\n- `response[].like_count: int`\n- `response[].play_count: int >= 0` — Number of times the song was played.\n- `response[].rating: float`\n- `response[].release_date: str` — Song release date in YYYY-MM-DD format.\n- `response[].song_id: int` — Stable song identifier.\n- `response[].title: str` — Song title.\n- `response.message: str` — Failure or status message."
1819        );
1820    }
1821
1822    #[test]
1823    fn json_schema_loaded_contract_merges_nullable_anyof_return_fields() {
1824        let tool: ToolDefinition = serde_json::from_value(serde_json::json!({
1825            "id": "tool:mcp__appworld__spotify_show_album_library",
1826            "name": "mcp__appworld__spotify_show_album_library",
1827            "description": "[MCP appworld] Search or show a list of albums in your album library.",
1828            "examples": ["show album library"],
1829            "input_schema": {
1830                "type": "object",
1831                "properties": {
1832                    "access_token": {
1833                        "type": "string",
1834                        "description": "Access token obtained from spotify app login."
1835                    },
1836                    "page_index": {
1837                        "type": "integer",
1838                        "description": "The index of the page to return.",
1839                        "minimum": 0,
1840                        "default": 0
1841                    },
1842                    "page_limit": {
1843                        "type": "integer",
1844                        "description": "The maximum number of results to return per page.",
1845                        "minimum": 1,
1846                        "maximum": 20,
1847                        "default": 5
1848                    }
1849                },
1850                "required": ["access_token"]
1851            },
1852            "output_schema": {
1853                "type": "object",
1854                "properties": {
1855                    "response": {
1856                        "anyOf": [
1857                            {
1858                                "type": "array",
1859                                "description": "Albums in the user's library.",
1860                                "items": {
1861                                    "type": "object",
1862                                    "properties": {
1863                                        "added_at": {
1864                                            "description": "When the album was added to the library.",
1865                                            "anyOf": [
1866                                                { "type": "string" },
1867                                                { "type": "null" }
1868                                            ]
1869                                        },
1870                                        "album_id": { "type": "integer" },
1871                                        "genre": {
1872                                            "type": "string",
1873                                            "description": "Album genre.",
1874                                            "minLength": 1
1875                                        },
1876                                        "song_ids": {
1877                                            "type": "array",
1878                                            "items": { "type": "integer" }
1879                                        },
1880                                        "title": {
1881                                            "type": "string",
1882                                            "minLength": 1
1883                                        }
1884                                    },
1885                                    "required": ["added_at", "album_id", "genre", "song_ids", "title"]
1886                                }
1887                            },
1888                            {
1889                                "type": "object",
1890                                "properties": {
1891                                    "message": {
1892                                        "type": "string",
1893                                        "description": "Failure or status message."
1894                                    }
1895                                },
1896                                "required": ["message"]
1897                            }
1898                        ]
1899                    }
1900                },
1901                "required": ["response"]
1902            }
1903        }))
1904        .unwrap();
1905
1906        let contract = tool.compact_contract();
1907        assert_eq!(
1908            serde_json::to_value(&contract).unwrap(),
1909            serde_json::json!({
1910                "name": "tools.mcp__appworld__spotify_show_album_library",
1911                "signature": "await tools.mcp__appworld__spotify_show_album_library({ access_token: str, page_index?: int >= 0 = 0, page_limit?: int >= 1 <= 20 = 5 })?",
1912                "returns": "record{response: list[record{added_at: null | str, album_id: int, genre: str, song_ids: list[int], title: str}] | record{message: str}}",
1913                "parameters": [
1914                    {
1915                        "name": "access_token",
1916                        "type": "str",
1917                        "required": true,
1918                        "description": "Access token obtained from spotify app login.",
1919                        "signature": "access_token: str"
1920                    },
1921                    {
1922                        "name": "page_index",
1923                        "type": "int",
1924                        "required": false,
1925                        "description": "The index of the page to return.",
1926                        "default": 0,
1927                        "minimum": 0,
1928                        "signature": "page_index?: int >= 0 = 0"
1929                    },
1930                    {
1931                        "name": "page_limit",
1932                        "type": "int",
1933                        "required": false,
1934                        "description": "The maximum number of results to return per page.",
1935                        "default": 5,
1936                        "minimum": 1,
1937                        "maximum": 20,
1938                        "signature": "page_limit?: int >= 1 <= 20 = 5"
1939                    }
1940                ],
1941                "return_fields": [
1942                    {
1943                        "path": "response",
1944                        "type": "list[record]",
1945                        "required": true,
1946                        "description": "Albums in the user's library.",
1947                        "items": "record",
1948                        "signature": "response: list[record]"
1949                    },
1950                    {
1951                        "path": "response[].added_at",
1952                        "type": "str | null",
1953                        "required": true,
1954                        "nullable": true,
1955                        "description": "When the album was added to the library.",
1956                        "signature": "response[].added_at: str | null"
1957                    },
1958                    {
1959                        "path": "response[].album_id",
1960                        "type": "int",
1961                        "required": true,
1962                        "signature": "response[].album_id: int"
1963                    },
1964                    {
1965                        "path": "response[].genre",
1966                        "type": "str",
1967                        "required": true,
1968                        "description": "Album genre.",
1969                        "min_length": 1,
1970                        "signature": "response[].genre: str min_len 1"
1971                    },
1972                    {
1973                        "path": "response[].song_ids[]",
1974                        "type": "int",
1975                        "required": true,
1976                        "signature": "response[].song_ids[]: int"
1977                    },
1978                    {
1979                        "path": "response[].title",
1980                        "type": "str",
1981                        "required": true,
1982                        "min_length": 1,
1983                        "signature": "response[].title: str min_len 1"
1984                    },
1985                    {
1986                        "path": "response.message",
1987                        "type": "str",
1988                        "required": true,
1989                        "description": "Failure or status message.",
1990                        "signature": "response.message: str"
1991                    }
1992                ],
1993                "description": "[MCP appworld] Search or show a list of albums in your album library.",
1994                "examples": ["show album library"]
1995            })
1996        );
1997        assert_eq!(
1998            contract.render_markdown(),
1999            "### await tools.mcp__appworld__spotify_show_album_library({ access_token: str, page_index?: int >= 0 = 0, page_limit?: int >= 1 <= 20 = 5 })? -> record{response: list[record{added_at: null | str, album_id: int, genre: str, song_ids: list[int], title: str}] | record{message: str}}\n[MCP appworld] Search or show a list of albums in your album library.\nParameters:\n- `access_token: str` — Access token obtained from spotify app login.\n- `page_index?: int >= 0 = 0` — The index of the page to return.\n- `page_limit?: int >= 1 <= 20 = 5` — The maximum number of results to return per page.\nReturn fields:\n- `response: list[record]` — Albums in the user's library.\n- `response[].added_at: str | null` — When the album was added to the library.\n- `response[].album_id: int`\n- `response[].genre: str min_len 1` — Album genre.\n- `response[].song_ids[]: int`\n- `response[].title: str min_len 1`\n- `response.message: str` — Failure or status message.\nExamples: show album library"
2000        );
2001    }
2002
2003    #[test]
2004    fn tool_agent_surface_serde_defaults_are_empty() {
2005        let tool: ToolDefinition = serde_json::from_value(serde_json::json!({
2006            "id": "tool:read_file",
2007            "name": "read_file",
2008            "description": "Read a file"
2009        }))
2010        .unwrap();
2011        assert!(tool.manifest.agent_surface.is_empty());
2012    }
2013
2014    #[test]
2015    fn tool_agent_surface_controls_prompt_call_form() {
2016        let mut with_metadata = ToolDefinition::raw_with_id(
2017            "tool:read_file",
2018            "read_file",
2019            "Read a file",
2020            ToolDefinition::default_input_schema(),
2021            serde_json::json!({"type": "string"}),
2022        );
2023        with_metadata.manifest.agent_surface =
2024            ToolAgentSurface::new(["fs"], "read").with_aliases(["cat"]);
2025
2026        assert!(
2027            ToolDefinition::format_tool_docs(&[with_metadata])
2028                .contains("### await fs.read({})? -> str")
2029        );
2030    }
2031}