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<C>` / `ToolCall<C>` 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`
This commit is contained in:
jif-oai
2026-05-11 13:56:59 +02:00
committed by GitHub
parent 569ff6a1c4
commit 95bfea847d
9 changed files with 289 additions and 0 deletions

12
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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",

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "tool-api",
crate_name = "codex_tool_api",
)

View File

@@ -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 }

View File

@@ -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<Box<dyn Future<Output = Result<Box<dyn ToolOutput>, 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.
#[derive(Clone)]
pub struct ToolBundle<C> {
definition: ToolDefinition,
executor: Arc<dyn ToolExecutor<C>>,
}
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,
}
}
/// 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<dyn ToolExecutor<C>> {
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<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. Context-free read tools should keep the default.
fn is_mutating<'a>(&'a self, _call: &'a ToolCall<C>) -> BoolFuture<'a> {
Box::pin(async { false })
}
}

View File

@@ -0,0 +1,14 @@
/// One executable tool call delivered to a contributed tool.
pub struct ToolCall<C> {
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 },
}

View File

@@ -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<String>) -> Self {
Self::RespondToModel(message.into())
}
/// Creates a host-fatal tool error.
pub fn fatal(message: impl Into<String>) -> Self {
Self::Fatal(message.into())
}
}

View File

@@ -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;

View File

@@ -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<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 }));
}
}