feat: add support for OpenAI tool type, local_shell (#961)

The new `codex-mini-latest` model expects a new tool with `{"type":
"local_shell"}`. Its contract is similar to the existing `function` tool
with `"name": "shell"`, so this takes the `local_shell` tool call into
`ExecParams` and sends it through the existing
`handle_container_exec_with_params()` code path.

This also adds the following logic when adding the default set of tools
to a request:

```rust
let default_tools = if self.model.starts_with("codex") {
    &DEFAULT_CODEX_MODEL_TOOLS
} else {
    &DEFAULT_TOOLS
};
```

That is, if the model name starts with `"codex"`, we add `{"type":
"local_shell"}` to the list of tools; otherwise, we add the
aforementioned `shell` tool.

To test this, I ran the TUI with `-m codex-mini-latest` and verified
that it used the `local_shell` tool. Though I also had some entries in
`[mcp_servers]` in my personal `config.toml`. The `codex-mini-latest`
model seemed eager to try the tools from the MCP servers first, so I
have personally commented them out for now, so keep an eye out if you're
testing `codex-mini-latest`!

Perhaps we should include more details with `{"type": "local_shell"}` or
update the following:


fd0b1b0208/codex-rs/core/prompt.md

For reference, the corresponding change in the TypeScript CLI is
https://github.com/openai/codex/pull/951.
This commit is contained in:
Michael Bolin
2025-05-16 14:38:08 -07:00
committed by GitHub
parent dfd54e1433
commit f48dd99f22
5 changed files with 109 additions and 18 deletions

View File

@@ -40,10 +40,18 @@ use crate::util::backoff;
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI /// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API. /// Responses API.
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(tag = "type")]
enum OpenAiTool {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "local_shell")]
LocalShell {},
}
#[derive(Debug, Clone, Serialize)]
struct ResponsesApiTool { struct ResponsesApiTool {
name: &'static str, name: &'static str,
r#type: &'static str, // "function"
description: &'static str, description: &'static str,
strict: bool, strict: bool,
parameters: JsonSchema, parameters: JsonSchema,
@@ -67,7 +75,7 @@ enum JsonSchema {
} }
/// Tool usage specification /// Tool usage specification
static DEFAULT_TOOLS: LazyLock<Vec<ResponsesApiTool>> = LazyLock::new(|| { static DEFAULT_TOOLS: LazyLock<Vec<OpenAiTool>> = LazyLock::new(|| {
let mut properties = BTreeMap::new(); let mut properties = BTreeMap::new();
properties.insert( properties.insert(
"command".to_string(), "command".to_string(),
@@ -78,9 +86,8 @@ static DEFAULT_TOOLS: LazyLock<Vec<ResponsesApiTool>> = LazyLock::new(|| {
properties.insert("workdir".to_string(), JsonSchema::String); properties.insert("workdir".to_string(), JsonSchema::String);
properties.insert("timeout".to_string(), JsonSchema::Number); properties.insert("timeout".to_string(), JsonSchema::Number);
vec![ResponsesApiTool { vec![OpenAiTool::Function(ResponsesApiTool {
name: "shell", name: "shell",
r#type: "function",
description: "Runs a shell command, and returns its output.", description: "Runs a shell command, and returns its output.",
strict: false, strict: false,
parameters: JsonSchema::Object { parameters: JsonSchema::Object {
@@ -88,9 +95,12 @@ static DEFAULT_TOOLS: LazyLock<Vec<ResponsesApiTool>> = LazyLock::new(|| {
required: &["command"], required: &["command"],
additional_properties: false, additional_properties: false,
}, },
}] })]
}); });
static DEFAULT_CODEX_MODEL_TOOLS: LazyLock<Vec<OpenAiTool>> =
LazyLock::new(|| vec![OpenAiTool::LocalShell {}]);
#[derive(Clone)] #[derive(Clone)]
pub struct ModelClient { pub struct ModelClient {
model: String, model: String,
@@ -152,8 +162,13 @@ impl ModelClient {
} }
// Assemble tool list: built-in tools + any extra tools from the prompt. // Assemble tool list: built-in tools + any extra tools from the prompt.
let mut tools_json = Vec::with_capacity(DEFAULT_TOOLS.len() + prompt.extra_tools.len()); let default_tools = if self.model.starts_with("codex") {
for t in DEFAULT_TOOLS.iter() { &DEFAULT_CODEX_MODEL_TOOLS
} else {
&DEFAULT_TOOLS
};
let mut tools_json = Vec::with_capacity(default_tools.len() + prompt.extra_tools.len());
for t in default_tools.iter() {
tools_json.push(serde_json::to_value(t)?); tools_json.push(serde_json::to_value(t)?);
} }
tools_json.extend( tools_json.extend(

View File

@@ -51,6 +51,7 @@ use crate::mcp_connection_manager::try_parse_fully_qualified_tool_name;
use crate::mcp_tool_call::handle_mcp_tool_call; use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::models::ContentItem; use crate::models::ContentItem;
use crate::models::FunctionCallOutputPayload; use crate::models::FunctionCallOutputPayload;
use crate::models::LocalShellAction;
use crate::models::ReasoningItemReasoningSummary; use crate::models::ReasoningItemReasoningSummary;
use crate::models::ResponseInputItem; use crate::models::ResponseInputItem;
use crate::models::ResponseItem; use crate::models::ResponseItem;
@@ -992,8 +993,7 @@ async fn handle_response_item(
item: ResponseItem, item: ResponseItem,
) -> CodexResult<Option<ResponseInputItem>> { ) -> CodexResult<Option<ResponseInputItem>> {
debug!(?item, "Output item"); debug!(?item, "Output item");
let mut output = None; let output = match item {
match item {
ResponseItem::Message { content, .. } => { ResponseItem::Message { content, .. } => {
for item in content { for item in content {
if let ContentItem::OutputText { text } = item { if let ContentItem::OutputText { text } = item {
@@ -1004,6 +1004,7 @@ async fn handle_response_item(
sess.tx_event.send(event).await.ok(); sess.tx_event.send(event).await.ok();
} }
} }
None
} }
ResponseItem::Reasoning { id: _, summary } => { ResponseItem::Reasoning { id: _, summary } => {
for item in summary { for item in summary {
@@ -1016,21 +1017,61 @@ async fn handle_response_item(
}; };
sess.tx_event.send(event).await.ok(); sess.tx_event.send(event).await.ok();
} }
None
} }
ResponseItem::FunctionCall { ResponseItem::FunctionCall {
name, name,
arguments, arguments,
call_id, call_id,
} => { } => {
output = Some( tracing::info!("FunctionCall: {arguments}");
handle_function_call(sess, sub_id.to_string(), name, arguments, call_id).await, Some(handle_function_call(sess, sub_id.to_string(), name, arguments, call_id).await)
); }
ResponseItem::LocalShellCall {
id,
call_id,
status: _,
action,
} => {
let LocalShellAction::Exec(action) = action;
tracing::info!("LocalShellCall: {action:?}");
let params = ShellToolCallParams {
command: action.command,
workdir: action.working_directory,
timeout_ms: action.timeout_ms,
};
let effective_call_id = match (call_id, id) {
(Some(call_id), _) => call_id,
(None, Some(id)) => id,
(None, None) => {
error!("LocalShellCall without call_id or id");
return Ok(Some(ResponseInputItem::FunctionCallOutput {
call_id: "".to_string(),
output: FunctionCallOutputPayload {
content: "LocalShellCall without call_id or id".to_string(),
success: None,
},
}));
}
};
let exec_params = to_exec_params(params, sess);
Some(
handle_container_exec_with_params(
exec_params,
sess,
sub_id.to_string(),
effective_call_id,
)
.await,
)
} }
ResponseItem::FunctionCallOutput { .. } => { ResponseItem::FunctionCallOutput { .. } => {
debug!("unexpected FunctionCallOutput from stream"); debug!("unexpected FunctionCallOutput from stream");
None
} }
ResponseItem::Other => (), ResponseItem::Other => None,
} };
Ok(output) Ok(output)
} }

View File

@@ -41,8 +41,9 @@ impl ConversationHistory {
fn is_api_message(message: &ResponseItem) -> bool { fn is_api_message(message: &ResponseItem) -> bool {
match message { match message {
ResponseItem::Message { role, .. } => role.as_str() != "system", ResponseItem::Message { role, .. } => role.as_str() != "system",
ResponseItem::FunctionCall { .. } => true, ResponseItem::FunctionCallOutput { .. }
ResponseItem::FunctionCallOutput { .. } => true, | ResponseItem::FunctionCall { .. }
_ => false, | ResponseItem::LocalShellCall { .. } => true,
ResponseItem::Reasoning { .. } | ResponseItem::Other => false,
} }
} }

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use base64::Engine; use base64::Engine;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
@@ -37,6 +39,14 @@ pub enum ResponseItem {
id: String, id: String,
summary: Vec<ReasoningItemReasoningSummary>, summary: Vec<ReasoningItemReasoningSummary>,
}, },
LocalShellCall {
/// Set when using the chat completions API.
id: Option<String>,
/// Set when using the Responses API.
call_id: Option<String>,
status: LocalShellStatus,
action: LocalShellAction,
},
FunctionCall { FunctionCall {
name: String, name: String,
// The Responses API returns the function call arguments as a *string* that contains // The Responses API returns the function call arguments as a *string* that contains
@@ -71,6 +81,29 @@ impl From<ResponseInputItem> for ResponseItem {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LocalShellStatus {
Completed,
InProgress,
Incomplete,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LocalShellAction {
Exec(LocalShellExecAction),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocalShellExecAction {
pub command: Vec<String>,
pub timeout_ms: Option<u64>,
pub working_directory: Option<String>,
pub env: Option<HashMap<String, String>>,
pub user: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ReasoningItemReasoningSummary { pub enum ReasoningItemReasoningSummary {

View File

@@ -115,6 +115,7 @@ impl RolloutRecorder {
// "fully qualified MCP tool calls," so we could consider // "fully qualified MCP tool calls," so we could consider
// reformatting them in that case. // reformatting them in that case.
ResponseItem::Message { .. } ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. } | ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. } => {} | ResponseItem::FunctionCallOutput { .. } => {}
ResponseItem::Reasoning { .. } | ResponseItem::Other => { ResponseItem::Reasoning { .. } | ResponseItem::Other => {