Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 236c4f7

Browse files
[apply_patch] freeform apply_patch tool (openai#2576)
## Summary GPT-5 introduced the concept of [custom tools](https://platform.openai.com/docs/guides/function-calling#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 - [x] Tested locally with various models - [x] Unit tests pass
1 parent dc42ec0 commit 236c4f7

File tree

14 files changed

+534
-138
lines changed

14 files changed

+534
-138
lines changed

codex-rs/core/src/chat_completions.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,33 @@ pub(crate) async fn stream_chat_completions(
102102
"content": output.content,
103103
}));
104104
}
105+
ResponseItem::CustomToolCall {
106+
id,
107+
call_id: _,
108+
name,
109+
input,
110+
status: _,
111+
} => {
112+
messages.push(json!({
113+
"role": "assistant",
114+
"content": null,
115+
"tool_calls": [{
116+
"id": id,
117+
"type": "custom",
118+
"custom": {
119+
"name": name,
120+
"input": input,
121+
}
122+
}]
123+
}));
124+
}
125+
ResponseItem::CustomToolCallOutput { call_id, output } => {
126+
messages.push(json!({
127+
"role": "tool",
128+
"tool_call_id": call_id,
129+
"content": output,
130+
}));
131+
}
105132
ResponseItem::Reasoning { .. } | ResponseItem::Other => {
106133
// Omit these items from the conversation history.
107134
continue;

codex-rs/core/src/client.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,8 @@ async fn process_sse<S>(
575575
}
576576
"response.content_part.done"
577577
| "response.function_call_arguments.delta"
578+
| "response.custom_tool_call_input.delta"
579+
| "response.custom_tool_call_input.done" // also emitted as response.output_item.done
578580
| "response.in_progress"
579581
| "response.output_item.added"
580582
| "response.output_text.done" => {

codex-rs/core/src/client_common.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,14 @@ impl Prompt {
4949
.unwrap_or(BASE_INSTRUCTIONS);
5050
let mut sections: Vec<&str> = vec![base];
5151

52-
// When there are no custom instructions, add apply_patch if either:
53-
// - the model needs special instructions, or
52+
// When there are no custom instructions, add apply_patch_tool_instructions if either:
53+
// - the model needs special instructions (4.1), or
5454
// - there is no apply_patch tool present
55-
let is_apply_patch_tool_present = self
56-
.tools
57-
.iter()
58-
.any(|t| matches!(t, OpenAiTool::Function(f) if f.name == "apply_patch"));
55+
let is_apply_patch_tool_present = self.tools.iter().any(|tool| match tool {
56+
OpenAiTool::Function(f) => f.name == "apply_patch",
57+
OpenAiTool::Freeform(f) => f.name == "apply_patch",
58+
_ => false,
59+
});
5960
if self.base_instructions_override.is_none()
6061
&& (model.needs_special_apply_patch_instructions || !is_apply_patch_tool_present)
6162
{

codex-rs/core/src/codex.rs

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,18 @@ async fn run_task(
14061406
},
14071407
);
14081408
}
1409+
(
1410+
ResponseItem::CustomToolCall { .. },
1411+
Some(ResponseInputItem::CustomToolCallOutput { call_id, output }),
1412+
) => {
1413+
items_to_record_in_conversation_history.push(item);
1414+
items_to_record_in_conversation_history.push(
1415+
ResponseItem::CustomToolCallOutput {
1416+
call_id: call_id.clone(),
1417+
output: output.clone(),
1418+
},
1419+
);
1420+
}
14091421
(
14101422
ResponseItem::FunctionCall { .. },
14111423
Some(ResponseInputItem::McpToolCallOutput { call_id, result }),
@@ -1586,6 +1598,7 @@ async fn try_run_turn(
15861598
call_id: Some(call_id),
15871599
..
15881600
} => Some(call_id),
1601+
ResponseItem::CustomToolCallOutput { call_id, .. } => Some(call_id),
15891602
_ => None,
15901603
})
15911604
.collect::<Vec<_>>();
@@ -1603,6 +1616,7 @@ async fn try_run_turn(
16031616
call_id: Some(call_id),
16041617
..
16051618
} => Some(call_id),
1619+
ResponseItem::CustomToolCall { call_id, .. } => Some(call_id),
16061620
_ => None,
16071621
})
16081622
.filter_map(|call_id| {
@@ -1612,12 +1626,9 @@ async fn try_run_turn(
16121626
Some(call_id.clone())
16131627
}
16141628
})
1615-
.map(|call_id| ResponseItem::FunctionCallOutput {
1629+
.map(|call_id| ResponseItem::CustomToolCallOutput {
16161630
call_id: call_id.clone(),
1617-
output: FunctionCallOutputPayload {
1618-
content: "aborted".to_string(),
1619-
success: Some(false),
1620-
},
1631+
output: "aborted".to_string(),
16211632
})
16221633
.collect::<Vec<_>>()
16231634
};
@@ -1882,7 +1893,7 @@ async fn handle_response_item(
18821893
call_id,
18831894
..
18841895
} => {
1885-
info!("FunctionCall: {arguments}");
1896+
info!("FunctionCall: {name}({arguments})");
18861897
Some(
18871898
handle_function_call(
18881899
sess,
@@ -1939,10 +1950,32 @@ async fn handle_response_item(
19391950
.await,
19401951
)
19411952
}
1953+
ResponseItem::CustomToolCall {
1954+
id: _,
1955+
call_id,
1956+
name,
1957+
input,
1958+
status: _,
1959+
} => Some(
1960+
handle_custom_tool_call(
1961+
sess,
1962+
turn_context,
1963+
turn_diff_tracker,
1964+
sub_id.to_string(),
1965+
name,
1966+
input,
1967+
call_id,
1968+
)
1969+
.await,
1970+
),
19421971
ResponseItem::FunctionCallOutput { .. } => {
19431972
debug!("unexpected FunctionCallOutput from stream");
19441973
None
19451974
}
1975+
ResponseItem::CustomToolCallOutput { .. } => {
1976+
debug!("unexpected CustomToolCallOutput from stream");
1977+
None
1978+
}
19461979
ResponseItem::Other => None,
19471980
};
19481981
Ok(output)
@@ -2032,6 +2065,58 @@ async fn handle_function_call(
20322065
}
20332066
}
20342067

2068+
async fn handle_custom_tool_call(
2069+
sess: &Session,
2070+
turn_context: &TurnContext,
2071+
turn_diff_tracker: &mut TurnDiffTracker,
2072+
sub_id: String,
2073+
name: String,
2074+
input: String,
2075+
call_id: String,
2076+
) -> ResponseInputItem {
2077+
info!("CustomToolCall: {name} {input}");
2078+
match name.as_str() {
2079+
"apply_patch" => {
2080+
let exec_params = ExecParams {
2081+
command: vec!["apply_patch".to_string(), input.clone()],
2082+
cwd: turn_context.cwd.clone(),
2083+
timeout_ms: None,
2084+
env: HashMap::new(),
2085+
with_escalated_permissions: None,
2086+
justification: None,
2087+
};
2088+
let resp = handle_container_exec_with_params(
2089+
exec_params,
2090+
sess,
2091+
turn_context,
2092+
turn_diff_tracker,
2093+
sub_id,
2094+
call_id,
2095+
)
2096+
.await;
2097+
2098+
// Convert function-call style output into a custom tool call output
2099+
match resp {
2100+
ResponseInputItem::FunctionCallOutput { call_id, output } => {
2101+
ResponseInputItem::CustomToolCallOutput {
2102+
call_id,
2103+
output: output.content,
2104+
}
2105+
}
2106+
// Pass through if already a custom tool output or other variant
2107+
other => other,
2108+
}
2109+
}
2110+
_ => {
2111+
debug!("unexpected CustomToolCall from stream");
2112+
ResponseInputItem::CustomToolCallOutput {
2113+
call_id,
2114+
output: format!("unsupported custom tool call: {name}"),
2115+
}
2116+
}
2117+
}
2118+
}
2119+
20352120
fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams {
20362121
ExecParams {
20372122
command: params.command,

codex-rs/core/src/config.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ impl Config {
646646
needs_special_apply_patch_instructions: false,
647647
supports_reasoning_summaries,
648648
uses_local_shell_tool: false,
649-
uses_apply_patch_tool: false,
649+
apply_patch_tool_type: None,
650650
}
651651
});
652652

@@ -673,9 +673,6 @@ impl Config {
673673
Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?;
674674
let base_instructions = base_instructions.or(file_base_instructions);
675675

676-
let include_apply_patch_tool_val =
677-
include_apply_patch_tool.unwrap_or(model_family.uses_apply_patch_tool);
678-
679676
let responses_originator_header: String = cfg
680677
.responses_originator_header_internal_override
681678
.unwrap_or(DEFAULT_RESPONSES_ORIGINATOR_HEADER.to_owned());
@@ -732,7 +729,7 @@ impl Config {
732729

733730
experimental_resume,
734731
include_plan_tool: include_plan_tool.unwrap_or(false),
735-
include_apply_patch_tool: include_apply_patch_tool_val,
732+
include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false),
736733
responses_originator_header,
737734
preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT),
738735
};

