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#[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 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 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 for (_k, child) in map.iter_mut() {
43 enforce_openai_strict_schema(child);
44 }
45
46 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 pub path: String,
80 pub offset: Option<usize>,
82 pub limit: Option<usize>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
87#[cfg_attr(feature = "schemars", derive(JsonSchema))]
88pub struct ListDirArgs {
89 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 pub pattern: String,
107 pub path: Option<String>,
109 pub limit: Option<usize>,
111 pub kind: Option<GlobKind>,
113 #[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 pub pattern: String,
123 pub path: Option<String>,
125 pub glob: Option<String>,
127 pub head_limit: Option<usize>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "schemars", derive(JsonSchema))]
133pub struct RunShellArgs {
134 pub command: String,
136 pub cwd: Option<String>,
138 pub timeout_seconds: Option<u64>,
143 pub max_output_bytes: Option<u64>,
150 #[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 pub shell_id: String,
160 #[serde(default)]
162 pub from_start: bool,
163 pub offset: Option<usize>,
165 pub limit: Option<usize>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[cfg_attr(feature = "schemars", derive(JsonSchema))]
171pub struct StopShellArgs {
172 pub shell_id: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
177#[cfg_attr(feature = "schemars", derive(JsonSchema))]
178pub struct SleepArgs {
179 pub seconds: u64,
181 #[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 pub diff: String,
199
200 pub cwd: Option<String>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205#[cfg_attr(feature = "schemars", derive(JsonSchema))]
206pub struct DeleteFilesArgs {
207 pub paths: Vec<String>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[cfg_attr(feature = "schemars", derive(JsonSchema))]
213pub struct CallMcpToolArgs {
214 pub server_id: Uuid,
216 pub tool_name: String,
218 #[serde(default)]
220 pub arguments: serde_json::Map<String, serde_json::Value>,
221}
222
223#[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}