Skip to content

Commit b8ce115

Browse files
committed
feat(agent): mount apply_patch tool for the gpt family
1 parent 9d43536 commit b8ce115

7 files changed

Lines changed: 195 additions & 5 deletions

File tree

‎src/agent/apply-patch-tool.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { mkdtemp, readFile, writeFile, stat } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { expect, test } from "bun:test";
5+
6+
import { createApplyPatchTool } from "./apply-patch-tool.js";
7+
8+
async function run(cwd: string, input: string): Promise<string> {
9+
const tool = createApplyPatchTool(cwd);
10+
if (tool.kind !== "string") throw new Error("expected a string tool");
11+
return tool.handler({ input }, new AbortController().signal);
12+
}
13+
14+
test("adds, updates, and deletes files in one envelope", async () => {
15+
const cwd = await mkdtemp(join(tmpdir(), "apply-patch-"));
16+
await writeFile(join(cwd, "a.txt"), "one\ntwo\nthree\n");
17+
await writeFile(join(cwd, "gone.txt"), "x\n");
18+
const out = await run(
19+
cwd,
20+
[
21+
"*** Begin Patch",
22+
"*** Add File: sub/new.txt",
23+
"+hello",
24+
"*** Update File: a.txt",
25+
"@@",
26+
" one",
27+
"-two",
28+
"+2",
29+
" three",
30+
"*** Delete File: gone.txt",
31+
"*** End Patch",
32+
].join("\n"),
33+
);
34+
expect(out).toContain("Success");
35+
expect(await readFile(join(cwd, "sub/new.txt"), "utf8")).toBe("hello\n");
36+
expect(await readFile(join(cwd, "a.txt"), "utf8")).toBe("one\n2\nthree\n");
37+
await expect(stat(join(cwd, "gone.txt"))).rejects.toThrow();
38+
});
39+
40+
test("a failing hunk leaves every file untouched", async () => {
41+
const cwd = await mkdtemp(join(tmpdir(), "apply-patch-"));
42+
await writeFile(join(cwd, "a.txt"), "one\n");
43+
const out = await run(
44+
cwd,
45+
[
46+
"*** Begin Patch",
47+
"*** Add File: b.txt",
48+
"+b",
49+
"*** Update File: missing.txt",
50+
"@@",
51+
"-x",
52+
"+y",
53+
"*** End Patch",
54+
].join("\n"),
55+
);
56+
expect(out).toContain("Error");
57+
await expect(stat(join(cwd, "b.txt"))).rejects.toThrow();
58+
});
59+
60+
test("rejects paths that escape the workspace", async () => {
61+
const cwd = await mkdtemp(join(tmpdir(), "apply-patch-"));
62+
const out = await run(
63+
cwd,
64+
"*** Begin Patch\n*** Add File: ../escape.txt\n+x\n*** End Patch",
65+
);
66+
expect(out).toContain("escapes the workspace");
67+
});

‎src/agent/apply-patch-tool.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2+
import { dirname, isAbsolute, relative, resolve } from "node:path";
3+
import { stringTool } from "@intx/agent";
4+
import type { AgentTool } from "@intx/agent";
5+
import type { ToolDefinition } from "@intx/types/runtime";
6+
import { type } from "arktype";
7+
8+
import {
9+
applyUpdateHunks,
10+
CodexApplyPatchError,
11+
parseCodexApplyPatch,
12+
type PatchOp,
13+
} from "./codex-apply-patch.js";
14+
15+
export const applyPatchDefinition: ToolDefinition = {
16+
name: "apply_patch",
17+
description:
18+
"Edit files with a patch envelope: *** Begin Patch, then *** Add File: / *** Update File: (optional *** Move to:) / *** Delete File: sections, then *** End Patch. Update hunks start with @@ and use ' ', '-', '+' line prefixes. Paths are workspace-relative.",
19+
inputSchema: {
20+
type: "object",
21+
properties: {
22+
input: { type: "string", description: "The full patch envelope" },
23+
},
24+
required: ["input"],
25+
},
26+
};
27+
28+
const ApplyPatchArgs = type({ input: "string" });
29+
30+
type Planned =
31+
| { kind: "write"; path: string; content: string; label: string }
32+
| { kind: "remove"; path: string; label: string };
33+
34+
function contained(cwd: string, path: string): string {
35+
const abs = resolve(cwd, path);
36+
const rel = relative(cwd, abs);
37+
if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
38+
throw new CodexApplyPatchError(`path escapes the workspace: ${path}`);
39+
}
40+
return abs;
41+
}
42+
43+
async function plan(cwd: string, op: PatchOp): Promise<Planned[]> {
44+
if (op.type === "add") {
45+
const path = contained(cwd, op.path);
46+
return [
47+
{ kind: "write", path, content: op.content, label: `A ${op.path}` },
48+
];
49+
}
50+
if (op.type === "delete") {
51+
return [
52+
{ kind: "remove", path: contained(cwd, op.path), label: `D ${op.path}` },
53+
];
54+
}
55+
const source = contained(cwd, op.path);
56+
let original: string;
57+
try {
58+
original = await readFile(source, "utf8");
59+
} catch {
60+
throw new CodexApplyPatchError(`cannot read ${op.path} to update it`);
61+
}
62+
const content = applyUpdateHunks(original, op.hunks);
63+
if (op.moveTo === undefined) {
64+
return [{ kind: "write", path: source, content, label: `M ${op.path}` }];
65+
}
66+
return [
67+
{
68+
kind: "write",
69+
path: contained(cwd, op.moveTo),
70+
content,
71+
label: `M ${op.moveTo}`,
72+
},
73+
{ kind: "remove", path: source, label: "" },
74+
];
75+
}
76+
77+
/**
78+
* Plans every op before touching disk so a bad hunk in the last file cannot
79+
* leave the earlier files half-patched.
80+
*/
81+
export function createApplyPatchTool(cwd: string): AgentTool {
82+
return stringTool({
83+
definition: applyPatchDefinition,
84+
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
85+
const parsedArgs = ApplyPatchArgs(rawArgs);
86+
if (parsedArgs instanceof type.errors) {
87+
return "Error: apply_patch requires input (string).";
88+
}
89+
try {
90+
const patch = parseCodexApplyPatch(parsedArgs.input);
91+
const steps: Planned[] = [];
92+
for (const op of patch.ops) steps.push(...(await plan(cwd, op)));
93+
for (const step of steps) {
94+
if (step.kind === "write") {
95+
await mkdir(dirname(step.path), { recursive: true });
96+
await writeFile(step.path, step.content);
97+
} else {
98+
await rm(step.path, { force: true });
99+
}
100+
}
101+
const labels = steps.map((s) => s.label).filter((l) => l !== "");
102+
return `Success. Updated the following files:\n${labels.join("\n")}`;
103+
} catch (err) {
104+
if (err instanceof CodexApplyPatchError) return `Error: ${err.message}`;
105+
throw err;
106+
}
107+
},
108+
});
109+
}

