Compare commits

...

5 Commits

Author SHA1 Message Date
Vivian Fang
8e8d9913f3 Fix JSON schema anyOf object inference 2026-04-06 20:44:09 -07:00
Vivian Fang
ee6d7595d0 Refactor code mode schema description rendering 2026-04-06 20:23:30 -07:00
Vivian Fang
9377b99705 function desc 2026-04-06 20:23:30 -07:00
Vivian Fang
1bd6e39940 Support enum and anyOf in tool JsonSchema
Trim json schema parser coverage to representative cases

Simplify code mode schema rendering assertion

Stabilize JsonSchema follow-up fixes

Annotate JsonSchema helper call arguments

Union
2026-04-06 20:22:17 -07:00
Vivian Fang
8a22f8f7aa Preserve nested nullable MCP tool schemas in code mode 2026-04-06 20:22:17 -07:00
35 changed files with 1665 additions and 1600 deletions

View File

@@ -408,6 +408,45 @@ fn render_json_schema_array(map: &serde_json::Map<String, JsonValue>) -> String
"unknown[]".to_string()
}
fn append_additional_properties_line(
lines: &mut Vec<String>,
map: &serde_json::Map<String, JsonValue>,
properties: &serde_json::Map<String, JsonValue>,
line_prefix: &str,
) {
if let Some(additional_properties) = map.get("additionalProperties") {
let property_type = match additional_properties {
JsonValue::Bool(true) => Some("unknown".to_string()),
JsonValue::Bool(false) => None,
value => Some(render_json_schema_to_typescript_inner(value)),
};
if let Some(property_type) = property_type {
lines.push(format!("{line_prefix}[key: string]: {property_type};"));
}
} else if properties.is_empty() {
lines.push(format!("{line_prefix}[key: string]: unknown;"));
}
}
fn has_property_description(value: &JsonValue) -> bool {
value
.get("description")
.and_then(JsonValue::as_str)
.is_some_and(|description| !description.is_empty())
}
fn render_json_schema_object_property(name: &str, value: &JsonValue, required: &[&str]) -> String {
let optional = if required.iter().any(|required_name| required_name == &name) {
""
} else {
"?"
};
let property_name = render_json_schema_property_name(name);
let property_type = render_json_schema_to_typescript_inner(value);
format!("{property_name}{optional}: {property_type};")
}
fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String {
let required = map
.get("required")
@@ -427,33 +466,39 @@ fn render_json_schema_object(map: &serde_json::Map<String, JsonValue>) -> String
let mut sorted_properties = properties.iter().collect::<Vec<_>>();
sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
if sorted_properties
.iter()
.any(|(_, value)| has_property_description(value))
{
let mut lines = vec!["{".to_string()];
for (name, value) in sorted_properties {
if let Some(description) = value.get("description").and_then(JsonValue::as_str) {
for description_line in description
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
lines.push(format!(" // {description_line}"));
}
}
lines.push(format!(
" {}",
render_json_schema_object_property(name, value, &required)
));
}
append_additional_properties_line(&mut lines, map, &properties, " ");
lines.push("}".to_string());
return lines.join("\n");
}
let mut lines = sorted_properties
.into_iter()
.map(|(name, value)| {
let optional = if required.iter().any(|required_name| required_name == name) {
""
} else {
"?"
};
let property_name = render_json_schema_property_name(name);
let property_type = render_json_schema_to_typescript_inner(value);
format!("{property_name}{optional}: {property_type};")
})
.map(|(name, value)| render_json_schema_object_property(name, value, &required))
.collect::<Vec<_>>();
if let Some(additional_properties) = map.get("additionalProperties") {
let property_type = match additional_properties {
JsonValue::Bool(true) => Some("unknown".to_string()),
JsonValue::Bool(false) => None,
value => Some(render_json_schema_to_typescript_inner(value)),
};
if let Some(property_type) = property_type {
lines.push(format!("[key: string]: {property_type};"));
}
} else if properties.is_empty() {
lines.push("[key: string]: unknown;".to_string());
}
append_additional_properties_line(&mut lines, map, &properties, "");
if lines.is_empty() {
return "{}".to_string();
@@ -550,6 +595,53 @@ mod tests {
);
}
#[test]
fn augment_tool_definition_includes_property_descriptions_as_comments() {
let definition = ToolDefinition {
name: "weather_tool".to_string(),
description: "Weather tool".to_string(),
kind: CodeModeToolKind::Function,
input_schema: Some(json!({
"type": "object",
"properties": {
"weather": {
"type": "array",
"description": "look up weather for a given list of locations",
"items": {
"type": "object",
"properties": {
"location": { "type": "string" }
},
"required": ["location"]
}
}
},
"required": ["weather"]
})),
output_schema: Some(json!({
"type": "object",
"properties": {
"forecast": {
"type": "string",
"description": "human readable weather forecast"
}
},
"required": ["forecast"]
})),
};
let description = augment_tool_definition(definition).description;
assert!(description.contains(
r#"weather_tool(args: {
// look up weather for a given list of locations
weather: Array<{ location: string; }>;
}): Promise<{
// human readable weather forecast
forecast: string;
}>;"#
));
}
#[test]
fn code_mode_only_description_includes_nested_tools() {
let description = build_exec_tool_description(

View File

@@ -147,11 +147,11 @@ fn tool_search_payloads_roundtrip_as_tool_search_outputs() {
description: String::new(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
parameters: codex_tools::JsonSchema::object(
/*properties*/ Default::default(),
/*required*/ None,
/*additional_properties*/ None,
),
output_schema: None,
},
)],

View File

@@ -832,16 +832,15 @@ fn test_mcp_tool_property_missing_type_defaults_to_string() {
tool.spec,
ToolSpec::Function(ResponsesApiTool {
name: "dash/search".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(
/*properties*/
BTreeMap::from([(
"query".to_string(),
JsonSchema::String {
description: Some("search query".to_string())
}
JsonSchema::string(Some("search query".to_string())),
)]),
required: None,
additional_properties: None,
},
/*required*/ None,
/*additional_properties*/ None
),
description: "Search docs".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
@@ -890,14 +889,12 @@ fn test_mcp_tool_integer_normalized_to_number() {
tool.spec,
ToolSpec::Function(ResponsesApiTool {
name: "dash/paginate".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"page".to_string(),
JsonSchema::Number { description: None }
)]),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
/*properties*/
BTreeMap::from([("page".to_string(), JsonSchema::number(/*description*/ None),)]),
/*required*/ None,
/*additional_properties*/ None
),
description: "Pagination".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
@@ -947,17 +944,18 @@ fn test_mcp_tool_array_without_items_gets_default_string_items() {
tool.spec,
ToolSpec::Function(ResponsesApiTool {
name: "dash/tags".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(
/*properties*/
BTreeMap::from([(
"tags".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: None
}
JsonSchema::array(
JsonSchema::string(/*description*/ None),
/*description*/ None,
),
)]),
required: None,
additional_properties: None,
},
/*required*/ None,
/*additional_properties*/ None
),
description: "Tags".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
@@ -1008,14 +1006,21 @@ fn test_mcp_tool_anyof_defaults_to_string() {
tool.spec,
ToolSpec::Function(ResponsesApiTool {
name: "dash/value".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(
/*properties*/
BTreeMap::from([(
"value".to_string(),
JsonSchema::String { description: None }
JsonSchema::any_of(
vec![
JsonSchema::string(/*description*/ None),
JsonSchema::number(/*description*/ None),
],
/*description*/ None,
),
)]),
required: None,
additional_properties: None,
},
/*required*/ None,
/*additional_properties*/ None
),
description: "AnyOf Value".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
@@ -1082,50 +1087,51 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
tool.spec,
ToolSpec::Function(ResponsesApiTool {
name: "test_server/do_something_cool".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(
/*properties*/
BTreeMap::from([
(
"string_argument".to_string(),
JsonSchema::String { description: None }
JsonSchema::string(/*description*/ None),
),
(
"number_argument".to_string(),
JsonSchema::Number { description: None }
JsonSchema::number(/*description*/ None),
),
(
"object_argument".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
JsonSchema::object(
BTreeMap::from([
(
"string_property".to_string(),
JsonSchema::String { description: None }
JsonSchema::string(/*description*/ None),
),
(
"number_property".to_string(),
JsonSchema::Number { description: None }
JsonSchema::number(/*description*/ None),
),
]),
required: Some(vec![
Some(vec![
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(
JsonSchema::Object {
properties: BTreeMap::from([(
Some(
JsonSchema::object(
BTreeMap::from([(
"addtl_prop".to_string(),
JsonSchema::String { description: None }
),]),
required: Some(vec!["addtl_prop".to_string(),]),
additional_properties: Some(false.into()),
}
.into()
JsonSchema::string(/*description*/ None),
)]),
Some(vec!["addtl_prop".to_string()]),
Some(false.into()),
)
.into(),
),
},
),
),
]),
required: None,
additional_properties: None,
},
/*required*/ None,
/*additional_properties*/ None
),
description: "Do something cool".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),

View File

@@ -7,64 +7,48 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"csv_path".to_string(),
JsonSchema::String {
description: Some("Path to the CSV file containing input rows.".to_string()),
},
JsonSchema::string(Some("Path to the CSV file containing input rows.".to_string())),
),
(
"instruction".to_string(),
JsonSchema::String {
description: Some(
"Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row."
.to_string(),
),
},
JsonSchema::string(Some(
"Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row."
.to_string(),
)),
),
(
"id_column".to_string(),
JsonSchema::String {
description: Some("Optional column name to use as stable item id.".to_string()),
},
JsonSchema::string(Some(
"Optional column name to use as stable item id.".to_string(),
)),
),
(
"output_csv_path".to_string(),
JsonSchema::String {
description: Some("Optional output CSV path for exported results.".to_string()),
},
JsonSchema::string(Some("Optional output CSV path for exported results.".to_string())),
),
(
"max_concurrency".to_string(),
JsonSchema::Number {
description: Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
),
},
JsonSchema::number(Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
)),
),
(
"max_workers".to_string(),
JsonSchema::Number {
description: Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
),
},
JsonSchema::number(Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
)),
),
(
"max_runtime_seconds".to_string(),
JsonSchema::Number {
description: Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
),
},
JsonSchema::number(Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
)),
),
(
"output_schema".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
JsonSchema::object(BTreeMap::new(), /*required*/ None, /*additional_properties*/ None),
),
]);
@@ -74,11 +58,7 @@ pub fn create_spawn_agents_on_csv_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, Some(vec!["csv_path".to_string(), "instruction".to_string()]), Some(false.into())),
output_schema: None,
})
}
@@ -87,32 +67,22 @@ pub fn create_report_agent_job_result_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"job_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job.".to_string()),
},
JsonSchema::string(Some("Identifier of the job.".to_string())),
),
(
"item_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job item.".to_string()),
},
JsonSchema::string(Some("Identifier of the job item.".to_string())),
),
(
"result".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
JsonSchema::object(BTreeMap::new(), /*required*/ None, /*additional_properties*/ None),
),
(
"stop".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
),
},
JsonSchema::boolean(Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
)),
),
]);
@@ -123,15 +93,11 @@ pub fn create_report_agent_job_result_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec![
parameters: JsonSchema::object(properties, Some(vec![
"job_id".to_string(),
"item_id".to_string(),
"result".to_string(),
]),
additional_properties: Some(false.into()),
},
]), Some(false.into())),
output_schema: None,
})
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -12,73 +13,61 @@ fn spawn_agents_on_csv_tool_requires_csv_and_instruction() {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"csv_path".to_string(),
JsonSchema::String {
description: Some("Path to the CSV file containing input rows.".to_string()),
},
JsonSchema::string(Some(
"Path to the CSV file containing input rows.".to_string(),
)),
),
(
"instruction".to_string(),
JsonSchema::String {
description: Some(
"Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row."
.to_string(),
),
},
JsonSchema::string(Some(
"Instruction template to apply to each CSV row. Use {column_name} placeholders to inject values from the row."
.to_string(),
)),
),
(
"id_column".to_string(),
JsonSchema::String {
description: Some("Optional column name to use as stable item id.".to_string()),
},
JsonSchema::string(Some(
"Optional column name to use as stable item id.".to_string(),
)),
),
(
"output_csv_path".to_string(),
JsonSchema::String {
description: Some("Optional output CSV path for exported results.".to_string()),
},
JsonSchema::string(Some(
"Optional output CSV path for exported results.".to_string(),
)),
),
(
"max_concurrency".to_string(),
JsonSchema::Number {
description: Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
),
},
JsonSchema::number(Some(
"Maximum concurrent workers for this job. Defaults to 16 and is capped by config."
.to_string(),
)),
),
(
"max_workers".to_string(),
JsonSchema::Number {
description: Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
),
},
JsonSchema::number(Some(
"Alias for max_concurrency. Set to 1 to run sequentially.".to_string(),
)),
),
(
"max_runtime_seconds".to_string(),
JsonSchema::Number {
description: Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
),
},
JsonSchema::number(Some(
"Maximum runtime per worker before it is failed. Defaults to 1800 seconds."
.to_string(),
)),
),
(
"output_schema".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
),
]),
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
additional_properties: Some(false.into()),
},
]), Some(vec!["csv_path".to_string(), "instruction".to_string()]), Some(false.into())),
output_schema: None,
})
);
@@ -95,45 +84,35 @@ fn report_agent_job_result_tool_requires_result_payload() {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"job_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job.".to_string()),
},
JsonSchema::string(Some("Identifier of the job.".to_string())),
),
(
"item_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the job item.".to_string()),
},
JsonSchema::string(Some("Identifier of the job item.".to_string())),
),
(
"result".to_string(),
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
),
(
"stop".to_string(),
JsonSchema::Boolean {
description: Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
),
},
JsonSchema::boolean(Some(
"Optional. When true, cancels the remaining job items after this result is recorded."
.to_string(),
)),
),
]),
required: Some(vec![
]), Some(vec![
"job_id".to_string(),
"item_id".to_string(),
"result".to_string(),
]),
additional_properties: Some(false.into()),
},
]), Some(false.into())),
output_schema: None,
})
);

