Compare commits

...

1 Commits

Author SHA1 Message Date
jif-oai
074b7692d8 feat: tools 1 2026-05-12 13:09:51 +01:00
19 changed files with 513 additions and 378 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -3689,6 +3689,7 @@ dependencies = [
name = "codex-tool-api"
version = "0.0.0"
dependencies = [
"codex-protocol",
"pretty_assertions",
"serde_json",
"thiserror 2.0.18",
@@ -3702,6 +3703,7 @@ dependencies = [
"codex-code-mode",
"codex-features",
"codex-protocol",
"codex-tool-api",
"codex-utils-absolute-path",
"codex-utils-pty",
"pretty_assertions",

View File

@@ -3,9 +3,7 @@ use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_tool_api::ToolBundle as ExtensionToolBundle;
use codex_tool_api::ToolError as ExtensionToolError;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde_json::Value;
use crate::function_tool::FunctionCallError;
@@ -52,12 +50,11 @@ impl ToolOutput for BundledToolOutput {
pub(crate) struct BundledToolHandler {
bundle: ExtensionToolBundle,
spec: ToolSpec,
}
impl BundledToolHandler {
pub(crate) fn new(bundle: ExtensionToolBundle, spec: ToolSpec) -> Self {
Self { bundle, spec }
pub(crate) fn new(bundle: ExtensionToolBundle) -> Self {
Self { bundle }
}
fn arguments_from_payload<'a>(&self, payload: &'a ToolPayload) -> Option<&'a str> {
@@ -75,10 +72,6 @@ impl ToolHandler for BundledToolHandler {
ToolName::plain(self.bundle.tool_name())
}
fn spec(&self) -> Option<ToolSpec> {
Some(self.spec.clone())
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
self.arguments_from_payload(payload).is_some()
}
@@ -133,19 +126,6 @@ impl ToolHandler for BundledToolHandler {
}
}
pub(crate) fn extension_tool_spec(
spec: &codex_tool_api::FunctionToolSpec,
) -> Result<ToolSpec, serde_json::Error> {
Ok(ToolSpec::Function(ResponsesApiTool {
name: spec.name.clone(),
description: spec.description.clone(),
strict: spec.strict,
defer_loading: None,
parameters: codex_tools::parse_tool_input_schema(&spec.parameters)?,
output_schema: None,
}))
}
fn map_extension_tool_error(error: ExtensionToolError) -> FunctionCallError {
match error {
ExtensionToolError::RespondToModel(message) => FunctionCallError::RespondToModel(message),
@@ -170,7 +150,6 @@ mod tests {
use super::BundledToolHandler;
use super::BundledToolOutput;
use super::extension_tool_spec;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
@@ -206,8 +185,7 @@ mod tests {
},
Arc::new(StubExtensionExecutor),
);
let spec = extension_tool_spec(bundle.spec()).expect("extension spec should convert");
let handler = BundledToolHandler::new(bundle, spec);
let handler = BundledToolHandler::new(bundle);
let (session, turn) = crate::session::tests::make_session_and_context().await;
let invocation = ToolInvocation {
session: session.into(),

View File

@@ -17,14 +17,11 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::flat_tool_name;
use crate::tools::handlers::extension_tools::BundledToolHandler;
use crate::tools::handlers::extension_tools::extension_tool_spec;
use crate::tools::hook_names::HookToolName;
use crate::tools::tool_dispatch_trace::ToolDispatchTrace;
use crate::util::error_or_panic;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::EventMsg;
use codex_tool_api::ToolBundle as ExtensionToolBundle;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_utils_readiness::Readiness;
@@ -180,7 +177,7 @@ pub(crate) struct PostToolUsePayload {
pub(crate) tool_response: Value,
}
trait AnyToolHandler: Send + Sync {
pub(crate) trait AnyToolHandler: Send + Sync {
fn supports_parallel_tool_calls(&self) -> bool;
fn matches_kind(&self, payload: &ToolPayload) -> bool;
@@ -593,26 +590,18 @@ impl ToolRegistryBuilder {
self.handlers.insert(name, handler);
}
pub fn register_tool_bundle(&mut self, bundle: ExtensionToolBundle) {
let tool_name = ToolName::plain(bundle.tool_name());
pub(crate) fn register_erased_handler(
&mut self,
tool_name: ToolName,
handler: Arc<dyn AnyToolHandler>,
) -> bool {
if self.handlers.contains_key(&tool_name) {
warn!("Skipping extension tool `{tool_name}`: handler already registered");
return;
warn!("Skipping tool handler `{tool_name}`: handler already registered");
return false;
}
let spec = match extension_tool_spec(bundle.spec()) {
Ok(spec) => spec,
Err(error) => {
error_or_panic(format!(
"failed to convert extension tool `{tool_name}` to a host spec: {error}"
));
return;
}
};
self.push_spec(spec.clone());
let handler: Arc<dyn AnyToolHandler> = Arc::new(BundledToolHandler::new(bundle, spec));
self.handlers.insert(tool_name, handler);
true
}
pub(crate) fn specs(&self) -> &[ToolSpec] {

View File

@@ -27,6 +27,7 @@ use crate::tools::handlers::ViewImageHandler;
use crate::tools::handlers::WriteStdinHandler;
use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler;
use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler;
use crate::tools::handlers::extension_tools::BundledToolHandler;
use crate::tools::handlers::multi_agents::CloseAgentHandler;
use crate::tools::handlers::multi_agents::ResumeAgentHandler;
use crate::tools::handlers::multi_agents::SendInputHandler;
@@ -44,13 +45,15 @@ use crate::tools::handlers::view_image_spec::ViewImageToolOptions;
use crate::tools::hosted_spec::WebSearchToolOptions;
use crate::tools::hosted_spec::create_image_generation_tool;
use crate::tools::hosted_spec::create_web_search_tool;
use crate::tools::registry::AnyToolHandler;
use crate::tools::registry::ToolRegistryBuilder;
use crate::tools::spec_plan_types::ToolNamespace;
use crate::tools::spec_plan_types::ToolRegistryBuildParams;
use crate::tools::spec_plan_types::agent_type_description;
use codex_mcp::ToolInfo;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tool_api::ToolBundle as ExtensionToolBundle;
use codex_tool_api::ToolDefinition;
use codex_tools::ToolEnvironmentMode;
use codex_tools::ToolName;
use codex_tools::ToolSearchSource;
@@ -61,8 +64,9 @@ use codex_tools::coalesce_loadable_tool_specs;
use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
use codex_tools::collect_tool_search_source_infos;
use codex_tools::default_namespace_description;
use codex_tools::dynamic_tool_to_loadable_tool_spec;
use codex_tools::mcp_tool_to_responses_api_tool;
use codex_tools::mcp_tool_definition;
use codex_tools::parse_dynamic_tool;
use codex_tools::tool_definition_to_loadable_tool_spec;
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::sync::Arc;
@@ -333,88 +337,15 @@ pub fn build_tool_registry_builder(
}
}
if let Some(mcp_tools) = params.mcp_tools {
let mut entries = mcp_tools
.iter()
.map(|tool| (tool.canonical_tool_name(), tool))
.collect::<Vec<_>>();
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
let mut namespace_entries = BTreeMap::new();
for (tool_name, tool) in entries {
let Some(namespace) = tool_name.namespace.as_ref() else {
tracing::error!("Skipping MCP tool `{tool_name}`: MCP tools must be namespaced");
continue;
};
namespace_entries
.entry(namespace.clone())
.or_insert_with(Vec::new)
.push((tool_name, tool));
}
for (namespace, mut entries) in namespace_entries {
entries.sort_by_key(|(tool_name, _)| tool_name.name.clone());
let tool_namespace = params
.tool_namespaces
.and_then(|namespaces| namespaces.get(&namespace));
let description = tool_namespace
.and_then(|namespace| namespace.description.as_deref())
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
let namespace_name = tool_namespace
.map(|namespace| namespace.name.as_str())
.unwrap_or(namespace.as_str());
default_namespace_description(namespace_name)
});
let mut tools = Vec::new();
for (tool_name, tool) in entries {
match mcp_tool_to_responses_api_tool(&tool_name, &tool.tool) {
Ok(converted_tool) => {
tools.push(ResponsesApiNamespaceTool::Function(converted_tool));
builder.register_handler(Arc::new(McpHandler::new(tool.clone())));
}
Err(error) => {
tracing::error!(
"Failed to convert `{tool_name}` MCP tool to OpenAI tool: {error:?}"
);
}
}
}
if config.namespace_tools && !tools.is_empty() {
builder.push_spec(ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace,
description,
tools,
}));
}
}
}
let mut dynamic_tool_specs = Vec::new();
for tool in params.dynamic_tools {
match dynamic_tool_to_loadable_tool_spec(tool) {
Ok(loadable_tool) => {
let handler_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
dynamic_tool_specs.push(loadable_tool);
builder.register_handler(Arc::new(DynamicToolHandler::new(handler_name)));
}
Err(error) => {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool: {error:?}",
tool.name
);
}
}
}
for spec in coalesce_loadable_tool_specs(dynamic_tool_specs) {
let spec = spec.into();
if config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_)) {
builder.push_spec(spec);
}
}
register_and_publish_tool_definitions(
&mut builder,
config,
params.tool_namespaces,
mcp_tool_definitions(params.mcp_tools)
.into_iter()
.chain(dynamic_tool_definitions(params.dynamic_tools))
.chain(extension_tool_definitions(params.extension_tool_bundles)),
);
if let Some(deferred_mcp_tools) = params.deferred_mcp_tools {
let directly_registered_mcp_tools = params
@@ -425,16 +356,129 @@ pub fn build_tool_registry_builder(
.collect::<HashSet<_>>();
for tool in deferred_mcp_tools {
if !directly_registered_mcp_tools.contains(&tool.canonical_tool_name()) {
builder.register_handler(Arc::new(McpHandler::new(tool.clone())));
let definition = mcp_tool_definition(tool.canonical_tool_name(), &tool.tool)
.deferred()
.with_runtime(
Arc::new(McpHandler::new(tool.clone())) as Arc<dyn AnyToolHandler>
);
register_tool_definition_handler(&mut builder, &definition);
}
}
}
for bundle in params.extension_tool_bundles.iter().cloned() {
builder.register_tool_bundle(bundle);
builder
}
type RuntimeToolDefinition = ToolDefinition<Arc<dyn AnyToolHandler>>;
fn register_and_publish_tool_definitions(
builder: &mut ToolRegistryBuilder,
config: &ToolsConfig,
tool_namespaces: Option<&std::collections::HashMap<String, ToolNamespace>>,
definitions: impl IntoIterator<Item = RuntimeToolDefinition>,
) {
let mut loadable_specs = Vec::new();
for definition in definitions {
let tool_name = definition.tool_name().clone();
let namespace_description = namespace_description_for_tool(&tool_name, tool_namespaces);
let loadable_spec =
match tool_definition_to_loadable_tool_spec(&definition, namespace_description) {
Ok(spec) => spec,
Err(error) => {
tracing::error!(
"Failed to convert tool `{tool_name}` to OpenAI tool: {error:?}"
);
continue;
}
};
if register_tool_definition_handler(builder, &definition) {
loadable_specs.push(loadable_spec);
}
}
builder
for spec in coalesce_loadable_tool_specs(loadable_specs) {
let spec = spec.into();
if config.namespace_tools || !matches!(spec, ToolSpec::Namespace(_)) {
builder.push_spec(spec);
}
}
}
fn register_tool_definition_handler(
builder: &mut ToolRegistryBuilder,
definition: &RuntimeToolDefinition,
) -> bool {
builder.register_erased_handler(
definition.tool_name().clone(),
Arc::clone(definition.runtime()),
)
}
fn mcp_tool_definitions(mcp_tools: Option<&[ToolInfo]>) -> Vec<RuntimeToolDefinition> {
let mut tools = mcp_tools.into_iter().flatten().collect::<Vec<_>>();
tools.sort_by_key(|tool| tool.canonical_tool_name());
tools
.into_iter()
.filter_map(|tool| {
let tool_name = tool.canonical_tool_name();
if tool_name.namespace.is_none() {
tracing::error!("Skipping MCP tool `{tool_name}`: MCP tools must be namespaced");
return None;
}
Some(
mcp_tool_definition(tool_name, &tool.tool)
.with_runtime(
Arc::new(McpHandler::new(tool.clone())) as Arc<dyn AnyToolHandler>
),
)
})
.collect()
}
fn dynamic_tool_definitions(
dynamic_tools: &[codex_protocol::dynamic_tools::DynamicToolSpec],
) -> Vec<RuntimeToolDefinition> {
dynamic_tools
.iter()
.map(|tool| {
let definition = parse_dynamic_tool(tool);
let handler = Arc::new(DynamicToolHandler::new(definition.tool_name().clone()))
as Arc<dyn AnyToolHandler>;
definition.with_runtime(handler)
})
.collect()
}
fn extension_tool_definitions(
extension_tool_bundles: &[ExtensionToolBundle],
) -> Vec<RuntimeToolDefinition> {
extension_tool_bundles
.iter()
.map(|bundle| {
let handler =
Arc::new(BundledToolHandler::new(bundle.clone())) as Arc<dyn AnyToolHandler>;
bundle.definition().clone().with_runtime(handler)
})
.collect()
}
fn namespace_description_for_tool(
tool_name: &ToolName,
tool_namespaces: Option<&std::collections::HashMap<String, ToolNamespace>>,
) -> Option<String> {
let namespace = tool_name.namespace.as_ref()?;
let tool_namespace = tool_namespaces.and_then(|namespaces| namespaces.get(namespace));
tool_namespace.map(|tool_namespace| {
tool_namespace
.description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
.unwrap_or_else(|| default_namespace_description(tool_namespace.name.as_str()))
})
}
fn compare_code_mode_tools(

View File

@@ -13,6 +13,7 @@ doctest = false
workspace = true
[dependencies]
codex-protocol = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@@ -8,13 +8,17 @@ Crates that define contributed tools should depend on this crate. It owns:
- the executable bundle contract: `ToolBundle`, `ToolExecutor`, `ToolCall`,
and `ToolError`
- the shared definition envelope: `ToolDefinition`, `ToolExposure`, and
`ToolName`
- the one model-visible spec an extension may contribute directly:
`FunctionToolSpec`
The contract is intentionally narrow: contributed tools receive a call id plus
raw JSON arguments and return a JSON value. If a feature needs richer host
integration, its extension is expected to do that wiring before exposing the
tool rather than widening this crate around the hardest native tools.
The contract is intentionally narrow: a definition keeps one tool's canonical
name, function metadata, exposure mode, and opaque runtime together. Contributed
tools receive a call id plus raw JSON arguments and return a JSON value. If a
feature needs richer host integration, its extension is expected to do that
wiring before exposing the tool rather than widening this crate around the
hardest native tools.
The intended dependency direction is:

View File

@@ -6,7 +6,9 @@ use serde_json::Value;
use crate::FunctionToolSpec;
use crate::ToolCall;
use crate::ToolDefinition;
use crate::ToolError;
use crate::ToolName;
/// Future returned by one contributed function-tool invocation.
pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, ToolError>> + Send + 'a>>;
@@ -15,29 +17,36 @@ pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, ToolError>>
/// function tool.
#[derive(Clone)]
pub struct ToolBundle {
spec: FunctionToolSpec,
executor: Arc<dyn ToolExecutor>,
definition: ToolDefinition<Arc<dyn ToolExecutor>>,
}
impl ToolBundle {
/// Creates one contributed function-tool bundle.
pub fn new(spec: FunctionToolSpec, executor: Arc<dyn ToolExecutor>) -> Self {
Self { spec, executor }
let tool_name = ToolName::plain(spec.name.clone());
Self {
definition: ToolDefinition::new(tool_name, spec, executor),
}
}
/// Returns the contributed function-tool spec.
pub fn spec(&self) -> &FunctionToolSpec {
&self.spec
self.definition.spec()
}
/// Returns the contributed function-tool name.
pub fn tool_name(&self) -> &str {
self.spec.name.as_str()
self.definition.tool_name().name.as_str()
}
/// Returns the executable implementation.
pub fn executor(&self) -> Arc<dyn ToolExecutor> {
Arc::clone(&self.executor)
Arc::clone(self.definition.runtime())
}
/// Returns the shared definition behind this extension-owned bundle.
pub fn definition(&self) -> &ToolDefinition<Arc<dyn ToolExecutor>> {
&self.definition
}
}

View File

@@ -0,0 +1,134 @@
use codex_protocol::ToolName;
use serde_json::Value;
use crate::FunctionToolSpec;
/// One callable function tool, its exposure mode, and the runtime object that
/// executes it.
#[derive(Clone, Debug, PartialEq)]
pub struct ToolDefinition<R> {
tool_name: ToolName,
spec: FunctionToolSpec,
output_schema: Option<Value>,
exposure: ToolExposure,
runtime: R,
}
/// Whether a tool is advertised immediately or marked for deferred loading.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ToolExposure {
#[default]
Published,
Deferred,
}
impl<R> ToolDefinition<R> {
/// Creates one immediately-published function tool definition.
pub fn new(tool_name: ToolName, spec: FunctionToolSpec, runtime: R) -> Self {
debug_assert_eq!(
tool_name.name, spec.name,
"tool definition name must match its function spec name"
);
Self {
tool_name,
spec,
output_schema: None,
exposure: ToolExposure::Published,
runtime,
}
}
/// Returns the callable tool name, including any namespace.
pub fn tool_name(&self) -> &ToolName {
&self.tool_name
}
/// Returns the function-tool metadata exposed to the model.
pub fn spec(&self) -> &FunctionToolSpec {
&self.spec
}
/// Returns the optional tool-output schema kept alongside the model spec.
pub fn output_schema(&self) -> Option<&Value> {
self.output_schema.as_ref()
}
/// Returns how this tool should be exposed to the model.
pub fn exposure(&self) -> ToolExposure {
self.exposure
}
/// Returns the runtime object bound to this tool definition.
pub fn runtime(&self) -> &R {
&self.runtime
}
/// Rebinds the same tool definition to a different runtime object.
pub fn with_runtime<S>(self, runtime: S) -> ToolDefinition<S> {
ToolDefinition {
tool_name: self.tool_name,
spec: self.spec,
output_schema: self.output_schema,
exposure: self.exposure,
runtime,
}
}
/// Attaches a tool-output schema.
pub fn with_output_schema(mut self, output_schema: Value) -> Self {
self.output_schema = Some(output_schema);
self
}
/// Marks this tool as deferred. Deferred tools intentionally omit output
/// schema metadata until they are loaded.
pub fn deferred(mut self) -> Self {
self.exposure = ToolExposure::Deferred;
self.output_schema = None;
self
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::ToolDefinition;
use super::ToolExposure;
use crate::FunctionToolSpec;
use crate::ToolName;
fn definition() -> ToolDefinition<()> {
ToolDefinition::new(
ToolName::namespaced("mcp__calendar", "create_event"),
FunctionToolSpec {
name: "create_event".to_string(),
description: "Create an event.".to_string(),
strict: false,
parameters: json!({ "type": "object" }),
},
(),
)
.with_output_schema(json!({ "type": "object" }))
}
#[test]
fn deferred_tools_drop_output_schema() {
let definition = definition().deferred();
assert_eq!(definition.exposure(), ToolExposure::Deferred);
assert_eq!(definition.output_schema(), None);
}
#[test]
fn runtime_can_be_rebound_without_rebuilding_metadata() {
let definition = definition().with_runtime("handler");
assert_eq!(
definition.tool_name(),
&ToolName::namespaced("mcp__calendar", "create_event")
);
assert_eq!(definition.runtime(), &"handler");
}
}

View File

@@ -3,6 +3,7 @@
mod bundle;
mod call;
mod definition;
mod error;
mod spec;
@@ -10,5 +11,8 @@ pub use bundle::ToolBundle;
pub use bundle::ToolExecutor;
pub use bundle::ToolFuture;
pub use call::ToolCall;
pub use codex_protocol::ToolName;
pub use definition::ToolDefinition;
pub use definition::ToolExposure;
pub use error::ToolError;
pub use spec::FunctionToolSpec;

View File

@@ -12,6 +12,7 @@ codex-app-server-protocol = { workspace = true }
codex-code-mode = { workspace = true }
codex-features = { workspace = true }
codex-protocol = { workspace = true }
codex-tool-api = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [

View File

@@ -1,15 +1,25 @@
use crate::ToolDefinition;
use crate::parse_tool_input_schema;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tool_api::FunctionToolSpec;
use codex_tool_api::ToolDefinition;
use codex_tool_api::ToolName;
pub fn parse_dynamic_tool(tool: &DynamicToolSpec) -> Result<ToolDefinition, serde_json::Error> {
Ok(ToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
input_schema: parse_tool_input_schema(&tool.input_schema)?,
output_schema: None,
defer_loading: tool.defer_loading,
})
pub fn parse_dynamic_tool(tool: &DynamicToolSpec) -> ToolDefinition<()> {
let definition = ToolDefinition::new(
ToolName::new(tool.namespace.clone(), tool.name.clone()),
FunctionToolSpec {
name: tool.name.clone(),
description: tool.description.clone(),
strict: false,
parameters: tool.input_schema.clone(),
},
(),
);
if tool.defer_loading {
definition.deferred()
} else {
definition
}
}
#[cfg(test)]

View File

@@ -1,12 +1,13 @@
use super::parse_dynamic_tool;
use crate::JsonSchema;
use crate::ToolDefinition;
use crate::ToolExposure;
use crate::ToolName;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tool_api::FunctionToolSpec;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn parse_dynamic_tool_sanitizes_input_schema() {
fn parse_dynamic_tool_preserves_definition_metadata() {
let tool = DynamicToolSpec {
namespace: None,
name: "lookup_ticket".to_string(),
@@ -22,21 +23,23 @@ fn parse_dynamic_tool_sanitizes_input_schema() {
};
assert_eq!(
parse_dynamic_tool(&tool).expect("parse dynamic tool"),
ToolDefinition {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: JsonSchema::object(
BTreeMap::from([(
"id".to_string(),
JsonSchema::string(Some("Ticket identifier".to_string()),),
)]),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
defer_loading: false,
}
parse_dynamic_tool(&tool),
ToolDefinition::new(
ToolName::plain("lookup_ticket"),
FunctionToolSpec {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
strict: false,
parameters: serde_json::json!({
"properties": {
"id": {
"description": "Ticket identifier"
}
}
}),
},
(),
)
);
}
@@ -53,18 +56,8 @@ fn parse_dynamic_tool_preserves_defer_loading() {
defer_loading: true,
};
assert_eq!(
parse_dynamic_tool(&tool).expect("parse dynamic tool"),
ToolDefinition {
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: None,
defer_loading: true,
}
);
let definition = parse_dynamic_tool(&tool);
assert_eq!(definition.exposure(), ToolExposure::Deferred);
assert_eq!(definition.tool_name(), &ToolName::plain("lookup_ticket"));
}

View File

@@ -9,7 +9,6 @@ mod mcp_tool;
mod request_plugin_install;
mod responses_api;
mod tool_config;
mod tool_definition;
mod tool_discovery;
mod tool_spec;
@@ -19,6 +18,8 @@ pub use code_mode::collect_code_mode_exec_prompt_tool_definitions;
pub use code_mode::collect_code_mode_tool_definitions;
pub use code_mode::tool_spec_to_code_mode_tool_definition;
pub use codex_protocol::ToolName;
pub use codex_tool_api::ToolDefinition;
pub use codex_tool_api::ToolExposure;
pub use dynamic_tool::parse_dynamic_tool;
pub use image_detail::can_request_original_image_detail;
pub use image_detail::normalize_output_image_detail;
@@ -29,7 +30,7 @@ pub use json_schema::JsonSchemaPrimitiveType;
pub use json_schema::JsonSchemaType;
pub use json_schema::parse_tool_input_schema;
pub use mcp_tool::mcp_call_tool_result_output_schema;
pub use mcp_tool::parse_mcp_tool;
pub use mcp_tool::mcp_tool_definition;
pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_APPROVAL_KIND_VALUE;
pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE;
pub use request_plugin_install::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;
@@ -50,7 +51,7 @@ pub use responses_api::default_namespace_description;
pub use responses_api::dynamic_tool_to_loadable_tool_spec;
pub use responses_api::dynamic_tool_to_responses_api_tool;
pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
pub use responses_api::tool_definition_to_loadable_tool_spec;
pub use responses_api::tool_definition_to_responses_api_tool;
pub use tool_config::ShellCommandBackendConfig;
pub use tool_config::ToolEnvironmentMode;
@@ -60,7 +61,6 @@ pub use tool_config::ToolsConfigParams;
pub use tool_config::UnifiedExecShellMode;
pub use tool_config::ZshForkConfig;
pub use tool_config::request_user_input_available_modes;
pub use tool_definition::ToolDefinition;
pub use tool_discovery::DiscoverablePluginInfo;
pub use tool_discovery::DiscoverableTool;
pub use tool_discovery::DiscoverableToolAction;

View File

@@ -1,9 +1,10 @@
use crate::ToolDefinition;
use crate::parse_tool_input_schema;
use codex_tool_api::FunctionToolSpec;
use codex_tool_api::ToolDefinition;
use codex_tool_api::ToolName;
use serde_json::Value as JsonValue;
use serde_json::json;
pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result<ToolDefinition, serde_json::Error> {
pub fn mcp_tool_definition(tool_name: ToolName, tool: &rmcp::model::Tool) -> ToolDefinition<()> {
let mut serialized_input_schema = serde_json::Value::Object(tool.input_schema.as_ref().clone());
// OpenAI models mandate the "properties" field in the schema. Some MCP
@@ -18,22 +19,25 @@ pub fn parse_mcp_tool(tool: &rmcp::model::Tool) -> Result<ToolDefinition, serde_
);
}
let input_schema = parse_tool_input_schema(&serialized_input_schema)?;
let structured_content_schema = tool
.output_schema
.as_ref()
.map(|output_schema| serde_json::Value::Object(output_schema.as_ref().clone()))
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()));
Ok(ToolDefinition {
name: tool.name.to_string(),
description: tool.description.clone().map(Into::into).unwrap_or_default(),
input_schema,
output_schema: Some(mcp_call_tool_result_output_schema(
structured_content_schema,
)),
defer_loading: false,
})
ToolDefinition::new(
tool_name.clone(),
FunctionToolSpec {
name: tool_name.name,
description: tool.description.clone().map(Into::into).unwrap_or_default(),
strict: false,
parameters: serialized_input_schema,
},
(),
)
.with_output_schema(mcp_call_tool_result_output_schema(
structured_content_schema,
))
}
pub fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue {

View File

@@ -1,9 +1,9 @@
use super::mcp_call_tool_result_output_schema;
use super::parse_mcp_tool;
use crate::JsonSchema;
use super::mcp_tool_definition;
use crate::ToolDefinition;
use crate::ToolName;
use codex_tool_api::FunctionToolSpec;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool {
rmcp::model::Tool {
@@ -30,21 +30,42 @@ fn parse_mcp_tool_inserts_empty_properties() {
);
assert_eq!(
parse_mcp_tool(&tool).expect("parse MCP tool"),
ToolDefinition {
name: "no_props".to_string(),
description: "No properties".to_string(),
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
defer_loading: false,
}
mcp_tool_definition(ToolName::plain("no_props"), &tool),
ToolDefinition::new(
ToolName::plain("no_props"),
FunctionToolSpec {
name: "no_props".to_string(),
description: "No properties".to_string(),
strict: false,
parameters: serde_json::json!({
"type": "object",
"properties": {}
}),
},
(),
)
.with_output_schema(mcp_call_tool_result_output_schema(serde_json::json!({})))
);
}
#[test]
fn mcp_tool_definition_uses_callable_name() {
let tool = mcp_tool(
"calendar_create_event",
"Create an event",
serde_json::json!({
"type": "object"
}),
);
let definition = mcp_tool_definition(
ToolName::namespaced("mcp__codex_apps__calendar", "_create_event"),
&tool,
);
assert_eq!(definition.spec().name, "_create_event");
}
#[test]
fn parse_mcp_tool_preserves_top_level_output_schema() {
let mut tool = mcp_tool(
@@ -68,27 +89,30 @@ fn parse_mcp_tool_preserves_top_level_output_schema() {
)));
assert_eq!(
parse_mcp_tool(&tool).expect("parse MCP tool"),
ToolDefinition {
name: "with_output".to_string(),
description: "Has output schema".to_string(),
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({
"properties": {
"result": {
"properties": {
"nested": {}
}
mcp_tool_definition(ToolName::plain("with_output"), &tool),
ToolDefinition::new(
ToolName::plain("with_output"),
FunctionToolSpec {
name: "with_output".to_string(),
description: "Has output schema".to_string(),
strict: false,
parameters: serde_json::json!({
"type": "object",
"properties": {}
}),
},
(),
)
.with_output_schema(mcp_call_tool_result_output_schema(serde_json::json!({
"properties": {
"result": {
"properties": {
"nested": {}
}
},
"required": ["result"]
}))),
defer_loading: false,
}
}
},
"required": ["result"]
})))
);
}
@@ -108,19 +132,22 @@ fn parse_mcp_tool_preserves_output_schema_without_inferred_type() {
)));
assert_eq!(
parse_mcp_tool(&tool).expect("parse MCP tool"),
ToolDefinition {
name: "with_enum_output".to_string(),
description: "Has enum output schema".to_string(),
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({
"enum": ["ok", "error"]
}))),
defer_loading: false,
}
mcp_tool_definition(ToolName::plain("with_enum_output"), &tool),
ToolDefinition::new(
ToolName::plain("with_enum_output"),
FunctionToolSpec {
name: "with_enum_output".to_string(),
description: "Has enum output schema".to_string(),
strict: false,
parameters: serde_json::json!({
"type": "object",
"properties": {}
}),
},
(),
)
.with_output_schema(mcp_call_tool_result_output_schema(serde_json::json!({
"enum": ["ok", "error"]
})))
);
}