‎src/agent/codex-tool-mount.test.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,6 @@ describe("Codex tool proxy mount", () => {
3737
isCodex: false,
3838
});
3939
const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name);
40-
expect(names).not.toContain("apply_patch");
4140
expect(names).not.toContain("shell");
4241
expect(names).not.toContain("update_plan");
4342
await toolset.dispose();
@@ -61,7 +60,6 @@ describe("Codex tool proxy mount", () => {
6160
isCodex: true,
6261
});
6362
const names = toolset.dynamicRunner.currentDefinitions().map((d) => d.name);
64-
expect(names).not.toContain("apply_patch");
6563
expect(names).toContain("write_file");
6664
expect(names).toContain("edit_file");
6765
expect(names).toContain("delete_file");

‎src/agent/tool-search.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,10 @@ function isAlreadyAdvertised(
168168
// Mounted built-ins that stay off the advertised prefix and off tool_search.
169169
// Promote-on-execute must not flush these onto the wire either — dispatch
170170
// without advertising. glob is the advertised replacement for list_dir.
171-
export const UNADVERTISED_MOUNTED_BUILTINS = new Set(["list_dir"]);
171+
export const UNADVERTISED_MOUNTED_BUILTINS = new Set([
172+
"list_dir",
173+
"apply_patch",
174+
]);
172175

173176
// Project the live tool registry onto the advertised set: the fixed built-in
174177
// prefix (its order never changes — this is what keeps the provider cache

‎src/agent/tools.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,8 @@ test("dynamicRunner contains posix tool names plus ask_operator", async () => {
248248
expect(names).toContain("write_file");
249249
expect(names).toContain("edit_file");
250250
expect(names).toContain("delete_file");
251-
// apply_patch is Codex-only and stripped on primary even when mounted.
252-
expect(names).not.toContain("apply_patch");
251+
// apply_patch is mounted everywhere but only the gpt profile advertises it.
252+
expect(names).toContain("apply_patch");
253253
});
254254

255255
test("dynamicRunner omits ask_operator when onOperatorGate is not provided", async () => {

‎src/agent/tools.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ import {
106106
createWebSearchTool,
107107
disposeWebSearchClients,
108108
} from "../tools/web-search.js";
109+
import { createApplyPatchTool } from "./apply-patch-tool.js";
109110
import { createUseSkillTool } from "./use-skill.js";
110111
import { createSkillSearchTool } from "./skill-search.js";
111112
import {
@@ -665,6 +666,7 @@ export async function createAgentToolset(
665666
rootsProvider: createWorktreeRootsProvider(cwd),
666667
}),
667668
createUseSkillTool(cwd, skillDirs, args.telemetry),
669+
createApplyPatchTool(cwd),
668670
createSkillSearchTool({ skills }),
669671
builtinExaEnabled
670672
? createExaMCPWebFetchTool({ connect: waitForBuiltinExaConnection })

‎src/subagent/run.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
} from "./intervention-log.js";
6868
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
6969
import { canonicalToolName } from "../agent/canonical-tool-name.js";
70+
import { createApplyPatchTool } from "../agent/apply-patch-tool.js";
7071
import {
7172
checkMountedRequiresTools,
7273
formatCapabilityUnavailable,
@@ -799,6 +800,16 @@ async function runSubAgentInner(
799800
if (params.capabilities !== undefined) {
800801
tools = applyCapabilityFilter(tools, params.capabilities);
801802
}
803+
if (
804+
toolProfileForModel(params.provider) === "gpt" &&
805+
tools.some((t) =>
806+
["write_file", "edit_file", "delete_file"].includes(
807+
canonicalToolName(t.definition.name),
808+
),
809+
)
810+
) {
811+
tools = [...tools, createApplyPatchTool(params.cwd)];
812+
}
802813
backgroundCollectMounted = tools.some(
803814
(tool) => tool.definition.name === "shell_collect",
804815
);

0 commit comments

Comments
 (0)