mirror of
https://github.com/openai/codex.git
synced 2026-04-28 08:34:54 +00:00
41 KiB
41 KiB
PR #2576: [apply_patch] freeform apply_patch tool
- URL: https://github.com/openai/codex/pull/2576
- Author: dylan-hurd-oai
- Created: 2025-08-22 06:44:23 UTC
- Updated: 2025-08-22 20:42:42 UTC
- Changes: +534/-138, Files changed: 14, Commits: 7
Description
Summary
GPT-5 introduced the concept of custom tools, which allow the model to send a raw string result back, simplifying json-escape issues. We are migrating gpt-5 to use this by default.
However, gpt-oss models do not support custom tools, only normal functions. So we keep both tool definitions, and provide whichever one the model family supports.
Testing
- Tested locally with various models
- Unit tests pass
Full Diff
diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs
index f3ed34c6dc..7131b8f466 100644
--- a/codex-rs/core/src/chat_completions.rs
+++ b/codex-rs/core/src/chat_completions.rs
@@ -102,6 +102,33 @@ pub(crate) async fn stream_chat_completions(
"content": output.content,
}));
}
+ ResponseItem::CustomToolCall {
+ id,
+ call_id: _,
+ name,
+ input,
+ status: _,
+ } => {
+ messages.push(json!({
+ "role": "assistant",
+ "content": null,
+ "tool_calls": [{
+ "id": id,
+ "type": "custom",
+ "custom": {
+ "name": name,
+ "input": input,
+ }
+ }]
+ }));
+ }
+ ResponseItem::CustomToolCallOutput { call_id, output } => {
+ messages.push(json!({
+ "role": "tool",
+ "tool_call_id": call_id,
+ "content": output,
+ }));
+ }
ResponseItem::Reasoning { .. } | ResponseItem::Other => {
// Omit these items from the conversation history.
continue;
diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs
index 174ac58f2e..43f3f1de9f 100644
--- a/codex-rs/core/src/client.rs
+++ b/codex-rs/core/src/client.rs
@@ -575,6 +575,8 @@ async fn process_sse<S>(
}
"response.content_part.done"
| "response.function_call_arguments.delta"
+ | "response.custom_tool_call_input.delta"
+ | "response.custom_tool_call_input.done" // also emitted as response.output_item.done
| "response.in_progress"
| "response.output_item.added"
| "response.output_text.done" => {
diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs
index f99fe94518..c9a8a42903 100644
--- a/codex-rs/core/src/client_common.rs
+++ b/codex-rs/core/src/client_common.rs
@@ -49,13 +49,14 @@ impl Prompt {
.unwrap_or(BASE_INSTRUCTIONS);
let mut sections: Vec<&str> = vec![base];
- // When there are no custom instructions, add apply_patch if either:
- // - the model needs special instructions, or
+ // When there are no custom instructions, add apply_patch_tool_instructions if either:
+ // - the model needs special instructions (4.1), or
// - there is no apply_patch tool present
- let is_apply_patch_tool_present = self
- .tools
- .iter()
- .any(|t| matches!(t, OpenAiTool::Function(f) if f.name == "apply_patch"));
+ let is_apply_patch_tool_present = self.tools.iter().any(|tool| match tool {
+ OpenAiTool::Function(f) => f.name == "apply_patch",
+ OpenAiTool::Freeform(f) => f.name == "apply_patch",
+ _ => false,
+ });
if self.base_instructions_override.is_none()
&& (model.needs_special_apply_patch_instructions || !is_apply_patch_tool_present)
{
diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index f30ebcd32e..4188a5df0b 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -1406,6 +1406,18 @@ async fn run_task(
},
);
}
+ (
+ ResponseItem::CustomToolCall { .. },
+ Some(ResponseInputItem::CustomToolCallOutput { call_id, output }),
+ ) => {
+ items_to_record_in_conversation_history.push(item);
+ items_to_record_in_conversation_history.push(
+ ResponseItem::CustomToolCallOutput {
+ call_id: call_id.clone(),
+ output: output.clone(),
+ },
+ );
+ }
(
ResponseItem::FunctionCall { .. },
Some(ResponseInputItem::McpToolCallOutput { call_id, result }),
@@ -1586,6 +1598,7 @@ async fn try_run_turn(
call_id: Some(call_id),
..
} => Some(call_id),
+ ResponseItem::CustomToolCallOutput { call_id, .. } => Some(call_id),
_ => None,
})
.collect::<Vec<_>>();
@@ -1603,6 +1616,7 @@ async fn try_run_turn(
call_id: Some(call_id),
..
} => Some(call_id),
+ ResponseItem::CustomToolCall { call_id, .. } => Some(call_id),
_ => None,
})
.filter_map(|call_id| {
@@ -1612,12 +1626,9 @@ async fn try_run_turn(
Some(call_id.clone())
}
})
- .map(|call_id| ResponseItem::FunctionCallOutput {
+ .map(|call_id| ResponseItem::CustomToolCallOutput {
call_id: call_id.clone(),
- output: FunctionCallOutputPayload {
- content: "aborted".to_string(),
- success: Some(false),
- },
+ output: "aborted".to_string(),
})
.collect::<Vec<_>>()
};
@@ -1882,7 +1893,7 @@ async fn handle_response_item(
call_id,
..
} => {
- info!("FunctionCall: {arguments}");
+ info!("FunctionCall: {name}({arguments})");
Some(
handle_function_call(
sess,
@@ -1939,10 +1950,32 @@ async fn handle_response_item(
.await,
)
}
+ ResponseItem::CustomToolCall {
+ id: _,
+ call_id,
+ name,
+ input,
+ status: _,
+ } => Some(
+ handle_custom_tool_call(
+ sess,
+ turn_context,
+ turn_diff_tracker,
+ sub_id.to_string(),
+ name,
+ input,
+ call_id,
+ )
+ .await,
+ ),
ResponseItem::FunctionCallOutput { .. } => {
debug!("unexpected FunctionCallOutput from stream");
None
}
+ ResponseItem::CustomToolCallOutput { .. } => {
+ debug!("unexpected CustomToolCallOutput from stream");
+ None
+ }
ResponseItem::Other => None,
};
Ok(output)
@@ -2032,6 +2065,58 @@ async fn handle_function_call(
}
}
+async fn handle_custom_tool_call(
+ sess: &Session,
+ turn_context: &TurnContext,
+ turn_diff_tracker: &mut TurnDiffTracker,
+ sub_id: String,
+ name: String,
+ input: String,
+ call_id: String,
+) -> ResponseInputItem {
+ info!("CustomToolCall: {name} {input}");
+ match name.as_str() {
+ "apply_patch" => {
+ let exec_params = ExecParams {
+ command: vec!["apply_patch".to_string(), input.clone()],
+ cwd: turn_context.cwd.clone(),
+ timeout_ms: None,
+ env: HashMap::new(),
+ with_escalated_permissions: None,
+ justification: None,
+ };
+ let resp = handle_container_exec_with_params(
+ exec_params,
+ sess,
+ turn_context,
+ turn_diff_tracker,
+ sub_id,
+ call_id,
+ )
+ .await;
+
+ // Convert function-call style output into a custom tool call output
+ match resp {
+ ResponseInputItem::FunctionCallOutput { call_id, output } => {
+ ResponseInputItem::CustomToolCallOutput {
+ call_id,
+ output: output.content,
+ }
+ }
+ // Pass through if already a custom tool output or other variant
+ other => other,
+ }
+ }
+ _ => {
+ debug!("unexpected CustomToolCall from stream");
+ ResponseInputItem::CustomToolCallOutput {
+ call_id,
+ output: format!("unsupported custom tool call: {name}"),
+ }
+ }
+ }
+}
+
fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams {
ExecParams {
command: params.command,
diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs
index 31ff5f103e..988d4767bf 100644
--- a/codex-rs/core/src/config.rs
+++ b/codex-rs/core/src/config.rs
@@ -646,7 +646,7 @@ impl Config {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries,
uses_local_shell_tool: false,
- uses_apply_patch_tool: false,
+ apply_patch_tool_type: None,
}
});
@@ -673,9 +673,6 @@ impl Config {
Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
let base_instructions = base_instructions.or(file_base_instructions);
- let include_apply_patch_tool_val =
- include_apply_patch_tool.unwrap_or(model_family.uses_apply_patch_tool);
-
let responses_originator_header: String = cfg
.responses_originator_header_internal_override
.unwrap_or(DEFAULT_RESPONSES_ORIGINATOR_HEADER.to_owned());
@@ -732,7 +729,7 @@ impl Config {
experimental_resume,
include_plan_tool: include_plan_tool.unwrap_or(false),
- include_apply_patch_tool: include_apply_patch_tool_val,
+ include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false),
responses_originator_header,
preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT),
};
diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs
index 1d55b125bc..5955ce44b9 100644
--- a/codex-rs/core/src/conversation_history.rs
+++ b/codex-rs/core/src/conversation_history.rs
@@ -110,6 +110,8 @@ fn is_api_message(message: &ResponseItem) -> bool {
ResponseItem::Message { role, .. } => role.as_str() != "system",
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::FunctionCall { .. }
+ | ResponseItem::CustomToolCall { .. }
+ | ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::Reasoning { .. } => true,
ResponseItem::Other => false,
diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs
index 40ccbf6769..37f30a72d0 100644
--- a/codex-rs/core/src/lib.rs
+++ b/codex-rs/core/src/lib.rs
@@ -50,6 +50,7 @@ pub mod seatbelt;
pub mod shell;
pub mod spawn;
pub mod terminal;
+mod tool_apply_patch;
pub mod turn_diff_tracker;
pub mod user_agent;
mod user_notification;
diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs
index 6d1c2efcc1..5b252e66bb 100644
--- a/codex-rs/core/src/model_family.rs
+++ b/codex-rs/core/src/model_family.rs
@@ -1,3 +1,5 @@
+use crate::tool_apply_patch::ApplyPatchToolType;
+
/// A model family is a group of models that share certain characteristics.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ModelFamily {
@@ -24,9 +26,9 @@ pub struct ModelFamily {
// See https://platform.openai.com/docs/guides/tools-local-shell
pub uses_local_shell_tool: bool,
- /// True if the model performs better when `apply_patch` is provided as
- /// a tool call instead of just a bash command.
- pub uses_apply_patch_tool: bool,
+ /// Present if the model performs better when `apply_patch` is provided as
+ /// a tool call instead of just a bash command
+ pub apply_patch_tool_type: Option<ApplyPatchToolType>,
}
macro_rules! model_family {
@@ -40,7 +42,7 @@ macro_rules! model_family {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
uses_local_shell_tool: false,
- uses_apply_patch_tool: false,
+ apply_patch_tool_type: None,
};
// apply overrides
$(
@@ -60,7 +62,7 @@ macro_rules! simple_model_family {
needs_special_apply_patch_instructions: false,
supports_reasoning_summaries: false,
uses_local_shell_tool: false,
- uses_apply_patch_tool: false,
+ apply_patch_tool_type: None,
})
}};
}
@@ -88,6 +90,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(
slug, slug,
supports_reasoning_summaries: true,
+ apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
)
} else if slug.starts_with("gpt-4.1") {
model_family!(
@@ -95,7 +98,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
needs_special_apply_patch_instructions: true,
)
} else if slug.starts_with("gpt-oss") {
- model_family!(slug, "gpt-oss", uses_apply_patch_tool: true)
+ model_family!(slug, "gpt-oss", apply_patch_tool_type: Some(ApplyPatchToolType::Function))
} else if slug.starts_with("gpt-4o") {
simple_model_family!(slug, "gpt-4o")
} else if slug.starts_with("gpt-3.5") {
@@ -104,6 +107,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
model_family!(
slug, "gpt-5",
supports_reasoning_summaries: true,
+ apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
)
} else {
None
diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs
index 0c509ee282..aa092e1844 100644
--- a/codex-rs/core/src/models.rs
+++ b/codex-rs/core/src/models.rs
@@ -24,6 +24,10 @@ pub enum ResponseInputItem {
call_id: String,
result: Result<CallToolResult, String>,
},
+ CustomToolCallOutput {
+ call_id: String,
+ output: String,
+ },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -77,6 +81,20 @@ pub enum ResponseItem {
call_id: String,
output: FunctionCallOutputPayload,
},
+ CustomToolCall {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ id: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ status: Option<String>,
+
+ call_id: String,
+ name: String,
+ input: String,
+ },
+ CustomToolCallOutput {
+ call_id: String,
+ output: String,
+ },
#[serde(other)]
Other,
}
@@ -114,6 +132,9 @@ impl From<ResponseInputItem> for ResponseItem {
),
},
},
+ ResponseInputItem::CustomToolCallOutput { call_id, output } => {
+ Self::CustomToolCallOutput { call_id, output }
+ }
}
}
}
diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs
index df60d0c373..bb5e6dacbf 100644
--- a/codex-rs/core/src/openai_tools.rs
+++ b/codex-rs/core/src/openai_tools.rs
@@ -9,6 +9,9 @@ use crate::model_family::ModelFamily;
use crate::plan_tool::PLAN_TOOL;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
+use crate::tool_apply_patch::ApplyPatchToolType;
+use crate::tool_apply_patch::create_apply_patch_freeform_tool;
+use crate::tool_apply_patch::create_apply_patch_json_tool;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiTool {
@@ -21,6 +24,20 @@ pub struct ResponsesApiTool {
pub(crate) parameters: JsonSchema,
}
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct FreeformTool {
+ pub(crate) name: String,
+ pub(crate) description: String,
+ pub(crate) format: FreeformToolFormat,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct FreeformToolFormat {
+ pub(crate) r#type: String,
+ pub(crate) syntax: String,
+ pub(crate) definition: String,
+}
+
/// When serialized as JSON, this produces a valid "Tool" in the OpenAI
/// Responses API.
#[derive(Debug, Clone, Serialize, PartialEq)]
@@ -30,6 +47,8 @@ pub(crate) enum OpenAiTool {
Function(ResponsesApiTool),
#[serde(rename = "local_shell")]
LocalShell {},
+ #[serde(rename = "custom")]
+ Freeform(FreeformTool),
}
#[derive(Debug, Clone)]
@@ -43,7 +62,7 @@ pub enum ConfigShellToolType {
pub struct ToolsConfig {
pub shell_type: ConfigShellToolType,
pub plan_tool: bool,
- pub apply_patch_tool: bool,
+ pub apply_patch_tool_type: Option<ApplyPatchToolType>,
}
impl ToolsConfig {
@@ -65,10 +84,22 @@ impl ToolsConfig {
}
}
+ let apply_patch_tool_type = match model_family.apply_patch_tool_type {
+ Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform),
+ Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function),
+ None => {
+ if include_apply_patch_tool {
+ Some(ApplyPatchToolType::Freeform)
+ } else {
+ None
+ }
+ }
+ };
+
Self {
shell_type,
plan_tool: include_plan_tool,
- apply_patch_tool: include_apply_patch_tool || model_family.uses_apply_patch_tool,
+ apply_patch_tool_type,
}
}
}
@@ -241,102 +272,12 @@ The shell tool is used to execute shell commands.
},
})
}
-
+/// TODO(dylan): deprecate once we get rid of json tool
#[derive(Serialize, Deserialize)]
pub(crate) struct ApplyPatchToolArgs {
pub(crate) input: String,
}
-/// Returns a JSON tool that can be used to edit files. Public for testing, please use `get_openai_tools`.
-fn create_apply_patch_json_tool() -> OpenAiTool {
- let mut properties = BTreeMap::new();
- properties.insert(
- "input".to_string(),
- JsonSchema::String {
- description: Some(r#"The entire contents of the apply_patch command"#.to_string()),
- },
- );
-
- OpenAiTool::Function(ResponsesApiTool {
- name: "apply_patch".to_string(),
- description: r#"Use the `apply_patch` tool to edit files.
-Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
-
-*** Begin Patch
-[ one or more file sections ]
-*** End Patch
-
-Within that envelope, you get a sequence of file operations.
-You MUST include a header to specify the action you are taking.
-Each operation starts with one of three headers:
-
-*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
-*** Delete File: <path> - remove an existing file. Nothing follows.
-*** Update File: <path> - patch an existing file in place (optionally with a rename).
-
-May be immediately followed by *** Move to: <new path> if you want to rename the file.
-Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
-Within a hunk each line starts with:
-
-For instructions on [context_before] and [context_after]:
-- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change’s [context_after] lines in the second change’s [context_before] lines.
-- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
-@@ class BaseClass
-[3 lines of pre-context]
-- [old_code]
-+ [new_code]
-[3 lines of post-context]
-
-- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
-
-@@ class BaseClass
-@@ def method():
-[3 lines of pre-context]
-- [old_code]
-+ [new_code]
-[3 lines of post-context]
-
-The full grammar definition is below:
-Patch := Begin { FileOp } End
-Begin := "*** Begin Patch" NEWLINE
-End := "*** End Patch" NEWLINE
-FileOp := AddFile | DeleteFile | UpdateFile
-AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
-DeleteFile := "*** Delete File: " path NEWLINE
-UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
-MoveTo := "*** Move to: " newPath NEWLINE
-Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
-HunkLine := (" " | "-" | "+") text NEWLINE
-
-A full patch can combine several operations:
-
-*** Begin Patch
-*** Add File: hello.txt
-+Hello world
-*** Update File: src/app.py
-*** Move to: src/main.py
-@@ def greet():
--print("Hi")
-+print("Hello, world!")
-*** Delete File: obsolete.txt
-*** End Patch
-
-It is important to remember:
-
-- You must include a header with your intended action (Add/Delete/Update)
-- You must prefix new lines with `+` even when creating a new file
-- File references can only be relative, NEVER ABSOLUTE.
-"#
- .to_string(),
- strict: false,
- parameters: JsonSchema::Object {
- properties,
- required: Some(vec!["input".to_string()]),
- additional_properties: Some(false),
- },
- })
-}
-
/// Returns JSON values that are compatible with Function Calling in the
/// Responses API:
/// https://platform.openai.com/docs/guides/function-calling?api-mode=responses
@@ -557,8 +498,15 @@ pub(crate) fn get_openai_tools(
tools.push(PLAN_TOOL.clone());
}
- if config.apply_patch_tool {
- tools.push(create_apply_patch_json_tool());
+ if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
+ match apply_patch_tool_type {
+ ApplyPatchToolType::Freeform => {
+ tools.push(create_apply_patch_freeform_tool());
+ }
+ ApplyPatchToolType::Function => {
+ tools.push(create_apply_patch_json_tool());
+ }
+ }
}
if let Some(mcp_tools) = mcp_tools {
@@ -589,6 +537,7 @@ mod tests {
.map(|tool| match tool {
OpenAiTool::Function(ResponsesApiTool { name, .. }) => name,
OpenAiTool::LocalShell {} => "local_shell",
+ OpenAiTool::Freeform(FreeformTool { name, .. }) => name,
})
.collect::<Vec<_>>();
@@ -614,7 +563,7 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
true,
- model_family.uses_apply_patch_tool,
+ false,
);
let tools = get_openai_tools(&config, Some(HashMap::new()));
@@ -629,7 +578,7 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
true,
- model_family.uses_apply_patch_tool,
+ false,
);
let tools = get_openai_tools(&config, Some(HashMap::new()));
@@ -644,7 +593,7 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
- model_family.uses_apply_patch_tool,
+ false,
);
let tools = get_openai_tools(
&config,
@@ -738,7 +687,7 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
- model_family.uses_apply_patch_tool,
+ false,
);
let tools = get_openai_tools(
@@ -794,7 +743,7 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
- model_family.uses_apply_patch_tool,
+ false,
);
let tools = get_openai_tools(
@@ -845,7 +794,7 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
- model_family.uses_apply_patch_tool,
+ false,
);
let tools = get_openai_tools(
@@ -899,7 +848,7 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
- model_family.uses_apply_patch_tool,
+ false,
);
let tools = get_openai_tools(
diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs
index 0ccd8e891b..f5a62ef8e6 100644
--- a/codex-rs/core/src/rollout.rs
+++ b/codex-rs/core/src/rollout.rs
@@ -132,6 +132,8 @@ impl RolloutRecorder {
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
+ | ResponseItem::CustomToolCall { .. }
+ | ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => filtered.push(item.clone()),
ResponseItem::Other => {
// These should never be serialized.
@@ -194,6 +196,8 @@ impl RolloutRecorder {
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
+ | ResponseItem::CustomToolCall { .. }
+ | ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => items.push(item),
ResponseItem::Other => {}
},
@@ -317,6 +321,8 @@ async fn rollout_writer(
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
+ | ResponseItem::CustomToolCall { .. }
+ | ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => {
writer.write_line(&item).await?;
}
diff --git a/codex-rs/core/src/tool_apply_patch.rs b/codex-rs/core/src/tool_apply_patch.rs
new file mode 100644
index 0000000000..3868cf0e70
--- /dev/null
+++ b/codex-rs/core/src/tool_apply_patch.rs
@@ -0,0 +1,145 @@
+use serde::Deserialize;
+use serde::Serialize;
+use std::collections::BTreeMap;
+
+use crate::openai_tools::FreeformTool;
+use crate::openai_tools::FreeformToolFormat;
+use crate::openai_tools::JsonSchema;
+use crate::openai_tools::OpenAiTool;
+use crate::openai_tools::ResponsesApiTool;
+
+#[derive(Serialize, Deserialize)]
+pub(crate) struct ApplyPatchToolArgs {
+ pub(crate) input: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
+#[serde(rename_all = "snake_case")]
+pub enum ApplyPatchToolType {
+ Freeform,
+ Function,
+}
+
+/// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models
+/// https://platform.openai.com/docs/guides/function-calling#custom-tools
+pub(crate) fn create_apply_patch_freeform_tool() -> OpenAiTool {
+ OpenAiTool::Freeform(FreeformTool {
+ name: "apply_patch".to_string(),
+ description: "Use the `apply_patch` tool to edit files".to_string(),
+ format: FreeformToolFormat {
+ r#type: "grammar".to_string(),
+ syntax: "lark".to_string(),
+ definition: r#"start: begin_patch hunk+ end_patch
+begin_patch: "*** Begin Patch" LF
+end_patch: "*** End Patch" LF?
+
+hunk: add_hunk | delete_hunk | update_hunk
+add_hunk: "*** Add File: " filename LF add_line+
+delete_hunk: "*** Delete File: " filename LF
+update_hunk: "*** Update File: " filename LF change_move? change?
+
+filename: /(.+)/
+add_line: "+" /(.+)/ LF -> line
+
+change_move: "*** Move to: " filename LF
+change: (change_context | change_line)+ eof_line?
+change_context: ("@@" | "@@ " /(.+)/) LF
+change_line: ("+" | "-" | " ") /(.+)/ LF
+eof_line: "*** End of File" LF
+
+%import common.LF
+"#
+ .to_string(),
+ },
+ })
+}
+
+/// Returns a json tool that can be used to edit files. Should only be used with gpt-oss models
+pub(crate) fn create_apply_patch_json_tool() -> OpenAiTool {
+ let mut properties = BTreeMap::new();
+ properties.insert(
+ "input".to_string(),
+ JsonSchema::String {
+ description: Some(r#"The entire contents of the apply_patch command"#.to_string()),
+ },
+ );
+
+ OpenAiTool::Function(ResponsesApiTool {
+ name: "apply_patch".to_string(),
+ description: r#"Use the `apply_patch` tool to edit files.
+Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
+
+*** Begin Patch
+[ one or more file sections ]
+*** End Patch
+
+Within that envelope, you get a sequence of file operations.
+You MUST include a header to specify the action you are taking.
+Each operation starts with one of three headers:
+
+*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
+*** Delete File: <path> - remove an existing file. Nothing follows.
+*** Update File: <path> - patch an existing file in place (optionally with a rename).
+
+May be immediately followed by *** Move to: <new path> if you want to rename the file.
+Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header).
+Within a hunk each line starts with:
+
+For instructions on [context_before] and [context_after]:
+- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change’s [context_after] lines in the second change’s [context_before] lines.
+- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:
+@@ class BaseClass
+[3 lines of pre-context]
+- [old_code]
++ [new_code]
+[3 lines of post-context]
+
+- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:
+
+@@ class BaseClass
+@@ def method():
+[3 lines of pre-context]
+- [old_code]
++ [new_code]
+[3 lines of post-context]
+
+The full grammar definition is below:
+Patch := Begin { FileOp } End
+Begin := "*** Begin Patch" NEWLINE
+End := "*** End Patch" NEWLINE
+FileOp := AddFile | DeleteFile | UpdateFile
+AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE }
+DeleteFile := "*** Delete File: " path NEWLINE
+UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
+MoveTo := "*** Move to: " newPath NEWLINE
+Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
+HunkLine := (" " | "-" | "+") text NEWLINE
+
+A full patch can combine several operations:
+
+*** Begin Patch
+*** Add File: hello.txt
++Hello world
+*** Update File: src/app.py
+*** Move to: src/main.py
+@@ def greet():
+-print("Hi")
++print("Hello, world!")
+*** Delete File: obsolete.txt
+*** End Patch
+
+It is important to remember:
+
+- You must include a header with your intended action (Add/Delete/Update)
+- You must prefix new lines with `+` even when creating a new file
+- File references can only be relative, NEVER ABSOLUTE.
+"#
+ .to_string(),
+ strict: false,
+ parameters: JsonSchema::Object {
+ properties,
+ required: Some(vec!["input".to_string()]),
+ additional_properties: Some(false),
+ },
+ })
+}
diff --git a/codex-rs/core/tests/prompt_caching.rs b/codex-rs/core/tests/prompt_caching.rs
index 958aff7b0b..17965e4915 100644
--- a/codex-rs/core/tests/prompt_caching.rs
+++ b/codex-rs/core/tests/prompt_caching.rs
@@ -1,6 +1,9 @@
+#![allow(clippy::unwrap_used)]
+
use codex_core::ConversationManager;
use codex_core::ModelProviderInfo;
use codex_core::built_in_model_providers;
+use codex_core::model_family::find_family_for_model;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
@@ -25,8 +28,20 @@ fn sse_completed(id: &str) -> String {
load_sse_fixture_with_id("tests/fixtures/completed_template.json", id)
}
+fn assert_tool_names(body: &serde_json::Value, expected_names: &[&str]) {
+ assert_eq!(
+ body["tools"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|t| t["name"].as_str().unwrap().to_string())
+ .collect::<Vec<_>>(),
+ expected_names
+ );
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-async fn default_system_instructions_contain_apply_patch() {
+async fn codex_mini_latest_tools() {
use pretty_assertions::assert_eq;
let server = MockServer::start().await;
@@ -58,6 +73,10 @@ async fn default_system_instructions_contain_apply_patch() {
let conversation_manager =
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
+ config.include_apply_patch_tool = false;
+ config.model = "codex-mini-latest".to_string();
+ config.model_family = find_family_for_model("codex-mini-latest").unwrap();
+
let codex = conversation_manager
.new_conversation(config)
.await
@@ -173,18 +192,6 @@ async fn prompt_tools_are_consistent_across_requests() {
// our internal implementation is responsible for keeping tools in sync
// with the OpenAI schema, so we just verify the tool presence here
let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch"];
- fn assert_tool_names(body: &serde_json::Value, expected_names: &[&str]) {
- assert_eq!(
- body["tools"]
- .as_array()
- .unwrap()
- .iter()
- .map(|t| t["name"].as_str().unwrap().to_string())
- .collect::<Vec<_>>(),
- expected_names
- );
- }
-
let body0 = requests[0].body_json::<serde_json::Value>().unwrap();
assert_eq!(
body0["instructions"],
diff --git a/codex-rs/exec/tests/apply_patch.rs b/codex-rs/exec/tests/apply_patch.rs
index ecce43d732..f05bb73298 100644
--- a/codex-rs/exec/tests/apply_patch.rs
+++ b/codex-rs/exec/tests/apply_patch.rs
@@ -123,6 +123,155 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> {
// Start a mock model server
let server = MockServer::start().await;
+ // First response: model calls apply_patch to create test.md
+ let first = ResponseTemplate::new(200)
+ .insert_header("content-type", "text/event-stream")
+ .set_body_raw(
+ load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_ADD, "call1"),
+ "text/event-stream",
+ );
+
+ Mock::given(method("POST"))
+ // .and(path("/v1/responses"))
+ .respond_with(first)
+ .up_to_n_times(1)
+ .mount(&server)
+ .await;
+
+ // Second response: model calls apply_patch to update test.md
+ let second = ResponseTemplate::new(200)
+ .insert_header("content-type", "text/event-stream")
+ .set_body_raw(
+ load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_UPDATE, "call2"),
+ "text/event-stream",
+ );
+
+ Mock::given(method("POST"))
+ .and(path("/v1/responses"))
+ .respond_with(second)
+ .up_to_n_times(1)
+ .mount(&server)
+ .await;
+
+ let final_completed = ResponseTemplate::new(200)
+ .insert_header("content-type", "text/event-stream")
+ .set_body_raw(
+ load_sse_fixture_with_id_from_str(SSE_TOOL_CALL_COMPLETED, "resp3"),
+ "text/event-stream",
+ );
+
+ Mock::given(method("POST"))
+ .and(path("/v1/responses"))
+ .respond_with(final_completed)
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let tmp_cwd = TempDir::new().unwrap();
+ Command::cargo_bin("codex-exec")
+ .context("should find binary for codex-exec")?
+ .current_dir(tmp_cwd.path())
+ .env("CODEX_HOME", tmp_cwd.path())
+ .env("OPENAI_API_KEY", "dummy")
+ .env("OPENAI_BASE_URL", format!("{}/v1", server.uri()))
+ .arg("--skip-git-repo-check")
+ .arg("-s")
+ .arg("workspace-write")
+ .arg("foo")
+ .assert()
+ .success();
+
+ // Verify final file contents
+ let final_path = tmp_cwd.path().join("test.md");
+ let contents = std::fs::read_to_string(&final_path)
+ .unwrap_or_else(|e| panic!("failed reading {}: {e}", final_path.display()));
+ assert_eq!(contents, "Final text\n");
+ Ok(())
+}
+
+#[cfg(not(target_os = "windows"))]
+#[tokio::test]
+async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> {
+ use core_test_support::load_sse_fixture_with_id_from_str;
+ use tempfile::TempDir;
+ use wiremock::Mock;
+ use wiremock::MockServer;
+ use wiremock::ResponseTemplate;
+ use wiremock::matchers::method;
+ use wiremock::matchers::path;
+
+ const SSE_TOOL_CALL_ADD: &str = r#"[
+ {
+ "type": "response.output_item.done",
+ "item": {
+ "type": "custom_tool_call",
+ "name": "apply_patch",
+ "input": "*** Begin Patch\n*** Add File: test.md\n+Hello world\n*** End Patch",
+ "call_id": "__ID__"
+ }
+ },
+ {
+ "type": "response.completed",
+ "response": {
+ "id": "__ID__",
+ "usage": {
+ "input_tokens": 0,
+ "input_tokens_details": null,
+ "output_tokens": 0,
+ "output_tokens_details": null,
+ "total_tokens": 0
+ },
+ "output": []
+ }
+ }
+]"#;
+
+ const SSE_TOOL_CALL_UPDATE: &str = r#"[
+ {
+ "type": "response.output_item.done",
+ "item": {
+ "type": "custom_tool_call",
+ "name": "apply_patch",
+ "input": "*** Begin Patch\n*** Update File: test.md\n@@\n-Hello world\n+Final text\n*** End Patch",
+ "call_id": "__ID__"
+ }
+ },
+ {
+ "type": "response.completed",
+ "response": {
+ "id": "__ID__",
+ "usage": {
+ "input_tokens": 0,
+ "input_tokens_details": null,
+ "output_tokens": 0,
+ "output_tokens_details": null,
+ "total_tokens": 0
+ },
+ "output": []
+ }
+ }
+]"#;
+
+ const SSE_TOOL_CALL_COMPLETED: &str = r#"[
+ {
+ "type": "response.completed",
+ "response": {
+ "id": "__ID__",
+ "usage": {
+ "input_tokens": 0,
+ "input_tokens_details": null,
+ "output_tokens": 0,
+ "output_tokens_details": null,
+ "total_tokens": 0
+ },
+ "output": []
+ }
+ }
+]"#;
+
+ // Start a mock model server
+ let server = MockServer::start().await;
+
// First response: model calls apply_patch to create test.md
let first = ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
Review Comments
codex-rs/core/src/models.rs
- Created: 2025-08-22 19:17:32 UTC | Link: https://github.com/openai/codex/pull/2576#discussion_r2294485811
@@ -77,6 +81,20 @@ pub enum ResponseItem {
call_id: String,
output: FunctionCallOutputPayload,
},
+ CustomToolCall {
Can you add a docstring and/or appropriate links to explain what a custom tool call is compared to a function tool call?
codex-rs/core/src/openai_tools.rs
- Created: 2025-08-22 19:17:56 UTC | Link: https://github.com/openai/codex/pull/2576#discussion_r2294486394
@@ -21,6 +24,20 @@ pub struct ResponsesApiTool {
pub(crate) parameters: JsonSchema,
}
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
Same here with respect to docstrings/links.
- Created: 2025-08-22 19:19:23 UTC | Link: https://github.com/openai/codex/pull/2576#discussion_r2294488635
@@ -614,7 +563,7 @@ mod tests {
AskForApproval::Never,
SandboxPolicy::ReadOnly,
true,
- model_family.uses_apply_patch_tool,
+ false,
In a follow up, perhaps
ToolsConfig::new()should take a struct instead of these args because it's not self-documenting this way.
codex-rs/exec/tests/apply_patch.rs
- Created: 2025-08-22 19:20:42 UTC | Link: https://github.com/openai/codex/pull/2576#discussion_r2294490641
@@ -188,3 +188,152 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> {
assert_eq!(contents, "Final text\n");
Ok(())
}
+
+#[cfg(not(target_os = "windows"))]
Why doesn't this test work on Windows?