View File

@@ -1,9 +1,11 @@
use crate::JsonSchema;
use crate::ToolDefinition;
use crate::ToolName;
use crate::mcp_tool_definition;
use crate::parse_dynamic_tool;
use crate::parse_mcp_tool;
use crate::parse_tool_input_schema;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tool_api::ToolDefinition;
use codex_tool_api::ToolExposure;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
@@ -69,24 +71,16 @@ pub enum ResponsesApiNamespaceTool {
pub fn dynamic_tool_to_responses_api_tool(
tool: &DynamicToolSpec,
) -> Result<ResponsesApiTool, serde_json::Error> {
Ok(tool_definition_to_responses_api_tool(parse_dynamic_tool(
tool,
)?))
tool_definition_to_responses_api_tool(&parse_dynamic_tool(tool))
}
pub fn dynamic_tool_to_loadable_tool_spec(
tool: &DynamicToolSpec,
) -> Result<LoadableToolSpec, serde_json::Error> {
let output_tool = dynamic_tool_to_responses_api_tool(tool)?;
Ok(match tool.namespace.as_ref() {
Some(namespace) => LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.clone(),
// the user doesn't provide a description for dynamic tools, so we use the default
description: default_namespace_description(namespace),
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
}),
None => LoadableToolSpec::Function(output_tool),
})
tool_definition_to_loadable_tool_spec(
&parse_dynamic_tool(tool),
/*namespace_description*/ None,
)
}
pub fn coalesce_loadable_tool_specs(
@@ -119,35 +113,41 @@ pub fn coalesce_loadable_tool_specs(
coalesced_specs
}
pub fn mcp_tool_to_responses_api_tool(
tool_name: &ToolName,
tool: &rmcp::model::Tool,
) -> Result<ResponsesApiTool, serde_json::Error> {
Ok(tool_definition_to_responses_api_tool(
parse_mcp_tool(tool)?.renamed(tool_name.name.clone()),
))
}
pub fn mcp_tool_to_deferred_responses_api_tool(
tool_name: &ToolName,
tool: &rmcp::model::Tool,
) -> Result<ResponsesApiTool, serde_json::Error> {
Ok(tool_definition_to_responses_api_tool(
parse_mcp_tool(tool)?
.renamed(tool_name.name.clone())
.into_deferred(),
))
tool_definition_to_responses_api_tool(&mcp_tool_definition(tool_name.clone(), tool).deferred())
}
pub fn tool_definition_to_responses_api_tool(tool_definition: ToolDefinition) -> ResponsesApiTool {
ResponsesApiTool {
name: tool_definition.name,
description: tool_definition.description,
strict: false,
defer_loading: tool_definition.defer_loading.then_some(true),
parameters: tool_definition.input_schema,
output_schema: tool_definition.output_schema,
}
pub fn tool_definition_to_responses_api_tool<R>(
tool_definition: &ToolDefinition<R>,
) -> Result<ResponsesApiTool, serde_json::Error> {
let spec = tool_definition.spec();
Ok(ResponsesApiTool {
name: tool_definition.tool_name().name.clone(),
description: spec.description.clone(),
strict: spec.strict,
defer_loading: matches!(tool_definition.exposure(), ToolExposure::Deferred).then_some(true),
parameters: parse_tool_input_schema(&spec.parameters)?,
output_schema: tool_definition.output_schema().cloned(),
})
}
pub fn tool_definition_to_loadable_tool_spec<R>(
tool_definition: &ToolDefinition<R>,
namespace_description: Option<String>,
) -> Result<LoadableToolSpec, serde_json::Error> {
let output_tool = tool_definition_to_responses_api_tool(tool_definition)?;
Ok(match tool_definition.tool_name().namespace.as_ref() {
Some(namespace) => LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.clone(),
description: namespace_description
.unwrap_or_else(|| default_namespace_description(namespace)),
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
}),
None => LoadableToolSpec::Function(output_tool),
})
}
#[cfg(test)]

