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:
pakrym-oai 2026-03-10 09:54:34 -07:00 committed by Michael Bolin
parent f9cba5cb16
commit 00ea8aa7ee
12 changed files with 278 additions and 154 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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