Skip to main content

magic_coder_types/
tools.rs

1use serde::{Deserialize, Serialize};
2
3#[cfg(feature = "schemars")]
4use schemars::{JsonSchema, schema_for};
5#[cfg(feature = "schemars")]
6use serde_json::Value;
7
8/// Convert a Rust struct schema into an OpenAI tool `parameters` object.
9///
10/// - remove `$schema` and `title`
11/// - convert `definitions` to `$defs`
12/// - convert `oneOf` to `anyOf`
13#[cfg(feature = "schemars")]
14pub fn tool_parameters<T: JsonSchema>() -> Value {
15    let mut v = serde_json::to_value(schema_for!(T)).expect("can't parse value from schema");
16
17    // remove the $schema and title fields
18    if let Some(value) = v.as_object_mut() {
19        value.remove("$schema");
20        value.remove("title");
21    }
22
23    let mut v_str = serde_json::to_string(&v).unwrap();
24    v_str = v_str
25        .replace("/definitions/", "/$defs/")
26        .replace("\"definitions\":", "\"$defs\":");
27
28    // Replace oneOf with anyOf, because it's better supported by the LLMs
29    v_str = v_str.replace("\"oneOf\":", "\"anyOf\":");
30
31    let mut v: Value = serde_json::from_str(&v_str).expect("can't parse value from updated schema");
32    enforce_openai_strict_schema(&mut v);
33    v
34}
35
36#[cfg(feature = "schemars")]
37fn enforce_openai_strict_schema(v: &mut Value) {
38    match v {
39        Value::Object(map) => {
40            // Recurse first so we fix nested schemas too.
41            for (_k, child) in map.iter_mut() {
42                enforce_openai_strict_schema(child);
43            }
44
45            // If this looks like an object schema, enforce strict rules.
46            let is_object = map
47                .get("type")
48                .and_then(|t| t.as_str())
49                .is_some_and(|t| t == "object");
50            let has_props = map.get("properties").is_some();
51            if is_object || has_props {
52                map.entry("additionalProperties".to_string())
53                    .or_insert(Value::Bool(false));
54
55                if let Some(Value::Object(props)) = map.get("properties") {
56                    let mut keys: Vec<String> = props.keys().cloned().collect();
57                    keys.sort();
58                    map.insert(
59                        "required".to_string(),
60                        Value::Array(keys.into_iter().map(Value::String).collect()),
61                    );
62                }
63            }
64        }
65        Value::Array(arr) => {
66            for child in arr.iter_mut() {
67                enforce_openai_strict_schema(child);
68            }
69        }
70        _ => {}
71    }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[cfg_attr(feature = "schemars", derive(JsonSchema))]
76pub struct ReadFileArgs {
77    /// Path to file.
78    pub path: String,
79    /// Optional starting line (0-based).
80    pub offset: Option<usize>,
81    /// Optional maximum number of lines to read.
82    pub limit: Option<usize>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[cfg_attr(feature = "schemars", derive(JsonSchema))]
87pub struct ListDirArgs {
88    /// Directory path to list.
89    pub path: String,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93#[cfg_attr(feature = "schemars", derive(JsonSchema))]
94#[serde(rename_all = "snake_case")]
95pub enum GlobKind {
96    Files,
97    Dirs,
98    All,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[cfg_attr(feature = "schemars", derive(JsonSchema))]
103pub struct GlobArgs {
104    /// Glob pattern to match. Supports `*`, `**`, `?`, and character classes.
105    pub pattern: String,
106    /// Optional directory root to search under. Defaults to `"."`.
107    pub path: Option<String>,
108    /// Optional maximum number of returned paths. Defaults to `50`.
109    pub limit: Option<usize>,
110    /// Optional match kind. Defaults to `files`.
111    pub kind: Option<GlobKind>,
112    /// Optional exclude patterns. Defaults to an empty list.
113    #[serde(default)]
114    pub exclude: Vec<String>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[cfg_attr(feature = "schemars", derive(JsonSchema))]
119pub struct GrepArgs {
120    /// Regex pattern to search for.
121    pub pattern: String,
122    /// Optional path (file or directory) to search in.
123    pub path: Option<String>,
124    /// Optional glob filter, e.g. `"*.rs"`.
125    pub glob: Option<String>,
126    /// Optional limit for returned matches.
127    pub head_limit: Option<usize>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "schemars", derive(JsonSchema))]
132pub struct RunShellArgs {
133    /// Shell command line to run (executed via `bash -lc`), supports pipes/redirection.
134    pub command: String,
135    /// Optional working directory.
136    pub cwd: Option<String>,
137    /// Optional timeout in seconds for foreground execution.
138    /// Omit to use the default 30 second timeout.
139    /// Must be omitted when `bg=true`.
140    /// For longer-running work like model training, set a larger value up front on the safe side to avoid retries.
141    pub timeout_seconds: Option<u64>,
142    /// Optional maximum captured bytes per stream (stdout/stderr) for foreground execution.
143    /// Must be omitted when `bg=true`.
144    ///
145    /// Truncated output keeps roughly the first 30% and last 70%, so very large
146    /// values are usually unnecessary; prefer a few KB or low tens of KB and only
147    /// increase if needed.
148    pub max_output_bytes: Option<u64>,
149    /// When true, spawn the shell in the background and return immediately with a shell id.
150    #[serde(default)]
151    pub bg: bool,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[cfg_attr(feature = "schemars", derive(JsonSchema))]
156pub struct ReadShellOutputArgs {
157    /// Background shell id returned by `run_shell` with `bg=true`.
158    pub shell_id: String,
159    /// When true, read from the start of the log. Defaults to `false` meaning read from the end.
160    #[serde(default)]
161    pub from_start: bool,
162    /// Optional 0-based line offset from the selected side. Defaults to `0`.
163    pub offset: Option<usize>,
164    /// Optional maximum number of lines to read. Defaults to `200`, max `1000`.
165    pub limit: Option<usize>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[cfg_attr(feature = "schemars", derive(JsonSchema))]
170pub struct StopShellArgs {
171    /// Background shell id returned by `run_shell` with `bg=true`.
172    pub shell_id: String,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[cfg_attr(feature = "schemars", derive(JsonSchema))]
177pub struct SleepArgs {
178    /// Sleep duration in seconds. Clients may clamp this to a supported range.
179    pub seconds: u64,
180    /// Background shell ids to watch. Use an empty array for a plain timer.
181    /// If any watched shell exits early, the sleep may end early.
182    #[serde(default)]
183    pub shell_ids: Vec<String>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[cfg_attr(feature = "schemars", derive(JsonSchema))]
188pub struct ApplyDiffArgs {
189    /// Patch text to apply to the working tree.
190    ///
191    /// Use the unified diff starting with `diff --git ...`
192    pub diff: String,
193
194    /// Optional working directory (workspace root).
195    pub cwd: Option<String>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[cfg_attr(feature = "schemars", derive(JsonSchema))]
200pub struct DeleteFilesArgs {
201    /// Paths to delete (relative to project root; no absolute paths; no `..`).
202    pub paths: Vec<String>,
203}
204
205/// Tools (function definitions) to send to the OpenAI Responses API.
206#[cfg(feature = "schemars")]
207pub fn openai_tools() -> Vec<Value> {
208    vec![
209        serde_json::json!({
210            "type": "function",
211            "name": "read_file",
212            "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 file contents.",
213            "strict": true,
214            "parameters": tool_parameters::<ReadFileArgs>(),
215        }),
216        serde_json::json!({
217            "type": "function",
218            "name": "list_dir",
219            "description": "List a local directory (by path). Returns plain text with `Path`, `Entries`, and one entry per line similar to `ls`; directories end with `/`.",
220            "strict": true,
221            "parameters": tool_parameters::<ListDirArgs>(),
222        }),
223        serde_json::json!({
224            "type": "function",
225            "name": "glob",
226            "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.",
227            "strict": true,
228            "parameters": tool_parameters::<GlobArgs>(),
229        }),
230        serde_json::json!({
231            "type": "function",
232            "name": "grep",
233            "description": "Search for a regex pattern in files. Returns a JSON string including matches (file, line_number, line). May be truncated to head_limit.",
234            "strict": true,
235            "parameters": tool_parameters::<GrepArgs>(),
236        }),
237        serde_json::json!({
238            "type": "function",
239            "name": "run_shell",
240            "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`.",
241            "parameters": tool_parameters::<RunShellArgs>(),
242        }),
243        serde_json::json!({
244            "type": "function",
245            "name": "read_shell_output",
246            "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.",
247            "strict": true,
248            "parameters": tool_parameters::<ReadShellOutputArgs>(),
249        }),
250        serde_json::json!({
251            "type": "function",
252            "name": "stop_shell",
253            "description": "Stop a background shell started with `run_shell(bg=true)` and discard its retained state and logs.",
254            "strict": true,
255            "parameters": tool_parameters::<StopShellArgs>(),
256        }),
257        serde_json::json!({
258            "type": "function",
259            "name": "sleep",
260            "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.",
261            "strict": true,
262            "parameters": tool_parameters::<SleepArgs>(),
263        }),
264        serde_json::json!({
265            "type": "function",
266            "name": "apply_diff",
267            "description": "Apply a patch to the local working tree (create/update files). Use the `*** Begin Patch` / `*** Update File:` format. Returns a JSON string describing what changed or an error.",
268            "strict": true,
269            "parameters": tool_parameters::<ApplyDiffArgs>(),
270        }),
271        serde_json::json!({
272            "type": "function",
273            "name": "delete_files",
274            "description": "Delete one or more files by path (relative to project root). Returns a JSON string listing deleted and missing paths.",
275            "strict": true,
276            "parameters": tool_parameters::<DeleteFilesArgs>(),
277        }),
278    ]
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use serde_json::json;
285
286    #[test]
287    fn glob_args_default_exclude_to_empty_list() {
288        let args: GlobArgs = serde_json::from_value(json!({
289            "pattern": "**/*.rs",
290            "path": "src",
291            "limit": 50,
292            "kind": "files",
293        }))
294        .expect("glob args");
295
296        assert_eq!(args.pattern, "**/*.rs");
297        assert_eq!(args.path.as_deref(), Some("src"));
298        assert_eq!(args.limit, Some(50));
299        assert_eq!(args.kind, Some(GlobKind::Files));
300        assert!(args.exclude.is_empty());
301    }
302
303    #[cfg(feature = "schemars")]
304    #[test]
305    fn run_shell_tool_schema_encourages_small_output_limits() {
306        let run_shell = openai_tools()
307            .into_iter()
308            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("run_shell"))
309            .expect("run_shell tool");
310
311        let description = run_shell
312            .get("description")
313            .and_then(Value::as_str)
314            .expect("run_shell description");
315        assert!(description.contains("max_output_bytes"));
316        assert!(description.contains("30%"));
317        assert!(description.contains("70%"));
318        assert!(description.contains("smallest limit"));
319        assert!(description.contains("bg=true"));
320        assert!(description.contains("omit `timeout_seconds`"));
321        assert!(description.contains("omit `max_output_bytes`"));
322
323        let timeout_description = run_shell
324            .get("parameters")
325            .and_then(|value| value.get("properties"))
326            .and_then(|value| value.get("timeout_seconds"))
327            .and_then(|value| value.get("description"))
328            .and_then(Value::as_str)
329            .expect("timeout_seconds description");
330        assert!(timeout_description.contains("30 second timeout"));
331        assert!(timeout_description.contains("model training"));
332        assert!(timeout_description.contains("safe side"));
333        assert!(timeout_description.contains("Must be omitted when `bg=true`"));
334
335        let max_output_description = run_shell
336            .get("parameters")
337            .and_then(|value| value.get("properties"))
338            .and_then(|value| value.get("max_output_bytes"))
339            .and_then(|value| value.get("description"))
340            .and_then(Value::as_str)
341            .expect("max_output_bytes description");
342        assert!(max_output_description.contains("30%"));
343        assert!(max_output_description.contains("70%"));
344        assert!(max_output_description.contains("few KB"));
345        assert!(max_output_description.contains("Must be omitted when `bg=true`"));
346
347        let properties = run_shell
348            .get("parameters")
349            .and_then(|value| value.get("properties"))
350            .and_then(Value::as_object)
351            .expect("run_shell parameters");
352        assert!(properties.contains_key("bg"));
353    }
354
355    #[cfg(feature = "schemars")]
356    #[test]
357    fn openai_tools_include_glob_tool() {
358        let glob_tool = openai_tools()
359            .into_iter()
360            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("glob"))
361            .expect("glob tool");
362
363        let description = glob_tool
364            .get("description")
365            .and_then(Value::as_str)
366            .expect("glob description");
367        assert!(description.contains("path discovery"));
368        assert!(description.contains("Returned"));
369        assert!(description.contains("Total"));
370
371        let properties = glob_tool
372            .get("parameters")
373            .and_then(|value| value.get("properties"))
374            .and_then(Value::as_object)
375            .expect("glob parameters");
376        assert!(properties.contains_key("pattern"));
377        assert!(properties.contains_key("path"));
378        assert!(properties.contains_key("limit"));
379        assert!(properties.contains_key("kind"));
380        assert!(properties.contains_key("exclude"));
381    }
382
383    #[cfg(feature = "schemars")]
384    #[test]
385    fn openai_tools_describe_plaintext_file_and_directory_reads() {
386        let tools = openai_tools();
387
388        let read_file = tools
389            .iter()
390            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("read_file"))
391            .expect("read_file tool");
392        let read_file_description = read_file
393            .get("description")
394            .and_then(Value::as_str)
395            .expect("read_file description");
396        assert!(read_file_description.contains("Returns plain text"));
397        assert!(read_file_description.contains("frontmatter"));
398        assert!(read_file_description.contains("returned_lines"));
399
400        let list_dir = tools
401            .iter()
402            .find(|tool| tool.get("name").and_then(Value::as_str) == Some("list_dir"))
403            .expect("list_dir tool");
404        let list_dir_description = list_dir
405            .get("description")
406            .and_then(Value::as_str)
407            .expect("list_dir description");
408        assert!(list_dir_description.contains("Returns plain text"));
409        assert!(list_dir_description.contains("Path"));
410        assert!(list_dir_description.contains("Entries"));
411        assert!(list_dir_description.contains("similar to `ls`"));
412        assert!(list_dir_description.contains("directories end with `/`"));
413    }
414
415    #[test]
416    fn background_shell_tool_args_default_to_tail_reads() {
417        let read_shell_output: ReadShellOutputArgs = serde_json::from_value(json!({
418            "shell_id": "bg_123"
419        }))
420        .expect("read_shell_output args");
421        assert_eq!(read_shell_output.shell_id, "bg_123");
422        assert!(!read_shell_output.from_start);
423        assert_eq!(read_shell_output.offset, None);
424        assert_eq!(read_shell_output.limit, None);
425
426        let run_shell: RunShellArgs = serde_json::from_value(json!({
427            "command": "echo hi"
428        }))
429        .expect("run_shell args");
430        assert_eq!(run_shell.command, "echo hi");
431        assert!(!run_shell.bg);
432
433        let sleep: SleepArgs = serde_json::from_value(json!({
434            "seconds": 30,
435            "shell_ids": ["bg_123", "bg_456"]
436        }))
437        .expect("sleep args");
438        assert_eq!(sleep.seconds, 30);
439        assert_eq!(sleep.shell_ids, vec!["bg_123", "bg_456"]);
440
441        let timer_only_sleep: SleepArgs = serde_json::from_value(json!({
442            "seconds": 15
443        }))
444        .expect("timer-only sleep args");
445        assert_eq!(timer_only_sleep.seconds, 15);
446        assert!(timer_only_sleep.shell_ids.is_empty());
447    }
448
449    #[cfg(feature = "schemars")]
450    #[test]
451    fn openai_tools_include_background_shell_tools() {
452        let tools = openai_tools();
453        let names = tools
454            .iter()
455            .filter_map(|tool| tool.get("name").and_then(Value::as_str))
456            .collect::<Vec<_>>();
457
458        assert!(names.contains(&"read_shell_output"));
459        assert!(names.contains(&"stop_shell"));
460        assert!(names.contains(&"sleep"));
461    }
462}