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        durable_content: None,
412        is_error,
413        is_fatal: false,
414        error_kind: None,
415        token_count: None,
416    }
417}
418
419// ── Structured tool envelope (safe_tool / ok / fail / tool_fail) ──────────────────────────────
420//
421// Opt-in: same shape as the Node/Python `safe_tool` + `ok()` / `fail()` envelope so a tool
422// authored once produces consistent `{success, code?, error?, hint?}` JSON for the model across
423// runtimes. The classic `RegisteredTool::text()` factory is unchanged.
424
425#[derive(Debug, Clone, serde::Serialize)]
426#[serde(untagged)]
427pub enum ToolEnvelope {
428    Ok(ToolEnvelopeOk),
429    Fail(ToolEnvelopeFail),
430}
431
432#[derive(Debug, Clone, serde::Serialize)]
433pub struct ToolEnvelopeOk {
434    pub success: bool, // always true
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub data: Option<Value>,
437}
438
439#[derive(Debug, Clone, serde::Serialize)]
440pub struct ToolEnvelopeFail {
441    pub success: bool, // always false
442    pub code: String,
443    pub error: String,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub hint: Option<String>,
446}
447
448pub fn ok(data: impl Into<Option<Value>>) -> ToolEnvelope {
449    ToolEnvelope::Ok(ToolEnvelopeOk {
450        success: true,
451        data: data.into(),
452    })
453}
454
455pub fn fail(
456    code: impl Into<String>,
457    error: impl Into<String>,
458    hint: Option<String>,
459) -> ToolEnvelope {
460    ToolEnvelope::Fail(ToolEnvelopeFail {
461        success: false,
462        code: code.into(),
463        error: error.into(),
464        hint,
465    })
466}
467
468/// Build a coded tool-failure `Error` (parity with Node `new ToolError(message, {code, hint})`).
469/// Throwing this from a `safe_tool` body produces `{success:false, code, error, hint?}`; thrown
470/// from a classic `tool()` body, the catch site formats it via `format_tool_error` as JSON.
471pub fn tool_fail(
472    message: impl Into<String>,
473    code: Option<String>,
474    hint: Option<String>,
475) -> crate::Error {
476    crate::Error::ToolFail {
477        output: message.into(),
478        code,
479        hint,
480        is_fatal: false,
481        error_kind: None,
482    }
483}
484
485/// `RegisteredTool::text` equivalent that wraps the body in a structured envelope. The body
486/// returns either:
487/// - `Ok(envelope)` produced by `ok(data)` / `fail(code, msg, hint)` — passed through
488/// - `Ok(value)` of any other `Value` — auto-wrapped as `ok(value)`
489/// - `Err(crate::Error::ToolFail{..})` — converted to `fail` envelope with the carried code/hint
490/// - `Err(other)` — converted to `{success:false, code:"internal", error: format_tool_error(...)}`
491pub fn safe_tool<F, Fut>(
492    name: impl Into<compact_str::CompactString>,
493    description: impl Into<String>,
494    parameters: Value,
495    f: F,
496) -> RegisteredTool
497where
498    F: Fn(Value) -> Fut + Send + Sync + 'static,
499    Fut: std::future::Future<Output = Result<SafeToolResult>> + Send + 'static,
500{
501    let f = Arc::new(f);
502    RegisteredTool::text(name, description, parameters, move |args| {
503        let f = Arc::clone(&f);
504        Box::pin(async move {
505            let envelope = match f(args).await {
506                Ok(SafeToolResult::Envelope(env)) => env,
507                Ok(SafeToolResult::Data(v)) => ok(Some(v)),
508                Err(e) => {
509                    let env = match &e {
510                        crate::Error::ToolFail {
511                            output, code, hint, ..
512                        } => fail(
513                            code.clone().unwrap_or_else(|| "internal".to_string()),
514                            output.clone(),
515                            hint.clone(),
516                        ),
517                        _ => fail("internal", crate::format_tool_error(&e), None),
518                    };
519                    env
520                }
521            };
522            Ok(serde_json::to_string(&envelope).unwrap_or_else(|_| String::from(r#"{"success":false,"code":"internal","error":"envelope serialization failed"}"#)))
523        })
524    })
525}
526
527/// Return type for `safe_tool` bodies. Use `ok(...)` / `fail(...)` to build an explicit envelope,
528/// or return `Data(Value)` to auto-wrap as `{success:true, data:value}`. `From<ToolEnvelope>` and
529/// `From<Value>` impls let bodies just `return Ok(env.into())` / `return Ok(value.into())`.
530#[derive(Debug, Clone)]
531pub enum SafeToolResult {
532    Envelope(ToolEnvelope),
533    Data(Value),
534}
535
536impl From<ToolEnvelope> for SafeToolResult {
537    fn from(e: ToolEnvelope) -> Self {
538        Self::Envelope(e)
539    }
540}
541impl From<Value> for SafeToolResult {
542    fn from(v: Value) -> Self {
543        Self::Data(v)
544    }
545}
546
547pub fn read_file_tool() -> RegisteredTool {
548    RegisteredTool::text(
549        "read_file",
550        "Read the contents of a file.",
551        serde_json::json!({ "type": "object", "properties": { "path": { "type": "string" } }, "required": ["path"] }),
552        |args| {
553            Box::pin(async move {
554                let path = args["path"]
555                    .as_str()
556                    .ok_or_else(|| Error::Tool("missing path".into()))?;
557                tokio::fs::read_to_string(path)
558                    .await
559                    .map_err(|e| Error::Tool(e.to_string()))
560            })
561        },
562    )
563}