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

@@ -31,6 +31,7 @@ use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
use codex_app_server_protocol::ExecOneOffCommandResponse;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
@@ -171,6 +172,7 @@ use codex_login::run_login_server;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentStatus;
@@ -1411,35 +1413,81 @@ impl CodexMessageProcessor {
}
async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) {
let ThreadStartParams {
model,
model_provider,
cwd,
approval_policy,
sandbox,
config,
base_instructions,
developer_instructions,
dynamic_tools,
experimental_raw_events,
personality,
ephemeral,
} = params;
let mut typesafe_overrides = self.build_thread_config_overrides(
params.model,
params.model_provider,
params.cwd,
params.approval_policy,
params.sandbox,
params.base_instructions,
params.developer_instructions,
params.personality,
model,
model_provider,
cwd,
approval_policy,
sandbox,
base_instructions,
developer_instructions,
personality,
);
typesafe_overrides.ephemeral = Some(params.ephemeral.unwrap_or_default());
typesafe_overrides.ephemeral = ephemeral;
let config =
match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let config = match derive_config_from_params(
&self.cli_overrides,
config,
typesafe_overrides,
)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self.thread_manager.start_thread(config).await {
let dynamic_tools = dynamic_tools.unwrap_or_default();
let core_dynamic_tools = if dynamic_tools.is_empty() {
Vec::new()
} else {
let snapshot = collect_mcp_snapshot(&config).await;
let mcp_tool_names = snapshot.tools.keys().cloned().collect::<HashSet<_>>();
if let Err(message) = validate_dynamic_tools(&dynamic_tools, &mcp_tool_names) {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
dynamic_tools
.into_iter()
.map(|tool| CoreDynamicToolSpec {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
})
.collect()
};
match self
.thread_manager
.start_thread_with_tools(config, core_dynamic_tools)
.await
{
Ok(new_conv) => {
let NewThread {
thread_id,
@@ -1489,7 +1537,7 @@ impl CodexMessageProcessor {
if let Err(err) = self
.attach_conversation_listener(
thread_id,
params.experimental_raw_events,
experimental_raw_events,
ApiVersion::V2,
)
.await
@@ -4322,6 +4370,41 @@ fn errors_to_info(
.collect()
}
fn validate_dynamic_tools(
tools: &[ApiDynamicToolSpec],
mcp_tool_names: &HashSet<String>,
) -> Result<(), String> {
let mut seen = HashSet::new();
for tool in tools {
let name = tool.name.trim();
if name.is_empty() {
return Err("dynamic tool name must not be empty".to_string());
}
if name != tool.name {
return Err(format!(
"dynamic tool name has leading/trailing whitespace: {}",
tool.name
));
}
if name == "mcp" || name.starts_with("mcp__") {
return Err(format!("dynamic tool name is reserved: {name}"));
}
if mcp_tool_names.contains(name) {
return Err(format!("dynamic tool name conflicts with MCP tool: {name}"));
}
if !seen.insert(name.to_string()) {
return Err(format!("duplicate dynamic tool name: {name}"));
}
if let Err(err) = codex_core::parse_tool_input_schema(&tool.input_schema) {
return Err(format!(
"dynamic tool input schema is not supported for {name}: {err}"
));
}
}
Ok(())
}
/// Derive the effective [`Config`] by layering three override sources.
///
/// Precedence (lowest to highest):
@@ -4602,6 +4685,28 @@ mod tests {
use serde_json::json;
use tempfile::TempDir;
#[test]
fn validate_dynamic_tools_rejects_unsupported_input_schema() {
let tools = vec![ApiDynamicToolSpec {
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({"type": "null"}),
}];
let err = validate_dynamic_tools(&tools, &HashSet::new()).expect_err("invalid schema");
assert!(err.contains("my_tool"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_accepts_sanitizable_input_schema() {
let tools = vec![ApiDynamicToolSpec {
name: "my_tool".to_string(),
description: "test".to_string(),
// Missing `type` is common; core sanitizes these to a supported schema.
input_schema: json!({"properties": {}}),
}];
validate_dynamic_tools(&tools, &HashSet::new()).expect("valid schema");
}
#[test]
fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> {
let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?;