1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4#[cfg(feature = "schemars")]
5use schemars::{JsonSchema, schema_for};
6
7#[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 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 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 for (_k, child) in map.iter_mut() {
41 enforce_openai_strict_schema(child);
42 }
43
44 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 pub path: String,
78 pub offset: Option<usize>,
80 pub limit: Option<usize>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[cfg_attr(feature = "schemars", derive(JsonSchema))]
86pub struct ListDirArgs {
87 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 pub pattern: String,
105 pub path: Option<String>,
107 pub limit: Option<usize>,
109 pub kind: Option<GlobKind>,
111 #[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 pub pattern: String,
121 pub path: Option<String>,
123 pub glob: Option<String>,
125 pub head_limit: Option<usize>,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[cfg_attr(feature = "schemars", derive(JsonSchema))]
131pub struct RunShellArgs {
132 pub command: String,
134 pub cwd: Option<String>,
136 pub timeout_seconds: Option<u64>,
141 pub max_output_bytes: Option<u64>,
148 #[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 pub shell_id: String,
158 #[serde(default)]
160 pub from_start: bool,
161 pub offset: Option<usize>,
163 pub limit: Option<usize>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[cfg_attr(feature = "schemars", derive(JsonSchema))]
169pub struct StopShellArgs {
170 pub shell_id: String,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[cfg_attr(feature = "schemars", derive(JsonSchema))]
176pub struct SleepArgs {
177 pub seconds: u64,
179 #[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 pub diff: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[cfg_attr(feature = "schemars", derive(JsonSchema))]
198pub struct DeleteFilesArgs {
199 pub paths: Vec<String>,
201}
202
203#[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}