Compare commits

...

2 Commits

Author SHA1 Message Date
Sayan Sisodiya
7fe2e420ab Fix dynamic namespace description precedence 2026-05-26 21:20:42 -07:00
Sayan Sisodiya
7041a830d1 Expose dynamic tool namespace descriptions 2026-05-26 20:07:06 -07:00
29 changed files with 262 additions and 56 deletions

View File

@@ -571,6 +571,12 @@
"string",
"null"
]
},
"namespaceDescription": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -8245,6 +8245,12 @@
"string",
"null"
]
},
"namespaceDescription": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -4614,6 +4614,12 @@
"string",
"null"
]
},
"namespaceDescription": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -81,6 +81,12 @@
"string",
"null"
]
},
"namespaceDescription": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -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, };

View File

@@ -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!({

View File

@@ -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,

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.

View File

@@ -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,

View File

@@ -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!({

View File

@@ -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!({

View File

@@ -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!({

View File

@@ -22,7 +22,6 @@ use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolName;
use codex_tools::ToolSearchSourceInfo;
use codex_tools::ToolSpec;
use codex_tools::default_namespace_description;
use codex_tools::dynamic_tool_to_responses_api_tool;
use serde_json::Value;
use std::time::Instant;
@@ -32,6 +31,7 @@ use tracing::warn;
pub struct DynamicToolHandler {
tool_name: ToolName,
spec: ToolSpec,
search_source_info: ToolSearchSourceInfo,
exposure: ToolExposure,
search_text: String,
}
@@ -40,17 +40,47 @@ 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.unwrap_or_default().to_string();
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 +161,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 +255,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(" ")

View File

@@ -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()),
})
);
}

View File

@@ -149,20 +149,35 @@ mod tests {
#[test]
fn mixed_search_results_coalesce_mcp_namespaces() {
let dynamic_tools = [DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"mode": { "type": "string" },
},
"required": ["mode"],
"additionalProperties": false,
}),
defer_loading: true,
}];
let dynamic_tools = [
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: None,
name: "automation_list".to_string(),
description: "List recurring automations.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: true,
},
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
namespace_description: Some("Create and update Codex automations.".to_string()),
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"mode": { "type": "string" },
},
"required": ["mode"],
"additionalProperties": false,
}),
defer_loading: true,
},
];
let mcp_tools = [
tool_info("calendar", "create_event", "Create events"),
tool_info("calendar", "list_events", "List events"),
@@ -186,6 +201,7 @@ mod tests {
let results = [
&handler.entries[0],
&handler.entries[2],
&handler.entries[3],
&handler.entries[1],
];
@@ -228,23 +244,37 @@ mod tests {
}),
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: "codex_app".to_string(),
description: "Tools in the codex_app namespace.".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations."
.to_string(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::object(
std::collections::BTreeMap::from([(
"mode".to_string(),
codex_tools::JsonSchema::string(/*description*/ None),
)]),
Some(vec!["mode".to_string()]),
Some(false.into()),
),
output_schema: None,
})],
description: "Create and update Codex automations.".to_string(),
tools: vec![
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "automation_list".to_string(),
description: "List recurring automations.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::object(
Default::default(),
/*required*/ None,
Some(false.into()),
),
output_schema: None,
}),
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations."
.to_string(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::object(
std::collections::BTreeMap::from([(
"mode".to_string(),
codex_tools::JsonSchema::string(/*description*/ None),
)]),
Some(vec!["mode".to_string()]),
Some(false.into()),
),
output_schema: None,
}),
],
}),
],
);

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!({

View File

@@ -494,6 +494,11 @@ fn code_mode_namespace_descriptions(
entry.description = namespace.description.clone();
}
}
for namespace in namespace_descriptions.values_mut() {
if namespace.description.trim().is_empty() {
namespace.description = default_namespace_description(&namespace.name);
}
}
namespace_descriptions
}

View File

@@ -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!({
@@ -702,6 +703,10 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() {
plain.namespace_function_names("codex_app"),
&["lookup".to_string()]
);
let ToolSpec::Namespace(namespace) = plain.visible_spec("codex_app") else {
panic!("expected namespaced dynamic tool");
};
assert_eq!(namespace.description, "Tools in the codex_app namespace.");
plain.assert_visible_lacks(&[
codex_code_mode::PUBLIC_TOOL_NAME,
codex_code_mode::WAIT_TOOL_NAME,
@@ -729,6 +734,40 @@ async fn code_mode_only_exposes_code_executor_and_hides_nested_tools() {
code_mode_only.namespace_function_names("codex_app"),
Vec::<String>::new().as_slice()
);
let ToolSpec::Freeform(exec) = code_mode_only.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME)
else {
panic!("expected code mode executor");
};
assert!(
exec.description
.contains("## codex_app\nTools in the codex_app namespace.")
);
}
#[tokio::test]
async fn dynamic_namespaces_prefer_a_later_supplied_description() {
let mut described_tool =
dynamic_tool(Some("codex_app"), "update", /*defer_loading*/ false);
described_tool.namespace_description = Some("Create and update Codex automations.".to_string());
let plan = probe_with(
|_| {},
ToolPlanInputs {
dynamic_tools: vec![
dynamic_tool(Some("codex_app"), "list", /*defer_loading*/ false),
described_tool,
],
..ToolPlanInputs::default()
},
)
.await;
let ToolSpec::Namespace(namespace) = plan.visible_spec("codex_app") else {
panic!("expected namespaced dynamic tools");
};
assert_eq!(
namespace.description,
"Create and update Codex automations."
);
}
#[tokio::test]

View File

@@ -2,7 +2,6 @@ use codex_tools::LoadableToolSpec;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolSearchSourceInfo;
use codex_tools::ToolSpec;
use codex_tools::default_namespace_description;
#[derive(Clone)]
pub(crate) struct ToolSearchEntry {
@@ -29,9 +28,6 @@ impl ToolSearchInfo {
LoadableToolSpec::Function(tool)
}
ToolSpec::Namespace(mut namespace) => {
if namespace.description.trim().is_empty() {
namespace.description = default_namespace_description(&namespace.name);
}
for tool in &mut namespace.tools {
let ResponsesApiNamespaceTool::Function(tool) = tool;
tool.defer_loading = Some(true);

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!({

View File

@@ -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,

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!({

View File

@@ -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!({

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!({

View File

@@ -0,0 +1,2 @@
ALTER TABLE thread_dynamic_tools
ADD COLUMN namespace_description TEXT;

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)

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!({

View File

@@ -94,6 +94,11 @@ pub fn coalesce_loadable_tool_specs(
LoadableToolSpec::Function(_) | LoadableToolSpec::Namespace(_) => None,
})
{
if existing_namespace.description.trim().is_empty()
&& !namespace.description.trim().is_empty()
{
existing_namespace.description = namespace.description;
}
existing_namespace.tools.append(&mut namespace.tools);
} else {
coalesced_specs.push(LoadableToolSpec::Namespace(namespace));
@@ -101,6 +106,14 @@ pub fn coalesce_loadable_tool_specs(
}
}
}
for spec in &mut coalesced_specs {
let LoadableToolSpec::Namespace(namespace) = spec else {
continue;
};
if namespace.description.trim().is_empty() {
namespace.description = default_namespace_description(&namespace.name);
}
}
coalesced_specs
}

View File

@@ -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!({