gestura_core_tools/
schemas.rs

1//! Provider-specific tool schemas (OpenAI / Anthropic / Gemini)
2//!
3//! Gestura's pipeline keeps a text prompt format for portability, but some LLM
4//! providers require tool definitions to be passed **out-of-band** (as JSON
5//! schema) to enable structured tool calls.
6//!
7//! This module converts Gestura's [`ToolDefinition`] inventory into provider-
8//! specific schemas.
9
10use crate::registry::ToolDefinition;
11use serde_json::Value;
12
13const OPENAI_DISALLOWED_TOP_LEVEL_SCHEMA_KEYWORDS: &[&str] =
14    &["oneOf", "anyOf", "allOf", "enum", "not"];
15
16/// Provider-specific tool schema bundles.
17#[derive(Debug, Clone, Default)]
18pub struct ProviderToolSchemas {
19    /// OpenAI-compatible `tools: [{type:"function", function:{...}}]`.
20    pub openai: Vec<Value>,
21    /// OpenAI Responses `tools: [{type:"function", name, description, parameters}]`.
22    pub openai_responses: Vec<Value>,
23    /// Anthropic `tools: [{name, description, input_schema}]`.
24    pub anthropic: Vec<Value>,
25    /// Gemini `functionDeclarations: [{name, description, parameters}]`.
26    pub gemini: Vec<Value>,
27}
28
29impl ProviderToolSchemas {
30    /// Merge another set of schemas into this one.
31    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
59/// Normalize a JSON Schema into the stricter top-level object shape required by
60/// OpenAI-compatible function calling APIs.
61///
62/// OpenAI rejects top-level combinators such as `oneOf`/`anyOf` on
63/// `function.parameters`, even when the schema is otherwise valid JSON Schema.
64/// We therefore keep the richer schema for providers that accept it, but strip
65/// only the disallowed top-level keywords for OpenAI requests.
66pub 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
130/// Build provider tool schemas for a set of tool definitions.
131///
132/// Note: We intentionally only include schemas for tools that have a well-
133/// defined structured interface in `gestura-core`'s pipeline.
134pub 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    // Keep schemas precise enough that models can infer required call shapes reliably.
195    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        // Not yet supported in the runtime tool executor.
998        "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        // OpenAI format: {type:"function", function:{name, description, parameters}}
1214        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        // Gemini format: {name, description, parameters}
1224        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}