mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: wire extension tool bundles into core (#22147)
## Why This is the next narrow step toward moving concrete tool families out of core. After #22138 introduced `codex-tool-api`, we still needed a real end-to-end seam that lets an extension own an executable tool definition once and have core install it without the temporary `extension-api` wrapper or a dependency on `codex-tools`. `codex-tool-api` is the small extension-facing execution contract, while `codex-tools` still has a different job: host-side shared tool metadata and planning logic that is not “run this contributed tool”, like spec shaping, namespaces, discovery, code-mode augmentation, and MCP/dynamic-to-Responses API conversion ## What changed - Moved the shared leaf tool-spec and JSON Schema types into `codex-tool-api`, so the executable contract now lives with [`ToolBundle`](https://github.com/openai/codex/blob/c538758095337d4fe0a52a172363ccede4066bda/codex-rs/tool-api/src/bundle.rs#L19-L70). - Replaced the temporary extension-side tool wrapper with direct `ToolBundle` use in `codex-extension-api`. - Taught core to collect contributed bundles, include them in spec planning, register them through [`ToolRegistryBuilder::register_tool_bundle`](https://github.com/openai/codex/blob/c538758095337d4fe0a52a172363ccede4066bda/codex-rs/core/src/tools/registry.rs#L653-L667), and dispatch them through the existing router/runtime path. - Added focused coverage for contributed tools becoming model-visible and dispatchable, plus spec-planning coverage for contributed function and freeform tools. ## Verification - Added `extension_tool_bundles_are_model_visible_and_dispatchable` in `core/src/tools/router_tests.rs`. - Added spec-plan coverage in `core/src/tools/spec_plan_tests.rs` for contributed extension bundles. ## Related - Follow-up to #22138
This commit is contained in:
committed by
GitHub
Unverified
parent
7e15e6db9e
commit
672cc1f669
Generated
+2
-6
@@ -2525,6 +2525,7 @@ dependencies = [
|
||||
"codex-terminal-detection",
|
||||
"codex-test-binary-support",
|
||||
"codex-thread-store",
|
||||
"codex-tool-api",
|
||||
"codex-tools",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cache",
|
||||
@@ -2825,9 +2826,7 @@ name = "codex-extension-api"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"codex-tool-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3710,10 +3709,7 @@ dependencies = [
|
||||
name = "codex-tool-api"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
@@ -474,7 +474,6 @@ unwrap_used = "deny"
|
||||
[workspace.metadata.cargo-shear]
|
||||
ignored = [
|
||||
"codex-agent-graph-store",
|
||||
"codex-tool-api",
|
||||
"icu_provider",
|
||||
"openssl-sys",
|
||||
"codex-v8-poc",
|
||||
|
||||
@@ -60,6 +60,7 @@ codex-sandboxing = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
codex-thread-store = { workspace = true }
|
||||
codex-tool-api = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-cache = { workspace = true }
|
||||
|
||||
@@ -545,6 +545,7 @@ fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> T
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
extension_tool_bundles: Vec::new(),
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
},
|
||||
));
|
||||
@@ -8555,6 +8556,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
extension_tool_bundles: Vec::new(),
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -55,6 +55,7 @@ use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::registry::ToolArgumentDiffConsumer;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::tools::router::extension_tool_bundles;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use crate::turn_timing::record_turn_ttft_metric;
|
||||
use crate::unavailable_tool::collect_unavailable_called_tools;
|
||||
@@ -1268,6 +1269,7 @@ pub(crate) async fn built_tools(
|
||||
unavailable_called_tools,
|
||||
parallel_mcp_server_names,
|
||||
discoverable_tools,
|
||||
extension_tool_bundles: extension_tool_bundles(sess),
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
},
|
||||
)))
|
||||
|
||||
@@ -29,6 +29,7 @@ use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolCallSource;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::tools::router::extension_tool_bundles;
|
||||
use crate::unified_exec::resolve_max_tokens;
|
||||
use codex_features::Feature;
|
||||
use codex_tools::ToolName;
|
||||
@@ -285,6 +286,7 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names,
|
||||
discoverable_tools: None,
|
||||
extension_tool_bundles: extension_tool_bundles(exec.session.as_ref()),
|
||||
dynamic_tools: exec.turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -19,9 +19,14 @@ use crate::tools::flat_tool_name;
|
||||
use crate::tools::hook_names::HookToolName;
|
||||
use crate::tools::tool_dispatch_trace::ToolDispatchTrace;
|
||||
use crate::util::error_or_panic;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_tool_api::ToolBundle as ExtensionToolBundle;
|
||||
use codex_tool_api::ToolError as ExtensionToolError;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_utils_readiness::Readiness;
|
||||
@@ -35,6 +40,125 @@ pub enum ToolKind {
|
||||
Mcp,
|
||||
}
|
||||
|
||||
struct BundledToolOutput {
|
||||
value: Value,
|
||||
}
|
||||
|
||||
impl ToolOutput for BundledToolOutput {
|
||||
fn log_preview(&self) -> String {
|
||||
self.value.to_string()
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(self.value.to_string()),
|
||||
success: Some(true),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<Value> {
|
||||
Some(self.value.clone())
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> Value {
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
struct BundledToolHandler {
|
||||
bundle: ExtensionToolBundle,
|
||||
spec: ToolSpec,
|
||||
}
|
||||
|
||||
impl BundledToolHandler {
|
||||
fn new(bundle: ExtensionToolBundle, spec: ToolSpec) -> Self {
|
||||
Self { bundle, spec }
|
||||
}
|
||||
|
||||
fn arguments_from_payload<'a>(&self, payload: &'a ToolPayload) -> Option<&'a str> {
|
||||
let ToolPayload::Function { arguments } = payload else {
|
||||
return None;
|
||||
};
|
||||
Some(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolHandler for BundledToolHandler {
|
||||
type Output = BundledToolOutput;
|
||||
|
||||
fn tool_name(&self) -> ToolName {
|
||||
ToolName::plain(self.bundle.tool_name())
|
||||
}
|
||||
|
||||
fn spec(&self) -> Option<ToolSpec> {
|
||||
Some(self.spec.clone())
|
||||
}
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
self.arguments_from_payload(payload).is_some()
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
|
||||
let arguments = self.arguments_from_payload(&invocation.payload)?;
|
||||
Some(PreToolUsePayload {
|
||||
tool_name: HookToolName::new(flat_tool_name(&self.tool_name()).into_owned()),
|
||||
tool_input: extension_tool_hook_input(arguments),
|
||||
})
|
||||
}
|
||||
|
||||
fn post_tool_use_payload(
|
||||
&self,
|
||||
invocation: &ToolInvocation,
|
||||
result: &Self::Output,
|
||||
) -> Option<PostToolUsePayload> {
|
||||
let arguments = self.arguments_from_payload(&invocation.payload)?;
|
||||
Some(PostToolUsePayload {
|
||||
tool_name: HookToolName::new(flat_tool_name(&self.tool_name()).into_owned()),
|
||||
tool_use_id: invocation.call_id.clone(),
|
||||
tool_input: extension_tool_hook_input(arguments),
|
||||
tool_response: result
|
||||
.post_tool_use_response(&invocation.call_id, &invocation.payload)?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let arguments = self
|
||||
.arguments_from_payload(&invocation.payload)
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"tool {} invoked with incompatible payload",
|
||||
self.bundle.tool_name()
|
||||
))
|
||||
})?
|
||||
.to_string();
|
||||
let value = self
|
||||
.bundle
|
||||
.executor()
|
||||
.execute(codex_tool_api::ToolCall {
|
||||
call_id: invocation.call_id,
|
||||
arguments,
|
||||
})
|
||||
.await
|
||||
.map_err(map_extension_tool_error)?;
|
||||
Ok(BundledToolOutput { value })
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToolHandler: Send + Sync {
|
||||
type Output: ToolOutput + 'static;
|
||||
|
||||
@@ -538,6 +662,28 @@ impl ToolRegistryBuilder {
|
||||
self.handlers.insert(name, handler);
|
||||
}
|
||||
|
||||
pub fn register_tool_bundle(&mut self, bundle: ExtensionToolBundle) {
|
||||
let tool_name = ToolName::plain(bundle.tool_name());
|
||||
if self.handlers.contains_key(&tool_name) {
|
||||
warn!("Skipping extension tool `{tool_name}`: handler already registered");
|
||||
return;
|
||||
}
|
||||
|
||||
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(), /*supports_parallel_tool_calls*/ false);
|
||||
|
||||
let handler: Arc<dyn AnyToolHandler> = Arc::new(BundledToolHandler::new(bundle, spec));
|
||||
self.handlers.insert(tool_name, handler);
|
||||
}
|
||||
|
||||
pub(crate) fn specs(&self) -> &[ConfiguredToolSpec] {
|
||||
&self.specs
|
||||
}
|
||||
@@ -555,6 +701,34 @@ fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) ->
|
||||
}
|
||||
}
|
||||
|
||||
fn map_extension_tool_error(error: ExtensionToolError) -> FunctionCallError {
|
||||
match error {
|
||||
ExtensionToolError::RespondToModel(message) => FunctionCallError::RespondToModel(message),
|
||||
ExtensionToolError::Fatal(message) => FunctionCallError::Fatal(message),
|
||||
}
|
||||
}
|
||||
|
||||
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 extension_tool_hook_input(arguments: &str) -> Value {
|
||||
if arguments.trim().is_empty() {
|
||||
return Value::Object(serde_json::Map::new());
|
||||
}
|
||||
|
||||
serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "registry_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::tools::handlers::GetGoalHandler;
|
||||
use crate::tools::handlers::goal_spec::GET_GOAL_TOOL_NAME;
|
||||
use crate::tools::handlers::goal_spec::create_get_goal_tool;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
struct TestHandler {
|
||||
tool_name: codex_tools::ToolName,
|
||||
@@ -80,3 +81,69 @@ fn register_handler_adds_handler_and_augments_specs_for_code_mode() {
|
||||
);
|
||||
assert!(registry.has_handler(&codex_tools::ToolName::plain(GET_GOAL_TOOL_NAME)));
|
||||
}
|
||||
|
||||
struct StubExtensionExecutor;
|
||||
|
||||
impl codex_tool_api::ToolExecutor for StubExtensionExecutor {
|
||||
fn execute<'a>(&'a self, _call: codex_tool_api::ToolCall) -> codex_tool_api::ToolFuture<'a> {
|
||||
Box::pin(async { Ok(json!({ "ok": true })) })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bundled_tool_handler_exposes_generic_hook_payloads_and_is_conservatively_mutating() {
|
||||
let bundle = codex_tool_api::ToolBundle::new(
|
||||
codex_tool_api::FunctionToolSpec {
|
||||
name: "extension_echo".to_string(),
|
||||
description: "Echoes arguments.".to_string(),
|
||||
strict: true,
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": { "type": "string" },
|
||||
},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
},
|
||||
Arc::new(StubExtensionExecutor),
|
||||
);
|
||||
let spec = extension_tool_spec(bundle.spec()).expect("extension spec should convert");
|
||||
let handler = BundledToolHandler::new(bundle, spec);
|
||||
let (session, turn) = crate::session::tests::make_session_and_context().await;
|
||||
let invocation = ToolInvocation {
|
||||
session: session.into(),
|
||||
turn: turn.into(),
|
||||
cancellation_token: tokio_util::sync::CancellationToken::new(),
|
||||
tracker: Arc::new(tokio::sync::Mutex::new(
|
||||
crate::turn_diff_tracker::TurnDiffTracker::new(),
|
||||
)),
|
||||
call_id: "call-extension".to_string(),
|
||||
tool_name: codex_tools::ToolName::plain("extension_echo"),
|
||||
source: crate::tools::context::ToolCallSource::Direct,
|
||||
payload: ToolPayload::Function {
|
||||
arguments: json!({ "message": "hello" }).to_string(),
|
||||
},
|
||||
};
|
||||
let output = BundledToolOutput {
|
||||
value: json!({ "ok": true }),
|
||||
};
|
||||
|
||||
assert!(ToolHandler::is_mutating(&handler, &invocation).await);
|
||||
assert_eq!(
|
||||
ToolHandler::pre_tool_use_payload(&handler, &invocation),
|
||||
Some(PreToolUsePayload {
|
||||
tool_name: HookToolName::new("extension_echo"),
|
||||
tool_input: json!({ "message": "hello" }),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
ToolHandler::post_tool_use_payload(&handler, &invocation, &output),
|
||||
Some(PostToolUsePayload {
|
||||
tool_name: HookToolName::new("extension_echo"),
|
||||
tool_use_id: "call-extension".to_string(),
|
||||
tool_input: json!({ "message": "hello" }),
|
||||
tool_response: json!({ "ok": true }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::SearchToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_tool_api::ToolBundle as ExtensionToolBundle;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
@@ -48,6 +49,7 @@ pub(crate) struct ToolRouterParams<'a> {
|
||||
pub(crate) unavailable_called_tools: Vec<ToolName>,
|
||||
pub(crate) parallel_mcp_server_names: HashSet<String>,
|
||||
pub(crate) discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
pub(crate) extension_tool_bundles: Vec<ExtensionToolBundle>,
|
||||
pub(crate) dynamic_tools: &'a [DynamicToolSpec],
|
||||
}
|
||||
|
||||
@@ -59,6 +61,7 @@ impl ToolRouter {
|
||||
unavailable_called_tools,
|
||||
parallel_mcp_server_names,
|
||||
discoverable_tools,
|
||||
extension_tool_bundles,
|
||||
dynamic_tools,
|
||||
} = params;
|
||||
let builder = build_specs_with_discoverable_tools(
|
||||
@@ -67,6 +70,7 @@ impl ToolRouter {
|
||||
deferred_mcp_tools,
|
||||
unavailable_called_tools,
|
||||
discoverable_tools,
|
||||
&extension_tool_bundles,
|
||||
dynamic_tools,
|
||||
);
|
||||
let (specs, registry) = builder.build();
|
||||
@@ -296,6 +300,21 @@ impl ToolRouter {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extension_tool_bundles(session: &Session) -> Vec<ExtensionToolBundle> {
|
||||
session
|
||||
.services
|
||||
.extensions
|
||||
.tool_contributors()
|
||||
.iter()
|
||||
.flat_map(|contributor| {
|
||||
contributor.tools(
|
||||
&session.services.session_extension_data,
|
||||
&session.services.thread_extension_data,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_deferred_dynamic_tool_spec(
|
||||
spec: ToolSpec,
|
||||
deferred_dynamic_tools: &HashSet<ToolName>,
|
||||
|
||||
@@ -1,19 +1,83 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_extension_api::ExtensionData;
|
||||
use codex_extension_api::ExtensionRegistry;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_extension_api::FunctionToolSpec;
|
||||
use codex_extension_api::ToolBundle;
|
||||
use codex_extension_api::ToolExecutor;
|
||||
use codex_extension_api::ToolFuture;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_tool_api::ToolCall as ExtensionToolCall;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::ToolCall;
|
||||
use super::ToolCallSource;
|
||||
use super::ToolRouter;
|
||||
use super::ToolRouterParams;
|
||||
use super::extension_tool_bundles;
|
||||
|
||||
struct ExtensionEchoContributor;
|
||||
|
||||
impl codex_extension_api::ToolContributor for ExtensionEchoContributor {
|
||||
fn tools(
|
||||
&self,
|
||||
_session_store: &ExtensionData,
|
||||
_thread_store: &ExtensionData,
|
||||
) -> Vec<ToolBundle> {
|
||||
vec![ToolBundle::new(
|
||||
FunctionToolSpec {
|
||||
name: "extension_echo".to_string(),
|
||||
description: "Echoes arguments through an extension tool.".to_string(),
|
||||
strict: true,
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": { "type": "string" },
|
||||
},
|
||||
"required": ["message"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
},
|
||||
Arc::new(ExtensionEchoExecutor),
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
struct ExtensionEchoExecutor;
|
||||
|
||||
impl ToolExecutor for ExtensionEchoExecutor {
|
||||
fn execute<'a>(&'a self, call: ExtensionToolCall) -> ToolFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let arguments: serde_json::Value =
|
||||
serde_json::from_str(&call.arguments).expect("test arguments should parse");
|
||||
Ok(json!({
|
||||
"arguments": arguments,
|
||||
"callId": call.call_id.clone(),
|
||||
"ok": true,
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_tool_test_registry() -> Arc<ExtensionRegistry<Config>> {
|
||||
let mut builder = ExtensionRegistryBuilder::new();
|
||||
builder.tool_contributor(Arc::new(ExtensionEchoContributor));
|
||||
Arc::new(builder.build())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[expect(
|
||||
@@ -37,6 +101,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
extension_tool_bundles: Vec::new(),
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
@@ -110,6 +175,7 @@ async fn mcp_parallel_support_uses_exact_payload_server() -> anyhow::Result<()>
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::from(["echo".to_string()]),
|
||||
discoverable_tools: None,
|
||||
extension_tool_bundles: Vec::new(),
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
@@ -177,6 +243,7 @@ async fn model_visible_specs_filter_deferred_dynamic_tools() -> anyhow::Result<(
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
extension_tool_bundles: Vec::new(),
|
||||
dynamic_tools: &dynamic_tools,
|
||||
},
|
||||
);
|
||||
@@ -198,6 +265,86 @@ async fn model_visible_specs_filter_deferred_dynamic_tools() -> anyhow::Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extension_tool_bundles_are_model_visible_and_dispatchable() -> anyhow::Result<()> {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
session.services.extensions = extension_tool_test_registry();
|
||||
|
||||
let router = ToolRouter::from_config(
|
||||
&turn.tools_config,
|
||||
ToolRouterParams {
|
||||
deferred_mcp_tools: None,
|
||||
mcp_tools: None,
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
extension_tool_bundles: extension_tool_bundles(&session),
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
|
||||
assert!(
|
||||
router
|
||||
.find_spec(&ToolName::plain("extension_echo"))
|
||||
.is_some(),
|
||||
"expected extension-provided tool spec to be registered"
|
||||
);
|
||||
assert!(
|
||||
router
|
||||
.model_visible_specs()
|
||||
.iter()
|
||||
.any(|spec| spec.name() == "extension_echo"),
|
||||
"expected extension-provided tool to be visible to the model"
|
||||
);
|
||||
|
||||
let call = ToolRouter::build_tool_call(
|
||||
&session,
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "extension_echo".to_string(),
|
||||
namespace: None,
|
||||
arguments: json!({ "message": "hello" }).to_string(),
|
||||
call_id: "call-extension".to_string(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.expect("function_call should produce a tool call");
|
||||
|
||||
let result = router
|
||||
.dispatch_tool_call_with_code_mode_result(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
CancellationToken::new(),
|
||||
Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
|
||||
call,
|
||||
ToolCallSource::Direct,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response = result.into_response();
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
||||
assert_eq!(call_id, "call-extension");
|
||||
let FunctionCallOutputBody::Text(text) = output.body else {
|
||||
panic!("expected text function call output")
|
||||
};
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&text).expect("extension tool output should be json");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"arguments": { "message": "hello" },
|
||||
"callId": "call-extension",
|
||||
"ok": true,
|
||||
})
|
||||
);
|
||||
}
|
||||
other => panic!("expected function call output, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec<String> {
|
||||
specs
|
||||
.iter()
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::tools::spec_plan_types::ToolRegistryBuildMcpTool;
|
||||
use crate::tools::spec_plan_types::ToolRegistryBuildParams;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_tool_api::ToolBundle as ExtensionToolBundle;
|
||||
use codex_tools::AdditionalProperties;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::JsonSchema;
|
||||
@@ -69,6 +70,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
deferred_mcp_tools: Option<Vec<ToolInfo>>,
|
||||
unavailable_called_tools: Vec<ToolName>,
|
||||
discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
extension_tool_bundles: &[ExtensionToolBundle],
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
use crate::tools::handlers::UnavailableToolHandler;
|
||||
@@ -120,6 +122,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
.as_ref()
|
||||
.map(|inputs| &inputs.tool_namespaces),
|
||||
discoverable_tools: discoverable_tools.as_deref(),
|
||||
extension_tool_bundles,
|
||||
dynamic_tools,
|
||||
default_agent_type_description: &default_agent_type_description,
|
||||
wait_agent_timeouts: WaitAgentTimeoutOptions {
|
||||
|
||||
@@ -436,6 +436,10 @@ pub fn build_tool_registry_builder(
|
||||
}
|
||||
}
|
||||
|
||||
for bundle in params.extension_tool_bundles.iter().cloned() {
|
||||
builder.register_tool_bundle(bundle);
|
||||
}
|
||||
|
||||
builder
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::WebSearchToolType;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_tool_api::FunctionToolSpec;
|
||||
use codex_tool_api::ToolBundle as ExtensionToolBundle;
|
||||
use codex_tool_api::ToolExecutor;
|
||||
use codex_tool_api::ToolFuture;
|
||||
use codex_tools::AdditionalProperties;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use codex_tools::DiscoverablePluginInfo;
|
||||
@@ -73,6 +77,74 @@ const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
|
||||
const MIN_WAIT_TIMEOUT_MS: i64 = 10_000;
|
||||
const MAX_WAIT_TIMEOUT_MS: i64 = 3_600_000;
|
||||
|
||||
struct UnusedExtensionExecutor;
|
||||
|
||||
impl ToolExecutor for UnusedExtensionExecutor {
|
||||
fn execute<'a>(&'a self, _call: codex_tool_api::ToolCall) -> ToolFuture<'a> {
|
||||
Box::pin(async { panic!("spec planning should not execute extension tools") })
|
||||
}
|
||||
}
|
||||
|
||||
fn extension_tool_bundle(name: &str, description: &str) -> ExtensionToolBundle {
|
||||
ExtensionToolBundle::new(
|
||||
FunctionToolSpec {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
strict: true,
|
||||
parameters: serde_json::to_value(JsonSchema::object(
|
||||
BTreeMap::from([(
|
||||
"message".to_string(),
|
||||
JsonSchema::string(/*description*/ None),
|
||||
)]),
|
||||
Some(vec!["message".to_string()]),
|
||||
Some(false.into()),
|
||||
))
|
||||
.expect("extension schema should serialize"),
|
||||
},
|
||||
std::sync::Arc::new(UnusedExtensionExecutor),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_tools_do_not_replace_builtin_tools() {
|
||||
let model_info = model_info();
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &Features::with_defaults(),
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let extension_tool_bundles = vec![extension_tool_bundle(
|
||||
"update_plan",
|
||||
"Extension attempt to replace a built-in tool.",
|
||||
)];
|
||||
let (tools, _) = build_specs_with_discoverable_tools(
|
||||
&tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
/*discoverable_tools*/ None,
|
||||
&extension_tool_bundles,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
find_tool(&tools, "update_plan").spec,
|
||||
create_update_plan_tool()
|
||||
);
|
||||
assert_eq!(
|
||||
tools
|
||||
.iter()
|
||||
.filter(|tool| tool.name() == "update_plan")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
||||
let model_info = model_info();
|
||||
@@ -1807,6 +1879,7 @@ fn request_plugin_install_is_not_registered_without_feature_flag() {
|
||||
"Google Calendar",
|
||||
"Plan events and schedules.",
|
||||
)]),
|
||||
/*extension_tool_bundles*/ &[],
|
||||
&[],
|
||||
);
|
||||
|
||||
@@ -1847,6 +1920,7 @@ fn request_plugin_install_can_be_registered_without_search_tool() {
|
||||
"Google Calendar",
|
||||
"Plan events and schedules.",
|
||||
)]),
|
||||
/*extension_tool_bundles*/ &[],
|
||||
&[],
|
||||
);
|
||||
|
||||
@@ -1913,6 +1987,7 @@ fn request_plugin_install_description_lists_discoverable_tools() {
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
Some(discoverable_tools),
|
||||
/*extension_tool_bundles*/ &[],
|
||||
&[],
|
||||
);
|
||||
assert!(registry.has_handler(&ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME)));
|
||||
@@ -2188,6 +2263,45 @@ fn code_mode_only_exec_description_includes_full_nested_tool_details() {
|
||||
assert!(description.contains("### `view_image`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_only_exec_description_includes_extension_tool_details() {
|
||||
let model_info = model_info();
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::CodeMode);
|
||||
features.enable(Feature::CodeModeOnly);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
permission_profile: &PermissionProfile::Disabled,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
|
||||
let extension_tool_bundles = vec![extension_tool_bundle(
|
||||
"extension_echo",
|
||||
"Echoes arguments through an extension tool.",
|
||||
)];
|
||||
let (tools, _) = build_specs_with_discoverable_tools(
|
||||
&tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
/*discoverable_tools*/ None,
|
||||
&extension_tool_bundles,
|
||||
&[],
|
||||
);
|
||||
let ToolSpec::Freeform(FreeformTool { description, .. }) = &find_tool(&tools, "exec").spec
|
||||
else {
|
||||
panic!("expected freeform tool");
|
||||
};
|
||||
|
||||
assert!(description.contains("### `extension_echo`"));
|
||||
assert!(description.contains("Echoes arguments through an extension tool."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only() {
|
||||
let model_info = model_info();
|
||||
@@ -2276,6 +2390,7 @@ fn build_specs<'a>(
|
||||
mcp_tools,
|
||||
deferred_mcp_tools,
|
||||
/*discoverable_tools*/ None,
|
||||
/*extension_tool_bundles*/ &[],
|
||||
dynamic_tools,
|
||||
)
|
||||
}
|
||||
@@ -2285,6 +2400,7 @@ fn build_specs_with_discoverable_tools<'a>(
|
||||
mcp_tools: Option<HashMap<ToolName, rmcp::model::Tool>>,
|
||||
deferred_mcp_tools: Option<Vec<ToolRegistryBuildDeferredTool<'a>>>,
|
||||
discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
extension_tool_bundles: &[codex_tool_api::ToolBundle],
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> (Vec<ConfiguredToolSpec>, ToolRegistry) {
|
||||
build_specs_with_optional_tool_namespaces(
|
||||
@@ -2293,6 +2409,7 @@ fn build_specs_with_discoverable_tools<'a>(
|
||||
deferred_mcp_tools,
|
||||
/*tool_namespaces*/ None,
|
||||
discoverable_tools,
|
||||
extension_tool_bundles,
|
||||
dynamic_tools,
|
||||
)
|
||||
}
|
||||
@@ -2303,6 +2420,7 @@ fn build_specs_with_optional_tool_namespaces<'a>(
|
||||
deferred_mcp_tools: Option<Vec<ToolRegistryBuildDeferredTool<'a>>>,
|
||||
tool_namespaces: Option<HashMap<String, ToolNamespace>>,
|
||||
discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
extension_tool_bundles: &[codex_tool_api::ToolBundle],
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> (Vec<ConfiguredToolSpec>, ToolRegistry) {
|
||||
let mcp_tool_inputs = mcp_tools.as_ref().map(|mcp_tools| {
|
||||
@@ -2321,6 +2439,7 @@ fn build_specs_with_optional_tool_namespaces<'a>(
|
||||
deferred_mcp_tools: deferred_mcp_tools.as_deref(),
|
||||
tool_namespaces: tool_namespaces.as_ref(),
|
||||
discoverable_tools: discoverable_tools.as_deref(),
|
||||
extension_tool_bundles,
|
||||
dynamic_tools,
|
||||
default_agent_type_description: DEFAULT_AGENT_TYPE_DESCRIPTION,
|
||||
wait_agent_timeouts: wait_agent_timeout_options(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_tool_api::ToolBundle as ExtensionToolBundle;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolsConfig;
|
||||
@@ -11,6 +12,7 @@ pub struct ToolRegistryBuildParams<'a> {
|
||||
pub deferred_mcp_tools: Option<&'a [ToolRegistryBuildDeferredTool<'a>]>,
|
||||
pub tool_namespaces: Option<&'a HashMap<String, ToolNamespace>>,
|
||||
pub discoverable_tools: Option<&'a [DiscoverableTool]>,
|
||||
pub extension_tool_bundles: &'a [ExtensionToolBundle],
|
||||
pub dynamic_tools: &'a [DynamicToolSpec],
|
||||
pub default_agent_type_description: &'a str,
|
||||
pub wait_agent_timeouts: WaitAgentTimeoutOptions,
|
||||
|
||||
@@ -292,6 +292,7 @@ fn build_specs_with_unavailable_tools(
|
||||
deferred_mcp_tools,
|
||||
unavailable_called_tools,
|
||||
/*discoverable_tools*/ None,
|
||||
/*extension_tool_bundles*/ &[],
|
||||
dynamic_tools,
|
||||
)
|
||||
}
|
||||
@@ -353,6 +354,7 @@ async fn assert_model_tools(
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: std::collections::HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
extension_tool_bundles: Vec::new(),
|
||||
dynamic_tools: &[],
|
||||
},
|
||||
);
|
||||
@@ -822,6 +824,7 @@ async fn request_plugin_install_requires_apps_and_plugins_features() {
|
||||
/*deferred_mcp_tools*/ None,
|
||||
Vec::new(),
|
||||
discoverable_tools.clone(),
|
||||
/*extension_tool_bundles*/ &[],
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
@@ -14,7 +14,5 @@ doctest = false
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-tool-api = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
use std::future::Future;
|
||||
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_tool_api::ToolBundle;
|
||||
|
||||
use crate::ExtensionData;
|
||||
|
||||
mod prompt;
|
||||
mod tool;
|
||||
|
||||
pub use prompt::PromptFragment;
|
||||
pub use prompt::PromptSlot;
|
||||
pub use tool::ToolCallError;
|
||||
pub use tool::ToolContribution;
|
||||
pub use tool::ToolHandler;
|
||||
|
||||
/// Contributor that receives host-owned thread-start input before later
|
||||
/// contributors read from extension stores.
|
||||
@@ -30,8 +27,9 @@ pub trait ContextContributor: Send + Sync {
|
||||
|
||||
/// Extension contribution that exposes native tools owned by a feature.
|
||||
pub trait ToolContributor: Send + Sync {
|
||||
/// Returns the native tools visible for the supplied runtime context.
|
||||
fn tools(&self, thread_store: &ExtensionData) -> Vec<ToolContribution>;
|
||||
/// Returns the native tools visible for the supplied extension stores.
|
||||
fn tools(&self, session_store: &ExtensionData, thread_store: &ExtensionData)
|
||||
-> Vec<ToolBundle>;
|
||||
}
|
||||
|
||||
/// Future returned by one ordered turn-item contribution.
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
// TMP while we don't have the fully extracted tools
|
||||
#[derive(Clone)]
|
||||
pub struct ToolContribution {
|
||||
spec: ResponsesApiTool,
|
||||
handler: Arc<dyn ToolHandler>,
|
||||
supports_parallel_tool_calls: bool,
|
||||
}
|
||||
|
||||
impl ToolContribution {
|
||||
pub fn new(spec: ResponsesApiTool, handler: Arc<dyn ToolHandler>) -> Self {
|
||||
Self {
|
||||
spec,
|
||||
handler,
|
||||
supports_parallel_tool_calls: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn allow_parallel_calls(mut self) -> Self {
|
||||
self.supports_parallel_tool_calls = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn spec(&self) -> &ResponsesApiTool {
|
||||
&self.spec
|
||||
}
|
||||
|
||||
pub fn supports_parallel_tool_calls(&self) -> bool {
|
||||
self.supports_parallel_tool_calls
|
||||
}
|
||||
|
||||
pub fn handler(&self) -> Arc<dyn ToolHandler> {
|
||||
Arc::clone(&self.handler)
|
||||
}
|
||||
}
|
||||
|
||||
//////// Just to make it compile ////////////////////////////////
|
||||
pub trait ToolHandler: Send + Sync {
|
||||
/// Handles one JSON-encoded invocation for this tool.
|
||||
fn handle<'a>(
|
||||
&'a self,
|
||||
arguments: Value,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Value, ToolCallError>> + Send + 'a>>;
|
||||
}
|
||||
|
||||
/// Error returned by a contributed native tool handler.
|
||||
#[derive(Clone, Debug, Error, PartialEq, Eq)]
|
||||
#[error("{message}")]
|
||||
pub struct ToolCallError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ToolCallError {
|
||||
/// Creates a contributed-tool error with the supplied model-visible text.
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,18 @@ mod contributors;
|
||||
mod registry;
|
||||
mod state;
|
||||
|
||||
pub use codex_tool_api::FunctionToolSpec;
|
||||
pub use codex_tool_api::ToolBundle;
|
||||
pub use codex_tool_api::ToolCall;
|
||||
pub use codex_tool_api::ToolError;
|
||||
pub use codex_tool_api::ToolExecutor;
|
||||
pub use codex_tool_api::ToolFuture;
|
||||
pub use contributors::ApprovalInterceptorContributor;
|
||||
pub use contributors::ContextContributor;
|
||||
pub use contributors::PromptFragment;
|
||||
pub use contributors::PromptSlot;
|
||||
pub use contributors::ThreadStartContributor;
|
||||
pub use contributors::ToolCallError;
|
||||
pub use contributors::ToolContribution;
|
||||
pub use contributors::ToolContributor;
|
||||
pub use contributors::ToolHandler;
|
||||
pub use contributors::TurnItemContributionFuture;
|
||||
pub use contributors::TurnItemContributor;
|
||||
pub use registry::ExtensionRegistry;
|
||||
|
||||
@@ -13,9 +13,6 @@ doctest = false
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# codex-tool-api
|
||||
|
||||
`codex-tool-api` is the minimal extension-facing contract for contributed
|
||||
function tools that can be injected into Codex without making `codex-core`
|
||||
depend on the tool owner's crate.
|
||||
|
||||
Crates that define contributed tools should depend on this crate. It owns:
|
||||
|
||||
- the executable bundle contract: `ToolBundle`, `ToolExecutor`, `ToolCall`,
|
||||
and `ToolError`
|
||||
- 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 intended dependency direction is:
|
||||
|
||||
```text
|
||||
tool-owning extension crate --> codex-tool-api <-- codex-core
|
||||
```
|
||||
|
||||
`codex-tools` has a different job. It remains the host-side owner of Responses
|
||||
API tool models, schema parsing, namespaces, discovery, MCP/dynamic conversion,
|
||||
code-mode shaping, and other aggregate host concerns. A crate that only wants
|
||||
to contribute one ordinary function tool through an extension should not need
|
||||
to depend on `codex-tools`.
|
||||
@@ -2,126 +2,86 @@ use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::FunctionToolSpec;
|
||||
use crate::ToolCall;
|
||||
use crate::ToolError;
|
||||
use crate::ToolOutput;
|
||||
|
||||
/// Future returned by one executable-tool invocation.
|
||||
pub type ToolFuture<'a> =
|
||||
Pin<Box<dyn Future<Output = Result<Box<dyn ToolOutput>, ToolError>> + Send + 'a>>;
|
||||
/// Future returned by one contributed function-tool invocation.
|
||||
pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, ToolError>> + Send + 'a>>;
|
||||
|
||||
/// Future returned by one mutability probe.
|
||||
pub type BoolFuture<'a> = Pin<Box<dyn Future<Output = bool> + Send + 'a>>;
|
||||
|
||||
/// Model-visible definition plus executable implementation for one tool.
|
||||
/// Model-visible definition plus executable implementation for one contributed
|
||||
/// function tool.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolBundle<C> {
|
||||
definition: ToolDefinition,
|
||||
executor: Arc<dyn ToolExecutor<C>>,
|
||||
pub struct ToolBundle {
|
||||
spec: FunctionToolSpec,
|
||||
executor: Arc<dyn ToolExecutor>,
|
||||
}
|
||||
|
||||
impl<C> ToolBundle<C> {
|
||||
/// Creates one executable tool bundle.
|
||||
pub fn new(name: ToolName, spec: ToolSpec, executor: Arc<dyn ToolExecutor<C>>) -> Self {
|
||||
Self {
|
||||
definition: ToolDefinition {
|
||||
name,
|
||||
spec,
|
||||
supports_parallel_tool_calls: false,
|
||||
},
|
||||
executor,
|
||||
}
|
||||
impl ToolBundle {
|
||||
/// Creates one contributed function-tool bundle.
|
||||
pub fn new(spec: FunctionToolSpec, executor: Arc<dyn ToolExecutor>) -> Self {
|
||||
Self { spec, executor }
|
||||
}
|
||||
|
||||
/// Marks this tool as safe for the host to run in parallel with peers.
|
||||
#[must_use]
|
||||
pub fn allow_parallel_calls(mut self) -> Self {
|
||||
self.definition.supports_parallel_tool_calls = true;
|
||||
self
|
||||
/// Returns the contributed function-tool spec.
|
||||
pub fn spec(&self) -> &FunctionToolSpec {
|
||||
&self.spec
|
||||
}
|
||||
|
||||
/// Returns the model-visible tool definition.
|
||||
pub fn definition(&self) -> &ToolDefinition {
|
||||
&self.definition
|
||||
/// Returns the contributed function-tool name.
|
||||
pub fn tool_name(&self) -> &str {
|
||||
self.spec.name.as_str()
|
||||
}
|
||||
|
||||
/// Returns the executable implementation.
|
||||
pub fn executor(&self) -> Arc<dyn ToolExecutor<C>> {
|
||||
pub fn executor(&self) -> Arc<dyn ToolExecutor> {
|
||||
Arc::clone(&self.executor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-visible metadata owned by an executable tool bundle.
|
||||
#[derive(Clone)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: ToolName,
|
||||
pub spec: ToolSpec,
|
||||
pub supports_parallel_tool_calls: bool,
|
||||
}
|
||||
|
||||
/// Executable behavior for one contributed tool.
|
||||
/// Executable behavior for one contributed function tool.
|
||||
///
|
||||
/// Implementations should keep host-specific needs inside `C`; tool owners that
|
||||
/// do not require host state can implement the trait for any `C`.
|
||||
pub trait ToolExecutor<C>: Send + Sync {
|
||||
fn execute<'a>(&'a self, call: ToolCall<C>) -> ToolFuture<'a>;
|
||||
|
||||
/// Returns whether the call may mutate user state.
|
||||
///
|
||||
/// Hosts can use this conservative signal for serialization or approval
|
||||
/// policy. Read-only tools should override this default.
|
||||
fn is_mutating<'a>(&'a self, _call: &'a ToolCall<C>) -> BoolFuture<'a> {
|
||||
Box::pin(async { true })
|
||||
}
|
||||
/// Implementations receive the model-supplied call id and JSON arguments and
|
||||
/// return the JSON value that should be exposed to the model.
|
||||
pub trait ToolExecutor: Send + Sync {
|
||||
fn execute<'a>(&'a self, call: ToolCall) -> ToolFuture<'a>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::task::Context;
|
||||
use std::task::Poll;
|
||||
use std::task::Wake;
|
||||
use std::task::Waker;
|
||||
|
||||
use super::*;
|
||||
use crate::JsonToolOutput;
|
||||
use crate::ToolInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
struct DefaultMutatingExecutor;
|
||||
use super::ToolBundle;
|
||||
use super::ToolExecutor;
|
||||
use super::ToolFuture;
|
||||
use crate::FunctionToolSpec;
|
||||
use crate::ToolCall;
|
||||
|
||||
impl ToolExecutor<()> for DefaultMutatingExecutor {
|
||||
fn execute<'a>(&'a self, _call: ToolCall<()>) -> ToolFuture<'a> {
|
||||
Box::pin(async {
|
||||
Ok(Box::new(JsonToolOutput::new(serde_json::json!(null))) as Box<dyn ToolOutput>)
|
||||
})
|
||||
struct StubExecutor;
|
||||
|
||||
impl ToolExecutor for StubExecutor {
|
||||
fn execute<'a>(&'a self, _call: ToolCall) -> ToolFuture<'a> {
|
||||
Box::pin(async { Ok(json!({ "ok": true })) })
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopWaker;
|
||||
|
||||
impl Wake for NoopWaker {
|
||||
fn wake(self: Arc<Self>) {}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contributed_tools_default_to_mutating() {
|
||||
let call = ToolCall {
|
||||
context: (),
|
||||
call_id: "call-default-mutating".to_string(),
|
||||
input: ToolInput::Function {
|
||||
arguments: "{}".to_string(),
|
||||
fn bundle_derives_name_from_function_spec() {
|
||||
let bundle = ToolBundle::new(
|
||||
FunctionToolSpec {
|
||||
name: "echo".to_string(),
|
||||
description: "Echo arguments.".to_string(),
|
||||
strict: false,
|
||||
parameters: json!({ "type": "object" }),
|
||||
},
|
||||
};
|
||||
let mut future = DefaultMutatingExecutor.is_mutating(&call);
|
||||
let waker = Waker::from(Arc::new(NoopWaker));
|
||||
let mut context = Context::from_waker(&waker);
|
||||
Arc::new(StubExecutor),
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
future.as_mut().poll(&mut context),
|
||||
Poll::Ready(true)
|
||||
));
|
||||
assert_eq!(bundle.tool_name(), "echo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
/// One executable tool call delivered to a contributed tool.
|
||||
pub struct ToolCall<C> {
|
||||
pub context: C,
|
||||
/// One contributed function-tool call.
|
||||
pub struct ToolCall {
|
||||
pub call_id: String,
|
||||
pub input: ToolInput,
|
||||
}
|
||||
|
||||
/// Model-supplied input for the executable tool families currently exposed by
|
||||
/// the shared tool seam.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ToolInput {
|
||||
Function { arguments: String },
|
||||
Freeform { input: String },
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
//! Reusable executable-tool contracts shared between hosts and tool owners.
|
||||
//! Minimal function-tool contracts shared between hosts and extension-owned
|
||||
//! tool crates.
|
||||
|
||||
mod bundle;
|
||||
mod call;
|
||||
mod error;
|
||||
mod output;
|
||||
mod spec;
|
||||
|
||||
pub use bundle::BoolFuture;
|
||||
pub use bundle::ToolBundle;
|
||||
pub use bundle::ToolDefinition;
|
||||
pub use bundle::ToolExecutor;
|
||||
pub use bundle::ToolFuture;
|
||||
pub use call::ToolCall;
|
||||
pub use call::ToolInput;
|
||||
pub use error::ToolError;
|
||||
pub use output::JsonToolOutput;
|
||||
pub use output::ToolOutput;
|
||||
pub use spec::FunctionToolSpec;
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ToolError;
|
||||
use crate::ToolInput;
|
||||
|
||||
/// Tool-owned output rendering for each host-facing boundary.
|
||||
pub trait ToolOutput: Send {
|
||||
fn log_preview(&self) -> String;
|
||||
|
||||
fn success_for_logging(&self) -> bool;
|
||||
|
||||
fn to_response_item(&self, call_id: &str, input: &ToolInput) -> ResponseInputItem;
|
||||
|
||||
/// Returns the stable value exposed to post-tool-use hook integration when a
|
||||
/// host chooses to wire that surface for this tool.
|
||||
fn post_tool_use_response(&self, _call_id: &str, _input: &ToolInput) -> Option<Value> {
|
||||
None
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, input: &ToolInput) -> Value;
|
||||
}
|
||||
|
||||
/// Convenience output for ordinary JSON-returning function tools.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct JsonToolOutput {
|
||||
value: Value,
|
||||
}
|
||||
|
||||
impl JsonToolOutput {
|
||||
/// Creates a JSON output from a serializable value.
|
||||
pub fn from_serializable(value: impl Serialize) -> Result<Self, ToolError> {
|
||||
serde_json::to_value(value).map(Self::new).map_err(|err| {
|
||||
ToolError::respond_to_model(format!("failed to serialize output: {err}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a JSON output from an already materialized value.
|
||||
pub fn new(value: Value) -> Self {
|
||||
Self { value }
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolOutput for JsonToolOutput {
|
||||
fn log_preview(&self) -> String {
|
||||
self.value.to_string()
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, _input: &ToolInput) -> ResponseInputItem {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(self.value.to_string()),
|
||||
success: Some(true),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn post_tool_use_response(&self, _call_id: &str, _input: &ToolInput) -> Option<Value> {
|
||||
Some(self.value.clone())
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _input: &ToolInput) -> Value {
|
||||
self.value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
use super::JsonToolOutput;
|
||||
use super::ToolOutput;
|
||||
use crate::ToolInput;
|
||||
|
||||
#[test]
|
||||
fn json_tool_output_renders_function_output() {
|
||||
let input = ToolInput::Function {
|
||||
arguments: "{}".to_string(),
|
||||
};
|
||||
let output = JsonToolOutput::from_serializable(json!({ "ok": true }))
|
||||
.expect("serializable value should produce json output");
|
||||
|
||||
assert_eq!(output.log_preview(), "{\"ok\":true}");
|
||||
assert!(output.success_for_logging());
|
||||
assert_eq!(
|
||||
output.to_response_item("call-1", &input),
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text("{\"ok\":true}".to_string()),
|
||||
success: Some(true),
|
||||
},
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
output.post_tool_use_response("call-1", &input),
|
||||
Some(json!({ "ok": true }))
|
||||
);
|
||||
assert_eq!(output.code_mode_result(&input), json!({ "ok": true }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
use serde_json::Value;
|
||||
|
||||
/// Model-visible definition for one contributed function tool.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FunctionToolSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub strict: bool,
|
||||
pub parameters: Value,
|
||||
}
|
||||
+26
-49
@@ -1,45 +1,22 @@
|
||||
# codex-tools
|
||||
|
||||
`codex-tools` is intended to become the home for tool-related code that is
|
||||
shared across multiple crates and does not need to stay coupled to
|
||||
`codex-core`.
|
||||
`codex-tools` is the host-side support crate for building, adapting, and
|
||||
planning tool sets outside `codex-core`.
|
||||
|
||||
Today this crate is intentionally small. It currently owns the shared tool
|
||||
schema and Responses API tool primitives that no longer need to live in
|
||||
`core/src/tools/spec.rs` or `core/src/client_common.rs`:
|
||||
It is deliberately not the API that external tool owners should depend on.
|
||||
Crates that contribute ordinary function tools through extensions should use
|
||||
`codex-tool-api`, which owns only that small executable-tool seam.
|
||||
|
||||
- `JsonSchema`
|
||||
- `AdditionalProperties`
|
||||
- `ToolDefinition`
|
||||
- `ToolSpec`
|
||||
- `ConfiguredToolSpec`
|
||||
- `ResponsesApiTool`
|
||||
- `FreeformTool`
|
||||
- `FreeformToolFormat`
|
||||
- `LoadableToolSpec`
|
||||
- `ResponsesApiWebSearchFilters`
|
||||
- `ResponsesApiWebSearchUserLocation`
|
||||
- `ResponsesApiNamespace`
|
||||
- `ResponsesApiNamespaceTool`
|
||||
- code-mode `ToolSpec` adapters and `exec` / `wait` spec builders
|
||||
- MCP resource and `test_sync_tool` spec builders
|
||||
- local host tool spec builders for shell/exec/request-permissions/view-image
|
||||
- collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close,
|
||||
`request_user_input`, and CSV fanout/reporting
|
||||
- discoverable-tool models, client filtering, and `ToolSpec` builders for
|
||||
`tool_search` and `request_plugin_install`
|
||||
- `parse_tool_input_schema()`
|
||||
- `parse_dynamic_tool()`
|
||||
- `parse_mcp_tool()`
|
||||
- `create_tools_json_for_responses_api()`
|
||||
- `mcp_call_tool_result_output_schema()`
|
||||
- `tool_definition_to_responses_api_tool()`
|
||||
- `dynamic_tool_to_loadable_tool_spec()`
|
||||
- `dynamic_tool_to_responses_api_tool()`
|
||||
- `mcp_tool_to_responses_api_tool()`
|
||||
- `mcp_tool_to_deferred_responses_api_tool()`
|
||||
- `augment_tool_spec_for_code_mode()`
|
||||
- `tool_spec_to_code_mode_tool_definition()`
|
||||
Today this crate owns the host-facing tool models and helpers that no longer
|
||||
need to live in `core/src/tools/spec.rs` or `core/src/client_common.rs`:
|
||||
|
||||
- aggregate host models such as `ToolSpec`, `ConfiguredToolSpec`,
|
||||
`LoadableToolSpec`, `ResponsesApiNamespace`, and
|
||||
`ResponsesApiNamespaceTool`
|
||||
- host config and discovery models used while assembling tool sets, including
|
||||
`ToolsConfig`, discoverable-tool models, and request-plugin-install helpers
|
||||
- host adapters such as schema sanitization, MCP/dynamic conversion, code-mode
|
||||
augmentation, and image-detail normalization
|
||||
|
||||
That extraction is the first step in a longer migration. The goal is not to
|
||||
move all of `core/src/tools` into this crate in one shot. Instead, the plan is
|
||||
@@ -49,16 +26,18 @@ boundaries are ready.
|
||||
|
||||
## Vision
|
||||
|
||||
Over time, this crate should hold tool-facing primitives that are shared by
|
||||
Over time, this crate should hold host-side tool machinery that is shared by
|
||||
multiple consumers, for example:
|
||||
|
||||
- schema and spec data models
|
||||
- tool input/output parsing helpers
|
||||
- tool metadata and compatibility shims that do not depend on `codex-core`
|
||||
- other narrowly scoped utility code that multiple crates need
|
||||
- host-visible aggregate tool models
|
||||
- tool-set planning and discovery helpers
|
||||
- MCP and dynamic-tool adaptation into Responses API shapes
|
||||
- code-mode compatibility shims that do not depend on `codex-core`
|
||||
- other narrowly scoped host utilities that multiple crates need
|
||||
|
||||
The corresponding non-goals are just as important:
|
||||
|
||||
- do not become the extension-facing authoring API for executable tools
|
||||
- do not move `codex-core` orchestration here prematurely
|
||||
- do not pull `Session` / `TurnContext` / approval flow / runtime execution
|
||||
logic into this crate unless those dependencies have first been split into
|
||||
@@ -69,16 +48,14 @@ The corresponding non-goals are just as important:
|
||||
|
||||
The expected migration shape is:
|
||||
|
||||
1. Move low-coupling tool primitives here.
|
||||
2. Switch non-core consumers to depend on `codex-tools` directly.
|
||||
1. Keep ordinary contributed function-tool authoring in `codex-tool-api`.
|
||||
2. Move host-side planning/adaptation helpers here when they no longer need to
|
||||
stay coupled to `codex-core`.
|
||||
3. Leave compatibility-sensitive adapters in `codex-core` while downstream
|
||||
call sites are updated.
|
||||
4. Only extract higher-level tool infrastructure after the crate boundaries are
|
||||
4. Only extract higher-level host infrastructure after the crate boundaries are
|
||||
clear and independently testable.
|
||||
|
||||
That means it is normal for `codex-core` to temporarily re-export types or
|
||||
helpers from `codex-tools` during the transition.
|
||||
|
||||
## Crate conventions
|
||||
|
||||
This crate should start with stricter structure than `core/src/tools` so it
|
||||
|
||||
Reference in New Issue
Block a user