View File

@@ -33,11 +33,7 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions<'_>) -> ToolSpe
),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: Some(spawn_agent_output_schema_v1()),
})
}
@@ -48,12 +44,10 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpe
let mut properties = spawn_agent_common_properties_v2(&options.agent_type_description);
properties.insert(
"task_name".to_string(),
JsonSchema::String {
description: Some(
"Task name for the new agent. Use lowercase letters, digits, and underscores."
.to_string(),
),
},
JsonSchema::string(Some(
"Task name for the new agent. Use lowercase letters, digits, and underscores."
.to_string(),
)),
);
ToolSpec::Function(ResponsesApiTool {
@@ -64,11 +58,11 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions<'_>) -> ToolSpe
),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["task_name".to_string(), "message".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["task_name".to_string(), "message".to_string()]),
Some(false.into()),
),
output_schema: Some(spawn_agent_output_schema_v2()),
})
}
@@ -77,28 +71,22 @@ pub fn create_send_input_tool_v1() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some("Agent id to message (from spawn_agent).".to_string()),
},
JsonSchema::string(Some("Agent id to message (from spawn_agent).".to_string())),
),
(
"message".to_string(),
JsonSchema::String {
description: Some(
"Legacy plain-text message to send to the agent. Use either message or items."
.to_string(),
),
},
JsonSchema::string(Some(
"Legacy plain-text message to send to the agent. Use either message or items."
.to_string(),
)),
),
("items".to_string(), create_collab_input_items_schema()),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
JsonSchema::boolean(Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
)),
),
]);
@@ -108,11 +96,7 @@ pub fn create_send_input_tool_v1() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
output_schema: Some(send_input_output_schema()),
})
}
@@ -121,17 +105,15 @@ pub fn create_send_message_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
JsonSchema::string(Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
)),
),
(
"message".to_string(),
JsonSchema::String {
description: Some("Message text to queue on the target agent.".to_string()),
},
JsonSchema::string(Some(
"Message text to queue on the target agent.".to_string(),
)),
),
]);
@@ -141,11 +123,7 @@ pub fn create_send_message_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "message".to_string()]),
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, Some(vec!["target".to_string(), "message".to_string()]), Some(false.into())),
output_schema: Some(send_input_output_schema()),
})
}
@@ -154,26 +132,22 @@ pub fn create_followup_task_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
),
},
JsonSchema::string(Some(
"Agent id or canonical task name to message (from spawn_agent).".to_string(),
)),
),
(
"message".to_string(),
JsonSchema::String {
description: Some("Message text to send to the target agent.".to_string()),
},
JsonSchema::string(Some(
"Message text to send to the target agent.".to_string(),
)),
),
(
"interrupt".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
),
},
JsonSchema::boolean(Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message."
.to_string(),
)),
),
]);
@@ -183,11 +157,7 @@ pub fn create_followup_task_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string(), "message".to_string()]),
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, Some(vec!["target".to_string(), "message".to_string()]), Some(false.into())),
output_schema: Some(send_input_output_schema()),
})
}
@@ -195,9 +165,7 @@ pub fn create_followup_task_tool() -> ToolSpec {
pub fn create_resume_agent_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"id".to_string(),
JsonSchema::String {
description: Some("Agent id to resume.".to_string()),
},
JsonSchema::string(Some("Agent id to resume.".to_string())),
)]);
ToolSpec::Function(ResponsesApiTool {
@@ -207,11 +175,7 @@ pub fn create_resume_agent_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["id".to_string()]),
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, Some(vec!["id".to_string()]), Some(false.into())),
output_schema: Some(resume_agent_output_schema()),
})
}
@@ -243,12 +207,10 @@ pub fn create_wait_agent_tool_v2(options: WaitAgentTimeoutOptions) -> ToolSpec {
pub fn create_list_agents_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"path_prefix".to_string(),
JsonSchema::String {
description: Some(
"Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets."
.to_string(),
)),
)]);
ToolSpec::Function(ResponsesApiTool {
@@ -258,11 +220,7 @@ pub fn create_list_agents_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: Some(list_agents_output_schema()),
})
}
@@ -270,9 +228,7 @@ pub fn create_list_agents_tool() -> ToolSpec {
pub fn create_close_agent_tool_v1() -> ToolSpec {
let properties = BTreeMap::from([(
"target".to_string(),
JsonSchema::String {
description: Some("Agent id to close (from spawn_agent).".to_string()),
},
JsonSchema::string(Some("Agent id to close (from spawn_agent).".to_string())),
)]);
ToolSpec::Function(ResponsesApiTool {
@@ -280,11 +236,7 @@ pub fn create_close_agent_tool_v1() -> ToolSpec {
description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
output_schema: Some(close_agent_output_schema()),
})
}
@@ -292,11 +244,9 @@ pub fn create_close_agent_tool_v1() -> ToolSpec {
pub fn create_close_agent_tool_v2() -> ToolSpec {
let properties = BTreeMap::from([(
"target".to_string(),
JsonSchema::String {
description: Some(
"Agent id or canonical task name to close (from spawn_agent).".to_string(),
),
},
JsonSchema::string(Some(
"Agent id or canonical task name to close (from spawn_agent).".to_string(),
)),
)]);
ToolSpec::Function(ResponsesApiTool {
@@ -304,11 +254,7 @@ pub fn create_close_agent_tool_v2() -> ToolSpec {
description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Don't keep agents open for too long if they are not needed anymore.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["target".to_string()]),
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
output_schema: Some(close_agent_output_schema()),
})
}
@@ -497,98 +443,71 @@ fn create_collab_input_items_schema() -> JsonSchema {
let properties = BTreeMap::from([
(
"type".to_string(),
JsonSchema::String {
description: Some(
"Input item type: text, image, local_image, skill, or mention.".to_string(),
),
},
JsonSchema::string(Some(
"Input item type: text, image, local_image, skill, or mention.".to_string(),
)),
),
(
"text".to_string(),
JsonSchema::String {
description: Some("Text content when type is text.".to_string()),
},
JsonSchema::string(Some("Text content when type is text.".to_string())),
),
(
"image_url".to_string(),
JsonSchema::String {
description: Some("Image URL when type is image.".to_string()),
},
JsonSchema::string(Some("Image URL when type is image.".to_string())),
),
(
"path".to_string(),
JsonSchema::String {
description: Some(
"Path when type is local_image/skill, or structured mention target such as app://<connector-id> or plugin://<plugin-name>@<marketplace-name> when type is mention."
.to_string(),
),
},
JsonSchema::string(Some(
"Path when type is local_image/skill, or structured mention target such as app://<connector-id> or plugin://<plugin-name>@<marketplace-name> when type is mention."
.to_string(),
)),
),
(
"name".to_string(),
JsonSchema::String {
description: Some("Display name when type is skill or mention.".to_string()),
},
JsonSchema::string(Some("Display name when type is skill or mention.".to_string())),
),
]);
JsonSchema::Array {
items: Box::new(JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
}),
description: Some(
JsonSchema::array(JsonSchema::object(properties, /*required*/ None, Some(false.into())), Some(
"Structured input items. Use this to pass explicit mentions (for example app:// connector paths)."
.to_string(),
),
}
))
}
fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
BTreeMap::from([
(
"message".to_string(),
JsonSchema::String {
description: Some(
"Initial plain-text task for the new agent. Use either message or items."
.to_string(),
),
},
JsonSchema::string(Some(
"Initial plain-text task for the new agent. Use either message or items."
.to_string(),
)),
),
("items".to_string(), create_collab_input_items_schema()),
(
"agent_type".to_string(),
JsonSchema::String {
description: Some(agent_type_description.to_string()),
},
JsonSchema::string(Some(agent_type_description.to_string())),
),
(
"fork_context".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, fork the current thread history into the new agent before sending the initial prompt. This must be used when you want the new agent to have exactly the same context as you."
.to_string(),
),
},
JsonSchema::boolean(Some(
"When true, fork the current thread history into the new agent before sending the initial prompt. This must be used when you want the new agent to have exactly the same context as you."
.to_string(),
)),
),
(
"model".to_string(),
JsonSchema::String {
description: Some(
"Optional model override for the new agent. Replaces the inherited model."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional model override for the new agent. Replaces the inherited model."
.to_string(),
)),
),
(
"reasoning_effort".to_string(),
JsonSchema::String {
description: Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
)),
),
])
}
@@ -597,42 +516,32 @@ fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<St
BTreeMap::from([
(
"message".to_string(),
JsonSchema::String {
description: Some("Initial plain-text task for the new agent.".to_string()),
},
JsonSchema::string(Some("Initial plain-text task for the new agent.".to_string())),
),
(
"agent_type".to_string(),
JsonSchema::String {
description: Some(agent_type_description.to_string()),
},
JsonSchema::string(Some(agent_type_description.to_string())),
),
(
"fork_turns".to_string(),
JsonSchema::String {
description: Some(
"Optional MultiAgentV2 fork mode. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional MultiAgentV2 fork mode. Use `none`, `all`, or a positive integer string such as `3` to fork only the most recent turns."
.to_string(),
)),
),
(
"model".to_string(),
JsonSchema::String {
description: Some(
"Optional model override for the new agent. Replaces the inherited model."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional model override for the new agent. Replaces the inherited model."
.to_string(),
)),
),
(
"reasoning_effort".to_string(),
JsonSchema::String {
description: Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
.to_string(),
)),
),
])
}
@@ -713,48 +622,40 @@ fn wait_agent_tool_parameters_v1(options: WaitAgentTimeoutOptions) -> JsonSchema
let properties = BTreeMap::from([
(
"targets".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some(
"Agent ids to wait on. Pass multiple ids to wait for whichever finishes first."
.to_string(),
),
},
),
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
)),
},
JsonSchema::number(Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
))),
),
]);
JsonSchema::Object {
JsonSchema::object(
properties,
required: Some(vec!["targets".to_string()]),
additional_properties: Some(false.into()),
}
Some(vec!["targets".to_string()]),
Some(false.into()),
)
}
fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema {
let properties = BTreeMap::from([(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
)),
},
JsonSchema::number(Some(format!(
"Optional timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
))),
)]);
JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
}
JsonSchema::object(properties, /*required*/ None, Some(false.into()))
}
#[cfg(test)]

