@lazyingart/agintiflow 0.8.10 → 0.8.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/docs/patch-tools.md +17 -0
- package/package.json +1 -1
- package/scripts/smoke-coding-tools.js +85 -0
- package/src/agent-runner.js +8 -6
- package/src/interactive-cli.js +159 -16
- package/src/model-client.js +32 -6
- package/src/model-routing.js +4 -0
- package/src/workspace-tools.js +301 -2
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ It is designed for workflows where an AI agent should act, but every tool, log,
|
|
|
28
28
|
| Core loop | Plan -> use tools -> log events -> finish or resume |
|
|
29
29
|
| Browser control | Playwright, lazy browser startup, domain allowlists |
|
|
30
30
|
| Model layer | Smart routing over DeepSeek fast/pro presets with manual OpenAI-compatible fallback |
|
|
31
|
-
| Local tools | Guarded workspace file tools, optional shell commands, Docker sandbox support, and advisory agent wrappers |
|
|
31
|
+
| Local tools | Guarded workspace file tools, Codex-style patching, optional shell commands, Docker sandbox support, and advisory agent wrappers |
|
|
32
32
|
| Memory | Session state, persisted web settings, chat continuation |
|
|
33
33
|
| Operator UX | Multilingual web UI with provider selection, run output, and conversation history |
|
|
34
34
|
|
|
@@ -65,6 +65,8 @@ aginti chat
|
|
|
65
65
|
|
|
66
66
|
Inside chat, type normal requests such as `write a small Python CLI app with tests`. The default is Docker workspace mode with approved package installs, so coding, plotting, and LaTeX tasks can set up project-local tools without touching the host. Use `/help` for commands, `/login` or `/auth` to paste a provider key, `/latex on` for PDF work, `/docker off` only when you intentionally want host mode, `/sessions` to list project runs, and `/resume latest` or `/resume <session-id>` to continue work. Type `/` then Tab for command completion. Esc or Ctrl+C stops the active run cleanly and prints the resume command.
|
|
67
67
|
|
|
68
|
+
For code edits, AgInTiFlow routes patch/refactor/database-style tasks to DeepSeek v4 pro by default and exposes `apply_patch` as a deterministic workspace tool. It supports exact replacements, Codex-style patch envelopes, and unified diffs, with preflight checks, path guardrails, hashes, and compact per-file diffs. See [docs/patch-tools.md](docs/patch-tools.md).
|
|
69
|
+
|
|
68
70
|
Launch the local web UI from an installed package:
|
|
69
71
|
|
|
70
72
|
```bash
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Patch Tools
|
|
2
|
+
|
|
3
|
+
AgInTiFlow exposes a deterministic `apply_patch` workspace tool for coding-agent edits. It is designed for DeepSeek v4 pro and other routed models to make auditable code changes without relying on free-form shell redirection.
|
|
4
|
+
|
|
5
|
+
## Supported Patch Modes
|
|
6
|
+
|
|
7
|
+
- Exact replacement: pass `path`, `search`, `replace`, and optionally `expectedReplacements` or `baseHash`.
|
|
8
|
+
- Codex-style patch envelope: pass `patch` with `*** Begin Patch`, `*** Update File`, `*** Add File`, `*** Delete File`, and `*** End Patch`.
|
|
9
|
+
- Unified diff: pass `patch` with standard `--- a/file`, `+++ b/file`, and `@@` hunks.
|
|
10
|
+
|
|
11
|
+
All paths must stay inside the configured workspace. Secret-like paths, `.git`, `node_modules` writes, binary files, and huge files are blocked. Multi-file patches are preflighted before writing, and each changed file records before/after hashes plus a compact diff in the session events.
|
|
12
|
+
|
|
13
|
+
## Agent Workflow
|
|
14
|
+
|
|
15
|
+
For large codebases, the model should first use `list_files`, `search_files`, and `read_file` to identify the relevant files. It should then call `apply_patch`, run safe tests or linters when available, and summarize changed files and residual risk.
|
|
16
|
+
|
|
17
|
+
Smart routing treats patch/refactor/edit/database tasks as complex work, so DeepSeek v4 pro is selected by default unless the user explicitly chooses another route.
|
package/package.json
CHANGED
|
@@ -5,7 +5,9 @@ import path from "node:path";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { repairModelMessageHistory, runAgent } from "../src/agent-runner.js";
|
|
7
7
|
import { resolveRuntimeConfig } from "../src/config.js";
|
|
8
|
+
import { selectModelRoute } from "../src/model-routing.js";
|
|
8
9
|
import { SessionStore } from "../src/session-store.js";
|
|
10
|
+
import { executeWorkspaceTool } from "../src/workspace-tools.js";
|
|
9
11
|
|
|
10
12
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
13
|
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "agintiflow-coding-tools-"));
|
|
@@ -78,6 +80,12 @@ try {
|
|
|
78
80
|
!staleDeepSeekState.messages.some((message) => message.role === "tool" && message.tool_call_id === "stale-call"),
|
|
79
81
|
"repaired DeepSeek history retained an orphan stale tool message"
|
|
80
82
|
);
|
|
83
|
+
const patchRoute = selectModelRoute({
|
|
84
|
+
routingMode: "smart",
|
|
85
|
+
provider: "deepseek",
|
|
86
|
+
goal: "patch this large codebase and migrate the database tests",
|
|
87
|
+
});
|
|
88
|
+
assert(/pro/i.test(patchRoute.model), "patch/refactor task did not route to DeepSeek pro");
|
|
81
89
|
|
|
82
90
|
const writeRun = await runMock("Create notes/hello.md with a short coding smoke message.", "coding-write");
|
|
83
91
|
const written = await fs.readFile(path.join(workspace, "notes/hello.md"), "utf8");
|
|
@@ -106,6 +114,78 @@ try {
|
|
|
106
114
|
assert(patched === "new\n", "mock patch did not update expected file");
|
|
107
115
|
assert(patchRun.events.some((event) => event.type === "file.changed"), "patch run did not persist file.changed event");
|
|
108
116
|
|
|
117
|
+
await fs.writeFile(path.join(workspace, "patch-target.txt"), "old\n", "utf8");
|
|
118
|
+
const multiPatchRun = await runMock("Apply multi-file Codex patch to replace old and add a note.", "coding-patch-multi");
|
|
119
|
+
const multiPatched = await fs.readFile(path.join(workspace, "patch-target.txt"), "utf8");
|
|
120
|
+
const patchNote = await fs.readFile(path.join(workspace, "notes/patch-note.md"), "utf8");
|
|
121
|
+
assert(multiPatched === "new\n", "mock multi-file patch did not update expected file");
|
|
122
|
+
assert(patchNote.includes("multi-file patch smoke"), "mock multi-file patch did not add expected file");
|
|
123
|
+
assert(
|
|
124
|
+
multiPatchRun.events.filter((event) => event.type === "file.changed").length >= 2,
|
|
125
|
+
"multi-file patch did not persist per-file change events"
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await fs.writeFile(path.join(workspace, "unified-target.txt"), "alpha\nold\nomega\n", "utf8");
|
|
129
|
+
const unified = await executeWorkspaceTool(
|
|
130
|
+
"apply_patch",
|
|
131
|
+
{
|
|
132
|
+
patch: [
|
|
133
|
+
"--- a/unified-target.txt",
|
|
134
|
+
"+++ b/unified-target.txt",
|
|
135
|
+
"@@ -1,3 +1,3 @@",
|
|
136
|
+
" alpha",
|
|
137
|
+
"-old",
|
|
138
|
+
"+new",
|
|
139
|
+
" omega",
|
|
140
|
+
].join("\n"),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
commandCwd: workspace,
|
|
144
|
+
allowFileTools: true,
|
|
145
|
+
}
|
|
146
|
+
);
|
|
147
|
+
const unifiedText = await fs.readFile(path.join(workspace, "unified-target.txt"), "utf8");
|
|
148
|
+
assert(unified.ok && unifiedText === "alpha\nnew\nomega\n", "unified apply_patch did not update expected file");
|
|
149
|
+
|
|
150
|
+
const blockedPatch = await executeWorkspaceTool(
|
|
151
|
+
"apply_patch",
|
|
152
|
+
{
|
|
153
|
+
patch: ["*** Begin Patch", "*** Add File: .env", "+TOKEN=blocked", "*** End Patch"].join("\n"),
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
commandCwd: workspace,
|
|
157
|
+
allowFileTools: true,
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
assert(blockedPatch.blocked, "patch document to sensitive path was not blocked by guardrail");
|
|
161
|
+
|
|
162
|
+
await fs.writeFile(path.join(workspace, "move-source.txt"), "source\n", "utf8");
|
|
163
|
+
await fs.writeFile(path.join(workspace, "move-target.txt"), "target\n", "utf8");
|
|
164
|
+
const moveOverResult = await executeWorkspaceTool(
|
|
165
|
+
"apply_patch",
|
|
166
|
+
{
|
|
167
|
+
patch: [
|
|
168
|
+
"*** Begin Patch",
|
|
169
|
+
"*** Update File: move-source.txt",
|
|
170
|
+
"*** Move to: move-target.txt",
|
|
171
|
+
"@@",
|
|
172
|
+
"-source",
|
|
173
|
+
"+moved",
|
|
174
|
+
"*** End Patch",
|
|
175
|
+
].join("\n"),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
commandCwd: workspace,
|
|
179
|
+
allowFileTools: true,
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
.then(() => "")
|
|
183
|
+
.catch((error) => String(error?.message || error));
|
|
184
|
+
assert(
|
|
185
|
+
/move over an existing file/.test(moveOverResult),
|
|
186
|
+
"patch move over an existing file was not rejected"
|
|
187
|
+
);
|
|
188
|
+
|
|
109
189
|
const envRun = await runMock("Create file: .env with blocked content.", "coding-block-env");
|
|
110
190
|
await fs
|
|
111
191
|
.access(path.join(workspace, ".env"))
|
|
@@ -135,11 +215,16 @@ try {
|
|
|
135
215
|
workspace,
|
|
136
216
|
checks: [
|
|
137
217
|
"deepseek_history_repair",
|
|
218
|
+
"deepseek_pro_patch_route",
|
|
138
219
|
"write_file",
|
|
139
220
|
"duplicate_write_failed",
|
|
140
221
|
"resume_session_write",
|
|
141
222
|
"virtual_workspace_path",
|
|
142
223
|
"apply_patch",
|
|
224
|
+
"multi_file_patch",
|
|
225
|
+
"unified_patch",
|
|
226
|
+
"patch_guardrail",
|
|
227
|
+
"patch_move_no_overwrite",
|
|
143
228
|
"block_env",
|
|
144
229
|
"block_outside",
|
|
145
230
|
],
|
package/src/agent-runner.js
CHANGED
|
@@ -271,7 +271,7 @@ function createInitialState(config, sessionId) {
|
|
|
271
271
|
: "A host shell command tool is available under the configured trust policy."
|
|
272
272
|
: "No shell command tool is available.",
|
|
273
273
|
config.allowFileTools
|
|
274
|
-
? `Workspace file tools are available in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, and preview_workspace. Always use workspace-relative paths such as plot_fx.svg or docs/report.tex, never absolute host paths. Secret paths, .git internals, node_modules writes, and huge files are blocked. For generated local websites/pages, use open_workspace_file or preview_workspace instead of starting a localhost server inside Docker.`
|
|
274
|
+
? `Workspace file tools are available in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, and preview_workspace. apply_patch supports exact single-file replacements plus Codex-style/unified multi-file patches; prefer it for source edits after reading/searching the relevant context. Always use workspace-relative paths such as plot_fx.svg or docs/report.tex, never absolute host paths. Secret paths, .git internals, node_modules writes, and huge files are blocked. For generated local websites/pages, use open_workspace_file or preview_workspace instead of starting a localhost server inside Docker.`
|
|
275
275
|
: "No workspace file tools are available.",
|
|
276
276
|
config.allowWrapperTools
|
|
277
277
|
? `External coding-agent wrappers are available as advisory tools only. Use the selected wrapper only: ${normalizeWrapperName(config.preferredWrapper)}. Wrapper status: ${wrapperStatusText()}.`
|
|
@@ -279,7 +279,7 @@ function createInitialState(config, sessionId) {
|
|
|
279
279
|
`Task profile: ${taskProfile.label}. ${taskProfile.prompt}`,
|
|
280
280
|
"A frontend canvas/artifacts tunnel exists. Use send_to_canvas when important markdown, diffs, screenshots, images, or workspace files should be highlighted in the UI. It is optional and ordinary final text can still go directly to finish.",
|
|
281
281
|
"For visual-output requests such as draw, plot, graph, chart, diagram, figure, image, or visualization, proactively publish a canvas artifact even when the user does not mention canvas. If workspace file tools are enabled, prefer creating a small SVG or markdown artifact and call send_to_canvas with selected=true.",
|
|
282
|
-
"Work like a practical coding agent: inspect when useful,
|
|
282
|
+
"Work like a practical coding agent: inspect when useful, patch code with apply_patch, run safe checks when they add confidence, iterate on failures, and keep outputs inside the workspace.",
|
|
283
283
|
"For large projects, decompose into useful files and milestones, implement a coherent minimal version first, then iterate with checks rather than only describing what you would do.",
|
|
284
284
|
"For website/app/code/LaTeX/Python/C/shell tasks, create or edit real workspace files, run available build/compile/test commands, and surface artifacts through the canvas when useful.",
|
|
285
285
|
"For research or web-search tasks, use browser tools or safe shell network tools when the current policy allows; cite or save useful sources in workspace notes when the task needs traceability.",
|
|
@@ -304,7 +304,7 @@ function createInitialState(config, sessionId) {
|
|
|
304
304
|
: `Shell working directory: ${config.commandCwd}`
|
|
305
305
|
: "",
|
|
306
306
|
config.allowFileTools
|
|
307
|
-
? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths. Local preview tools available: open_workspace_file and preview_workspace.`
|
|
307
|
+
? `Workspace file tools enabled in: ${config.commandCwd}. Use workspace-relative paths. Use apply_patch for code edits; it accepts exact replacements or Codex-style/unified multi-file patches. Local preview tools available: open_workspace_file and preview_workspace.`
|
|
308
308
|
: "",
|
|
309
309
|
config.allowWrapperTools
|
|
310
310
|
? `Agent wrappers: selected=${normalizeWrapperName(config.preferredWrapper)}; ${wrapperStatusText()}`
|
|
@@ -499,6 +499,7 @@ function sanitizeToolArgs(toolName, args) {
|
|
|
499
499
|
if (toolName === "apply_patch") {
|
|
500
500
|
return {
|
|
501
501
|
...safeArgs,
|
|
502
|
+
patch: typeof args.patch === "string" ? `[${Buffer.byteLength(args.patch, "utf8")} bytes sha256=${hashForLog(args.patch)}]` : safeArgs.patch,
|
|
502
503
|
search: typeof args.search === "string" ? redactSensitiveText(args.search).slice(0, 160) : safeArgs.search,
|
|
503
504
|
replace: typeof args.replace === "string" ? redactSensitiveText(args.replace).slice(0, 160) : safeArgs.replace,
|
|
504
505
|
};
|
|
@@ -610,7 +611,7 @@ async function captureSyntheticSnapshot(store, step, config) {
|
|
|
610
611
|
: `Shell tool available in: ${config.commandCwd}`
|
|
611
612
|
: "Shell tool disabled.",
|
|
612
613
|
config.allowFileTools
|
|
613
|
-
? `Workspace file tools available in: ${config.commandCwd}. Use workspace-relative paths.`
|
|
614
|
+
? `Workspace file tools available in: ${config.commandCwd}. Use workspace-relative paths. Use apply_patch for code edits; it supports exact single-file replacement and multi-file Codex-style/unified patches.`
|
|
614
615
|
: "Workspace file tools disabled.",
|
|
615
616
|
config.allowWrapperTools
|
|
616
617
|
? `Agent wrappers available: selected=${normalizeWrapperName(config.preferredWrapper)}; ${wrapperStatusText()}`
|
|
@@ -819,9 +820,10 @@ async function executeTool(browserState, toolCall, snapshot, config, store, obse
|
|
|
819
820
|
|
|
820
821
|
await store.appendEvent("tool.completed", eventResult);
|
|
821
822
|
observers.event("tool.completed", eventResult);
|
|
822
|
-
|
|
823
|
+
const changes = Array.isArray(result.changes) && result.changes.length ? result.changes : result.change ? [result.change] : [];
|
|
824
|
+
for (const item of changes) {
|
|
823
825
|
const change = {
|
|
824
|
-
...
|
|
826
|
+
...item,
|
|
825
827
|
toolName: toolCall.function.name,
|
|
826
828
|
commandCwd: config.commandCwd,
|
|
827
829
|
};
|
package/src/interactive-cli.js
CHANGED
|
@@ -60,7 +60,7 @@ function label(name, bgCode) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function userPrompt() {
|
|
63
|
-
return `\n${label("user>", ansi.userBg)} `;
|
|
63
|
+
return `\n${label("user>", ansi.userBg)} ${color("|", ansi.userBg)} `;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function commandCompleter(line = "") {
|
|
@@ -70,6 +70,21 @@ function commandCompleter(line = "") {
|
|
|
70
70
|
return [hits.length > 0 ? hits : SLASH_COMMANDS, trimmed];
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function stripAnsi(value) {
|
|
74
|
+
return String(value || "").replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function promptGutter() {
|
|
78
|
+
const visible = stripAnsi(userPrompt()).replace(/^\n/, "").length;
|
|
79
|
+
return " ".repeat(Math.max(visible - 2, 0)) + `${color("|", ansi.userBg)} `;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function commandSuggestions(line = "") {
|
|
83
|
+
const trimmed = String(line || "");
|
|
84
|
+
if (!trimmed.startsWith("/") || /\s/.test(trimmed)) return [];
|
|
85
|
+
return SLASH_COMMANDS.filter((command) => command.startsWith(trimmed)).slice(0, 8);
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
function stripMarkdown(text) {
|
|
74
89
|
const lines = String(text || "").split(/\r?\n/);
|
|
75
90
|
let inFence = false;
|
|
@@ -79,16 +94,26 @@ function stripMarkdown(text) {
|
|
|
79
94
|
let line = rawLine;
|
|
80
95
|
if (/^\s*```/.test(line)) {
|
|
81
96
|
inFence = !inFence;
|
|
82
|
-
if (inFence)
|
|
97
|
+
if (inFence) {
|
|
98
|
+
const language = line.replace(/^\s*```/, "").trim();
|
|
99
|
+
rendered.push(color(language ? `code ${language}` : "code", ansi.dim));
|
|
100
|
+
}
|
|
83
101
|
continue;
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
if (!inFence) {
|
|
87
105
|
if (/^\s*[-*_]{3,}\s*$/.test(line)) {
|
|
88
|
-
rendered.push("");
|
|
106
|
+
rendered.push(color("-".repeat(42), ansi.dim));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+)$/);
|
|
110
|
+
if (heading) {
|
|
111
|
+
rendered.push(color(heading[2].replace(/\s+#*$/, ""), ansi.bold, ansi.cyan));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(line)) {
|
|
89
115
|
continue;
|
|
90
116
|
}
|
|
91
|
-
line = line.replace(/^\s{0,3}#{1,6}\s+/, "");
|
|
92
117
|
line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
|
|
93
118
|
line = line.replace(/\*\*([^*]+)\*\*/g, (_, value) => color(value, ansi.bold));
|
|
94
119
|
line = line.replace(/__([^_]+)__/g, (_, value) => color(value, ansi.bold));
|
|
@@ -96,7 +121,9 @@ function stripMarkdown(text) {
|
|
|
96
121
|
line = line.replace(/(^|[^\w])_([^_\n]+)_/g, "$1$2");
|
|
97
122
|
line = line.replace(/`([^`]+)`/g, (_, value) => color(value, ansi.yellow));
|
|
98
123
|
line = line.replace(/^(\s*)[-*+]\s+/, "$1- ");
|
|
99
|
-
line = line.replace(/^\s*>\s
|
|
124
|
+
line = line.replace(/^\s*>\s?(.+)$/, (_, value) => color(`| ${value}`, ansi.dim));
|
|
125
|
+
} else {
|
|
126
|
+
line = color(` ${line}`, ansi.yellow);
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
rendered.push(line);
|
|
@@ -105,10 +132,15 @@ function stripMarkdown(text) {
|
|
|
105
132
|
return rendered.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
106
133
|
}
|
|
107
134
|
|
|
108
|
-
function
|
|
135
|
+
function rolePrefix(name, bgCode) {
|
|
136
|
+
return `${label(name, bgCode)} ${color("|", bgCode)} `;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function printWrapped(prefix, text, { stripCode = "" } = {}) {
|
|
109
140
|
const rendered = stripMarkdown(text);
|
|
110
141
|
const lines = rendered.split("\n");
|
|
111
|
-
const
|
|
142
|
+
const visible = stripAnsi(prefix).length;
|
|
143
|
+
const gutter = `${" ".repeat(Math.max(visible - 2, 0))}${stripCode ? color("|", stripCode) : "|"} `;
|
|
112
144
|
console.log(`${prefix}${lines[0] || ""}`);
|
|
113
145
|
for (const line of lines.slice(1)) {
|
|
114
146
|
console.log(`${gutter}${line}`);
|
|
@@ -116,7 +148,7 @@ function printWrapped(prefix, text) {
|
|
|
116
148
|
}
|
|
117
149
|
|
|
118
150
|
function printAgentMessage(text) {
|
|
119
|
-
printWrapped(
|
|
151
|
+
printWrapped(rolePrefix("aginti>", ansi.agentBg), text, { stripCode: ansi.agentBg });
|
|
120
152
|
}
|
|
121
153
|
|
|
122
154
|
function printSystemLine(text) {
|
|
@@ -197,6 +229,113 @@ function printHelp() {
|
|
|
197
229
|
);
|
|
198
230
|
}
|
|
199
231
|
|
|
232
|
+
function renderPromptBuffer(buffer, previousLineCount = 0) {
|
|
233
|
+
for (let index = 0; index < previousLineCount; index += 1) {
|
|
234
|
+
output.write(`\r${ansi.clearLine}`);
|
|
235
|
+
if (index < previousLineCount - 1) output.write("\x1b[1A");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const lines = String(buffer || "").split("\n");
|
|
239
|
+
const suggestions = commandSuggestions(lines[0] || "");
|
|
240
|
+
const rendered = [];
|
|
241
|
+
rendered.push(`${userPrompt().replace(/^\n/, "")}${lines[0] || ""}`);
|
|
242
|
+
for (const line of lines.slice(1)) {
|
|
243
|
+
rendered.push(`${promptGutter()}${line}`);
|
|
244
|
+
}
|
|
245
|
+
if (suggestions.length > 0) {
|
|
246
|
+
rendered.push(`${promptGutter()}${color(`suggest: ${suggestions.join(" ")}`, ansi.dim)}`);
|
|
247
|
+
}
|
|
248
|
+
output.write(rendered.join("\n"));
|
|
249
|
+
return rendered.length;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function createAbortError(message = "Aborted with Ctrl+C") {
|
|
253
|
+
const error = new Error(message);
|
|
254
|
+
error.code = "ABORT_ERR";
|
|
255
|
+
error.name = "AbortError";
|
|
256
|
+
return error;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readTtyPrompt() {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
emitKeypressEvents(input);
|
|
262
|
+
const wasRaw = Boolean(input.isRaw);
|
|
263
|
+
let buffer = "";
|
|
264
|
+
let renderedLines = 0;
|
|
265
|
+
|
|
266
|
+
const cleanup = () => {
|
|
267
|
+
input.off("keypress", handler);
|
|
268
|
+
if (typeof input.setRawMode === "function") input.setRawMode(wasRaw);
|
|
269
|
+
input.pause();
|
|
270
|
+
output.write(ansi.cursorShow);
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const redraw = () => {
|
|
274
|
+
renderedLines = renderPromptBuffer(buffer, renderedLines);
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const submit = () => {
|
|
278
|
+
cleanup();
|
|
279
|
+
output.write("\n");
|
|
280
|
+
resolve(buffer);
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const handler = (str = "", key = {}) => {
|
|
284
|
+
if (key.ctrl && key.name === "c") {
|
|
285
|
+
cleanup();
|
|
286
|
+
output.write("\n");
|
|
287
|
+
reject(createAbortError());
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if ((key.ctrl && key.name === "j") || (key.sequence === "\n" && key.name !== "return" && key.name !== "enter")) {
|
|
291
|
+
buffer += "\n";
|
|
292
|
+
redraw();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (key.name === "return" || key.name === "enter" || key.sequence === "\r" || str === "\r") {
|
|
296
|
+
submit();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (key.name === "backspace") {
|
|
300
|
+
buffer = buffer.slice(0, -1);
|
|
301
|
+
redraw();
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (key.name === "tab") {
|
|
305
|
+
const suggestions = commandSuggestions(buffer.split("\n")[0] || "");
|
|
306
|
+
if (suggestions.length === 1) {
|
|
307
|
+
buffer = suggestions[0];
|
|
308
|
+
}
|
|
309
|
+
redraw();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (key.name === "escape") {
|
|
313
|
+
buffer = "";
|
|
314
|
+
redraw();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (key.ctrl || key.meta) return;
|
|
318
|
+
if (str && !key.sequence?.startsWith("\x1b")) {
|
|
319
|
+
buffer += str;
|
|
320
|
+
redraw();
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
input.resume();
|
|
325
|
+
input.setRawMode(true);
|
|
326
|
+
output.write(ansi.cursorHide);
|
|
327
|
+
input.on("keypress", handler);
|
|
328
|
+
redraw();
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function readPromptAnswer(rl) {
|
|
333
|
+
if (input.isTTY && output.isTTY && typeof input.setRawMode === "function") {
|
|
334
|
+
return readTtyPrompt();
|
|
335
|
+
}
|
|
336
|
+
return rl.question(userPrompt());
|
|
337
|
+
}
|
|
338
|
+
|
|
200
339
|
function printStatus(state) {
|
|
201
340
|
printSystemLine(`project=${process.cwd()}`);
|
|
202
341
|
printSystemLine(`cwd=${state.commandCwd || process.cwd()}`);
|
|
@@ -237,6 +376,7 @@ function attachRunInterrupts(controller) {
|
|
|
237
376
|
|
|
238
377
|
emitKeypressEvents(input);
|
|
239
378
|
const wasRaw = Boolean(input.isRaw);
|
|
379
|
+
input.resume();
|
|
240
380
|
input.setRawMode(true);
|
|
241
381
|
const handler = (_str, key = {}) => {
|
|
242
382
|
const isEscape = key.name === "escape";
|
|
@@ -564,12 +704,15 @@ async function runPrompt(prompt, state, packageDir) {
|
|
|
564
704
|
|
|
565
705
|
export async function startInteractiveCli(args = {}, { packageDir, packageVersion } = {}) {
|
|
566
706
|
const state = createState(args);
|
|
567
|
-
const rl =
|
|
568
|
-
input
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
707
|
+
const rl =
|
|
708
|
+
input.isTTY && output.isTTY
|
|
709
|
+
? null
|
|
710
|
+
: readline.createInterface({
|
|
711
|
+
input,
|
|
712
|
+
output,
|
|
713
|
+
terminal: false,
|
|
714
|
+
completer: commandCompleter,
|
|
715
|
+
});
|
|
573
716
|
|
|
574
717
|
await renderLaunchHeader(packageVersion);
|
|
575
718
|
printSystemLine(`Project: ${process.cwd()}`);
|
|
@@ -581,7 +724,7 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
|
|
|
581
724
|
while (true) {
|
|
582
725
|
let answer = "";
|
|
583
726
|
try {
|
|
584
|
-
answer = await rl
|
|
727
|
+
answer = await readPromptAnswer(rl);
|
|
585
728
|
} catch (error) {
|
|
586
729
|
if (error?.code === "ERR_USE_AFTER_CLOSE") break;
|
|
587
730
|
if (isAbortError(error)) {
|
|
@@ -609,6 +752,6 @@ export async function startInteractiveCli(args = {}, { packageDir, packageVersio
|
|
|
609
752
|
}
|
|
610
753
|
}
|
|
611
754
|
} finally {
|
|
612
|
-
rl
|
|
755
|
+
rl?.close();
|
|
613
756
|
}
|
|
614
757
|
}
|
package/src/model-client.js
CHANGED
|
@@ -81,6 +81,21 @@ function mockWorkspaceToolForGoal(goal = "") {
|
|
|
81
81
|
const text = String(goal).toLowerCase();
|
|
82
82
|
const targetPath = mockPathForGoal(goal);
|
|
83
83
|
if (/patch|replace|edit/.test(text)) {
|
|
84
|
+
if (/multi|codex|unified|several|multiple/i.test(text)) {
|
|
85
|
+
return mockToolCall("apply_patch", {
|
|
86
|
+
patch: [
|
|
87
|
+
"*** Begin Patch",
|
|
88
|
+
"*** Update File: patch-target.txt",
|
|
89
|
+
"@@",
|
|
90
|
+
"-old",
|
|
91
|
+
"+new",
|
|
92
|
+
"*** Add File: notes/patch-note.md",
|
|
93
|
+
"+Created by AgInTiFlow mock mode.",
|
|
94
|
+
"+Goal: multi-file patch smoke.",
|
|
95
|
+
"*** End Patch",
|
|
96
|
+
].join("\n"),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
84
99
|
return mockToolCall("apply_patch", {
|
|
85
100
|
path: targetPath,
|
|
86
101
|
search: "old",
|
|
@@ -166,7 +181,7 @@ export async function createPlan(client, config, state) {
|
|
|
166
181
|
? `Shell tool is enabled in ${config.commandCwd}. In Docker, this path is mounted as /workspace with persistent /aginti-env and /aginti-cache mounts. Use relative paths or /workspace paths, not absolute host temp paths. Sandbox mode: ${config.sandboxMode}. Package install policy: ${config.packageInstallPolicy}. For npm/pip/conda/venv setup, explain the need and wait for approval unless policy is allow.`
|
|
167
182
|
: "",
|
|
168
183
|
config.allowFileTools
|
|
169
|
-
? `Workspace file tools are enabled in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, preview_workspace. Keep all paths workspace-relative, for example plot_fx.svg or docs/report.tex, and avoid secrets. For generated local HTML/SVG/PDF/static sites, plan to use open_workspace_file or preview_workspace rather than starting a localhost server inside Docker.`
|
|
184
|
+
? `Workspace file tools are enabled in ${config.commandCwd}: list_files, read_file, search_files, write_file, apply_patch, open_workspace_file, preview_workspace. apply_patch supports exact single-file replacements and Codex-style/unified multi-file patches; prefer it for edits after reading relevant context. Keep all paths workspace-relative, for example plot_fx.svg or docs/report.tex, and avoid secrets. For generated local HTML/SVG/PDF/static sites, plan to use open_workspace_file or preview_workspace rather than starting a localhost server inside Docker.`
|
|
170
185
|
: "",
|
|
171
186
|
config.allowWrapperTools
|
|
172
187
|
? `Agent wrappers are enabled. Use the selected wrapper only: ${normalizeWrapperName(config.preferredWrapper)}. Status: ${wrapperStatusText()}.`
|
|
@@ -456,16 +471,21 @@ export async function requestNextStep(client, config, messages) {
|
|
|
456
471
|
function: {
|
|
457
472
|
name: "apply_patch",
|
|
458
473
|
description:
|
|
459
|
-
"Apply
|
|
474
|
+
"Apply deterministic workspace-local code edits. Either provide path/search/replace for one exact replacement, or provide patch with a Codex-style patch envelope (*** Begin Patch / *** Update File / *** Add File / *** Delete File / *** End Patch) or unified diff. Use this after reading/searching relevant files. It supports multi-file add/update/delete patches, blocks secrets/.git/node_modules/outside-workspace paths, preflights hunks before writing, and records before/after hashes plus compact diffs.",
|
|
460
475
|
parameters: {
|
|
461
476
|
type: "object",
|
|
462
477
|
properties: {
|
|
478
|
+
patch: {
|
|
479
|
+
type: "string",
|
|
480
|
+
description:
|
|
481
|
+
"Optional multi-file patch document. Supports Codex-style patch envelope or unified diff. Use workspace-relative paths only.",
|
|
482
|
+
},
|
|
463
483
|
path: { type: "string", description: "Workspace-relative file path." },
|
|
464
|
-
search: { type: "string" },
|
|
465
|
-
replace: { type: "string" },
|
|
484
|
+
search: { type: "string", description: "Exact text to replace when using single-file patch mode." },
|
|
485
|
+
replace: { type: "string", description: "Replacement text when using single-file patch mode." },
|
|
466
486
|
expectedReplacements: { type: "integer" },
|
|
487
|
+
baseHash: { type: "string", description: "Optional sha256 from read_file; rejects if file changed before patching." },
|
|
467
488
|
},
|
|
468
|
-
required: ["path", "search", "replace"],
|
|
469
489
|
additionalProperties: false,
|
|
470
490
|
},
|
|
471
491
|
},
|
|
@@ -538,7 +558,13 @@ export async function requestNextStep(client, config, messages) {
|
|
|
538
558
|
toolPayload.error,
|
|
539
559
|
toolPayload.reason,
|
|
540
560
|
toolPayload.path ? `Path: ${toolPayload.path}` : "",
|
|
541
|
-
|
|
561
|
+
Array.isArray(toolPayload.changes)
|
|
562
|
+
? toolPayload.changes
|
|
563
|
+
.map((change) => [change.path ? `Path: ${change.path}` : "", change.diff ? `Diff:\n${change.diff}` : ""].filter(Boolean).join("\n"))
|
|
564
|
+
.filter(Boolean)
|
|
565
|
+
.join("\n\n")
|
|
566
|
+
: "",
|
|
567
|
+
!Array.isArray(toolPayload.changes) && toolPayload.change?.diff ? `Diff:\n${toolPayload.change.diff}` : "",
|
|
542
568
|
]
|
|
543
569
|
.filter(Boolean)
|
|
544
570
|
.join("\n");
|
package/src/model-routing.js
CHANGED
package/src/workspace-tools.js
CHANGED
|
@@ -8,6 +8,7 @@ export const WORKSPACE_WRITE_TOOL_NAMES = ["write_file", "apply_patch"];
|
|
|
8
8
|
|
|
9
9
|
const MAX_READ_BYTES = 220_000;
|
|
10
10
|
const MAX_WRITE_BYTES = 220_000;
|
|
11
|
+
const MAX_PATCH_BYTES = 260_000;
|
|
11
12
|
const MAX_LIST_ENTRIES = 360;
|
|
12
13
|
const MAX_SEARCH_RESULTS = 80;
|
|
13
14
|
const DEFAULT_MAX_DEPTH = 4;
|
|
@@ -52,6 +53,23 @@ function compactPreview(value, limit = 180) {
|
|
|
52
53
|
return text.length <= limit ? text : `${text.slice(0, limit)}...`;
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
function countOccurrences(text, search) {
|
|
57
|
+
if (!search) return 0;
|
|
58
|
+
let count = 0;
|
|
59
|
+
let index = 0;
|
|
60
|
+
while ((index = text.indexOf(search, index)) !== -1) {
|
|
61
|
+
count += 1;
|
|
62
|
+
index += search.length;
|
|
63
|
+
}
|
|
64
|
+
return count;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function replaceOnce(text, search, replace) {
|
|
68
|
+
const index = text.indexOf(search);
|
|
69
|
+
if (index === -1) return text;
|
|
70
|
+
return `${text.slice(0, index)}${replace}${text.slice(index + search.length)}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
55
73
|
function sanitizePathInput(inputPath) {
|
|
56
74
|
const value = String(inputPath || ".").trim();
|
|
57
75
|
if (!value) return ".";
|
|
@@ -130,6 +148,16 @@ export function checkWorkspaceToolUse(toolName, args, config) {
|
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
try {
|
|
151
|
+
if (toolName === "apply_patch" && typeof args.patch === "string" && args.patch.trim()) {
|
|
152
|
+
for (const operation of parsePatchDocument(args.patch)) {
|
|
153
|
+
for (const candidate of [operation.path, operation.newPath].filter(Boolean)) {
|
|
154
|
+
const target = resolveWorkspacePath(config, candidate);
|
|
155
|
+
const policy = pathPolicy(toolName, target.relativePath);
|
|
156
|
+
if (!policy.allowed) return policy;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { allowed: true };
|
|
160
|
+
}
|
|
133
161
|
const target = resolveWorkspacePath(config, args.path || ".");
|
|
134
162
|
return pathPolicy(toolName, target.relativePath);
|
|
135
163
|
} catch (error) {
|
|
@@ -150,6 +178,7 @@ export function summarizeWorkspaceTools(config) {
|
|
|
150
178
|
limits: {
|
|
151
179
|
maxReadBytes: MAX_READ_BYTES,
|
|
152
180
|
maxWriteBytes: MAX_WRITE_BYTES,
|
|
181
|
+
maxPatchBytes: MAX_PATCH_BYTES,
|
|
153
182
|
maxListEntries: MAX_LIST_ENTRIES,
|
|
154
183
|
maxSearchResults: MAX_SEARCH_RESULTS,
|
|
155
184
|
},
|
|
@@ -333,7 +362,8 @@ async function writeChange(target, nextContent, action, details = {}) {
|
|
|
333
362
|
beforeText = before.toString("utf8");
|
|
334
363
|
beforeHash = hashBuffer(before);
|
|
335
364
|
beforeBytes = before.length;
|
|
336
|
-
} catch {
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (error?.code !== "ENOENT") throw error;
|
|
337
367
|
// Missing files are valid for create/overwrite actions.
|
|
338
368
|
}
|
|
339
369
|
|
|
@@ -357,6 +387,27 @@ async function writeChange(target, nextContent, action, details = {}) {
|
|
|
357
387
|
};
|
|
358
388
|
}
|
|
359
389
|
|
|
390
|
+
async function deleteChange(target, action = "delete_file", details = {}) {
|
|
391
|
+
const before = await fs.readFile(target.absolutePath);
|
|
392
|
+
if (before.length > MAX_READ_BYTES) throw new Error(`Existing file is too large to delete safely: ${target.relativePath}`);
|
|
393
|
+
if (before.includes(0)) throw new Error(`Binary files cannot be deleted through this tool: ${target.relativePath}`);
|
|
394
|
+
const beforeText = before.toString("utf8");
|
|
395
|
+
const beforeHash = hashBuffer(before);
|
|
396
|
+
await fs.unlink(target.absolutePath);
|
|
397
|
+
return {
|
|
398
|
+
ok: true,
|
|
399
|
+
action,
|
|
400
|
+
path: target.relativePath,
|
|
401
|
+
beforeHash,
|
|
402
|
+
afterHash: null,
|
|
403
|
+
beforeBytes: before.length,
|
|
404
|
+
afterBytes: 0,
|
|
405
|
+
deleted: true,
|
|
406
|
+
diff: compactDiff(target.relativePath, beforeText, ""),
|
|
407
|
+
...details,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
360
411
|
async function writeFile(config, args) {
|
|
361
412
|
const target = resolveWorkspacePath(config, args.path);
|
|
362
413
|
const mode = args.mode === "overwrite" ? "overwrite" : "create";
|
|
@@ -380,8 +431,15 @@ async function writeFile(config, args) {
|
|
|
380
431
|
}
|
|
381
432
|
|
|
382
433
|
async function applyPatch(config, args) {
|
|
434
|
+
if (typeof args.patch === "string" && args.patch.trim()) {
|
|
435
|
+
return applyPatchDocument(config, args);
|
|
436
|
+
}
|
|
437
|
+
|
|
383
438
|
const target = resolveWorkspacePath(config, args.path);
|
|
384
|
-
const { content: beforeText } = await readTextFile(target);
|
|
439
|
+
const { content: beforeText, hash } = await readTextFile(target);
|
|
440
|
+
if (args.baseHash && args.baseHash !== hash) {
|
|
441
|
+
throw new Error(`Base hash mismatch for ${target.relativePath}; read the file again before patching.`);
|
|
442
|
+
}
|
|
385
443
|
const search = String(args.search || "");
|
|
386
444
|
const replace = String(args.replace ?? "");
|
|
387
445
|
if (!search) throw new Error("Patch search text is required.");
|
|
@@ -409,6 +467,247 @@ async function applyPatch(config, args) {
|
|
|
409
467
|
};
|
|
410
468
|
}
|
|
411
469
|
|
|
470
|
+
function ensurePatchSize(patch) {
|
|
471
|
+
const bytes = Buffer.byteLength(String(patch || ""), "utf8");
|
|
472
|
+
if (bytes > MAX_PATCH_BYTES) throw new Error(`Patch is too large for safe workspace tools (${bytes} bytes).`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function cleanPatchPath(rawPath) {
|
|
476
|
+
let value = String(rawPath || "").trim();
|
|
477
|
+
value = value.replace(/^"|"$/g, "");
|
|
478
|
+
if (!value || value === "/dev/null") return "";
|
|
479
|
+
value = value.replace(/^\.[/\\]/, "");
|
|
480
|
+
value = value.replace(/^[ab]\//, "");
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function flushPatchHunk(hunks, oldLines, newLines) {
|
|
485
|
+
if (!oldLines.length && !newLines.length) return;
|
|
486
|
+
hunks.push({
|
|
487
|
+
search: oldLines.join("\n"),
|
|
488
|
+
replace: newLines.join("\n"),
|
|
489
|
+
});
|
|
490
|
+
oldLines.length = 0;
|
|
491
|
+
newLines.length = 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function parsePrefixedHunks(lines) {
|
|
495
|
+
const hunks = [];
|
|
496
|
+
const oldLines = [];
|
|
497
|
+
const newLines = [];
|
|
498
|
+
|
|
499
|
+
for (const line of lines) {
|
|
500
|
+
if (line.startsWith("@@")) {
|
|
501
|
+
flushPatchHunk(hunks, oldLines, newLines);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (line.startsWith("\\ No newline")) continue;
|
|
505
|
+
const marker = line[0];
|
|
506
|
+
const content = line.slice(1);
|
|
507
|
+
if (marker === " ") {
|
|
508
|
+
oldLines.push(content);
|
|
509
|
+
newLines.push(content);
|
|
510
|
+
} else if (marker === "-") {
|
|
511
|
+
oldLines.push(content);
|
|
512
|
+
} else if (marker === "+") {
|
|
513
|
+
newLines.push(content);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
flushPatchHunk(hunks, oldLines, newLines);
|
|
518
|
+
return hunks.filter((hunk) => hunk.search !== hunk.replace);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function parseCodexPatchDocument(patch) {
|
|
522
|
+
const lines = String(patch || "").replace(/\r\n?/g, "\n").split("\n");
|
|
523
|
+
const operations = [];
|
|
524
|
+
let index = 0;
|
|
525
|
+
|
|
526
|
+
while (index < lines.length) {
|
|
527
|
+
const line = lines[index];
|
|
528
|
+
const add = line.match(/^\*\*\* Add File:\s+(.+)$/);
|
|
529
|
+
const update = line.match(/^\*\*\* Update File:\s+(.+)$/);
|
|
530
|
+
const remove = line.match(/^\*\*\* Delete File:\s+(.+)$/);
|
|
531
|
+
|
|
532
|
+
if (add) {
|
|
533
|
+
const filePath = cleanPatchPath(add[1]);
|
|
534
|
+
const contentLines = [];
|
|
535
|
+
index += 1;
|
|
536
|
+
while (index < lines.length && !lines[index].startsWith("*** ")) {
|
|
537
|
+
contentLines.push(lines[index].startsWith("+") ? lines[index].slice(1) : lines[index]);
|
|
538
|
+
index += 1;
|
|
539
|
+
}
|
|
540
|
+
operations.push({ type: "add", path: filePath, content: contentLines.join("\n") });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (remove) {
|
|
545
|
+
operations.push({ type: "delete", path: cleanPatchPath(remove[1]) });
|
|
546
|
+
index += 1;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (update) {
|
|
551
|
+
const filePath = cleanPatchPath(update[1]);
|
|
552
|
+
const hunkLines = [];
|
|
553
|
+
let moveTo = "";
|
|
554
|
+
index += 1;
|
|
555
|
+
while (index < lines.length) {
|
|
556
|
+
const move = lines[index].match(/^\*\*\* Move to:\s+(.+)$/);
|
|
557
|
+
if (move) {
|
|
558
|
+
moveTo = cleanPatchPath(move[1]);
|
|
559
|
+
index += 1;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
if (lines[index].startsWith("*** ")) break;
|
|
563
|
+
hunkLines.push(lines[index]);
|
|
564
|
+
index += 1;
|
|
565
|
+
}
|
|
566
|
+
operations.push({ type: "update", path: filePath, newPath: moveTo, hunks: parsePrefixedHunks(hunkLines) });
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
index += 1;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return operations;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function parseUnifiedPatchDocument(patch) {
|
|
577
|
+
const lines = String(patch || "").replace(/\r\n?/g, "\n").split("\n");
|
|
578
|
+
const operations = [];
|
|
579
|
+
let index = 0;
|
|
580
|
+
|
|
581
|
+
while (index < lines.length) {
|
|
582
|
+
const oldHeader = lines[index]?.match(/^---\s+(.+)$/);
|
|
583
|
+
const newHeader = lines[index + 1]?.match(/^\+\+\+\s+(.+)$/);
|
|
584
|
+
if (!oldHeader || !newHeader) {
|
|
585
|
+
index += 1;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const oldPath = cleanPatchPath(oldHeader[1].split(/\s+/)[0]);
|
|
590
|
+
const newPath = cleanPatchPath(newHeader[1].split(/\s+/)[0]);
|
|
591
|
+
const hunkLines = [];
|
|
592
|
+
index += 2;
|
|
593
|
+
while (index < lines.length && !/^---\s+/.test(lines[index])) {
|
|
594
|
+
hunkLines.push(lines[index]);
|
|
595
|
+
index += 1;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!oldPath && newPath) {
|
|
599
|
+
const content = hunkLines
|
|
600
|
+
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
|
601
|
+
.map((line) => line.slice(1))
|
|
602
|
+
.join("\n");
|
|
603
|
+
operations.push({ type: "add", path: newPath, content });
|
|
604
|
+
} else if (oldPath && !newPath) {
|
|
605
|
+
operations.push({ type: "delete", path: oldPath });
|
|
606
|
+
} else {
|
|
607
|
+
operations.push({ type: "update", path: oldPath, newPath: newPath && newPath !== oldPath ? newPath : "", hunks: parsePrefixedHunks(hunkLines) });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return operations;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function parsePatchDocument(patch) {
|
|
615
|
+
const text = String(patch || "").trim();
|
|
616
|
+
if (!text) throw new Error("Patch content is required.");
|
|
617
|
+
ensurePatchSize(text);
|
|
618
|
+
const operations = text.includes("*** Begin Patch") ? parseCodexPatchDocument(text) : parseUnifiedPatchDocument(text);
|
|
619
|
+
if (!operations.length) throw new Error("Patch did not contain any supported file operations.");
|
|
620
|
+
return operations;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function applyPatchDocument(config, args) {
|
|
624
|
+
const operations = parsePatchDocument(args.patch);
|
|
625
|
+
const planned = [];
|
|
626
|
+
const seenPaths = new Set();
|
|
627
|
+
|
|
628
|
+
for (const operation of operations) {
|
|
629
|
+
if (!operation.path) throw new Error("Patch operation is missing a file path.");
|
|
630
|
+
if (seenPaths.has(operation.path)) throw new Error(`Patch contains duplicate file operations for ${operation.path}.`);
|
|
631
|
+
seenPaths.add(operation.path);
|
|
632
|
+
|
|
633
|
+
const target = resolveWorkspacePath(config, operation.path);
|
|
634
|
+
const policy = pathPolicy("apply_patch", target.relativePath);
|
|
635
|
+
if (!policy.allowed) throw new Error(policy.reason);
|
|
636
|
+
const newTarget = operation.newPath ? resolveWorkspacePath(config, operation.newPath) : null;
|
|
637
|
+
if (newTarget) {
|
|
638
|
+
const newPolicy = pathPolicy("apply_patch", newTarget.relativePath);
|
|
639
|
+
if (!newPolicy.allowed) throw new Error(newPolicy.reason);
|
|
640
|
+
const newTargetExists = await fs
|
|
641
|
+
.stat(newTarget.absolutePath)
|
|
642
|
+
.then(() => true)
|
|
643
|
+
.catch((error) => {
|
|
644
|
+
if (error?.code === "ENOENT") return false;
|
|
645
|
+
throw error;
|
|
646
|
+
});
|
|
647
|
+
if (newTargetExists) throw new Error(`Patch cannot move over an existing file: ${newTarget.relativePath}`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (operation.type === "add") {
|
|
651
|
+
const exists = await fs
|
|
652
|
+
.stat(target.absolutePath)
|
|
653
|
+
.then(() => true)
|
|
654
|
+
.catch((error) => {
|
|
655
|
+
if (error?.code === "ENOENT") return false;
|
|
656
|
+
throw error;
|
|
657
|
+
});
|
|
658
|
+
if (exists) throw new Error(`Patch cannot add an existing file: ${target.relativePath}`);
|
|
659
|
+
planned.push({ operation, target, afterText: String(operation.content ?? "") });
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (operation.type === "delete") {
|
|
664
|
+
await readTextFile(target);
|
|
665
|
+
planned.push({ operation, target, delete: true });
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const { content: beforeText } = await readTextFile(target);
|
|
670
|
+
let afterText = beforeText;
|
|
671
|
+
for (const hunk of operation.hunks || []) {
|
|
672
|
+
if (!hunk.search) throw new Error(`Patch hunk for ${target.relativePath} has no removable/context lines.`);
|
|
673
|
+
const matches = countOccurrences(afterText, hunk.search);
|
|
674
|
+
if (matches !== 1) {
|
|
675
|
+
throw new Error(`Patch hunk for ${target.relativePath} expected exactly 1 match, found ${matches}. Add more context or read the file again.`);
|
|
676
|
+
}
|
|
677
|
+
afterText = replaceOnce(afterText, hunk.search, hunk.replace);
|
|
678
|
+
}
|
|
679
|
+
if (afterText === beforeText && !operation.newPath) throw new Error(`Patch made no changes to ${target.relativePath}.`);
|
|
680
|
+
planned.push({ operation, target, afterText });
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const changes = [];
|
|
684
|
+
for (const item of planned) {
|
|
685
|
+
const { operation, target } = item;
|
|
686
|
+
if (item.delete) {
|
|
687
|
+
changes.push(await deleteChange(target, "apply_patch_delete", { patchFormat: "multi-file" }));
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (operation.newPath) {
|
|
691
|
+
const newTarget = resolveWorkspacePath(config, operation.newPath);
|
|
692
|
+
const policy = pathPolicy("apply_patch", newTarget.relativePath);
|
|
693
|
+
if (!policy.allowed) throw new Error(policy.reason);
|
|
694
|
+
changes.push(await writeChange(newTarget, item.afterText, "apply_patch_move", { fromPath: target.relativePath, patchFormat: "multi-file" }));
|
|
695
|
+
await fs.unlink(target.absolutePath);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
changes.push(await writeChange(target, item.afterText, operation.type === "add" ? "apply_patch_add" : "apply_patch_update", { patchFormat: "multi-file" }));
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
ok: true,
|
|
703
|
+
toolName: "apply_patch",
|
|
704
|
+
path: changes.length === 1 ? changes[0].path : "",
|
|
705
|
+
changes,
|
|
706
|
+
change: changes[0],
|
|
707
|
+
summary: `${changes.length} file change(s) applied`,
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
412
711
|
export async function executeWorkspaceTool(toolName, args, config) {
|
|
413
712
|
const guard = checkWorkspaceToolUse(toolName, args, config);
|
|
414
713
|
if (!guard.allowed) {
|