use super::exa::OUTPUT_MAX_BYTES;
use super::{
args::{
AST_GREP_DEFAULT_LIMIT, AST_GREP_MAX_LIMIT, AST_GREP_STDOUT_MAX_BYTES,
AST_GREP_TIMEOUT_SECS, BASH_STDERR_MAX_BYTES, BASH_STDOUT_MAX_BYTES,
FILE_EDIT_TARGET_MAX_BYTES, FILE_EDIT_TEXT_MAX_BYTES, FILE_READ_DEFAULT_LINES,
FILE_READ_MAX_BYTES, FILE_READ_MAX_FILES, FILE_READ_MAX_LINES, FILE_WRITE_MAX_BYTES,
FIND_DEFAULT_LIMIT, FIND_MAX_LIMIT, GREP_DEADLINE_SECS, GREP_DEFAULT_LIMIT,
GREP_MAX_CONTEXT, GREP_MAX_FILE_BYTES, GREP_MAX_FILES, GREP_MAX_LIMIT, GREP_MAX_OFFSET,
GREP_MAX_PATTERN_BYTES, GREP_MAX_PATTERN_CHARS, GREP_MAX_PATTERN_TOTAL_BYTES,
GREP_MAX_PATTERNS, GREP_MAX_SCAN_BYTES, GREP_OUTPUT_MAX_BYTES, GREP_RANKED_PER_FILE,
VIEW_IMAGE_RESPONSE_MAX_BYTES,
},
contract::{
BASH_DISPATCH_ALIASES, FIND_DISPATCH_ALIASES, GREP_DISPATCH_ALIASES, arg_key, tool_name,
},
descriptions,
};
use crate::config::{DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES, MAX_VIEW_IMAGE_MAX_IMAGE_BYTES};
use crate::subagents::{
DEFAULT_SUBAGENT_CONCURRENCY, MAX_SUBAGENT_CONCURRENCY, MAX_SUBAGENT_TASK_CONTEXT_BYTES,
MAX_SUBAGENT_TASK_INTENT_BYTES, MAX_SUBAGENT_TASKS,
};
use serde_json::{Value, json};
use std::{collections::HashSet, sync::OnceLock};
const HASH_EDIT_INPUT_DESCRIPTION: &str = "Hashline patch text. Before editing, read each target without :raw. Use the latest [PATH#TAG] from that read and only line anchors displayed under that tag. Before another anchored edit, re-read the affected file. REM has no reusable tag. After an error or unexpected result, inspect touched files, re-read them, and rebuild the patch instead of replaying it.\n\nEach file section starts [PATH#TAG].\n\nBody operations:\nSWAP N.=M: replaces inclusive lines N..M.\nSWAP.BLK N: replaces the syntax block starting at N.\nINS.PRE N: and INS.POST N: insert around line N.\nINS.BLK.POST N: inserts after the block starting at N.\nINS.HEAD: and INS.TAIL: insert at file boundaries.\n\nBodyless operations:\nDEL N or DEL N.=M deletes lines.\nDEL.BLK N deletes a syntax block.\nMV DEST moves the file after line edits.\nREM deletes the file and must be the section's only operation.\n\nAll line numbers refer to the original tagged snapshot; never shift later hunk numbers. .BLK supports Rust, Python, TypeScript/TSX, and Go.\n\nEvery body row is +TEXT. The first + is removed. + means a blank line; use ++text or +-text for content beginning with + or -. Bodies contain final content, never diff context, -old rows, hashline prefixes, or unchanged surrounding lines.\n\nExample:\n[src/app.rs#A1B2]\nSWAP 10.=12:\n+fn main() {\n+ println!(\"ok\");\n+}\nDEL 25:";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolCapability {
Read,
ViewImage,
Bash,
HashEdit,
Write,
Grep,
Find,
ListFiles,
Subagents,
Web,
AstGrep,
}
pub(crate) const ALL_TOOL_CAPABILITIES: [ToolCapability; 11] = MVP_TOOL_CAPABILITIES;
pub(crate) const MVP_TOOL_CAPABILITIES: [ToolCapability; 11] = [
ToolCapability::Read,
ToolCapability::ViewImage,
ToolCapability::Bash,
ToolCapability::HashEdit,
ToolCapability::Write,
ToolCapability::Grep,
ToolCapability::Find,
ToolCapability::ListFiles,
ToolCapability::Subagents,
ToolCapability::Web,
ToolCapability::AstGrep,
];
impl ToolCapability {
pub fn canonical_name(self) -> &'static str {
match self {
Self::Read => tool_name::READ,
Self::ViewImage => tool_name::VIEW_IMAGE,
Self::Bash => tool_name::BASH,
Self::HashEdit => tool_name::HASH_EDIT,
Self::Write => tool_name::WRITE,
Self::Grep => tool_name::GREP,
Self::Find => tool_name::FIND,
Self::ListFiles => tool_name::LIST_FILES,
Self::Subagents => tool_name::SUBAGENTS,
Self::Web => tool_name::WEB,
Self::AstGrep => tool_name::AST_GREP,
}
}
pub fn aliases(self) -> &'static [&'static str] {
match self {
Self::Bash => BASH_DISPATCH_ALIASES,
Self::Grep => GREP_DISPATCH_ALIASES,
Self::Find => FIND_DISPATCH_ALIASES,
_ => &[],
}
}
pub fn from_dispatch_name(name: &str) -> Option<Self> {
ALL_TOOL_CAPABILITIES
.iter()
.copied()
.find(|tool| tool.canonical_name() == name || tool.aliases().contains(&name))
}
pub(crate) fn description(self) -> &'static str {
descriptions::get(self)
}
fn parameters_json(self) -> Value {
match self {
Self::Read => json!({
"type": "object",
"properties": {
(arg_key::PATHS): {
"type": "array",
"minItems": 1,
"maxItems": FILE_READ_MAX_FILES,
"items": {"type": "string", "minLength": 1},
"description": "Ordered targets: local files, public http(s) URLs, skill://<name>[/relative-reference], session://<id>, issue://<number>, or pr://<number>. Selectors: :N, :N-M, :-M, :N+, :N+K, and :raw; combine :raw with one line selector. Selectors override offset and limit. Raw disables hashline formatting only. Use web for public research."
},
(arg_key::OFFSET): {"type": "integer", "minimum": 1, "description": "Legacy 1-based starting line for local files without selectors; defaults to 1."},
(arg_key::LIMIT): {"type": "integer", "minimum": 1, "maximum": FILE_READ_MAX_LINES, "description": "Legacy line count per local file without selectors; defaults to 400, maximum 2000."}
},
"required": [arg_key::PATHS],
"additionalProperties": false,
"x-defaultOffset": 1,
"x-defaultLimit": FILE_READ_DEFAULT_LINES,
"x-maxBytes": FILE_READ_MAX_BYTES,
"x-maxLines": FILE_READ_MAX_LINES,
"x-maxFiles": FILE_READ_MAX_FILES
}),
Self::ViewImage => json!({
"type": "object",
"properties": {
(arg_key::PATH): {"type": "string", "minLength": 1, "description": "Absolute path to one existing regular local PNG, JPEG, GIF, or WebP image; extensions are case-insensitive and the file signature must match the extension. The path is canonicalized; outside-cwd access follows tools.view_image.absolute_paths."},
(arg_key::PROMPT): {"type": "string", "minLength": 1, "description": "Required nonblank instructions identifying the visual details to inspect and report. Runtime trimming remains authoritative."}
},
"required": [arg_key::PATH, arg_key::PROMPT],
"additionalProperties": false,
"x-defaultMaxImageBytes": DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES,
"x-maxConfigurableImageBytes": MAX_VIEW_IMAGE_MAX_IMAGE_BYTES,
"x-outputMaxBytes": VIEW_IMAGE_RESPONSE_MAX_BYTES
}),
Self::Bash => json!({
"type": "object",
"properties": {
(arg_key::COMMAND): {
"type": "string",
"minLength": 1,
"description": "Shell command to run."
},
(arg_key::TIMEOUT): {"type": "integer", "minimum": 1, "maximum": 300, "description": "Timeout in seconds; defaults to 30."}
},
"required": [arg_key::COMMAND],
"additionalProperties": false,
"x-stdoutMaxBytes": BASH_STDOUT_MAX_BYTES,
"x-stderrMaxBytes": BASH_STDERR_MAX_BYTES,
"x-notSandboxed": true
}),
Self::HashEdit => json!({
"type": "object",
"properties": {
(arg_key::INPUT): {"type": "string", "minLength": 1, "description": HASH_EDIT_INPUT_DESCRIPTION},
},
"required": [arg_key::INPUT],
"additionalProperties": false,
"x-targetMaxBytes": FILE_EDIT_TARGET_MAX_BYTES,
"x-editTextMaxBytes": FILE_EDIT_TEXT_MAX_BYTES,
"x-writeMaxBytes": FILE_WRITE_MAX_BYTES
}),
Self::Write => json!({
"type": "object",
"properties": {
(arg_key::PATH): {"type": "string", "minLength": 1, "description": "Target file path, relative resolves from cwd, outside-cwd absolute behavior follows tools.write.absolute_paths, parent directories created. Final symlink targets are rejected."},
(arg_key::CONTENT): {"type": "string", "description": format!("Complete UTF-8 file content, empty allowed, replaces rather than appends, runtime maximum {FILE_WRITE_MAX_BYTES} UTF-8 bytes.")}
},
"required": [arg_key::PATH, arg_key::CONTENT],
"additionalProperties": false,
"x-maxContentBytes": FILE_WRITE_MAX_BYTES
}),
Self::Grep => json!({
"type": "object",
"properties": {
(arg_key::PATTERNS): {
"type": "array",
"minItems": 1,
"maxItems": GREP_MAX_PATTERNS,
"items": {"type": "string", "minLength": 1, "maxLength": GREP_MAX_PATTERN_CHARS},
"description": format!("OR semantics; each pattern is an independent case-sensitive regex with literal fallback. Each pattern is limited to {GREP_MAX_PATTERN_CHARS} characters and {GREP_MAX_PATTERN_BYTES} UTF-8 bytes; all patterns together are limited to {GREP_MAX_PATTERN_TOTAL_BYTES} UTF-8 bytes.")
},
(arg_key::PATH): {"type": "string", "description": "Optional recursive search path; defaults to cwd. Relative paths resolve from cwd; outside-cwd absolute paths follow tools.grep.absolute_paths. Hidden and ignored files, .git, target, binary and special files are skipped."},
(arg_key::MODE): {"type": "string", "enum": ["ranked", "raw"], "description": format!("Optional output mode; defaults to ranked. Ranked results are grouped by file, heuristically ordered, and limited to {GREP_RANKED_PER_FILE} primary matches per file. Raw results are deterministic path/line records and exhaustive only within read, traversal, scan-time, and output budgets; raw pagination uses one extra match as lookahead.")},
(arg_key::LIMIT): {"type": "integer", "minimum": 1, "maximum": GREP_MAX_LIMIT, "description": format!("primary matching lines per page; defaults to {GREP_DEFAULT_LIMIT}. Context rows do not consume this limit.")},
(arg_key::OFFSET): {"type": "integer", "minimum": 0, "maximum": GREP_MAX_OFFSET, "description": format!("Number of primary matching lines to skip for pagination; defaults to 0 and is capped at {GREP_MAX_OFFSET}. has_more and next_offset count primary rows only.")},
(arg_key::CONTEXT): {"type": "integer", "minimum": 0, "maximum": GREP_MAX_CONTEXT, "description": format!("Optional context lines before and after each primary match; defaults to 0 and is capped at {GREP_MAX_CONTEXT} per side. Context does not consume the primary limit and may be omitted at the output cap.")}
},
"required": [arg_key::PATTERNS],
"additionalProperties": false,
"x-defaultLimit": GREP_DEFAULT_LIMIT,
"x-defaultOffset": 0,
"x-defaultContext": 0,
"x-maxPatternBytes": GREP_MAX_PATTERN_BYTES,
"x-maxPatternTotalBytes": GREP_MAX_PATTERN_TOTAL_BYTES,
"x-maxFileBytes": GREP_MAX_FILE_BYTES,
"x-maxScanBytes": GREP_MAX_SCAN_BYTES,
"x-maxFiles": GREP_MAX_FILES,
"x-deadlineSeconds": GREP_DEADLINE_SECS,
"x-rankedPerFile": GREP_RANKED_PER_FILE,
"x-outputMaxBytes": GREP_OUTPUT_MAX_BYTES
}),
Self::Find => json!({
"type": "object",
"properties": {
(arg_key::QUERY): {"type": "string", "minLength": 1, "maxLength": 512, "description": "Case-insensitive fuzzy terms matched against full paths; every whitespace-separated term must match. Glob syntax is unsupported."},
(arg_key::PATH): {"type": "string", "description": "Existing directory to search; defaults to cwd. Relative paths resolve from cwd; outside-cwd absolute paths follow tools.find.absolute_paths."},
(arg_key::KIND): {"type": "string", "enum": ["files", "directories", "mixed"], "description": "Path kinds to return; defaults to files."},
(arg_key::LIMIT): {"type": "integer", "minimum": 1, "maximum": FIND_MAX_LIMIT, "description": "Maximum ranked matches returned; defaults to 50."},
(arg_key::OFFSET): {"type": "integer", "minimum": 0, "description": "Ranked matches to skip for pagination; defaults to 0."}
},
"required": [arg_key::QUERY],
"additionalProperties": false,
"x-defaultLimit": FIND_DEFAULT_LIMIT
}),
Self::ListFiles => json!({
"type": "object",
"properties": {
(arg_key::PATH): {"type": "string", "minLength": 1, "description": "Existing directory to list. Relative paths resolve from cwd; outside-cwd absolute paths follow tools.list_files.absolute_paths."},
(arg_key::INCLUDE_DIRECTORIES): {"type": "boolean", "description": "Also return direct child directories; defaults to false."}
},
"required": [arg_key::PATH],
"additionalProperties": false
}),
Self::Subagents => json!({
"type": "object",
"properties": {
(arg_key::TASKS): {
"type": "array",
"minItems": 1,
"maxItems": MAX_SUBAGENT_TASKS,
"description": "Independent tasks; may run concurrently. Results are returned in input order.",
"items": {
"type": "object",
"properties": {
"intent": {
"type": "string",
"minLength": 1,
"description": format!("Required nonblank objective; runtime maximum is {MAX_SUBAGENT_TASK_INTENT_BYTES} UTF-8 bytes.")
},
"agent": {"type": "string", "description": "Optional label/persona included with the task; it does not select an identity profile."},
(arg_key::IDENTITY): {"type": "string", "description": "Optional profile ID from available identities listed in the system prompt. IDs use ASCII letters, digits, `_`, or `-`; availability is dynamic."},
"context": {
"type": "string",
"description": format!("Optional self-contained exact instructions and relevant context; runtime maximum is {MAX_SUBAGENT_TASK_CONTEXT_BYTES} UTF-8 bytes.")
},
"cwd": {"type": "string", "description": "Optional existing child directory; defaults to the parent cwd. Relative paths resolve from the parent cwd; absolute behavior follows tools.subagents.absolute_paths."}
},
"required": ["intent"],
"additionalProperties": false
}
},
(arg_key::CONCURRENCY): {
"type": "integer",
"minimum": 1,
"maximum": MAX_SUBAGENT_CONCURRENCY,
"description": format!("Optional worker count from 1 to {MAX_SUBAGENT_CONCURRENCY}; defaults to {DEFAULT_SUBAGENT_CONCURRENCY}. Effective workers are capped by task count.")
}
},
"required": [arg_key::TASKS],
"additionalProperties": false,
"x-defaultConcurrency": DEFAULT_SUBAGENT_CONCURRENCY,
"x-maxTasks": MAX_SUBAGENT_TASKS,
"x-maxConcurrency": MAX_SUBAGENT_CONCURRENCY,
"x-taskIntentMaxBytes": MAX_SUBAGENT_TASK_INTENT_BYTES,
"x-taskContextMaxBytes": MAX_SUBAGENT_TASK_CONTEXT_BYTES
}),
Self::Web => json!({
"type": "object",
"properties": {
"operation": {"type":"string", "enum":["search","open","find"]},
"query": {"type":["string","null"], "minLength":1,"maxLength":512,"description":"Required for search/find. Find is a case-sensitive literal search of cached text."},
"domainFilter": {"type":["array","null"], "maxItems":super::exa::MAX_DOMAINS, "items":{"type":"string","minLength":1,"maxLength":super::exa::MAX_DOMAIN_CHARS + 1}, "description":"Search only. Bare ASCII domains; prefix '-' to exclude. Domain portion at most 253 characters, labels at most 63. No schemes, paths, ports or whitespace."},
"recencyFilter": {"type":["string","null"], "enum":["day","week","month","year",null], "description":"Search only. Publication-date window: past 1, 7, 30 or 365 days, measured from the current UTC date; not crawl freshness."},
"url": {"type":["string","null"], "minLength":1,"maxLength":2048,"description":"Public HTTP(S) URL for open; no credentials or private networks."},
"reference": {"type":["string","null"], "minLength":1,"maxLength":80,"description":"Stable web reference from a prior result; required for find, alternative to URL for cache-only open."},
"limit": {"type":["integer","null"], "minimum":1,"maximum":10,"description":"Search results or find passages; default 5. Omit for open."},
"offset": {"type":["integer","null"], "minimum":0,"maximum":40000,"description":"Find matches to skip; default 0. Omit for search/open."}
},
"required": ["operation"],
"additionalProperties": false,
"x-outputMaxBytes": OUTPUT_MAX_BYTES
}),
Self::AstGrep => json!({
"type": "object",
"properties": {
(arg_key::OPERATION): {"type": "string", "enum": ["search", "outline"], "description": "Defaults to search. Outline returns complete top-level symbols with direct members and 1-based ranges."},
(arg_key::PATTERN): {"type": "string", "minLength": 1, "description": "Required for search; omit for outline. $X matches one named node; $$$X matches zero or more nodes."},
(arg_key::LANGUAGE): {"type": "string", "description": "Language hint; inferred from file extensions when omitted."},
(arg_key::PATH): {"type": "string", "description": "File or directory to search; relative paths and the default resolve from cwd."},
(arg_key::REWRITE): {"type": "string", "description": "Replacement pattern; may reuse pattern metavariables. Previews a diff without modifying files."},
"items": {"type": "string", "enum": ["structure", "exports", "imports", "all"], "description": "Outline only. Defaults to structure, including internal symbols."},
"view": {"type": "string", "enum": ["names", "signatures", "digest", "expanded"], "description": "Outline only. Defaults to digest; controls included signatures and direct members."},
"name": {"type": "string", "minLength": 1, "maxLength": 512, "description": "Outline only: case-sensitive Rust regex over top-level name, signature and first source line. Runtime maximum 512 bytes."},
"symbol_type": {"type": "string", "minLength": 1, "maxLength": 512, "description": "Outline only: comma-separated lower camel case symbol types, e.g. class,function. Runtime maximum 512 bytes."},
"pub_members": {"type": "boolean", "description": "Outline only: restrict direct members to public ones; defaults to false."},
(arg_key::LIMIT): {"type": "integer", "minimum": 1, "maximum": AST_GREP_MAX_LIMIT, "description": "Maximum search lines or complete top-level outline entries; defaults to 100. Output byte and work budgets also apply."}
},
"required": [],
"additionalProperties": false,
"x-defaultLimit": AST_GREP_DEFAULT_LIMIT,
"x-timeoutSeconds": AST_GREP_TIMEOUT_SECS,
"x-stdoutMaxBytes": AST_GREP_STDOUT_MAX_BYTES
}),
}
}
pub(crate) fn provider_definition_json(self) -> Value {
json!({
"type": "function",
"name": self.canonical_name(),
"description": self.description(),
"parameters": self.parameters_json()
})
}
}
static MVP_TOOL_DEFINITIONS_JSON: OnceLock<Value> = OnceLock::new();
static MVP_TOOL_DEFINITIONS_WITHOUT_SUBAGENTS_JSON: OnceLock<Value> = OnceLock::new();
fn build_tool_definitions_json(include_subagents: bool, disabled_tools: &HashSet<String>) -> Value {
Value::Array(
MVP_TOOL_CAPABILITIES
.iter()
.filter(|tool| {
(include_subagents || **tool != ToolCapability::Subagents)
&& !disabled_tools.contains(tool.canonical_name())
})
.map(|tool| tool.provider_definition_json())
.collect(),
)
}
fn build_mvp_tool_definitions_json() -> Value {
build_tool_definitions_json(true, &HashSet::new())
}
fn build_mvp_tool_definitions_without_subagents_json() -> Value {
build_tool_definitions_json(false, &HashSet::new())
}
pub(crate) fn mvp_tool_definitions_json() -> Value {
MVP_TOOL_DEFINITIONS_JSON
.get_or_init(build_mvp_tool_definitions_json)
.clone()
}
pub(crate) fn mvp_tool_definitions_json_with_subagents(include_subagents: bool) -> Value {
if include_subagents {
mvp_tool_definitions_json()
} else {
MVP_TOOL_DEFINITIONS_WITHOUT_SUBAGENTS_JSON
.get_or_init(build_mvp_tool_definitions_without_subagents_json)
.clone()
}
}
pub(crate) fn tool_definitions_json_with_dynamic(
include_subagents: bool,
dynamic_definitions: &[Value],
disabled_tools: &HashSet<String>,
) -> Value {
if dynamic_definitions.is_empty() && disabled_tools.is_empty() {
return mvp_tool_definitions_json_with_subagents(include_subagents);
}
let mut definitions = build_tool_definitions_json(include_subagents, disabled_tools)
.as_array()
.cloned()
.unwrap_or_default();
definitions.extend(dynamic_definitions.iter().filter_map(|definition| {
let name = definition.get("name")?.as_str()?;
(!disabled_tools.contains(name)).then(|| definition.clone())
}));
Value::Array(definitions)
}
#[cfg(test)]
mod tests {
use super::*;
fn expected_markdown_description(tool: ToolCapability) -> &'static str {
match tool {
ToolCapability::Read => include_str!("../../prompts/tools/read.md").trim(),
ToolCapability::ViewImage => include_str!("../../prompts/tools/view_image.md").trim(),
ToolCapability::Bash => include_str!("../../prompts/tools/bash.md").trim(),
ToolCapability::HashEdit => include_str!("../../prompts/tools/hash_edit.md").trim(),
ToolCapability::Write => include_str!("../../prompts/tools/write.md").trim(),
ToolCapability::Grep => include_str!("../../prompts/tools/grep.md").trim(),
ToolCapability::Find => include_str!("../../prompts/tools/find.md").trim(),
ToolCapability::ListFiles => include_str!("../../prompts/tools/list_files.md").trim(),
ToolCapability::Subagents => include_str!("../../prompts/tools/subagents.md").trim(),
ToolCapability::Web => include_str!("../../prompts/tools/web.md").trim(),
ToolCapability::AstGrep => include_str!("../../prompts/tools/ast_grep.md").trim(),
}
}
#[test]
fn write_prompt_is_exactly_two_non_empty_lines() {
let prompt = include_str!("../../prompts/tools/write.md");
assert_eq!(
prompt,
"**Tool Name**: `write`\nCreate or fully replace a file, creating parent directories as needed. Use `hash_edit` for anchored changes to existing files.\n"
);
assert_eq!(
prompt
.lines()
.filter(|line| !line.trim().is_empty())
.count(),
2
);
}
#[test]
fn write_schema_matches_prompt_to_schema_contract() {
let parameters = &ToolCapability::Write.provider_definition_json()["parameters"];
assert_eq!(parameters["required"], json!(["path", "content"]));
assert_eq!(parameters["additionalProperties"], false);
assert_eq!(parameters["x-maxContentBytes"], json!(FILE_WRITE_MAX_BYTES));
assert_eq!(parameters["properties"]["path"]["type"], "string");
assert_eq!(parameters["properties"]["path"]["minLength"], json!(1));
assert_eq!(parameters["properties"]["content"]["type"], "string");
assert!(
parameters["properties"]["content"]
.get("maxLength")
.is_none()
);
assert!(parameters["properties"]["content"].get("default").is_none());
}
#[test]
fn descriptions_match_bundled_markdown_sources() {
for tool in ALL_TOOL_CAPABILITIES {
assert_eq!(tool.description(), expected_markdown_description(tool));
}
}
#[test]
fn mvp_definitions_omit_conditional_web_extract_and_routing_references() {
let definitions = mvp_tool_definitions_json();
let text = definitions.to_string();
assert!(!text.contains("web_extract"), "{text}");
assert!(!text.contains("static HTML"), "{text}");
assert!(!text.contains("DOM/selectors"), "{text}");
assert!(
definitions
.as_array()
.unwrap()
.iter()
.all(|definition| definition["name"] != "web_extract")
);
}
#[test]
fn subagents_prompt_is_exactly_two_non_empty_lines() {
let prompt = include_str!("../../prompts/tools/subagents.md");
assert_eq!(
prompt,
"**Tool Name**: `subagents`\nDelegate independent or parallel work to child agents with self-contained context. Use a listed `identity` for specialized work; the call returns after every task finishes.\n"
);
assert_eq!(
prompt
.lines()
.filter(|line| !line.trim().is_empty())
.count(),
2
);
}
#[test]
fn view_image_prompt_is_exactly_two_non_empty_lines() {
let prompt = include_str!("../../prompts/tools/view_image.md");
assert_eq!(
prompt,
"**Tool Name**: `view_image`\nInspect one local image with the configured vision model; provide an absolute path and specific visual details to report.\n"
);
assert_eq!(
prompt
.lines()
.filter(|line| !line.trim().is_empty())
.count(),
2
);
}
#[test]
fn view_image_schema_matches_prompt_to_schema_contract() {
let parameters = &ToolCapability::ViewImage.provider_definition_json()["parameters"];
assert_eq!(parameters["type"], "object");
assert_eq!(parameters["required"], json!(["path", "prompt"]));
assert_eq!(parameters["additionalProperties"], false);
assert_eq!(
parameters["x-defaultMaxImageBytes"],
json!(DEFAULT_VIEW_IMAGE_MAX_IMAGE_BYTES)
);
assert_eq!(
parameters["x-maxConfigurableImageBytes"],
json!(MAX_VIEW_IMAGE_MAX_IMAGE_BYTES)
);
assert_eq!(
parameters["x-outputMaxBytes"],
json!(VIEW_IMAGE_RESPONSE_MAX_BYTES)
);
assert!(parameters.get("x-maxImageBytes").is_none());
let properties = ¶meters["properties"];
assert_eq!(properties["path"]["type"], "string");
assert_eq!(properties["path"]["minLength"], json!(1));
assert_eq!(
properties["path"]["description"],
"Absolute path to one existing regular local PNG, JPEG, GIF, or WebP image; extensions are case-insensitive and the file signature must match the extension. The path is canonicalized; outside-cwd access follows tools.view_image.absolute_paths."
);
assert_eq!(properties["prompt"]["type"], "string");
assert_eq!(properties["prompt"]["minLength"], json!(1));
assert_eq!(
properties["prompt"]["description"],
"Required nonblank instructions identifying the visual details to inspect and report. Runtime trimming remains authoritative."
);
assert!(properties["path"].get("default").is_none());
assert!(properties["prompt"].get("default").is_none());
}
#[test]
fn subagents_schema_matches_prompt_to_schema_contract() {
let parameters = &ToolCapability::Subagents.provider_definition_json()["parameters"];
assert_eq!(parameters["type"], "object");
assert_eq!(parameters["required"], json!(["tasks"]));
assert_eq!(parameters["additionalProperties"], false);
assert_eq!(
parameters["x-defaultConcurrency"],
json!(DEFAULT_SUBAGENT_CONCURRENCY)
);
assert_eq!(parameters["x-maxTasks"], json!(MAX_SUBAGENT_TASKS));
assert_eq!(
parameters["x-maxConcurrency"],
json!(MAX_SUBAGENT_CONCURRENCY)
);
assert_eq!(
parameters["x-taskIntentMaxBytes"],
json!(MAX_SUBAGENT_TASK_INTENT_BYTES)
);
assert_eq!(
parameters["x-taskContextMaxBytes"],
json!(MAX_SUBAGENT_TASK_CONTEXT_BYTES)
);
assert_eq!(
parameters
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect::<Vec<_>>(),
[
"additionalProperties",
"properties",
"required",
"type",
"x-defaultConcurrency",
"x-maxConcurrency",
"x-maxTasks",
"x-taskContextMaxBytes",
"x-taskIntentMaxBytes",
]
);
let properties = ¶meters["properties"];
let tasks = &properties["tasks"];
assert_eq!(tasks["type"], "array");
assert_eq!(tasks["minItems"], json!(1));
assert_eq!(tasks["maxItems"], json!(MAX_SUBAGENT_TASKS));
assert_eq!(
tasks["description"],
"Independent tasks; may run concurrently. Results are returned in input order."
);
let item = &tasks["items"];
assert_eq!(item["required"], json!(["intent"]));
assert_eq!(item["additionalProperties"], false);
let item_properties = &item["properties"];
assert_eq!(item_properties["intent"]["type"], "string");
assert_eq!(item_properties["intent"]["minLength"], json!(1));
assert_eq!(
item_properties["intent"]["description"],
format!(
"Required nonblank objective; runtime maximum is {MAX_SUBAGENT_TASK_INTENT_BYTES} UTF-8 bytes."
)
);
assert_eq!(
item_properties["agent"]["description"],
"Optional label/persona included with the task; it does not select an identity profile."
);
assert_eq!(
item_properties["identity"]["description"],
"Optional profile ID from available identities listed in the system prompt. IDs use ASCII letters, digits, `_`, or `-`; availability is dynamic."
);
assert_eq!(
item_properties["context"]["description"],
format!(
"Optional self-contained exact instructions and relevant context; runtime maximum is {MAX_SUBAGENT_TASK_CONTEXT_BYTES} UTF-8 bytes."
)
);
assert_eq!(
item_properties["cwd"]["description"],
"Optional existing child directory; defaults to the parent cwd. Relative paths resolve from the parent cwd; absolute behavior follows tools.subagents.absolute_paths."
);
assert_eq!(properties["concurrency"]["minimum"], json!(1));
assert_eq!(
properties["concurrency"]["maximum"],
json!(MAX_SUBAGENT_CONCURRENCY)
);
assert_eq!(
properties["concurrency"]["description"],
format!(
"Optional worker count from 1 to {MAX_SUBAGENT_CONCURRENCY}; defaults to {DEFAULT_SUBAGENT_CONCURRENCY}. Effective workers are capped by task count."
)
);
}
#[test]
fn provider_parameter_schemas_are_top_level_objects_without_forbidden_keywords() {
let definitions = mvp_tool_definitions_json();
let definitions = definitions.as_array().unwrap();
let forbidden_top_level_keywords = ["oneOf", "anyOf", "allOf", "enum", "not"];
for definition in definitions {
let name = definition["name"].as_str().unwrap();
let parameters = &definition["parameters"];
assert_eq!(
parameters["type"], "object",
"{name} parameters must be a top-level object schema"
);
for keyword in forbidden_top_level_keywords {
assert!(
parameters.get(keyword).is_none(),
"{name} parameters must not use top-level {keyword}"
);
}
}
}
#[test]
fn read_schema_describes_selectors_and_legacy_defaults() {
let definition = ToolCapability::Read.provider_definition_json();
let parameters = &definition["parameters"];
assert_eq!(parameters["required"], json!(["paths"]));
assert_eq!(parameters["additionalProperties"], false);
assert_eq!(parameters["x-defaultOffset"], json!(1));
assert_eq!(parameters["x-defaultLimit"], json!(FILE_READ_DEFAULT_LINES));
assert_eq!(parameters["x-maxBytes"], json!(FILE_READ_MAX_BYTES));
assert_eq!(parameters["x-maxLines"], json!(FILE_READ_MAX_LINES));
assert_eq!(parameters["x-maxFiles"], json!(FILE_READ_MAX_FILES));
let properties = ¶meters["properties"];
let paths = &properties["paths"];
assert_eq!(paths["type"], "array");
assert_eq!(paths["minItems"], json!(1));
assert_eq!(paths["maxItems"], json!(FILE_READ_MAX_FILES));
assert_eq!(paths["items"]["type"], "string");
assert_eq!(paths["items"]["minLength"], json!(1));
assert!(
paths["description"]
.as_str()
.unwrap()
.contains("Selectors:")
);
assert_eq!(properties["offset"]["type"], "integer");
assert_eq!(properties["offset"]["minimum"], json!(1));
assert_eq!(
properties["offset"]["description"],
"Legacy 1-based starting line for local files without selectors; defaults to 1."
);
assert_eq!(properties["limit"]["type"], "integer");
assert_eq!(properties["limit"]["minimum"], json!(1));
assert_eq!(properties["limit"]["maximum"], json!(FILE_READ_MAX_LINES));
assert_eq!(
properties["limit"]["description"],
"Legacy line count per local file without selectors; defaults to 400, maximum 2000."
);
}
#[test]
fn find_schema_describes_search_contract_and_bounds() {
let definition = ToolCapability::Find.provider_definition_json();
let parameters = &definition["parameters"];
assert_eq!(parameters["required"], json!(["query"]));
assert_eq!(parameters["additionalProperties"], false);
assert_eq!(parameters["x-defaultLimit"], json!(FIND_DEFAULT_LIMIT));
let properties = ¶meters["properties"];
assert_eq!(properties["query"]["type"], "string");
assert_eq!(properties["query"]["minLength"], json!(1));
assert_eq!(properties["query"]["maxLength"], json!(512));
assert_eq!(
properties["query"]["description"],
"Case-insensitive fuzzy terms matched against full paths; every whitespace-separated term must match. Glob syntax is unsupported."
);
assert_eq!(properties["path"]["type"], "string");
assert_eq!(
properties["path"]["description"],
"Existing directory to search; defaults to cwd. Relative paths resolve from cwd; outside-cwd absolute paths follow tools.find.absolute_paths."
);
assert_eq!(properties["kind"]["type"], "string");
assert_eq!(
properties["kind"]["enum"],
json!(["files", "directories", "mixed"])
);
assert_eq!(
properties["kind"]["description"],
"Path kinds to return; defaults to files."
);
assert_eq!(properties["limit"]["type"], "integer");
assert_eq!(properties["limit"]["minimum"], json!(1));
assert_eq!(properties["limit"]["maximum"], json!(FIND_MAX_LIMIT));
assert_eq!(
properties["limit"]["description"],
"Maximum ranked matches returned; defaults to 50."
);
assert_eq!(properties["offset"]["type"], "integer");
assert_eq!(properties["offset"]["minimum"], json!(0));
assert_eq!(
properties["offset"]["description"],
"Ranked matches to skip for pagination; defaults to 0."
);
}
#[test]
fn list_files_schema_includes_optional_directory_flag() {
let definitions = mvp_tool_definitions_json();
let list_files = definitions
.as_array()
.unwrap()
.iter()
.find(|definition| definition["name"] == "list_files")
.expect("list_files definition");
let parameters = &list_files["parameters"];
let properties = ¶meters["properties"];
assert_eq!(parameters["required"], json!(["path"]));
assert_eq!(parameters["additionalProperties"], false);
assert_eq!(properties["path"]["type"], "string");
assert_eq!(properties["path"]["minLength"], 1);
assert_eq!(
properties["path"]["description"],
"Existing directory to list. Relative paths resolve from cwd; outside-cwd absolute paths follow tools.list_files.absolute_paths."
);
assert_eq!(properties["include_directories"]["type"], "boolean");
assert_eq!(
properties["include_directories"]["description"],
"Also return direct child directories; defaults to false."
);
}
#[test]
fn grep_schema_describes_search_contract_and_defaults() {
let parameters = &ToolCapability::Grep.provider_definition_json()["parameters"];
let properties = ¶meters["properties"];
assert_eq!(parameters["required"], json!(["patterns"]));
assert_eq!(parameters["additionalProperties"], false);
assert!(properties.get("pattern").is_none());
assert_eq!(properties["patterns"]["type"], "array");
assert_eq!(properties["patterns"]["maxItems"], json!(GREP_MAX_PATTERNS));
assert_eq!(
properties["patterns"]["items"]["maxLength"],
json!(GREP_MAX_PATTERN_CHARS)
);
let patterns_description = properties["patterns"]["description"].as_str().unwrap();
assert!(patterns_description.contains("1024 UTF-8 bytes"));
assert!(patterns_description.contains("4096 UTF-8 bytes"));
assert!(
properties["mode"]["description"]
.as_str()
.unwrap()
.contains("one extra match as lookahead")
);
assert!(
properties["limit"]["description"]
.as_str()
.unwrap()
.contains("primary matching lines per page")
);
assert_eq!(properties["offset"]["maximum"], json!(GREP_MAX_OFFSET));
assert!(
properties["context"]["description"]
.as_str()
.unwrap()
.contains("capped at 20")
);
assert_eq!(parameters["x-defaultLimit"], json!(GREP_DEFAULT_LIMIT));
assert_eq!(parameters["x-defaultOffset"], json!(0));
assert_eq!(parameters["x-defaultContext"], json!(0));
assert_eq!(
parameters["x-maxPatternBytes"],
json!(GREP_MAX_PATTERN_BYTES)
);
assert_eq!(
parameters["x-maxPatternTotalBytes"],
json!(GREP_MAX_PATTERN_TOTAL_BYTES)
);
assert_eq!(parameters["x-maxFileBytes"], json!(GREP_MAX_FILE_BYTES));
assert_eq!(parameters["x-maxScanBytes"], json!(GREP_MAX_SCAN_BYTES));
assert_eq!(parameters["x-maxFiles"], json!(GREP_MAX_FILES));
assert_eq!(parameters["x-deadlineSeconds"], json!(GREP_DEADLINE_SECS));
assert_eq!(parameters["x-rankedPerFile"], json!(GREP_RANKED_PER_FILE));
assert_eq!(parameters["x-outputMaxBytes"], json!(GREP_OUTPUT_MAX_BYTES));
}
#[test]
fn hash_edit_dispatch_name_and_schema_are_registered() {
assert_eq!(
ToolCapability::from_dispatch_name("hash_edit"),
Some(ToolCapability::HashEdit)
);
let definitions = mvp_tool_definitions_json();
let hash_edit = definitions
.as_array()
.unwrap()
.iter()
.find(|definition| definition["name"] == "hash_edit")
.expect("hash_edit definition");
let parameters = &hash_edit["parameters"];
assert_eq!(parameters["required"], json!(["input"]));
assert_eq!(parameters["additionalProperties"], false);
let input = ¶meters["properties"]["input"];
assert_eq!(input["type"], "string");
assert_eq!(input["minLength"], json!(1));
assert_eq!(input["description"], HASH_EDIT_INPUT_DESCRIPTION);
assert_eq!(
parameters["x-targetMaxBytes"],
json!(FILE_EDIT_TARGET_MAX_BYTES)
);
assert_eq!(
parameters["x-editTextMaxBytes"],
json!(FILE_EDIT_TEXT_MAX_BYTES)
);
assert_eq!(parameters["x-writeMaxBytes"], json!(FILE_WRITE_MAX_BYTES));
}
#[test]
fn grep_and_find_dispatch_aliases_map_to_canonical_capabilities() {
assert_eq!(
ToolCapability::from_dispatch_name("grep"),
Some(ToolCapability::Grep)
);
assert_eq!(
ToolCapability::from_dispatch_name("ffgrep"),
Some(ToolCapability::Grep)
);
assert_eq!(
ToolCapability::from_dispatch_name("find"),
Some(ToolCapability::Find)
);
assert_eq!(
ToolCapability::from_dispatch_name("fffind"),
Some(ToolCapability::Find)
);
}
}