mirror of
https://github.com/openai/codex.git
synced 2026-05-21 11:42:55 +00:00
## Why Extension tools were split across two public runtime contracts: `codex-tool-api` exposed `ToolBundle` plus its own call/spec/error types, while core native tools used `codex_tools::ToolExecutor`. That made contributed tool specs and execution behavior easy to drift apart and added another crate boundary for what should be one executable-tool seam. This PR makes `ToolExecutor` the single runtime contract and keeps extension-specific pinning in `codex-extension-api`. ## Remaining todo https://github.com/openai/codex/pull/22369/changes#diff-b935ea8245c3ce568a30cff660175fa6390b66b872ae409e1e2e965738250741R5 Either generic `Invocation` or sub-extract the `ToolCall` and clean `ToolInvocation` ## What changed - Removed the `codex-tool-api` workspace crate and its dependencies from core and `codex-extension-api`. - Made `codex_tools::ToolExecutor` object-safe with `async_trait` so extension contributors can return a dyn executor. - Added the extension-facing aliases under `ext/extension-api/src/contributors/tools.rs`, including `ExtensionToolExecutor = dyn ToolExecutor<ToolCall, Output = ExtensionToolOutput>`. - Changed `ToolContributor::tools` to return extension executors directly instead of `ToolBundle`s. - Updated core’s extension tool handler/registry/router path to adapt those extension executors into the existing native `ToolInvocation` runtime path. - Added focused coverage for extension tools being registered, model-visible, dispatchable, and not replacing built-in tools. ## Verification - `cargo test -p codex-tools` - `cargo test -p codex-extension-api`
240 lines
7.9 KiB
Rust
240 lines
7.9 KiB
Rust
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
|
use codex_protocol::models::FunctionCallOutputBody;
|
|
use codex_protocol::models::FunctionCallOutputContentItem;
|
|
use codex_protocol::models::FunctionCallOutputPayload;
|
|
use codex_protocol::models::ResponseInputItem;
|
|
use codex_utils_string::take_bytes_at_char_boundary;
|
|
use serde_json::Value as JsonValue;
|
|
|
|
use crate::ToolPayload;
|
|
|
|
const TELEMETRY_PREVIEW_MAX_BYTES: usize = 2 * 1024;
|
|
const TELEMETRY_PREVIEW_MAX_LINES: usize = 64;
|
|
const TELEMETRY_PREVIEW_TRUNCATION_NOTICE: &str = "[... telemetry preview truncated ...]";
|
|
|
|
/// Model-facing output contract returned by executable tool runtimes.
|
|
pub trait ToolOutput: Send {
|
|
fn log_preview(&self) -> String;
|
|
|
|
fn success_for_logging(&self) -> bool;
|
|
|
|
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;
|
|
|
|
/// Returns the stable value exposed to `PostToolUse` hooks for this tool output.
|
|
///
|
|
/// Tool handlers decide whether a tool participates in `PostToolUse`, but
|
|
/// this method lets the output type own any conversion from model-facing
|
|
/// response content to hook-facing data. Returning `None` means the output
|
|
/// should not produce a post-use hook payload, not merely that the tool had
|
|
/// empty output.
|
|
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
|
|
None
|
|
}
|
|
|
|
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
|
|
response_input_to_code_mode_result(self.to_response_item("", payload))
|
|
}
|
|
}
|
|
|
|
impl<T> ToolOutput for Box<T>
|
|
where
|
|
T: ToolOutput + ?Sized,
|
|
{
|
|
fn log_preview(&self) -> String {
|
|
(**self).log_preview()
|
|
}
|
|
|
|
fn success_for_logging(&self) -> bool {
|
|
(**self).success_for_logging()
|
|
}
|
|
|
|
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
|
(**self).to_response_item(call_id, payload)
|
|
}
|
|
|
|
fn post_tool_use_response(&self, call_id: &str, payload: &ToolPayload) -> Option<JsonValue> {
|
|
(**self).post_tool_use_response(call_id, payload)
|
|
}
|
|
|
|
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
|
|
(**self).code_mode_result(payload)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct JsonToolOutput {
|
|
value: JsonValue,
|
|
success: Option<bool>,
|
|
}
|
|
|
|
impl JsonToolOutput {
|
|
pub fn new(value: JsonValue) -> Self {
|
|
Self {
|
|
value,
|
|
success: Some(true),
|
|
}
|
|
}
|
|
|
|
pub fn with_success(value: JsonValue, success: Option<bool>) -> Self {
|
|
Self { value, success }
|
|
}
|
|
}
|
|
|
|
impl ToolOutput for JsonToolOutput {
|
|
fn log_preview(&self) -> String {
|
|
telemetry_preview(&self.value.to_string())
|
|
}
|
|
|
|
fn success_for_logging(&self) -> bool {
|
|
self.success.unwrap_or(true)
|
|
}
|
|
|
|
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
|
let output = FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::Text(self.value.to_string()),
|
|
success: self.success,
|
|
};
|
|
|
|
if matches!(payload, ToolPayload::Custom { .. }) {
|
|
return ResponseInputItem::CustomToolCallOutput {
|
|
call_id: call_id.to_string(),
|
|
name: None,
|
|
output,
|
|
};
|
|
}
|
|
|
|
ResponseInputItem::FunctionCallOutput {
|
|
call_id: call_id.to_string(),
|
|
output,
|
|
}
|
|
}
|
|
|
|
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
|
|
Some(self.value.clone())
|
|
}
|
|
|
|
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
|
self.value.clone()
|
|
}
|
|
}
|
|
|
|
impl ToolOutput for codex_protocol::mcp::CallToolResult {
|
|
fn log_preview(&self) -> String {
|
|
let output = self.as_function_call_output_payload();
|
|
let preview = output.body.to_text().unwrap_or_else(|| output.to_string());
|
|
telemetry_preview(&preview)
|
|
}
|
|
|
|
fn success_for_logging(&self) -> bool {
|
|
self.success()
|
|
}
|
|
|
|
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
|
ResponseInputItem::McpToolCallOutput {
|
|
call_id: call_id.to_string(),
|
|
output: self.clone(),
|
|
}
|
|
}
|
|
|
|
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
|
serde_json::to_value(self).unwrap_or_else(|err| {
|
|
JsonValue::String(format!("failed to serialize mcp result: {err}"))
|
|
})
|
|
}
|
|
}
|
|
|
|
fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue {
|
|
match response {
|
|
ResponseInputItem::Message { content, .. } => content_items_to_code_mode_result(
|
|
&content
|
|
.into_iter()
|
|
.map(|item| match item {
|
|
codex_protocol::models::ContentItem::InputText { text }
|
|
| codex_protocol::models::ContentItem::OutputText { text } => {
|
|
FunctionCallOutputContentItem::InputText { text }
|
|
}
|
|
codex_protocol::models::ContentItem::InputImage { image_url, detail } => {
|
|
FunctionCallOutputContentItem::InputImage {
|
|
image_url,
|
|
detail: detail.or(Some(DEFAULT_IMAGE_DETAIL)),
|
|
}
|
|
}
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
),
|
|
ResponseInputItem::FunctionCallOutput { output, .. }
|
|
| ResponseInputItem::CustomToolCallOutput { output, .. } => match output.body {
|
|
FunctionCallOutputBody::Text(text) => JsonValue::String(text),
|
|
FunctionCallOutputBody::ContentItems(items) => {
|
|
content_items_to_code_mode_result(&items)
|
|
}
|
|
},
|
|
ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools),
|
|
ResponseInputItem::McpToolCallOutput { output, .. } => serde_json::to_value(output)
|
|
.unwrap_or_else(|err| {
|
|
JsonValue::String(format!("failed to serialize mcp result: {err}"))
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn content_items_to_code_mode_result(items: &[FunctionCallOutputContentItem]) -> JsonValue {
|
|
JsonValue::String(
|
|
items
|
|
.iter()
|
|
.filter_map(|item| match item {
|
|
FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => {
|
|
Some(text.clone())
|
|
}
|
|
FunctionCallOutputContentItem::InputImage { image_url, .. }
|
|
if !image_url.trim().is_empty() =>
|
|
{
|
|
Some(image_url.clone())
|
|
}
|
|
FunctionCallOutputContentItem::InputText { .. }
|
|
| FunctionCallOutputContentItem::InputImage { .. } => None,
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
)
|
|
}
|
|
|
|
fn telemetry_preview(content: &str) -> String {
|
|
let truncated_slice = take_bytes_at_char_boundary(content, TELEMETRY_PREVIEW_MAX_BYTES);
|
|
let truncated_by_bytes = truncated_slice.len() < content.len();
|
|
|
|
let mut preview = String::new();
|
|
let mut lines_iter = truncated_slice.lines();
|
|
for idx in 0..TELEMETRY_PREVIEW_MAX_LINES {
|
|
match lines_iter.next() {
|
|
Some(line) => {
|
|
if idx > 0 {
|
|
preview.push('\n');
|
|
}
|
|
preview.push_str(line);
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
let truncated_by_lines = lines_iter.next().is_some();
|
|
|
|
if !truncated_by_bytes && !truncated_by_lines {
|
|
return content.to_string();
|
|
}
|
|
|
|
if preview.len() < truncated_slice.len()
|
|
&& truncated_slice
|
|
.as_bytes()
|
|
.get(preview.len())
|
|
.is_some_and(|byte| *byte == b'\n')
|
|
{
|
|
preview.push('\n');
|
|
}
|
|
|
|
if !preview.is_empty() && !preview.ends_with('\n') {
|
|
preview.push('\n');
|
|
}
|
|
preview.push_str(TELEMETRY_PREVIEW_TRUNCATION_NOTICE);
|
|
|
|
preview
|
|
}
|