Skip to main content

vtcode_core/tools/handlers/
apply_patch_handler.rs

1//! Apply patch handler (from Codex)
2//!
3//! Implements the apply_patch tool using the Codex-style handler pattern.
4//! Supports both freeform and JSON function call formats.
5
6use hashbrown::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13
14use super::events::{ToolEmitter, ToolEventCtx};
15use super::sandboxing::{
16    Approvable, ApprovalCtx, AskForApproval, BoxFuture, ExecApprovalRequirement,
17    ExecToolCallOutput, ReviewDecision, SandboxAttempt, Sandboxable, SandboxablePreference,
18    ToolCtx, ToolError, ToolRuntime,
19};
20use super::tool_handler::{
21    ApprovalPolicy, FileChange, FreeformTool, FreeformToolFormat, ResponsesApiTool, ToolCallError,
22    ToolHandler, ToolInvocation, ToolKind, ToolOutput, ToolPayload, ToolSpec,
23};
24use super::tool_orchestrator::ToolOrchestrator;
25use crate::config::constants::tools;
26use crate::tools::editing::{Patch, PatchOperation};
27
28/// Context for intercepting apply_patch commands
29pub struct InterceptApplyPatchContext<'a> {
30    pub cwd: &'a Path,
31    pub timeout_ms: Option<u64>,
32    pub session: Arc<dyn super::tool_handler::ToolSession>,
33    pub turn: Arc<super::tool_handler::TurnContext>,
34    pub tracker: Option<&'a Arc<tokio::sync::Mutex<super::tool_handler::DiffTracker>>>,
35    pub call_id: &'a str,
36    pub tool_name: &'a str,
37}
38
39/// Apply patch handler
40pub struct ApplyPatchHandler;
41
42/// Arguments for apply_patch function call
43#[derive(Debug, Deserialize, Serialize)]
44pub struct ApplyPatchToolArgs {
45    pub input: Option<String>,
46    pub patch: Option<String>,
47}
48
49/// Request for apply_patch runtime
50#[derive(Clone, Debug)]
51pub struct ApplyPatchRequest {
52    pub patch: String,
53    pub cwd: PathBuf,
54    pub timeout_ms: Option<u64>,
55    pub user_explicitly_approved: bool,
56}
57
58/// Approval key for caching
59#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize)]
60pub struct ApplyPatchApprovalKey {
61    patch: String,
62    cwd: PathBuf,
63}
64
65/// Apply patch runtime for orchestrated execution
66#[derive(Default)]
67pub struct ApplyPatchRuntime;
68
69impl ApplyPatchRuntime {
70    pub fn new() -> Self {
71        Self
72    }
73}
74
75impl Sandboxable for ApplyPatchRuntime {
76    fn sandbox_preference(&self) -> SandboxablePreference {
77        // Patches modify files, so we prefer auto sandbox
78        SandboxablePreference::Auto
79    }
80
81    fn escalate_on_failure(&self) -> bool {
82        // Allow escalation if sandbox fails
83        true
84    }
85}
86
87impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
88    type ApprovalKey = ApplyPatchApprovalKey;
89
90    fn approval_key(&self, req: &ApplyPatchRequest) -> Self::ApprovalKey {
91        ApplyPatchApprovalKey {
92            patch: req.patch.clone(),
93            cwd: req.cwd.clone(),
94        }
95    }
96
97    fn exec_approval_requirement(
98        &self,
99        _req: &ApplyPatchRequest,
100    ) -> Option<ExecApprovalRequirement> {
101        // Preserve existing behavior from the legacy orchestrator path:
102        // apply_patch is executed without additional approval prompts here.
103        Some(ExecApprovalRequirement::Skip {
104            bypass_sandbox: false,
105            proposed_execpolicy_amendment: None,
106        })
107    }
108
109    fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool {
110        match policy {
111            AskForApproval::Never => false,
112            AskForApproval::Reject(reject_config) => !reject_config.rejects_sandbox_approval(),
113            AskForApproval::OnFailure => true,
114            AskForApproval::OnRequest => true,
115            AskForApproval::UnlessTrusted => true,
116        }
117    }
118
119    fn start_approval_async<'a>(
120        &'a mut self,
121        _req: &'a ApplyPatchRequest,
122        _ctx: ApprovalCtx<'a>,
123    ) -> BoxFuture<'a, ReviewDecision> {
124        Box::pin(async { ReviewDecision::Approved })
125    }
126}
127
128#[async_trait]
129impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
130    async fn run(
131        &mut self,
132        req: &ApplyPatchRequest,
133        _attempt: &SandboxAttempt<'_>,
134        _ctx: &ToolCtx,
135    ) -> Result<ExecToolCallOutput, ToolError> {
136        // Parse and apply the patch
137        let patch = Patch::parse(&req.patch)
138            .map_err(|e| ToolError::Rejected(format!("Failed to parse patch: {}", e)))?;
139
140        if patch.is_empty() {
141            return Ok(ExecToolCallOutput {
142                stdout: "Patch is empty, no changes applied".to_string(),
143                stderr: String::new(),
144                exit_code: 0,
145            });
146        }
147
148        // Apply the patch
149        match patch.apply(&req.cwd).await {
150            Ok(results) => {
151                let output = results.join("\n");
152                Ok(ExecToolCallOutput {
153                    stdout: output,
154                    stderr: String::new(),
155                    exit_code: 0,
156                })
157            }
158            Err(e) => Ok(ExecToolCallOutput {
159                stdout: String::new(),
160                stderr: format!("Patch application failed: {}", e),
161                exit_code: 1,
162            }),
163        }
164    }
165}
166
167#[async_trait]
168impl ToolHandler for ApplyPatchHandler {
169    fn kind(&self) -> ToolKind {
170        ToolKind::Function
171    }
172
173    fn matches_kind(&self, payload: &ToolPayload) -> bool {
174        matches!(
175            payload,
176            ToolPayload::Function { .. } | ToolPayload::Custom { .. }
177        )
178    }
179
180    async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
181        true // apply_patch always mutates
182    }
183
184    async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, ToolCallError> {
185        let ToolInvocation {
186            session,
187            turn,
188            tracker,
189            call_id,
190            tool_name,
191            payload,
192        } = invocation;
193
194        // Extract patch input from payload
195        let patch_input = match payload {
196            ToolPayload::Function { arguments } => {
197                let args: Value = serde_json::from_str(&arguments).map_err(|e| {
198                    ToolCallError::respond(format!("Failed to parse function arguments: {}", e))
199                })?;
200                crate::tools::apply_patch::decode_apply_patch_input(&args)
201                    .map_err(|e| {
202                        ToolCallError::respond(format!("Failed to decode patch input: {e}"))
203                    })?
204                    .map(|input| input.text)
205                    .ok_or_else(|| ToolCallError::respond("Missing patch input"))?
206            }
207            ToolPayload::Custom { input } => input,
208            _ => {
209                return Err(ToolCallError::respond(
210                    "apply_patch handler received unsupported payload",
211                ));
212            }
213        };
214
215        // Parse the patch to get file changes
216        let patch = Patch::parse(&patch_input)
217            .map_err(|e| ToolCallError::respond(format!("Failed to parse patch: {}", e)))?;
218
219        // Convert patch operations to file changes for tracking
220        let changes = convert_patch_to_changes(&patch, &turn.cwd);
221
222        // Create emitter for event tracking
223        let emitter = ToolEmitter::apply_patch(changes.clone(), true);
224        let event_ctx =
225            ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, tracker.as_ref());
226        emitter.begin(event_ctx).await;
227
228        // Create request
229        let req = ApplyPatchRequest {
230            patch: patch_input.clone(),
231            cwd: turn.cwd.clone(),
232            timeout_ms: None,
233            user_explicitly_approved: true,
234        };
235
236        // Execute using orchestrator
237        let mut orchestrator = ToolOrchestrator::new();
238        let mut runtime = ApplyPatchRuntime::new();
239        let tool_ctx = ToolCtx {
240            session: session.clone(),
241            turn: turn.clone(),
242            call_id: call_id.clone(),
243            tool_name: tool_name.clone(),
244        };
245
246        let result = orchestrator
247            .run(
248                &mut runtime,
249                &req,
250                &tool_ctx,
251                turn.as_ref(),
252                map_approval_policy(turn.approval_policy.value()),
253            )
254            .await;
255
256        // Emit completion event and format output
257        let event_ctx =
258            ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, tracker.as_ref());
259        let content = emitter.finish(event_ctx, result).await?;
260
261        Ok(ToolOutput::Function {
262            content,
263            content_items: None,
264            success: Some(true),
265        })
266    }
267}
268
269/// Convert patch operations to file changes for tracking
270fn convert_patch_to_changes(patch: &Patch, cwd: &Path) -> HashMap<PathBuf, FileChange> {
271    let mut changes = HashMap::new();
272
273    for op in patch.operations() {
274        match op {
275            PatchOperation::AddFile { path, content } => {
276                let full_path = cwd.join(path);
277                changes.insert(
278                    full_path,
279                    FileChange::Add {
280                        content: content.clone(),
281                    },
282                );
283            }
284            PatchOperation::DeleteFile { path } => {
285                let full_path = cwd.join(path);
286                changes.insert(full_path, FileChange::Delete);
287            }
288            PatchOperation::UpdateFile {
289                path,
290                new_path,
291                chunks: _,
292            } => {
293                let full_path = cwd.join(path);
294                if let Some(new_path) = new_path {
295                    changes.insert(
296                        full_path,
297                        FileChange::Rename {
298                            new_path: cwd.join(new_path),
299                            content: None,
300                        },
301                    );
302                } else {
303                    // For updates, we track as update with empty placeholders
304                    // The actual content will be computed during application
305                    changes.insert(
306                        full_path,
307                        FileChange::Update {
308                            old_content: String::new(),
309                            new_content: String::new(),
310                        },
311                    );
312                }
313            }
314        }
315    }
316
317    changes
318}
319
320/// Create freeform apply_patch tool spec (for GPT-5 style models)
321pub fn create_apply_patch_freeform_tool() -> ToolSpec {
322    ToolSpec::Freeform(FreeformTool {
323        name: tools::APPLY_PATCH.to_string(),
324        description: APPLY_PATCH_DESCRIPTION.to_string(),
325        format: FreeformToolFormat {
326            lark_grammar: Some(APPLY_PATCH_LARK_GRAMMAR.to_string()),
327            examples: vec![
328                APPLY_PATCH_ADD_EXAMPLE.to_string(),
329                APPLY_PATCH_UPDATE_EXAMPLE.to_string(),
330            ],
331        },
332    })
333}
334
335/// Create JSON function apply_patch tool spec (for standard function calling)
336pub fn create_apply_patch_json_tool() -> ToolSpec {
337    ToolSpec::Function(ResponsesApiTool {
338        name: tools::APPLY_PATCH.to_string(),
339        description: format!(
340            "{}\n\n{}",
341            APPLY_PATCH_DESCRIPTION, APPLY_PATCH_GRAMMAR_HELP
342        ),
343        strict: false,
344        parameters: json!({
345            "type": "object",
346            "properties": {
347                "input": {
348                    "type": "string",
349                    "description": "The entire contents of the apply_patch command"
350                },
351                "patch": {
352                    "type": "string",
353                    "description": crate::tools::apply_patch::APPLY_PATCH_ALIAS_DESCRIPTION
354                }
355            },
356            "required": ["input"],
357            "additionalProperties": false
358        }),
359    })
360}
361
362/// Intercept apply_patch from shell command
363///
364/// This checks if a shell command is actually an apply_patch invocation
365/// and handles it through the apply_patch handler instead.
366#[expect(clippy::too_many_arguments)]
367pub async fn intercept_apply_patch(
368    command: &[String],
369    ctx: InterceptApplyPatchContext<'_>,
370) -> Result<Option<ToolOutput>, ToolCallError> {
371    // Check if this is an apply_patch command
372    let (is_apply_patch, patch_content) = parse_apply_patch_command(command);
373
374    if !is_apply_patch {
375        return Ok(None);
376    }
377
378    let Some(patch_input) = patch_content else {
379        return Ok(None);
380    };
381
382    // Log warning about using shell for apply_patch
383    ctx.session
384        .record_warning(format!(
385            "apply_patch was requested via {}. Use the apply_patch tool instead of shell command execution.",
386            ctx.tool_name
387        ))
388        .await;
389
390    // Parse the patch
391    let patch = Patch::parse(&patch_input)
392        .map_err(|e| ToolCallError::respond(format!("Failed to parse patch: {}", e)))?;
393
394    let changes = convert_patch_to_changes(&patch, ctx.cwd);
395
396    // Create emitter
397    let emitter = ToolEmitter::apply_patch(changes.clone(), true);
398    let event_ctx = ToolEventCtx::new(
399        ctx.session.as_ref(),
400        ctx.turn.as_ref(),
401        ctx.call_id,
402        ctx.tracker,
403    );
404    emitter.begin(event_ctx).await;
405
406    // Execute
407    let req = ApplyPatchRequest {
408        patch: patch_input,
409        cwd: ctx.cwd.to_path_buf(),
410        timeout_ms: ctx.timeout_ms,
411        user_explicitly_approved: true,
412    };
413
414    let mut orchestrator = ToolOrchestrator::new();
415    let mut runtime = ApplyPatchRuntime::new();
416    let tool_ctx = ToolCtx {
417        session: ctx.session.clone(),
418        turn: ctx.turn.clone(),
419        call_id: ctx.call_id.to_string(),
420        tool_name: ctx.tool_name.to_string(),
421    };
422
423    let result = orchestrator
424        .run(
425            &mut runtime,
426            &req,
427            &tool_ctx,
428            ctx.turn.as_ref(),
429            map_approval_policy(ctx.turn.approval_policy.value()),
430        )
431        .await;
432
433    let event_ctx = ToolEventCtx::new(
434        ctx.session.as_ref(),
435        ctx.turn.as_ref(),
436        ctx.call_id,
437        ctx.tracker,
438    );
439    let content = emitter.finish(event_ctx, result).await?;
440
441    Ok(Some(ToolOutput::Function {
442        content,
443        content_items: None,
444        success: Some(true),
445    }))
446}
447
448fn map_approval_policy(policy: ApprovalPolicy) -> AskForApproval {
449    match policy {
450        ApprovalPolicy::Never => AskForApproval::Never,
451        ApprovalPolicy::OnMutation => AskForApproval::OnRequest,
452        ApprovalPolicy::Always => AskForApproval::UnlessTrusted,
453    }
454}
455
456/// Parse a shell command to check if it's an apply_patch invocation
457pub(crate) fn parse_apply_patch_command(command: &[String]) -> (bool, Option<String>) {
458    const APPLY_PATCH_COMMANDS: &[&str] = &["apply_patch", "applypatch"];
459
460    match command {
461        // Direct invocation: apply_patch <patch>
462        [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => (true, Some(body.clone())),
463        // Shell heredoc form is not directly supported here
464        // The Codex implementation uses tree-sitter to parse these
465        _ => (false, None),
466    }
467}
468
469// Constants for tool descriptions
470const APPLY_PATCH_DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files.
471Your patch language is a stripped-down, file-oriented diff format designed to be easy to parse and safe to apply.
472
473You can think of it as a high-level envelope:
474
475*** Begin Patch
476[ one or more file sections ]
477*** End Patch
478
479Within that envelope, you get a sequence of file operations.
480You MUST include a header to specify the action you are taking.
481Each operation starts with one of three headers:
482
483*** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
484*** Delete File: <path> - remove an existing file. Nothing follows.
485*** Update File: <path> - patch an existing file in place (optionally with a rename)."#;
486
487const APPLY_PATCH_GRAMMAR_HELP: &str = r#"May be immediately followed by *** Move to: <new path> if you want to rename the file.
488Then one or more "hunks", each introduced by @@ (optionally followed by a hunk header).
489Within a hunk each line starts with:
490
491- ` ` (space) for context lines
492- `-` for lines to remove
493- `+` for lines to add
494
495Important rules:
496- You must include a header with your intended action (Add/Delete/Update)
497- You must prefix new lines with `+` even when creating a new file
498- File references can only be relative, NEVER ABSOLUTE
499- Prefer small hunks with stable semantic @@ anchors like function, class, method, or impl names"#;
500
501const APPLY_PATCH_LARK_GRAMMAR: &str = r#"
502patch := "*** Begin Patch" NEWLINE { operation } "*** End Patch"
503operation := AddFile | DeleteFile | UpdateFile
504AddFile := "*** Add File: " path NEWLINE { "+" text NEWLINE }
505DeleteFile := "*** Delete File: " path NEWLINE
506UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk }
507MoveTo := "*** Move to: " newPath NEWLINE
508Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ]
509HunkLine := (" " | "-" | "+") text NEWLINE
510"#;
511
512const APPLY_PATCH_ADD_EXAMPLE: &str = r#"*** Begin Patch
513*** Add File: hello.txt
514+Hello world
515*** End Patch"#;
516
517const APPLY_PATCH_UPDATE_EXAMPLE: &str = r#"*** Begin Patch
518*** Update File: src/app.py
519*** Move to: src/main.py
520@@ def greet():
521-print("Hi")
522+print("Hello, world!")
523*** End Patch"#;
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::exec_policy::RejectConfig;
529
530    #[test]
531    fn test_parse_apply_patch_command_direct() {
532        let cmd = vec![
533            "apply_patch".to_string(),
534            "*** Begin Patch\n*** End Patch".to_string(),
535        ];
536        let (is_patch, content) = parse_apply_patch_command(&cmd);
537        assert!(is_patch);
538        assert!(content.is_some());
539    }
540
541    #[test]
542    fn test_parse_apply_patch_command_not_patch() {
543        let cmd = vec!["ls".to_string(), "-la".to_string()];
544        let (is_patch, content) = parse_apply_patch_command(&cmd);
545        assert!(!is_patch);
546        assert!(content.is_none());
547    }
548
549    #[test]
550    fn test_create_freeform_tool() {
551        let tool = create_apply_patch_freeform_tool();
552        assert_eq!(tool.name(), "apply_patch");
553    }
554
555    #[test]
556    fn test_create_json_tool() {
557        let tool = create_apply_patch_json_tool();
558        assert_eq!(tool.name(), "apply_patch");
559    }
560
561    #[test]
562    fn test_apply_patch_json_args_support_patch_alias() {
563        let parsed: ApplyPatchToolArgs =
564            serde_json::from_str(r#"{"patch":"*** Begin Patch\n*** End Patch\n"}"#)
565                .expect("json args should parse");
566
567        assert_eq!(parsed.input, None);
568        assert_eq!(
569            parsed.patch.as_deref(),
570            Some("*** Begin Patch\n*** End Patch\n")
571        );
572    }
573
574    #[test]
575    fn wants_no_sandbox_approval_reject_respects_sandbox_flag() {
576        let runtime = ApplyPatchRuntime::new();
577        assert!(runtime.wants_no_sandbox_approval(AskForApproval::OnRequest));
578        assert!(
579            !runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
580                sandbox_approval: true,
581                rules: false,
582                request_permissions: false,
583                mcp_elicitations: false,
584            }))
585        );
586        assert!(
587            runtime.wants_no_sandbox_approval(AskForApproval::Reject(RejectConfig {
588                sandbox_approval: false,
589                rules: false,
590                request_permissions: false,
591                mcp_elicitations: false,
592            }))
593        );
594    }
595}