swiftide_core/chat_completion/
tools.rs1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, strum_macros::EnumIs)]
14#[non_exhaustive]
15pub enum ToolOutput {
16 Text(String),
18
19 FeedbackRequired(Option<serde_json::Value>),
21
22 Fail(String),
24
25 Stop(Option<serde_json::Value>),
27
28 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 pub fn as_text(&self) -> Option<&str> {
66 match self {
67 ToolOutput::Text(s) => Some(s),
68 _ => None,
69 }
70 }
71
72 pub fn as_fail(&self) -> Option<&str> {
74 match self {
75 ToolOutput::Fail(s) => Some(s),
76 _ => None,
77 }
78 }
79
80 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 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 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#[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
151impl 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#[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 pub name: String,
220 pub description: String,
222
223 #[builder(default, setter(strip_option))]
224 #[serde(skip_serializing_if = "Option::is_none")]
225 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 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 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 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}