mirror of
https://github.com/openai/codex.git
synced 2026-05-19 18:52:57 +00:00
app-server: align dynamic tool identifiers with Responses API (#20724)
## Why Codex currently accepts dynamic tool names and namespaces that the upstream Responses function-tool path does not actually support. In practice, that means app-server can register a dynamic tool successfully and only discover later that the LLM-facing tool contract will reject or mishandle it. This PR tightens the app-server-side dynamic tool contract to match the Responses API before we stack dynamic tool hook support on top of it. ## What changed - validate dynamic tool `name` against the Responses function-tool identifier contract: `^[a-zA-Z0-9_-]+$`, length `1..128` - validate dynamic tool `namespace` the same way, with the Responses namespace length limit `1..64` - reject namespaces that collide with the always-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`, and `submodel_delegator` - escape invalid identifiers in error messages so control characters do not spill raw into logs or client-visible error text - document the tightened dynamic tool identifier contract in `codex-rs/app-server/README.md` - add both unit coverage for the validator and an app-server integration test that rejects a `thread/start` request with Responses-incompatible dynamic tool identifiers ## Verification - `cargo test -p codex-app-server validate_dynamic_tools_` - `cargo test -p codex-app-server --test all thread_start_rejects_dynamic_tools_not_supported_by_responses`
This commit is contained in:
@@ -1393,6 +1393,12 @@ If the session approval policy uses `Granular` with `request_permissions: false`
|
||||
|
||||
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client:
|
||||
|
||||
@@ -184,6 +184,53 @@ fn has_model_resume_override(
|
||||
}
|
||||
|
||||
fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
|
||||
const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128;
|
||||
const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64;
|
||||
const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$";
|
||||
const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[
|
||||
"api_tool",
|
||||
"browser",
|
||||
"computer",
|
||||
"container",
|
||||
"file_search",
|
||||
"functions",
|
||||
"image_gen",
|
||||
"multi_tool_use",
|
||||
"python",
|
||||
"python_user_visible",
|
||||
"submodel_delegator",
|
||||
"terminal",
|
||||
"tool_search",
|
||||
"web",
|
||||
];
|
||||
|
||||
fn escape_identifier_for_error(value: &str) -> String {
|
||||
value.escape_default().to_string()
|
||||
}
|
||||
|
||||
fn validate_dynamic_tool_identifier(
|
||||
value: &str,
|
||||
label: &str,
|
||||
max_len: usize,
|
||||
) -> Result<(), String> {
|
||||
if !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
|
||||
{
|
||||
return Err(format!(
|
||||
"{label} must match {DYNAMIC_TOOL_IDENTIFIER_PATTERN} to match Responses API: {}",
|
||||
escape_identifier_for_error(value),
|
||||
));
|
||||
}
|
||||
if value.chars().count() > max_len {
|
||||
return Err(format!(
|
||||
"{label} must be at most {max_len} characters to match Responses API: {}",
|
||||
escape_identifier_for_error(value),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
for tool in tools {
|
||||
let name = tool.name.trim();
|
||||
@@ -193,9 +240,10 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
|
||||
if name != tool.name {
|
||||
return Err(format!(
|
||||
"dynamic tool name has leading/trailing whitespace: {}",
|
||||
tool.name
|
||||
escape_identifier_for_error(&tool.name),
|
||||
));
|
||||
}
|
||||
validate_dynamic_tool_identifier(name, "dynamic tool name", DYNAMIC_TOOL_NAME_MAX_LEN)?;
|
||||
if name == "mcp" || name.starts_with("mcp__") {
|
||||
return Err(format!("dynamic tool name is reserved: {name}"));
|
||||
}
|
||||
@@ -209,13 +257,25 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
|
||||
if Some(namespace) != tool.namespace.as_deref() {
|
||||
return Err(format!(
|
||||
"dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}",
|
||||
name = escape_identifier_for_error(name),
|
||||
namespace = escape_identifier_for_error(namespace),
|
||||
));
|
||||
}
|
||||
validate_dynamic_tool_identifier(
|
||||
namespace,
|
||||
"dynamic tool namespace",
|
||||
DYNAMIC_TOOL_NAMESPACE_MAX_LEN,
|
||||
)?;
|
||||
if namespace == "mcp" || namespace.starts_with("mcp__") {
|
||||
return Err(format!(
|
||||
"dynamic tool namespace is reserved for {name}: {namespace}"
|
||||
));
|
||||
}
|
||||
if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) {
|
||||
return Err(format!(
|
||||
"dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}",
|
||||
));
|
||||
}
|
||||
}
|
||||
if !seen.insert((namespace, name)) {
|
||||
if let Some(namespace) = namespace {
|
||||
|
||||
@@ -156,6 +156,22 @@ mod thread_processor_behavior_tests {
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_accepts_responses_compatible_identifiers() {
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: Some("Codex-App_2".to_string()),
|
||||
name: "lookup-ticket_2".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: true,
|
||||
}];
|
||||
validate_dynamic_tools(&tools).expect("valid schema");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() {
|
||||
let tools = vec![
|
||||
@@ -260,6 +276,104 @@ mod thread_processor_behavior_tests {
|
||||
assert!(err.contains("reserved"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_name_not_supported_by_responses() {
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: None,
|
||||
name: "lookup.ticket".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: false,
|
||||
}];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("invalid name");
|
||||
assert!(err.contains("lookup.ticket"), "unexpected error: {err}");
|
||||
assert!(
|
||||
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() {
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: Some("codex.app".to_string()),
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: true,
|
||||
}];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("invalid namespace");
|
||||
assert!(err.contains("codex.app"), "unexpected error: {err}");
|
||||
assert!(
|
||||
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() {
|
||||
let long_name = "a".repeat(129);
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: None,
|
||||
name: long_name.clone(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: false,
|
||||
}];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("name too long");
|
||||
assert!(err.contains("at most 128"), "unexpected error: {err}");
|
||||
assert!(err.contains(&long_name), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() {
|
||||
let long_namespace = "a".repeat(65);
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: Some(long_namespace.clone()),
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: true,
|
||||
}];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("namespace too long");
|
||||
assert!(err.contains("at most 64"), "unexpected error: {err}");
|
||||
assert!(err.contains(&long_namespace), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_dynamic_tools_rejects_reserved_responses_namespace() {
|
||||
let tools = vec![ApiDynamicToolSpec {
|
||||
namespace: Some("functions".to_string()),
|
||||
name: "lookup_ticket".to_string(),
|
||||
description: "test".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}),
|
||||
defer_loading: true,
|
||||
}];
|
||||
let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace");
|
||||
assert!(err.contains("functions"), "unexpected error: {err}");
|
||||
assert!(err.contains("Responses API"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_from_stored_thread_preserves_millisecond_precision() {
|
||||
let created_at =
|
||||
|
||||
@@ -239,6 +239,46 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let dynamic_tool = DynamicToolSpec {
|
||||
namespace: Some("codex.app".to_string()),
|
||||
name: "lookup.ticket".to_string(),
|
||||
description: "Invalid dynamic tool".to_string(),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
defer_loading: false,
|
||||
};
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
dynamic_tools: Some(vec![dynamic_tool]),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(error.error.code, -32600);
|
||||
assert!(error.error.message.contains("Responses API"));
|
||||
assert!(error.error.message.contains("lookup.ticket"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exercises the full dynamic tool call path (server request, client response, model output).
|
||||
#[tokio::test]
|
||||
async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {
|
||||
|
||||
Reference in New Issue
Block a user