Zed asked each language model which JSON Schema dialect it wanted, and
rewrote every tool's schema into that dialect before sending it.
`LanguageModelToolSchemaFormat` had grown three variants, and providers
picked between them by model name, so Grok and Gemini took a lossy path
while everything else did not.
The subset exists because Google's `FunctionDeclaration.parameters`
field accepts an OpenAPI-flavored schema rather than JSON Schema, and
`adapt_schema_to_format` tried to translate into it. The translation was
best-effort: constructs it could not represent were left in place and
the function still returned `Ok`. The shape schemars produces for a Rust
`String | Vec<String>` union survives as a nested `anyOf` whose outer
node carries no `type`, and Vertex rejects the entire request over it,
taking every other tool down with it. Where the translation did fail,
the caller logged the error and dropped the tool, so the model silently
lost a capability.
Google now offers `parametersJsonSchema` alongside `parameters`, which
takes plain JSON Schema, and every other provider we ship documents JSON
Schema for function parameters. So the first commit switches
`into_google` to `parametersJsonSchema`, and the second deletes the
dialect: `LanguageModelToolSchemaFormat`,
`LanguageModel::tool_input_format`, `adapt_schema_to_format`, and the
per-model branching in every provider go away, along with
`x_ai::Model::requires_json_schema_subset`.
What remains is `normalize_tool_schema`, which every tool now passes
through. It strips `$schema`, `title`, and `description` from the root,
which no provider reads and which cost tokens; gives an object schema an
explicit `"properties": {}`, which OpenAI requires; and inlines
same-document `$ref`s. That last part is carried over from the deleted
code and matters only for context servers: MCP's Python SDK derives
input schemas through pydantic, which emits `$defs` and `$ref` for any
nested model or enum, and `zod-to-json-schema` emits `definitions` for
reused shapes, while Google rejects references outright. Built-in tools
never contain one, because `root_schema_for` inlines subschemas during
generation. A schema whose references cannot all be resolved is passed
through untouched and the reason logged, so a provider explains what is
wrong instead of receiving something half-rewritten.
## Providers whose behavior changes
| Provider | Models affected | Before | After |
|---|---|---|---|
| Google | all | `JsonSchemaSubset` in `parameters` | full JSON Schema
in `parametersJsonSchema` |
| Zed Cloud | Google, xAI | `JsonSchemaSubset` | full JSON Schema |
| Copilot Chat | Google, xAI, unknown vendors | `JsonSchemaSubset` |
full JSON Schema |
| xAI | Grok 4.3, 4.5, 4.6, 4.20 Reasoning, 4.20 Non-Reasoning |
`JsonSchemaSubset` | full JSON Schema |
| OpenRouter | model ids containing `gemini` or `grok` |
`JsonSchemaSubset` | full JSON Schema |
| Vercel AI Gateway | all | `JsonSchemaSubset` | full JSON Schema |
| OpenAI-compatible | all | `JsonSchemaSubset` | full JSON Schema |
| Bedrock Mantle | all | `JsonSchemaSubset` | full JSON Schema |
| Everything else | — | `JsonSchema` | unchanged apart from
normalization |
One deliberate difference for the last row: the old `JsonSchema` path
also injected `additionalProperties: false` into object roots, and
normalization does not. Nothing depends on it. Serde ignores unknown
fields, so a model inventing an argument never broke deserialization,
and OpenAI's strict function calling — the one feature that requires the
flag — is never enabled: every `ToolDefinition::Function` in the tree
passes `strict: None`. It was also only ever applied on the `JsonSchema`
path, so Google, Grok, Vercel and Bedrock Mantle users never received
it. Dropping it makes behavior uniform rather than removing a guarantee.
## Verification
This was checked against live providers rather than by reasoning about
documentation. A harness in the Delta repository dumps all 24 built-in
tool schemas from this branch and submits them with `tool_choice: none`
to each provider, so the only thing under test is whether the provider
accepts the schemas. Anthropic, OpenAI (chat and responses), Gemini,
xAI, Baseten, OpenRouter (Gemini and Grok), Vercel AI Gateway (Gemini
and Grok), Copilot (Gemini and Grok), the ChatGPT subscription, Bedrock
Converse, Bedrock Mantle (GPT and Grok), and Zed Cloud (Anthropic,
OpenAI, and Google) all accepted them. DeepSeek, Mistral, and OpenCode
were not probed for lack of a key.
The failure this fixes is reproducible on the route that broke. Vertex,
not the Gemini Developer API, is what enforces the constraint: the
harness pins the `grep` declaration production generated, replays it to
Vertex through Zed Cloud in the legacy `parameters` field, and gets back
the exact `INVALID_ARGUMENT` naming `parameters.globs`, while the same
route accepts this branch's schemas through `parametersJsonSchema`. The
Gemini Developer API accepts the broken shape on every model tried, so a
direct API key would never have caught it.
Testing:
- `cargo nextest run -p language_model_core -p google_ai -p agent` for
the schema, `into_google`, MCP and built-in tool tests
- New tests cover reference inlining for the shapes pydantic and zod
produce, recursive references, and unresolvable references
- `built_in_tool_schemas_are_normalized` asserts every built-in tool is
present and normalized
- `crates/agent/examples/dump_tool_schemas.rs` prints the definitions
the harness consumes
- 21 live provider probes with the complete tool set
Release Notes:
- Fixed Gemini rejecting requests because Zed translated tool schemas
into a restricted dialect it could not represent
295 lines
9.3 KiB
Rust
295 lines
9.3 KiB
Rust
mod apply_code_action_tool;
|
|
mod ask_user_tool;
|
|
mod context_server_registry;
|
|
mod copy_path_tool;
|
|
mod create_directory_tool;
|
|
mod create_thread_tool;
|
|
mod delete_path_tool;
|
|
mod diagnostics_tool;
|
|
mod edit_file_tool;
|
|
mod edit_session;
|
|
#[cfg(all(test, feature = "unit-eval"))]
|
|
mod evals;
|
|
mod fetch_tool;
|
|
mod find_path_tool;
|
|
mod find_references_tool;
|
|
mod get_code_actions_tool;
|
|
mod go_to_definition_tool;
|
|
mod grep_tool;
|
|
mod list_agents_and_models_tool;
|
|
mod list_directory_tool;
|
|
mod move_path_tool;
|
|
mod read_file_tool;
|
|
mod rename_tool;
|
|
mod skill_tool;
|
|
mod spawn_agent_tool;
|
|
mod symbol_locator;
|
|
mod terminal_tool;
|
|
mod tool_permissions;
|
|
mod web_search_tool;
|
|
mod write_file_tool;
|
|
|
|
use crate::AgentTool;
|
|
use feature_flags::{
|
|
CreateThreadToolFeatureFlag, FeatureFlagAppExt as _, LspToolFeatureFlag, RenameToolFeatureFlag,
|
|
};
|
|
use gpui::App;
|
|
use language_model::LanguageModelRequestTool;
|
|
use serde::{
|
|
Deserialize, Deserializer,
|
|
de::{DeserializeOwned, Error as _},
|
|
};
|
|
|
|
/// Deserialize a value that may have been provided as a JSON-encoded string
|
|
/// instead of the structured value. Some models occasionally stringify nested
|
|
/// arguments, so we accept either form.
|
|
pub(crate) fn deserialize_maybe_stringified<'de, T, D>(deserializer: D) -> Result<T, D::Error>
|
|
where
|
|
T: DeserializeOwned,
|
|
D: Deserializer<'de>,
|
|
{
|
|
fn to_custom_error<E>(e: serde_json::Error) -> E
|
|
where
|
|
E: serde::de::Error,
|
|
{
|
|
E::custom(format!("{e}"))
|
|
}
|
|
|
|
let raw_value = serde_json::Value::deserialize(deserializer)
|
|
.map_err(|error| D::Error::custom(format!("invalid JSON: {error}")))?;
|
|
|
|
match T::deserialize(&raw_value) {
|
|
Ok(value) => Ok(value),
|
|
Err(original_error) => {
|
|
let Some(string) = raw_value.as_str() else {
|
|
return Err(to_custom_error(original_error));
|
|
};
|
|
|
|
serde_json::from_str(string).map_err(to_custom_error)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub use apply_code_action_tool::*;
|
|
pub use ask_user_tool::*;
|
|
pub use context_server_registry::*;
|
|
pub use copy_path_tool::*;
|
|
pub use create_directory_tool::*;
|
|
pub use create_thread_tool::*;
|
|
pub use delete_path_tool::*;
|
|
pub use diagnostics_tool::*;
|
|
pub use edit_file_tool::*;
|
|
pub use fetch_tool::*;
|
|
pub use find_path_tool::*;
|
|
pub use find_references_tool::*;
|
|
pub use get_code_actions_tool::*;
|
|
pub use go_to_definition_tool::*;
|
|
pub use grep_tool::*;
|
|
pub use list_agents_and_models_tool::*;
|
|
pub use list_directory_tool::*;
|
|
pub use move_path_tool::*;
|
|
pub use read_file_tool::*;
|
|
pub use rename_tool::*;
|
|
pub use skill_tool::*;
|
|
pub use spawn_agent_tool::*;
|
|
pub use symbol_locator::*;
|
|
|
|
pub use terminal_tool::*;
|
|
pub use tool_permissions::*;
|
|
pub use web_search_tool::*;
|
|
pub use write_file_tool::*;
|
|
|
|
macro_rules! tools {
|
|
($($tool:ty),* $(,)?) => {
|
|
/// Every built-in tool name, determined at compile time.
|
|
pub const ALL_TOOL_NAMES: &[&str] = &[
|
|
$(<$tool>::NAME,)*
|
|
];
|
|
|
|
const _: () = {
|
|
const fn str_eq(a: &str, b: &str) -> bool {
|
|
let a = a.as_bytes();
|
|
let b = b.as_bytes();
|
|
if a.len() != b.len() {
|
|
return false;
|
|
}
|
|
let mut i = 0;
|
|
while i < a.len() {
|
|
if a[i] != b[i] {
|
|
return false;
|
|
}
|
|
i += 1;
|
|
}
|
|
true
|
|
}
|
|
|
|
const NAMES: &[&str] = ALL_TOOL_NAMES;
|
|
let mut i = 0;
|
|
while i < NAMES.len() {
|
|
let mut j = i + 1;
|
|
while j < NAMES.len() {
|
|
if str_eq(NAMES[i], NAMES[j]) {
|
|
panic!("Duplicate tool name in tools! macro");
|
|
}
|
|
j += 1;
|
|
}
|
|
i += 1;
|
|
}
|
|
};
|
|
|
|
/// Returns whether the tool with the given name supports the given provider.
|
|
pub fn tool_supports_provider(name: &str, provider: &language_model::LanguageModelProviderId) -> bool {
|
|
$(
|
|
if name == <$tool>::NAME {
|
|
return <$tool>::supports_provider(provider);
|
|
}
|
|
)*
|
|
false
|
|
}
|
|
|
|
/// Returns whether the tool with the given name may be provided to an
|
|
/// agent in a restricted workspace. Unknown tools (e.g. MCP tools) are
|
|
/// considered allowed.
|
|
pub fn tool_allowed_in_restricted_mode(name: &str) -> bool {
|
|
$(
|
|
if name == <$tool>::NAME {
|
|
return <$tool>::allow_in_restricted_mode();
|
|
}
|
|
)*
|
|
true
|
|
}
|
|
|
|
/// A list of all built-in tools
|
|
pub fn built_in_tools() -> impl Iterator<Item = LanguageModelRequestTool> {
|
|
fn language_model_tool<T: AgentTool>() -> LanguageModelRequestTool {
|
|
let mut input_schema = T::input_schema().to_value();
|
|
language_model::tool_schema::normalize_tool_schema(&mut input_schema);
|
|
LanguageModelRequestTool::function(
|
|
T::NAME.to_string(),
|
|
T::description().to_string(),
|
|
input_schema,
|
|
T::supports_input_streaming(),
|
|
)
|
|
}
|
|
[
|
|
$(
|
|
language_model_tool::<$tool>(),
|
|
)*
|
|
]
|
|
.into_iter()
|
|
}
|
|
};
|
|
}
|
|
|
|
// Adding a tool here (and constructing it in `Thread::add_default_tools`) is
|
|
// not enough to make the model actually receive it. Three further gates will
|
|
// silently drop the tool rather than fail to compile:
|
|
//
|
|
// 1. `assets/settings/default.json`: the `write` and `ask` agent profiles each
|
|
// carry an explicit `tools` allowlist. `Thread::enabled_tools` filters out
|
|
// any tool not present there with value `true`, so it never reaches the
|
|
// model.
|
|
// 2. `test_all_tools_are_in_tool_info_or_excluded` in
|
|
// `crates/settings_ui/src/pages/tool_permissions_setup.rs`: every tool must
|
|
// be in the permission-UI `TOOLS` list (if it calls
|
|
// `decide_permission_from_settings`) or in `EXCLUDED_TOOLS`.
|
|
// 3. `tool_feature_flag_enabled`: some tools are gated behind a feature flag and
|
|
// are dropped unless it is active. The agent-profile UI uses the same gate so
|
|
// it never offers a tool the agent can't actually use.
|
|
tools! {
|
|
ApplyCodeActionTool,
|
|
AskUserTool,
|
|
CopyPathTool,
|
|
CreateDirectoryTool,
|
|
CreateThreadTool,
|
|
DeletePathTool,
|
|
DiagnosticsTool,
|
|
EditFileTool,
|
|
FetchTool,
|
|
FindPathTool,
|
|
FindReferencesTool,
|
|
GetCodeActionsTool,
|
|
GoToDefinitionTool,
|
|
GrepTool,
|
|
ListAgentsAndModelsTool,
|
|
ListDirectoryTool,
|
|
MovePathTool,
|
|
ReadFileTool,
|
|
RenameTool,
|
|
SkillTool,
|
|
SpawnAgentTool,
|
|
TerminalTool,
|
|
WebSearchTool,
|
|
WriteFileTool,
|
|
}
|
|
|
|
/// Some built-in tools are gated behind a feature flag and only become usable
|
|
/// once that flag is active. Tools without a flag are always available.
|
|
///
|
|
/// This is the single source of truth for that gating: `Thread::enabled_tools`
|
|
/// uses it to decide what the model receives, and the agent-profile
|
|
/// configuration UI uses it to decide what to offer — so the UI can never list
|
|
/// a tool the agent would silently drop (see #56778).
|
|
pub fn tool_feature_flag_enabled(tool_name: &str, cx: &App) -> bool {
|
|
match tool_name {
|
|
RenameTool::NAME => cx.has_flag::<RenameToolFeatureFlag>(),
|
|
FindReferencesTool::NAME
|
|
| GetCodeActionsTool::NAME
|
|
| ApplyCodeActionTool::NAME
|
|
| GoToDefinitionTool::NAME => cx.has_flag::<LspToolFeatureFlag>(),
|
|
CreateThreadTool::NAME | ListAgentsAndModelsTool::NAME => {
|
|
cx.has_flag::<CreateThreadToolFeatureFlag>()
|
|
}
|
|
_ => true,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn built_in_tool_schemas_are_normalized() {
|
|
let tools = built_in_tools().collect::<Vec<_>>();
|
|
|
|
assert_eq!(tools.len(), ALL_TOOL_NAMES.len());
|
|
for tool in tools {
|
|
let language_model::LanguageModelRequestToolInput::Function { input_schema, .. } =
|
|
tool.input
|
|
else {
|
|
panic!("built-in tool `{}` should use a JSON schema", tool.name);
|
|
};
|
|
assert_eq!(input_schema.get("$schema"), None, "tool `{}`", tool.name);
|
|
assert_eq!(input_schema.get("title"), None, "tool `{}`", tool.name);
|
|
assert_eq!(
|
|
input_schema.get("description"),
|
|
None,
|
|
"tool `{}`",
|
|
tool.name
|
|
);
|
|
assert!(
|
|
input_schema["properties"].is_object(),
|
|
"tool `{}` should have object properties",
|
|
tool.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn fetch_and_terminal_are_forbidden_in_restricted_mode() {
|
|
assert!(!tool_allowed_in_restricted_mode(FetchTool::NAME));
|
|
assert!(!tool_allowed_in_restricted_mode(TerminalTool::NAME));
|
|
|
|
// Every other built-in tool, and unknown (e.g. MCP) tools, are allowed.
|
|
for name in ALL_TOOL_NAMES {
|
|
let expected = *name != FetchTool::NAME && *name != TerminalTool::NAME;
|
|
assert_eq!(
|
|
tool_allowed_in_restricted_mode(name),
|
|
expected,
|
|
"unexpected restricted-mode policy for tool `{name}`"
|
|
);
|
|
}
|
|
assert!(tool_allowed_in_restricted_mode("some_mcp_tool"));
|
|
}
|
|
}
|