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(sub, child, &format!("{path}.{k}"), false, repaired)?;
271                                }
272                            }
273                            _ => {
274                                // additionalProperties 缺省/false → 维持旧行为
275                                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// ── Structured tool envelope (safe_tool / ok / fail / tool_fail) ──────────────────────────────
413//
414// Opt-in: same shape as the Node/Python `safe_tool` + `ok()` / `fail()` envelope so a tool
415// authored once produces consistent `{success, code?, error?, hint?}` JSON for the model across
416// runtimes. The classic `RegisteredTool::text()` factory is unchanged.
417
418#[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, // always true
428    #[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, // always false
435    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
454/// Build a coded tool-failure `Error` (parity with Node `new ToolError(message, {code, hint})`).
455/// Throwing this from a `safe_tool` body produces `{success:false, code, error, hint?}`; thrown
456/// from a classic `tool()` body, the catch site formats it via `format_tool_error` as JSON.
457pub 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
467/// `RegisteredTool::text` equivalent that wraps the body in a structured envelope. The body
468/// returns either:
469/// - `Ok(envelope)` produced by `ok(data)` / `fail(code, msg, hint)` — passed through
470/// - `Ok(value)` of any other `Value` — auto-wrapped as `ok(value)`
471/// - `Err(crate::Error::ToolFail{..})` — converted to `fail` envelope with the carried code/hint
472/// - `Err(other)` — converted to `{success:false, code:"internal", error: format_tool_error(...)}`
473pub 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/// Return type for `safe_tool` bodies. Use `ok(...)` / `fail(...)` to build an explicit envelope,
508/// or return `Data(Value)` to auto-wrap as `{success:true, data:value}`. `From<ToolEnvelope>` and
509/// `From<Value>` impls let bodies just `return Ok(env.into())` / `return Ok(value.into())`.
510#[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}