1use crate::registry::ToolDefinition;
11use serde_json::Value;
12
13const OPENAI_DISALLOWED_TOP_LEVEL_SCHEMA_KEYWORDS: &[&str] =
14 &["oneOf", "anyOf", "allOf", "enum", "not"];
15
16#[derive(Debug, Clone, Default)]
18pub struct ProviderToolSchemas {
19 pub openai: Vec<Value>,
21 pub openai_responses: Vec<Value>,
23 pub anthropic: Vec<Value>,
25 pub gemini: Vec<Value>,
27}
28
29impl ProviderToolSchemas {
30 pub fn merge(&mut self, other: ProviderToolSchemas) {
32 self.openai.extend(other.openai);
33 self.openai_responses.extend(other.openai_responses);
34 self.anthropic.extend(other.anthropic);
35 self.gemini.extend(other.gemini);
36 }
37}
38
39fn build_openai_chat_tool_schema(name: &str, description: &str, parameters: Value) -> Value {
40 serde_json::json!({
41 "type": "function",
42 "function": {
43 "name": name,
44 "description": description,
45 "parameters": parameters
46 }
47 })
48}
49
50fn build_openai_responses_tool_schema(name: &str, description: &str, parameters: Value) -> Value {
51 serde_json::json!({
52 "type": "function",
53 "name": name,
54 "description": description,
55 "parameters": parameters
56 })
57}
58
59pub fn normalize_openai_parameters_schema(input_schema: Value) -> Value {
67 match input_schema {
68 Value::Object(mut schema) => {
69 let mut stripped_keyword = false;
70 for keyword in OPENAI_DISALLOWED_TOP_LEVEL_SCHEMA_KEYWORDS {
71 stripped_keyword |= schema.remove(*keyword).is_some();
72 }
73
74 let original_type = schema
75 .get("type")
76 .and_then(Value::as_str)
77 .map(str::to_string);
78 let had_properties = matches!(schema.get("properties"), Some(Value::Object(_)));
79
80 if original_type.as_deref() != Some("object") {
81 schema.insert("type".to_string(), Value::String("object".to_string()));
82 }
83
84 if !had_properties {
85 schema.insert("properties".to_string(), serde_json::json!({}));
86 }
87
88 if (!had_properties || original_type.as_deref() != Some("object"))
89 && !schema.contains_key("additionalProperties")
90 {
91 schema.insert("additionalProperties".to_string(), Value::Bool(true));
92 }
93
94 if stripped_keyword || original_type.as_deref() != Some("object") {
95 let note = match original_type.as_deref() {
96 Some("object") => {
97 "OpenAI compatibility note: top-level combinator keywords were removed from this schema; rely on field descriptions/examples for operation-specific constraints.".to_string()
98 }
99 Some(other) => format!(
100 "OpenAI compatibility note: the original top-level schema type was `{other}`; it was normalized to an object wrapper for function calling."
101 ),
102 None => "OpenAI compatibility note: the schema was normalized to an object wrapper for function calling.".to_string(),
103 };
104
105 let description = schema
106 .get("description")
107 .and_then(Value::as_str)
108 .unwrap_or_default();
109 if description.is_empty() {
110 schema.insert("description".to_string(), Value::String(note));
111 } else if !description.contains("OpenAI compatibility note:") {
112 schema.insert(
113 "description".to_string(),
114 Value::String(format!("{description}\n\n{note}")),
115 );
116 }
117 }
118
119 Value::Object(schema)
120 }
121 _ => serde_json::json!({
122 "type": "object",
123 "properties": {},
124 "additionalProperties": true,
125 "description": "OpenAI compatibility note: the original schema was not an object, so a permissive top-level object wrapper was used for function calling."
126 }),
127 }
128}
129
130pub fn build_provider_tool_schemas(tools: &[&'static ToolDefinition]) -> ProviderToolSchemas {
135 let mut out = ProviderToolSchemas::default();
136
137 for tool in tools {
138 if tool.name == "file" {
139 if let Some((openai, openai_responses, anthropic, gemini)) =
140 schema_for_tool(tool.name, tool.description)
141 {
142 out.openai.push(openai);
143 out.openai_responses.push(openai_responses);
144 out.anthropic.push(anthropic);
145 out.gemini.push(gemini);
146 }
147
148 for (name, description, input_schema) in split_file_tool_schemas() {
149 let (openai, openai_responses, anthropic, gemini) =
150 build_provider_schema(name.as_str(), description.as_str(), input_schema);
151 out.openai.push(openai);
152 out.openai_responses.push(openai_responses);
153 out.anthropic.push(anthropic);
154 out.gemini.push(gemini);
155 }
156 continue;
157 }
158
159 if tool.name == "task" || tool.name == "tasks" {
160 if let Some((openai, openai_responses, anthropic, gemini)) =
161 schema_for_tool(tool.name, tool.description)
162 {
163 out.openai.push(openai);
164 out.openai_responses.push(openai_responses);
165 out.anthropic.push(anthropic);
166 out.gemini.push(gemini);
167 }
168
169 for (name, description, input_schema) in split_task_tool_schemas() {
170 let (openai, openai_responses, anthropic, gemini) =
171 build_provider_schema(name.as_str(), description.as_str(), input_schema);
172 out.openai.push(openai);
173 out.openai_responses.push(openai_responses);
174 out.anthropic.push(anthropic);
175 out.gemini.push(gemini);
176 }
177 continue;
178 }
179
180 if let Some((openai, openai_responses, anthropic, gemini)) =
181 schema_for_tool(tool.name, tool.description)
182 {
183 out.openai.push(openai);
184 out.openai_responses.push(openai_responses);
185 out.anthropic.push(anthropic);
186 out.gemini.push(gemini);
187 }
188 }
189
190 out
191}
192
193fn schema_for_tool(name: &str, summary: &str) -> Option<(Value, Value, Value, Value)> {
194 let (description, input_schema) = match name {
196 "shell" => (
197 summary,
198 serde_json::json!({
199 "type": "object",
200 "properties": {
201 "command": {"type": "string", "description": "Shell command to run. Commands must be non-interactive: this tool cannot answer prompts or confirmations, so include unattended flags such as -y/--yes/CI=1 when needed."},
202 "cwd": {"type": "string", "description": "Working directory (optional)"},
203 "env": {"type": "object", "description": "Environment variables", "additionalProperties": {"type": "string"}},
204 "timeout_secs": {"type": "integer", "description": "Timeout in seconds (optional). Quick commands can use short timeouts, but install/build/test/scaffold commands should usually use about 300 seconds. Interactive commands are not supported; if a command may prompt, use non-interactive flags or ask the user first."},
205 "allow_long_running": {"type": "boolean", "description": "When true, active PTY-backed shell commands may continue beyond timeout_secs while output activity is still arriving. Use this for long-running builds, tests, installs, or scaffolds that should only be interrupted if they appear stalled."},
206 "stall_timeout_secs": {"type": "integer", "description": "Optional quiet-period threshold used with allow_long_running. If the command produces no shell activity for this many seconds, it is treated as stalled and interrupted."}
207 },
208 "required": ["command"],
209 "additionalProperties": true
210 }),
211 ),
212 "file" => (
213 summary,
214 serde_json::json!({
215 "type": "object",
216 "properties": {
217 "operation": {
218 "type": "string",
219 "description": "Inspection-oriented file operation to perform. Use `read`, `list`, `tree`, or `search`. For writes, use the strict `write_file` tool; for targeted replacements, use the strict `edit_file` tool.",
220 "enum": ["read", "list", "tree", "search"]
221 },
222 "path": {
223 "type": "string",
224 "minLength": 1,
225 "description": "Path to the target file or directory. Required for `read` and `search`. Optional for `list` and `tree`, where it defaults to '.'."
226 },
227 "pattern": {
228 "type": "string",
229 "minLength": 1,
230 "description": "Search pattern (regex) for `search`."
231 },
232 "recursive": {
233 "type": "boolean",
234 "description": "Whether `search` should recurse into subdirectories."
235 },
236 "max_matches": {
237 "type": "integer",
238 "minimum": 1,
239 "description": "Maximum number of `search` matches to return."
240 },
241 "show_hidden": {
242 "type": "boolean",
243 "description": "Whether `list` or `tree` should include hidden files and directories."
244 },
245 "max_entries": {
246 "type": "integer",
247 "minimum": 1,
248 "description": "Maximum number of entries to return for `list`."
249 },
250 "max_depth": {
251 "type": "integer",
252 "minimum": 1,
253 "description": "Maximum traversal depth for `tree`."
254 },
255 "start": {
256 "type": "integer",
257 "minimum": 1,
258 "description": "Starting line number for partial `read` (1-based)."
259 },
260 "end": {
261 "type": "integer",
262 "minimum": 1,
263 "description": "Ending line number for partial `read` (1-based, inclusive)."
264 }
265 },
266 "oneOf": [
267 {
268 "type": "object",
269 "description": "Read a file. REQUIRED fields: `operation`, `path`. Optional fields: `start`, `end`.",
270 "properties": {
271 "operation": {
272 "type": "string",
273 "description": "Read a file.",
274 "enum": ["read"]
275 },
276 "path": {
277 "type": "string",
278 "minLength": 1,
279 "description": "Exact file path to read."
280 },
281 "start": {
282 "type": "integer",
283 "minimum": 1,
284 "description": "Starting line number for a partial read (1-based)."
285 },
286 "end": {
287 "type": "integer",
288 "minimum": 1,
289 "description": "Ending line number for a partial read (1-based, inclusive)."
290 }
291 },
292 "required": ["operation", "path"],
293 "additionalProperties": false
294 },
295 {
296 "type": "object",
297 "description": "List directory contents. REQUIRED field: `operation`. `path` defaults to '.'. Optional fields: `show_hidden`, `max_entries`.",
298 "properties": {
299 "operation": {
300 "type": "string",
301 "description": "List directory contents.",
302 "enum": ["list"]
303 },
304 "path": {
305 "type": "string",
306 "minLength": 1,
307 "description": "Directory path. Defaults to '.'."
308 },
309 "show_hidden": {
310 "type": "boolean",
311 "description": "Whether to include hidden files and directories."
312 },
313 "max_entries": {
314 "type": "integer",
315 "minimum": 1,
316 "description": "Maximum number of entries to return."
317 }
318 },
319 "required": ["operation"],
320 "additionalProperties": false
321 },
322 {
323 "type": "object",
324 "description": "Show a directory tree. REQUIRED field: `operation`. `path` defaults to '.'. Optional fields: `show_hidden`, `max_depth`.",
325 "properties": {
326 "operation": {
327 "type": "string",
328 "description": "Show a directory tree.",
329 "enum": ["tree"]
330 },
331 "path": {
332 "type": "string",
333 "minLength": 1,
334 "description": "Directory path. Defaults to '.'."
335 },
336 "show_hidden": {
337 "type": "boolean",
338 "description": "Whether to include hidden files and directories."
339 },
340 "max_depth": {
341 "type": "integer",
342 "minimum": 1,
343 "description": "Maximum directory depth to traverse."
344 }
345 },
346 "required": ["operation"],
347 "additionalProperties": false
348 },
349 {
350 "type": "object",
351 "description": "Search for text in files. REQUIRED fields: `operation`, `path`, `pattern`. Optional fields: `recursive`, `max_matches`. Use this for discovery only; do not use `pattern` as a substitute for file edits.",
352 "properties": {
353 "operation": {
354 "type": "string",
355 "description": "Search files for text or regex matches.",
356 "enum": ["search"]
357 },
358 "path": {
359 "type": "string",
360 "minLength": 1,
361 "description": "File or directory path to search within."
362 },
363 "pattern": {
364 "type": "string",
365 "minLength": 1,
366 "description": "Search pattern (regex)."
367 },
368 "recursive": {
369 "type": "boolean",
370 "description": "Whether to search recursively in subdirectories."
371 },
372 "max_matches": {
373 "type": "integer",
374 "minimum": 1,
375 "description": "Maximum number of matches to return."
376 }
377 },
378 "required": ["operation", "path", "pattern"],
379 "additionalProperties": false
380 }
381 ],
382 "examples": [
383 {"operation": "read", "path": "README.md"},
384 {"operation": "list", "path": ".", "show_hidden": false},
385 {"operation": "search", "path": "src", "pattern": "TODO"}
386 ],
387 "required": ["operation"],
388 "additionalProperties": false
389 }),
390 ),
391 "git" => (
392 summary,
393 serde_json::json!({
394 "type": "object",
395 "properties": {
396 "operation": {
397 "type": "string",
398 "description": "Git operation",
399 "enum": ["status", "diff", "diff-staged", "log", "branches"]
400 },
401 "path": {"type": "string", "description": "Repository path (optional, default '.')"}
402 },
403 "required": ["operation"],
404 "additionalProperties": true
405 }),
406 ),
407 "web" | "web_search" => (
408 summary,
409 serde_json::json!({
410 "type": "object",
411 "properties": {
412 "operation": {
413 "type": "string",
414 "enum": ["fetch", "search"],
415 "description": "Operation to perform. 'fetch' requires 'url' parameter. 'search' requires 'query' parameter."
416 },
417 "url": {
418 "type": "string",
419 "description": "URL to fetch. REQUIRED when operation='fetch'."
420 },
421 "query": {
422 "type": "string",
423 "description": "Search query. REQUIRED when operation='search'."
424 },
425 "num_results": {
426 "type": "integer",
427 "description": "Number of search results to return (optional, for search operation, default varies by provider)"
428 },
429 "max_results": {
430 "type": "integer",
431 "description": "Alias for num_results (optional, for search operation)"
432 }
433 },
434 "required": ["operation"],
435 "additionalProperties": true
436 }),
437 ),
438 "code" => (
439 summary,
440 serde_json::json!({
441 "type": "object",
442 "properties": {
443 "operation": {
444 "type": "string",
445 "enum": [
446 "stats", "map", "symbols", "references", "definition",
447 "deps", "lint", "test", "glob", "grep",
448 "batch_read", "batch_edit", "outline"
449 ],
450 "description": "Code operation to perform:\n\
451 • stats — line/language counts for a directory\n\
452 • map — repository structure map (file types, key files)\n\
453 • symbols — extract top-level symbols from a file\n\
454 • references — find all references to a symbol (requires: symbol)\n\
455 • definition — find the first definition of a symbol (requires: symbol)\n\
456 • deps — list Cargo.toml dependencies\n\
457 • lint — run cargo clippy (optional: fix=true)\n\
458 • test — run cargo test (optional: filter)\n\
459 • glob — find files matching a glob pattern (requires: pattern)\n\
460 • grep — regex search in file contents (requires: pattern)\n\
461 • batch_read — read multiple files at once (requires: paths)\n\
462 • batch_edit — apply multiple str-replace edits (requires: edits)\n\
463 • outline — structured symbol outline of a file"
464 },
465 "path": {
466 "type": "string",
467 "description": "Root directory or file path (default '.'). Used by stats, map, symbols, references, definition, deps, lint, test, glob, grep, outline."
468 },
469 "symbol": {
470 "type": "string",
471 "description": "Symbol name to search for. REQUIRED for: references, definition."
472 },
473 "pattern": {
474 "type": "string",
475 "description": "Glob pattern (for glob) or regex pattern (for grep). REQUIRED for: glob, grep."
476 },
477 "max_depth": {
478 "type": "integer",
479 "description": "Maximum directory depth for map (default 4).",
480 "default": 4
481 },
482 "max_results": {
483 "type": "integer",
484 "description": "Maximum number of results for glob and grep (default 100).",
485 "default": 100
486 },
487 "file_glob": {
488 "type": "string",
489 "description": "Optional glob to filter which files are searched by grep (e.g. '*.rs')."
490 },
491 "context_lines": {
492 "type": "integer",
493 "description": "Number of context lines before and after each grep match (default 2).",
494 "default": 2
495 },
496 "case_sensitive": {
497 "type": "boolean",
498 "description": "Whether grep is case-sensitive (default false).",
499 "default": false
500 },
501 "paths": {
502 "type": "array",
503 "items": {"type": "string"},
504 "description": "List of file paths to read. REQUIRED for: batch_read."
505 },
506 "edits": {
507 "type": "array",
508 "description": "List of str-replace edit operations. REQUIRED for: batch_edit.",
509 "items": {
510 "type": "object",
511 "properties": {
512 "path": {"type": "string", "description": "File to edit."},
513 "old_str": {"type": "string", "description": "Exact string to find."},
514 "new_str": {"type": "string", "description": "Replacement string."}
515 },
516 "required": ["path", "old_str", "new_str"]
517 }
518 },
519 "fix": {
520 "type": "boolean",
521 "description": "Pass --fix to cargo clippy (lint operation only, default false).",
522 "default": false
523 },
524 "filter": {
525 "type": "string",
526 "description": "Test name filter for cargo test (test operation only)."
527 }
528 },
529 "oneOf": [
530 {
531 "properties": { "operation": { "enum": ["stats", "map", "deps", "lint", "test"] } },
532 "required": ["operation"]
533 },
534 {
535 "description": "Extract symbols or an outline from a single file. REQUIRED fields: `operation`, `path`.",
536 "properties": { "operation": { "enum": ["symbols", "outline"] } },
537 "required": ["operation", "path"]
538 },
539 {
540 "description": "Find references or a definition for a symbol. REQUIRED fields: `operation`, `symbol`.",
541 "properties": { "operation": { "enum": ["references", "definition"] } },
542 "required": ["operation", "symbol"]
543 },
544 {
545 "description": "Find files matching a glob or grep pattern. REQUIRED fields: `operation`, `pattern`.",
546 "properties": { "operation": { "enum": ["glob", "grep"] } },
547 "required": ["operation", "pattern"]
548 },
549 {
550 "description": "Read multiple files in one call. REQUIRED fields: `operation`, `paths`.",
551 "properties": { "operation": { "enum": ["batch_read"] } },
552 "required": ["operation", "paths"]
553 },
554 {
555 "description": "Apply one or more exact string replacements. REQUIRED fields: `operation`, `edits`. `edits` must be an array even for one change. Each edit requires `path`, `old_str`, and `new_str`.",
556 "properties": { "operation": { "enum": ["batch_edit"] } },
557 "required": ["operation", "edits"]
558 }
559 ],
560 "examples": [
561 {"operation": "batch_read", "paths": ["src/lib.rs", "app/main.py"]},
562 {"operation": "batch_edit", "edits": [{"path": "src/lib.rs", "old_str": "fn greet() {}", "new_str": "fn greet() { println!(\"hello\"); }"}]},
563 {"operation": "grep", "pattern": "TODO", "path": ".", "max_results": 20},
564 {"operation": "test", "path": ".", "filter": "integration"}
565 ],
566 "required": ["operation"],
567 "additionalProperties": false
568 }),
569 ),
570 "code_read_files" => (
571 summary,
572 serde_json::json!({
573 "type": "object",
574 "properties": {
575 "paths": {
576 "type": "array",
577 "items": {"type": "string"},
578 "description": "Exact file paths to read. This tool is file-only; do not pass directories."
579 }
580 },
581 "required": ["paths"],
582 "additionalProperties": false,
583 "examples": [
584 {"paths": ["src/lib.rs", "app/main.py"]}
585 ]
586 }),
587 ),
588 "code_edit_files" => (
589 summary,
590 serde_json::json!({
591 "type": "object",
592 "properties": {
593 "edits": {
594 "type": "array",
595 "description": "Strict array of exact str-replace edits. Each edit must include only `path`, `old_str`, and `new_str`.",
596 "items": {
597 "type": "object",
598 "properties": {
599 "path": {"type": "string", "description": "Exact file path to edit. Must not be a directory."},
600 "old_str": {"type": "string", "description": "Exact string to find."},
601 "new_str": {"type": "string", "description": "Replacement string."}
602 },
603 "required": ["path", "old_str", "new_str"],
604 "additionalProperties": false
605 }
606 }
607 },
608 "required": ["edits"],
609 "additionalProperties": false,
610 "examples": [
611 {"edits": [{"path": "src/lib.rs", "old_str": "fn greet() {}", "new_str": "fn greet() { println!(\"hello\"); }"}]}
612 ]
613 }),
614 ),
615 "code_outline" | "code_symbols" => (
616 summary,
617 serde_json::json!({
618 "type": "object",
619 "properties": {
620 "path": {
621 "type": "string",
622 "description": "Exact file path. This tool is file-only; do not pass a directory."
623 }
624 },
625 "required": ["path"],
626 "additionalProperties": false
627 }),
628 ),
629 "code_references" | "code_definition" => (
630 summary,
631 serde_json::json!({
632 "type": "object",
633 "properties": {
634 "symbol": {"type": "string", "description": "Symbol name to search for."},
635 "path": {"type": "string", "description": "Existing file or directory path to search within."}
636 },
637 "required": ["symbol", "path"],
638 "additionalProperties": false
639 }),
640 ),
641 "code_glob" => (
642 summary,
643 serde_json::json!({
644 "type": "object",
645 "properties": {
646 "path": {"type": "string", "description": "Existing directory path to search within."},
647 "pattern": {"type": "string", "description": "Glob pattern such as **/*.rs."},
648 "max_results": {"type": "integer", "default": 100}
649 },
650 "required": ["path", "pattern"],
651 "additionalProperties": false
652 }),
653 ),
654 "code_grep" => (
655 summary,
656 serde_json::json!({
657 "type": "object",
658 "properties": {
659 "path": {"type": "string", "description": "Existing directory path to search within."},
660 "pattern": {"type": "string", "description": "Regex pattern to search for."},
661 "file_glob": {"type": "string"},
662 "context_lines": {"type": "integer", "default": 2},
663 "case_sensitive": {"type": "boolean", "default": false},
664 "max_results": {"type": "integer", "default": 100}
665 },
666 "required": ["path", "pattern"],
667 "additionalProperties": false
668 }),
669 ),
670 "code_map" => (
671 summary,
672 serde_json::json!({
673 "type": "object",
674 "properties": {
675 "path": {"type": "string", "description": "Existing directory path to map."},
676 "max_depth": {"type": "integer", "default": 4}
677 },
678 "required": ["path"],
679 "additionalProperties": false
680 }),
681 ),
682 "code_stats" | "code_deps" => (
683 summary,
684 serde_json::json!({
685 "type": "object",
686 "properties": {
687 "path": {"type": "string", "description": "Existing path to inspect."}
688 },
689 "required": ["path"],
690 "additionalProperties": false
691 }),
692 ),
693 "code_lint" => (
694 summary,
695 serde_json::json!({
696 "type": "object",
697 "properties": {
698 "path": {"type": "string", "description": "Existing project directory path."},
699 "fix": {"type": "boolean", "default": false}
700 },
701 "required": ["path"],
702 "additionalProperties": false
703 }),
704 ),
705 "code_test" => (
706 summary,
707 serde_json::json!({
708 "type": "object",
709 "properties": {
710 "path": {"type": "string", "description": "Existing project directory path."},
711 "filter": {"type": "string"}
712 },
713 "required": ["path"],
714 "additionalProperties": false
715 }),
716 ),
717 "task" | "tasks" => (
718 "Create, update, delete, list, and inspect task hierarchies for the current session. For `create`, provide `name` and let the runtime assign the `task_id`; do not send `task_id` in create calls. For `update`, provide `task_id` plus at least one of `name` or `description`; do not send `status` with `update` if the only intended change is state. For `update_status`, ALWAYS provide both `task_id` and `status` in the same call. Correct example: {\"operation\":\"update_status\",\"task_id\":\"abc123\",\"status\":\"completed\"}. Invalid examples: {\"operation\":\"update_status\",\"task_id\":\"abc123\"} and {\"operation\":\"update\",\"task_id\":\"abc123\",\"status\":\"completed\"}. Do not call `update_status` just to confirm or preserve the current state; if no status changed, continue the real work instead of repeating bookkeeping.",
719 serde_json::json!({
720 "type": "object",
721 "properties": {
722 "operation": {
723 "type": "string",
724 "enum": ["create", "update_status", "update", "delete", "list", "get_hierarchy"],
725 "description": "Task operation to perform. `update` requires `task_id` plus at least one of `name` or `description`; if you only need to change task state, use `update_status` instead of sending `status` to `update`. `update_status` requires BOTH `task_id` and `status`; do not call it with only `task_id`, and do not use it just to confirm the current state."
726 },
727 "task_id": {
728 "type": "string",
729 "description": "Task ID. REQUIRED for update_status, update, delete operations. For `update`, send this together with at least one field to change (`name` or `description`). For `update_status`, send this together with `status` in the same call. Omit this field entirely for create/list/get_hierarchy; do not send the string 'None' or 'null'."
730 },
731 "name": {
732 "type": "string",
733 "description": "Task name. REQUIRED for create operation, optional for update. Provide plain text only; do not wrap it in XML or parameter tags. For create, send `name` and optional `description`; do not send a `task_id`. For update, include `name` when renaming the task."
734 },
735 "description": {
736 "type": "string",
737 "description": "Task description. Optional for create and update operations. For update, include at least one of `name` or `description`; do not call `update` with only `task_id`."
738 },
739 "status": {
740 "type": "string",
741 "enum": ["notstarted", "blocked", "inprogress", "completed", "cancelled"],
742 "description": "Task status. REQUIRED for update_status operation. Use plain JSON text only: 'notstarted', 'blocked', 'inprogress', 'completed', or 'cancelled'. Do not wrap status in XML/parameter tags. Do not omit this field to ask the runtime to infer or preserve the current state; if no status changed, skip the task update and continue the real work."
743 },
744 "parent_id": {
745 "type": "string",
746 "description": "Parent task ID for creating subtasks. Optional for create operation. Omit this field when there is no parent; do not send the string 'None' or 'null'."
747 }
748 },
749 "oneOf": [
750 {
751 "properties": {
752 "operation": { "enum": ["create"] }
753 },
754 "required": ["operation", "name"],
755 "additionalProperties": false
756 },
757 {
758 "description": "Update a task's status. REQUIRED fields: `task_id` and `status`. Correct example: {\"operation\":\"update_status\",\"task_id\":\"abc123\",\"status\":\"inprogress\"}. Invalid: {\"operation\":\"update_status\",\"task_id\":\"abc123\"}.",
759 "properties": {
760 "operation": { "enum": ["update_status"] }
761 },
762 "required": ["operation", "task_id", "status"],
763 "additionalProperties": false
764 },
765 {
766 "description": "Update a task's name and/or description. REQUIRED fields: `task_id` plus at least one of `name` or `description`. Correct example: {\"operation\":\"update\",\"task_id\":\"abc123\",\"name\":\"Refine onboarding\"}. Invalid: {\"operation\":\"update\",\"task_id\":\"abc123\"} and {\"operation\":\"update\",\"task_id\":\"abc123\",\"status\":\"completed\"}. If you only need to change status, use `update_status` with both `task_id` and `status`.",
767 "properties": {
768 "operation": { "enum": ["update"] }
769 },
770 "required": ["operation", "task_id"],
771 "anyOf": [
772 { "required": ["name"] },
773 { "required": ["description"] }
774 ],
775 "additionalProperties": false
776 },
777 {
778 "description": "Delete a task by `task_id`.",
779 "properties": {
780 "operation": { "enum": ["delete"] }
781 },
782 "required": ["operation", "task_id"],
783 "additionalProperties": false
784 },
785 {
786 "properties": {
787 "operation": { "enum": ["list"] }
788 },
789 "required": ["operation"],
790 "additionalProperties": false
791 },
792 {
793 "properties": {
794 "operation": { "enum": ["get_hierarchy"] }
795 },
796 "required": ["operation"],
797 "additionalProperties": false
798 }
799 ],
800 "examples": [
801 {"operation": "create", "name": "Implement feature", "description": "Add new API endpoint"},
802 {"operation": "update", "task_id": "abc123", "name": "Refine onboarding task"},
803 {"operation": "update", "task_id": "abc123", "description": "Add regression coverage and validation"},
804 {"operation": "update_status", "task_id": "abc123", "status": "inprogress"},
805 {"operation": "update_status", "task_id": "abc123", "status": "completed"},
806 {"operation": "delete", "task_id": "abc123"},
807 {"operation": "list"}
808 ],
809 "required": ["operation"],
810 "additionalProperties": false
811 }),
812 ),
813 "screenshot" => (
814 summary,
815 serde_json::json!({
816 "type": "object",
817 "properties": {
818 "operation": {
819 "type": "string",
820 "enum": ["screenshot", "capture"],
821 "description": "Optional operation alias. If omitted, defaults to screenshot"
822 },
823 "output_format": {
824 "type": "string",
825 "enum": ["png", "jpg", "jpeg"],
826 "description": "Optional output image format. If provided and output_path has an extension, they must match. (jpeg is accepted as an alias for jpg)"
827 },
828 "output_path": {
829 "type": "string",
830 "description": "Optional path where the screenshot will be saved. If omitted, Gestura will generate a default artifact path"
831 },
832 "return": {
833 "type": "object",
834 "description": "Controls how the result is returned. PREFER mode='path' (default) — the GUI displays the full image from the file path. Use inline_base64 only when you need the image data in the response text; it produces a small JPEG thumbnail (≤128px) and may still fail for very large captures.",
835 "properties": {
836 "mode": {
837 "type": "string",
838 "enum": ["path", "inline_base64"],
839 "description": "Return mode. 'path' (RECOMMENDED) = metadata + file path, displayed natively in the GUI. 'inline_base64' = metadata + a small JPEG thumbnail (strict size limits; may be iteratively downsized)."
840 },
841 "inline": {
842 "type": "object",
843 "description": "Bounds for inline_base64 mode. Values above hard safety caps may be clamped.",
844 "properties": {
845 "max_width": {"type": "integer", "description": "Max thumbnail width in pixels (optional)"},
846 "max_height": {"type": "integer", "description": "Max thumbnail height in pixels (optional)"},
847 "max_base64_chars": {"type": "integer", "description": "Max characters allowed in inline base64 payload"},
848 "max_result_chars": {"type": "integer", "description": "Max characters allowed in the full tool JSON result (must stay <= pipeline truncation)"}
849 },
850 "additionalProperties": false
851 }
852 },
853 "additionalProperties": false
854 },
855 "region": {
856 "type": "object",
857 "description": "Optional region to capture (x, y, width, height)",
858 "properties": {
859 "x": {"type": "integer", "description": "X coordinate"},
860 "y": {"type": "integer", "description": "Y coordinate"},
861 "width": {"type": "integer", "description": "Width in pixels"},
862 "height": {"type": "integer", "description": "Height in pixels"}
863 },
864 "required": ["x", "y", "width", "height"]
865 },
866 "display": {
867 "type": "integer",
868 "description": "Optional display number (0 = primary)"
869 }
870 },
871 "additionalProperties": false
872 }),
873 ),
874 "screen_record" => (
875 summary,
876 serde_json::json!({
877 "type": "object",
878 "properties": {
879 "operation": {
880 "type": "string",
881 "enum": ["start", "stop"],
882 "description": "Operation to perform: 'start' to begin recording, 'stop' to end recording"
883 },
884 "output_format": {
885 "type": "string",
886 "enum": ["mp4", "mov"],
887 "description": "Output video container format (optional, for 'start'). If provided and output_path has an extension, they must match."
888 },
889 "output_path": {
890 "type": "string",
891 "description": "Path where the recording will be saved (optional, for 'start'). If omitted, Gestura will generate a default artifact path"
892 },
893 "recording_id": {
894 "type": "string",
895 "description": "Recording ID to stop. REQUIRED when operation='stop'"
896 },
897 "region": {
898 "type": "object",
899 "description": "Optional region to record (for 'start')",
900 "properties": {
901 "x": {"type": "integer", "description": "X coordinate"},
902 "y": {"type": "integer", "description": "Y coordinate"},
903 "width": {"type": "integer", "description": "Width in pixels"},
904 "height": {"type": "integer", "description": "Height in pixels"}
905 },
906 "required": ["x", "y", "width", "height"]
907 },
908 "display": {
909 "type": "integer",
910 "description": "Optional display number (0 = primary, for 'start')"
911 }
912 },
913 "required": ["operation"],
914 "additionalProperties": false
915 }),
916 ),
917 "gui_control" => (
918 summary,
919 serde_json::json!({
920 "type": "object",
921 "properties": {
922 "action": {
923 "type": "string",
924 "enum": ["toggle_view_mode", "open_explorer", "close_explorer", "open_chat", "close_chat", "navigate_config"],
925 "description": "The GUI action to perform"
926 },
927 "target": {
928 "type": "string",
929 "description": "Optional target argument for the action (if applicable)"
930 }
931 },
932 "required": ["action"],
933 "additionalProperties": false
934 }),
935 ),
936 "mcp" => (
937 summary,
938 serde_json::json!({
939 "type": "object",
940 "properties": {
941 "operation": {
942 "type": "string",
943 "enum": ["search", "evaluate", "install", "enable", "disable", "list", "remove", "info"],
944 "description": "The MCP manager operation to perform. Use 'search' to find servers in the registry, 'evaluate'/'info' to inspect a server's details and install requirements, 'install' to add a server to .mcp.json, 'enable'/'disable' to toggle a configured server, 'list' to see all configured servers, 'remove' to delete an entry."
945 },
946 "query": {
947 "type": "string",
948 "description": "Search keyword for operation=search (searches server names in the registry)"
949 },
950 "limit": {
951 "type": "integer",
952 "description": "Maximum results to return for operation=search (default 20, max 50)",
953 "default": 20
954 },
955 "server_id": {
956 "type": "string",
957 "description": "Registry server identifier (e.g. 'io.github.modelcontextprotocol/server-filesystem') for evaluate, install, and info operations"
958 },
959 "name": {
960 "type": "string",
961 "description": "Local alias for the server in .mcp.json (for install, enable, disable, remove). Defaults to last path segment of server_id."
962 },
963 "scope": {
964 "type": "string",
965 "enum": ["project", "user"],
966 "description": "Config scope: 'project' writes .mcp.json in the current directory, 'user' writes to ~/.mcp.json. Default: project.",
967 "default": "project"
968 },
969 "transport": {
970 "type": "string",
971 "enum": ["stdio", "http"],
972 "description": "Override the auto-detected transport type for install"
973 },
974 "command": {
975 "type": "string",
976 "description": "Override the launch command for stdio install (e.g. 'npx', 'uvx', 'docker')"
977 },
978 "args": {
979 "type": "array",
980 "items": {"type": "string"},
981 "description": "Override the command args array for stdio install"
982 },
983 "url": {
984 "type": "string",
985 "description": "Override the remote URL for http install"
986 },
987 "env": {
988 "type": "object",
989 "additionalProperties": {"type": "string"},
990 "description": "Environment variables to embed in the .mcp.json entry (e.g. API keys). Use for install."
991 }
992 },
993 "required": ["operation"],
994 "additionalProperties": false
995 }),
996 ),
997 "a2a" | "permissions" => return None,
999 _ => return None,
1000 };
1001
1002 Some(build_provider_schema(name, description, input_schema))
1003}
1004
1005fn build_provider_schema(
1006 name: &str,
1007 description: &str,
1008 input_schema: Value,
1009) -> (Value, Value, Value, Value) {
1010 let openai_parameters = normalize_openai_parameters_schema(input_schema.clone());
1011 let openai = build_openai_chat_tool_schema(name, description, openai_parameters.clone());
1012 let openai_responses = build_openai_responses_tool_schema(name, description, openai_parameters);
1013
1014 let anthropic = serde_json::json!({
1015 "name": name,
1016 "description": description,
1017 "input_schema": input_schema
1018 });
1019
1020 let gemini = serde_json::json!({
1021 "name": name,
1022 "description": description,
1023 "parameters": input_schema
1024 });
1025
1026 (openai, openai_responses, anthropic, gemini)
1027}
1028
1029fn split_file_tool_schemas() -> Vec<(String, String, Value)> {
1030 vec![
1031 (
1032 "read_file".to_string(),
1033 "Read one exact file with an optional line range. Strict file-only contract; do not pass directory, search, or edit fields.".to_string(),
1034 serde_json::json!({
1035 "type": "object",
1036 "properties": {
1037 "path": {
1038 "type": "string",
1039 "minLength": 1,
1040 "description": "Exact file path to read. Must not be a directory."
1041 },
1042 "start": {
1043 "type": "integer",
1044 "minimum": 1,
1045 "description": "Starting line number for a partial read (1-based)."
1046 },
1047 "end": {
1048 "type": "integer",
1049 "minimum": 1,
1050 "description": "Ending line number for a partial read (1-based, inclusive)."
1051 }
1052 },
1053 "required": ["path"],
1054 "additionalProperties": false,
1055 "examples": [
1056 {"path": "src/main.rs"},
1057 {"path": "src/main.rs", "start": 1, "end": 80}
1058 ]
1059 }),
1060 ),
1061 (
1062 "write_file".to_string(),
1063 "Write one exact file using the full replacement content. Strict full-document contract; include `path` and canonical `content` only.".to_string(),
1064 serde_json::json!({
1065 "type": "object",
1066 "properties": {
1067 "path": {
1068 "type": "string",
1069 "minLength": 1,
1070 "description": "Destination file path."
1071 },
1072 "content": {
1073 "type": "string",
1074 "minLength": 1,
1075 "description": "Full replacement file content. Use this canonical field only; do not substitute `pattern`, `text`, or `contents`."
1076 }
1077 },
1078 "required": ["path", "content"],
1079 "additionalProperties": false,
1080 "examples": [
1081 {"path": "docs/summary.txt", "content": "Build completed successfully.\n"}
1082 ]
1083 }),
1084 ),
1085 (
1086 "edit_file".to_string(),
1087 "Apply one exact string replacement inside one existing file. Strict edit contract; include only `path`, `old`, and `new`.".to_string(),
1088 serde_json::json!({
1089 "type": "object",
1090 "properties": {
1091 "path": {
1092 "type": "string",
1093 "minLength": 1,
1094 "description": "Existing file path to edit."
1095 },
1096 "old": {
1097 "type": "string",
1098 "minLength": 1,
1099 "description": "Exact existing text to replace."
1100 },
1101 "new": {
1102 "type": "string",
1103 "description": "Replacement text."
1104 }
1105 },
1106 "required": ["path", "old", "new"],
1107 "additionalProperties": false,
1108 "examples": [
1109 {"path": "src/lib.rs", "old": "fn greet() { println!(\"hi\"); }", "new": "fn greet() { println!(\"hello\"); }"}
1110 ]
1111 }),
1112 ),
1113 ]
1114}
1115
1116fn split_task_tool_schemas() -> Vec<(String, String, Value)> {
1117 vec![
1118 (
1119 "task_create".to_string(),
1120 "Create a new task in the current session hierarchy. Do not provide a task_id; one will be generated.".to_string(),
1121 serde_json::json!({
1122 "type": "object",
1123 "properties": {
1124 "name": {"type": "string", "description": "Task name"},
1125 "description": {"type": "string", "description": "Task description"},
1126 "parent_id": {"type": "string", "description": "Optional parent task ID"}
1127 },
1128 "required": ["name"],
1129 "additionalProperties": false
1130 })
1131 ),
1132 (
1133 "task_update_status".to_string(),
1134 "Update ONLY the state/status of an existing task. ALWAYS provide both `task_id` and `status` in the same call. Do not omit `status` to ask the runtime to infer or preserve the current state; if no status changed, skip the task update and continue the real work instead.".to_string(),
1135 serde_json::json!({
1136 "type": "object",
1137 "properties": {
1138 "task_id": {"type": "string", "description": "The ID of the task to update. Required together with `status`."},
1139 "status": {
1140 "type": "string",
1141 "enum": ["notstarted", "blocked", "inprogress", "completed", "cancelled"],
1142 "description": "The new status. Required together with `task_id`; do not omit it and do not wrap it in XML or parameter tags."
1143 }
1144 },
1145 "required": ["task_id", "status"],
1146 "additionalProperties": false
1147 })
1148 ),
1149 (
1150 "task_update".to_string(),
1151 "Update the name or description of an existing task. Do not use this to change status.".to_string(),
1152 serde_json::json!({
1153 "type": "object",
1154 "properties": {
1155 "task_id": {"type": "string", "description": "The ID of the task to update"},
1156 "name": {"type": "string", "description": "New task name"},
1157 "description": {"type": "string", "description": "New task description"}
1158 },
1159 "required": ["task_id"],
1160 "anyOf": [
1161 {"required": ["name"]},
1162 {"required": ["description"]}
1163 ],
1164 "additionalProperties": false
1165 })
1166 ),
1167 (
1168 "task_delete".to_string(),
1169 "Delete an existing task.".to_string(),
1170 serde_json::json!({
1171 "type": "object",
1172 "properties": {
1173 "task_id": {"type": "string", "description": "The ID of the task to delete"}
1174 },
1175 "required": ["task_id"],
1176 "additionalProperties": false
1177 })
1178 ),
1179 (
1180 "task_list".to_string(),
1181 "List all tasks in the current session.".to_string(),
1182 serde_json::json!({
1183 "type": "object",
1184 "properties": {},
1185 "additionalProperties": false
1186 })
1187 ),
1188 (
1189 "task_get_hierarchy".to_string(),
1190 "Get the current session's task hierarchy.".to_string(),
1191 serde_json::json!({
1192 "type": "object",
1193 "properties": {},
1194 "additionalProperties": false
1195 })
1196 ),
1197 ]
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202 use super::*;
1203 use crate::registry::{ToolDefinition, all_tools, find_tool};
1204
1205 #[test]
1206 fn builds_shell_schema_for_all_providers() {
1207 let shell = find_tool("shell").unwrap();
1208 let schemas = build_provider_tool_schemas(&[shell]);
1209 assert_eq!(schemas.openai.len(), 1);
1210 assert_eq!(schemas.anthropic.len(), 1);
1211 assert_eq!(schemas.gemini.len(), 1);
1212
1213 assert_eq!(schemas.openai[0]["function"]["name"], "shell");
1215 assert!(
1216 schemas.openai[0]["function"]["parameters"]["required"]
1217 .as_array()
1218 .unwrap()
1219 .iter()
1220 .any(|v| v == "command")
1221 );
1222
1223 assert_eq!(schemas.gemini[0]["name"], "shell");
1225 assert!(
1226 schemas.gemini[0]["parameters"]["required"]
1227 .as_array()
1228 .unwrap()
1229 .iter()
1230 .any(|v| v == "command")
1231 );
1232
1233 let command_description =
1234 schemas.openai[0]["function"]["parameters"]["properties"]["command"]["description"]
1235 .as_str()
1236 .expect("shell command description should exist");
1237 assert!(command_description.contains("non-interactive"));
1238 }
1239
1240 #[test]
1241 fn task_schema_requires_name_for_create_operation() {
1242 let task = find_tool("task").unwrap();
1243 let schemas = build_provider_tool_schemas(&[task]);
1244 let parameters = &schemas.anthropic[0]["input_schema"];
1245
1246 let branches = parameters["oneOf"]
1247 .as_array()
1248 .expect("task schema should define oneOf branches");
1249
1250 let create_branch = branches
1251 .iter()
1252 .find(|branch| branch["properties"]["operation"]["enum"][0] == "create")
1253 .expect("missing create branch");
1254
1255 let required = create_branch["required"]
1256 .as_array()
1257 .expect("create branch should have required fields");
1258
1259 assert_eq!(parameters["additionalProperties"], serde_json::json!(false));
1260 assert_eq!(
1261 create_branch["additionalProperties"],
1262 serde_json::json!(false)
1263 );
1264 assert!(required.iter().any(|value| value == "name"));
1265 }
1266
1267 #[test]
1268 fn task_schema_warns_against_missing_status_noop_updates() {
1269 let task = find_tool("task").unwrap();
1270 let schemas = build_provider_tool_schemas(&[task]);
1271
1272 let function_description = schemas.openai[0]["function"]["description"]
1273 .as_str()
1274 .expect("task function description should exist");
1275 assert!(function_description.contains("ALWAYS provide both `task_id` and `status`"));
1276 assert!(function_description.contains("Invalid example"));
1277 assert!(function_description.contains(
1278 "{\"operation\":\"update\",\"task_id\":\"abc123\",\"status\":\"completed\"}"
1279 ));
1280
1281 let operation_description =
1282 schemas.openai[0]["function"]["parameters"]["properties"]["operation"]["description"]
1283 .as_str()
1284 .expect("task operation description should exist");
1285 assert!(operation_description.contains("requires BOTH `task_id` and `status`"));
1286 assert!(operation_description.contains("use `update_status` instead"));
1287 assert!(operation_description.contains("do not use it just to confirm the current state"));
1288
1289 let status_description =
1290 schemas.openai[0]["function"]["parameters"]["properties"]["status"]["description"]
1291 .as_str()
1292 .expect("task status description should exist");
1293 assert!(status_description.contains("Do not omit this field"));
1294 assert!(status_description.contains("skip the task update and continue the real work"));
1295
1296 let status_enum =
1297 schemas.openai[0]["function"]["parameters"]["properties"]["status"]["enum"]
1298 .as_array()
1299 .expect("task status enum should exist");
1300 assert!(status_enum.iter().any(|value| value == "blocked"));
1301
1302 let update_status_branch = schemas.anthropic[0]["input_schema"]["oneOf"]
1303 .as_array()
1304 .expect("task schema should define oneOf branches")
1305 .iter()
1306 .find(|branch| branch["properties"]["operation"]["enum"][0] == "update_status")
1307 .expect("missing update_status branch");
1308 let branch_description = update_status_branch["description"]
1309 .as_str()
1310 .expect("update_status branch description should exist");
1311 assert!(branch_description.contains("Correct example"));
1312 assert!(branch_description.contains("Invalid"));
1313 assert_eq!(
1314 update_status_branch["additionalProperties"],
1315 serde_json::json!(false)
1316 );
1317
1318 let examples = schemas.openai[0]["function"]["parameters"]["examples"]
1319 .as_array()
1320 .expect("task schema should include examples");
1321 assert!(examples.iter().any(|example| {
1322 example["operation"] == "update_status"
1323 && example["task_id"] == "abc123"
1324 && example["status"] == "inprogress"
1325 }));
1326 }
1327
1328 #[test]
1329 fn task_schema_describes_update_requirements_and_examples() {
1330 let task = find_tool("task").unwrap();
1331 let schemas = build_provider_tool_schemas(&[task]);
1332
1333 let function_description = schemas.openai[0]["function"]["description"]
1334 .as_str()
1335 .expect("task function description should exist");
1336 assert!(function_description.contains(
1337 "For `update`, provide `task_id` plus at least one of `name` or `description`"
1338 ));
1339
1340 let update_branch = schemas.anthropic[0]["input_schema"]["oneOf"]
1341 .as_array()
1342 .expect("task schema should define oneOf branches")
1343 .iter()
1344 .find(|branch| branch["properties"]["operation"]["enum"][0] == "update")
1345 .expect("missing update branch");
1346
1347 let branch_description = update_branch["description"]
1348 .as_str()
1349 .expect("update branch description should exist");
1350 assert!(branch_description.contains("at least one of `name` or `description`"));
1351 assert!(branch_description.contains("Invalid"));
1352 assert!(branch_description.contains(
1353 "{\"operation\":\"update\",\"task_id\":\"abc123\",\"status\":\"completed\"}"
1354 ));
1355 assert!(
1356 branch_description.contains("use `update_status` with both `task_id` and `status`")
1357 );
1358
1359 let any_of = update_branch["anyOf"]
1360 .as_array()
1361 .expect("update branch should require at least one mutable field");
1362 assert_eq!(any_of.len(), 2);
1363
1364 let examples = schemas.openai[0]["function"]["parameters"]["examples"]
1365 .as_array()
1366 .expect("task schema should include examples");
1367 assert!(examples.iter().any(|example| {
1368 example["operation"] == "update"
1369 && example["task_id"] == "abc123"
1370 && example["name"] == "Refine onboarding task"
1371 }));
1372 assert!(
1373 examples.iter().any(|example| {
1374 example["operation"] == "delete" && example["task_id"] == "abc123"
1375 })
1376 );
1377 }
1378
1379 #[test]
1380 fn split_task_update_status_schema_requires_explicit_status() {
1381 let task = find_tool("task").unwrap();
1382 let schemas = build_provider_tool_schemas(&[task]);
1383
1384 let split_schema = schemas
1385 .openai
1386 .iter()
1387 .find(|schema| schema["function"]["name"] == "task_update_status")
1388 .expect("task_update_status split schema should exist");
1389
1390 let description = split_schema["function"]["description"]
1391 .as_str()
1392 .expect("split schema description should exist");
1393 assert!(description.contains("ALWAYS provide both `task_id` and `status`"));
1394 assert!(description.contains("if no status changed, skip the task update"));
1395
1396 let status_description =
1397 split_schema["function"]["parameters"]["properties"]["status"]["description"]
1398 .as_str()
1399 .expect("split status description should exist");
1400 assert!(status_description.contains("Required together with `task_id`"));
1401 }
1402
1403 #[test]
1404 fn file_schema_requires_content_for_write_operation() {
1405 let file = find_tool("file").unwrap();
1406 let schemas = build_provider_tool_schemas(&[file]);
1407
1408 let write_file_schema = schemas
1409 .openai
1410 .iter()
1411 .find(|schema| schema["function"]["name"] == "write_file")
1412 .expect("missing write_file schema");
1413 let parameters = &write_file_schema["function"]["parameters"];
1414 let required = parameters["required"]
1415 .as_array()
1416 .expect("write_file schema should have required fields");
1417
1418 assert!(required.iter().any(|value| value == "path"));
1419 assert!(required.iter().any(|value| value == "content"));
1420
1421 let examples = parameters["examples"]
1422 .as_array()
1423 .expect("write_file schema should include examples");
1424 assert!(examples.iter().any(|example| {
1425 example["path"] == "docs/summary.txt" && example["content"].is_string()
1426 }));
1427
1428 let branch_properties = parameters["properties"]
1429 .as_object()
1430 .expect("write_file schema should define properties");
1431 assert!(parameters["additionalProperties"] == serde_json::json!(false));
1432 assert!(!branch_properties.contains_key("pattern"));
1433 assert!(!branch_properties.contains_key("start"));
1434
1435 let description = write_file_schema["function"]["description"]
1436 .as_str()
1437 .expect("write_file description should exist");
1438 assert!(description.contains("canonical `content` only"));
1439 assert!(!description.contains("contents"));
1440 assert!(!description.contains("text"));
1441 }
1442
1443 #[test]
1444 fn file_schema_uses_strict_operation_specific_branches() {
1445 let file = find_tool("file").unwrap();
1446 let schemas = build_provider_tool_schemas(&[file]);
1447
1448 let parameters = &schemas.anthropic[0]["input_schema"];
1449 let branches = parameters["oneOf"]
1450 .as_array()
1451 .expect("file schema should define oneOf branches");
1452
1453 assert_eq!(branches.len(), 4);
1454 let root_properties = parameters["properties"]
1455 .as_object()
1456 .expect("file schema should expose top-level properties");
1457 assert!(parameters["additionalProperties"] == serde_json::json!(false));
1458 assert!(root_properties.contains_key("operation"));
1459 assert!(root_properties.contains_key("path"));
1460 assert!(root_properties.contains_key("pattern"));
1461 assert!(root_properties.contains_key("start"));
1462 assert!(!root_properties.contains_key("content"));
1463 assert!(!root_properties.contains_key("old"));
1464 assert!(!root_properties.contains_key("new"));
1465
1466 let search_branch = branches
1467 .iter()
1468 .find(|branch| branch["properties"]["operation"]["enum"][0] == "search")
1469 .expect("missing search branch");
1470 let search_properties = search_branch["properties"]
1471 .as_object()
1472 .expect("search branch should define properties");
1473
1474 assert!(search_branch["additionalProperties"] == serde_json::json!(false));
1475 assert!(search_properties.contains_key("pattern"));
1476 assert!(!search_properties.contains_key("content"));
1477
1478 let read_branch = branches
1479 .iter()
1480 .find(|branch| branch["properties"]["operation"]["enum"][0] == "read")
1481 .expect("missing read branch");
1482 let read_properties = read_branch["properties"]
1483 .as_object()
1484 .expect("read branch should define properties");
1485
1486 assert!(read_branch["additionalProperties"] == serde_json::json!(false));
1487 assert!(read_properties.contains_key("start"));
1488 assert!(read_properties.contains_key("end"));
1489 assert!(!read_properties.contains_key("content"));
1490 }
1491
1492 #[test]
1493 fn file_provider_schemas_split_mutations_from_inspection_tool() {
1494 let file = find_tool("file").unwrap();
1495 let schemas = build_provider_tool_schemas(&[file]);
1496
1497 let tool_names = schemas
1498 .openai
1499 .iter()
1500 .filter_map(|schema| schema["function"]["name"].as_str())
1501 .collect::<Vec<_>>();
1502
1503 assert!(tool_names.contains(&"file"));
1504 assert!(tool_names.contains(&"read_file"));
1505 assert!(tool_names.contains(&"write_file"));
1506 assert!(tool_names.contains(&"edit_file"));
1507
1508 let edit_file_schema = schemas
1509 .openai
1510 .iter()
1511 .find(|schema| schema["function"]["name"] == "edit_file")
1512 .expect("missing edit_file schema");
1513
1514 let required = edit_file_schema["function"]["parameters"]["required"]
1515 .as_array()
1516 .expect("edit_file required fields");
1517 assert!(required.iter().any(|value| value == "path"));
1518 assert!(required.iter().any(|value| value == "old"));
1519 assert!(required.iter().any(|value| value == "new"));
1520 }
1521
1522 #[test]
1523 fn code_schema_requires_edits_for_batch_edit_operation() {
1524 let code = find_tool("code_edit_files").unwrap();
1525 let schemas = build_provider_tool_schemas(&[code]);
1526
1527 let parameters = &schemas.openai[0]["function"]["parameters"];
1528 let required = parameters["required"]
1529 .as_array()
1530 .expect("code_edit_files schema should have required fields");
1531
1532 assert!(required.iter().any(|value| value == "edits"));
1533
1534 let description = parameters["properties"]["edits"]["description"]
1535 .as_str()
1536 .expect("edits description should exist");
1537 assert!(description.contains("Strict array of exact str-replace edits"));
1538
1539 let examples = parameters["examples"]
1540 .as_array()
1541 .expect("code_edit_files schema should include examples");
1542 assert!(examples.iter().any(|example| {
1543 example["edits"].is_array() && example["edits"][0]["old_str"].is_string()
1544 }));
1545 }
1546
1547 #[test]
1548 fn openai_tool_schemas_avoid_top_level_combinators() {
1549 let tools: Vec<&'static ToolDefinition> = all_tools().iter().collect();
1550 let schemas = build_provider_tool_schemas(&tools);
1551
1552 for schema in &schemas.openai {
1553 let name = schema["function"]["name"]
1554 .as_str()
1555 .expect("openai schema should include a function name");
1556 let parameters = &schema["function"]["parameters"];
1557
1558 assert_eq!(
1559 parameters["type"],
1560 serde_json::json!("object"),
1561 "openai parameters for {name} must use a top-level object schema"
1562 );
1563
1564 for keyword in OPENAI_DISALLOWED_TOP_LEVEL_SCHEMA_KEYWORDS {
1565 assert!(
1566 parameters.get(*keyword).is_none(),
1567 "openai parameters for {name} must not expose top-level {keyword}"
1568 );
1569 }
1570 }
1571 }
1572}