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, serde::Serialize, serde::Deserialize)]
217#[serde(tag = "kind", rename_all = "snake_case")]
218pub enum ToolOutputContract {
219    #[default]
220    Static,
221    FromInputSchema {
222        input_field: String,
223        #[serde(default, skip_serializing_if = "Option::is_none")]
224        default_schema: Option<serde_json::Value>,
225    },
226}
227
228impl ToolOutputContract {
229    pub fn from_input_schema(
230        input_field: impl Into<String>,
231        default_schema: Option<serde_json::Value>,
232    ) -> Self {
233        Self::FromInputSchema {
234            input_field: input_field.into(),
235            default_schema,
236        }
237    }
238
239    pub fn is_static(&self) -> bool {
240        matches!(self, Self::Static)
241    }
242
243    fn return_type_label(&self, static_schema: &serde_json::Value) -> String {
244        match self {
245            Self::Static => compact_schema_label(static_schema),
246            Self::FromInputSchema { .. } => "T".to_string(),
247        }
248    }
249
250    fn type_parameter_suffix(&self) -> Option<String> {
251        match self {
252            Self::Static => None,
253            Self::FromInputSchema { default_schema, .. } => {
254                let default = default_schema
255                    .as_ref()
256                    .map(compact_schema_label)
257                    .unwrap_or_else(|| "any".to_string());
258                Some(format!("<T = {default}>"))
259            }
260        }
261    }
262
263    fn apply_type_witness_parameter(&self, params: &mut [ParameterDoc]) {
264        let Self::FromInputSchema { input_field, .. } = self else {
265            return;
266        };
267        if let Some(param) = params.iter_mut().find(|param| param.name == *input_field) {
268            param.type_label = "TypeSpec<T>".to_string();
269            param.nullable = false;
270            param.default_value = None;
271            param.enum_values.clear();
272            param.minimum = None;
273            param.maximum = None;
274            param.min_length = None;
275            param.max_length = None;
276            param.min_items = None;
277            param.max_items = None;
278            param.item_type = None;
279        }
280    }
281
282    fn return_fields(&self, static_schema: &serde_json::Value) -> Vec<serde_json::Value> {
283        match self {
284            Self::Static => return_field_metadata(static_schema),
285            Self::FromInputSchema { .. } => Vec::new(),
286        }
287    }
288}
289
290#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
291#[serde(tag = "kind", rename_all = "snake_case")]
292pub enum ToolArgumentProjectionPolicy {
293    #[default]
294    MaterializeProjectedValues,
295    PreserveProjectedRefsInField {
296        field: String,
297    },
298}
299
300impl ToolArgumentProjectionPolicy {
301    pub fn preserve_projected_refs_in_field(field: impl Into<String>) -> Self {
302        Self::PreserveProjectedRefsInField {
303            field: field.into(),
304        }
305    }
306
307    pub fn is_materialize_projected_values(&self) -> bool {
308        matches!(self, Self::MaterializeProjectedValues)
309    }
310}
311
312fn is_default_tool_argument_projection_policy(policy: &ToolArgumentProjectionPolicy) -> bool {
313    policy.is_materialize_projected_values()
314}
315
316#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
317#[serde(transparent)]
318pub struct ToolId(String);
319
320impl ToolId {
321    pub fn new(id: impl Into<String>) -> Self {
322        let id = id.into();
323        assert!(!id.trim().is_empty(), "tool id must not be empty");
324        Self(id)
325    }
326
327    pub fn as_str(&self) -> &str {
328        &self.0
329    }
330}
331
332impl<'de> serde::Deserialize<'de> for ToolId {
333    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
334    where
335        D: serde::Deserializer<'de>,
336    {
337        let id = <String as serde::Deserialize>::deserialize(deserializer)?;
338        if id.trim().is_empty() {
339            return Err(serde::de::Error::custom("tool id must not be empty"));
340        }
341        Ok(Self(id))
342    }
343}
344
345impl From<String> for ToolId {
346    fn from(id: String) -> Self {
347        Self::new(id)
348    }
349}
350
351impl From<&str> for ToolId {
352    fn from(id: &str) -> Self {
353        Self::new(id)
354    }
355}
356
357impl std::fmt::Display for ToolId {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        f.write_str(&self.0)
360    }
361}
362
363/// Tool metadata exposed to prompts, catalogs, UI, and availability checks.
364/// The optional compact contract is the catalog-facing projection of the
365/// resolved contract; full schemas stay in [`ToolContract`].
366#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
367pub struct ToolManifest {
368    pub id: ToolId,
369    pub name: String,
370    #[serde(default, skip_serializing_if = "String::is_empty")]
371    pub description: String,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub compact_contract: Option<CompactToolContract>,
374    #[serde(default, skip_serializing_if = "is_default_tool_availability_config")]
375    pub availability: ToolAvailabilityConfig,
376    #[serde(default, skip_serializing_if = "is_default_tool_activation")]
377    pub activation: ToolActivation,
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub availability_override: Option<ToolAvailability>,
380    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
381    pub bindings: std::collections::BTreeMap<String, serde_json::Value>,
382    #[serde(
383        default,
384        skip_serializing_if = "is_default_tool_argument_projection_policy"
385    )]
386    pub argument_projection: ToolArgumentProjectionPolicy,
387    #[serde(
388        default = "default_tool_scheduling",
389        skip_serializing_if = "is_default_tool_scheduling"
390    )]
391    pub scheduling: ToolScheduling,
392    #[serde(
393        default = "default_tool_retry_policy",
394        skip_serializing_if = "is_default_tool_retry_policy"
395    )]
396    pub retry_policy: ToolRetryPolicy,
397}
398
399impl ToolManifest {
400    pub fn effective_availability(&self) -> ToolAvailability {
401        self.availability_override
402            .unwrap_or_else(|| self.availability.base())
403    }
404}
405
406/// Heavy tool contract resolved only when a prompt or call needs schemas/docs.
407#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
408pub struct ToolContract {
409    #[serde(default = "ToolContract::default_input_schema")]
410    pub input_schema: serde_json::Value,
411    #[serde(default)]
412    pub output_schema: serde_json::Value,
413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
414    pub input_schema_projections: Vec<SchemaProjectionOverride>,
415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
416    pub output_schema_projections: Vec<SchemaProjectionOverride>,
417    #[serde(default, skip_serializing_if = "ToolOutputContract::is_static")]
418    pub output_contract: ToolOutputContract,
419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
420    pub examples: Vec<String>,
421}
422
423impl Default for ToolContract {
424    fn default() -> Self {
425        Self {
426            input_schema: Self::default_input_schema(),
427            output_schema: serde_json::Value::Null,
428            input_schema_projections: Vec::new(),
429            output_schema_projections: Vec::new(),
430            output_contract: ToolOutputContract::Static,
431            examples: Vec::new(),
432        }
433    }
434}
435
436impl ToolContract {
437    pub fn default_input_schema() -> serde_json::Value {
438        serde_json::json!({
439            "type": "object",
440            "properties": {},
441            "additionalProperties": true
442        })
443    }
444
445    pub fn compact_contract(&self, manifest: &ToolManifest) -> CompactToolContract {
446        self.compact_contract_with_example_limit(manifest, COMPACT_TOOL_EXAMPLE_LIMIT)
447    }
448
449    pub fn compact_contract_with_example_limit(
450        &self,
451        manifest: &ToolManifest,
452        example_limit: usize,
453    ) -> CompactToolContract {
454        self.compact_contract_with_signature_name_and_example_limit(
455            manifest,
456            &manifest.name,
457            example_limit,
458        )
459    }
460
461    pub fn compact_contract_with_signature_name(
462        &self,
463        manifest: &ToolManifest,
464        signature_name: &str,
465    ) -> CompactToolContract {
466        self.compact_contract_with_signature_name_and_example_limit(
467            manifest,
468            signature_name,
469            COMPACT_TOOL_EXAMPLE_LIMIT,
470        )
471    }
472
473    pub fn compact_contract_with_signature_name_and_example_limit(
474        &self,
475        manifest: &ToolManifest,
476        signature_name: &str,
477        example_limit: usize,
478    ) -> CompactToolContract {
479        CompactToolContract {
480            name: signature_name.to_string(),
481            signature: self.input_signature_with_name(manifest, signature_name),
482            returns: self.output_summary(),
483            parameters: self.parameter_metadata(),
484            return_fields: self.output_contract.return_fields(&self.output_schema),
485            description: manifest.description.trim().to_string(),
486            examples: compact_examples(&self.examples, example_limit),
487        }
488    }
489
490    pub fn input_signature(&self, manifest: &ToolManifest) -> String {
491        self.input_signature_with_name(manifest, &manifest.name)
492    }
493
494    pub fn input_signature_with_name(
495        &self,
496        _manifest: &ToolManifest,
497        signature_name: &str,
498    ) -> String {
499        let params = self
500            .parameter_docs()
501            .into_iter()
502            .map(|p| p.signature_fragment())
503            .collect::<Vec<_>>();
504        let body = if params.is_empty() {
505            "{}".to_string()
506        } else {
507            format!("{{ {} }}", params.join(", "))
508        };
509        format!(
510            "{}{}({})",
511            signature_name,
512            self.output_contract
513                .type_parameter_suffix()
514                .unwrap_or_default(),
515            body
516        )
517    }
518
519    pub fn output_summary(&self) -> String {
520        self.output_contract.return_type_label(&self.output_schema)
521    }
522
523    pub fn parameter_metadata(&self) -> Vec<serde_json::Value> {
524        self.parameter_docs()
525            .into_iter()
526            .map(|param| param.into_value())
527            .collect()
528    }
529
530    pub fn model_tool(&self, manifest: &ToolManifest) -> ModelTool {
531        ModelTool {
532            name: manifest.name.clone(),
533            description: manifest.description.clone(),
534            input_schema: self.input_schema.clone(),
535            output_schema: self.output_schema.clone(),
536            input_schema_projections: self.input_schema_projections.clone(),
537            output_schema_projections: self.output_schema_projections.clone(),
538        }
539    }
540
541    fn parameter_docs(&self) -> Vec<ParameterDoc> {
542        let mut params = schema_parameter_docs(&self.input_schema);
543        self.output_contract
544            .apply_type_witness_parameter(&mut params);
545        params
546    }
547}
548
549/// Static authoring helper for tools.
550///
551/// Composes the runtime [`ToolManifest`] and [`ToolContract`] projections. Both
552/// are `#[serde(flatten)]`ed so the serialized JSON shape stays flat (and wire/
553/// persistence compatible); the two structs have disjoint field names.
554#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
555pub struct ToolDefinition {
556    #[serde(flatten)]
557    pub manifest: ToolManifest,
558    #[serde(flatten)]
559    pub contract: ToolContract,
560}
561
562#[derive(Clone, Debug, PartialEq, Eq)]
563pub struct ModelTool {
564    pub name: String,
565    pub description: String,
566    pub input_schema: serde_json::Value,
567    pub output_schema: serde_json::Value,
568    pub input_schema_projections: Vec<SchemaProjectionOverride>,
569    pub output_schema_projections: Vec<SchemaProjectionOverride>,
570}
571
572#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
573pub struct SchemaProjectionOverride {
574    pub profile: String,
575    pub schema: serde_json::Value,
576}
577
578const COMPACT_TOOL_EXAMPLE_LIMIT: usize = 2;
579const COMPACT_TOOL_EXAMPLE_CHAR_LIMIT: usize = 240;
580
581#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
582pub struct CompactToolContract {
583    pub name: String,
584    pub signature: String,
585    pub returns: String,
586    #[serde(default, skip_serializing_if = "Vec::is_empty")]
587    pub parameters: Vec<serde_json::Value>,
588    #[serde(default, skip_serializing_if = "Vec::is_empty")]
589    pub return_fields: Vec<serde_json::Value>,
590    #[serde(default, skip_serializing_if = "String::is_empty")]
591    pub description: String,
592    #[serde(default, skip_serializing_if = "Vec::is_empty")]
593    pub examples: Vec<String>,
594}
595
596impl CompactToolContract {
597    pub fn render_signature_head(&self) -> String {
598        format!("{} -> {}", self.signature.trim(), self.returns.trim())
599    }
600
601    pub fn render_signature(&self) -> String {
602        let mut sections = vec![self.render_signature_head()];
603        let parameter_lines = self
604            .parameters
605            .iter()
606            .filter_map(compact_doc_line)
607            .collect::<Vec<_>>();
608        if !parameter_lines.is_empty() {
609            sections.push(format!("Parameters:\n{}", parameter_lines.join("\n")));
610        }
611        let return_field_lines = self
612            .return_fields
613            .iter()
614            .filter_map(compact_doc_line)
615            .collect::<Vec<_>>();
616        if !return_field_lines.is_empty() {
617            sections.push(format!("Return fields:\n{}", return_field_lines.join("\n")));
618        }
619        sections.join("\n")
620    }
621
622    pub fn render_returns(&self) -> String {
623        let mut sections = Vec::new();
624        let return_field_lines = self
625            .return_fields
626            .iter()
627            .filter_map(compact_doc_line)
628            .collect::<Vec<_>>();
629        if !return_field_lines.is_empty() {
630            sections.push(format!("Return fields:\n{}", return_field_lines.join("\n")));
631        }
632        sections.join("\n")
633    }
634
635    pub fn render_markdown(&self) -> String {
636        let mut sections = vec![format!("### {}", self.render_signature_head())];
637        if !self.description.trim().is_empty() {
638            sections.push(self.description.trim().to_string());
639        }
640        if !self.parameters.is_empty() {
641            sections.push(format!(
642                "Parameters:\n{}",
643                self.parameters
644                    .iter()
645                    .filter_map(compact_doc_line)
646                    .collect::<Vec<_>>()
647                    .join("\n")
648            ));
649        }
650        if !self.return_fields.is_empty() {
651            sections.push(format!(
652                "Return fields:\n{}",
653                self.return_fields
654                    .iter()
655                    .filter_map(compact_doc_line)
656                    .collect::<Vec<_>>()
657                    .join("\n")
658            ));
659        }
660        if !self.examples.is_empty() {
661            sections.push(format!("Examples: {}", self.examples.join("; ")));
662        }
663        sections.join("\n")
664    }
665}
666
667impl ToolDefinition {
668    pub fn raw(
669        id: impl Into<ToolId>,
670        name: impl Into<String>,
671        description: impl Into<String>,
672        input_schema: serde_json::Value,
673        output_schema: serde_json::Value,
674    ) -> Self {
675        Self {
676            manifest: ToolManifest {
677                id: id.into(),
678                name: name.into(),
679                description: description.into(),
680                compact_contract: None,
681                availability: ToolAvailabilityConfig::default(),
682                activation: ToolActivation::default(),
683                availability_override: None,
684                bindings: std::collections::BTreeMap::new(),
685                argument_projection: ToolArgumentProjectionPolicy::default(),
686                scheduling: default_tool_scheduling(),
687                retry_policy: default_tool_retry_policy(),
688            },
689            contract: ToolContract {
690                input_schema,
691                output_schema,
692                ..ToolContract::default()
693            },
694        }
695    }
696
697    pub fn typed<Args, Output>(
698        id: impl Into<ToolId>,
699        name: impl Into<String>,
700        description: impl Into<String>,
701    ) -> Self
702    where
703        Args: schemars::JsonSchema,
704        Output: schemars::JsonSchema,
705    {
706        Self::raw(
707            id,
708            name,
709            description,
710            schema_for::<Args>(),
711            schema_for::<Output>(),
712        )
713    }
714
715    pub fn with_examples(mut self, examples: Vec<String>) -> Self {
716        self.contract.examples = examples;
717        self
718    }
719
720    pub fn with_availability(mut self, availability: ToolAvailabilityConfig) -> Self {
721        self.manifest.availability = availability;
722        self
723    }
724
725    pub fn with_activation(mut self, activation: ToolActivation) -> Self {
726        self.manifest.activation = activation;
727        self
728    }
729
730    pub fn with_argument_projection(
731        mut self,
732        argument_projection: ToolArgumentProjectionPolicy,
733    ) -> Self {
734        self.manifest.argument_projection = argument_projection;
735        self
736    }
737
738    pub fn with_scheduling(mut self, scheduling: ToolScheduling) -> Self {
739        self.manifest.scheduling = scheduling;
740        self
741    }
742
743    pub fn with_retry_policy(mut self, retry_policy: ToolRetryPolicy) -> Self {
744        self.manifest.retry_policy = retry_policy;
745        self
746    }
747
748    pub fn with_output_contract(mut self, output_contract: ToolOutputContract) -> Self {
749        self.contract.output_contract = output_contract;
750        self
751    }
752
753    pub fn with_input_schema_projection(
754        mut self,
755        profile: impl Into<String>,
756        schema: serde_json::Value,
757    ) -> Self {
758        let profile = profile.into();
759        self.contract
760            .input_schema_projections
761            .retain(|projection| projection.profile != profile);
762        self.contract
763            .input_schema_projections
764            .push(SchemaProjectionOverride { profile, schema });
765        self
766    }
767
768    pub fn with_output_schema_projection(
769        mut self,
770        profile: impl Into<String>,
771        schema: serde_json::Value,
772    ) -> Self {
773        let profile = profile.into();
774        self.contract
775            .output_schema_projections
776            .retain(|projection| projection.profile != profile);
777        self.contract
778            .output_schema_projections
779            .push(SchemaProjectionOverride { profile, schema });
780        self
781    }
782
783    pub fn with_output_from_input_schema(
784        self,
785        input_field: impl Into<String>,
786        default_schema: Option<serde_json::Value>,
787    ) -> Self {
788        self.with_output_contract(ToolOutputContract::from_input_schema(
789            input_field,
790            default_schema,
791        ))
792    }
793
794    pub fn default_input_schema() -> serde_json::Value {
795        ToolContract::default_input_schema()
796    }
797
798    /// Tool identity. Read very widely, so exposed as a thin accessor over the
799    /// composed [`ToolManifest`].
800    pub fn id(&self) -> &ToolId {
801        &self.manifest.id
802    }
803
804    /// Tool name. Read very widely, so exposed as a thin accessor.
805    pub fn name(&self) -> &str {
806        &self.manifest.name
807    }
808
809    /// Tool description. Read very widely, so exposed as a thin accessor.
810    pub fn description(&self) -> &str {
811        &self.manifest.description
812    }
813
814    pub fn input_signature(&self) -> String {
815        self.contract.input_signature(&self.manifest)
816    }
817
818    pub fn output_summary(&self) -> String {
819        self.contract.output_summary()
820    }
821
822    pub fn signature(&self) -> String {
823        format!("{} -> {}", self.input_signature(), self.output_summary())
824    }
825
826    pub fn compact_contract(&self) -> CompactToolContract {
827        self.compact_contract_with_example_limit(COMPACT_TOOL_EXAMPLE_LIMIT)
828    }
829
830    pub fn compact_contract_with_example_limit(&self, example_limit: usize) -> CompactToolContract {
831        self.contract
832            .compact_contract_with_example_limit(&self.manifest, example_limit)
833    }
834
835    pub fn effective_availability(&self) -> ToolAvailability {
836        self.manifest.effective_availability()
837    }
838
839    pub fn model_tool(&self) -> ModelTool {
840        self.contract.model_tool(&self.manifest)
841    }
842
843    /// Project the manifest, computing the catalog-facing compact contract from
844    /// the resolved [`ToolContract`].
845    pub fn manifest(&self) -> ToolManifest {
846        let mut manifest = self.manifest.clone();
847        manifest.compact_contract = Some(self.contract.compact_contract(&manifest));
848        manifest
849    }
850
851    pub fn contract(&self) -> ToolContract {
852        self.contract.clone()
853    }
854
855    /// Recompose a definition from its [`ToolManifest`] and [`ToolContract`]
856    /// projections — the inverse of [`ToolDefinition::manifest`]/[`ToolDefinition::contract`].
857    pub fn from_parts(manifest: ToolManifest, contract: ToolContract) -> Self {
858        Self { manifest, contract }
859    }
860
861    pub fn format_tool_docs(tools: &[ToolDefinition]) -> String {
862        Self::format_tool_docs_iter(tools.iter())
863    }
864
865    pub fn format_tool_docs_iter<'a>(
866        tools: impl IntoIterator<Item = &'a ToolDefinition>,
867    ) -> String {
868        tools
869            .into_iter()
870            .map(|tool| tool.compact_contract().render_markdown())
871            .collect::<Vec<_>>()
872            .join("\n\n")
873    }
874
875    pub fn parameter_metadata(&self) -> Vec<serde_json::Value> {
876        self.parameter_docs()
877            .into_iter()
878            .map(|param| param.into_value())
879            .collect()
880    }
881
882    fn parameter_docs(&self) -> Vec<ParameterDoc> {
883        let mut params = schema_parameter_docs(&self.contract.input_schema);
884        self.contract
885            .output_contract
886            .apply_type_witness_parameter(&mut params);
887        params
888    }
889}
890
891mod schema_docs;
892pub use schema_docs::schema_for;
893use schema_docs::{
894    ParameterDoc, compact_doc_line, compact_examples, compact_schema_label, return_field_metadata,
895    schema_parameter_docs,
896};
897
898mod schema_validation;
899pub use schema_validation::{LashSchema, validate_tool_input};
900
901include!("tool_contract/tests.rs");