View File

@@ -9,6 +9,7 @@ use crate::JsonSchema;
use crate::ToolDefinition;
use crate::ToolName;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tool_api::FunctionToolSpec;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
@@ -16,20 +17,27 @@ use std::collections::BTreeMap;
#[test]
fn tool_definition_to_responses_api_tool_omits_false_defer_loading() {
assert_eq!(
tool_definition_to_responses_api_tool(ToolDefinition {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
input_schema: JsonSchema::object(
BTreeMap::from([(
"order_id".to_string(),
JsonSchema::string(/*description*/ None),
)]),
Some(vec!["order_id".to_string()]),
Some(false.into())
),
output_schema: Some(json!({"type": "object"})),
defer_loading: false,
}),
tool_definition_to_responses_api_tool(
&ToolDefinition::new(
ToolName::plain("lookup_order"),
FunctionToolSpec {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
parameters: json!({
"type": "object",
"properties": {
"order_id": {"type": "string"}
},
"required": ["order_id"],
"additionalProperties": false,
}),
},
(),
)
.with_output_schema(json!({"type": "object"})),
)
.expect("convert definition"),
ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),

View File

@@ -1,30 +0,0 @@
use crate::JsonSchema;
use serde_json::Value as JsonValue;
/// Tool metadata and schemas that downstream crates can adapt into higher-level
/// tool specs.
#[derive(Debug, PartialEq)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub input_schema: JsonSchema,
pub output_schema: Option<JsonValue>,
pub defer_loading: bool,
}
impl ToolDefinition {
pub fn renamed(mut self, name: String) -> Self {
self.name = name;
self
}
pub fn into_deferred(mut self) -> Self {
self.output_schema = None;
self.defer_loading = true;
self
}
}
#[cfg(test)]
#[path = "tool_definition_tests.rs"]
mod tests;

View File

@@ -1,43 +0,0 @@
use super::ToolDefinition;
use crate::JsonSchema;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn tool_definition() -> ToolDefinition {
ToolDefinition {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
input_schema: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
output_schema: Some(serde_json::json!({
"type": "object",
})),
defer_loading: false,
}
}
#[test]
fn renamed_overrides_name_only() {
assert_eq!(
tool_definition().renamed("mcp__orders__lookup_order".to_string()),
ToolDefinition {
name: "mcp__orders__lookup_order".to_string(),
..tool_definition()
}
);
}
#[test]
fn into_deferred_drops_output_schema_and_sets_defer_loading() {
assert_eq!(
tool_definition().into_deferred(),
ToolDefinition {
output_schema: None,
defer_loading: true,
..tool_definition()
}
);
}