View File

@@ -1,4 +1,6 @@
use super::*;
use crate::JsonSchemaPrimitiveType;
use crate::JsonSchemaType;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
@@ -45,14 +47,14 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
else {
panic!("spawn_agent should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("spawn_agent should use object params");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("spawn_agent should use object params");
assert!(description.contains("visible display (`visible-model`)"));
assert!(!description.contains("hidden display (`hidden-model`)"));
assert!(properties.contains_key("task_name"));
@@ -62,13 +64,11 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() {
assert!(!properties.contains_key("fork_context"));
assert_eq!(
properties.get("agent_type"),
Some(&JsonSchema::String {
description: Some("role help".to_string()),
})
Some(&JsonSchema::string(Some("role help".to_string())))
);
assert_eq!(
required,
Some(vec!["task_name".to_string(), "message".to_string()])
parameters.required.as_ref(),
Some(&vec!["task_name".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("spawn_agent output schema")["required"],
@@ -86,9 +86,14 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
panic!("spawn_agent should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("spawn_agent should use object params");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("spawn_agent should use object params");
assert!(properties.contains_key("fork_context"));
assert!(!properties.contains_key("fork_turns"));
@@ -104,21 +109,21 @@ fn send_message_tool_requires_message_and_uses_submission_output() {
else {
panic!("send_message should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("send_message should use object params");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("send_message should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
required,
Some(vec!["target".to_string(), "message".to_string()])
parameters.required.as_ref(),
Some(&vec!["target".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("send_message output schema")["required"],
@@ -136,21 +141,21 @@ fn followup_task_tool_requires_message_and_uses_submission_output() {
else {
panic!("followup_task should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("followup_task should use object params");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("followup_task should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
required,
Some(vec!["target".to_string(), "message".to_string()])
parameters.required.as_ref(),
Some(&vec!["target".to_string(), "message".to_string()])
);
assert_eq!(
output_schema.expect("followup_task output schema")["required"],
@@ -172,17 +177,17 @@ fn wait_agent_tool_v2_uses_timeout_only_summary_output() {
else {
panic!("wait_agent should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("wait_agent should use object params");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("wait_agent should use object params");
assert!(!properties.contains_key("targets"));
assert!(properties.contains_key("timeout_ms"));
assert_eq!(required, None);
assert_eq!(parameters.required.as_ref(), None);
assert_eq!(
output_schema.expect("wait output schema")["properties"]["message"]["description"],
json!("Brief wait summary without the agent's final content.")
@@ -199,9 +204,14 @@ fn list_agents_tool_includes_path_prefix_and_agent_fields() {
else {
panic!("list_agents should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("list_agents should use object params");
};
assert_eq!(
parameters.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = parameters
.properties
.as_ref()
.expect("list_agents should use object params");
assert!(properties.contains_key("path_prefix"));
assert_eq!(
output_schema.expect("list_agents output schema")["properties"]["agents"]["items"]["required"],

View File

@@ -102,9 +102,9 @@ pub fn create_apply_patch_freeform_tool() -> ToolSpec {
pub fn create_apply_patch_json_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"input".to_string(),
JsonSchema::String {
description: Some("The entire contents of the apply_patch command".to_string()),
},
JsonSchema::string(Some(
"The entire contents of the apply_patch command".to_string(),
)),
)]);
ToolSpec::Function(ResponsesApiTool {
@@ -112,11 +112,11 @@ pub fn create_apply_patch_json_tool() -> ToolSpec {
description: APPLY_PATCH_JSON_TOOL_DESCRIPTION.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["input".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["input".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -29,18 +30,16 @@ fn create_apply_patch_json_tool_matches_expected_spec() {
description: APPLY_PATCH_JSON_TOOL_DESCRIPTION.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(
BTreeMap::from([(
"input".to_string(),
JsonSchema::String {
description: Some(
"The entire contents of the apply_patch command".to_string(),
),
},
JsonSchema::string(Some(
"The entire contents of the apply_patch command".to_string(),
),),
)]),
required: Some(vec!["input".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["input".to_string()]),
Some(false.into())
),
output_schema: None,
})
);

View File

@@ -53,32 +53,26 @@ pub fn create_wait_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"cell_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the running exec cell.".to_string()),
},
JsonSchema::string(Some("Identifier of the running exec cell.".to_string())),
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for more output before yielding again."
.to_string(),
),
},
JsonSchema::number(Some(
"How long to wait (in milliseconds) for more output before yielding again."
.to_string(),
)),
),
(
"max_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of output tokens to return for this wait call.".to_string(),
),
},
JsonSchema::number(Some(
"Maximum number of output tokens to return for this wait call.".to_string(),
)),
),
(
"terminate".to_string(),
JsonSchema::Boolean {
description: Some("Whether to terminate the running exec cell.".to_string()),
},
JsonSchema::boolean(Some(
"Whether to terminate the running exec cell.".to_string(),
)),
),
]);
@@ -90,11 +84,11 @@ pub fn create_wait_tool() -> ToolSpec {
codex_code_mode::build_wait_tool_description().trim()
),
strict: false,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["cell_id".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["cell_id".to_string()]),
Some(false.into()),
),
output_schema: None,
defer_loading: None,
})

View File

@@ -20,14 +20,10 @@ fn augment_tool_spec_for_code_mode_augments_function_tools() {
description: "Look up an order".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(AdditionalProperties::Boolean(false)),
},
JsonSchema::string(/*description*/ None),
)]), Some(vec!["order_id".to_string()]), Some(AdditionalProperties::Boolean(false))),
output_schema: Some(json!({
"type": "object",
"properties": {
@@ -41,14 +37,10 @@ fn augment_tool_spec_for_code_mode_augments_function_tools() {
description: "Look up an order\n\nexec tool declaration:\n```ts\ndeclare const tools: { lookup_order(args: { order_id: string; }): Promise<{ ok: boolean; }>; };\n```".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(AdditionalProperties::Boolean(false)),
},
JsonSchema::string(/*description*/ None),
)]), Some(vec!["order_id".to_string()]), Some(AdditionalProperties::Boolean(false))),
output_schema: Some(json!({
"type": "object",
"properties": {
@@ -114,11 +106,11 @@ fn tool_spec_to_code_mode_tool_definition_skips_unsupported_variants() {
tool_spec_to_code_mode_tool_definition(&ToolSpec::ToolSearch {
execution: "sync".to_string(),
description: "Search".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
}),
None
);
@@ -137,44 +129,32 @@ fn create_wait_tool_matches_expected_spec() {
),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"cell_id".to_string(),
JsonSchema::String {
description: Some("Identifier of the running exec cell.".to_string()),
},
JsonSchema::string(Some("Identifier of the running exec cell.".to_string()),),
),
(
"max_tokens".to_string(),
JsonSchema::Number {
description: Some(
JsonSchema::number(Some(
"Maximum number of output tokens to return for this wait call."
.to_string(),
),
},
),),
),
(
"terminate".to_string(),
JsonSchema::Boolean {
description: Some(
JsonSchema::boolean(Some(
"Whether to terminate the running exec cell.".to_string(),
),
},
),),
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
JsonSchema::number(Some(
"How long to wait (in milliseconds) for more output before yielding again."
.to_string(),
),
},
),),
),
]),
required: Some(vec!["cell_id".to_string()]),
additional_properties: Some(false.into()),
},
]), Some(vec!["cell_id".to_string()]), Some(false.into())),
output_schema: None,
})
);

View File

@@ -25,16 +25,14 @@ fn parse_dynamic_tool_sanitizes_input_schema() {
ToolDefinition {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::from([(
input_schema: JsonSchema::object(
BTreeMap::from([(
"id".to_string(),
JsonSchema::String {
description: Some("Ticket identifier".to_string()),
},
JsonSchema::string(Some("Ticket identifier".to_string()),),
)]),
required: None,
additional_properties: None,
},
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
defer_loading: false,
}
@@ -58,11 +56,11 @@ fn parse_dynamic_tool_preserves_defer_loading() {
ToolDefinition {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
defer_loading: true,
}

View File

@@ -45,11 +45,7 @@ pub fn create_js_repl_reset_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())),
output_schema: None,
})
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::JsonSchema;
use crate::ToolSpec;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -29,11 +30,11 @@ fn js_repl_reset_tool_matches_expected_spec() {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
Some(false.into())
),
output_schema: None,
})
);

View File

