feat: dynamic tools injection (#9539)

## Summary
Add dynamic tool injection to thread startup in API v2, wire dynamic
tool calls through the app server to clients, and plumb responses back
into the model tool pipeline.

### Flow (high level)
- Thread start injects `dynamic_tools` into the model tool list for that
thread (validation is done here).
- When the model emits a tool call for one of those names, core raises a
`DynamicToolCallRequest` event.
- The app server forwards it to the client as `item/tool/call`, waits
for the client’s response, then submits a `DynamicToolResponse` back to
core.
- Core turns that into a `function_call_output` in the next model
request so the model can continue.

### What changed
- Added dynamic tool specs to v2 thread start params and protocol types;
introduced `item/tool/call` (request/response) for dynamic tool
execution.
- Core now registers dynamic tool specs at request time and routes those
calls via a new dynamic tool handler.
- App server validates tool names/schemas, forwards dynamic tool call
requests to clients, and publishes tool outputs back into the session.
- Integration tests
This commit is contained in:
jif-oai
2026-01-26 11:06:44 +01:00
committed by GitHub
parent 25fccc3d4d
commit d594693d1a
25 changed files with 864 additions and 49 deletions

View File

@@ -10,6 +10,7 @@ use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS;
use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS;
use crate::tools::registry::ToolRegistryBuilder;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
@@ -87,7 +88,7 @@ impl ToolsConfig {
/// Generic JSONSchema subset needed for our tool definitions
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub(crate) enum JsonSchema {
pub enum JsonSchema {
Boolean {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
@@ -123,7 +124,7 @@ pub(crate) enum JsonSchema {
/// Whether additional properties are allowed, and if so, any required schema
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum AdditionalProperties {
pub enum AdditionalProperties {
Boolean(bool),
Schema(Box<JsonSchema>),
}
@@ -1101,6 +1102,26 @@ pub(crate) fn mcp_tool_to_openai_tool(
})
}
fn dynamic_tool_to_openai_tool(
tool: &DynamicToolSpec,
) -> Result<ResponsesApiTool, serde_json::Error> {
let input_schema = parse_tool_input_schema(&tool.input_schema)?;
Ok(ResponsesApiTool {
name: tool.name.clone(),
description: tool.description.clone(),
strict: false,
parameters: input_schema,
})
}
/// Parse the tool input_schema or return an error for invalid schema
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
let mut input_schema = input_schema.clone();
sanitize_json_schema(&mut input_schema);
serde_json::from_value::<JsonSchema>(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
@@ -1216,9 +1237,11 @@ fn sanitize_json_schema(value: &mut JsonValue) {
pub(crate) fn build_specs(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, mcp_types::Tool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::CollabHandler;
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::GrepFilesHandler;
use crate::tools::handlers::ListDirHandler;
use crate::tools::handlers::McpHandler;
@@ -1239,6 +1262,7 @@ pub(crate) fn build_specs(
let unified_exec_handler = Arc::new(UnifiedExecHandler);
let plan_handler = Arc::new(PlanHandler);
let apply_patch_handler = Arc::new(ApplyPatchHandler);
let dynamic_tool_handler = Arc::new(DynamicToolHandler);
let view_image_handler = Arc::new(ViewImageHandler);
let mcp_handler = Arc::new(McpHandler);
let mcp_resource_handler = Arc::new(McpResourceHandler);
@@ -1384,6 +1408,23 @@ pub(crate) fn build_specs(
}
}
if !dynamic_tools.is_empty() {
for tool in dynamic_tools {
match dynamic_tool_to_openai_tool(tool) {
Ok(converted_tool) => {
builder.push_spec(ToolSpec::Function(converted_tool));
builder.register_handler(tool.name.clone(), dynamic_tool_handler.clone());
}
Err(e) => {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool: {e:?}",
tool.name
);
}
}
}
}
builder
}
@@ -1496,7 +1537,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Live),
});
let (tools, _) = build_specs(&config, None).build();
let (tools, _) = build_specs(&config, None, &[]).build();
// Build actual map name -> spec
use std::collections::BTreeMap;
@@ -1560,7 +1601,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert_contains_tool_names(
&tools,
&["spawn_agent", "send_input", "wait", "close_agent"],
@@ -1578,7 +1619,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert!(
!tools.iter().any(|t| t.spec.name() == "request_user_input"),
"request_user_input should be disabled when collaboration_modes feature is off"
@@ -1590,7 +1631,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert_contains_tool_names(&tools, &["request_user_input"]);
}
@@ -1607,7 +1648,7 @@ mod tests {
features,
web_search_mode,
});
let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build();
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), &[]).build();
let tool_names = tools.iter().map(|t| t.spec.name()).collect::<Vec<_>>();
assert_eq!(&tool_names, &expected_tools,);
}
@@ -1623,7 +1664,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
let tool = find_tool(&tools, "web_search");
assert_eq!(
@@ -1645,7 +1686,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Live),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
let tool = find_tool(&tools, "web_search");
assert_eq!(
@@ -1891,7 +1932,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Live),
});
let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build();
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), &[]).build();
// Only check the shell variant and a couple of core tools.
let mut subset = vec!["exec_command", "write_stdin", "update_plan"];
@@ -1913,7 +1954,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert!(!find_tool(&tools, "exec_command").supports_parallel_tool_calls);
assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls);
@@ -1932,7 +1973,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert!(
tools
@@ -1999,6 +2040,7 @@ mod tests {
description: Some("Do something cool".to_string()),
},
)])),
&[],
)
.build();
@@ -2108,7 +2150,7 @@ mod tests {
),
]);
let (tools, _) = build_specs(&tools_config, Some(tools_map)).build();
let (tools, _) = build_specs(&tools_config, Some(tools_map), &[]).build();
// Only assert that the MCP tools themselves are sorted by fully-qualified name.
let mcp_names: Vec<_> = tools
@@ -2157,6 +2199,7 @@ mod tests {
description: Some("Search docs".to_string()),
},
)])),
&[],
)
.build();
@@ -2212,6 +2255,7 @@ mod tests {
description: Some("Pagination".to_string()),
},
)])),
&[],
)
.build();
@@ -2266,6 +2310,7 @@ mod tests {
description: Some("Tags".to_string()),
},
)])),
&[],
)
.build();
@@ -2322,6 +2367,7 @@ mod tests {
description: Some("AnyOf Value".to_string()),
},
)])),
&[],
)
.build();
@@ -2459,6 +2505,7 @@ Examples of valid command strings:
description: Some("Do something cool".to_string()),
},
)])),
&[],
)
.build();