1use serde::{Deserialize, Serialize};
2
3#[cfg(feature = "schemars")]
4use schemars::{JsonSchema, schema_for};
5#[cfg(feature = "schemars")]
6use serde_json::Value;
7
8#[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 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 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 for (_k, child) in map.iter_mut() {
42 enforce_openai_strict_schema(child);
43 }
44
45 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 pub path: String,
79 pub offset: Option<usize>,
81 pub limit: Option<usize>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[cfg_attr(feature = "schemars", derive(JsonSchema))]
87pub struct ListDirArgs {
88 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 pub pattern: String,
106 pub path: Option<String>,
108 pub limit: Option<usize>,
110 pub kind: Option<GlobKind>,
112 #[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 pub pattern: String,
122 pub path: Option<String>,
124 pub glob: Option<String>,
126 pub head_limit: Option<usize>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[cfg_attr(feature = "schemars", derive(JsonSchema))]
132pub struct RunShellArgs {
133 pub command: String,
135 pub cwd: Option<String>,
137 pub timeout_seconds: Option<u64>,
142 pub max_output_bytes: Option<u64>,
149 #[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 pub shell_id: String,
159 #[serde(default)]
161 pub from_start: bool,
162 pub offset: Option<usize>,
164 pub limit: Option<usize>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[cfg_attr(feature = "schemars", derive(JsonSchema))]
170pub struct StopShellArgs {
171 pub shell_id: String,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[cfg_attr(feature = "schemars", derive(JsonSchema))]
177pub struct SleepArgs {
178 pub seconds: u64,
180 #[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 pub diff: String,
193
194 pub cwd: Option<String>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[cfg_attr(feature = "schemars", derive(JsonSchema))]
200pub struct DeleteFilesArgs {
201 pub paths: Vec<String>,
203}
204
205#[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}