@@ -4,40 +4,132 @@ use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
/// Generic JSON-Schema subset needed for our tool definitions.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum JsonSchema {
Boolean {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
String {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// MCP schema allows "number" | "integer" for Number.
#[serde(alias = "integer")]
Number {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
Array {
items: Box<JsonSchema>,
/// Primitive JSON Schema type names we support in tool definitions.
///
/// This mirrors the OpenAI Structured Outputs "Supported types" subset:
/// string, number, boolean, integer, object, array, enum, and anyOf.
/// 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,
}
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
Object {
/// JSON Schema `type` supports either a single type name or a union of names.
///
/// OpenAI Structured Outputs allows `anyOf`, while the root schema must still
/// be an object. Nested unions can be represented either through `anyOf` or a
/// multi-valued `type`.
#[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 enumeration(values: Vec<JsonValue>, description: Option<String>) -> Self {
Self {
schema_type: infer_enum_type(Some(&values)).map(JsonSchemaType::Single),
description,
enum_values: Some(values),
..Default::default()
}
}
pub fn string_enum(values: Vec<JsonValue>, description: Option<String>) -> Self {
Self::enumeration(values, description)
}
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>,
#[serde(skip_serializing_if = "Option::is_none")]
required: Option<Vec<String>>,
#[serde(
rename = "additionalProperties",
skip_serializing_if = "Option::is_none"
)]
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.
@@ -64,14 +156,14 @@ impl From<JsonSchema> for AdditionalProperties {
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);
serde_json::from_value::<JsonSchema>(input_schema)
serde_json::from_value(input_schema)
}
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
/// JsonSchema enum. This function:
/// - Ensures every schema object has a "type". If missing, infers it from
/// common keywords (properties => object, items => array, enum/const/format => string)
/// and otherwise defaults to "string".
/// 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 (e.g. array items, object properties) with
/// permissive defaults when absent.
fn sanitize_json_schema(value: &mut JsonValue) {
@@ -96,31 +188,43 @@ fn sanitize_json_schema(value: &mut JsonValue) {
if let Some(items) = map.get_mut("items") {
sanitize_json_schema(items);
}
for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] {
if let Some(value) = map.get_mut(combiner) {
sanitize_json_schema(value);
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]));
if matches!(
map.get("type").and_then(JsonValue::as_str),
Some("const") | None
) {
map.remove("type");
}
}
normalize_type_field(map);
let mut schema_type = map
.get("type")
.and_then(|value| value.as_str())
.and_then(JsonValue::as_str)
.map(str::to_string);
if schema_type.is_none()
&& let Some(JsonValue::Array(types)) = map.get("type")
{
for candidate in types {
if let Some(candidate_type) = candidate.as_str()
&& matches!(
candidate_type,
"object" | "array" | "string" | "number" | "integer" | "boolean"
)
{
schema_type = Some(candidate_type.to_string());
break;
}
}
if matches!(schema_type.as_deref(), Some("enum") | Some("const")) {
schema_type = None;
map.remove("type");
}
if let Some(types) = map.get("type").and_then(JsonValue::as_array).cloned() {
ensure_default_children_for_type_union(map, &types);
return;
}
if schema_type.is_none() {
@@ -131,11 +235,13 @@ fn sanitize_json_schema(value: &mut JsonValue) {
schema_type = Some("object".to_string());
} else if map.contains_key("items") || map.contains_key("prefixItems") {
schema_type = Some("array".to_string());
} else if map.contains_key("enum")
|| map.contains_key("const")
|| map.contains_key("format")
{
schema_type = Some("string".to_string());
} else if map.contains_key("anyOf") {
return;
} else if map.contains_key("enum") || map.contains_key("format") {
schema_type = infer_enum_type(map.get("enum").and_then(JsonValue::as_array))
.map(schema_type_name)
.map(str::to_string)
.or_else(|| Some("string".to_string()));
} else if map.contains_key("minimum")
|| map.contains_key("maximum")
|| map.contains_key("exclusiveMinimum")
@@ -143,24 +249,19 @@ fn sanitize_json_schema(value: &mut JsonValue) {
|| map.contains_key("multipleOf")
{
schema_type = Some("number".to_string());
} else {
schema_type = Some("string".to_string());
}
}
let schema_type = schema_type.unwrap_or_else(|| "string".to_string());
map.insert("type".to_string(), JsonValue::String(schema_type.clone()));
if schema_type == "object" {
if !map.contains_key("properties") {
map.insert(
"properties".to_string(),
JsonValue::Object(serde_json::Map::new()),
);
}
if let Some(additional_properties) = map.get_mut("additionalProperties")
&& !matches!(additional_properties, JsonValue::Bool(_))
{
sanitize_json_schema(additional_properties);
}
if schema_type == "object" && !map.contains_key("properties") {
map.insert(
"properties".to_string(),
JsonValue::Object(serde_json::Map::new()),
);
}
if schema_type == "array" && !map.contains_key("items") {
@@ -171,6 +272,97 @@ fn sanitize_json_schema(value: &mut JsonValue) {
}
}
fn normalize_type_field(map: &mut serde_json::Map<String, JsonValue>) {
if let Some(schema_type) = map.get("type").and_then(JsonValue::as_array).cloned() {
let normalized = schema_type
.into_iter()
.filter_map(|value| value.as_str().and_then(normalize_schema_type_name))
.map(|value| JsonValue::String(value.to_string()))
.collect::<Vec<_>>();
match normalized.as_slice() {
[] => {
map.remove("type");
}
[single] => {
map.insert("type".to_string(), single.clone());
}
_ => {
map.insert("type".to_string(), JsonValue::Array(normalized));
}
}
} else if let Some(schema_type) = map.get("type").and_then(JsonValue::as_str)
&& normalize_schema_type_name(schema_type).is_none()
{
map.remove("type");
}
}
fn ensure_default_children_for_type_union(
map: &mut serde_json::Map<String, JsonValue>,
types: &[JsonValue],
) {
let has_object = types.iter().any(|value| value.as_str() == Some("object"));
if has_object && !map.contains_key("properties") {
map.insert(
"properties".to_string(),
JsonValue::Object(serde_json::Map::new()),
);
}
let has_array = types.iter().any(|value| value.as_str() == Some("array"));
if has_array && !map.contains_key("items") {
map.insert("items".to_string(), json!({ "type": "string" }));
}
}
fn infer_enum_type(values: Option<&Vec<JsonValue>>) -> Option<JsonSchemaPrimitiveType> {
let values = values?;
let first = infer_json_value_type(values.first()?)?;
if values
.iter()
.all(|value| infer_json_value_type(value) == Some(first))
{
Some(first)
} else {
None
}
}
fn infer_json_value_type(value: &JsonValue) -> Option<JsonSchemaPrimitiveType> {
match value {
JsonValue::String(_) => Some(JsonSchemaPrimitiveType::String),
JsonValue::Number(_) => Some(JsonSchemaPrimitiveType::Number),
JsonValue::Bool(_) => Some(JsonSchemaPrimitiveType::Boolean),
JsonValue::Null => Some(JsonSchemaPrimitiveType::Null),
_ => None,
}
}
fn normalize_schema_type_name(schema_type: &str) -> Option<&'static str> {
match schema_type {
"string" => Some("string"),
"number" => Some("number"),
"boolean" => Some("boolean"),
"integer" => Some("integer"),
"object" => Some("object"),
"array" => Some("array"),
"null" => Some("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",
}
}
#[cfg(test)]
#[path = "json_schema_tests.rs"]
mod tests;

View File

@@ -1,19 +1,20 @@
use super::AdditionalProperties;
use super::JsonSchema;
use super::parse_tool_input_schema;
use super::JsonSchemaPrimitiveType;
use super::JsonSchemaType;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn parse_tool_input_schema_coerces_boolean_schemas() {
let schema = parse_tool_input_schema(&serde_json::json!(true)).expect("parse schema");
let schema = super::parse_tool_input_schema(&serde_json::json!(true)).expect("parse schema");
assert_eq!(schema, JsonSchema::String { description: None });
assert_eq!(schema, JsonSchema::string(/*description*/ None));
}
#[test]
fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() {
let schema = parse_tool_input_schema(&serde_json::json!({
let schema = super::parse_tool_input_schema(&serde_json::json!({
"properties": {
"query": {"description": "search query"}
}
@@ -22,22 +23,20 @@ fn parse_tool_input_schema_infers_object_shape_and_defaults_properties() {
assert_eq!(
schema,
JsonSchema::Object {
properties: BTreeMap::from([(
JsonSchema::object(
BTreeMap::from([(
"query".to_string(),
JsonSchema::String {
description: Some("search query".to_string()),
},
JsonSchema::string(Some("search query".to_string())),
)]),
required: None,
additional_properties: None,
}
/*required*/ None,
/*additional_properties*/ None
)
);
}
#[test]
fn parse_tool_input_schema_normalizes_integer_and_missing_array_items() {
let schema = parse_tool_input_schema(&serde_json::json!({
fn parse_tool_input_schema_preserves_integer_and_defaults_array_items() {
let schema = super::parse_tool_input_schema(&serde_json::json!({
"type": "object",
"properties": {
"page": {"type": "integer"},
@@ -48,26 +47,29 @@ fn parse_tool_input_schema_normalizes_integer_and_missing_array_items() {
assert_eq!(
schema,
JsonSchema::Object {
properties: BTreeMap::from([
("page".to_string(), JsonSchema::Number { description: None },),
JsonSchema::object(
BTreeMap::from([
(
"page".to_string(),
JsonSchema::integer(/*description*/ None),
),
(
"tags".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: None,
},
JsonSchema::array(
JsonSchema::string(/*description*/ None),
/*description*/ None,
)
),
]),
required: None,
additional_properties: None,
}
/*required*/ None,
/*additional_properties*/ None
)
);
}
#[test]
fn parse_tool_input_schema_sanitizes_additional_properties_schema() {
let schema = parse_tool_input_schema(&serde_json::json!({
let schema = super::parse_tool_input_schema(&serde_json::json!({
"type": "object",
"additionalProperties": {
"required": ["value"],
@@ -80,19 +82,168 @@ fn parse_tool_input_schema_sanitizes_additional_properties_schema() {
assert_eq!(
schema,
JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: Some(AdditionalProperties::Schema(Box::new(
JsonSchema::Object {
properties: BTreeMap::from([(
"value".to_string(),
JsonSchema::String { description: None },
)]),
required: Some(vec!["value".to_string()]),
additional_properties: None,
},
))),
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_preserves_nested_nullable_any_of_shape() {
let schema = super::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_type_unions_without_rewriting_to_any_of() {
let schema = super::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_string_enum_constraints() {
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
)
);
}

View File

@@ -55,6 +55,8 @@ pub use js_repl_tool::create_js_repl_reset_tool;
pub use js_repl_tool::create_js_repl_tool;
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 local_tool::CommandToolOptions;
pub use local_tool::ShellToolOptions;

View File

@@ -20,62 +20,47 @@ pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"cmd".to_string(),
JsonSchema::String {
description: Some("Shell command to execute.".to_string()),
},
JsonSchema::string(Some("Shell command to execute.".to_string())),
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
)),
),
(
"shell".to_string(),
JsonSchema::String {
description: Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
),
},
JsonSchema::string(Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
)),
),
(
"tty".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
.to_string(),
),
},
JsonSchema::boolean(Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
.to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
JsonSchema::number(Some(
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
)),
),
]);
if options.allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
),
},
JsonSchema::boolean(Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
)),
);
}
properties.extend(create_approval_parameters(
@@ -95,11 +80,11 @@ pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
},
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["cmd".to_string()]),
Some(false.into()),
),
output_schema: Some(unified_exec_output_schema()),
})
}
@@ -108,32 +93,27 @@ pub fn create_write_stdin_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"session_id".to_string(),
JsonSchema::Number {
description: Some("Identifier of the running unified exec session.".to_string()),
},
JsonSchema::number(Some(
"Identifier of the running unified exec session.".to_string(),
)),
),
(
"chars".to_string(),
JsonSchema::String {
description: Some("Bytes to write to stdin (may be empty to poll).".to_string()),
},
JsonSchema::string(Some(
"Bytes to write to stdin (may be empty to poll).".to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
JsonSchema::number(Some(
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
)),
),
]);
@@ -144,11 +124,11 @@ pub fn create_write_stdin_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["session_id".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["session_id".to_string()]),
Some(false.into()),
),
output_schema: Some(unified_exec_output_schema()),
})
}
@@ -157,22 +137,22 @@ pub fn create_shell_tool(options: ShellToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("The command to execute".to_string()),
},
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some("The command to execute".to_string()),
),
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
JsonSchema::number(Some(
"The timeout for the command in milliseconds".to_string(),
)),
),
]);
properties.extend(create_approval_parameters(
@@ -207,11 +187,11 @@ Examples of valid command strings:
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["command".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
@@ -220,34 +200,30 @@ pub fn create_shell_command_tool(options: CommandToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::String {
description: Some(
"The shell script to execute in the user's default shell".to_string(),
),
},
JsonSchema::string(Some(
"The shell script to execute in the user's default shell".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
JsonSchema::number(Some(
"The timeout for the command in milliseconds".to_string(),
)),
),
]);
if options.allow_login_shell {
properties.insert(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with login shell semantics. Defaults to true."
.to_string(),
),
},
JsonSchema::boolean(Some(
"Whether to run the shell with login shell semantics. Defaults to true."
.to_string(),
)),
);
}
properties.extend(create_approval_parameters(
@@ -281,11 +257,11 @@ Examples of valid command strings:
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["command".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
@@ -294,12 +270,9 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec {
let properties = BTreeMap::from([
(
"reason".to_string(),
JsonSchema::String {
description: Some(
"Optional short explanation for why additional permissions are needed."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional short explanation for why additional permissions are needed.".to_string(),
)),
),
("permissions".to_string(), permission_profile_schema()),
]);
@@ -309,11 +282,11 @@ pub fn create_request_permissions_tool(description: String) -> ToolSpec {
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["permissions".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["permissions".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}
@@ -363,40 +336,33 @@ fn create_approval_parameters(
let mut properties = BTreeMap::from([
(
"sandbox_permissions".to_string(),
JsonSchema::String {
description: Some(
if exec_permission_approvals_enabled {
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
} else {
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
}
.to_string(),
),
},
JsonSchema::string(Some(
if exec_permission_approvals_enabled {
"Sandbox permissions for the command. Use \"with_additional_permissions\" to request additional sandboxed filesystem or network permissions (preferred), or \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
} else {
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
}
.to_string(),
)),
),
(
"justification".to_string(),
JsonSchema::String {
description: Some(
r#"Only set if sandbox_permissions is \"require_escalated\".
JsonSchema::string(Some(
r#"Only set if sandbox_permissions is \"require_escalated\".
Request approval from the user to run this command outside the sandbox.
Phrased as a simple question that summarizes the purpose of the
command as it relates to the task at hand - e.g. 'Do you want to
fetch and pull the latest version of this git branch?'"#
.to_string(),
),
},
)),
),
(
"prefix_rule".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
JsonSchema::array(JsonSchema::string(/*description*/ None), Some(
r#"Only specify when sandbox_permissions is `require_escalated`.
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#.to_string(),
),
},
)),
),
]);
@@ -411,50 +377,48 @@ fn create_approval_parameters(
}
fn permission_profile_schema() -> JsonSchema {
JsonSchema::Object {
properties: BTreeMap::from([
JsonSchema::object(
BTreeMap::from([
("network".to_string(), network_permissions_schema()),
("file_system".to_string(), file_system_permissions_schema()),
]),
required: None,
additional_properties: Some(false.into()),
}
/*required*/ None,
Some(false.into()),
)
}
fn network_permissions_schema() -> JsonSchema {
JsonSchema::Object {
properties: BTreeMap::from([(
JsonSchema::object(
BTreeMap::from([(
"enabled".to_string(),
JsonSchema::Boolean {
description: Some("Set to true to request network access.".to_string()),
},
JsonSchema::boolean(Some("Set to true to request network access.".to_string())),
)]),
required: None,
additional_properties: Some(false.into()),
}
/*required*/ None,
Some(false.into()),
)
}
fn file_system_permissions_schema() -> JsonSchema {
JsonSchema::Object {
properties: BTreeMap::from([
JsonSchema::object(
BTreeMap::from([
(
"read".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("Absolute paths to grant read access to.".to_string()),
},
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some("Absolute paths to grant read access to.".to_string()),
),
),
(
"write".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("Absolute paths to grant write access to.".to_string()),
},
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some("Absolute paths to grant write access to.".to_string()),
),
),
]),
required: None,
additional_properties: Some(false.into()),
}
/*required*/ None,
Some(false.into()),
)
}
fn windows_destructive_filesystem_guidance() -> &'static str {

View File

@@ -35,56 +35,42 @@ Examples of valid command strings:
let properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("The command to execute".to_string()),
},
JsonSchema::array(JsonSchema::string(/*description*/ None), Some("The command to execute".to_string())),
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
JsonSchema::string(Some("The working directory to execute the command in".to_string())),
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
JsonSchema::number(Some("The timeout for the command in milliseconds".to_string())),
),
(
"sandbox_permissions".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."
.to_string(),
),
},
)),
),
(
"justification".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
r#"Only set if sandbox_permissions is \"require_escalated\".
Request approval from the user to run this command outside the sandbox.
Phrased as a simple question that summarizes the purpose of the
command as it relates to the task at hand - e.g. 'Do you want to
fetch and pull the latest version of this git branch?'"#
.to_string(),
),
},
)),
),
(
"prefix_rule".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some(
JsonSchema::array(JsonSchema::string(/*description*/ None), Some(
r#"Only specify when sandbox_permissions is `require_escalated`.
Suggest a prefix command pattern that will allow you to fulfill similar requests from the user in the future.
Should be a short but reasonable prefix, e.g. [\"git\", \"pull\"] or [\"uv\", \"run\"] or [\"pytest\"]."#
.to_string(),
),
},
)),
),
]);
@@ -95,11 +81,11 @@ Examples of valid command strings:
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["command".to_string()]),
Some(false.into())
),
output_schema: None,
})
);
@@ -125,60 +111,46 @@ fn exec_command_tool_matches_expected_spec() {
let mut properties = BTreeMap::from([
(
"cmd".to_string(),
JsonSchema::String {
description: Some("Shell command to execute.".to_string()),
},
JsonSchema::string(Some("Shell command to execute.".to_string())),
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Optional working directory to run the command in; defaults to the turn cwd."
.to_string(),
),
},
)),
),
(
"shell".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Shell binary to launch. Defaults to the user's default shell.".to_string(),
),
},
)),
),
(
"tty".to_string(),
JsonSchema::Boolean {
description: Some(
JsonSchema::boolean(Some(
"Whether to allocate a TTY for the command. Defaults to false (plain pipes); set to true to open a PTY and access TTY process."
.to_string(),
),
},
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
JsonSchema::number(Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
)),
),
(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
JsonSchema::boolean(Some(
"Whether to run the shell with -l/-i semantics. Defaults to true.".to_string(),
),
},
)),
),
]);
properties.extend(create_approval_parameters(
@@ -192,11 +164,11 @@ fn exec_command_tool_matches_expected_spec() {
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["cmd".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["cmd".to_string()]),
Some(false.into())
),
output_schema: Some(unified_exec_output_schema()),
})
);
@@ -209,32 +181,27 @@ fn write_stdin_tool_matches_expected_spec() {
let properties = BTreeMap::from([
(
"session_id".to_string(),
JsonSchema::Number {
description: Some("Identifier of the running unified exec session.".to_string()),
},
JsonSchema::number(Some(
"Identifier of the running unified exec session.".to_string(),
)),
),
(
"chars".to_string(),
JsonSchema::String {
description: Some("Bytes to write to stdin (may be empty to poll).".to_string()),
},
JsonSchema::string(Some(
"Bytes to write to stdin (may be empty to poll).".to_string(),
)),
),
(
"yield_time_ms".to_string(),
JsonSchema::Number {
description: Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
),
},
JsonSchema::number(Some(
"How long to wait (in milliseconds) for output before yielding.".to_string(),
)),
),
(
"max_output_tokens".to_string(),
JsonSchema::Number {
description: Some(
"Maximum number of tokens to return. Excess output will be truncated."
.to_string(),
),
},
JsonSchema::number(Some(
"Maximum number of tokens to return. Excess output will be truncated.".to_string(),
)),
),
]);
@@ -247,11 +214,11 @@ fn write_stdin_tool_matches_expected_spec() {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["session_id".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["session_id".to_string()]),
Some(false.into())
),
output_schema: Some(unified_exec_output_schema()),
})
);
@@ -266,22 +233,22 @@ fn shell_tool_with_request_permission_includes_additional_permissions() {
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("The command to execute".to_string()),
},
JsonSchema::array(
JsonSchema::string(/*description*/ None),
Some("The command to execute".to_string()),
),
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
JsonSchema::number(Some(
"The timeout for the command in milliseconds".to_string(),
)),
),
]);
properties.extend(create_approval_parameters(
@@ -318,11 +285,11 @@ Examples of valid command strings:
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["command".to_string()]),
Some(false.into())
),
output_schema: None,
})
);
@@ -336,12 +303,9 @@ fn request_permissions_tool_includes_full_permission_schema() {
let properties = BTreeMap::from([
(
"reason".to_string(),
JsonSchema::String {
description: Some(
"Optional short explanation for why additional permissions are needed."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional short explanation for why additional permissions are needed.".to_string(),
)),
),
("permissions".to_string(), permission_profile_schema()),
]);
@@ -353,11 +317,11 @@ fn request_permissions_tool_includes_full_permission_schema() {
description: "Request extra permissions for this turn.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["permissions".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["permissions".to_string()]),
Some(false.into())
),
output_schema: None,
})
);
@@ -392,32 +356,28 @@ Examples of valid command strings:
let mut properties = BTreeMap::from([
(
"command".to_string(),
JsonSchema::String {
description: Some(
"The shell script to execute in the user's default shell".to_string(),
),
},
JsonSchema::string(Some(
"The shell script to execute in the user's default shell".to_string(),
)),
),
(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
JsonSchema::string(Some(
"The working directory to execute the command in".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
},
JsonSchema::number(Some(
"The timeout for the command in milliseconds".to_string(),
)),
),
(
"login".to_string(),
JsonSchema::Boolean {
description: Some(
"Whether to run the shell with login shell semantics. Defaults to true."
.to_string(),
),
},
JsonSchema::boolean(Some(
"Whether to run the shell with login shell semantics. Defaults to true."
.to_string(),
)),
),
]);
properties.extend(create_approval_parameters(
@@ -431,11 +391,11 @@ Examples of valid command strings:
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["command".to_string()]),
Some(false.into())
),
output_schema: None,
})
);

