Expose dynamic tool namespace descriptions

This commit is contained in:
Sayan Sisodiya
2026-05-26 20:07:06 -07:00
parent 64e340ad28
commit 7041a830d1
26 changed files with 148 additions and 20 deletions
@@ -571,6 +571,12 @@
"string",
"null"
]
},
"namespaceDescription": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -8245,6 +8245,12 @@
"string",
"null"
]
},
"namespaceDescription": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -4614,6 +4614,12 @@
"string",
"null"
]
},
"namespaceDescription": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -81,6 +81,12 @@
"string",
"null"
]
},
"namespaceDescription": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
export type DynamicToolSpec = { namespace?: string, name: string, description: string, inputSchema: JsonValue, deferLoading?: boolean, };
export type DynamicToolSpec = { namespace?: string, namespaceDescription?: string, name: string, description: string, inputSchema: JsonValue, deferLoading?: boolean, };
@@ -3297,6 +3297,8 @@ fn dynamic_tool_response_serializes_text_and_image_content_items() {
#[test]
fn dynamic_tool_spec_deserializes_defer_loading() {
let value = json!({
"namespace": "tickets",
"namespaceDescription": "Read and update tickets.",
"name": "lookup_ticket",
"description": "Fetch a ticket",
"inputSchema": {
@@ -3313,7 +3315,8 @@ fn dynamic_tool_spec_deserializes_defer_loading() {
assert_eq!(
actual,
DynamicToolSpec {
namespace: None,
namespace: Some("tickets".to_string()),
namespace_description: Some("Read and update tickets.".to_string()),
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: json!({
@@ -42,6 +42,8 @@ pub enum ThreadStartSource {
pub struct DynamicToolSpec {
#[ts(optional)]
pub namespace: Option<String>,
#[ts(optional)]
pub namespace_description: Option<String>,
pub name: String,
pub description: String,
pub input_schema: JsonValue,
@@ -53,6 +55,7 @@ pub struct DynamicToolSpec {
#[serde(rename_all = "camelCase")]
struct DynamicToolSpecDe {
namespace: Option<String>,
namespace_description: Option<String>,
name: String,
description: String,
input_schema: JsonValue,
@@ -67,6 +70,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
{
let DynamicToolSpecDe {
namespace,
namespace_description,
name,
description,
input_schema,
@@ -76,6 +80,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
Ok(Self {
namespace,
namespace_description,
name,
description,
input_schema,
+1
View File
@@ -1407,6 +1407,7 @@ Dynamic tool identifiers follow the same constraints as Responses function tools
- `name` must match `^[a-zA-Z0-9_-]+$` and be between 1 and 128 characters.
- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters.
- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`.
- `namespaceDescription`, when present on a namespaced tool, is surfaced with that namespace and, for deferred tools, in `tool_search` discovery metadata. Without it, Codex generates a generic namespace description.
Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result.
@@ -1079,6 +1079,7 @@ impl ThreadRequestProcessor {
.into_iter()
.map(|tool| CoreDynamicToolSpec {
namespace: tool.namespace,
namespace_description: tool.namespace_description,
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
@@ -92,6 +92,7 @@ mod thread_processor_behavior_tests {
fn validate_dynamic_tools_rejects_unsupported_input_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
namespace_description: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({"type": "null"}),
@@ -105,6 +106,7 @@ mod thread_processor_behavior_tests {
fn validate_dynamic_tools_accepts_sanitizable_input_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
namespace_description: None,
name: "my_tool".to_string(),
description: "test".to_string(),
// Missing `type` is common; core sanitizes these to a supported schema.
@@ -118,6 +120,7 @@ mod thread_processor_behavior_tests {
fn validate_dynamic_tools_accepts_nullable_field_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
namespace_description: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -138,6 +141,7 @@ mod thread_processor_behavior_tests {
let tools = vec![
ApiDynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -149,6 +153,7 @@ mod thread_processor_behavior_tests {
},
ApiDynamicToolSpec {
namespace: Some("other_app".to_string()),
namespace_description: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -166,6 +171,7 @@ mod thread_processor_behavior_tests {
fn validate_dynamic_tools_accepts_responses_compatible_identifiers() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("Codex-App_2".to_string()),
namespace_description: None,
name: "lookup-ticket_2".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -183,6 +189,7 @@ mod thread_processor_behavior_tests {
let tools = vec![
ApiDynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -194,6 +201,7 @@ mod thread_processor_behavior_tests {
},
ApiDynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -251,6 +259,7 @@ mod thread_processor_behavior_tests {
fn validate_dynamic_tools_rejects_empty_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("".to_string()),
namespace_description: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -269,6 +278,7 @@ mod thread_processor_behavior_tests {
fn validate_dynamic_tools_rejects_reserved_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("mcp__server__".to_string()),
namespace_description: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -287,6 +297,7 @@ mod thread_processor_behavior_tests {
fn validate_dynamic_tools_rejects_name_not_supported_by_responses() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
namespace_description: None,
name: "lookup.ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -308,6 +319,7 @@ mod thread_processor_behavior_tests {
fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("codex.app".to_string()),
namespace_description: None,
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -330,6 +342,7 @@ mod thread_processor_behavior_tests {
let long_name = "a".repeat(129);
let tools = vec![ApiDynamicToolSpec {
namespace: None,
namespace_description: None,
name: long_name.clone(),
description: "test".to_string(),
input_schema: json!({
@@ -349,6 +362,7 @@ mod thread_processor_behavior_tests {
let long_namespace = "a".repeat(65);
let tools = vec![ApiDynamicToolSpec {
namespace: Some(long_namespace.clone()),
namespace_description: None,
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -367,6 +381,7 @@ mod thread_processor_behavior_tests {
fn validate_dynamic_tools_rejects_reserved_responses_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("functions".to_string()),
namespace_description: None,
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -65,6 +65,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
});
let dynamic_tool = DynamicToolSpec {
namespace: None,
namespace_description: None,
name: "demo_tool".to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: input_schema.clone(),
@@ -139,6 +140,7 @@ async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Resu
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: "hidden_tool".to_string(),
description: "Hidden dynamic tool".to_string(),
input_schema: json!({
@@ -211,6 +213,7 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result
let dynamic_tool = DynamicToolSpec {
namespace: None,
namespace_description: None,
name: "hidden_tool".to_string(),
description: "Hidden dynamic tool".to_string(),
input_schema: json!({
@@ -251,6 +254,7 @@ async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Resu
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex.app".to_string()),
namespace_description: None,
name: "lookup.ticket".to_string(),
description: "Invalid dynamic tool".to_string(),
input_schema: json!({
@@ -316,6 +320,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
let dynamic_tool = DynamicToolSpec {
namespace: Some(tool_namespace.to_string()),
namespace_description: None,
name: tool_name.to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: json!({
@@ -491,6 +496,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
let dynamic_tool = DynamicToolSpec {
namespace: None,
namespace_description: None,
name: tool_name.to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: json!({
@@ -128,6 +128,7 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
model: Some("mock-model".to_string()),
dynamic_tools: Some(vec![DynamicToolSpec {
namespace: None,
namespace_description: None,
name: tool_name.to_string(),
description: "Deterministic wait tool".to_string(),
input_schema: json!({
+49 -11
View File
@@ -32,6 +32,7 @@ use tracing::warn;
pub struct DynamicToolHandler {
tool_name: ToolName,
spec: ToolSpec,
search_source_info: ToolSearchSourceInfo,
exposure: ToolExposure,
search_text: String,
}
@@ -40,17 +41,49 @@ impl DynamicToolHandler {
pub fn new(tool: &DynamicToolSpec) -> Option<Self> {
let tool_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?;
let spec = match tool.namespace.as_ref() {
Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.clone(),
description: default_namespace_description(namespace),
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
}),
None => ToolSpec::Function(output_tool),
let (spec, search_source_info) = match tool.namespace.as_ref() {
Some(namespace) => {
let custom_description = tool
.namespace_description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty());
let description = custom_description
.map(str::to_string)
.unwrap_or_else(|| default_namespace_description(namespace));
let search_source_info = custom_description.map_or_else(
|| ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some(
"Tools provided by the current Codex thread.".to_string(),
),
},
|description| ToolSearchSourceInfo {
name: namespace.clone(),
description: Some(description.to_string()),
},
);
(
ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.clone(),
description,
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
}),
search_source_info,
)
}
None => (
ToolSpec::Function(output_tool),
ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some("Tools provided by the current Codex thread.".to_string()),
},
),
};
Some(Self {
tool_name,
spec,
search_source_info,
exposure: if tool.defer_loading {
ToolExposure::Deferred
} else {
@@ -131,10 +164,7 @@ impl CoreToolRuntime for DynamicToolHandler {
ToolSearchInfo::from_spec(
self.search_text.clone(),
self.spec(),
Some(ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some("Tools provided by the current Codex thread.".to_string()),
}),
Some(self.search_source_info.clone()),
)
}
}
@@ -228,6 +258,14 @@ fn build_dynamic_search_text(tool: &DynamicToolSpec) -> String {
];
if let Some(namespace) = &tool.namespace {
parts.push(namespace.clone());
if let Some(namespace_description) = tool
.namespace_description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
{
parts.push(namespace_description.to_string());
}
}
parts.extend(schema_properties);
parts.join(" ")
@@ -7,6 +7,7 @@ use serde_json::json;
fn search_info_uses_dynamic_tool_metadata_and_parameter_names() {
let handler = DynamicToolHandler::new(&DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: Some("Create and update Codex automations.".to_string()),
name: "automation_update".to_string(),
description: "Create or update automations.".to_string(),
input_schema: json!({
@@ -24,13 +25,13 @@ fn search_info_uses_dynamic_tool_metadata_and_parameter_names() {
assert_eq!(
search_info.entry.search_text,
"automation_update automation update Create or update automations. codex_app mode timezone"
"automation_update automation update Create or update automations. codex_app Create and update Codex automations. mode timezone"
);
assert_eq!(
search_info.source_info,
Some(ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some("Tools provided by the current Codex thread.".to_string()),
name: "codex_app".to_string(),
description: Some("Create and update Codex automations.".to_string()),
})
);
}
@@ -151,6 +151,7 @@ mod tests {
fn mixed_search_results_coalesce_mcp_namespaces() {
let dynamic_tools = [DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations.".to_string(),
input_schema: serde_json::json!({
+2
View File
@@ -252,6 +252,7 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> {
let dynamic_tools = vec![
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: hidden_tool.to_string(),
description: "Hidden until discovered.".to_string(),
input_schema: json!({
@@ -263,6 +264,7 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> {
},
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: visible_tool.to_string(),
description: "Visible immediately.".to_string(),
input_schema: json!({
@@ -334,6 +334,7 @@ fn invalid_mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo {
fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> DynamicToolSpec {
DynamicToolSpec {
namespace: namespace.map(str::to_string),
namespace_description: None,
name: name.to_string(),
description: format!("{name} dynamic tool"),
input_schema: json!({
+1
View File
@@ -2928,6 +2928,7 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
base_test.config.clone(),
vec![DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: "hidden_dynamic_tool".to_string(),
description: "A hidden dynamic tool.".to_string(),
input_schema: serde_json::json!({
@@ -1048,6 +1048,7 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> {
let dynamic_tools = vec![
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: hidden_tool.to_string(),
description: "Hidden until discovered.".to_string(),
input_schema: input_schema.clone(),
@@ -1055,6 +1056,7 @@ async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> {
},
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: visible_tool.to_string(),
description: "Visible immediately.".to_string(),
input_schema,
+9 -1
View File
@@ -788,6 +788,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
let dynamic_call_id = "dyn-search-call-1";
let tool_name = "automation_update";
let tool_description = "Create, update, view, or delete recurring automations.";
let namespace_description = "Create and update Codex automations.";
let tool_args = json!({ "mode": "create" });
let tool_call_arguments = serde_json::to_string(&tool_args)?;
let mock = mount_sse_sequence(
@@ -837,6 +838,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
});
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: Some(namespace_description.to_string()),
name: tool_name.to_string(),
description: tool_description.to_string(),
input_schema: input_schema.clone(),
@@ -915,6 +917,11 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
!first_request_tools.iter().any(|name| name == tool_name),
"deferred dynamic tool should be hidden before search: {first_request_tools:?}"
);
assert!(
tool_search_description(&first_request_body)
.is_some_and(|description| description.contains(namespace_description)),
"tool_search should advertise the dynamic namespace description"
);
let tools = tool_search_output_tools(&requests[1], search_call_id);
assert_eq!(
@@ -922,7 +929,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
vec![json!({
"type": "namespace",
"name": "codex_app",
"description": "Tools in the codex_app namespace.",
"description": namespace_description,
"tools": [{
"type": "function",
"name": tool_name,
@@ -1459,6 +1466,7 @@ async fn tool_search_matches_dynamic_tools_by_name_description_namespace_and_sch
let dynamic_tool = DynamicToolSpec {
namespace: Some("orbit_ops".to_string()),
namespace_description: None,
name: "quasar_ping_beacon".to_string(),
description: "Trigger the saffron metronome workflow for reminder follow-ups.".to_string(),
input_schema: json!({
@@ -105,6 +105,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
let dynamic_tools = vec![
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: Some("Look up geographic data.".to_string()),
name: "geo_lookup".to_string(),
description: "lookup a city".to_string(),
input_schema: json!({
@@ -116,6 +117,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
},
DynamicToolSpec {
namespace: None,
namespace_description: None,
name: "weather_lookup".to_string(),
description: "lookup weather".to_string(),
input_schema: json!({
+9 -1
View File
@@ -10,6 +10,8 @@ use ts_rs::TS;
pub struct DynamicToolSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace_description: Option<String>,
pub name: String,
pub description: String,
pub input_schema: JsonValue,
@@ -51,6 +53,7 @@ pub enum DynamicToolCallOutputContentItem {
#[serde(rename_all = "camelCase")]
struct DynamicToolSpecDe {
namespace: Option<String>,
namespace_description: Option<String>,
name: String,
description: String,
input_schema: JsonValue,
@@ -65,6 +68,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
{
let DynamicToolSpecDe {
namespace,
namespace_description,
name,
description,
input_schema,
@@ -74,6 +78,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
Ok(Self {
namespace,
namespace_description,
name,
description,
input_schema,
@@ -92,6 +97,8 @@ mod tests {
#[test]
fn dynamic_tool_spec_deserializes_defer_loading() {
let value = json!({
"namespace": "tickets",
"namespaceDescription": "Read and update tickets.",
"name": "lookup_ticket",
"description": "Fetch a ticket",
"inputSchema": {
@@ -108,7 +115,8 @@ mod tests {
assert_eq!(
actual,
DynamicToolSpec {
namespace: None,
namespace: Some("tickets".to_string()),
namespace_description: Some("Read and update tickets.".to_string()),
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: json!({
@@ -0,0 +1,2 @@
ALTER TABLE thread_dynamic_tools
ADD COLUMN namespace_description TEXT;
+5 -2
View File
@@ -81,7 +81,7 @@ WHERE id = ? AND preview = ''
) -> anyhow::Result<Option<Vec<DynamicToolSpec>>> {
let rows = sqlx::query(
r#"
SELECT namespace, name, description, input_schema, defer_loading
SELECT namespace, namespace_description, name, description, input_schema, defer_loading
FROM thread_dynamic_tools
WHERE thread_id = ?
ORDER BY position ASC
@@ -99,6 +99,7 @@ ORDER BY position ASC
let input_schema = serde_json::from_str::<Value>(input_schema.as_str())?;
tools.push(DynamicToolSpec {
namespace: row.try_get("namespace")?,
namespace_description: row.try_get("namespace_description")?,
name: row.try_get("name")?,
description: row.try_get("description")?,
input_schema,
@@ -839,17 +840,19 @@ INSERT INTO thread_dynamic_tools (
thread_id,
position,
namespace,
namespace_description,
name,
description,
input_schema,
defer_loading
) VALUES (?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(thread_id, position) DO NOTHING
"#,
)
.bind(thread_id.as_str())
.bind(position)
.bind(tool.namespace.as_deref())
.bind(tool.namespace_description.as_deref())
.bind(tool.name.as_str())
.bind(tool.description.as_str())
.bind(input_schema)
+2
View File
@@ -9,6 +9,7 @@ use std::collections::BTreeMap;
fn parse_dynamic_tool_sanitizes_input_schema() {
let tool = DynamicToolSpec {
namespace: None,
namespace_description: None,
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: serde_json::json!({
@@ -41,6 +42,7 @@ fn parse_dynamic_tool_sanitizes_input_schema() {
fn parse_dynamic_tool_preserves_defer_loading() {
let tool = DynamicToolSpec {
namespace: None,
namespace_description: None,
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: serde_json::json!({
@@ -52,6 +52,7 @@ fn tool_definition_to_responses_api_tool_omits_false_defer_loading() {
fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() {
let tool = DynamicToolSpec {
namespace: None,
namespace_description: None,
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
input_schema: json!({