Skip to main content

magic_coder_types/
tools.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4#[cfg(feature = "schemars")]
5use schemars::{JsonSchema, schema_for};
6#[cfg(feature = "schemars")]
7use serde_json::Value;
8
9/// Convert a Rust struct schema into an OpenAI tool `parameters` object.
10///
11/// - remove `$schema` and `title`
12/// - convert `definitions` to `$defs`
13/// - convert `oneOf` to `anyOf`
14#[cfg(feature = "schemars")]
15pub fn tool_parameters<T: JsonSchema>() -> Value {
16    let mut v = serde_json::to_value(schema_for!(T)).expect("can't parse value from schema");
17
18    // remove the $schema and title fields
19    if let Some(value) = v.as_object_mut() {
20        value.remove("$schema");
21        value.remove("title");
22    }
23
24    let mut v_str = serde_json::to_string(&v).unwrap();
25    v_str = v_str
26        .replace("/definitions/", "/$defs/")
27        .replace("\"definitions\":", "\"$defs\":");
28
29    // Replace oneOf with anyOf, because it's better supported by the LLMs
30    v_str = v_str.replace("\"oneOf\":", "\"anyOf\":");
31
32    let mut v: Value = serde_json::from_str(&v_str).expect("can't parse value from updated schema");
33    enforce_openai_strict_schema(&mut v);
34    v
35}
36
37#[cfg(feature = "schemars")]
38fn enforce_openai_strict_schema(v: &mut Value) {
39    match v {
40        Value::Object(map) => {
41            // Recurse first so we fix nested schemas too.
42            for (_k, child) in map.iter_mut() {
43                enforce_openai_strict_schema(child);
44            }
45
46            // If this looks like an object schema, enforce strict rules.
47            let is_object = map
48                .get("type")
49                .and_then(|t| t.as_str())
50                .is_some_and(|t| t == "object");
51            let has_props = map.get("properties").is_some();
52            if is_object || has_props {
53                map.entry("additionalProperties".to_string())
54                    .or_insert(Value::Bool(false));
55
56                if let Some(Value::Object(props)) = map.get("properties") {
57                    let mut keys: Vec<String> = props.keys().cloned().collect();
58                    keys.sort();
59                    map.insert(
60                        "required".to_string(),
61                        Value::Array(keys.into_iter().map(Value::String).collect()),
62                    );
63                }
64            }
65        }
66        Value::Array(arr) => {
67            for child in arr.iter_mut() {
68                enforce_openai_strict_schema(child);
69            }
70        }
71        _ => {}
72    }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[cfg_attr(feature = "schemars", derive(JsonSchema))]
77pub struct ReadFileArgs {
78    /// Path to file.
79    pub path: String,
80    /// Optional starting line (0-based).
81    pub offset: Option<usize>,
82    /// Optional maximum number of lines to read.
83    pub limit: Option<usize>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87#[cfg_attr(feature = "schemars", derive(JsonSchema))]
88pub struct ListDirArgs {
89    /// Directory path to list.
90    pub path: String,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[cfg_attr(feature = "schemars", derive(JsonSchema))]
95#[serde(rename_all = "snake_case")]
96pub enum GlobKind {
97    Files,
98    Dirs,
99    All,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[cfg_attr(feature = "schemars", derive(JsonSchema))]
104pub struct GlobArgs {
105    /// Glob pattern to match. Supports `*`, `**`, `?`, and character classes.
106    pub pattern: String,
107    /// Optional directory root to search under. Defaults to `"."`.
108    pub path: Option<String>,
109    /// Optional maximum number of returned paths. Defaults to `50`.
110    pub limit: Option<usize>,
111    /// Optional match kind. Defaults to `files`.
112    pub kind: Option<GlobKind>,
113    /// Optional exclude patterns. Defaults to an empty list.
114    #[serde(default)]
115    pub exclude: Vec<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[cfg_attr(feature = "schemars", derive(JsonSchema))]
120pub struct GrepArgs {
121    /// Regex pattern to search for.
122    pub pattern: String,
123    /// Optional path (file or directory) to search in.
124    pub path: Option<String>,
125    /// Optional glob filter, e.g. `"*.rs"`.
126    pub glob: Option<String>,
127    /// Optional limit for returned matches.
128    pub head_limit: Option<usize>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "schemars", derive(JsonSchema))]
133pub struct RunShellArgs {
134    /// Shell command line to run (executed via `bash -lc`), supports pipes/redirection.
135    pub command: String,
136    /// Optional working directory.
137    pub cwd: Option<String>,
138    /// Optional timeout in seconds for foreground execution.
139    /// Omit to use the default 30 second timeout.
140    /// Must be omitted when `bg=true`.
141    /// For longer-running work like model training, set a larger value up front on the safe side to avoid retries.
142    pub timeout_seconds: Option<u64>,
143    /// Optional maximum captured bytes per stream (stdout/stderr) for foreground execution.
144    /// Must be omitted when `bg=true`.
145    ///
146    /// Truncated output keeps roughly the first 30% and last 70%, so very large
147    /// values are usually unnecessary; prefer a few KB or low tens of KB and only
148    /// increase if needed.
149    pub max_output_bytes: Option<u64>,
150    /// When true, spawn the shell in the background and return immediately with a shell id.
151    #[serde(default)]
152    pub bg: bool,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[cfg_attr(feature = "schemars", derive(JsonSchema))]
157pub struct ReadShellOutputArgs {
158    /// Background shell id returned by `run_shell` with `bg=true`.
159    pub shell_id: String,
160    /// When true, read from the start of the log. Defaults to `false` meaning read from the end.
161    #[serde(default)]
162    pub from_start: bool,
163    /// Optional 0-based line offset from the selected side. Defaults to `0`.
164    pub offset: Option<usize>,
165    /// Optional maximum number of lines to read. Defaults to `200`, max `1000`.
166    pub limit: Option<usize>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[cfg_attr(feature = "schemars", derive(JsonSchema))]
171pub struct StopShellArgs {
172    /// Background shell id returned by `run_shell` with `bg=true`.
173    pub shell_id: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[cfg_attr(feature = "schemars", derive(JsonSchema))]
178pub struct SleepArgs {
179    /// Sleep duration in seconds. Clients may clamp this to a supported range.
180    pub seconds: u64,
181    /// Background shell ids to watch. Use an empty array for a plain timer.
182    /// If any watched shell exits early, the sleep may end early.
183    #[serde(default)]
184    pub shell_ids: Vec<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[cfg_attr(feature = "schemars", derive(JsonSchema))]
189pub struct ApplyDiffArgs {
190    /// Wrap file blocks between `*** Begin Patch` and `*** End Patch`.
191    ///
192    /// Start each block with `*** Add File: <path>`,
193    /// `*** Update File: <path>`, or `*** Delete File: <path>`;
194    /// `*** Move to: <path>` may follow Update. Add lines start `+`.
195    /// `@@ <unchanged anchor>` starts an update hunk and searches forward after
196    /// that line; following space/`-` lines must match the current file, while
197    /// `+` lines are inserted. Include enough context to identify one location.
198    pub diff: String,
199
200    /// Optional working directory used as the root for relative patch paths.
201    pub cwd: Option<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[cfg_attr(feature = "schemars", derive(JsonSchema))]
206pub struct DeleteFilesArgs {
207    /// Paths to delete (relative to project root; no absolute paths; no `..`).
208    pub paths: Vec<String>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[cfg_attr(feature = "schemars", derive(JsonSchema))]
213pub struct CallMcpToolArgs {
214    /// Connected local MCP server UUID.
215    pub server_id: Uuid,
216    /// Name of the tool to call on the selected MCP server.
217    pub tool_name: String,
218    /// Arguments object matching the selected MCP tool's advertised input schema.
219    #[serde(default)]
220    pub arguments: serde_json::Map<String, serde_json::Value>,
221}
222
223/// Tools (function definitions) to send to the OpenAI Responses API.
224#[cfg(feature = "schemars")]
225pub fn openai_tools() -> Vec<Value> {
226    vec![
227        serde_json::json!({
228            "type": "function",
229            "name": "read_file",
230            "description": "Read a local file (by path), optionally with offset/limit. Returns plain text with a frontmatter block containing `path`, `offset`, `limit`, `total_lines`, `truncated`, and `returned_lines`, followed by the requested line-numbered file contents.",
231            "strict": true,
232            "parameters": tool_parameters::<ReadFileArgs>(),
233        }),
234        serde_json::json!({
235            "type": "function",
236            "name": "list_dir",
237            "description": "List a local directory (by path). Returns plain text with `Path`, `Entries`, and one entry per line similar to `ls`; directories end with `/`.",
238            "strict": true,
239            "parameters": tool_parameters::<ListDirArgs>(),
240        }),
241        serde_json::json!({
242            "type": "function",
243            "name": "glob",
244            "description": "Find local file or directory paths using a glob pattern under a search root. Use this for path discovery when you need matching paths, not file contents. Returns plain text with Returned, Total, and one relative path per line.",
245            "strict": true,
246            "parameters": tool_parameters::<GlobArgs>(),
247        }),
248        serde_json::json!({
249            "type": "function",
250            "name": "grep",
251            "description": "Search for a regex pattern in files. Returns a JSON string including matches (file, line_number, line). May be truncated to head_limit.",
252            "strict": true,
253            "parameters": tool_parameters::<GrepArgs>(),
254        }),
255        serde_json::json!({
256            "type": "function",
257            "name": "run_shell",
258            "description": "Run a shell command via `bash -lc` (supports pipes/redirection). Requires user confirmation unless the client auto-approves it. Use `max_output_bytes` intentionally for foreground runs: prefer the smallest limit that answers the question, and increase only when needed. Oversize output is cut from the middle, preserving roughly the first 30% and last 70%, so large requests are rarely necessary just to inspect the tail. Set `bg=true` to start a background shell that returns immediately with a shell id. When `bg=true`, omit `timeout_seconds` and omit `max_output_bytes`.",
259            "parameters": tool_parameters::<RunShellArgs>(),
260        }),
261        serde_json::json!({
262            "type": "function",
263            "name": "read_shell_output",
264            "description": "Read captured output from a background shell started with `run_shell(bg=true)`. Output is line-oriented. By default it reads from the end; set `from_start=true` to read from the beginning.",
265            "strict": true,
266            "parameters": tool_parameters::<ReadShellOutputArgs>(),
267        }),
268        serde_json::json!({
269            "type": "function",
270            "name": "stop_shell",
271            "description": "Stop a background shell started with `run_shell(bg=true)` and discard its retained state and logs.",
272            "strict": true,
273            "parameters": tool_parameters::<StopShellArgs>(),
274        }),
275        serde_json::json!({
276            "type": "function",
277            "name": "sleep",
278            "description": "Wait for 15 to 275 seconds. Provide `shell_ids` to return early when any watched background shell exits. Use `shell_ids: []` for a plain timer.",
279            "strict": true,
280            "parameters": tool_parameters::<SleepArgs>(),
281        }),
282        serde_json::json!({
283            "type": "function",
284            "name": "apply_diff",
285            "description": "Apply one ApplyPatch document to the local working tree. Files commit independently; returns applied changes and per-file failures.",
286            "strict": true,
287            "parameters": tool_parameters::<ApplyDiffArgs>(),
288        }),
289        serde_json::json!({
290            "type": "function",
291            "name": "delete_files",
292            "description": "Delete one or more files by path (relative to project root). Returns a JSON string listing deleted and missing paths.",
293            "strict": true,
294            "parameters": tool_parameters::<DeleteFilesArgs>(),
295        }),
296        serde_json::json!({
297            "type": "function",
298            "name": "call_mcp_tool",
299            "description": "Call one tool from a connected local MCP server by `server_id` and `tool_name`. The `arguments` field must be a JSON object matching that tool's advertised input schema.",
300            "parameters": tool_parameters::<CallMcpToolArgs>(),
301        }),
302    ]
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use serde_json::json;
309
310    #[test]
311    fn glob_args_default_exclude_to_empty_list() {
312        let args: GlobArgs = serde_json::from_value(json!({
313            "pattern": "**/*.rs",
314            "path": "src",
315            "limit": 50,
316            "kind": "files",
317        }))
318        .expect("glob args");
319
320        assert_eq!(args.pattern, "**/*.rs");
321        assert_eq!(args.path.as_deref(), Some("src"));
322        assert_eq!(args.limit, Some(50));
323        assert_eq!(args.kind, Some(GlobKind::Files));
324        assert!(args.exclude.is_empty());
325    }
326
327    #[cfg(feature = "schemars")]
328    #[test]
329    fn run_shell_tool_schema_encourages_small_output_limits() {
330        let run_shell = openai_tools()
331            .into_iter()
332            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("run_shell"))
333            .expect("run_shell tool");
334
335        let description = run_shell
336            .get("description")
337            .and_then(Value::as_str)
338            .expect("run_shell description");
339        assert!(description.contains("max_output_bytes"));
340        assert!(description.contains("30%"));
341        assert!(description.contains("70%"));
342        assert!(description.contains("smallest limit"));
343        assert!(description.contains("bg=true"));
344        assert!(description.contains("omit `timeout_seconds`"));
345        assert!(description.contains("omit `max_output_bytes`"));
346
347        let timeout_description = run_shell
348            .get("parameters")
349            .and_then(|value| value.get("properties"))
350            .and_then(|value| value.get("timeout_seconds"))
351            .and_then(|value| value.get("description"))
352            .and_then(Value::as_str)
353            .expect("timeout_seconds description");
354        assert!(timeout_description.contains("30 second timeout"));
355        assert!(timeout_description.contains("model training"));
356        assert!(timeout_description.contains("safe side"));
357        assert!(timeout_description.contains("Must be omitted when `bg=true`"));
358
359        let max_output_description = run_shell
360            .get("parameters")
361            .and_then(|value| value.get("properties"))
362            .and_then(|value| value.get("max_output_bytes"))
363            .and_then(|value| value.get("description"))
364            .and_then(Value::as_str)
365            .expect("max_output_bytes description");
366        assert!(max_output_description.contains("30%"));
367        assert!(max_output_description.contains("70%"));
368        assert!(max_output_description.contains("few KB"));
369        assert!(max_output_description.contains("Must be omitted when `bg=true`"));
370
371        let properties = run_shell
372            .get("parameters")
373            .and_then(|value| value.get("properties"))
374            .and_then(Value::as_object)
375            .expect("run_shell parameters");
376        assert!(properties.contains_key("bg"));
377    }
378
379    #[cfg(feature = "schemars")]
380    #[test]
381    fn openai_tools_include_glob_tool() {
382        let glob_tool = openai_tools()
383            .into_iter()
384            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("glob"))
385            .expect("glob tool");
386
387        let description = glob_tool
388            .get("description")
389            .and_then(Value::as_str)
390            .expect("glob description");
391        assert!(description.contains("path discovery"));
392        assert!(description.contains("Returned"));
393        assert!(description.contains("Total"));
394
395        let properties = glob_tool
396            .get("parameters")
397            .and_then(|value| value.get("properties"))
398            .and_then(Value::as_object)
399            .expect("glob parameters");
400        assert!(properties.contains_key("pattern"));
401        assert!(properties.contains_key("path"));
402        assert!(properties.contains_key("limit"));
403        assert!(properties.contains_key("kind"));
404        assert!(properties.contains_key("exclude"));
405    }
406
407    #[cfg(feature = "schemars")]
408    #[test]
409    fn openai_tools_describe_plaintext_file_and_directory_reads() {
410        let tools = openai_tools();
411
412        let read_file = tools
413            .iter()
414            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("read_file"))
415            .expect("read_file tool");
416        let read_file_description = read_file
417            .get("description")
418            .and_then(Value::as_str)
419            .expect("read_file description");
420        assert!(read_file_description.contains("Returns plain text"));
421        assert!(read_file_description.contains("frontmatter"));
422        assert!(read_file_description.contains("returned_lines"));
423        assert!(read_file_description.contains("line-numbered"));
424
425        let list_dir = tools
426            .iter()
427            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("list_dir"))
428            .expect("list_dir tool");
429        let list_dir_description = list_dir
430            .get("description")
431            .and_then(Value::as_str)
432            .expect("list_dir description");
433        assert!(list_dir_description.contains("Returns plain text"));
434        assert!(list_dir_description.contains("Path"));
435        assert!(list_dir_description.contains("Entries"));
436        assert!(list_dir_description.contains("similar to `ls`"));
437        assert!(list_dir_description.contains("directories end with `/`"));
438    }
439
440    #[cfg(feature = "schemars")]
441    #[test]
442    fn openai_tools_describe_apply_diff_contract() {
443        let apply_diff = openai_tools()
444            .into_iter()
445            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("apply_diff"))
446            .expect("apply_diff tool");
447
448        let description = apply_diff
449            .get("description")
450            .and_then(Value::as_str)
451            .expect("apply_diff description");
452        let diff_description = apply_diff
453            .pointer("/parameters/properties/diff/description")
454            .and_then(Value::as_str)
455            .expect("apply_diff diff description");
456
457        assert!(description.contains("per-file failures"));
458        assert!(diff_description.contains("*** Begin Patch"));
459        assert!(diff_description.contains("*** Add File: <path>"));
460        assert!(diff_description.contains("*** Update File: <path>"));
461        assert!(diff_description.contains("*** Delete File: <path>"));
462        assert!(diff_description.contains("*** Move to: <path>"));
463        assert!(diff_description.contains("@@ <unchanged anchor>"));
464        assert!(diff_description.contains("must match the current file"));
465        assert!(diff_description.contains("identify one location"));
466        assert!(description.len() + diff_description.len() <= 750);
467    }
468
469    #[test]
470    fn background_shell_tool_args_default_to_tail_reads() {
471        let read_shell_output: ReadShellOutputArgs = serde_json::from_value(json!({
472            "shell_id": "bg_123"
473        }))
474        .expect("read_shell_output args");
475        assert_eq!(read_shell_output.shell_id, "bg_123");
476        assert!(!read_shell_output.from_start);
477        assert_eq!(read_shell_output.offset, None);
478        assert_eq!(read_shell_output.limit, None);
479
480        let run_shell: RunShellArgs = serde_json::from_value(json!({
481            "command": "echo hi"
482        }))
483        .expect("run_shell args");
484        assert_eq!(run_shell.command, "echo hi");
485        assert!(!run_shell.bg);
486
487        let sleep: SleepArgs = serde_json::from_value(json!({
488            "seconds": 30,
489            "shell_ids": ["bg_123", "bg_456"]
490        }))
491        .expect("sleep args");
492        assert_eq!(sleep.seconds, 30);
493        assert_eq!(sleep.shell_ids, vec!["bg_123", "bg_456"]);
494
495        let timer_only_sleep: SleepArgs = serde_json::from_value(json!({
496            "seconds": 15
497        }))
498        .expect("timer-only sleep args");
499        assert_eq!(timer_only_sleep.seconds, 15);
500        assert!(timer_only_sleep.shell_ids.is_empty());
501    }
502
503    #[cfg(feature = "schemars")]
504    #[test]
505    fn openai_tools_include_background_shell_tools() {
506        let tools = openai_tools();
507        let names = tools
508            .iter()
509            .filter_map(|tool| tool.get("name").and_then(Value::as_str))
510            .collect::<Vec<_>>();
511
512        assert!(names.contains(&"read_shell_output"));
513        assert!(names.contains(&"stop_shell"));
514        assert!(names.contains(&"sleep"));
515    }
516
517    #[test]
518    fn call_mcp_tool_args_default_arguments_to_empty_object() {
519        let args: CallMcpToolArgs = serde_json::from_value(json!({
520            "server_id": "00000000-0000-0000-0000-000000000000",
521            "tool_name": "create_page"
522        }))
523        .expect("call_mcp_tool args");
524
525        assert_eq!(args.server_id, Uuid::nil());
526        assert_eq!(args.tool_name, "create_page");
527        assert!(args.arguments.is_empty());
528    }
529
530    #[cfg(feature = "schemars")]
531    #[test]
532    fn openai_tools_include_call_mcp_tool() {
533        let tool = openai_tools()
534            .into_iter()
535            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("call_mcp_tool"))
536            .expect("call_mcp_tool");
537
538        let description = tool
539            .get("description")
540            .and_then(Value::as_str)
541            .expect("call_mcp_tool description");
542        assert!(description.contains("connected local MCP server"));
543        assert!(description.contains("server_id"));
544        assert!(description.contains("tool_name"));
545
546        let properties = tool
547            .get("parameters")
548            .and_then(|value| value.get("properties"))
549            .and_then(Value::as_object)
550            .expect("call_mcp_tool parameters");
551        assert!(properties.contains_key("server_id"));
552        assert!(properties.contains_key("tool_name"));
553        assert!(properties.contains_key("arguments"));
554        assert_eq!(
555            properties["arguments"].get("additionalProperties"),
556            Some(&Value::Bool(true))
557        );
558        assert!(tool.get("strict").is_none());
559    }
560}