View File

@@ -7,21 +7,17 @@ pub fn create_list_mcp_resources_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"server".to_string(),
JsonSchema::String {
description: Some(
"Optional MCP server name. When omitted, lists resources from every configured server."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resources from every configured server."
.to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::String {
description: Some(
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
.to_string(),
),
},
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
.to_string(),
)),
),
]);
@@ -30,11 +26,7 @@ pub fn create_list_mcp_resources_tool() -> ToolSpec {
description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: None,
})
}
@@ -43,21 +35,17 @@ pub fn create_list_mcp_resource_templates_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"server".to_string(),
JsonSchema::String {
description: Some(
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
.to_string(),
),
},
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
.to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::String {
description: Some(
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
.to_string(),
),
},
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
.to_string(),
)),
),
]);
@@ -66,11 +54,7 @@ pub fn create_list_mcp_resource_templates_tool() -> ToolSpec {
description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: None,
})
}
@@ -79,21 +63,17 @@ pub fn create_read_mcp_resource_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"server".to_string(),
JsonSchema::String {
description: Some(
"MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."
.to_string(),
),
},
JsonSchema::string(Some(
"MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."
.to_string(),
)),
),
(
"uri".to_string(),
JsonSchema::String {
description: Some(
"Resource URI to read. Must be one of the URIs returned by list_mcp_resources."
.to_string(),
),
},
JsonSchema::string(Some(
"Resource URI to read. Must be one of the URIs returned by list_mcp_resources."
.to_string(),
)),
),
]);
@@ -104,11 +84,11 @@ pub fn create_read_mcp_resource_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["server".to_string(), "uri".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["server".to_string(), "uri".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -11,30 +12,22 @@ fn list_mcp_resources_tool_matches_expected_spec() {
description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"server".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resources from every configured server."
.to_string(),
),
},
),),
),
(
"cursor".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resources call for the same server."
.to_string(),
),
},
),),
),
]),
required: None,
additional_properties: Some(false.into()),
},
]), /*required*/ None, Some(false.into())),
output_schema: None,
})
);
@@ -49,30 +42,22 @@ fn list_mcp_resource_templates_tool_matches_expected_spec() {
description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"server".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Optional MCP server name. When omitted, lists resource templates from all configured servers."
.to_string(),
),
},
),),
),
(
"cursor".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."
.to_string(),
),
},
),),
),
]),
required: None,
additional_properties: Some(false.into()),
},
]), /*required*/ None, Some(false.into())),
output_schema: None,
})
);
@@ -89,30 +74,22 @@ fn read_mcp_resource_tool_matches_expected_spec() {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"server".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."
.to_string(),
),
},
),),
),
(
"uri".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Resource URI to read. Must be one of the URIs returned by list_mcp_resources."
.to_string(),
),
},
),),
),
]),
required: Some(vec!["server".to_string(), "uri".to_string()]),
additional_properties: Some(false.into()),
},
]), Some(vec!["server".to_string(), "uri".to_string()]), Some(false.into())),
output_schema: None,
})
);

View File

