From 95bfea847d9672de9e94f27db51ab52efd76b346 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 11 May 2026 13:56:59 +0200 Subject: [PATCH] refactor: extract executable tool contracts into codex-tool-api (#22138) ## Why The tool-extraction work needs one shared executable-tool seam that hosts and tool owners can depend on without reaching into `codex-core`. Landing that seam first makes the later tool-family ports incremental and keeps the reusable contract separate from any one migration. ## What changed - add a new `codex-tool-api` crate and workspace wiring - move the common executable-tool contracts into that crate: `ToolBundle`, `ToolDefinition`, `ToolExecutor`, `ToolCall`, `ToolInput`, `ToolOutput`, `JsonToolOutput`, and `ToolError` - keep host state generic through `ToolBundle` / `ToolCall` so later integrations can provide their own runtime context without baking core types into the API - carry the host signals the runtime will need later, including parallel-call support and mutability probing - leave existing tool families in place for now; this PR only establishes the reusable API surface - add the Bazel target and lockfile updates for the new crate ## Testing - `cargo test -p codex-tool-api` --- codex-rs/Cargo.lock | 12 ++++ codex-rs/Cargo.toml | 3 + codex-rs/tool-api/BUILD.bazel | 6 ++ codex-rs/tool-api/Cargo.toml | 23 +++++++ codex-rs/tool-api/src/bundle.rs | 79 ++++++++++++++++++++++ codex-rs/tool-api/src/call.rs | 14 ++++ codex-rs/tool-api/src/error.rs | 22 +++++++ codex-rs/tool-api/src/lib.rs | 17 +++++ codex-rs/tool-api/src/output.rs | 113 ++++++++++++++++++++++++++++++++ 9 files changed, 289 insertions(+) create mode 100644 codex-rs/tool-api/BUILD.bazel create mode 100644 codex-rs/tool-api/Cargo.toml create mode 100644 codex-rs/tool-api/src/bundle.rs create mode 100644 codex-rs/tool-api/src/call.rs create mode 100644 codex-rs/tool-api/src/error.rs create mode 100644 codex-rs/tool-api/src/lib.rs create mode 100644 codex-rs/tool-api/src/output.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f600b38d6d..cee6592843 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3706,6 +3706,18 @@ dependencies = [ "uuid", ] +[[package]] +name = "codex-tool-api" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "codex-tools", + "pretty_assertions", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "codex-tools" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ae7ef5dc3f..5715fe7260 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -108,6 +108,7 @@ members = [ "test-binary-support", "thread-manager-sample", "thread-store", + "tool-api", "uds", "codex-experimental-api-macros", "plugin", @@ -209,6 +210,7 @@ codex-stdio-to-uds = { path = "stdio-to-uds" } codex-terminal-detection = { path = "terminal-detection" } codex-test-binary-support = { path = "test-binary-support" } codex-thread-store = { path = "thread-store" } +codex-tool-api = { path = "tool-api" } codex-tools = { path = "tools" } codex-tui = { path = "tui" } codex-uds = { path = "uds" } @@ -472,6 +474,7 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", + "codex-tool-api", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/tool-api/BUILD.bazel b/codex-rs/tool-api/BUILD.bazel new file mode 100644 index 0000000000..1dc9eb3bf1 --- /dev/null +++ b/codex-rs/tool-api/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "tool-api", + crate_name = "codex_tool_api", +) diff --git a/codex-rs/tool-api/Cargo.toml b/codex-rs/tool-api/Cargo.toml new file mode 100644 index 0000000000..49c095dbff --- /dev/null +++ b/codex-rs/tool-api/Cargo.toml @@ -0,0 +1,23 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-tool-api" +version.workspace = true + +[lib] +name = "codex_tool_api" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-protocol = { workspace = true } +codex-tools = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/tool-api/src/bundle.rs b/codex-rs/tool-api/src/bundle.rs new file mode 100644 index 0000000000..ba24e1e2dd --- /dev/null +++ b/codex-rs/tool-api/src/bundle.rs @@ -0,0 +1,79 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use codex_tools::ToolName; +use codex_tools::ToolSpec; + +use crate::ToolCall; +use crate::ToolError; +use crate::ToolOutput; + +/// Future returned by one executable-tool invocation. +pub type ToolFuture<'a> = + Pin, ToolError>> + Send + 'a>>; + +/// Future returned by one mutability probe. +pub type BoolFuture<'a> = Pin + Send + 'a>>; + +/// Model-visible definition plus executable implementation for one tool. +#[derive(Clone)] +pub struct ToolBundle { + definition: ToolDefinition, + executor: Arc>, +} + +impl ToolBundle { + /// Creates one executable tool bundle. + pub fn new(name: ToolName, spec: ToolSpec, executor: Arc>) -> Self { + Self { + definition: ToolDefinition { + name, + spec, + supports_parallel_tool_calls: false, + }, + 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 model-visible tool definition. + pub fn definition(&self) -> &ToolDefinition { + &self.definition + } + + /// Returns the executable implementation. + pub fn executor(&self) -> Arc> { + 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. +/// +/// 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: Send + Sync { + fn execute<'a>(&'a self, call: ToolCall) -> ToolFuture<'a>; + + /// Returns whether the call may mutate user state. + /// + /// Hosts can use this conservative signal for serialization or approval + /// policy. Context-free read tools should keep the default. + fn is_mutating<'a>(&'a self, _call: &'a ToolCall) -> BoolFuture<'a> { + Box::pin(async { false }) + } +} diff --git a/codex-rs/tool-api/src/call.rs b/codex-rs/tool-api/src/call.rs new file mode 100644 index 0000000000..f6b813501e --- /dev/null +++ b/codex-rs/tool-api/src/call.rs @@ -0,0 +1,14 @@ +/// One executable tool call delivered to a contributed tool. +pub struct ToolCall { + pub context: C, + 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 }, +} diff --git a/codex-rs/tool-api/src/error.rs b/codex-rs/tool-api/src/error.rs new file mode 100644 index 0000000000..c4d30e63d2 --- /dev/null +++ b/codex-rs/tool-api/src/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +/// Error returned by a contributed executable tool. +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum ToolError { + #[error("{0}")] + RespondToModel(String), + #[error("fatal tool error: {0}")] + Fatal(String), +} + +impl ToolError { + /// Creates a model-visible tool error. + pub fn respond_to_model(message: impl Into) -> Self { + Self::RespondToModel(message.into()) + } + + /// Creates a host-fatal tool error. + pub fn fatal(message: impl Into) -> Self { + Self::Fatal(message.into()) + } +} diff --git a/codex-rs/tool-api/src/lib.rs b/codex-rs/tool-api/src/lib.rs new file mode 100644 index 0000000000..f65c9a6f9f --- /dev/null +++ b/codex-rs/tool-api/src/lib.rs @@ -0,0 +1,17 @@ +//! Reusable executable-tool contracts shared between hosts and tool owners. + +mod bundle; +mod call; +mod error; +mod output; + +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; diff --git a/codex-rs/tool-api/src/output.rs b/codex-rs/tool-api/src/output.rs new file mode 100644 index 0000000000..23da24d00b --- /dev/null +++ b/codex-rs/tool-api/src/output.rs @@ -0,0 +1,113 @@ +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 { + 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 { + 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 { + 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 })); + } +}