codex-rs/core/src/conversation_history.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ fn is_api_message(message: &ResponseItem) -> bool {
110110
ResponseItem::Message { role, .. } => role.as_str() != "system",
111111
ResponseItem::FunctionCallOutput { .. }
112112
| ResponseItem::FunctionCall { .. }
113+
| ResponseItem::CustomToolCall { .. }
114+
| ResponseItem::CustomToolCallOutput { .. }
113115
| ResponseItem::LocalShellCall { .. }
114116
| ResponseItem::Reasoning { .. } => true,
115117
ResponseItem::Other => false,

codex-rs/core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ pub mod seatbelt;
5050
pub mod shell;
5151
pub mod spawn;
5252
pub mod terminal;
53+
mod tool_apply_patch;
5354
pub mod turn_diff_tracker;
5455
pub mod user_agent;
5556
mod user_notification;

codex-rs/core/src/model_family.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use crate::tool_apply_patch::ApplyPatchToolType;
2+
13
/// A model family is a group of models that share certain characteristics.
24
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35
pub struct ModelFamily {
@@ -24,9 +26,9 @@ pub struct ModelFamily {
2426
// See https://platform.openai.com/docs/guides/tools-local-shell
2527
pub uses_local_shell_tool: bool,
2628

27-
/// True if the model performs better when `apply_patch` is provided as
28-
/// a tool call instead of just a bash command.
29-
pub uses_apply_patch_tool: bool,
29+
/// Present if the model performs better when `apply_patch` is provided as
30+
/// a tool call instead of just a bash command
31+
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
3032
}
3133

3234
macro_rules! model_family {
@@ -40,7 +42,7 @@ macro_rules! model_family {
4042
needs_special_apply_patch_instructions: false,
4143
supports_reasoning_summaries: false,
4244
uses_local_shell_tool: false,
43-
uses_apply_patch_tool: false,
45+
apply_patch_tool_type: None,
4446
};
4547
// apply overrides
4648
$(
@@ -60,7 +62,7 @@ macro_rules! simple_model_family {
6062
needs_special_apply_patch_instructions: false,
6163
supports_reasoning_summaries: false,
6264
uses_local_shell_tool: false,
63-
uses_apply_patch_tool: false,
65+
apply_patch_tool_type: None,
6466
})
6567
}};
6668
}
@@ -88,14 +90,15 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
8890
model_family!(
8991
slug, slug,
9092
supports_reasoning_summaries: true,
93+
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
9194
)
9295
} else if slug.starts_with("gpt-4.1") {
9396
model_family!(
9497
slug, "gpt-4.1",
9598
needs_special_apply_patch_instructions: true,
9699
)
97100
} else if slug.starts_with("gpt-oss") {
98-
model_family!(slug, "gpt-oss", uses_apply_patch_tool: true)
101+
model_family!(slug, "gpt-oss", apply_patch_tool_type: Some(ApplyPatchToolType::Function))
99102
} else if slug.starts_with("gpt-4o") {
100103
simple_model_family!(slug, "gpt-4o")
101104
} else if slug.starts_with("gpt-3.5") {
@@ -104,6 +107,7 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
104107
model_family!(
105108
slug, "gpt-5",
106109
supports_reasoning_summaries: true,
110+
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
107111
)
108112
} else {
109113
None

codex-rs/core/src/models.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ pub enum ResponseInputItem {
2424
call_id: String,
2525
result: Result<CallToolResult, String>,
2626
},
27+
CustomToolCallOutput {
28+
call_id: String,
29+
output: String,
30+
},
2731
}
2832

2933
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -77,6 +81,20 @@ pub enum ResponseItem {
7781
call_id: String,
7882
output: FunctionCallOutputPayload,
7983
},
84+
CustomToolCall {
85+
#[serde(default, skip_serializing_if = "Option::is_none")]
86+
id: Option<String>,
87+
#[serde(default, skip_serializing_if = "Option::is_none")]
88+
status: Option<String>,
89+
90+
call_id: String,
91+
name: String,
92+
input: String,
93+
},
94+
CustomToolCallOutput {
95+
call_id: String,
96+
output: String,
97+
},
8098
#[serde(other)]
8199
Other,
82100
}
@@ -114,6 +132,9 @@ impl From<ResponseInputItem> for ResponseItem {
114132
),
115133
},
116134
},
135+
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
136+
Self::CustomToolCallOutput { call_id, output }
137+
}
117138
}
118139
}
119140
}

0 commit comments

Comments
 (0)