@@ -34,11 +34,11 @@ fn parse_mcp_tool_inserts_empty_properties() {
ToolDefinition {
name: "no_props".to_string(),
description: "No properties".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
defer_loading: false,
}
@@ -72,11 +72,11 @@ fn parse_mcp_tool_preserves_top_level_output_schema() {
ToolDefinition {
name: "with_output".to_string(),
description: "Has output schema".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({
"properties": {
"result": {
@@ -112,11 +112,11 @@ fn parse_mcp_tool_preserves_output_schema_without_inferred_type() {
ToolDefinition {
name: "with_enum_output".to_string(),
description: "Has enum output schema".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({
"enum": ["ok", "error"]
}))),

View File

@@ -5,30 +5,28 @@ use std::collections::BTreeMap;
pub fn create_update_plan_tool() -> ToolSpec {
let plan_item_properties = BTreeMap::from([
("step".to_string(), JsonSchema::String { description: None }),
("step".to_string(), JsonSchema::string(/*description*/ None)),
(
"status".to_string(),
JsonSchema::String {
description: Some("One of: pending, in_progress, completed".to_string()),
},
JsonSchema::string(Some("One of: pending, in_progress, completed".to_string())),
),
]);
let properties = BTreeMap::from([
(
"explanation".to_string(),
JsonSchema::String { description: None },
JsonSchema::string(/*description*/ None),
),
(
"plan".to_string(),
JsonSchema::Array {
description: Some("The list of steps".to_string()),
items: Box::new(JsonSchema::Object {
properties: plan_item_properties,
required: Some(vec!["step".to_string(), "status".to_string()]),
additional_properties: Some(false.into()),
}),
},
JsonSchema::array(
JsonSchema::object(
plan_item_properties,
Some(vec!["step".to_string(), "status".to_string()]),
Some(false.into()),
),
Some("The list of steps".to_string()),
),
),
]);
@@ -41,11 +39,11 @@ At most one step can be in_progress at a time.
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["plan".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["plan".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}

View File

@@ -12,71 +12,60 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec {
let option_props = BTreeMap::from([
(
"label".to_string(),
JsonSchema::String {
description: Some("User-facing label (1-5 words).".to_string()),
},
JsonSchema::string(Some("User-facing label (1-5 words).".to_string())),
),
(
"description".to_string(),
JsonSchema::String {
description: Some(
"One short sentence explaining impact/tradeoff if selected.".to_string(),
),
},
JsonSchema::string(Some(
"One short sentence explaining impact/tradeoff if selected.".to_string(),
)),
),
]);
let options_schema = JsonSchema::Array {
description: Some(
let options_schema = JsonSchema::array(JsonSchema::object(
option_props,
Some(vec!["label".to_string(), "description".to_string()]),
Some(false.into()),
), Some(
"Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically."
.to_string(),
),
items: Box::new(JsonSchema::Object {
properties: option_props,
required: Some(vec!["label".to_string(), "description".to_string()]),
additional_properties: Some(false.into()),
}),
};
));
let question_props = BTreeMap::from([
(
"id".to_string(),
JsonSchema::String {
description: Some(
"Stable identifier for mapping answers (snake_case).".to_string(),
),
},
JsonSchema::string(Some(
"Stable identifier for mapping answers (snake_case).".to_string(),
)),
),
(
"header".to_string(),
JsonSchema::String {
description: Some(
"Short header label shown in the UI (12 or fewer chars).".to_string(),
),
},
JsonSchema::string(Some(
"Short header label shown in the UI (12 or fewer chars).".to_string(),
)),
),
(
"question".to_string(),
JsonSchema::String {
description: Some("Single-sentence prompt shown to the user.".to_string()),
},
JsonSchema::string(Some(
"Single-sentence prompt shown to the user.".to_string(),
)),
),
("options".to_string(), options_schema),
]);
let questions_schema = JsonSchema::Array {
description: Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
items: Box::new(JsonSchema::Object {
properties: question_props,
required: Some(vec![
let questions_schema = JsonSchema::array(
JsonSchema::object(
question_props,
Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
"options".to_string(),
]),
additional_properties: Some(false.into()),
}),
};
Some(false.into()),
),
Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
);
let properties = BTreeMap::from([("questions".to_string(), questions_schema)]);
@@ -85,11 +74,11 @@ pub fn create_request_user_input_tool(description: String) -> ToolSpec {
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["questions".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["questions".to_string()]),
Some(false.into()),
),
output_schema: None,
})
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::JsonSchema;
use codex_protocol::config_types::ModeKind;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -12,91 +13,77 @@ fn request_user_input_tool_includes_questions_schema() {
description: "Ask the user to choose.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(BTreeMap::from([(
"questions".to_string(),
JsonSchema::Array {
description: Some(
"Questions to show the user. Prefer 1 and do not exceed 3".to_string(),
),
items: Box::new(JsonSchema::Object {
properties: BTreeMap::from([
JsonSchema::array(
JsonSchema::object(
BTreeMap::from([
(
"header".to_string(),
JsonSchema::String {
description: Some(
"Short header label shown in the UI (12 or fewer chars)."
.to_string(),
),
},
JsonSchema::string(Some(
"Short header label shown in the UI (12 or fewer chars)."
.to_string(),
)),
),
(
"id".to_string(),
JsonSchema::String {
description: Some(
"Stable identifier for mapping answers (snake_case)."
.to_string(),
),
},
JsonSchema::string(Some(
"Stable identifier for mapping answers (snake_case)."
.to_string(),
)),
),
(
"options".to_string(),
JsonSchema::Array {
description: Some(
"Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically."
.to_string(),
),
items: Box::new(JsonSchema::Object {
properties: BTreeMap::from([
JsonSchema::array(
JsonSchema::object(
BTreeMap::from([
(
"description".to_string(),
JsonSchema::String {
description: Some(
"One short sentence explaining impact/tradeoff if selected."
.to_string(),
),
},
JsonSchema::string(Some(
"One short sentence explaining impact/tradeoff if selected."
.to_string(),
)),
),
(
"label".to_string(),
JsonSchema::String {
description: Some(
"User-facing label (1-5 words)."
.to_string(),
),
},
JsonSchema::string(Some(
"User-facing label (1-5 words)."
.to_string(),
)),
),
]),
required: Some(vec![
Some(vec![
"label".to_string(),
"description".to_string(),
]),
additional_properties: Some(false.into()),
}),
},
Some(false.into()),
),
Some(
"Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically."
.to_string(),
),
),
),
(
"question".to_string(),
JsonSchema::String {
description: Some(
"Single-sentence prompt shown to the user.".to_string(),
),
},
JsonSchema::string(Some(
"Single-sentence prompt shown to the user.".to_string(),
)),
),
]),
required: Some(vec![
Some(vec![
"id".to_string(),
"header".to_string(),
"question".to_string(),
"options".to_string(),
]),
additional_properties: Some(false.into()),
}),
},
)]),
required: Some(vec!["questions".to_string()]),
additional_properties: Some(false.into()),
},
Some(false.into()),
),
Some(
"Questions to show the user. Prefer 1 and do not exceed 3".to_string(),
),
),
)]), Some(vec!["questions".to_string()]), Some(false.into())),
output_schema: None,
})
);

View File

@@ -18,14 +18,14 @@ fn tool_definition_to_responses_api_tool_omits_false_defer_loading() {
tool_definition_to_responses_api_tool(ToolDefinition {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::from([(
input_schema: JsonSchema::object(
BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
JsonSchema::string(/*description*/ None),
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["order_id".to_string()]),
Some(false.into())
),
output_schema: Some(json!({"type": "object"})),
defer_loading: false,
}),
@@ -34,14 +34,14 @@ fn tool_definition_to_responses_api_tool_omits_false_defer_loading() {
description: "Look up an order".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(
BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
JsonSchema::string(/*description*/ None),
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["order_id".to_string()]),
Some(false.into())
),
output_schema: Some(json!({"type": "object"})),
}
);
@@ -70,14 +70,14 @@ fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() {
description: "Look up an order".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(
BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
JsonSchema::string(/*description*/ None),
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["order_id".to_string()]),
Some(false.into())
),
output_schema: None,
}
);
@@ -115,14 +115,10 @@ fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() {
description: "Look up an order".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(BTreeMap::from([(
"order_id".to_string(),
JsonSchema::String { description: None },
)]),
required: Some(vec!["order_id".to_string()]),
additional_properties: Some(false.into()),
},
JsonSchema::string(/*description*/ None),
)]), Some(vec!["order_id".to_string()]), Some(false.into())),
output_schema: None,
}
);
@@ -138,11 +134,11 @@ fn tool_search_output_namespace_serializes_with_deferred_child_tools() {
description: "Create a calendar event.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
Default::default(),
/*required*/ None,
/*additional_properties*/ None,
),
output_schema: None,
})],
});

View File

