use super::{
args::{
AST_GREP_DEFAULT_LIMIT, AST_GREP_MAX_LIMIT, AST_GREP_STDERR_MAX_BYTES,
AST_GREP_STDOUT_MAX_BYTES, AST_GREP_TIMEOUT_SECS, BASH_STDERR_MAX_BYTES,
BASH_STDOUT_MAX_BYTES, BROWSER_STDERR_MAX_BYTES, BROWSER_STDOUT_MAX_BYTES,
BROWSER_TIMEOUT_SECS, CODE_SEARCH_DEFAULT_MAX_TOKENS, CODE_SEARCH_MAX_MAX_TOKENS,
CODE_SEARCH_MIN_MAX_TOKENS, DIAGNOSTICS_DEFAULT_LIMIT, DIAGNOSTICS_MAX_LIMIT,
FILE_EDIT_TARGET_MAX_BYTES, FILE_EDIT_TEXT_MAX_BYTES, FILE_READ_MAX_BYTES,
FILE_READ_MAX_FILES, FILE_READ_MAX_LINES, FILE_WRITE_MAX_BYTES, FIND_DEFAULT_LIMIT,
FIND_MAX_LIMIT, REFERENCES_DEFAULT_LIMIT, REFERENCES_MAX_LIMIT,
REPO_MAP_DEFAULT_MAX_TOKENS, REPO_MAP_MAX_FILES, REPO_MAP_MAX_MAX_TOKENS,
REPO_MAP_MAX_SYMBOLS_PER_FILE, REPO_MAP_MIN_MAX_TOKENS, VIEW_IMAGE_RESPONSE_MAX_BYTES,
WEB_SEARCH_MAX_DOMAINS, WEB_SEARCH_MAX_QUERIES, WEB_SEARCH_MAX_RESULTS,
},
contract::{
BASH_DISPATCH_ALIASES, FIND_DISPATCH_ALIASES, GREP_DISPATCH_ALIASES, arg_key, tool_name,
},
descriptions,
};
use serde_json::{Value, json};
use std::{collections::HashSet, sync::OnceLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolCapability {
Read,
ViewImage,
Bash,
HashEdit,
Write,
Grep,
Find,
ListFiles,
RepoMap,
Subagents,
Browser,
WebSearch,
CodeSearch,
AstGrep,
Diagnostics,
References,
}
pub(crate) const ALL_TOOL_CAPABILITIES: [ToolCapability; 16] = [
ToolCapability::Read,
ToolCapability::ViewImage,
ToolCapability::Bash,
ToolCapability::Browser,
ToolCapability::HashEdit,
ToolCapability::Write,
ToolCapability::Grep,
ToolCapability::Find,
ToolCapability::ListFiles,
ToolCapability::RepoMap,
ToolCapability::Subagents,
ToolCapability::WebSearch,
ToolCapability::CodeSearch,
ToolCapability::AstGrep,
ToolCapability::Diagnostics,
ToolCapability::References,
];
pub(crate) const MVP_TOOL_CAPABILITIES: [ToolCapability; 14] = [
ToolCapability::Read,
ToolCapability::ViewImage,
ToolCapability::Bash,
ToolCapability::Browser,
ToolCapability::HashEdit,
ToolCapability::Write,
ToolCapability::Grep,
ToolCapability::Find,
ToolCapability::ListFiles,
ToolCapability::RepoMap,
ToolCapability::Subagents,
ToolCapability::WebSearch,
ToolCapability::CodeSearch,
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::Browser => tool_name::BROWSER,
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::RepoMap => tool_name::REPO_MAP,
Self::Subagents => tool_name::SUBAGENTS,
Self::WebSearch => tool_name::WEB_SEARCH,
Self::CodeSearch => tool_name::CODE_SEARCH,
Self::AstGrep => tool_name::AST_GREP,
Self::Diagnostics => tool_name::DIAGNOSTICS,
Self::References => tool_name::REFERENCES,
}
}
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}
},
(arg_key::OFFSET): {"type": "integer", "minimum": 1},
(arg_key::LIMIT): {"type": "integer", "minimum": 1, "maximum": FILE_READ_MAX_LINES}
},
"required": [arg_key::PATHS],
"additionalProperties": false,
"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},
(arg_key::PROMPT): {"type": "string", "minLength": 1}
},
"required": [arg_key::PATH, arg_key::PROMPT],
"additionalProperties": false,
"x-maxImageBytes": crate::config::default_view_image_max_image_bytes(),
"x-outputMaxBytes": VIEW_IMAGE_RESPONSE_MAX_BYTES
}),
Self::Bash => json!({
"type": "object",
"properties": {
(arg_key::COMMAND): {"type": "string"},
(arg_key::TIMEOUT): {"type": "integer", "minimum": 1, "maximum": 300}
},
"required": [arg_key::COMMAND],
"additionalProperties": false,
"x-stdoutMaxBytes": BASH_STDOUT_MAX_BYTES,
"x-stderrMaxBytes": BASH_STDERR_MAX_BYTES,
"x-notSandboxed": true
}),
Self::Browser => json!({
"type": "object",
"properties": {
(arg_key::COMMAND): {"type": "string", "minLength": 1},
(arg_key::MODE): {"type": "string", "enum": ["headless", "headed", "connect"]},
(arg_key::CONNECT_URL): {"type": "string"},
(arg_key::IGNORE_HTTPS_ERRORS): {"type": "boolean"}
},
"required": [arg_key::COMMAND],
"additionalProperties": false,
"x-timeoutSeconds": BROWSER_TIMEOUT_SECS,
"x-stdoutMaxBytes": BROWSER_STDOUT_MAX_BYTES,
"x-stderrMaxBytes": BROWSER_STDERR_MAX_BYTES
}),
Self::HashEdit => json!({
"type": "object",
"properties": {
(arg_key::INPUT): {"type": "string", "minLength": 1}
},
"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"},
(arg_key::CONTENT): {"type": "string", "maxLength": FILE_WRITE_MAX_BYTES}
},
"required": [arg_key::PATH, arg_key::CONTENT],
"additionalProperties": false
}),
Self::Grep => json!({
"type": "object",
"properties": {
(arg_key::PATTERN): {"type": "string"},
(arg_key::PATH): {"type": "string"},
(arg_key::LIMIT): {"type": "integer", "minimum": 1},
(arg_key::CONTEXT): {"type": "integer", "minimum": 0}
},
"required": [arg_key::PATTERN],
"additionalProperties": false
}),
Self::Find => json!({
"type": "object",
"properties": {
(arg_key::QUERY): {"type": "string", "minLength": 1, "maxLength": 512},
(arg_key::PATH): {"type": "string"},
(arg_key::KIND): {"type": "string", "enum": ["files", "directories", "mixed"]},
(arg_key::LIMIT): {"type": "integer", "minimum": 1, "maximum": FIND_MAX_LIMIT},
(arg_key::OFFSET): {"type": "integer", "minimum": 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},
(arg_key::INCLUDE_DIRECTORIES): {"type": "boolean"}
},
"required": [arg_key::PATH],
"additionalProperties": false
}),
Self::RepoMap => json!({
"type": "object",
"properties": {
(arg_key::PATH): {"type": "string", "minLength": 1},
(arg_key::QUERY): {"type": "string", "minLength": 1, "maxLength": 512},
(arg_key::MAX_TOKENS): {"type": "integer", "minimum": REPO_MAP_MIN_MAX_TOKENS, "maximum": REPO_MAP_MAX_MAX_TOKENS}
},
"additionalProperties": false,
"x-defaultMaxTokens": REPO_MAP_DEFAULT_MAX_TOKENS,
"x-maxFiles": REPO_MAP_MAX_FILES,
"x-maxSymbolsPerFile": REPO_MAP_MAX_SYMBOLS_PER_FILE
}),
Self::Subagents => json!({
"type": "object",
"properties": {
(arg_key::TASKS): {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": {
"type": "object",
"properties": {
"intent": {"type": "string"},
"agent": {"type": "string", "description": "Optional label/name for the subagent; not the same as identity"},
(arg_key::IDENTITY): {"type": "string", "description": "Optional identity id from the system prompt's available subagent list"},
"context": {"type": "string"},
"cwd": {"type": "string"}
},
"required": ["intent"],
"additionalProperties": false
}
},
(arg_key::CONCURRENCY): {"type": "integer", "minimum": 1, "maximum": 4}
},
"required": [arg_key::TASKS],
"additionalProperties": false
}),
Self::WebSearch => json!({
"type": "object",
"properties": {
(arg_key::QUERIES): {"type": "array", "minItems": 1, "maxItems": WEB_SEARCH_MAX_QUERIES, "items": {"type": "string", "minLength": 1, "maxLength": 512}},
(arg_key::NUM_RESULTS): {"type": "integer", "minimum": 1, "maximum": WEB_SEARCH_MAX_RESULTS},
(arg_key::INCLUDE_CONTENT): {"type": "boolean"},
(arg_key::RECENCY_FILTER): {"type": "string", "enum": ["day", "week", "month", "year"]},
(arg_key::DOMAIN_FILTER): {"type": "array", "maxItems": WEB_SEARCH_MAX_DOMAINS, "items": {"type": "string", "minLength": 1, "maxLength": 253}}
},
"required": [arg_key::QUERIES],
"additionalProperties": false,
"x-outputMaxBytes": 49152
}),
Self::CodeSearch => json!({
"type": "object",
"properties": {
(arg_key::QUERY): {"type": "string", "minLength": 1, "maxLength": 512},
(arg_key::MAX_TOKENS): {"type": "integer", "minimum": CODE_SEARCH_MIN_MAX_TOKENS, "maximum": CODE_SEARCH_MAX_MAX_TOKENS}
},
"required": [arg_key::QUERY],
"additionalProperties": false,
"x-defaultMaxTokens": CODE_SEARCH_DEFAULT_MAX_TOKENS,
"x-outputMaxBytes": 49152
}),
Self::AstGrep => json!({
"type": "object",
"properties": {
(arg_key::PATTERN): {"type": "string", "minLength": 1},
(arg_key::LANGUAGE): {"type": "string", "description": "Optional language hint. If omitted, ast-grep auto-detects from file extension. Examples: rust, ts, js, python, go, java, c, cpp"},
(arg_key::PATH): {"type": "string"},
(arg_key::REWRITE): {"type": "string", "description": "Optional rewrite pattern using $METAVAR wildcards. Shows diff preview without modifying files."},
(arg_key::LIMIT): {"type": "integer", "minimum": 1, "maximum": AST_GREP_MAX_LIMIT}
},
"required": [arg_key::PATTERN],
"additionalProperties": false,
"x-defaultLimit": AST_GREP_DEFAULT_LIMIT,
"x-timeoutSeconds": AST_GREP_TIMEOUT_SECS,
"x-stdoutMaxBytes": AST_GREP_STDOUT_MAX_BYTES,
"x-stderrMaxBytes": AST_GREP_STDERR_MAX_BYTES
}),
Self::Diagnostics => json!({
"type": "object",
"properties": {
(arg_key::PATH): {"type": "string", "minLength": 1},
(arg_key::LIMIT): {"type": "integer", "minimum": 1, "maximum": DIAGNOSTICS_MAX_LIMIT}
},
"additionalProperties": false,
"x-defaultLimit": DIAGNOSTICS_DEFAULT_LIMIT
}),
Self::References => json!({
"type": "object",
"properties": {
(arg_key::PATH): {"type": "string", "minLength": 1},
(arg_key::LINE): {"type": "integer", "minimum": 1},
(arg_key::COLUMN): {"type": "integer", "minimum": 1},
(arg_key::INCLUDE_DECLARATION): {"type": "boolean"},
(arg_key::LIMIT): {"type": "integer", "minimum": 1, "maximum": REFERENCES_MAX_LIMIT}
},
"required": [arg_key::PATH, arg_key::LINE, arg_key::COLUMN],
"additionalProperties": false,
"x-defaultLimit": REFERENCES_DEFAULT_LIMIT
}),
}
}
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)
}
pub(crate) fn lsp_tool_provider_definitions() -> Vec<Value> {
[ToolCapability::Diagnostics, ToolCapability::References]
.into_iter()
.map(ToolCapability::provider_definition_json)
.collect()
}
#[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::Browser => include_str!("../../prompts/tools/browser.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::RepoMap => include_str!("../../prompts/tools/repo_map.md").trim(),
ToolCapability::Subagents => include_str!("../../prompts/tools/subagents.md").trim(),
ToolCapability::WebSearch => include_str!("../../prompts/tools/web_search.md").trim(),
ToolCapability::CodeSearch => include_str!("../../prompts/tools/code_search.md").trim(),
ToolCapability::AstGrep => include_str!("../../prompts/tools/ast_grep.md").trim(),
ToolCapability::Diagnostics => {
include_str!("../../prompts/tools/diagnostics.md").trim()
}
ToolCapability::References => include_str!("../../prompts/tools/references.md").trim(),
}
}
#[test]
fn descriptions_match_bundled_markdown_sources() {
for tool in ALL_TOOL_CAPABILITIES {
assert_eq!(tool.description(), expected_markdown_description(tool));
}
}
#[test]
fn cached_provider_definitions_match_uncached_builder_and_preserve_order() {
let cached = mvp_tool_definitions_json();
let rebuilt = build_mvp_tool_definitions_json();
assert_eq!(cached, rebuilt);
assert_eq!(mvp_tool_definitions_json(), cached);
let names = cached
.as_array()
.unwrap()
.iter()
.map(|definition| definition["name"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(
names,
MVP_TOOL_CAPABILITIES
.iter()
.map(|tool| tool.canonical_name())
.collect::<Vec<_>>()
);
}
#[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 web_search_schema_requires_queries_array_contract() {
let definition = ToolCapability::WebSearch.provider_definition_json();
let parameters = &definition["parameters"];
assert_eq!(parameters["required"], json!(["queries"]));
assert_eq!(parameters["additionalProperties"], false);
assert!(parameters["properties"].get("query").is_none());
let queries = ¶meters["properties"]["queries"];
assert_eq!(queries["minItems"], json!(1));
assert_eq!(queries["maxItems"], json!(5));
assert_eq!(queries["items"]["minLength"], json!(1));
assert_eq!(queries["items"]["maxLength"], json!(512));
}
#[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"];
assert_eq!(parameters["required"], json!(["path"]));
assert_eq!(parameters["additionalProperties"], false);
assert_eq!(
parameters["properties"]["include_directories"]["type"],
"boolean"
);
}
#[test]
fn browser_dispatch_name_and_schema_are_registered() {
assert_eq!(
ToolCapability::from_dispatch_name("browser"),
Some(ToolCapability::Browser)
);
let definitions = mvp_tool_definitions_json();
let browser = definitions
.as_array()
.unwrap()
.iter()
.find(|definition| definition["name"] == "browser")
.expect("browser definition");
let parameters = &browser["parameters"];
assert_eq!(parameters["required"], json!(["command"]));
assert_eq!(parameters["additionalProperties"], false);
assert_eq!(
parameters["properties"]["mode"]["enum"],
json!(["headless", "headed", "connect"])
);
assert!(parameters["properties"].get("connectUrl").is_some());
assert!(parameters["properties"].get("ignoreHttpsErrors").is_some());
}
#[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);
assert_eq!(parameters["properties"]["input"]["type"], "string");
}
#[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)
);
}
#[test]
fn provider_definitions_use_canonical_names_and_shared_descriptions() {
let definitions = mvp_tool_definitions_json();
let definitions = definitions.as_array().unwrap();
for tool in MVP_TOOL_CAPABILITIES {
let definition = definitions
.iter()
.find(|definition| definition["name"] == tool.canonical_name())
.unwrap_or_else(|| panic!("missing definition for {}", tool.canonical_name()));
assert_eq!(definition["description"], tool.description());
}
}
#[test]
fn references_schema_limit_matches_lsp_cap() {
let definition = ToolCapability::References.provider_definition_json();
let parameters = &definition["parameters"];
assert_eq!(parameters["properties"]["limit"]["maximum"], json!(100));
assert_eq!(REFERENCES_MAX_LIMIT, crate::lsp::MAX_REFERENCES);
}
}