2026-02-01 10:03:47 +09:00
import type { AgentTool } from "@mariozechner/pi-agent-core" ;
import { Type } from "@sinclair/typebox" ;
2026-01-12 03:42:49 +00:00
import fs from "node:fs/promises" ;
import os from "node:os" ;
import path from "node:path" ;
2026-01-14 01:08:15 +00:00
import { applyUpdateHunk } from "./apply-patch-update.js" ;
2026-01-12 03:42:49 +00:00
import { assertSandboxPath } from "./sandbox-paths.js" ;
const BEGIN_PATCH_MARKER = "*** Begin Patch" ;
const END_PATCH_MARKER = "*** End Patch" ;
const ADD_FILE_MARKER = "*** Add File: " ;
const DELETE_FILE_MARKER = "*** Delete File: " ;
const UPDATE_FILE_MARKER = "*** Update File: " ;
const MOVE_TO_MARKER = "*** Move to: " ;
const EOF_MARKER = "*** End of File" ;
const CHANGE_CONTEXT_MARKER = "@@ " ;
const EMPTY_CHANGE_CONTEXT_MARKER = "@@" ;
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g ;
type AddFileHunk = {
kind : "add" ;
path : string ;
contents : string ;
} ;
type DeleteFileHunk = {
kind : "delete" ;
path : string ;
} ;
type UpdateFileChunk = {
changeContext? : string ;
oldLines : string [ ] ;
newLines : string [ ] ;
isEndOfFile : boolean ;
} ;
type UpdateFileHunk = {
kind : "update" ;
path : string ;
movePath? : string ;
chunks : UpdateFileChunk [ ] ;
} ;
type Hunk = AddFileHunk | DeleteFileHunk | UpdateFileHunk ;
export type ApplyPatchSummary = {
added : string [ ] ;
modified : string [ ] ;
deleted : string [ ] ;
} ;
export type ApplyPatchResult = {
summary : ApplyPatchSummary ;
text : string ;
} ;
export type ApplyPatchToolDetails = {
summary : ApplyPatchSummary ;
} ;
type ApplyPatchOptions = {
cwd : string ;
sandboxRoot? : string ;
signal? : AbortSignal ;
} ;
const applyPatchSchema = Type . Object ( {
input : Type.String ( {
description : "Patch content using the *** Begin Patch/End Patch format." ,
} ) ,
} ) ;
export function createApplyPatchTool (
options : { cwd? : string ; sandboxRoot? : string } = { } ,
2026-02-02 15:45:05 +09:00
// oxlint-disable-next-line typescript/no-explicit-any
2026-01-12 03:42:49 +00:00
) : AgentTool < any , ApplyPatchToolDetails > {
const cwd = options . cwd ? ? process . cwd ( ) ;
const sandboxRoot = options . sandboxRoot ;
return {
name : "apply_patch" ,
label : "apply_patch" ,
description :
"Apply a patch to one or more files using the apply_patch format. The input should include *** Begin Patch and *** End Patch markers." ,
parameters : applyPatchSchema ,
execute : async ( _toolCallId , args , signal ) = > {
const params = args as { input? : string } ;
const input = typeof params . input === "string" ? params . input : "" ;
if ( ! input . trim ( ) ) {
throw new Error ( "Provide a patch input." ) ;
}
if ( signal ? . aborted ) {
const err = new Error ( "Aborted" ) ;
err . name = "AbortError" ;
throw err ;
}
const result = await applyPatch ( input , {
cwd ,
sandboxRoot ,
signal ,
} ) ;
return {
content : [ { type : "text" , text : result.text } ] ,
details : { summary : result.summary } ,
} ;
} ,
} ;
}
export async function applyPatch (
input : string ,
options : ApplyPatchOptions ,
) : Promise < ApplyPatchResult > {
const parsed = parsePatchText ( input ) ;
if ( parsed . hunks . length === 0 ) {
throw new Error ( "No files were modified." ) ;
}
const summary : ApplyPatchSummary = {
added : [ ] ,
modified : [ ] ,
deleted : [ ] ,
} ;
const seen = {
added : new Set < string > ( ) ,
modified : new Set < string > ( ) ,
deleted : new Set < string > ( ) ,
} ;
for ( const hunk of parsed . hunks ) {
if ( options . signal ? . aborted ) {
const err = new Error ( "Aborted" ) ;
err . name = "AbortError" ;
throw err ;
}
if ( hunk . kind === "add" ) {
const target = await resolvePatchPath ( hunk . path , options ) ;
await ensureDir ( target . resolved ) ;
await fs . writeFile ( target . resolved , hunk . contents , "utf8" ) ;
recordSummary ( summary , seen , "added" , target . display ) ;
continue ;
}
if ( hunk . kind === "delete" ) {
const target = await resolvePatchPath ( hunk . path , options ) ;
await fs . rm ( target . resolved ) ;
recordSummary ( summary , seen , "deleted" , target . display ) ;
continue ;
}
const target = await resolvePatchPath ( hunk . path , options ) ;
const applied = await applyUpdateHunk ( target . resolved , hunk . chunks ) ;
if ( hunk . movePath ) {
const moveTarget = await resolvePatchPath ( hunk . movePath , options ) ;
await ensureDir ( moveTarget . resolved ) ;
await fs . writeFile ( moveTarget . resolved , applied , "utf8" ) ;
await fs . rm ( target . resolved ) ;
recordSummary ( summary , seen , "modified" , moveTarget . display ) ;
} else {
await fs . writeFile ( target . resolved , applied , "utf8" ) ;
recordSummary ( summary , seen , "modified" , target . display ) ;
}
}
return {
summary ,
text : formatSummary ( summary ) ,
} ;
}
function recordSummary (
summary : ApplyPatchSummary ,
seen : {
added : Set < string > ;
modified : Set < string > ;
deleted : Set < string > ;
} ,
bucket : keyof ApplyPatchSummary ,
value : string ,
) {
2026-01-31 16:19:20 +09:00
if ( seen [ bucket ] . has ( value ) ) {
return ;
}
2026-01-12 03:42:49 +00:00
seen [ bucket ] . add ( value ) ;
summary [ bucket ] . push ( value ) ;
}
function formatSummary ( summary : ApplyPatchSummary ) : string {
const lines = [ "Success. Updated the following files:" ] ;
2026-01-31 16:19:20 +09:00
for ( const file of summary . added ) {
lines . push ( ` A ${ file } ` ) ;
}
for ( const file of summary . modified ) {
lines . push ( ` M ${ file } ` ) ;
}
for ( const file of summary . deleted ) {
lines . push ( ` D ${ file } ` ) ;
}
2026-01-12 03:42:49 +00:00
return lines . join ( "\n" ) ;
}
async function ensureDir ( filePath : string ) {
const parent = path . dirname ( filePath ) ;
2026-01-31 16:19:20 +09:00
if ( ! parent || parent === "." ) {
return ;
}
2026-01-12 03:42:49 +00:00
await fs . mkdir ( parent , { recursive : true } ) ;
}
async function resolvePatchPath (
filePath : string ,
options : ApplyPatchOptions ,
) : Promise < { resolved : string ; display : string } > {
if ( options . sandboxRoot ) {
const resolved = await assertSandboxPath ( {
filePath ,
cwd : options.cwd ,
root : options.sandboxRoot ,
} ) ;
return {
resolved : resolved.resolved ,
display : resolved.relative || resolved . resolved ,
} ;
}
const resolved = resolvePathFromCwd ( filePath , options . cwd ) ;
return {
resolved ,
display : toDisplayPath ( resolved , options . cwd ) ,
} ;
}
function normalizeUnicodeSpaces ( value : string ) : string {
return value . replace ( UNICODE_SPACES , " " ) ;
}
function expandPath ( filePath : string ) : string {
const normalized = normalizeUnicodeSpaces ( filePath ) ;
2026-01-31 16:19:20 +09:00
if ( normalized === "~" ) {
return os . homedir ( ) ;
}
if ( normalized . startsWith ( "~/" ) ) {
return os . homedir ( ) + normalized . slice ( 1 ) ;
}
2026-01-12 03:42:49 +00:00
return normalized ;
}
function resolvePathFromCwd ( filePath : string , cwd : string ) : string {
const expanded = expandPath ( filePath ) ;
2026-01-31 16:19:20 +09:00
if ( path . isAbsolute ( expanded ) ) {
return path . normalize ( expanded ) ;
}
2026-01-12 03:42:49 +00:00
return path . resolve ( cwd , expanded ) ;
}
function toDisplayPath ( resolved : string , cwd : string ) : string {
const relative = path . relative ( cwd , resolved ) ;
2026-01-31 16:19:20 +09:00
if ( ! relative || relative === "" ) {
return path . basename ( resolved ) ;
}
if ( relative . startsWith ( ".." ) || path . isAbsolute ( relative ) ) {
return resolved ;
}
2026-01-12 03:42:49 +00:00
return relative ;
}
function parsePatchText ( input : string ) : { hunks : Hunk [ ] ; patch : string } {
const trimmed = input . trim ( ) ;
if ( ! trimmed ) {
throw new Error ( "Invalid patch: input is empty." ) ;
}
const lines = trimmed . split ( /\r?\n/ ) ;
const validated = checkPatchBoundariesLenient ( lines ) ;
const hunks : Hunk [ ] = [ ] ;
const lastLineIndex = validated . length - 1 ;
let remaining = validated . slice ( 1 , lastLineIndex ) ;
let lineNumber = 2 ;
while ( remaining . length > 0 ) {
const { hunk , consumed } = parseOneHunk ( remaining , lineNumber ) ;
hunks . push ( hunk ) ;
lineNumber += consumed ;
remaining = remaining . slice ( consumed ) ;
}
return { hunks , patch : validated.join ( "\n" ) } ;
}
function checkPatchBoundariesLenient ( lines : string [ ] ) : string [ ] {
const strictError = checkPatchBoundariesStrict ( lines ) ;
2026-01-31 16:19:20 +09:00
if ( ! strictError ) {
return lines ;
}
2026-01-12 03:42:49 +00:00
if ( lines . length < 4 ) {
throw new Error ( strictError ) ;
}
const first = lines [ 0 ] ;
const last = lines [ lines . length - 1 ] ;
2026-01-14 14:31:43 +00:00
if ( ( first === "<<EOF" || first === "<<'EOF'" || first === '<<"EOF"' ) && last . endsWith ( "EOF" ) ) {
2026-01-12 03:42:49 +00:00
const inner = lines . slice ( 1 , lines . length - 1 ) ;
const innerError = checkPatchBoundariesStrict ( inner ) ;
2026-01-31 16:19:20 +09:00
if ( ! innerError ) {
return inner ;
}
2026-01-12 03:42:49 +00:00
throw new Error ( innerError ) ;
}
throw new Error ( strictError ) ;
}
function checkPatchBoundariesStrict ( lines : string [ ] ) : string | null {
const firstLine = lines [ 0 ] ? . trim ( ) ;
const lastLine = lines [ lines . length - 1 ] ? . trim ( ) ;
if ( firstLine === BEGIN_PATCH_MARKER && lastLine === END_PATCH_MARKER ) {
return null ;
}
if ( firstLine !== BEGIN_PATCH_MARKER ) {
return "The first line of the patch must be '*** Begin Patch'" ;
}
return "The last line of the patch must be '*** End Patch'" ;
}
2026-01-14 14:31:43 +00:00
function parseOneHunk ( lines : string [ ] , lineNumber : number ) : { hunk : Hunk ; consumed : number } {
2026-01-12 03:42:49 +00:00
if ( lines . length === 0 ) {
throw new Error ( ` Invalid patch hunk at line ${ lineNumber } : empty hunk ` ) ;
}
const firstLine = lines [ 0 ] . trim ( ) ;
if ( firstLine . startsWith ( ADD_FILE_MARKER ) ) {
const targetPath = firstLine . slice ( ADD_FILE_MARKER . length ) ;
let contents = "" ;
let consumed = 1 ;
for ( const addLine of lines . slice ( 1 ) ) {
if ( addLine . startsWith ( "+" ) ) {
contents += ` ${ addLine . slice ( 1 ) } \ n ` ;
consumed += 1 ;
} else {
break ;
}
}
return {
hunk : { kind : "add" , path : targetPath , contents } ,
consumed ,
} ;
}
if ( firstLine . startsWith ( DELETE_FILE_MARKER ) ) {
const targetPath = firstLine . slice ( DELETE_FILE_MARKER . length ) ;
return {
hunk : { kind : "delete" , path : targetPath } ,
consumed : 1 ,
} ;
}
if ( firstLine . startsWith ( UPDATE_FILE_MARKER ) ) {
const targetPath = firstLine . slice ( UPDATE_FILE_MARKER . length ) ;
let remaining = lines . slice ( 1 ) ;
let consumed = 1 ;
let movePath : string | undefined ;
const moveCandidate = remaining [ 0 ] ? . trim ( ) ;
if ( moveCandidate ? . startsWith ( MOVE_TO_MARKER ) ) {
movePath = moveCandidate . slice ( MOVE_TO_MARKER . length ) ;
remaining = remaining . slice ( 1 ) ;
consumed += 1 ;
}
const chunks : UpdateFileChunk [ ] = [ ] ;
while ( remaining . length > 0 ) {
if ( remaining [ 0 ] . trim ( ) === "" ) {
remaining = remaining . slice ( 1 ) ;
consumed += 1 ;
continue ;
}
if ( remaining [ 0 ] . startsWith ( "***" ) ) {
break ;
}
const { chunk , consumed : chunkLines } = parseUpdateFileChunk (
remaining ,
lineNumber + consumed ,
chunks . length === 0 ,
) ;
chunks . push ( chunk ) ;
remaining = remaining . slice ( chunkLines ) ;
consumed += chunkLines ;
}
if ( chunks . length === 0 ) {
throw new Error (
` Invalid patch hunk at line ${ lineNumber } : Update file hunk for path ' ${ targetPath } ' is empty ` ,
) ;
}
return {
hunk : {
kind : "update" ,
path : targetPath ,
movePath ,
chunks ,
} ,
consumed ,
} ;
}
throw new Error (
` Invalid patch hunk at line ${ lineNumber } : ' ${ lines [ 0 ] } ' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}' ` ,
) ;
}
function parseUpdateFileChunk (
lines : string [ ] ,
lineNumber : number ,
allowMissingContext : boolean ,
) : { chunk : UpdateFileChunk ; consumed : number } {
if ( lines . length === 0 ) {
throw new Error (
` Invalid patch hunk at line ${ lineNumber } : Update hunk does not contain any lines ` ,
) ;
}
let changeContext : string | undefined ;
let startIndex = 0 ;
if ( lines [ 0 ] === EMPTY_CHANGE_CONTEXT_MARKER ) {
startIndex = 1 ;
} else if ( lines [ 0 ] . startsWith ( CHANGE_CONTEXT_MARKER ) ) {
changeContext = lines [ 0 ] . slice ( CHANGE_CONTEXT_MARKER . length ) ;
startIndex = 1 ;
} else if ( ! allowMissingContext ) {
throw new Error (
` Invalid patch hunk at line ${ lineNumber } : Expected update hunk to start with a @@ context marker, got: ' ${ lines [ 0 ] } ' ` ,
) ;
}
if ( startIndex >= lines . length ) {
throw new Error (
` Invalid patch hunk at line ${ lineNumber + 1 } : Update hunk does not contain any lines ` ,
) ;
}
const chunk : UpdateFileChunk = {
changeContext ,
oldLines : [ ] ,
newLines : [ ] ,
isEndOfFile : false ,
} ;
let parsedLines = 0 ;
for ( const line of lines . slice ( startIndex ) ) {
if ( line === EOF_MARKER ) {
if ( parsedLines === 0 ) {
throw new Error (
` Invalid patch hunk at line ${ lineNumber + 1 } : Update hunk does not contain any lines ` ,
) ;
}
chunk . isEndOfFile = true ;
parsedLines += 1 ;
break ;
}
const marker = line [ 0 ] ;
if ( ! marker ) {
chunk . oldLines . push ( "" ) ;
chunk . newLines . push ( "" ) ;
parsedLines += 1 ;
continue ;
}
if ( marker === " " ) {
const content = line . slice ( 1 ) ;
chunk . oldLines . push ( content ) ;
chunk . newLines . push ( content ) ;
parsedLines += 1 ;
continue ;
}
if ( marker === "+" ) {
chunk . newLines . push ( line . slice ( 1 ) ) ;
parsedLines += 1 ;
continue ;
}
if ( marker === "-" ) {
chunk . oldLines . push ( line . slice ( 1 ) ) ;
parsedLines += 1 ;
continue ;
}
if ( parsedLines === 0 ) {
throw new Error (
` Invalid patch hunk at line ${ lineNumber + 1 } : Unexpected line found in update hunk: ' ${ line } '. Every line should start with ' ' (context line), '+' (added line), or '-' (removed line) ` ,
) ;
}
break ;
}
return { chunk , consumed : parsedLines + startIndex } ;
}