Expose strongly-typed result for exec_command (#14183)
Summary - document output types for the various tool handlers and registry so the API exposes richer descriptions - update unified execution helpers and client tests to align with the new output metadata - clean up unused helpers across tool dispatch paths Testing - Not run (not requested)
This commit is contained in:
parent
f9cba5cb16
commit
00ea8aa7ee
12 changed files with 278 additions and 154 deletions
|
|
@ -160,6 +160,7 @@ pub(crate) mod tools {
|
|||
use codex_protocol::config_types::WebSearchUserLocationType;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
|
||||
/// Responses API.
|
||||
|
|
@ -268,6 +269,8 @@ pub(crate) mod tools {
|
|||
/// `properties` must be present in `required`.
|
||||
pub(crate) strict: bool,
|
||||
pub(crate) parameters: JsonSchema,
|
||||
#[serde(skip)]
|
||||
pub(crate) output_schema: Option<Value>,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,15 +14,10 @@ use crate::tools::context::ToolPayload;
|
|||
use crate::tools::js_repl::resolve_compatible_node;
|
||||
use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolCallSource;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_json::json;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
|
@ -60,7 +55,7 @@ enum HostToNodeMessage {
|
|||
},
|
||||
Response {
|
||||
id: String,
|
||||
content_items: Vec<JsonValue>,
|
||||
code_mode_result: JsonValue,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -90,11 +85,11 @@ pub(crate) fn instructions(config: &Config) -> Option<String> {
|
|||
section.push_str("- `code_mode` is a freeform/custom tool. Direct `code_mode` calls must send raw JavaScript tool input. Do not wrap code in JSON, quotes, or markdown code fences.\n");
|
||||
section.push_str("- Direct tool calls remain available while `code_mode` is enabled.\n");
|
||||
section.push_str("- `code_mode` uses the same Node runtime resolution as `js_repl`. If needed, point `js_repl_node_path` at the Node binary you want Codex to use.\n");
|
||||
section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { tools } from \"tools.js\"`. `tools[name]` and identifier wrappers like `await exec_command(args)` remain available for compatibility. Nested tool calls resolve to arrays of content items.\n");
|
||||
section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { tools } from \"tools.js\"`. `tools[name]` and identifier wrappers like `await exec_command(args)` remain available for compatibility. Nested tool calls resolve to their code-mode result values.\n");
|
||||
section.push_str(
|
||||
"- Function tools require JSON object arguments. Freeform tools require raw strings.\n",
|
||||
);
|
||||
section.push_str("- `add_content(value)` is synchronous. It accepts a content item or an array of content items, so `add_content(await exec_command(...))` returns the same content items a direct tool call would expose to the model.\n");
|
||||
section.push_str("- `add_content(value)` is synchronous. It accepts a content item, an array of content items, or a string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`.\n");
|
||||
section
|
||||
.push_str("- Only content passed to `add_content(value)` is surfaced back to the model.");
|
||||
Some(section)
|
||||
|
|
@ -186,7 +181,7 @@ async fn execute_node(
|
|||
NodeToHostMessage::ToolCall { id, name, input } => {
|
||||
let response = HostToNodeMessage::Response {
|
||||
id,
|
||||
content_items: call_nested_tool(exec.clone(), name, input).await,
|
||||
code_mode_result: call_nested_tool(exec.clone(), name, input).await,
|
||||
};
|
||||
write_message(&mut stdin, &response).await?;
|
||||
}
|
||||
|
|
@ -290,9 +285,9 @@ async fn call_nested_tool(
|
|||
exec: ExecContext,
|
||||
tool_name: String,
|
||||
input: Option<JsonValue>,
|
||||
) -> Vec<JsonValue> {
|
||||
) -> JsonValue {
|
||||
if tool_name == "code_mode" {
|
||||
return error_content_items_json("code_mode cannot invoke itself".to_string());
|
||||
return JsonValue::String("code_mode cannot invoke itself".to_string());
|
||||
}
|
||||
|
||||
let nested_config = exec.turn.tools_config.for_code_mode_nested_tools();
|
||||
|
|
@ -306,7 +301,7 @@ async fn call_nested_tool(
|
|||
let specs = router.specs();
|
||||
let payload = match build_nested_tool_payload(&specs, &tool_name, input) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return error_content_items_json(error),
|
||||
Err(error) => return JsonValue::String(error),
|
||||
};
|
||||
|
||||
let call = ToolCall {
|
||||
|
|
@ -314,8 +309,8 @@ async fn call_nested_tool(
|
|||
call_id: format!("code_mode-{}", uuid::Uuid::new_v4()),
|
||||
payload,
|
||||
};
|
||||
let response = router
|
||||
.dispatch_tool_call(
|
||||
let result = router
|
||||
.dispatch_tool_call_with_code_mode_result(
|
||||
Arc::clone(&exec.session),
|
||||
Arc::clone(&exec.turn),
|
||||
Arc::clone(&exec.tracker),
|
||||
|
|
@ -324,11 +319,9 @@ async fn call_nested_tool(
|
|||
)
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) => {
|
||||
json_values_from_output_content_items(content_items_from_response_input(response))
|
||||
}
|
||||
Err(error) => error_content_items_json(error.to_string()),
|
||||
match result {
|
||||
Ok(result) => result.code_mode_result(),
|
||||
Err(error) => JsonValue::String(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -387,70 +380,6 @@ fn build_freeform_tool_payload(
|
|||
}
|
||||
}
|
||||
|
||||
fn content_items_from_response_input(
|
||||
response: ResponseInputItem,
|
||||
) -> Vec<FunctionCallOutputContentItem> {
|
||||
match response {
|
||||
ResponseInputItem::Message { content, .. } => content
|
||||
.into_iter()
|
||||
.map(function_output_content_item_from_content_item)
|
||||
.collect(),
|
||||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||||
content_items_from_function_output(output)
|
||||
}
|
||||
ResponseInputItem::CustomToolCallOutput { output, .. } => {
|
||||
content_items_from_function_output(output)
|
||||
}
|
||||
ResponseInputItem::McpToolCallOutput { result, .. } => match result {
|
||||
Ok(result) => {
|
||||
content_items_from_function_output(FunctionCallOutputPayload::from(&result))
|
||||
}
|
||||
Err(error) => vec![FunctionCallOutputContentItem::InputText { text: error }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn content_items_from_function_output(
|
||||
output: FunctionCallOutputPayload,
|
||||
) -> Vec<FunctionCallOutputContentItem> {
|
||||
match output.body {
|
||||
FunctionCallOutputBody::Text(text) => {
|
||||
vec![FunctionCallOutputContentItem::InputText { text }]
|
||||
}
|
||||
FunctionCallOutputBody::ContentItems(items) => items,
|
||||
}
|
||||
}
|
||||
|
||||
fn function_output_content_item_from_content_item(
|
||||
item: ContentItem,
|
||||
) -> FunctionCallOutputContentItem {
|
||||
match item {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
FunctionCallOutputContentItem::InputText { text }
|
||||
}
|
||||
ContentItem::InputImage { image_url } => FunctionCallOutputContentItem::InputImage {
|
||||
image_url,
|
||||
detail: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn json_values_from_output_content_items(
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
) -> Vec<JsonValue> {
|
||||
content_items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
FunctionCallOutputContentItem::InputText { text } => {
|
||||
json!({ "type": "input_text", "text": text })
|
||||
}
|
||||
FunctionCallOutputContentItem::InputImage { image_url, detail } => {
|
||||
json!({ "type": "input_image", "image_url": image_url, "detail": detail })
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn output_content_items_from_json_values(
|
||||
content_items: Vec<JsonValue>,
|
||||
) -> Result<Vec<FunctionCallOutputContentItem>, String> {
|
||||
|
|
@ -463,7 +392,3 @@ fn output_content_items_from_json_values(
|
|||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn error_content_items_json(message: String) -> Vec<JsonValue> {
|
||||
vec![json!({ "type": "input_text", "text": message })]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,13 +22,20 @@ function __codexCloneContentItem(item) {
|
|||
}
|
||||
}
|
||||
|
||||
function __codexNormalizeContentItems(value) {
|
||||
function __codexNormalizeRawContentItems(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((entry) => __codexNormalizeContentItems(entry));
|
||||
return value.flatMap((entry) => __codexNormalizeRawContentItems(entry));
|
||||
}
|
||||
return [__codexCloneContentItem(value)];
|
||||
}
|
||||
|
||||
function __codexNormalizeContentItems(value) {
|
||||
if (typeof value === 'string') {
|
||||
return [{ type: 'input_text', text: value }];
|
||||
}
|
||||
return __codexNormalizeRawContentItems(value);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, '__codexContentItems', {
|
||||
value: __codexContentItems,
|
||||
configurable: true,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function createProtocol() {
|
|||
return;
|
||||
}
|
||||
pending.delete(message.id);
|
||||
entry.resolve(Array.isArray(message.content_items) ? message.content_items : []);
|
||||
entry.resolve(message.code_mode_result ?? '');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ use codex_protocol::models::ResponseInputItem;
|
|||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
|
@ -73,7 +75,11 @@ pub trait ToolOutput: Send {
|
|||
|
||||
fn success_for_logging(&self) -> bool;
|
||||
|
||||
fn into_response(self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem;
|
||||
|
||||
fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
|
||||
response_input_to_code_mode_result(self.to_response_item("", payload))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct McpToolOutput {
|
||||
|
|
@ -89,11 +95,10 @@ impl ToolOutput for McpToolOutput {
|
|||
self.result.is_ok()
|
||||
}
|
||||
|
||||
fn into_response(self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
||||
let Self { result } = self;
|
||||
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
||||
ResponseInputItem::McpToolCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
result,
|
||||
result: self.result.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -137,9 +142,8 @@ impl ToolOutput for FunctionToolOutput {
|
|||
self.success.unwrap_or(true)
|
||||
}
|
||||
|
||||
fn into_response(self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
let Self { body, success } = self;
|
||||
function_tool_response(call_id, payload, body, success)
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
function_tool_response(call_id, payload, self.body.clone(), self.success)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +170,7 @@ impl ToolOutput for ExecCommandToolOutput {
|
|||
true
|
||||
}
|
||||
|
||||
fn into_response(self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
function_tool_response(
|
||||
call_id,
|
||||
payload,
|
||||
|
|
@ -176,6 +180,35 @@ impl ToolOutput for ExecCommandToolOutput {
|
|||
Some(true),
|
||||
)
|
||||
}
|
||||
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
|
||||
#[derive(Serialize)]
|
||||
struct UnifiedExecCodeModeResult {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
chunk_id: Option<String>,
|
||||
wall_time_seconds: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
exit_code: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
session_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
original_token_count: Option<usize>,
|
||||
output: String,
|
||||
}
|
||||
|
||||
let result = UnifiedExecCodeModeResult {
|
||||
chunk_id: (!self.chunk_id.is_empty()).then(|| self.chunk_id.clone()),
|
||||
wall_time_seconds: self.wall_time.as_secs_f64(),
|
||||
exit_code: self.exit_code,
|
||||
session_id: self.process_id.clone(),
|
||||
original_token_count: self.original_token_count,
|
||||
output: self.truncated_output(),
|
||||
};
|
||||
|
||||
serde_json::to_value(result).unwrap_or_else(|err| {
|
||||
JsonValue::String(format!("failed to serialize exec result: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecCommandToolOutput {
|
||||
|
|
@ -214,6 +247,65 @@ impl ExecCommandToolOutput {
|
|||
}
|
||||
}
|
||||
|
||||
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 } => {
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url,
|
||||
detail: None,
|
||||
}
|
||||
}
|
||||
})
|
||||
.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::McpToolCallOutput { result, .. } => match result {
|
||||
Ok(result) => match FunctionCallOutputPayload::from(&result).body {
|
||||
FunctionCallOutputBody::Text(text) => JsonValue::String(text),
|
||||
FunctionCallOutputBody::ContentItems(items) => {
|
||||
content_items_to_code_mode_result(&items)
|
||||
}
|
||||
},
|
||||
Err(error) => JsonValue::String(error),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 function_tool_response(
|
||||
call_id: &str,
|
||||
payload: &ToolPayload,
|
||||
|
|
@ -292,7 +384,7 @@ mod tests {
|
|||
input: "patch".to_string(),
|
||||
};
|
||||
let response = FunctionToolOutput::from_text("patched".to_string(), Some(true))
|
||||
.into_response("call-42", &payload);
|
||||
.to_response_item("call-42", &payload);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
|
|
@ -311,7 +403,7 @@ mod tests {
|
|||
arguments: "{}".to_string(),
|
||||
};
|
||||
let response = FunctionToolOutput::from_text("ok".to_string(), Some(true))
|
||||
.into_response("fn-1", &payload);
|
||||
.to_response_item("fn-1", &payload);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
||||
|
|
@ -344,7 +436,7 @@ mod tests {
|
|||
],
|
||||
Some(true),
|
||||
)
|
||||
.into_response("call-99", &payload);
|
||||
.to_response_item("call-99", &payload);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
|
|
@ -433,7 +525,7 @@ mod tests {
|
|||
original_token_count: Some(10),
|
||||
session_command: None,
|
||||
}
|
||||
.into_response("call-42", &payload);
|
||||
.to_response_item("call-42", &payload);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { call_id, output } => {
|
||||
|
|
|
|||
|
|
@ -420,13 +420,14 @@ It is important to remember:
|
|||
- You must prefix new lines with `+` even when creating a new file
|
||||
- File references can only be relative, NEVER ABSOLUTE.
|
||||
"#
|
||||
.to_string(),
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["input".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ impl crate::tools::context::ToolOutput for McpHandlerOutput {
|
|||
}
|
||||
}
|
||||
|
||||
fn into_response(self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
|
||||
match self {
|
||||
Self::Mcp(output) => output.into_response(call_id, payload),
|
||||
Self::Function(output) => output.into_response(call_id, payload),
|
||||
Self::Mcp(output) => output.to_response_item(call_id, payload),
|
||||
Self::Function(output) => output.to_response_item(call_id, payload),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ At most one step can be in_progress at a time.
|
|||
required: Some(vec!["plan".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -57,10 +57,28 @@ pub trait ToolHandler: Send + Sync {
|
|||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError>;
|
||||
}
|
||||
|
||||
struct AnyToolResult {
|
||||
preview: String,
|
||||
success: bool,
|
||||
response: ResponseInputItem,
|
||||
pub(crate) struct AnyToolResult {
|
||||
pub(crate) call_id: String,
|
||||
pub(crate) payload: ToolPayload,
|
||||
pub(crate) result: Box<dyn ToolOutput>,
|
||||
}
|
||||
|
||||
impl AnyToolResult {
|
||||
pub(crate) fn into_response(self) -> ResponseInputItem {
|
||||
let Self {
|
||||
call_id,
|
||||
payload,
|
||||
result,
|
||||
} = self;
|
||||
result.to_response_item(&call_id, &payload)
|
||||
}
|
||||
|
||||
pub(crate) fn code_mode_result(self) -> serde_json::Value {
|
||||
let Self {
|
||||
payload, result, ..
|
||||
} = self;
|
||||
result.code_mode_result(&payload)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -95,13 +113,10 @@ where
|
|||
let call_id = invocation.call_id.clone();
|
||||
let payload = invocation.payload.clone();
|
||||
let output = self.handle(invocation).await?;
|
||||
let preview = output.log_preview();
|
||||
let success = output.success_for_logging();
|
||||
let response = output.into_response(&call_id, &payload);
|
||||
Ok(AnyToolResult {
|
||||
preview,
|
||||
success,
|
||||
response,
|
||||
call_id,
|
||||
payload,
|
||||
result: Box::new(output),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -127,10 +142,10 @@ impl ToolRegistry {
|
|||
// }
|
||||
// }
|
||||
|
||||
pub async fn dispatch(
|
||||
pub(crate) async fn dispatch_any(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<ResponseInputItem, FunctionCallError> {
|
||||
) -> Result<AnyToolResult, FunctionCallError> {
|
||||
let tool_name = invocation.tool_name.clone();
|
||||
let call_id_owned = invocation.call_id.clone();
|
||||
let otel = invocation.turn.session_telemetry.clone();
|
||||
|
|
@ -237,13 +252,10 @@ impl ToolRegistry {
|
|||
}
|
||||
match handler.handle_any(invocation_for_tool).await {
|
||||
Ok(result) => {
|
||||
let AnyToolResult {
|
||||
preview,
|
||||
success,
|
||||
response,
|
||||
} = result;
|
||||
let preview = result.result.log_preview();
|
||||
let success = result.result.success_for_logging();
|
||||
let mut guard = response_cell.lock().await;
|
||||
*guard = Some(response);
|
||||
*guard = Some(result);
|
||||
Ok((preview, success))
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
|
|
@ -275,10 +287,10 @@ impl ToolRegistry {
|
|||
match result {
|
||||
Ok(_) => {
|
||||
let mut guard = response_cell.lock().await;
|
||||
let response = guard.take().ok_or_else(|| {
|
||||
let result = guard.take().ok_or_else(|| {
|
||||
FunctionCallError::Fatal("tool produced no output".to_string())
|
||||
})?;
|
||||
Ok(response)
|
||||
Ok(result)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,16 @@ use crate::codex::TurnContext;
|
|||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::registry::AnyToolResult;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::build_specs;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
|
@ -145,6 +146,21 @@ impl ToolRouter {
|
|||
call: ToolCall,
|
||||
source: ToolCallSource,
|
||||
) -> Result<ResponseInputItem, FunctionCallError> {
|
||||
Ok(self
|
||||
.dispatch_tool_call_with_code_mode_result(session, turn, tracker, call, source)
|
||||
.await?
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[instrument(level = "trace", skip_all, err)]
|
||||
pub async fn dispatch_tool_call_with_code_mode_result(
|
||||
&self,
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
call: ToolCall,
|
||||
source: ToolCallSource,
|
||||
) -> Result<AnyToolResult, FunctionCallError> {
|
||||
let ToolCall {
|
||||
tool_name,
|
||||
call_id,
|
||||
|
|
@ -161,7 +177,7 @@ impl ToolRouter {
|
|||
"direct tool calls are disabled; use js_repl and codex.tool(...) instead"
|
||||
.to_string(),
|
||||
);
|
||||
return Ok(Self::failure_response(
|
||||
return Ok(Self::failure_result(
|
||||
failure_call_id,
|
||||
payload_outputs_custom,
|
||||
err,
|
||||
|
|
@ -177,10 +193,10 @@ impl ToolRouter {
|
|||
payload,
|
||||
};
|
||||
|
||||
match self.registry.dispatch(invocation).await {
|
||||
match self.registry.dispatch_any(invocation).await {
|
||||
Ok(response) => Ok(response),
|
||||
Err(FunctionCallError::Fatal(message)) => Err(FunctionCallError::Fatal(message)),
|
||||
Err(err) => Ok(Self::failure_response(
|
||||
Err(err) => Ok(Self::failure_result(
|
||||
failure_call_id,
|
||||
payload_outputs_custom,
|
||||
err,
|
||||
|
|
@ -188,27 +204,27 @@ impl ToolRouter {
|
|||
}
|
||||
}
|
||||
|
||||
fn failure_response(
|
||||
fn failure_result(
|
||||
call_id: String,
|
||||
payload_outputs_custom: bool,
|
||||
err: FunctionCallError,
|
||||
) -> ResponseInputItem {
|
||||
) -> AnyToolResult {
|
||||
let message = err.to_string();
|
||||
if payload_outputs_custom {
|
||||
ResponseInputItem::CustomToolCallOutput {
|
||||
AnyToolResult {
|
||||
call_id,
|
||||
output: codex_protocol::models::FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(message),
|
||||
success: Some(false),
|
||||
payload: ToolPayload::Custom {
|
||||
input: String::new(),
|
||||
},
|
||||
result: Box::new(FunctionToolOutput::from_text(message, Some(false))),
|
||||
}
|
||||
} else {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
AnyToolResult {
|
||||
call_id,
|
||||
output: codex_protocol::models::FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(message),
|
||||
success: Some(false),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
result: Box::new(FunctionToolOutput::from_text(message, Some(false))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,40 @@ use std::collections::HashMap;
|
|||
const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_description.md");
|
||||
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
|
||||
|
||||
fn unified_exec_output_schema() -> JsonValue {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"chunk_id": {
|
||||
"type": "string",
|
||||
"description": "Chunk identifier included when the response reports one."
|
||||
},
|
||||
"wall_time_seconds": {
|
||||
"type": "number",
|
||||
"description": "Elapsed wall time spent waiting for output in seconds."
|
||||
},
|
||||
"exit_code": {
|
||||
"type": "number",
|
||||
"description": "Process exit code when the command finished during this call."
|
||||
},
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "Session identifier to pass to write_stdin when the process is still running."
|
||||
},
|
||||
"original_token_count": {
|
||||
"type": "number",
|
||||
"description": "Approximate token count before output truncation."
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"description": "Command output text, possibly truncated."
|
||||
}
|
||||
},
|
||||
"required": ["wall_time_seconds", "output"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ShellCommandBackendConfig {
|
||||
Classic,
|
||||
|
|
@ -479,6 +513,7 @@ fn create_exec_command_tool(allow_login_shell: bool, request_permission_enabled:
|
|||
required: Some(vec!["cmd".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(unified_exec_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -526,6 +561,7 @@ fn create_write_stdin_tool() -> ToolSpec {
|
|||
required: Some(vec!["session_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: Some(unified_exec_output_schema()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -579,6 +615,7 @@ Examples of valid command strings:
|
|||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -646,6 +683,7 @@ Examples of valid command strings:
|
|||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -668,6 +706,7 @@ fn create_view_image_tool() -> ToolSpec {
|
|||
required: Some(vec!["path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -793,6 +832,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
|||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -869,6 +909,7 @@ fn create_spawn_agents_on_csv_tool() -> ToolSpec {
|
|||
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -918,6 +959,7 @@ fn create_report_agent_job_result_tool() -> ToolSpec {
|
|||
]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -960,6 +1002,7 @@ fn create_send_input_tool() -> ToolSpec {
|
|||
required: Some(vec!["id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -983,6 +1026,7 @@ fn create_resume_agent_tool() -> ToolSpec {
|
|||
required: Some(vec!["id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1017,6 +1061,7 @@ fn create_wait_tool() -> ToolSpec {
|
|||
required: Some(vec!["ids".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1102,6 +1147,7 @@ fn create_request_user_input_tool(
|
|||
required: Some(vec!["questions".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1126,6 +1172,7 @@ fn create_request_permissions_tool() -> ToolSpec {
|
|||
required: Some(vec!["permissions".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1148,6 +1195,7 @@ fn create_close_agent_tool() -> ToolSpec {
|
|||
required: Some(vec!["id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1215,6 +1263,7 @@ fn create_test_sync_tool() -> ToolSpec {
|
|||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1266,6 +1315,7 @@ fn create_grep_files_tool() -> ToolSpec {
|
|||
required: Some(vec!["pattern".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1311,6 +1361,7 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSp
|
|||
required: Some(vec!["query".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1414,6 +1465,7 @@ fn create_read_file_tool() -> ToolSpec {
|
|||
required: Some(vec!["file_path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1460,6 +1512,7 @@ fn create_list_dir_tool() -> ToolSpec {
|
|||
required: Some(vec!["dir_path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1534,6 +1587,7 @@ fn create_js_repl_reset_tool() -> ToolSpec {
|
|||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1549,7 +1603,7 @@ source: /[\s\S]+/
|
|||
enabled_tool_names.join(", ")
|
||||
};
|
||||
let description = format!(
|
||||
"Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `code_mode` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ tools }} from \"tools.js\"`. `tools[name]` and identifier wrappers like `await shell(args)` remain available for compatibility when the tool name is a valid JS identifier. Nested tool calls resolve to arrays of content items. Function tools require JSON object arguments. Freeform tools require raw strings. Use synchronous `add_content(value)` with a content item or content-item array, including `add_content(await exec_command(...))`, to return the same content items a direct tool call would expose to the model. Only content passed to `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}."
|
||||
"Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `code_mode` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ tools }} from \"tools.js\"`. `tools[name]` and identifier wrappers like `await shell(args)` remain available for compatibility when the tool name is a valid JS identifier. Nested tool calls resolve to their code-mode result values. Function tools require JSON object arguments. Freeform tools require raw strings. Use synchronous `add_content(value)` with a content item, content-item array, or string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`. Only content passed to `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}."
|
||||
);
|
||||
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
|
|
@ -1594,6 +1648,7 @@ fn create_list_mcp_resources_tool() -> ToolSpec {
|
|||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1628,6 +1683,7 @@ fn create_list_mcp_resource_templates_tool() -> ToolSpec {
|
|||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1664,6 +1720,7 @@ fn create_read_mcp_resource_tool() -> ToolSpec {
|
|||
required: Some(vec!["server".to_string(), "uri".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1726,6 +1783,7 @@ pub(crate) fn mcp_tool_to_openai_tool(
|
|||
description: description.map(Into::into).unwrap_or_default(),
|
||||
strict: false,
|
||||
parameters: input_schema,
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1739,6 +1797,7 @@ fn dynamic_tool_to_openai_tool(
|
|||
description: tool.description.clone(),
|
||||
strict: false,
|
||||
parameters: input_schema,
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -3278,6 +3337,7 @@ mod tests {
|
|||
},
|
||||
description: "Do something cool".to_string(),
|
||||
strict: false,
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -3516,6 +3576,7 @@ mod tests {
|
|||
},
|
||||
description: "Search docs".to_string(),
|
||||
strict: false,
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -3567,6 +3628,7 @@ mod tests {
|
|||
},
|
||||
description: "Pagination".to_string(),
|
||||
strict: false,
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -3622,6 +3684,7 @@ mod tests {
|
|||
},
|
||||
description: "Tags".to_string(),
|
||||
strict: false,
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -3675,6 +3738,7 @@ mod tests {
|
|||
},
|
||||
description: "AnyOf Value".to_string(),
|
||||
strict: false,
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -3933,6 +3997,7 @@ Examples of valid command strings:
|
|||
},
|
||||
description: "Do something cool".to_string(),
|
||||
strict: false,
|
||||
output_schema: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -3950,6 +4015,7 @@ Examples of valid command strings:
|
|||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
})];
|
||||
|
||||
let responses_json = create_tools_json_for_responses_api(&tools).unwrap();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use core_test_support::skip_if_no_network;
|
|||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use wiremock::MockServer;
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ async fn code_mode_can_return_exec_command_output() -> Result<()> {
|
|||
r#"
|
||||
import { exec_command } from "tools.js";
|
||||
|
||||
add_content(await exec_command({ cmd: "printf code_mode_exec_marker" }));
|
||||
add_content(JSON.stringify(await exec_command({ cmd: "printf code_mode_exec_marker" })));
|
||||
"#,
|
||||
false,
|
||||
)
|
||||
|
|
@ -88,19 +88,20 @@ add_content(await exec_command({ cmd: "printf code_mode_exec_marker" }));
|
|||
Some(false),
|
||||
"code_mode call failed unexpectedly: {output}"
|
||||
);
|
||||
let regex = Regex::new(
|
||||
r#"(?ms)^Chunk ID: [[:xdigit:]]+
|
||||
Wall time: [0-9]+(?:\.[0-9]+)? seconds
|
||||
Process exited with code 0
|
||||
Original token count: [0-9]+
|
||||
Output:
|
||||
code_mode_exec_marker
|
||||
?$"#,
|
||||
)?;
|
||||
let parsed: Value = serde_json::from_str(&output)?;
|
||||
assert!(
|
||||
regex.is_match(&output),
|
||||
"expected exec_command output envelope to match regex, got: {output}"
|
||||
parsed
|
||||
.get("chunk_id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|chunk_id| !chunk_id.is_empty())
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.get("output").and_then(Value::as_str),
|
||||
Some("code_mode_exec_marker"),
|
||||
);
|
||||
assert_eq!(parsed.get("exit_code").and_then(Value::as_i64), Some(0));
|
||||
assert!(parsed.get("wall_time_seconds").is_some());
|
||||
assert!(parsed.get("session_id").is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue