Skip to main content

swiftide_core/chat_completion/
tools.rs

1use std::cmp::Ordering;
2
3use crate::command_output::CommandOutput;
4use derive_builder::Builder;
5use schemars::Schema;
6use serde::{Deserialize, Serialize};
7use serde_json::{Map as JsonMap, Value as JsonValue};
8use thiserror::Error;
9
10pub use super::tool_schema::{StrictToolParametersSchema, ToolSchemaError};
11
12/// Output of a `ToolCall` which will be added as a message for the agent to use.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, strum_macros::EnumIs)]
14#[non_exhaustive]
15pub enum ToolOutput {
16    /// Adds the result of the toolcall to messages
17    Text(String),
18
19    /// Indicates that the toolcall requires feedback, i.e. in a human-in-the-loop
20    FeedbackRequired(Option<serde_json::Value>),
21
22    /// Indicates that the toolcall failed, but can be handled by the llm
23    Fail(String),
24
25    /// Stops an agent with an optional message
26    Stop(Option<serde_json::Value>),
27
28    /// Indicates that the agent failed and should stop
29    AgentFailed(Option<serde_json::Value>),
30}
31
32impl ToolOutput {
33    pub fn text(text: impl Into<String>) -> Self {
34        ToolOutput::Text(text.into())
35    }
36
37    pub fn feedback_required(feedback: Option<serde_json::Value>) -> Self {
38        ToolOutput::FeedbackRequired(feedback)
39    }
40
41    pub fn stop() -> Self {
42        ToolOutput::Stop(None)
43    }
44
45    pub fn stop_with_args(output: impl Into<serde_json::Value>) -> Self {
46        ToolOutput::Stop(Some(output.into()))
47    }
48
49    pub fn agent_failed(output: impl Into<serde_json::Value>) -> Self {
50        ToolOutput::AgentFailed(Some(output.into()))
51    }
52
53    pub fn fail(text: impl Into<String>) -> Self {
54        ToolOutput::Fail(text.into())
55    }
56
57    pub fn content(&self) -> Option<&str> {
58        match self {
59            ToolOutput::Fail(s) | ToolOutput::Text(s) => Some(s),
60            _ => None,
61        }
62    }
63
64    /// Get the inner text if the output is a `Text` variant.
65    pub fn as_text(&self) -> Option<&str> {
66        match self {
67            ToolOutput::Text(s) => Some(s),
68            _ => None,
69        }
70    }
71
72    /// Get the inner text if the output is a `Fail` variant.
73    pub fn as_fail(&self) -> Option<&str> {
74        match self {
75            ToolOutput::Fail(s) => Some(s),
76            _ => None,
77        }
78    }
79
80    /// Get the inner text if the output is a `Stop` variant.
81    pub fn as_stop(&self) -> Option<&serde_json::Value> {
82        match self {
83            ToolOutput::Stop(args) => args.as_ref(),
84            _ => None,
85        }
86    }
87
88    /// Get the inner text if the output is an `AgentFailed` variant.
89    pub fn as_agent_failed(&self) -> Option<&serde_json::Value> {
90        match self {
91            ToolOutput::AgentFailed(args) => args.as_ref(),
92            _ => None,
93        }
94    }
95
96    /// Get the inner feedback if the output is a `FeedbackRequired` variant.
97    pub fn as_feedback_required(&self) -> Option<&serde_json::Value> {
98        match self {
99            ToolOutput::FeedbackRequired(args) => args.as_ref(),
100            _ => None,
101        }
102    }
103}
104
105impl<S: AsRef<str>> From<S> for ToolOutput {
106    fn from(value: S) -> Self {
107        ToolOutput::Text(value.as_ref().to_string())
108    }
109}
110
111impl From<CommandOutput> for ToolOutput {
112    fn from(output: CommandOutput) -> Self {
113        ToolOutput::Text(output.into_string_lossy())
114    }
115}
116impl std::fmt::Display for ToolOutput {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            ToolOutput::Text(value) => write!(f, "{value}"),
120            ToolOutput::Fail(value) => write!(f, "Tool call failed: {value}"),
121            ToolOutput::Stop(args) => {
122                if let Some(value) = args {
123                    write!(f, "Stop {value}")
124                } else {
125                    write!(f, "Stop")
126                }
127            }
128            ToolOutput::FeedbackRequired(_) => {
129                write!(f, "Feedback required")
130            }
131            ToolOutput::AgentFailed(args) => write!(
132                f,
133                "Agent failed with output: {}",
134                args.as_ref().unwrap_or_default()
135            ),
136        }
137    }
138}
139
140/// A tool call that can be executed by the executor
141#[derive(Clone, Debug, Builder, PartialEq, Serialize, Deserialize, Eq)]
142#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
143#[builder(setter(into, strip_option))]
144pub struct ToolCall {
145    id: String,
146    name: String,
147    #[builder(default)]
148    args: Option<String>,
149}
150
151/// Hash is used for finding tool calls that have been retried by agents
152impl std::hash::Hash for ToolCall {
153    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
154        self.name.hash(state);
155        self.args.hash(state);
156    }
157}
158
159impl std::fmt::Display for ToolCall {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        write!(
162            f,
163            "{id}#{name} {args}",
164            id = self.id,
165            name = self.name,
166            args = self.args.as_deref().unwrap_or("")
167        )
168    }
169}
170
171impl ToolCall {
172    pub fn builder() -> ToolCallBuilder {
173        ToolCallBuilder::default()
174    }
175
176    pub fn id(&self) -> &str {
177        &self.id
178    }
179
180    pub fn name(&self) -> &str {
181        &self.name
182    }
183
184    pub fn args(&self) -> Option<&str> {
185        self.args.as_deref()
186    }
187
188    pub fn with_args(&mut self, args: Option<String>) {
189        self.args = args;
190    }
191}
192
193impl ToolCallBuilder {
194    pub fn maybe_args<T: Into<Option<String>>>(&mut self, args: T) -> &mut Self {
195        self.args = Some(args.into());
196        self
197    }
198
199    pub fn maybe_id<T: Into<Option<String>>>(&mut self, id: T) -> &mut Self {
200        self.id = id.into();
201        self
202    }
203
204    pub fn maybe_name<T: Into<Option<String>>>(&mut self, name: T) -> &mut Self {
205        self.name = name.into();
206        self
207    }
208}
209
210/// A typed tool specification intended to be usable for multiple LLMs
211///
212/// i.e. the json spec `OpenAI` uses to define their tools
213#[derive(Clone, Debug, Serialize, Deserialize, Builder, Default)]
214#[builder(setter(into), derive(Debug, Serialize, Deserialize), build_fn(skip))]
215#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
216#[serde(deny_unknown_fields)]
217pub struct ToolSpec {
218    /// Name of the tool
219    pub name: String,
220    /// Description passed to the LLM for the tool
221    pub description: String,
222
223    #[builder(default, setter(strip_option))]
224    #[serde(skip_serializing_if = "Option::is_none")]
225    /// Optional JSON schema describing the tool arguments
226    pub parameters_schema: Option<Schema>,
227}
228
229#[derive(Debug, Error)]
230pub enum ToolSpecError {
231    #[error(transparent)]
232    InvalidParametersSchema(#[from] ToolSchemaError),
233}
234
235#[derive(Debug, Error)]
236pub enum ToolSpecBuildError {
237    #[error("missing required field `{field}`")]
238    MissingField { field: &'static str },
239    #[error(transparent)]
240    InvalidParametersSchema(#[from] ToolSchemaError),
241}
242
243impl ToolSpec {
244    pub fn builder() -> ToolSpecBuilder {
245        ToolSpecBuilder::default()
246    }
247
248    /// Returns the provider-neutral strict parameters schema for this tool.
249    ///
250    /// # Errors
251    ///
252    /// Returns an error when the configured parameters schema is not compatible
253    /// with Swiftide's strict tool-schema contract.
254    pub fn strict_parameters_schema(&self) -> Result<StrictToolParametersSchema, ToolSpecError> {
255        Ok(StrictToolParametersSchema::try_from_raw(
256            self.parameters_schema.as_ref(),
257        )?)
258    }
259
260    /// Returns the provider-neutral strict parameters schema with deterministic JSON key ordering.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error when the configured parameters schema is not compatible
265    /// with Swiftide's strict tool-schema contract.
266    pub fn canonical_parameters_schema_json(&self) -> Result<JsonValue, ToolSpecError> {
267        Ok(canonicalize_json(
268            self.strict_parameters_schema()?.into_json(),
269        ))
270    }
271}
272
273impl ToolSpecBuilder {
274    /// Builds a tool specification and validates its parameters schema.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error when a required field is missing or when the provided
279    /// parameters schema is not compatible with Swiftide's strict tool-schema
280    /// contract.
281    pub fn build(&self) -> Result<ToolSpec, ToolSpecBuildError> {
282        let name = self
283            .name
284            .clone()
285            .ok_or(ToolSpecBuildError::MissingField { field: "name" })?;
286        let description = self
287            .description
288            .clone()
289            .ok_or(ToolSpecBuildError::MissingField {
290                field: "description",
291            })?;
292        let parameters_schema = self.parameters_schema.clone().unwrap_or(None);
293
294        StrictToolParametersSchema::try_from_raw(parameters_schema.as_ref())?;
295
296        Ok(ToolSpec {
297            name,
298            description,
299            parameters_schema,
300        })
301    }
302}
303
304impl PartialEq for ToolSpec {
305    fn eq(&self, other: &Self) -> bool {
306        self.name == other.name
307            && self.description == other.description
308            && tool_spec_schema_key(self) == tool_spec_schema_key(other)
309    }
310}
311
312impl Eq for ToolSpec {}
313
314impl PartialOrd for ToolSpec {
315    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
316        Some(self.cmp(other))
317    }
318}
319
320impl Ord for ToolSpec {
321    fn cmp(&self, other: &Self) -> Ordering {
322        self.name
323            .cmp(&other.name)
324            .then_with(|| self.description.cmp(&other.description))
325            .then_with(|| tool_spec_schema_key(self).cmp(&tool_spec_schema_key(other)))
326    }
327}
328
329impl std::hash::Hash for ToolSpec {
330    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
331        self.name.hash(state);
332        self.description.hash(state);
333        tool_spec_schema_key(self).hash(state);
334    }
335}
336
337fn tool_spec_schema_key(spec: &ToolSpec) -> String {
338    spec.canonical_parameters_schema_json()
339        .ok()
340        .or_else(|| {
341            spec.parameters_schema
342                .as_ref()
343                .and_then(|schema| serde_json::to_value(schema).ok())
344                .map(canonicalize_json)
345        })
346        .and_then(|schema| serde_json::to_string(&schema).ok())
347        .unwrap_or_default()
348}
349
350pub fn canonicalize_json(value: JsonValue) -> JsonValue {
351    match value {
352        JsonValue::Object(object) => {
353            let mut keys = object.keys().cloned().collect::<Vec<_>>();
354            keys.sort();
355
356            let mut sorted = JsonMap::with_capacity(object.len());
357            for key in keys {
358                if let Some(child) = object.get(&key) {
359                    sorted.insert(key, canonicalize_json(child.clone()));
360                }
361            }
362
363            JsonValue::Object(sorted)
364        }
365        JsonValue::Array(values) => {
366            JsonValue::Array(values.into_iter().map(canonicalize_json).collect())
367        }
368        scalar => scalar,
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use serde_json::{Value, json};
376    use std::collections::{BTreeSet, HashSet};
377    use std::hash::{DefaultHasher, Hash, Hasher};
378
379    #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
380    struct ExampleArgs {
381        value: String,
382    }
383
384    #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
385    struct NestedCommentArgs {
386        request: NestedCommentRequest,
387    }
388
389    #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
390    #[serde(deny_unknown_fields)]
391    struct NestedCommentRequest {
392        #[serde(default, skip_serializing_if = "Option::is_none")]
393        body: Option<String>,
394        #[serde(default, skip_serializing_if = "Option::is_none")]
395        text: Option<String>,
396        #[serde(default, skip_serializing_if = "Option::is_none")]
397        page_id: Option<String>,
398        #[serde(default, skip_serializing_if = "Option::is_none")]
399        block_id: Option<String>,
400        #[serde(default, skip_serializing_if = "Option::is_none")]
401        discussion_id: Option<String>,
402    }
403
404    #[derive(serde::Serialize, serde::Deserialize)]
405    #[serde(transparent)]
406    struct FreeformObject(serde_json::Map<String, Value>);
407
408    impl schemars::JsonSchema for FreeformObject {
409        fn schema_name() -> std::borrow::Cow<'static, str> {
410            "FreeformObject".into()
411        }
412
413        fn json_schema(_generator: &mut schemars::SchemaGenerator) -> Schema {
414            serde_json::from_value(json!({
415                "type": "object",
416                "additionalProperties": true
417            }))
418            .expect("freeform object schema should serialize")
419        }
420    }
421
422    #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
423    #[serde(deny_unknown_fields)]
424    struct CreateViewArgs {
425        request: CreateViewRequest,
426    }
427
428    #[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
429    #[serde(deny_unknown_fields)]
430    struct CreateViewRequest {
431        body: FreeformObject,
432    }
433
434    #[test]
435    fn tool_spec_serializes_schema() {
436        let schema = schemars::schema_for!(ExampleArgs);
437
438        let spec = ToolSpec::builder()
439            .name("example")
440            .description("An example tool")
441            .parameters_schema(schema)
442            .build()
443            .unwrap();
444
445        let json = serde_json::to_value(&spec).unwrap();
446        assert_eq!(json.get("name").and_then(|v| v.as_str()), Some("example"));
447        assert!(json.get("parameters_schema").is_some());
448    }
449
450    #[test]
451    fn tool_spec_is_hashable() {
452        let schema = schemars::schema_for!(ExampleArgs);
453        let spec = ToolSpec::builder()
454            .name("example")
455            .description("An example tool")
456            .parameters_schema(schema)
457            .build()
458            .unwrap();
459
460        let mut set = HashSet::new();
461        set.insert(spec.clone());
462
463        assert!(set.contains(&spec));
464    }
465
466    #[test]
467    fn tool_spec_hash_is_stable_across_schema_key_order() {
468        let first = ToolSpec::builder()
469            .name("create_view")
470            .description("Create a view")
471            .parameters_schema(
472                serde_json::from_value::<Schema>(json!({
473                    "type": "object",
474                    "properties": {
475                        "body": { "type": "string" },
476                        "name": { "type": "string" }
477                    }
478                }))
479                .unwrap(),
480            )
481            .build()
482            .unwrap();
483
484        let second = ToolSpec::builder()
485            .name("create_view")
486            .description("Create a view")
487            .parameters_schema(
488                serde_json::from_value::<Schema>(json!({
489                    "properties": {
490                        "name": { "type": "string" },
491                        "body": { "type": "string" }
492                    },
493                    "type": "object"
494                }))
495                .unwrap(),
496            )
497            .build()
498            .unwrap();
499
500        let mut first_hasher = DefaultHasher::new();
501        first.hash(&mut first_hasher);
502
503        let mut second_hasher = DefaultHasher::new();
504        second.hash(&mut second_hasher);
505
506        assert_eq!(first_hasher.finish(), second_hasher.finish());
507    }
508
509    #[test]
510    fn tool_spec_order_is_stable_across_schema_key_order() {
511        let first = ToolSpec::builder()
512            .name("create_view")
513            .description("Create a view")
514            .parameters_schema(
515                serde_json::from_value::<Schema>(json!({
516                    "type": "object",
517                    "properties": {
518                        "body": { "type": "string" },
519                        "name": { "type": "string" }
520                    }
521                }))
522                .unwrap(),
523            )
524            .build()
525            .unwrap();
526
527        let second = ToolSpec::builder()
528            .name("create_view")
529            .description("Create a view")
530            .parameters_schema(
531                serde_json::from_value::<Schema>(json!({
532                    "properties": {
533                        "name": { "type": "string" },
534                        "body": { "type": "string" }
535                    },
536                    "type": "object"
537                }))
538                .unwrap(),
539            )
540            .build()
541            .unwrap();
542
543        let set = BTreeSet::from([first, second]);
544
545        assert_eq!(set.len(), 1);
546    }
547
548    #[test]
549    fn strict_parameters_schema_returns_canonical_nested_schema() {
550        let spec = ToolSpec::builder()
551            .name("comment")
552            .description("Create a comment")
553            .parameters_schema(schemars::schema_for!(NestedCommentArgs))
554            .build()
555            .unwrap();
556
557        let normalized = spec.strict_parameters_schema().unwrap().into_json();
558
559        assert_eq!(normalized["type"], Value::String("object".into()));
560        assert_eq!(normalized["additionalProperties"], Value::Bool(false));
561        assert_eq!(
562            normalized["required"],
563            Value::Array(vec![Value::String("request".into())])
564        );
565
566        let nested_ref = normalized["properties"]["request"]["$ref"]
567            .as_str()
568            .expect("nested request should be referenced");
569        let nested_name = nested_ref
570            .rsplit('/')
571            .next()
572            .expect("nested request ref name");
573        assert!(
574            normalized["$defs"][nested_name].get("required").is_none(),
575            "strict schema parsing should preserve optional nested fields before provider shaping"
576        );
577    }
578
579    #[test]
580    fn strict_parameters_schema_sets_additional_properties_false_on_nested_typed_objects() {
581        let spec = ToolSpec::builder()
582            .name("comment")
583            .description("Create a comment")
584            .parameters_schema(schemars::schema_for!(NestedCommentArgs))
585            .build()
586            .unwrap();
587
588        let normalized = spec.strict_parameters_schema().unwrap().into_json();
589
590        let nested_ref = normalized["properties"]["request"]["$ref"]
591            .as_str()
592            .expect("nested request should be referenced");
593        let nested_name = nested_ref
594            .rsplit('/')
595            .next()
596            .expect("nested request ref name");
597
598        assert_eq!(
599            normalized["$defs"][nested_name]["additionalProperties"],
600            Value::Bool(false)
601        );
602    }
603
604    #[test]
605    fn tool_spec_builder_rejects_nested_freeform_objects_in_strict_mode() {
606        let error = ToolSpec::builder()
607            .name("create_view")
608            .description("Create a view")
609            .parameters_schema(schemars::schema_for!(CreateViewArgs))
610            .build()
611            .expect_err("freeform object should be rejected in strict mode");
612
613        let message = error.to_string();
614        assert!(message.contains("strict tool schemas do not support open object schemas"));
615        assert!(message.contains("FreeformObject"));
616    }
617
618    #[test]
619    fn strict_parameters_schema_preserves_optional_nested_fields() {
620        let spec = ToolSpec::builder()
621            .name("comment")
622            .description("Create a comment")
623            .parameters_schema(schemars::schema_for!(NestedCommentArgs))
624            .build()
625            .unwrap();
626
627        let normalized = spec.strict_parameters_schema().unwrap().into_json();
628
629        assert_eq!(normalized["type"], Value::String("object".into()));
630        assert_eq!(normalized["additionalProperties"], Value::Bool(false));
631        assert_eq!(
632            normalized["required"],
633            Value::Array(vec![Value::String("request".into())])
634        );
635
636        let nested_ref = normalized["properties"]["request"]["$ref"]
637            .as_str()
638            .expect("nested request should be referenced");
639        let nested_name = nested_ref
640            .rsplit('/')
641            .next()
642            .expect("nested request ref name");
643        assert_eq!(
644            normalized["$defs"][nested_name]["additionalProperties"],
645            Value::Bool(false)
646        );
647        assert!(normalized["$defs"][nested_name].get("required").is_none());
648    }
649}