mirror of
https://github.com/openai/codex.git
synced 2026-04-30 01:16:54 +00:00
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:
@@ -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")?;
|
||||
|
||||
Reference in New Issue
Block a user