From 23dd110315ae3561d014f62992ac9867d8010f15 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Wed, 20 May 2026 10:22:18 +0530 Subject: [PATCH] fix: strip schema titles via schemars SchemaSettings transform Register RemoveSchemaTitles as a schemars Transform inside SchemaSettings so Rust-generated title fields are stripped at schema generation time, not post-hoc in individual providers. - Add tool_schema_generator() that wraps SchemaSettings with RemoveSchemaTitles - Use generator in ToolDefinition::new() and agent.rs instead of schema_for! - Add RemoveSchemaTitles to existing SchemaSettings in ToolCatalog::schema() - Remove redundant serde serialize_with/deserialize_with overrides on input_schema - Remove without_schema_titles() helper (no longer needed) - Remove strip_schema_titles() post-hoc function - Update snapshots: tool catalog schemas no longer contain title fields - Add tests proving generator-level stripping works end-to-end Co-Authored-By: ForgeCode --- crates/forge_domain/src/agent.rs | 3 +- crates/forge_domain/src/tools/catalog.rs | 1 + .../src/tools/definition/tool_definition.rs | 133 +++++++++++++++++- ..._catalog__tests__tool_definition_json.snap | 16 --- 4 files changed, 133 insertions(+), 20 deletions(-) diff --git a/crates/forge_domain/src/agent.rs b/crates/forge_domain/src/agent.rs index d0428f3159..ace8bfdfc0 100644 --- a/crates/forge_domain/src/agent.rs +++ b/crates/forge_domain/src/agent.rs @@ -289,7 +289,8 @@ impl From for ToolDefinition { ToolDefinition { name, description, - input_schema: schemars::schema_for!(crate::AgentInput), + input_schema: crate::tool_schema_generator() + .into_root_schema_for::(), } } } diff --git a/crates/forge_domain/src/tools/catalog.rs b/crates/forge_domain/src/tools/catalog.rs index 2849ad91c8..ef911357c7 100644 --- a/crates/forge_domain/src/tools/catalog.rs +++ b/crates/forge_domain/src/tools/catalog.rs @@ -866,6 +866,7 @@ impl ToolCatalog { .with(|s| { s.meta_schema = None; s.inline_subschemas = true; + s.transforms.push(Box::new(crate::RemoveSchemaTitles)); }) .into_generator(); diff --git a/crates/forge_domain/src/tools/definition/tool_definition.rs b/crates/forge_domain/src/tools/definition/tool_definition.rs index 39fe5ae4dc..e33a3c005f 100644 --- a/crates/forge_domain/src/tools/definition/tool_definition.rs +++ b/crates/forge_domain/src/tools/definition/tool_definition.rs @@ -1,31 +1,158 @@ use derive_setters::Setters; use schemars::Schema; +use schemars::generate::SchemaGenerator; +use schemars::transform::{Transform, transform_subschemas}; use serde::{Deserialize, Serialize}; use crate::ToolName; +/// A schemars [`Transform`] that recursively removes the `title` field from +/// every schema node. +/// +/// Rust type names are emitted as `title` by the `JsonSchema` derive. These +/// are internal implementation details and must not be forwarded to LLM +/// provider APIs. +#[derive(Debug, Clone, Default)] +pub struct RemoveSchemaTitles; + +impl Transform for RemoveSchemaTitles { + fn transform(&mut self, schema: &mut Schema) { + if let Some(map) = schema.as_object_mut() { + map.remove("title"); + } + + transform_subschemas(self, schema); + } +} + +/// Returns a [`SchemaGenerator`] whose settings include [`RemoveSchemaTitles`] +/// as a registered transform. +/// +/// All schemas produced via this generator will never contain `title` fields, +/// eliminating the need for any post-hoc stripping. +pub fn tool_schema_generator() -> SchemaGenerator { + schemars::generate::SchemaSettings::default() + .with(|s| { + s.transforms.push(Box::new(RemoveSchemaTitles)); + }) + .into_generator() +} + /// /// Refer to the specification over here: -/// https://glama.ai/blog/2024-11-25-model-context-protocol-quickstart#server +/// https://glama.ai/blog/2024-11-25-model-context-protocol-quickstart #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Setters)] #[setters(into, strip_option)] pub struct ToolDefinition { pub name: ToolName, pub description: String, + #[setters(skip)] pub input_schema: Schema, } impl ToolDefinition { - /// Create a new ToolDefinition + /// Create a new ToolDefinition with an empty input schema. pub fn new(name: N) -> Self { ToolDefinition { name: ToolName::new(name), description: String::new(), - input_schema: schemars::schema_for!(()), // Empty input schema + input_schema: tool_schema_generator().into_root_schema_for::<()>(), } } + + /// Sets the input schema. + /// + /// # Arguments + /// * `input_schema` - The JSON schema describing accepted tool input + pub fn input_schema(mut self, input_schema: impl Into) -> Self { + self.input_schema = input_schema.into(); + self + } } pub trait ToolDescription { fn description(&self) -> String; } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use schemars::JsonSchema; + use serde::Deserialize as SerdeDeserialize; + + use super::*; + + /// A struct with a Rust type name that schemars would emit as `title`. + #[derive(SerdeDeserialize, JsonSchema)] + #[allow(dead_code)] + struct InternalPatchInput { + old_string: String, + nested: NestedInput, + } + + #[derive(SerdeDeserialize, JsonSchema)] + #[allow(dead_code)] + struct NestedInput { + value: String, + } + + #[test] + fn test_tool_schema_generator_strips_titles() { + let r#gen = tool_schema_generator(); + let actual = + serde_json::to_value(r#gen.into_root_schema_for::()).unwrap(); + + assert_eq!( + actual.pointer("/title"), + None, + "root title should be absent" + ); + assert_eq!( + actual.pointer("/properties/nested/title"), + None, + "nested title should be absent" + ); + } + + #[test] + fn test_tool_definition_new_has_no_title() { + let fixture = ToolDefinition::new("patch"); + let actual = serde_json::to_value(&fixture.input_schema).unwrap(); + assert_eq!(actual.pointer("/title"), None); + } + + #[test] + fn test_tool_definition_round_trip_preserves_no_title() { + let r#gen = tool_schema_generator(); + let schema = r#gen.into_root_schema_for::(); + let fixture = ToolDefinition::new("patch") + .description("Patch a file") + .input_schema(schema); + + // Serialise then deserialise and confirm no title leaks in + let json_str = serde_json::to_string(&fixture).unwrap(); + let roundtripped: ToolDefinition = serde_json::from_str(&json_str).unwrap(); + let actual = serde_json::to_value(roundtripped.input_schema).unwrap(); + assert_eq!(actual.pointer("/title"), None); + assert_eq!(actual.pointer("/properties/nested/title"), None); + } + + #[test] + fn test_tool_definition_serialization_has_no_title() { + let r#gen = tool_schema_generator(); + let schema = r#gen.into_root_schema_for::(); + let fixture = ToolDefinition { + name: ToolName::new("patch"), + description: "Patch a file".to_string(), + input_schema: schema, + }; + let actual = serde_json::to_value(&fixture).unwrap(); + + // Titles must be absent at every level regardless of the schema structure + assert_eq!(actual.pointer("/input_schema/title"), None); + assert_eq!( + actual.pointer("/input_schema/$defs/NestedInput/title"), + None + ); + } +} diff --git a/crates/forge_domain/src/tools/snapshots/forge_domain__tools__catalog__tests__tool_definition_json.snap b/crates/forge_domain/src/tools/snapshots/forge_domain__tools__catalog__tests__tool_definition_json.snap index 25672f24b3..c12f16e7f9 100644 --- a/crates/forge_domain/src/tools/snapshots/forge_domain__tools__catalog__tests__tool_definition_json.snap +++ b/crates/forge_domain/src/tools/snapshots/forge_domain__tools__catalog__tests__tool_definition_json.snap @@ -3,7 +3,6 @@ source: crates/forge_domain/src/tools/catalog.rs expression: tools --- { - "title": "FSRead", "type": "object", "properties": { "file_path": { @@ -42,7 +41,6 @@ expression: tools ] } { - "title": "FSWrite", "type": "object", "properties": { "content": { @@ -64,7 +62,6 @@ expression: tools ] } { - "title": "FSSearch", "type": "object", "properties": { "-A": { @@ -153,7 +150,6 @@ expression: tools ] } { - "title": "SemanticSearch", "type": "object", "properties": { "queries": { @@ -184,7 +180,6 @@ expression: tools ] } { - "title": "FSRemove", "type": "object", "properties": { "path": { @@ -197,7 +192,6 @@ expression: tools ] } { - "title": "FSPatch", "type": "object", "properties": { "file_path": { @@ -225,7 +219,6 @@ expression: tools ] } { - "title": "FSMultiPatch", "type": "object", "properties": { "edits": { @@ -266,7 +259,6 @@ expression: tools ] } { - "title": "FSUndo", "type": "object", "properties": { "path": { @@ -279,7 +271,6 @@ expression: tools ] } { - "title": "Shell", "type": "object", "properties": { "command": { @@ -314,7 +305,6 @@ expression: tools ] } { - "title": "NetFetch", "description": "Input type for the net fetch tool", "type": "object", "properties": { @@ -333,7 +323,6 @@ expression: tools ] } { - "title": "Followup", "type": "object", "properties": { "multiple": { @@ -376,7 +365,6 @@ expression: tools ] } { - "title": "PlanCreate", "type": "object", "properties": { "content": { @@ -399,7 +387,6 @@ expression: tools ] } { - "title": "SkillFetch", "type": "object", "properties": { "name": { @@ -412,7 +399,6 @@ expression: tools ] } { - "title": "TodoWrite", "type": "object", "properties": { "todos": { @@ -449,11 +435,9 @@ expression: tools ] } { - "title": "TodoRead", "type": "object" } { - "title": "TaskInput", "description": "Input structure for the Task tool - delegates work to specialized agents", "type": "object", "properties": {