use hashbrown::HashMap;
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::events::{ToolEmitter, ToolEventCtx};
use super::sandboxing::{
Approvable, ApprovalCtx, AskForApproval, BoxFuture, ExecApprovalRequirement, ExecToolCallOutput, ReviewDecision,
SandboxAttempt, Sandboxable, SandboxablePreference, ToolCtx, ToolError, ToolRuntime,
};
use super::tool_handler::{
ApprovalPolicy, FileChange, FreeformTool, FreeformToolFormat, ResponsesApiTool, ToolCallError, ToolHandler,
ToolInvocation, ToolKind, ToolOutput, ToolPayload, ToolSpec,
};
use super::tool_orchestrator::ToolOrchestrator;
use crate::config::constants::tools;
use crate::tools::editing::{Patch, PatchOperation};
pub struct ApplyPatchHandler;
#[derive(Debug, Deserialize, Serialize)]
pub struct ApplyPatchToolArgs {
pub input: Option<String>,
pub patch: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ApplyPatchRequest {
pub patch: String,
pub cwd: PathBuf,
pub timeout_ms: Option<u64>,
pub user_explicitly_approved: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize)]
pub struct ApplyPatchApprovalKey {
patch: String,
cwd: PathBuf,
}
#[derive(Default)]
pub struct ApplyPatchRuntime;
impl ApplyPatchRuntime {
pub fn new() -> Self {
Self
}
}
impl Sandboxable for ApplyPatchRuntime {
fn sandbox_preference(&self) -> SandboxablePreference {
SandboxablePreference::Auto
}
fn escalate_on_failure(&self) -> bool {
true
}
}
impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
type ApprovalKey = ApplyPatchApprovalKey;
fn approval_key(&self, req: &ApplyPatchRequest) -> Self::ApprovalKey {
ApplyPatchApprovalKey { patch: req.patch.clone(), cwd: req.cwd.clone() }
}
fn exec_approval_requirement(&self, _req: &ApplyPatchRequest) -> Option<ExecApprovalRequirement> {
Some(ExecApprovalRequirement::Skip {
bypass_sandbox: false,
proposed_execpolicy_amendment: None,
})
}
fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
match policy {
AskForApproval::Never => false,
AskForApproval::Reject(reject_config) => !reject_config.rejects_sandbox_approval(),
AskForApproval::OnFailure => true,
AskForApproval::OnRequest => true,
AskForApproval::UnlessTrusted => true,
}
}
fn start_approval_async<'a>(
&'a mut self,
_req: &'a ApplyPatchRequest,
_ctx: ApprovalCtx<'a>,
) -> BoxFuture<'a, ReviewDecision> {
Box::pin(async { ReviewDecision::Approved })
}
}
#[async_trait]
impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
async fn run(
&mut self,
req: &ApplyPatchRequest,
_attempt: &SandboxAttempt<'_>,
_ctx: &ToolCtx,
) -> Result<ExecToolCallOutput, ToolError> {
let patch = Patch::parse(&req.patch).map_err(|e| ToolError::Rejected(format!("Failed to parse patch: {e}")))?;
if patch.is_empty() {
return Ok(ExecToolCallOutput {
stdout: "Patch is empty, no changes applied".to_string(),
stderr: String::new(),
exit_code: 0,
});
}
match patch.apply(&req.cwd).await {
Ok(results) => {
let output = results.join("\n");
Ok(ExecToolCallOutput {
stdout: output,
stderr: String::new(),
exit_code: 0,
})
}
Err(e) => Ok(ExecToolCallOutput {
stdout: String::new(),
stderr: format!("Patch application failed: {e}"),
exit_code: 1,
}),
}
}
}
#[async_trait]
impl ToolHandler for ApplyPatchHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. } | ToolPayload::Custom { .. })
}
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
true }
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
tool_name,
payload,
} = invocation;
let patch_input = match payload {
ToolPayload::Function { arguments } => {
let args: Value = serde_json::from_str(&arguments)
.map_err(|e| ToolCallError::respond(format!("Failed to parse function arguments: {e}")))?;
crate::tools::apply_patch::decode_apply_patch_input(&args)
.map_err(|e| ToolCallError::respond(format!("Failed to decode patch input: {e}")))?
.map(|input| input.text)
.ok_or_else(|| {
ToolCallError::respond(format!(
"Missing patch input {}",
crate::tools::error_helpers::PATCH_PARAMETER_HINT
))
})?
}
ToolPayload::Custom { input } => input,
_ => {
return Err(ToolCallError::respond("apply_patch handler received unsupported payload"));
}
};
let patch =
Patch::parse(&patch_input).map_err(|e| ToolCallError::respond(format!("Failed to parse patch: {e}")))?;
let changes = convert_patch_to_changes(&patch, &turn.cwd);
let emitter = ToolEmitter::apply_patch(changes.clone(), true);
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, tracker.as_ref());
emitter.begin(event_ctx).await;
let req = ApplyPatchRequest {
patch: patch_input.clone(),
cwd: turn.cwd.clone(),
timeout_ms: None,
user_explicitly_approved: true,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = ApplyPatchRuntime::new();
let tool_ctx = ToolCtx {
session: session.clone(),
turn: turn.clone(),
call_id: call_id.clone(),
tool_name: tool_name.clone(),
};
let result = orchestrator
.run(&mut runtime, &req, &tool_ctx, turn.as_ref(), map_approval_policy(turn.approval_policy.value()))
.await;
let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, tracker.as_ref());
let content = emitter.finish(event_ctx, result).await?;
Ok(ToolOutput::Function { content, content_items: None, success: Some(true) })
}
}
fn convert_patch_to_changes(patch: &Patch, cwd: &Path) -> HashMap<PathBuf, FileChange> {
let mut changes = HashMap::new();
for op in patch.operations() {
match op {
PatchOperation::AddFile { path, content } => {
let full_path = cwd.join(path);
changes.insert(full_path, FileChange::Add { content: content.clone() });
}
PatchOperation::DeleteFile { path } => {
let full_path = cwd.join(path);
changes.insert(full_path, FileChange::Delete);
}
PatchOperation::UpdateFile { path, new_path, chunks: _ } => {
let full_path = cwd.join(path);
if let Some(new_path) = new_path {
changes.insert(full_path, FileChange::Rename { new_path: cwd.join(new_path), content: None });
} else {
changes.insert(
full_path,
FileChange::Update {
old_content: String::new(),
new_content: String::new(),
},
);
}
}
}
}
changes
}
fn map_approval_policy(policy: ApprovalPolicy) -> AskForApproval {
match policy {
ApprovalPolicy::Never => AskForApproval::Never,
ApprovalPolicy::OnMutation => AskForApproval::OnRequest,
ApprovalPolicy::Always => AskForApproval::UnlessTrusted,
}
}
pub fn create_apply_patch_freeform_tool() -> ToolSpec {
ToolSpec::Freeform(FreeformTool {
name: tools::APPLY_PATCH.to_string(),
description: APPLY_PATCH_DESCRIPTION.to_string(),
format: FreeformToolFormat {
lark_grammar: Some(APPLY_PATCH_LARK_GRAMMAR.to_string()),
examples: vec![
APPLY_PATCH_ADD_EXAMPLE.to_string(),
APPLY_PATCH_UPDATE_EXAMPLE.to_string(),
],
},
})
}
pub fn create_apply_patch_json_tool() -> ToolSpec {
use crate::tools::apply_patch::{
APPLY_PATCH_ALIAS_DESCRIPTION, DEFAULT_APPLY_PATCH_INPUT_DESCRIPTION, with_semantic_anchor_guidance,
};
ToolSpec::Function(ResponsesApiTool {
name: tools::APPLY_PATCH.to_string(),
description: format!("{APPLY_PATCH_DESCRIPTION}\n\n{APPLY_PATCH_GRAMMAR_HELP}"),
strict: false,
parameters: json!({
"type": "object",
"properties": {
"input": {
"type": "string",
"description": with_semantic_anchor_guidance(DEFAULT_APPLY_PATCH_INPUT_DESCRIPTION)
},
"patch": {
"type": "string",
"description": with_semantic_anchor_guidance(APPLY_PATCH_ALIAS_DESCRIPTION)
}
},
"required": ["input"],
"additionalProperties": false
}),
})
}
pub(crate) fn parse_apply_patch_command(command: &[String]) -> (bool, Option<String>) {
const APPLY_PATCH_COMMANDS: &[&str] = &["apply_patch", "applypatch"];
match command {
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => (true, Some(body.clone())),
_ => (false, None),
}
}
const APPLY_PATCH_DESCRIPTION: &str = 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)."#;
const APPLY_PATCH_GRAMMAR_HELP: &str = r#"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:
- ` ` (space) for context lines
- `-` for lines to remove
- `+` for lines to add
Important rules:
- 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
- Prefer small hunks with stable semantic @@ anchors like function, class, method, or impl names"#;
const APPLY_PATCH_LARK_GRAMMAR: &str = r#"
patch := "*** Begin Patch" NEWLINE { operation } "*** End Patch"
operation := AddFile | DeleteFile | UpdateFile
AddFile := "*** Add File: " path NEWLINE { "+" text 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
"#;
const APPLY_PATCH_ADD_EXAMPLE: &str = r#"*** Begin Patch
*** Add File: hello.txt
+Hello world
*** End Patch"#;
const APPLY_PATCH_UPDATE_EXAMPLE: &str = r#"*** Begin Patch
*** Update File: src/app.py
*** Move to: src/main.py
@@ def greet():
-print("Hi")
+print("Hello, world!")
*** End Patch"#;
#[cfg(test)]
mod tests {
use super::*;
use crate::exec_policy::RejectConfig;
#[test]
fn test_parse_apply_patch_command_direct() {
let cmd = vec!["apply_patch".to_string(), "*** Begin Patch\n*** End Patch".to_string()];
let (is_patch, content) = parse_apply_patch_command(&cmd);
assert!(is_patch);
assert!(content.is_some());
}
#[test]
fn test_parse_apply_patch_command_not_patch() {
let cmd = vec!["ls".to_string(), "-la".to_string()];
let (is_patch, content) = parse_apply_patch_command(&cmd);
assert!(!is_patch);
assert!(content.is_none());
}
#[test]
fn test_create_freeform_tool() {
let tool = create_apply_patch_freeform_tool();
assert_eq!(tool.name(), "apply_patch");
}
#[test]
fn test_create_json_tool() {
let tool = create_apply_patch_json_tool();
assert_eq!(tool.name(), "apply_patch");
}
#[test]
fn test_apply_patch_json_args_support_patch_alias() {
let parsed: ApplyPatchToolArgs =
serde_json::from_str(r#"{"patch":"*** Begin Patch\n*** End Patch\n"}"#).expect("json args should parse");
assert_eq!(parsed.input, None);
assert_eq!(parsed.patch.as_deref(), Some("*** Begin Patch\n*** End Patch\n"));
}
#[test]
fn wants_no_sandbox_approval_reject_respects_sandbox_flag() {
let runtime = ApplyPatchRuntime::new();
assert!(runtime.wants_no_sandbox_approval(AskForApproval::OnRequest));
assert!(!runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
sandbox_approval: true,
rules: false,
request_permissions: false,
mcp_elicitations: false,
})));
assert!(runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
sandbox_approval: false,
rules: false,
request_permissions: false,
mcp_elicitations: false,
})));
}
}