@@ -7,11 +7,11 @@ fn tool_definition() -> ToolDefinition {
ToolDefinition {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
input_schema: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
output_schema: Some(serde_json::json!({
"type": "object",
})),

View File

@@ -147,17 +147,13 @@ pub fn create_tool_search_tool(app_tools: &[ToolSearchAppInfo], default_limit: u
let properties = BTreeMap::from([
(
"query".to_string(),
JsonSchema::String {
description: Some("Search query for apps tools.".to_string()),
},
JsonSchema::string(Some("Search query for apps tools.".to_string())),
),
(
"limit".to_string(),
JsonSchema::Number {
description: Some(format!(
"Maximum number of tools to return (defaults to {default_limit})."
)),
},
JsonSchema::number(Some(format!(
"Maximum number of tools to return (defaults to {default_limit})."
))),
),
]);
@@ -193,11 +189,11 @@ pub fn create_tool_search_tool(app_tools: &[ToolSearchAppInfo], default_limit: u
ToolSpec::ToolSearch {
execution: "client".to_string(),
description,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec!["query".to_string()]),
additional_properties: Some(false.into()),
},
Some(vec!["query".to_string()]),
Some(false.into()),
),
}
}
@@ -279,37 +275,29 @@ pub fn create_tool_suggest_tool(discoverable_tools: &[ToolSuggestEntry]) -> Tool
let properties = BTreeMap::from([
(
"tool_type".to_string(),
JsonSchema::String {
description: Some(
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
.to_string(),
),
},
JsonSchema::string(Some(
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
.to_string(),
)),
),
(
"action_type".to_string(),
JsonSchema::String {
description: Some(
"Suggested action for the tool. Use \"install\" or \"enable\".".to_string(),
),
},
JsonSchema::string(Some(
"Suggested action for the tool. Use \"install\" or \"enable\".".to_string(),
)),
),
(
"tool_id".to_string(),
JsonSchema::String {
description: Some(format!(
"Connector or plugin id to suggest. Must be one of: {discoverable_tool_ids}."
)),
},
JsonSchema::string(Some(format!(
"Connector or plugin id to suggest. Must be one of: {discoverable_tool_ids}."
))),
),
(
"suggest_reason".to_string(),
JsonSchema::String {
description: Some(
"Concise one-line user-facing reason why this tool can help with the current request."
.to_string(),
),
},
JsonSchema::string(Some(
"Concise one-line user-facing reason why this tool can help with the current request."
.to_string(),
)),
),
]);
@@ -323,16 +311,16 @@ pub fn create_tool_suggest_tool(discoverable_tools: &[ToolSuggestEntry]) -> Tool
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
parameters: JsonSchema::object(
properties,
required: Some(vec![
Some(vec![
"tool_type".to_string(),
"action_type".to_string(),
"tool_id".to_string(),
"suggest_reason".to_string(),
]),
additional_properties: Some(false.into()),
},
Some(false.into()),
),
output_schema: None,
})
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::JsonSchema;
use codex_app_server_protocol::AppInfo;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
@@ -50,27 +51,19 @@ fn create_tool_search_tool_deduplicates_and_renders_enabled_apps() {
ToolSpec::ToolSearch {
execution: "client".to_string(),
description: "# Apps (Connectors) tool discovery\n\nSearches over apps/connectors tool metadata with BM25 and exposes matching tools for the next model call.\n\nYou have access to all the tools of the following apps/connectors:\n- Google Drive: Use Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work.\n- Slack\nSome of the tools may not have been provided to you upfront, and you should use this tool (`tool_search`) to search for the required tools and load them for the apps mentioned above. For the apps mentioned above, always use `tool_search` instead of `list_mcp_resources` or `list_mcp_resource_templates` for tool discovery.".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"limit".to_string(),
JsonSchema::Number {
description: Some(
JsonSchema::number(Some(
"Maximum number of tools to return (defaults to 8)."
.to_string(),
),
},
),),
),
(
"query".to_string(),
JsonSchema::String {
description: Some("Search query for apps tools.".to_string()),
},
JsonSchema::string(Some("Search query for apps tools.".to_string()),),
),
]),
required: Some(vec!["query".to_string()]),
additional_properties: Some(false.into()),
},
]), Some(vec!["query".to_string()]), Some(false.into())),
}
);
}
@@ -103,53 +96,41 @@ fn create_tool_suggest_tool_uses_plugin_summary_fallback() {
description: "# Tool suggestion discovery\n\nSuggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin, when the user clearly wants a capability that is not currently available in the active `tools` list.\n\nUse this ONLY when:\n- You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means.\n- For connectors/apps that are not installed but needed for an installed plugin, suggest to install them if the task requirements match precisely.\n- For plugins that are not installed but discoverable, only suggest discoverable and installable plugins when the user's intent very explicitly and unambiguously matches that plugin itself. Do not suggest a plugin just because one of its connectors or capabilities seems relevant.\n\nTool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list.\n\nDiscoverable tools:\n- GitHub (id: `github`, type: plugin, action: install): skills; MCP servers: github-mcp; app connectors: github-app\n- Slack (id: `slack@openai-curated`, type: connector, action: install): No description provided.\n\nWorkflow:\n\n1. Ensure all possible means have been exhausted to find an existing available tool but none of them matches the request intent.\n2. Match the user's request against the discoverable tools list above. Apply the stricter explicit-and-unambiguous rule for *discoverable tools* like plugin install suggestions; *missing tools* like connector install suggestions continue to use the normal clear-fit standard.\n3. If one tool clearly fits, call `tool_suggest` with:\n - `tool_type`: `connector` or `plugin`\n - `action_type`: `install` or `enable`\n - `tool_id`: exact id from the discoverable tools list above\n - `suggest_reason`: concise one-line user-facing reason this tool can help with the current request\n4. After the suggestion flow completes:\n - if the user finished the install or enable flow, continue by searching again or using the newly available tool\n - if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks for it.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"action_type".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Suggested action for the tool. Use \"install\" or \"enable\"."
.to_string(),
),
},
),),
),
(
"suggest_reason".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Concise one-line user-facing reason why this tool can help with the current request."
.to_string(),
),
},
),),
),
(
"tool_id".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Connector or plugin id to suggest. Must be one of: slack@openai-curated, github."
.to_string(),
),
},
),),
),
(
"tool_type".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
.to_string(),
),
},
),),
),
]),
required: Some(vec![
]), Some(vec![
"tool_type".to_string(),
"action_type".to_string(),
"tool_id".to_string(),
"suggest_reason".to_string(),
]),
additional_properties: Some(false.into()),
},
]), Some(false.into())),
output_schema: None,
})
);
@@ -198,11 +179,11 @@ fn collect_tool_search_output_tools_groups_results_by_namespace() {
description: "Create a calendar event.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
Default::default(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
}),
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
@@ -210,11 +191,11 @@ fn collect_tool_search_output_tools_groups_results_by_namespace() {
description: "List calendar events.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
Default::default(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
}),
],
@@ -227,11 +208,11 @@ fn collect_tool_search_output_tools_groups_results_by_namespace() {
description: "Read an email.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
Default::default(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
})],
}),
@@ -262,11 +243,11 @@ fn collect_tool_search_output_tools_falls_back_to_connector_name_description() {
description: "Read multiple emails.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
Default::default(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
})],
})],

View File

