Since we removed the filtering step during context gathering, we want
the model to perform more targeted searches. This PR tweaks search tool
schema allowing the model to search within syntax nodes such as `impl`
blocks or methods.
This is what the query schema looks like now:
```rust
/// Search for relevant code by path, syntax hierarchy, and content.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SearchToolQuery {
/// 1. A glob pattern to match file paths in the codebase to search in.
pub glob: String,
/// 2. Regular expressions to match syntax nodes **by their first line** and hierarchy.
///
/// Subsequent regexes match nodes within the full content of the nodes matched by the previous regexes.
///
/// Example: Searching for a `User` class
/// ["class\s+User"]
///
/// Example: Searching for a `get_full_name` method under a `User` class
/// ["class\s+User", "def\sget_full_name"]
///
/// Skip this field to match on content alone.
#[schemars(length(max = 3))]
#[serde(default)]
pub syntax_node: Vec<String>,
/// 3. An optional regular expression to match the final content that should appear in the results.
///
/// - Content will be matched within all lines of the matched syntax nodes.
/// - If syntax node regexes are provided, this field can be skipped to include as much of the node itself as possible.
/// - If no syntax node regexes are provided, the content will be matched within the entire file.
pub content: Option<String>,
}
```
We'll need to keep refining this, but the core implementation is ready.
Release Notes:
- N/A
---------
Co-authored-by: Ben <ben@zed.dev>
Co-authored-by: Max <max@zed.dev>
Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
385 lines
11 KiB
Rust
385 lines
11 KiB
Rust
use anyhow::Result;
|
|
use schemars::{
|
|
JsonSchema, Schema,
|
|
generate::SchemaSettings,
|
|
transform::{Transform, transform_subschemas},
|
|
};
|
|
use serde_json::Value;
|
|
|
|
/// Indicates the format used to define the input schema for a language model tool.
|
|
#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
|
|
pub enum LanguageModelToolSchemaFormat {
|
|
/// A JSON schema, see https://json-schema.org
|
|
JsonSchema,
|
|
/// A subset of an OpenAPI 3.0 schema object supported by Google AI, see https://ai.google.dev/api/caching#Schema
|
|
JsonSchemaSubset,
|
|
}
|
|
|
|
pub fn root_schema_for<T: JsonSchema>(format: LanguageModelToolSchemaFormat) -> Schema {
|
|
let mut generator = match format {
|
|
LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07().into_generator(),
|
|
LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3()
|
|
.with(|settings| {
|
|
settings.meta_schema = None;
|
|
settings.inline_subschemas = true;
|
|
})
|
|
.with_transform(ToJsonSchemaSubsetTransform)
|
|
.into_generator(),
|
|
};
|
|
generator.root_schema_for::<T>()
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct ToJsonSchemaSubsetTransform;
|
|
|
|
impl Transform for ToJsonSchemaSubsetTransform {
|
|
fn transform(&mut self, schema: &mut Schema) {
|
|
// Ensure that the type field is not an array, this happens when we use
|
|
// Option<T>, the type will be [T, "null"].
|
|
if let Some(type_field) = schema.get_mut("type")
|
|
&& let Some(types) = type_field.as_array()
|
|
&& let Some(first_type) = types.first()
|
|
{
|
|
*type_field = first_type.clone();
|
|
}
|
|
|
|
// oneOf is not supported, use anyOf instead
|
|
if let Some(one_of) = schema.remove("oneOf") {
|
|
schema.insert("anyOf".to_string(), one_of);
|
|
}
|
|
|
|
transform_subschemas(self, schema);
|
|
}
|
|
}
|
|
|
|
/// Tries to adapt a JSON schema representation to be compatible with the specified format.
|
|
///
|
|
/// If the json cannot be made compatible with the specified format, an error is returned.
|
|
pub fn adapt_schema_to_format(
|
|
json: &mut Value,
|
|
format: LanguageModelToolSchemaFormat,
|
|
) -> Result<()> {
|
|
if let Value::Object(obj) = json {
|
|
obj.remove("$schema");
|
|
obj.remove("title");
|
|
}
|
|
|
|
match format {
|
|
LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json),
|
|
LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json),
|
|
}
|
|
}
|
|
|
|
fn preprocess_json_schema(json: &mut Value) -> Result<()> {
|
|
// `additionalProperties` defaults to `false` unless explicitly specified.
|
|
// This prevents models from hallucinating tool parameters.
|
|
if let Value::Object(obj) = json
|
|
&& matches!(obj.get("type"), Some(Value::String(s)) if s == "object")
|
|
{
|
|
if !obj.contains_key("additionalProperties") {
|
|
obj.insert("additionalProperties".to_string(), Value::Bool(false));
|
|
}
|
|
|
|
// OpenAI API requires non-missing `properties`
|
|
if !obj.contains_key("properties") {
|
|
obj.insert("properties".to_string(), Value::Object(Default::default()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Tries to adapt the json schema so that it is compatible with https://ai.google.dev/api/caching#Schema
|
|
fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
|
|
if let Value::Object(obj) = json {
|
|
const UNSUPPORTED_KEYS: [&str; 4] = ["if", "then", "else", "$ref"];
|
|
|
|
for key in UNSUPPORTED_KEYS {
|
|
anyhow::ensure!(
|
|
!obj.contains_key(key),
|
|
"Schema cannot be made compatible because it contains \"{key}\""
|
|
);
|
|
}
|
|
|
|
const KEYS_TO_REMOVE: [(&str, fn(&Value) -> bool); 5] = [
|
|
("format", |value| value.is_string()),
|
|
("additionalProperties", |value| value.is_boolean()),
|
|
("exclusiveMinimum", |value| value.is_number()),
|
|
("exclusiveMaximum", |value| value.is_number()),
|
|
("optional", |value| value.is_boolean()),
|
|
];
|
|
for (key, predicate) in KEYS_TO_REMOVE {
|
|
if let Some(value) = obj.get(key)
|
|
&& predicate(value)
|
|
{
|
|
obj.remove(key);
|
|
}
|
|
}
|
|
|
|
// If a type is not specified for an input parameter, add a default type
|
|
if matches!(obj.get("description"), Some(Value::String(_)))
|
|
&& !obj.contains_key("type")
|
|
&& !(obj.contains_key("anyOf")
|
|
|| obj.contains_key("oneOf")
|
|
|| obj.contains_key("allOf"))
|
|
{
|
|
obj.insert("type".to_string(), Value::String("string".to_string()));
|
|
}
|
|
|
|
// Handle oneOf -> anyOf conversion
|
|
if let Some(subschemas) = obj.get_mut("oneOf")
|
|
&& subschemas.is_array()
|
|
{
|
|
let subschemas_clone = subschemas.clone();
|
|
obj.remove("oneOf");
|
|
obj.insert("anyOf".to_string(), subschemas_clone);
|
|
}
|
|
|
|
// Recursively process all nested objects and arrays
|
|
for (_, value) in obj.iter_mut() {
|
|
if let Value::Object(_) | Value::Array(_) = value {
|
|
adapt_to_json_schema_subset(value)?;
|
|
}
|
|
}
|
|
} else if let Value::Array(arr) = json {
|
|
for item in arr.iter_mut() {
|
|
adapt_to_json_schema_subset(item)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn test_transform_adds_type_when_missing() {
|
|
let mut json = json!({
|
|
"description": "A test field without type"
|
|
});
|
|
|
|
adapt_to_json_schema_subset(&mut json).unwrap();
|
|
|
|
assert_eq!(
|
|
json,
|
|
json!({
|
|
"description": "A test field without type",
|
|
"type": "string"
|
|
})
|
|
);
|
|
|
|
// Ensure that we do not add a type if it is an object
|
|
let mut json = json!({
|
|
"description": {
|
|
"value": "abc",
|
|
"type": "string"
|
|
}
|
|
});
|
|
|
|
adapt_to_json_schema_subset(&mut json).unwrap();
|
|
|
|
assert_eq!(
|
|
json,
|
|
json!({
|
|
"description": {
|
|
"value": "abc",
|
|
"type": "string"
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transform_removes_unsupported_keys() {
|
|
let mut json = json!({
|
|
"description": "A test field",
|
|
"type": "integer",
|
|
"format": "uint32",
|
|
"exclusiveMinimum": 0,
|
|
"exclusiveMaximum": 100,
|
|
"additionalProperties": false,
|
|
"optional": true
|
|
});
|
|
|
|
adapt_to_json_schema_subset(&mut json).unwrap();
|
|
|
|
assert_eq!(
|
|
json,
|
|
json!({
|
|
"description": "A test field",
|
|
"type": "integer"
|
|
})
|
|
);
|
|
|
|
// Ensure that we do not remove keys that are actually supported (e.g. "format" can just be used as another property)
|
|
let mut json = json!({
|
|
"description": "A test field",
|
|
"type": "integer",
|
|
"format": {},
|
|
});
|
|
|
|
adapt_to_json_schema_subset(&mut json).unwrap();
|
|
|
|
assert_eq!(
|
|
json,
|
|
json!({
|
|
"description": "A test field",
|
|
"type": "integer",
|
|
"format": {},
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transform_one_of_to_any_of() {
|
|
let mut json = json!({
|
|
"description": "A test field",
|
|
"oneOf": [
|
|
{ "type": "string" },
|
|
{ "type": "integer" }
|
|
]
|
|
});
|
|
|
|
adapt_to_json_schema_subset(&mut json).unwrap();
|
|
|
|
assert_eq!(
|
|
json,
|
|
json!({
|
|
"description": "A test field",
|
|
"anyOf": [
|
|
{ "type": "string" },
|
|
{ "type": "integer" }
|
|
]
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transform_nested_objects() {
|
|
let mut json = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"nested": {
|
|
"oneOf": [
|
|
{ "type": "string" },
|
|
{ "type": "null" }
|
|
],
|
|
"format": "email"
|
|
}
|
|
}
|
|
});
|
|
|
|
adapt_to_json_schema_subset(&mut json).unwrap();
|
|
|
|
assert_eq!(
|
|
json,
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"nested": {
|
|
"anyOf": [
|
|
{ "type": "string" },
|
|
{ "type": "null" }
|
|
]
|
|
}
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_transform_fails_if_unsupported_keys_exist() {
|
|
let mut json = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"$ref": "#/definitions/User",
|
|
}
|
|
});
|
|
|
|
assert!(adapt_to_json_schema_subset(&mut json).is_err());
|
|
|
|
let mut json = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"if": "...",
|
|
}
|
|
});
|
|
|
|
assert!(adapt_to_json_schema_subset(&mut json).is_err());
|
|
|
|
let mut json = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"then": "...",
|
|
}
|
|
});
|
|
|
|
assert!(adapt_to_json_schema_subset(&mut json).is_err());
|
|
|
|
let mut json = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"else": "...",
|
|
}
|
|
});
|
|
|
|
assert!(adapt_to_json_schema_subset(&mut json).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_preprocess_json_schema_adds_additional_properties() {
|
|
let mut json = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string"
|
|
}
|
|
}
|
|
});
|
|
|
|
preprocess_json_schema(&mut json).unwrap();
|
|
|
|
assert_eq!(
|
|
json,
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"additionalProperties": false
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_preprocess_json_schema_preserves_additional_properties() {
|
|
let mut json = json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"additionalProperties": true
|
|
});
|
|
|
|
preprocess_json_schema(&mut json).unwrap();
|
|
|
|
assert_eq!(
|
|
json,
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {
|
|
"type": "string"
|
|
}
|
|
},
|
|
"additionalProperties": true
|
|
})
|
|
);
|
|
}
|
|
}
|