Skip to main content

deepstrike_sdk/
tools.rs

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    // 0. 多态联合 (oneOf / anyOf) —— 先于单一 type 分支匹配
151    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            // 先克隆再试:避免某分支的 auto-cast/裁剪部分改写后又失败,污染后续分支
158            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; // 接受首个匹配分支(连同它内部的 repair)
162                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    // 1. 类型自动规整 (Auto-cast / Repair)
172    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                // coerceItemArray: LLMs commonly wrap array args in a single-key { item: X } /
204                // { items: X } envelope, or emit a lone object where a one-element array was
205                // expected. Coerce both to an array so per-element validation runs (precise
206                // `$.path[i]…` errors) instead of a blunt "must be array". Aligned with the
207                // string→number/boolean casts above.
208                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    // 2. 补默认值 (Default Injection)
231    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    // 3. 校验并递归
245    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                // 3a. 裁剪 schema 未声明的多余字段 —— 尊重 additionalProperties。
253                // 仅当显式声明了 properties 时才介入(保持原有行为:无 properties 的对象不裁剪);
254                // 缺省/false 维持旧的"裁剪"行为(现存工具都依赖它),只有显式 true 或子 schema 才放行。
255                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)) => {} // 任意键放行:不校验、不裁剪
267                            Some(sub @ Value::Object(_)) => {
268                                // 用子 schema 递归校验每个额外键的值(也会 auto-cast / 补默认)
269                                if let Some(child) = obj.get_mut(&k) {
270                                    validate_value(
271                                        sub,
272                                        child,
273                                        &format!("{path}.{k}"),
274                                        false,
275                                        repaired,
276                                    )?;
277                                }
278                            }
279                            _ => {
280                                // additionalProperties 缺省/false → 维持旧行为
281                                obj.remove(&k);
282                                *repaired = true;
283                            }
284                        }
285                    }
286                }
287
288                if let Some(required) = schema.get("required").and_then(Value::as_array) {
289                    for key in required.iter().filter_map(Value::as_str) {
290                        if !obj.contains_key(key) {
291                            return Err(format!("{path}.{key} is required"));
292                        }
293                    }
294                }
295                if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
296                    for (key, child_schema) in properties {
297                        if let Some(child_value) = obj.get_mut(key) {
298                            validate_value(
299                                child_schema,
300                                child_value,
301                                &format!("{path}.{key}"),
302                                false,
303                                repaired,
304                            )?;
305                        }
306                    }
307                }
308            }
309            "array" => {
310                let Some(arr) = value.as_array_mut() else {
311                    return Err(format!("{path} must be array"));
312                };
313                if let Some(items_schema) = schema.get("items") {
314                    for (i, child_value) in arr.iter_mut().enumerate() {
315                        validate_value(
316                            items_schema,
317                            child_value,
318                            &format!("{path}[{i}]"),
319                            false,
320                            repaired,
321                        )?;
322                    }
323                }
324            }
325            "string" if !value.is_string() => return Err(format!("{path} must be string")),
326            "number" if !value.is_number() => return Err(format!("{path} must be number")),
327            "integer" if !value.is_i64() && !value.is_u64() => {
328                return Err(format!("{path} must be integer"));
329            }
330            "boolean" if !value.is_boolean() => return Err(format!("{path} must be boolean")),
331            _ => {}
332        }
333    } else if is_root && !value.is_object() {
334        return Err(format!("{path} must be object"));
335    }
336    if let Some(values) = schema.get("enum").and_then(Value::as_array) {
337        if !values.contains(value) {
338            return Err(format!("{path} must be one of enum values"));
339        }
340    }
341    Ok(())
342}
343
344pub async fn execute_tools(
345    calls: &[deepstrike_core::types::message::ToolCall],
346    registry: &HashMap<String, RegisteredTool>,
347) -> Vec<deepstrike_core::types::message::ToolResult> {
348    let mut results = Vec::new();
349    for call in calls {
350        let Some(tool) = registry.get(call.name.as_str()) else {
351            results.push(tool_result(
352                call.id.clone(),
353                format!("unknown tool: {}", call.name),
354                true,
355            ));
356            continue;
357        };
358        let mut call_args = call.arguments.clone();
359        if let Err(e) = validate_tool_arguments(&tool.schema.parameters, &mut call_args) {
360            results.push(tool_result(
361                call.id.clone(),
362                format!("invalid arguments: {e}"),
363                true,
364            ));
365            continue;
366        }
367        let mut session = match (tool.start)(call_args).await {
368            Ok(session) => session,
369            Err(e) => {
370                results.push(tool_result(call.id.clone(), e.to_string(), true));
371                continue;
372            }
373        };
374        let mut combined = String::new();
375        loop {
376            match session.next(None).await {
377                Ok(ToolStep::Chunk(chunk)) => {
378                    if matches!(chunk, ToolChunk::Suspend { .. }) {
379                        results.push(tool_result(
380                            call.id.clone(),
381                            "tool suspended without resume handler".into(),
382                            true,
383                        ));
384                        break;
385                    }
386                    combined.push_str(chunk.text_projection());
387                }
388                Ok(ToolStep::Done(text)) => {
389                    combined.push_str(&text);
390                    results.push(tool_result(call.id.clone(), combined, false));
391                    break;
392                }
393                Err(e) => {
394                    results.push(tool_result(call.id.clone(), e.to_string(), true));
395                    break;
396                }
397            }
398        }
399    }
400    results
401}
402
403fn tool_result(
404    call_id: compact_str::CompactString,
405    output: String,
406    is_error: bool,
407) -> deepstrike_core::types::message::ToolResult {
408    deepstrike_core::types::message::ToolResult {
409        call_id,
410        output: deepstrike_core::types::message::Content::Text(output),
411        is_error,
412        is_fatal: false,
413        error_kind: None,
414        token_count: None,
415    }
416}
417
418// ── Structured tool envelope (safe_tool / ok / fail / tool_fail) ──────────────────────────────
419//
420// Opt-in: same shape as the Node/Python `safe_tool` + `ok()` / `fail()` envelope so a tool
421// authored once produces consistent `{success, code?, error?, hint?}` JSON for the model across
422// runtimes. The classic `RegisteredTool::text()` factory is unchanged.
423
424#[derive(Debug, Clone, serde::Serialize)]
425#[serde(untagged)]
426pub enum ToolEnvelope {
427    Ok(ToolEnvelopeOk),
428    Fail(ToolEnvelopeFail),
429}
430
431#[derive(Debug, Clone, serde::Serialize)]
432pub struct ToolEnvelopeOk {
433    pub success: bool, // always true
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub data: Option<Value>,
436}
437
438#[derive(Debug, Clone, serde::Serialize)]
439pub struct ToolEnvelopeFail {
440    pub success: bool, // always false
441    pub code: String,
442    pub error: String,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub hint: Option<String>,
445}
446
447pub fn ok(data: impl Into<Option<Value>>) -> ToolEnvelope {
448    ToolEnvelope::Ok(ToolEnvelopeOk {
449        success: true,
450        data: data.into(),
451    })
452}
453
454pub fn fail(
455    code: impl Into<String>,
456    error: impl Into<String>,
457    hint: Option<String>,
458) -> ToolEnvelope {
459    ToolEnvelope::Fail(ToolEnvelopeFail {
460        success: false,
461        code: code.into(),
462        error: error.into(),
463        hint,
464    })
465}
466
467/// Build a coded tool-failure `Error` (parity with Node `new ToolError(message, {code, hint})`).
468/// Throwing this from a `safe_tool` body produces `{success:false, code, error, hint?}`; thrown
469/// from a classic `tool()` body, the catch site formats it via `format_tool_error` as JSON.
470pub fn tool_fail(
471    message: impl Into<String>,
472    code: Option<String>,
473    hint: Option<String>,
474) -> crate::Error {
475    crate::Error::ToolFail {
476        output: message.into(),
477        code,
478        hint,
479        is_fatal: false,
480        error_kind: None,
481    }
482}
483
484/// `RegisteredTool::text` equivalent that wraps the body in a structured envelope. The body
485/// returns either:
486/// - `Ok(envelope)` produced by `ok(data)` / `fail(code, msg, hint)` — passed through
487/// - `Ok(value)` of any other `Value` — auto-wrapped as `ok(value)`
488/// - `Err(crate::Error::ToolFail{..})` — converted to `fail` envelope with the carried code/hint
489/// - `Err(other)` — converted to `{success:false, code:"internal", error: format_tool_error(...)}`
490pub fn safe_tool<F, Fut>(
491    name: impl Into<compact_str::CompactString>,
492    description: impl Into<String>,
493    parameters: Value,
494    f: F,
495) -> RegisteredTool
496where
497    F: Fn(Value) -> Fut + Send + Sync + 'static,
498    Fut: std::future::Future<Output = Result<SafeToolResult>> + Send + 'static,
499{
500    let f = Arc::new(f);
501    RegisteredTool::text(name, description, parameters, move |args| {
502        let f = Arc::clone(&f);
503        Box::pin(async move {
504            let envelope = match f(args).await {
505                Ok(SafeToolResult::Envelope(env)) => env,
506                Ok(SafeToolResult::Data(v)) => ok(Some(v)),
507                Err(e) => {
508                    let env = match &e {
509                        crate::Error::ToolFail {
510                            output, code, hint, ..
511                        } => fail(
512                            code.clone().unwrap_or_else(|| "internal".to_string()),
513                            output.clone(),
514                            hint.clone(),
515                        ),
516                        _ => fail("internal", crate::format_tool_error(&e), None),
517                    };
518                    env
519                }
520            };
521            Ok(serde_json::to_string(&envelope).unwrap_or_else(|_| String::from(r#"{"success":false,"code":"internal","error":"envelope serialization failed"}"#)))
522        })
523    })
524}
525
526/// Return type for `safe_tool` bodies. Use `ok(...)` / `fail(...)` to build an explicit envelope,
527/// or return `Data(Value)` to auto-wrap as `{success:true, data:value}`. `From<ToolEnvelope>` and
528/// `From<Value>` impls let bodies just `return Ok(env.into())` / `return Ok(value.into())`.
529#[derive(Debug, Clone)]
530pub enum SafeToolResult {
531    Envelope(ToolEnvelope),
532    Data(Value),
533}
534
535impl From<ToolEnvelope> for SafeToolResult {
536    fn from(e: ToolEnvelope) -> Self {
537        Self::Envelope(e)
538    }
539}
540impl From<Value> for SafeToolResult {
541    fn from(v: Value) -> Self {
542        Self::Data(v)
543    }
544}
545
546pub fn read_file_tool() -> RegisteredTool {
547    RegisteredTool::text(
548        "read_file",
549        "Read the contents of a file.",
550        serde_json::json!({ "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"] }),
551        |args| {
552            Box::pin(async move {
553                let path = args["path"]
554                    .as_str()
555                    .ok_or_else(|| Error::Tool("missing path".into()))?;
556                tokio::fs::read_to_string(path)
557                    .await
558                    .map_err(|e| Error::Tool(e.to_string()))
559            })
560        },
561    )
562}