@@ -5,6 +5,8 @@ use crate::DiscoverablePluginInfo;
use crate::DiscoverableTool;
use crate::FreeformTool;
use crate::JsonSchema;
use crate::JsonSchemaPrimitiveType;
use crate::JsonSchemaType;
use crate::ResponsesApiTool;
use crate::ResponsesApiWebSearchFilters;
use crate::ResponsesApiWebSearchUserLocation;
@@ -172,9 +174,7 @@ fn test_build_specs_collab_tools_enabled() {
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &spawn_agent.spec else {
panic!("spawn_agent should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("spawn_agent should use object params");
};
let (properties, _) = expect_object_schema(parameters);
assert!(properties.contains_key("fork_context"));
assert!(!properties.contains_key("fork_turns"));
}
@@ -223,21 +223,14 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
else {
panic!("spawn_agent should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("spawn_agent should use object params");
};
let (properties, required) = expect_object_schema(parameters);
assert!(properties.contains_key("task_name"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("fork_turns"));
assert!(!properties.contains_key("items"));
assert!(!properties.contains_key("fork_context"));
assert_eq!(
required.as_ref(),
required,
Some(&vec!["task_name".to_string(), "message".to_string()])
);
let output_schema = output_schema
@@ -252,20 +245,13 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_message.spec else {
panic!("send_message should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("send_message should use object params");
};
let (properties, required) = expect_object_schema(parameters);
assert!(properties.contains_key("target"));
assert!(!properties.contains_key("interrupt"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert_eq!(
required.as_ref(),
required,
Some(&vec!["target".to_string(), "message".to_string()])
);
@@ -273,19 +259,12 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &followup_task.spec else {
panic!("followup_task should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("followup_task should use object params");
};
let (properties, required) = expect_object_schema(parameters);
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("items"));
assert_eq!(
required.as_ref(),
required,
Some(&vec!["target".to_string(), "message".to_string()])
);
@@ -298,17 +277,10 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
else {
panic!("wait_agent should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("wait_agent should use object params");
};
let (properties, required) = expect_object_schema(parameters);
assert!(!properties.contains_key("targets"));
assert!(properties.contains_key("timeout_ms"));
assert_eq!(required, &None);
assert_eq!(required, None);
let output_schema = output_schema
.as_ref()
.expect("wait_agent should define output schema");
@@ -326,16 +298,9 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
else {
panic!("list_agents should be a function tool");
};
let JsonSchema::Object {
properties,
required,
..
} = parameters
else {
panic!("list_agents should use object params");
};
let (properties, required) = expect_object_schema(parameters);
assert!(properties.contains_key("path_prefix"));
assert_eq!(required.as_ref(), None);
assert_eq!(required, None);
let output_schema = output_schema
.as_ref()
.expect("list_agents should define output schema");
@@ -407,9 +372,7 @@ fn view_image_tool_omits_detail_without_original_detail_feature() {
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else {
panic!("view_image should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("view_image should use an object schema");
};
let (properties, _) = expect_object_schema(parameters);
assert!(!properties.contains_key("detail"));
}
@@ -439,16 +402,13 @@ fn view_image_tool_includes_detail_with_original_detail_feature() {
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else {
panic!("view_image should be a function tool");
};
let JsonSchema::Object { properties, .. } = parameters else {
panic!("view_image should use an object schema");
};
let (properties, _) = expect_object_schema(parameters);
assert!(properties.contains_key("detail"));
let Some(JsonSchema::String {
description: Some(description),
}) = properties.get("detail")
else {
panic!("view_image detail should include a description");
};
let description = expect_string_description(
properties
.get("detail")
.expect("view_image detail should include a description"),
);
assert!(description.contains("only supported value is `original`"));
assert!(description.contains("omit this field for default resized behavior"));
}
@@ -1109,40 +1069,40 @@ fn test_build_specs_mcp_tools_converted() {
&tool.spec,
&ToolSpec::Function(ResponsesApiTool {
name: "test_server/do_something_cool".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(
BTreeMap::from([
(
"string_argument".to_string(),
JsonSchema::String { description: None }
JsonSchema::string(/*description*/ None),
),
(
"number_argument".to_string(),
JsonSchema::Number { description: None }
JsonSchema::number(/*description*/ None),
),
(
"object_argument".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
JsonSchema::object(
BTreeMap::from([
(
"string_property".to_string(),
JsonSchema::String { description: None }
JsonSchema::string(/*description*/ None),
),
(
"number_property".to_string(),
JsonSchema::Number { description: None }
JsonSchema::number(/*description*/ None),
),
]),
required: Some(vec![
Some(vec![
"string_property".to_string(),
"number_property".to_string(),
]),
additional_properties: Some(false.into()),
},
Some(false.into()),
),
),
]),
required: None,
additional_properties: None,
},
/*required*/ None,
/*additional_properties*/ None
),
description: "Do something cool".to_string(),
strict: false,
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
@@ -1515,11 +1475,9 @@ fn tool_suggest_description_lists_discoverable_tools() {
assert!(description.contains("DO NOT explore or recommend tools that are not on this list."));
assert!(!description.contains("{{discoverable_tools}}"));
assert!(!description.contains("tool_search fails to find a good match"));
let JsonSchema::Object { required, .. } = parameters else {
panic!("expected object parameters");
};
let (_, required) = expect_object_schema(parameters);
assert_eq!(
required.as_ref(),
required,
Some(&vec![
"tool_type".to_string(),
"action_type".to_string(),
@@ -1579,6 +1537,91 @@ fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() {
);
}
#[test]
fn code_mode_preserves_nullable_and_literal_mcp_input_shapes() {
let model_info = model_info();
let mut features = Features::with_defaults();
features.enable(Feature::CodeMode);
features.enable(Feature::UnifiedExec);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let (tools, _) = build_specs(
&tools_config,
Some(HashMap::from([(
"mcp__sample__fn".to_string(),
mcp_tool(
"fn",
"Sample fn",
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"}
]
},
"tagged_list": {
"anyOf": [
{
"type": "array",
"items": {
"type": "object",
"properties": {
"kind": {"type": "const", "const": "tagged"},
"variant": {"type": "enum", "enum": ["alpha", "beta"]},
"scope": {"type": "enum", "enum": ["one", "two"]}
},
"required": ["kind", "variant", "scope"]
}
},
{"type": "null"}
]
},
"response_length": {"type": "enum", "enum": ["short", "medium", "long"]}
},
"additionalProperties": false
}),
),
)])),
/*app_tools*/ None,
&[],
);
let ToolSpec::Function(ResponsesApiTool { description, .. }) =
&find_tool(&tools, "mcp__sample__fn").spec
else {
panic!("expected function tool");
};
assert!(description.contains(
r#"exec tool declaration:
```ts
declare const tools: { mcp__sample__fn(args: { open?: Array<{ lineno?: number | null; ref_id: string; }> | null; response_length?: "short" | "medium" | "long"; tagged_list?: Array<{ kind: "tagged"; scope: "one" | "two"; variant: "alpha" | "beta"; }> | null; }): Promise<{ _meta?: unknown; content: Array<unknown>; isError?: boolean; structuredContent?: unknown; }>; };
```"#
));
}
#[test]
fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() {
let model_info = model_info();
@@ -1610,7 +1653,7 @@ fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() {
assert_eq!(
description,
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<{ detail: string | null; image_url: string; }>; };\n```"
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: {\n // Local filesystem path to an image file\n path: string;\n}): Promise<{\n // Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`.\n detail: string | null;\n // Data URL for the loaded image.\n image_url: string;\n}>; };\n```"
);
}
@@ -1874,30 +1917,46 @@ fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a Co
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
}
fn expect_object_schema(
schema: &JsonSchema,
) -> (&BTreeMap<String, JsonSchema>, Option<&Vec<String>>) {
assert_eq!(
schema.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::Object))
);
let properties = schema
.properties
.as_ref()
.expect("expected object properties");
(properties, schema.required.as_ref())
}
fn expect_string_description(schema: &JsonSchema) -> &str {
assert_eq!(
schema.schema_type,
Some(JsonSchemaType::Single(JsonSchemaPrimitiveType::String))
);
schema.description.as_deref().expect("expected description")
}
fn strip_descriptions_schema(schema: &mut JsonSchema) {
match schema {
JsonSchema::Boolean { description }
| JsonSchema::String { description }
| JsonSchema::Number { description } => {
*description = None;
}
JsonSchema::Array { items, description } => {
strip_descriptions_schema(items);
*description = None;
}
JsonSchema::Object {
properties,
required: _,
additional_properties,
} => {
for value in properties.values_mut() {
strip_descriptions_schema(value);
}
if let Some(AdditionalProperties::Schema(schema)) = additional_properties {
strip_descriptions_schema(schema);
}
if let Some(variants) = &mut schema.any_of {
for variant in variants {
strip_descriptions_schema(variant);
}
}
if let Some(items) = &mut schema.items {
strip_descriptions_schema(items);
}
if let Some(properties) = &mut schema.properties {
for value in properties.values_mut() {
strip_descriptions_schema(value);
}
}
if let Some(AdditionalProperties::Schema(schema)) = &mut schema.additional_properties {
strip_descriptions_schema(schema);
}
schema.description = None;
}
fn strip_descriptions_tool(spec: &mut ToolSpec) {

View File

@@ -24,11 +24,11 @@ fn tool_spec_name_covers_all_variants() {
description: "Look up an order".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
})
.name(),
@@ -38,11 +38,11 @@ fn tool_spec_name_covers_all_variants() {
ToolSpec::ToolSearch {
execution: "sync".to_string(),
description: "Search for tools".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
}
.name(),
"tool_search"
@@ -90,11 +90,11 @@ fn configured_tool_spec_name_delegates_to_tool_spec() {
description: "Look up an order".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
}),
/*supports_parallel_tool_calls*/ true,
@@ -140,14 +140,11 @@ fn create_tools_json_for_responses_api_includes_top_level_name() {
description: "A demo tool".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"foo".to_string(),
JsonSchema::String { description: None },
)]),
required: None,
additional_properties: None,
},
parameters: JsonSchema::object(
BTreeMap::from([("foo".to_string(), JsonSchema::string(/*description*/ None),)]),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
})])
.expect("serialize tools"),
@@ -210,16 +207,14 @@ fn tool_search_tool_spec_serializes_expected_wire_shape() {
serde_json::to_value(ToolSpec::ToolSearch {
execution: "sync".to_string(),
description: "Search app tools".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(
BTreeMap::from([(
"query".to_string(),
JsonSchema::String {
description: Some("Tool search query".to_string()),
},
JsonSchema::string(Some("Tool search query".to_string()),),
)]),
required: Some(vec!["query".to_string()]),
additional_properties: Some(AdditionalProperties::Boolean(false)),
},
Some(vec!["query".to_string()]),
Some(AdditionalProperties::Boolean(false))
),
})
.expect("serialize tool_search"),
json!({

View File

@@ -7,31 +7,23 @@ pub fn create_list_dir_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"dir_path".to_string(),
JsonSchema::String {
description: Some("Absolute path to the directory to list.".to_string()),
},
JsonSchema::string(Some("Absolute path to the directory to list.".to_string())),
),
(
"offset".to_string(),
JsonSchema::Number {
description: Some(
"The entry number to start listing from. Must be 1 or greater.".to_string(),
),
},
JsonSchema::number(Some(
"The entry number to start listing from. Must be 1 or greater.".to_string(),
)),
),
(
"limit".to_string(),
JsonSchema::Number {
description: Some("The maximum number of entries to return.".to_string()),
},
JsonSchema::number(Some("The maximum number of entries to return.".to_string())),
),
(
"depth".to_string(),
JsonSchema::Number {
description: Some(
"The maximum directory depth to traverse. Must be 1 or greater.".to_string(),
),
},
JsonSchema::number(Some(
"The maximum directory depth to traverse. Must be 1 or greater.".to_string(),
)),
),
]);
@@ -42,11 +34,7 @@ pub fn create_list_dir_tool() -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["dir_path".to_string()]),
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, Some(vec!["dir_path".to_string()]), Some(false.into())),
output_schema: None,
})
}
@@ -55,54 +43,44 @@ pub fn create_test_sync_tool() -> ToolSpec {
let barrier_properties = BTreeMap::from([
(
"id".to_string(),
JsonSchema::String {
description: Some(
"Identifier shared by concurrent calls that should rendezvous".to_string(),
),
},
JsonSchema::string(Some(
"Identifier shared by concurrent calls that should rendezvous".to_string(),
)),
),
(
"participants".to_string(),
JsonSchema::Number {
description: Some(
"Number of tool calls that must arrive before the barrier opens".to_string(),
),
},
JsonSchema::number(Some(
"Number of tool calls that must arrive before the barrier opens".to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(
"Maximum time in milliseconds to wait at the barrier".to_string(),
),
},
JsonSchema::number(Some(
"Maximum time in milliseconds to wait at the barrier".to_string(),
)),
),
]);
let properties = BTreeMap::from([
(
"sleep_before_ms".to_string(),
JsonSchema::Number {
description: Some(
"Optional delay in milliseconds before any other action".to_string(),
),
},
JsonSchema::number(Some(
"Optional delay in milliseconds before any other action".to_string(),
)),
),
(
"sleep_after_ms".to_string(),
JsonSchema::Number {
description: Some(
"Optional delay in milliseconds after completing the barrier".to_string(),
),
},
JsonSchema::number(Some(
"Optional delay in milliseconds after completing the barrier".to_string(),
)),
),
(
"barrier".to_string(),
JsonSchema::Object {
properties: barrier_properties,
required: Some(vec!["id".to_string(), "participants".to_string()]),
additional_properties: Some(false.into()),
},
JsonSchema::object(
barrier_properties,
Some(vec!["id".to_string(), "participants".to_string()]),
Some(false.into()),
),
),
]);
@@ -111,11 +89,7 @@ pub fn create_test_sync_tool() -> ToolSpec {
description: "Internal synchronization helper used by Codex integration tests.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: None,
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: None,
})
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -13,46 +14,34 @@ fn list_dir_tool_matches_expected_spec() {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"depth".to_string(),
JsonSchema::Number {
description: Some(
"The maximum directory depth to traverse. Must be 1 or greater."
.to_string(),
),
},
JsonSchema::number(Some(
"The maximum directory depth to traverse. Must be 1 or greater."
.to_string(),
)),
),
(
"dir_path".to_string(),
JsonSchema::String {
description: Some(
"Absolute path to the directory to list.".to_string(),
),
},
JsonSchema::string(Some(
"Absolute path to the directory to list.".to_string(),
)),
),
(
"limit".to_string(),
JsonSchema::Number {
description: Some(
"The maximum number of entries to return.".to_string(),
),
},
JsonSchema::number(Some(
"The maximum number of entries to return.".to_string(),
)),
),
(
"offset".to_string(),
JsonSchema::Number {
description: Some(
"The entry number to start listing from. Must be 1 or greater."
.to_string(),
),
},
JsonSchema::number(Some(
"The entry number to start listing from. Must be 1 or greater."
.to_string(),
)),
),
]),
required: Some(vec!["dir_path".to_string()]),
additional_properties: Some(false.into()),
},
]), Some(vec!["dir_path".to_string()]), Some(false.into())),
output_schema: None,
})
);
@@ -68,69 +57,51 @@ fn test_sync_tool_matches_expected_spec() {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"barrier".to_string(),
JsonSchema::Object {
properties: BTreeMap::from([
JsonSchema::object(
BTreeMap::from([
(
"id".to_string(),
JsonSchema::String {
description: Some(
"Identifier shared by concurrent calls that should rendezvous"
.to_string(),
),
},
JsonSchema::string(Some(
"Identifier shared by concurrent calls that should rendezvous"
.to_string(),
)),
),
(
"participants".to_string(),
JsonSchema::Number {
description: Some(
"Number of tool calls that must arrive before the barrier opens"
.to_string(),
),
},
JsonSchema::number(Some(
"Number of tool calls that must arrive before the barrier opens"
.to_string(),
)),
),
(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(
"Maximum time in milliseconds to wait at the barrier"
.to_string(),
),
},
JsonSchema::number(Some(
"Maximum time in milliseconds to wait at the barrier"
.to_string(),
)),
),
]),
required: Some(vec![
"id".to_string(),
"participants".to_string(),
]),
additional_properties: Some(false.into()),
},
Some(vec!["id".to_string(), "participants".to_string()]),
Some(false.into()),
),
),
(
"sleep_after_ms".to_string(),
JsonSchema::Number {
description: Some(
"Optional delay in milliseconds after completing the barrier"
.to_string(),
),
},
JsonSchema::number(Some(
"Optional delay in milliseconds after completing the barrier"
.to_string(),
)),
),
(
"sleep_before_ms".to_string(),
JsonSchema::Number {
description: Some(
"Optional delay in milliseconds before any other action"
.to_string(),
),
},
JsonSchema::number(Some(
"Optional delay in milliseconds before any other action".to_string(),
)),
),
]),
required: None,
additional_properties: Some(false.into()),
},
]), /*required*/ None, Some(false.into())),
output_schema: None,
})
);

View File

@@ -14,18 +14,14 @@ pub struct ViewImageToolOptions {
pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
let mut properties = BTreeMap::from([(
"path".to_string(),
JsonSchema::String {
description: Some("Local filesystem path to an image file".to_string()),
},
JsonSchema::string(Some("Local filesystem path to an image file".to_string())),
)]);
if options.can_request_original_image_detail {
properties.insert(
"detail".to_string(),
JsonSchema::String {
description: Some(
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
),
},
JsonSchema::string(Some(
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
)),
);
}
@@ -35,11 +31,7 @@ pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
parameters: JsonSchema::object(properties, Some(vec!["path".to_string()]), Some(false.into())),
output_schema: Some(view_image_output_schema()),
})
}

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
@@ -14,16 +15,10 @@ fn view_image_tool_omits_detail_without_original_detail_feature() {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
parameters: JsonSchema::object(BTreeMap::from([(
"path".to_string(),
JsonSchema::String {
description: Some("Local filesystem path to an image file".to_string()),
},
)]),
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
JsonSchema::string(Some("Local filesystem path to an image file".to_string()),),
)]), Some(vec!["path".to_string()]), Some(false.into())),
output_schema: Some(view_image_output_schema()),
})
);
@@ -41,26 +36,18 @@ fn view_image_tool_includes_detail_with_original_detail_feature() {
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties: BTreeMap::from([
parameters: JsonSchema::object(BTreeMap::from([
(
"detail".to_string(),
JsonSchema::String {
description: Some(
JsonSchema::string(Some(
"Optional detail override. The only supported value is `original`; omit this field for default resized behavior. Use `original` to preserve the file's original resolution instead of resizing to fit. This is important when high-fidelity image perception or precise localization is needed, especially for CUA agents.".to_string(),
),
},
),),
),
(
"path".to_string(),
JsonSchema::String {
description: Some("Local filesystem path to an image file".to_string()),
},
JsonSchema::string(Some("Local filesystem path to an image file".to_string()),),
),
]),
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
]), Some(vec!["path".to_string()]), Some(false.into())),
output_schema: Some(view_image_output_schema()),
})
);