go further

This commit is contained in:
jif-oai
2026-05-11 19:11:15 +01:00
parent a124ddb854
commit 4e244b254b
17 changed files with 535 additions and 121 deletions
+2
View File
@@ -13,6 +13,8 @@ doctest = false
workspace = true
[dependencies]
codex-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
+7 -6
View File
@@ -8,8 +8,9 @@ Crates that define contributed tools should depend on this crate. It owns:
- the executable bundle contract: `ToolBundle`, `ToolExecutor`, `ToolCall`,
and `ToolError`
- the one model-visible spec an extension may contribute directly:
`FunctionToolSpec`
- the shared model-visible function-tool fields stored directly on
`ToolBundle`
- contributed namespace metadata through `ToolNamespace`
The contract is intentionally narrow: contributed tools receive a call id plus
raw JSON arguments and return a JSON value. If a feature needs richer host
@@ -23,7 +24,7 @@ tool-owning extension crate --> codex-tool-api <-- codex-core
```
`codex-tools` has a different job. It remains the host-side owner of Responses
API tool models, schema parsing, namespaces, discovery, MCP/dynamic conversion,
code-mode shaping, and other aggregate host concerns. A crate that only wants
to contribute one ordinary function tool through an extension should not need
to depend on `codex-tools`.
API tool models, namespace rendering and aggregation, discovery, MCP/dynamic
conversion, code-mode shaping, and other aggregate host concerns. A crate that
only wants to contribute one ordinary function tool through an extension should
not need to depend on `codex-tools`.
+151 -18
View File
@@ -4,9 +4,11 @@ use std::sync::Arc;
use serde_json::Value;
use crate::FunctionToolSpec;
use crate::JsonSchema;
use crate::ToolCall;
use crate::ToolError;
use crate::ToolName;
use crate::ToolNamespace;
/// Future returned by one contributed function-tool invocation.
pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, ToolError>> + Send + 'a>>;
@@ -15,24 +17,98 @@ pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, ToolError>>
/// function tool.
#[derive(Clone)]
pub struct ToolBundle {
spec: FunctionToolSpec,
tool_name: ToolName,
namespace: Option<ToolNamespace>,
description: String,
input_schema: JsonSchema,
strict: bool,
output_schema: Option<Value>,
defer_loading: bool,
executor: Arc<dyn ToolExecutor>,
}
impl ToolBundle {
/// Creates one contributed function-tool bundle.
pub fn new(spec: FunctionToolSpec, executor: Arc<dyn ToolExecutor>) -> Self {
Self { spec, executor }
pub fn new(
tool_name: ToolName,
description: String,
input_schema: JsonSchema,
executor: Arc<dyn ToolExecutor>,
) -> Self {
let namespace = tool_name
.namespace
.as_ref()
.map(|namespace| ToolNamespace::new(namespace.clone()));
Self {
tool_name,
namespace,
description,
input_schema,
strict: false,
output_schema: None,
defer_loading: false,
executor,
}
}
/// Returns the contributed function-tool spec.
pub fn spec(&self) -> &FunctionToolSpec {
&self.spec
/// Enables strict schema handling for this contributed tool.
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
/// Adds a model-visible output schema for this contributed tool.
pub fn with_output_schema(mut self, output_schema: Value) -> Self {
self.output_schema = Some(output_schema);
self
}
/// Marks this contributed tool as loadable on demand.
pub fn deferred(mut self) -> Self {
self.defer_loading = true;
self
}
/// Places this contributed tool in a model-visible namespace.
pub fn in_namespace(mut self, namespace: ToolNamespace) -> Self {
self.tool_name.namespace = Some(namespace.name().to_string());
self.namespace = Some(namespace);
self
}
/// Returns the contributed function-tool name.
pub fn tool_name(&self) -> &str {
self.spec.name.as_str()
pub fn tool_name(&self) -> &ToolName {
&self.tool_name
}
/// Returns the contributed tool namespace, if any.
pub fn namespace(&self) -> Option<&ToolNamespace> {
self.namespace.as_ref()
}
/// Returns the contributed function-tool description.
pub fn description(&self) -> &str {
self.description.as_str()
}
/// Returns the contributed function-tool input schema.
pub fn input_schema(&self) -> &JsonSchema {
&self.input_schema
}
/// Returns whether strict schema handling is enabled.
pub fn is_strict(&self) -> bool {
self.strict
}
/// Returns the optional contributed function-tool output schema.
pub fn output_schema(&self) -> Option<&Value> {
self.output_schema.as_ref()
}
/// Returns whether the contributed function tool should be loadable on demand.
pub fn defer_loading(&self) -> bool {
self.defer_loading
}
/// Returns the executable implementation.
@@ -59,8 +135,10 @@ mod tests {
use super::ToolBundle;
use super::ToolExecutor;
use super::ToolFuture;
use crate::FunctionToolSpec;
use crate::JsonSchema;
use crate::ToolCall;
use crate::ToolName;
use crate::ToolNamespace;
struct StubExecutor;
@@ -71,17 +149,72 @@ mod tests {
}
#[test]
fn bundle_derives_name_from_function_spec() {
fn bundle_preserves_plain_tool_name() {
let bundle = ToolBundle::new(
FunctionToolSpec {
name: "echo".to_string(),
description: "Echo arguments.".to_string(),
strict: false,
parameters: json!({ "type": "object" }),
},
ToolName::plain("echo"),
"Echo arguments.".to_string(),
JsonSchema::object(
std::collections::BTreeMap::new(),
/*required*/ None,
Some(true.into()),
),
Arc::new(StubExecutor),
);
assert_eq!(bundle.tool_name(), "echo");
assert_eq!(bundle.tool_name(), &ToolName::plain("echo"));
assert_eq!(bundle.namespace(), None);
}
#[test]
fn bundle_derives_namespace_from_namespaced_tool_name() {
let bundle = ToolBundle::new(
ToolName::namespaced("extension_tools/", "echo"),
"Echo arguments.".to_string(),
JsonSchema::object(
std::collections::BTreeMap::new(),
/*required*/ None,
Some(true.into()),
),
Arc::new(StubExecutor),
);
assert_eq!(
bundle.tool_name(),
&ToolName::namespaced("extension_tools/", "echo")
);
assert_eq!(
bundle.namespace(),
Some(&ToolNamespace::new("extension_tools/"))
);
}
#[test]
fn bundle_sets_tool_name_when_namespace_metadata_is_attached() {
let bundle = ToolBundle::new(
ToolName::plain("echo"),
"Echo arguments.".to_string(),
JsonSchema::object(
std::collections::BTreeMap::new(),
/*required*/ None,
Some(true.into()),
),
Arc::new(StubExecutor),
)
.in_namespace(
ToolNamespace::new("extension_tools/")
.with_description("Extension-owned function tools."),
);
assert_eq!(
bundle.tool_name(),
&ToolName::namespaced("extension_tools/", "echo")
);
assert_eq!(
bundle.namespace(),
Some(
&ToolNamespace::new("extension_tools/")
.with_description("Extension-owned function tools.")
)
);
}
}
+340
View File
@@ -0,0 +1,340 @@
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
/// Primitive JSON Schema type names we support in tool definitions.
///
/// This mirrors the OpenAI Structured Outputs subset for JSON Schema `type`:
/// string, number, boolean, integer, object, array, and null.
/// Keywords such as `enum`, `const`, and `anyOf` are modeled separately.
/// See <https://developers.openai.com/api/docs/guides/structured-outputs#supported-schemas>.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum JsonSchemaPrimitiveType {
String,
Number,
Boolean,
Integer,
Object,
Array,
Null,
}
/// JSON Schema `type` supports either a single type name or a union of names.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum JsonSchemaType {
Single(JsonSchemaPrimitiveType),
Multiple(Vec<JsonSchemaPrimitiveType>),
}
/// Generic JSON-Schema subset needed for our tool definitions.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct JsonSchema {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub schema_type: Option<JsonSchemaType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<JsonValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<JsonSchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<BTreeMap<String, JsonSchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
pub additional_properties: Option<AdditionalProperties>,
#[serde(rename = "anyOf", skip_serializing_if = "Option::is_none")]
pub any_of: Option<Vec<JsonSchema>>,
}
impl JsonSchema {
/// Construct a scalar/object/array schema with a single JSON Schema type.
fn typed(schema_type: JsonSchemaPrimitiveType, description: Option<String>) -> Self {
Self {
schema_type: Some(JsonSchemaType::Single(schema_type)),
description,
..Default::default()
}
}
pub fn any_of(variants: Vec<JsonSchema>, description: Option<String>) -> Self {
Self {
description,
any_of: Some(variants),
..Default::default()
}
}
pub fn boolean(description: Option<String>) -> Self {
Self::typed(JsonSchemaPrimitiveType::Boolean, description)
}
pub fn string(description: Option<String>) -> Self {
Self::typed(JsonSchemaPrimitiveType::String, description)
}
pub fn number(description: Option<String>) -> Self {
Self::typed(JsonSchemaPrimitiveType::Number, description)
}
pub fn integer(description: Option<String>) -> Self {
Self::typed(JsonSchemaPrimitiveType::Integer, description)
}
pub fn null(description: Option<String>) -> Self {
Self::typed(JsonSchemaPrimitiveType::Null, description)
}
pub fn string_enum(values: Vec<JsonValue>, description: Option<String>) -> Self {
Self {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::String)),
description,
enum_values: Some(values),
..Default::default()
}
}
pub fn array(items: JsonSchema, description: Option<String>) -> Self {
Self {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Array)),
description,
items: Some(Box::new(items)),
..Default::default()
}
}
pub fn object(
properties: BTreeMap<String, JsonSchema>,
required: Option<Vec<String>>,
additional_properties: Option<AdditionalProperties>,
) -> Self {
Self {
schema_type: Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object)),
properties: Some(properties),
required,
additional_properties,
..Default::default()
}
}
}
/// Whether additional properties are allowed, and if so, any required schema.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum AdditionalProperties {
Boolean(bool),
Schema(Box<JsonSchema>),
}
impl From<bool> for AdditionalProperties {
fn from(value: bool) -> Self {
Self::Boolean(value)
}
}
impl From<JsonSchema> for AdditionalProperties {
fn from(value: JsonSchema) -> Self {
Self::Schema(Box::new(value))
}
}
/// Parse the tool `input_schema` or return an error for invalid schema.
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
let mut input_schema = input_schema.clone();
sanitize_json_schema(&mut input_schema);
let schema: JsonSchema = serde_json::from_value(input_schema)?;
if matches!(
schema.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Null))
) {
return Err(singleton_null_schema_error());
}
Ok(schema)
}
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
/// schema representation. This function:
/// - Ensures every typed schema object has a `"type"` when required.
/// - Preserves explicit `anyOf`.
/// - Collapses `const` into single-value `enum`.
/// - Fills required child fields for object/array schema types, including
/// nullable unions, with permissive defaults when absent.
fn sanitize_json_schema(value: &mut JsonValue) {
match value {
JsonValue::Bool(_) => {
// JSON Schema boolean form: true/false. Coerce to an accept-all string.
*value = json!({ "type": "string" });
}
JsonValue::Array(values) => {
for value in values {
sanitize_json_schema(value);
}
}
JsonValue::Object(map) => {
if let Some(properties) = map.get_mut("properties")
&& let Some(properties_map) = properties.as_object_mut()
{
for value in properties_map.values_mut() {
sanitize_json_schema(value);
}
}
if let Some(items) = map.get_mut("items") {
sanitize_json_schema(items);
}
if let Some(additional_properties) = map.get_mut("additionalProperties")
&& !matches!(additional_properties, JsonValue::Bool(_))
{
sanitize_json_schema(additional_properties);
}
if let Some(value) = map.get_mut("prefixItems") {
sanitize_json_schema(value);
}
if let Some(value) = map.get_mut("anyOf") {
sanitize_json_schema(value);
}
if let Some(const_value) = map.remove("const") {
map.insert("enum".to_string(), JsonValue::Array(vec![const_value]));
}
let mut schema_types = normalized_schema_types(map);
if schema_types.is_empty() && map.contains_key("anyOf") {
return;
}
if schema_types.is_empty() {
if map.contains_key("properties")
|| map.contains_key("required")
|| map.contains_key("additionalProperties")
{
schema_types.push(JsonSchemaPrimitiveType::Object);
} else if map.contains_key("items") || map.contains_key("prefixItems") {
schema_types.push(JsonSchemaPrimitiveType::Array);
} else if map.contains_key("enum") || map.contains_key("format") {
schema_types.push(JsonSchemaPrimitiveType::String);
} else if map.contains_key("minimum")
|| map.contains_key("maximum")
|| map.contains_key("exclusiveMinimum")
|| map.contains_key("exclusiveMaximum")
|| map.contains_key("multipleOf")
{
schema_types.push(JsonSchemaPrimitiveType::Number);
} else {
schema_types.push(JsonSchemaPrimitiveType::String);
}
}
write_schema_types(map, &schema_types);
ensure_default_children_for_schema_types(map, &schema_types);
}
_ => {}
}
}
fn ensure_default_children_for_schema_types(
map: &mut serde_json::Map<String, JsonValue>,
schema_types: &[JsonSchemaPrimitiveType],
) {
if schema_types.contains(&JsonSchemaPrimitiveType::Object) && !map.contains_key("properties") {
map.insert(
"properties".to_string(),
JsonValue::Object(serde_json::Map::new()),
);
}
if schema_types.contains(&JsonSchemaPrimitiveType::Array) && !map.contains_key("items") {
map.insert("items".to_string(), json!({ "type": "string" }));
}
}
fn normalized_schema_types(
map: &serde_json::Map<String, JsonValue>,
) -> Vec<JsonSchemaPrimitiveType> {
let Some(schema_type) = map.get("type") else {
return Vec::new();
};
match schema_type {
JsonValue::String(schema_type) => schema_type_from_str(schema_type).into_iter().collect(),
JsonValue::Array(schema_types) => schema_types
.iter()
.filter_map(JsonValue::as_str)
.filter_map(schema_type_from_str)
.collect(),
_ => Vec::new(),
}
}
fn write_schema_types(
map: &mut serde_json::Map<String, JsonValue>,
schema_types: &[JsonSchemaPrimitiveType],
) {
match schema_types {
[] => {
map.remove("type");
}
[schema_type] => {
map.insert(
"type".to_string(),
JsonValue::String(schema_type_name(*schema_type).to_string()),
);
}
_ => {
map.insert(
"type".to_string(),
JsonValue::Array(
schema_types
.iter()
.map(|schema_type| {
JsonValue::String(schema_type_name(*schema_type).to_string())
})
.collect(),
),
);
}
}
}
fn schema_type_from_str(schema_type: &str) -> Option<JsonSchemaPrimitiveType> {
match schema_type {
"string" => Some(JsonSchemaPrimitiveType::String),
"number" => Some(JsonSchemaPrimitiveType::Number),
"boolean" => Some(JsonSchemaPrimitiveType::Boolean),
"integer" => Some(JsonSchemaPrimitiveType::Integer),
"object" => Some(JsonSchemaPrimitiveType::Object),
"array" => Some(JsonSchemaPrimitiveType::Array),
"null" => Some(JsonSchemaPrimitiveType::Null),
_ => None,
}
}
fn schema_type_name(schema_type: JsonSchemaPrimitiveType) -> &'static str {
match schema_type {
JsonSchemaPrimitiveType::String => "string",
JsonSchemaPrimitiveType::Number => "number",
JsonSchemaPrimitiveType::Boolean => "boolean",
JsonSchemaPrimitiveType::Integer => "integer",
JsonSchemaPrimitiveType::Object => "object",
JsonSchemaPrimitiveType::Array => "array",
JsonSchemaPrimitiveType::Null => "null",
}
}
fn singleton_null_schema_error() -> serde_json::Error {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"tool input schema must not be a singleton null type",
))
}
#[cfg(test)]
#[path = "json_schema_tests.rs"]
mod tests;
+786
View File
@@ -0,0 +1,786 @@
use super::AdditionalProperties;
use super::JsonSchema;
use super::JsonSchemaPrimitiveType;
use super::JsonSchemaType;
use super::parse_tool_input_schema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
// Tests in this section exercise normalization transforms that mutate badly
// formed JSON for consumption by the Responses API.
#[test]
fn parse_tool_input_schema_coerces_boolean_schemas() {
// Example schema shape:
// true
//
// Expected normalization behavior:
// - JSON Schema boolean forms are coerced to `{ "type": "string" }`
// because the baseline enum model cannot represent boolean-schema
// semantics directly.
let schema = parse_tool_input_schema(&serde_json::json!(true)).expect("parse schema");
assert_eq!(schema, JsonSchema::string(/*description*/ None));
}
#[test]
fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() {
// Example schema shape:
// {
// "properties": {
// "query": { "description": "search query" }
// }
// }
//
// Expected normalization behavior:
// - `properties` implies an object schema when `type` is omitted.
// - The child property keeps its description and defaults to a string type.
let schema = parse_tool_input_schema(&serde_json::json!({
"properties": {
"query": {"description": "search query"}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(
BTreeMap::from([(
"query".to_string(),
JsonSchema::string(Some("search query".to_string())),
)]),
/*required*/ None,
/*additional_properties*/ None
)
);
}
#[test]
fn parse_tool_input_schema_preserves_integer_and_defaults_array_items() {
// Example schema shape:
// {
// "type": "object",
// "properties": {
// "page": { "type": "integer" },
// "tags": { "type": "array" }
// }
// }
//
// Expected normalization behavior:
// - `"integer"` is preserved distinctly from `"number"`.
// - Arrays missing `items` receive a permissive string `items` schema.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"page": {"type": "integer"},
"tags": {"type": "array"}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(
BTreeMap::from([
(
"page".to_string(),
JsonSchema::integer(/*description*/ None),
),
(
"tags".to_string(),
JsonSchema::array(
JsonSchema::string(/*description*/ None),
/*description*/ None,
)
),
]),
/*required*/ None,
/*additional_properties*/ None
)
);
}
#[test]
fn parse_tool_input_schema_sanitizes_additional_properties_schema() {
// Example schema shape:
// {
// "type": "object",
// "additionalProperties": {
// "required": ["value"],
// "properties": {
// "value": {
// "anyOf": [
// { "type": "string" },
// { "type": "number" }
// ]
// }
// }
// }
// }
//
// Expected normalization behavior:
// - `additionalProperties` schema objects are recursively sanitized.
// - The nested schema is normalized into the current object/anyOf form.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"additionalProperties": {
"required": ["value"],
"properties": {
"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}
}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
Some(AdditionalProperties::Schema(Box::new(JsonSchema::object(
BTreeMap::from([(
"value".to_string(),
JsonSchema::any_of(
vec![
JsonSchema::string(/*description*/ None),
JsonSchema::number(/*description*/ None),
],
/*description*/ None,
),
)]),
Some(vec!["value".to_string()]),
/*additional_properties*/ None,
))))
)
);
}
#[test]
fn parse_tool_input_schema_infers_object_shape_from_boolean_additional_properties_only() {
// Example schema shape:
// {
// "additionalProperties": false
// }
//
// Expected normalization behavior:
// - `additionalProperties` implies an object schema when `type` is omitted.
// - The boolean `additionalProperties` setting is preserved.
let schema = parse_tool_input_schema(&serde_json::json!({
"additionalProperties": false
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into()))
);
}
#[test]
fn parse_tool_input_schema_infers_number_from_numeric_keywords() {
// Example schema shape:
// {
// "minimum": 1
// }
//
// Expected normalization behavior:
// - Numeric constraint keywords imply a number schema when `type` is
// omitted.
let schema = parse_tool_input_schema(&serde_json::json!({
"minimum": 1
}))
.expect("parse schema");
assert_eq!(schema, JsonSchema::number(/*description*/ None));
}
#[test]
fn parse_tool_input_schema_infers_number_from_multiple_of() {
// Example schema shape:
// {
// "multipleOf": 5
// }
//
// Expected normalization behavior:
// - `multipleOf` follows the same numeric-keyword inference path as
// `minimum` / `maximum`.
let schema = parse_tool_input_schema(&serde_json::json!({
"multipleOf": 5
}))
.expect("parse schema");
assert_eq!(schema, JsonSchema::number(/*description*/ None));
}
#[test]
fn parse_tool_input_schema_infers_string_from_enum_const_and_format_keywords() {
// Example schema shapes:
// { "enum": ["fast", "safe"] }
// { "const": "file" }
// { "format": "date-time" }
//
// Expected normalization behavior:
// - `enum` and `const` normalize into explicit string-enum schemas.
// - `format` still falls back to a plain string schema.
let enum_schema = parse_tool_input_schema(&serde_json::json!({
"enum": ["fast", "safe"]
}))
.expect("parse enum schema");
let const_schema = parse_tool_input_schema(&serde_json::json!({
"const": "file"
}))
.expect("parse const schema");
let format_schema = parse_tool_input_schema(&serde_json::json!({
"format": "date-time"
}))
.expect("parse format schema");
assert_eq!(
enum_schema,
JsonSchema::string_enum(
vec![serde_json::json!("fast"), serde_json::json!("safe")],
/*description*/ None,
)
);
assert_eq!(
const_schema,
JsonSchema::string_enum(vec![serde_json::json!("file")], /*description*/ None)
);
assert_eq!(format_schema, JsonSchema::string(/*description*/ None));
}
#[test]
fn parse_tool_input_schema_defaults_empty_schema_to_string() {
// Example schema shape:
// {}
//
// Expected normalization behavior:
// - With no structural hints at all, the normalizer falls back to a
// permissive string schema.
let schema = parse_tool_input_schema(&serde_json::json!({})).expect("parse schema");
assert_eq!(schema, JsonSchema::string(/*description*/ None));
}
#[test]
fn parse_tool_input_schema_infers_array_from_prefix_items() {
// Example schema shape:
// {
// "prefixItems": [
// { "type": "string" }
// ]
// }
//
// Expected normalization behavior:
// - `prefixItems` implies an array schema when `type` is omitted.
// - The normalized result is stored as a regular array schema with string
// items.
let schema = parse_tool_input_schema(&serde_json::json!({
"prefixItems": [
{"type": "string"}
]
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::array(
JsonSchema::string(/*description*/ None),
/*description*/ None,
)
);
}
#[test]
fn parse_tool_input_schema_preserves_boolean_additional_properties_on_inferred_object() {
// Example schema shape:
// {
// "type": "object",
// "properties": {
// "metadata": {
// "additionalProperties": true
// }
// }
// }
//
// Expected normalization behavior:
// - The nested `metadata` schema is inferred to be an object because it has
// `additionalProperties`.
// - `additionalProperties: true` is preserved rather than rewritten.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"metadata": {
"additionalProperties": true
}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(
BTreeMap::from([(
"metadata".to_string(),
JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(true.into())),
)]),
/*required*/ None,
/*additional_properties*/ None
)
);
}
#[test]
fn parse_tool_input_schema_infers_object_shape_from_schema_additional_properties_only() {
// Example schema shape:
// {
// "additionalProperties": {
// "type": "string"
// }
// }
//
// Expected normalization behavior:
// - A schema-valued `additionalProperties` also implies an object schema
// when `type` is omitted.
// - The nested schema is preserved as the object's
// `additionalProperties` definition.
let schema = parse_tool_input_schema(&serde_json::json!({
"additionalProperties": {
"type": "string"
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
Some(JsonSchema::string(/*description*/ None).into())
)
);
}
#[test]
fn parse_tool_input_schema_rewrites_const_to_single_value_enum() {
// Example schema shape:
// {
// "const": "tagged"
// }
//
// Expected normalization behavior:
// - `const` is rewritten through the sanitizer's `map.remove("const")`
// path into an equivalent single-value string enum schema.
let schema = parse_tool_input_schema(&serde_json::json!({
"const": "tagged"
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::string_enum(vec![serde_json::json!("tagged")], /*description*/ None)
);
}
#[test]
fn parse_tool_input_schema_rejects_singleton_null_type() {
let err = parse_tool_input_schema(&serde_json::json!({
"type": "null"
}))
.expect_err("singleton null should be rejected");
assert!(
err.to_string()
.contains("tool input schema must not be a singleton null type"),
"unexpected error: {err}"
);
}
#[test]
fn parse_tool_input_schema_fills_default_properties_for_nullable_object_union() {
// Example schema shape:
// {
// "type": ["object", "null"]
// }
//
// Expected normalization behavior:
// - The full union is preserved.
// - Object members of the union still receive default `properties`.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": ["object", "null"]
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Multiple(vec![
JsonSchemaPrimitiveType::Object,
JsonSchemaPrimitiveType::Null,
])),
properties: Some(BTreeMap::new()),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_fills_default_items_for_nullable_array_union() {
// Example schema shape:
// {
// "type": ["array", "null"]
// }
//
// Expected normalization behavior:
// - The full union is preserved.
// - Array members of the union still receive default `items`.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": ["array", "null"]
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Multiple(vec![
JsonSchemaPrimitiveType::Array,
JsonSchemaPrimitiveType::Null,
])),
items: Some(Box::new(JsonSchema::string(/*description*/ None))),
..Default::default()
}
);
}
// Schemas that should be preserved for Responses API compatibility rather than
// being rewritten into a different shape.
#[test]
fn parse_tool_input_schema_preserves_nested_nullable_any_of_shape() {
// Example schema shape:
// {
// "type": "object",
// "properties": {
// "open": {
// "anyOf": [
// {
// "type": "array",
// "items": {
// "type": "object",
// "properties": {
// "ref_id": { "type": "string" },
// "lineno": { "anyOf": [{ "type": "integer" }, { "type": "null" }] }
// },
// "required": ["ref_id"],
// "additionalProperties": false
// }
// },
// { "type": "null" }
// ]
// }
// }
// }
//
// Expected normalization behavior:
// - Nested nullable `anyOf` shapes are preserved all the way down.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"open": {
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"ref_id": {"type": "string"},
"lineno": {"anyOf": [{"type": "integer"}, {"type": "null"}]}
},
"required": ["ref_id"],
"additionalProperties": false
}
},
{"type": "null"}
]
}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(
BTreeMap::from([(
"open".to_string(),
JsonSchema::any_of(
vec![
JsonSchema::array(
JsonSchema::object(
BTreeMap::from([
(
"lineno".to_string(),
JsonSchema::any_of(
vec![
JsonSchema::integer(/*description*/ None),
JsonSchema::null(/*description*/ None),
],
/*description*/ None,
),
),
(
"ref_id".to_string(),
JsonSchema::string(/*description*/ None),
),
]),
Some(vec!["ref_id".to_string()]),
Some(false.into()),
),
/*description*/ None,
),
JsonSchema::null(/*description*/ None),
],
/*description*/ None,
),
),]),
/*required*/ None,
/*additional_properties*/ None
)
);
}
#[test]
fn parse_tool_input_schema_preserves_nested_nullable_type_union() {
// Example schema shape:
// {
// "type": "object",
// "properties": {
// "nickname": {
// "type": ["string", "null"],
// "description": "Optional nickname"
// }
// },
// "required": ["nickname"],
// "additionalProperties": false
// }
//
// Expected normalization behavior:
// - The nested property keeps the explicit `["string", "null"]` union.
// - The object-level `required` and `additionalProperties: false` stay intact.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"nickname": {
"type": ["string", "null"],
"description": "Optional nickname"
}
},
"required": ["nickname"],
"additionalProperties": false
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(
BTreeMap::from([(
"nickname".to_string(),
JsonSchema {
schema_type: Some(JsonSchemaType::Multiple(vec![
JsonSchemaPrimitiveType::String,
JsonSchemaPrimitiveType::Null,
])),
description: Some("Optional nickname".to_string()),
..Default::default()
},
)]),
Some(vec!["nickname".to_string()]),
Some(false.into()),
)
);
}
#[test]
fn parse_tool_input_schema_preserves_nested_any_of_property() {
// Example schema shape:
// {
// "type": "object",
// "properties": {
// "query": {
// "anyOf": [
// { "type": "string" },
// { "type": "number" }
// ]
// }
// }
// }
//
// Expected normalization behavior:
// - The nested `anyOf` is preserved rather than flattened into a single
// fallback type.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"query": {
"anyOf": [
{ "type": "string" },
{ "type": "number" }
]
}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(
BTreeMap::from([(
"query".to_string(),
JsonSchema::any_of(
vec![
JsonSchema::string(/*description*/ None),
JsonSchema::number(/*description*/ None),
],
/*description*/ None,
),
)]),
/*required*/ None,
/*additional_properties*/ None
)
);
}
#[test]
fn parse_tool_input_schema_preserves_type_unions_without_rewriting_to_any_of() {
// Example schema shape:
// {
// "type": ["string", "null"],
// "description": "optional string"
// }
//
// Expected normalization behavior:
// - Explicit type unions are preserved as unions rather than rewritten to
// `anyOf`.
let schema = parse_tool_input_schema(&serde_json::json!({
"type": ["string", "null"],
"description": "optional string"
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Multiple(vec![
JsonSchemaPrimitiveType::String,
JsonSchemaPrimitiveType::Null,
])),
description: Some("optional string".to_string()),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_preserves_explicit_enum_type_union() {
// Example schema shape:
// {
// "type": ["string", "null"],
// "enum": ["short", "medium", "long"],
// "description": "optional response length"
// }
//
// Expected normalization behavior:
// - The explicit string/null union is preserved alongside the enum values.
let schema = super::parse_tool_input_schema(&serde_json::json!({
"type": ["string", "null"],
"enum": ["short", "medium", "long"],
"description": "optional response length"
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema {
schema_type: Some(JsonSchemaType::Multiple(vec![
JsonSchemaPrimitiveType::String,
JsonSchemaPrimitiveType::Null,
])),
description: Some("optional response length".to_string()),
enum_values: Some(vec![
serde_json::json!("short"),
serde_json::json!("medium"),
serde_json::json!("long"),
]),
..Default::default()
}
);
}
#[test]
fn parse_tool_input_schema_preserves_string_enum_constraints() {
// Example schema shape:
// {
// "type": "object",
// "properties": {
// "response_length": { "type": "enum", "enum": ["short", "medium", "long"] },
// "kind": { "type": "const", "const": "tagged" },
// "scope": { "type": "enum", "enum": ["one", "two"] }
// }
// }
//
// Expected normalization behavior:
// - Legacy `type: "enum"` and `type: "const"` inputs are normalized into
// the current string-enum representation.
let schema = super::parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"response_length": {
"type": "enum",
"enum": ["short", "medium", "long"]
},
"kind": {
"type": "const",
"const": "tagged"
},
"scope": {
"type": "enum",
"enum": ["one", "two"]
}
}
}))
.expect("parse schema");
assert_eq!(
schema,
JsonSchema::object(
BTreeMap::from([
(
"kind".to_string(),
JsonSchema::string_enum(
vec![serde_json::json!("tagged")],
/*description*/ None,
),
),
(
"response_length".to_string(),
JsonSchema::string_enum(
vec![
serde_json::json!("short"),
serde_json::json!("medium"),
serde_json::json!("long"),
],
/*description*/ None,
),
),
(
"scope".to_string(),
JsonSchema::string_enum(
vec![serde_json::json!("one"), serde_json::json!("two")],
/*description*/ None,
),
),
]),
/*required*/ None,
/*additional_properties*/ None
)
);
}
+9 -2
View File
@@ -4,11 +4,18 @@
mod bundle;
mod call;
mod error;
mod spec;
mod json_schema;
mod namespace;
pub use bundle::ToolBundle;
pub use bundle::ToolExecutor;
pub use bundle::ToolFuture;
pub use call::ToolCall;
pub use codex_protocol::ToolName;
pub use error::ToolError;
pub use spec::FunctionToolSpec;
pub use json_schema::AdditionalProperties;
pub use json_schema::JsonSchema;
pub use json_schema::JsonSchemaPrimitiveType;
pub use json_schema::JsonSchemaType;
pub use json_schema::parse_tool_input_schema;
pub use namespace::ToolNamespace;
+36
View File
@@ -0,0 +1,36 @@
/// Model-visible namespace metadata for a contributed tool bundle.
///
/// The namespace name participates in the callable tool identity, while the
/// optional description is host-visible metadata used when rendering namespace
/// specs for the model.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ToolNamespace {
name: String,
description: Option<String>,
}
impl ToolNamespace {
/// Creates namespace metadata with no explicit description.
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
}
}
/// Sets the model-visible namespace description.
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Returns the callable namespace name.
pub fn name(&self) -> &str {
self.name.as_str()
}
/// Returns the optional model-visible namespace description.
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
}
-10
View File
@@ -1,10 +0,0 @@
use serde_json::Value;
/// Model-visible definition for one contributed function tool.
#[derive(Debug, Clone, PartialEq)]
pub struct FunctionToolSpec {
pub name: String,
pub description: String,
pub strict: bool,
pub parameters: Value,
}