mirror of
https://github.com/openai/codex.git
synced 2026-04-30 11:21:34 +03: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:
@@ -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 JSON‑Schema 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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user