1use async_trait::async_trait;
2use deepstrike_core::types::message::ToolSchema;
3use futures::future::BoxFuture;
4use serde_json::Value;
5use std::collections::HashMap;
6use std::sync::Arc;
7
8use crate::{Error, Result};
9
10#[derive(Debug, Clone, PartialEq)]
11pub enum ToolChunk {
12 Text(String),
13 Progress {
14 progress: f64,
15 message: Option<String>,
16 },
17 Artifact {
18 artifact_id: String,
19 mime_type: Option<String>,
20 label: Option<String>,
21 },
22 JsonPatch(Value),
23 Suspend {
24 suspension_id: String,
25 payload: Option<Value>,
26 },
27}
28
29impl ToolChunk {
30 pub fn text(value: impl Into<String>) -> Self {
31 Self::Text(value.into())
32 }
33 pub fn progress(progress: f64, message: Option<String>) -> Self {
34 Self::Progress { progress, message }
35 }
36 pub fn artifact(
37 artifact_id: impl Into<String>,
38 mime_type: Option<String>,
39 label: Option<String>,
40 ) -> Self {
41 Self::Artifact {
42 artifact_id: artifact_id.into(),
43 mime_type,
44 label,
45 }
46 }
47 pub fn json_patch(patch: Value) -> Self {
48 Self::JsonPatch(patch)
49 }
50 pub fn suspend(suspension_id: impl Into<String>, payload: Option<Value>) -> Self {
51 Self::Suspend {
52 suspension_id: suspension_id.into(),
53 payload,
54 }
55 }
56 pub fn text_projection(&self) -> &str {
57 match self {
58 Self::Text(s) => s.as_str(),
59 _ => "",
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq)]
65pub enum ToolStep {
66 Chunk(ToolChunk),
67 Done(String),
68}
69
70#[async_trait]
71pub trait ToolSession: Send {
72 async fn next(&mut self, resume_input: Option<Value>) -> Result<ToolStep>;
73}
74
75pub struct TextToolSession {
76 output: Option<String>,
77}
78
79impl TextToolSession {
80 pub fn new(output: impl Into<String>) -> Self {
81 Self {
82 output: Some(output.into()),
83 }
84 }
85}
86
87#[async_trait]
88impl ToolSession for TextToolSession {
89 async fn next(&mut self, _resume_input: Option<Value>) -> Result<ToolStep> {
90 Ok(ToolStep::Done(self.output.take().unwrap_or_default()))
91 }
92}
93
94pub type ToolFn =
95 Arc<dyn Fn(Value) -> BoxFuture<'static, Result<Box<dyn ToolSession>>> + Send + Sync>;
96
97pub struct RegisteredTool {
98 pub schema: ToolSchema,
99 pub start: ToolFn,
100}
101
102impl RegisteredTool {
103 pub fn new(
104 name: impl Into<compact_str::CompactString>,
105 description: impl Into<String>,
106 parameters: Value,
107 f: impl Fn(Value) -> BoxFuture<'static, Result<Box<dyn ToolSession>>> + Send + Sync + 'static,
108 ) -> Self {
109 Self {
110 schema: ToolSchema {
111 name: name.into(),
112 description: description.into(),
113 parameters,
114 },
115 start: Arc::new(f),
116 }
117 }
118
119 pub fn text(
120 name: impl Into<compact_str::CompactString>,
121 description: impl Into<String>,
122 parameters: Value,
123 f: impl Fn(Value) -> BoxFuture<'static, Result<String>> + Send + Sync + 'static,
124 ) -> Self {
125 Self::new(name, description, parameters, move |args| {
126 let fut = f(args);
127 Box::pin(async move {
128 Ok(Box::new(TextToolSession::new(fut.await?)) as Box<dyn ToolSession>)
129 })
130 })
131 }
132}
133
134pub fn validate_tool_arguments(
135 schema: &Value,
136 args: &mut Value,
137) -> std::result::Result<bool, String> {
138 let mut repaired = false;
139 validate_value(schema, args, "$", true, &mut repaired)?;
140 Ok(repaired)
141}
142
143fn validate_value(
144 schema: &Value,
145 value: &mut Value,
146 path: &str,
147 is_root: bool,
148 repaired: &mut bool,
149) -> std::result::Result<(), String> {
150 if let Some(union) = schema
152 .get("oneOf")
153 .or_else(|| schema.get("anyOf"))
154 .and_then(Value::as_array)
155 {
156 for sub in union {
157 let mut probe = value.clone();
159 let mut probe_repaired = false;
160 if validate_value(sub, &mut probe, path, is_root, &mut probe_repaired).is_ok() {
161 *value = probe; if probe_repaired {
163 *repaired = true;
164 }
165 return Ok(());
166 }
167 }
168 return Err(format!("{path} does not match any allowed shape"));
169 }
170
171 if let Some(expected) = schema.get("type").and_then(Value::as_str) {
173 match expected {
174 "boolean" => {
175 if let Some(s) = value.as_str() {
176 if s == "true" {
177 *value = Value::Bool(true);
178 *repaired = true;
179 } else if s == "false" {
180 *value = Value::Bool(false);
181 *repaired = true;
182 }
183 }
184 }
185 "number" | "integer" => {
186 if let Some(s) = value.as_str() {
187 if let Ok(num) = s.parse::<f64>() {
188 if expected == "integer" {
189 if num == num.round() {
190 *value = Value::Number(serde_json::Number::from(num as i64));
191 *repaired = true;
192 }
193 } else {
194 if let Some(n) = serde_json::Number::from_f64(num) {
195 *value = Value::Number(n);
196 *repaired = true;
197 }
198 }
199 }
200 }
201 }
202 "array" => {
203 if value.is_object() {
209 let coerced = {
210 let obj = value.as_object().unwrap();
211 let single = if obj.len() == 1 {
212 obj.get("item").or_else(|| obj.get("items"))
213 } else {
214 None
215 };
216 match single {
217 Some(inner) if inner.is_array() => inner.clone(),
218 Some(inner) => Value::Array(vec![inner.clone()]),
219 None => Value::Array(vec![value.clone()]),
220 }
221 };
222 *value = coerced;
223 *repaired = true;
224 }
225 }
226 _ => {}
227 }
228 }
229
230 if let Some(obj) = value.as_object_mut() {
232 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
233 for (key, child_schema) in properties {
234 if !obj.contains_key(key) {
235 if let Some(default_val) = child_schema.get("default") {
236 obj.insert(key.clone(), default_val.clone());
237 *repaired = true;
238 }
239 }
240 }
241 }
242 }
243
244 if let Some(expected) = schema.get("type").and_then(Value::as_str) {
246 match expected {
247 "object" => {
248 let Some(obj) = value.as_object_mut() else {
249 return Err(format!("{path} must be object"));
250 };
251
252 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
256 let allowed_keys: std::collections::HashSet<&str> =
257 properties.keys().map(|s| s.as_str()).collect();
258 let additional = schema.get("additionalProperties");
259 let extra_keys: Vec<String> = obj
260 .keys()
261 .filter(|k| !allowed_keys.contains(k.as_str()))
262 .cloned()
263 .collect();
264 for k in extra_keys {
265 match additional {
266 Some(Value::Bool(true)) => {} Some(sub @ Value::Object(_)) => {
268 if let Some(child) = obj.get_mut(&k) {
270 validate_value(sub, child, &format!("{path}.{k}"), false, repaired)?;
271 }
272 }
273 _ => {
274 obj.remove(&k);
276 *repaired = true;
277 }
278 }
279 }
280 }
281
282 if let Some(required) = schema.get("required").and_then(Value::as_array) {
283 for key in required.iter().filter_map(Value::as_str) {
284 if !obj.contains_key(key) {
285 return Err(format!("{path}.{key} is required"));
286 }
287 }
288 }
289 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
290 for (key, child_schema) in properties {
291 if let Some(child_value) = obj.get_mut(key) {
292 validate_value(
293 child_schema,
294 child_value,
295 &format!("{path}.{key}"),
296 false,
297 repaired,
298 )?;
299 }
300 }
301 }
302 }
303 "array" => {
304 let Some(arr) = value.as_array_mut() else {
305 return Err(format!("{path} must be array"));
306 };
307 if let Some(items_schema) = schema.get("items") {
308 for (i, child_value) in arr.iter_mut().enumerate() {
309 validate_value(
310 items_schema,
311 child_value,
312 &format!("{path}[{i}]"),
313 false,
314 repaired,
315 )?;
316 }
317 }
318 }
319 "string" if !value.is_string() => return Err(format!("{path} must be string")),
320 "number" if !value.is_number() => return Err(format!("{path} must be number")),
321 "integer" if !value.is_i64() && !value.is_u64() => {
322 return Err(format!("{path} must be integer"));
323 }
324 "boolean" if !value.is_boolean() => return Err(format!("{path} must be boolean")),
325 _ => {}
326 }
327 } else if is_root && !value.is_object() {
328 return Err(format!("{path} must be object"));
329 }
330 if let Some(values) = schema.get("enum").and_then(Value::as_array) {
331 if !values.contains(value) {
332 return Err(format!("{path} must be one of enum values"));
333 }
334 }
335 Ok(())
336}
337
338pub async fn execute_tools(
339 calls: &[deepstrike_core::types::message::ToolCall],
340 registry: &HashMap<String, RegisteredTool>,
341) -> Vec<deepstrike_core::types::message::ToolResult> {
342 let mut results = Vec::new();
343 for call in calls {
344 let Some(tool) = registry.get(call.name.as_str()) else {
345 results.push(tool_result(
346 call.id.clone(),
347 format!("unknown tool: {}", call.name),
348 true,
349 ));
350 continue;
351 };
352 let mut call_args = call.arguments.clone();
353 if let Err(e) = validate_tool_arguments(&tool.schema.parameters, &mut call_args) {
354 results.push(tool_result(
355 call.id.clone(),
356 format!("invalid arguments: {e}"),
357 true,
358 ));
359 continue;
360 }
361 let mut session = match (tool.start)(call_args).await {
362 Ok(session) => session,
363 Err(e) => {
364 results.push(tool_result(call.id.clone(), e.to_string(), true));
365 continue;
366 }
367 };
368 let mut combined = String::new();
369 loop {
370 match session.next(None).await {
371 Ok(ToolStep::Chunk(chunk)) => {
372 if matches!(chunk, ToolChunk::Suspend { .. }) {
373 results.push(tool_result(
374 call.id.clone(),
375 "tool suspended without resume handler".into(),
376 true,
377 ));
378 break;
379 }
380 combined.push_str(chunk.text_projection());
381 }
382 Ok(ToolStep::Done(text)) => {
383 combined.push_str(&text);
384 results.push(tool_result(call.id.clone(), combined, false));
385 break;
386 }
387 Err(e) => {
388 results.push(tool_result(call.id.clone(), e.to_string(), true));
389 break;
390 }
391 }
392 }
393 }
394 results
395}
396
397fn tool_result(
398 call_id: compact_str::CompactString,
399 output: String,
400 is_error: bool,
401) -> deepstrike_core::types::message::ToolResult {
402 deepstrike_core::types::message::ToolResult {
403 call_id,
404 output: deepstrike_core::types::message::Content::Text(output),
405 is_error,
406 is_fatal: false,
407 error_kind: None,
408 token_count: None,
409 }
410}
411
412#[derive(Debug, Clone, serde::Serialize)]
419#[serde(untagged)]
420pub enum ToolEnvelope {
421 Ok(ToolEnvelopeOk),
422 Fail(ToolEnvelopeFail),
423}
424
425#[derive(Debug, Clone, serde::Serialize)]
426pub struct ToolEnvelopeOk {
427 pub success: bool, #[serde(skip_serializing_if = "Option::is_none")]
429 pub data: Option<Value>,
430}
431
432#[derive(Debug, Clone, serde::Serialize)]
433pub struct ToolEnvelopeFail {
434 pub success: bool, pub code: String,
436 pub error: String,
437 #[serde(skip_serializing_if = "Option::is_none")]
438 pub hint: Option<String>,
439}
440
441pub fn ok(data: impl Into<Option<Value>>) -> ToolEnvelope {
442 ToolEnvelope::Ok(ToolEnvelopeOk { success: true, data: data.into() })
443}
444
445pub fn fail(code: impl Into<String>, error: impl Into<String>, hint: Option<String>) -> ToolEnvelope {
446 ToolEnvelope::Fail(ToolEnvelopeFail {
447 success: false,
448 code: code.into(),
449 error: error.into(),
450 hint,
451 })
452}
453
454pub fn tool_fail(message: impl Into<String>, code: Option<String>, hint: Option<String>) -> crate::Error {
458 crate::Error::ToolFail {
459 output: message.into(),
460 code,
461 hint,
462 is_fatal: false,
463 error_kind: None,
464 }
465}
466
467pub fn safe_tool<F, Fut>(
474 name: impl Into<compact_str::CompactString>,
475 description: impl Into<String>,
476 parameters: Value,
477 f: F,
478) -> RegisteredTool
479where
480 F: Fn(Value) -> Fut + Send + Sync + 'static,
481 Fut: std::future::Future<Output = Result<SafeToolResult>> + Send + 'static,
482{
483 let f = Arc::new(f);
484 RegisteredTool::text(name, description, parameters, move |args| {
485 let f = Arc::clone(&f);
486 Box::pin(async move {
487 let envelope = match f(args).await {
488 Ok(SafeToolResult::Envelope(env)) => env,
489 Ok(SafeToolResult::Data(v)) => ok(Some(v)),
490 Err(e) => {
491 let env = match &e {
492 crate::Error::ToolFail { output, code, hint, .. } => fail(
493 code.clone().unwrap_or_else(|| "internal".to_string()),
494 output.clone(),
495 hint.clone(),
496 ),
497 _ => fail("internal", crate::format_tool_error(&e), None),
498 };
499 env
500 }
501 };
502 Ok(serde_json::to_string(&envelope).unwrap_or_else(|_| String::from(r#"{"success":false,"code":"internal","error":"envelope serialization failed"}"#)))
503 })
504 })
505}
506
507#[derive(Debug, Clone)]
511pub enum SafeToolResult {
512 Envelope(ToolEnvelope),
513 Data(Value),
514}
515
516impl From<ToolEnvelope> for SafeToolResult {
517 fn from(e: ToolEnvelope) -> Self { Self::Envelope(e) }
518}
519impl From<Value> for SafeToolResult {
520 fn from(v: Value) -> Self { Self::Data(v) }
521}
522
523pub fn read_file_tool() -> RegisteredTool {
524 RegisteredTool::text(
525 "read_file",
526 "Read the contents of a file.",
527 serde_json::json!({ "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"] }),
528 |args| {
529 Box::pin(async move {
530 let path = args["path"]
531 .as_str()
532 .ok_or_else(|| Error::Tool("missing path".into()))?;
533 tokio::fs::read_to_string(path)
534 .await
535 .map_err(|e| Error::Tool(e.to_string()))
536 })
537 },
538 )
539}