Skip to main content

magic_coder_types/
tools.rs

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