1#[cfg(feature = "macros")]
9pub use ftl_sdk_macros::tools;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ToolMetadata {
16 pub name: String,
18
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub title: Option<String>,
22
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub description: Option<String>,
26
27 #[serde(rename = "inputSchema")]
29 pub input_schema: Value,
30
31 #[serde(rename = "outputSchema", skip_serializing_if = "Option::is_none")]
33 pub output_schema: Option<Value>,
34
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub annotations: Option<ToolAnnotations>,
38
39 #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
41 pub meta: Option<Value>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ToolAnnotations {
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub title: Option<String>,
50
51 #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
53 pub read_only_hint: Option<bool>,
54
55 #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
57 pub destructive_hint: Option<bool>,
58
59 #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
61 pub idempotent_hint: Option<bool>,
62
63 #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
65 pub open_world_hint: Option<bool>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ToolResponse {
71 pub content: Vec<ToolContent>,
73
74 #[serde(rename = "structuredContent", skip_serializing_if = "Option::is_none")]
76 pub structured_content: Option<Value>,
77
78 #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
80 pub is_error: Option<bool>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(tag = "type")]
86pub enum ToolContent {
87 #[serde(rename = "text")]
89 Text {
90 text: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 annotations: Option<ContentAnnotations>,
95 },
96
97 #[serde(rename = "image")]
99 Image {
100 data: String,
102 #[serde(rename = "mimeType")]
104 mime_type: String,
105 #[serde(skip_serializing_if = "Option::is_none")]
107 annotations: Option<ContentAnnotations>,
108 },
109
110 #[serde(rename = "audio")]
112 Audio {
113 data: String,
115 #[serde(rename = "mimeType")]
117 mime_type: String,
118 #[serde(skip_serializing_if = "Option::is_none")]
120 annotations: Option<ContentAnnotations>,
121 },
122
123 #[serde(rename = "resource")]
125 Resource {
126 resource: ResourceContents,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 annotations: Option<ContentAnnotations>,
131 },
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ContentAnnotations {
137 #[serde(skip_serializing_if = "Option::is_none")]
139 pub audience: Option<Vec<String>>,
140
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub priority: Option<f32>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct ResourceContents {
149 pub uri: String,
151
152 #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
154 pub mime_type: Option<String>,
155
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub text: Option<String>,
159
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub blob: Option<String>,
163}
164
165impl ToolResponse {
167 pub fn text(text: impl Into<String>) -> Self {
169 Self {
170 content: vec![ToolContent::Text {
171 text: text.into(),
172 annotations: None,
173 }],
174 structured_content: None,
175 is_error: None,
176 }
177 }
178
179 pub fn error(error: impl Into<String>) -> Self {
181 Self {
182 content: vec![ToolContent::Text {
183 text: error.into(),
184 annotations: None,
185 }],
186 structured_content: None,
187 is_error: Some(true),
188 }
189 }
190
191 pub fn with_structured(text: impl Into<String>, structured: Value) -> Self {
193 Self {
194 content: vec![ToolContent::Text {
195 text: text.into(),
196 annotations: None,
197 }],
198 structured_content: Some(structured),
199 is_error: None,
200 }
201 }
202}
203
204impl ToolContent {
205 pub fn text(text: impl Into<String>) -> Self {
207 Self::Text {
208 text: text.into(),
209 annotations: None,
210 }
211 }
212
213 pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
215 Self::Image {
216 data: data.into(),
217 mime_type: mime_type.into(),
218 annotations: None,
219 }
220 }
221}
222
223#[cfg(feature = "macros")]
225#[macro_export]
226macro_rules! text {
227 ($($arg:tt)*) => {
228 $crate::ToolResponse::text(format!($($arg)*))
229 };
230}
231
232#[cfg(feature = "macros")]
233#[macro_export]
234macro_rules! error {
235 ($($arg:tt)*) => {
236 $crate::ToolResponse::error(format!($($arg)*))
237 };
238}
239
240#[cfg(feature = "macros")]
241#[macro_export]
242macro_rules! structured {
243 ($data:expr, $($text:tt)*) => {
244 $crate::ToolResponse::with_structured(format!($($text)*), $data)
245 };
246}
247
248#[cfg(test)]
249mod tests {
250 use serde_json::json;
251
252 use super::*;
253
254 #[test]
255 fn test_tool_response_text() {
256 let response = ToolResponse::text("Hello, world!");
257 assert_eq!(response.content.len(), 1);
258 assert!(response.is_error.is_none());
259 }
260
261 #[test]
262 fn test_tool_response_error() {
263 let response = ToolResponse::error("Something went wrong");
264 assert_eq!(response.is_error, Some(true));
265 }
266
267 #[test]
268 fn test_serialization() {
269 let metadata = ToolMetadata {
270 name: "test-tool".to_string(),
271 title: None,
272 description: Some("A test tool".to_string()),
273 input_schema: json!({
274 "type": "object",
275 "properties": {
276 "input": { "type": "string" }
277 }
278 }),
279 output_schema: None,
280 annotations: None,
281 meta: None,
282 };
283
284 let Ok(json) = serde_json::to_string(&metadata) else {
285 assert!(
287 serde_json::to_string(&metadata).is_ok(),
288 "Failed to serialize metadata"
289 );
290 return;
291 };
292 assert!(json.contains("\"name\":\"test-tool\""));
293 assert!(!json.contains("\"title\""));
294 assert!(json.contains("\"description\":\"A test tool\""));
295 }
296
297 #[cfg(all(test, feature = "macros"))]
298 #[test]
299 fn test_response_macros() {
300 let response = text!("Hello, {}", "world");
302 assert_eq!(response.content.len(), 1);
303 if let Some(ToolContent::Text { text, .. }) = response.content.first() {
304 assert_eq!(text, "Hello, world");
305 } else {
306 assert!(
308 matches!(response.content.first(), Some(ToolContent::Text { .. })),
309 "Expected text content"
310 );
311 }
312
313 let response = error!("Error: {}", 42);
315 assert_eq!(response.is_error, Some(true));
316
317 let data = json!({"status": "ok"});
319 let response = structured!(data.clone(), "Operation {}", "successful");
320 assert_eq!(response.structured_content, Some(data));
321 }
322}