Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/forge_domain/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,8 @@ impl From<Agent> for ToolDefinition {
ToolDefinition {
name,
description,
input_schema: schemars::schema_for!(crate::AgentInput),
input_schema: crate::tool_schema_generator()
.into_root_schema_for::<crate::AgentInput>(),
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/forge_domain/src/tools/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
133 changes: 130 additions & 3 deletions crates/forge_domain/src/tools/definition/tool_definition.rs
Original file line number Diff line number Diff line change
@@ -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<N: ToString>(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<Schema>) -> 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::<InternalPatchInput>()).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::<InternalPatchInput>();
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::<InternalPatchInput>();
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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ source: crates/forge_domain/src/tools/catalog.rs
expression: tools
---
{
"title": "FSRead",
"type": "object",
"properties": {
"file_path": {
Expand Down Expand Up @@ -42,7 +41,6 @@ expression: tools
]
}
{
"title": "FSWrite",
"type": "object",
"properties": {
"content": {
Expand All @@ -64,7 +62,6 @@ expression: tools
]
}
{
"title": "FSSearch",
"type": "object",
"properties": {
"-A": {
Expand Down Expand Up @@ -153,7 +150,6 @@ expression: tools
]
}
{
"title": "SemanticSearch",
"type": "object",
"properties": {
"queries": {
Expand Down Expand Up @@ -184,7 +180,6 @@ expression: tools
]
}
{
"title": "FSRemove",
"type": "object",
"properties": {
"path": {
Expand All @@ -197,7 +192,6 @@ expression: tools
]
}
{
"title": "FSPatch",
"type": "object",
"properties": {
"file_path": {
Expand Down Expand Up @@ -225,7 +219,6 @@ expression: tools
]
}
{
"title": "FSMultiPatch",
"type": "object",
"properties": {
"edits": {
Expand Down Expand Up @@ -266,7 +259,6 @@ expression: tools
]
}
{
"title": "FSUndo",
"type": "object",
"properties": {
"path": {
Expand All @@ -279,7 +271,6 @@ expression: tools
]
}
{
"title": "Shell",
"type": "object",
"properties": {
"command": {
Expand Down Expand Up @@ -314,7 +305,6 @@ expression: tools
]
}
{
"title": "NetFetch",
"description": "Input type for the net fetch tool",
"type": "object",
"properties": {
Expand All @@ -333,7 +323,6 @@ expression: tools
]
}
{
"title": "Followup",
"type": "object",
"properties": {
"multiple": {
Expand Down Expand Up @@ -376,7 +365,6 @@ expression: tools
]
}
{
"title": "PlanCreate",
"type": "object",
"properties": {
"content": {
Expand All @@ -399,7 +387,6 @@ expression: tools
]
}
{
"title": "SkillFetch",
"type": "object",
"properties": {
"name": {
Expand All @@ -412,7 +399,6 @@ expression: tools
]
}
{
"title": "TodoWrite",
"type": "object",
"properties": {
"todos": {
Expand Down Expand Up @@ -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": {
Expand Down
Loading