@cruxy/cli 1.7.0 → 1.8.0
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 +1 -1
- package/dist/agent/loop.js +9 -0
- package/dist/agent/permissions.js +72 -0
- package/dist/agent/session.js +30 -1
- package/dist/approval/classify.js +194 -44
- package/dist/approval/policy.js +142 -5
- package/dist/approval/prompt.js +52 -7
- package/dist/budget/session-budget.js +43 -7
- package/dist/checkpoint/capture.js +9 -12
- package/dist/checkpoint/coverage.js +70 -0
- package/dist/cli/command-catalog.js +5 -0
- package/dist/cli/commands/run.js +6 -1
- package/dist/cli/session-commands.js +59 -0
- package/dist/cli/session-factory.js +4 -0
- package/dist/errors/constructors.js +94 -1
- package/dist/errors/types.js +17 -0
- package/dist/indexing/walker.js +7 -2
- package/dist/limits/index.js +1 -1
- package/dist/limits/reduce.js +46 -4
- package/dist/mcp/bounds.js +21 -3
- package/dist/plan/policy.js +22 -6
- package/dist/render/permissions-view.js +88 -0
- package/dist/render/state.js +22 -3
- package/dist/subagent/orchestrator.js +157 -13
- package/dist/subagent/spawn-tool.js +30 -9
- package/dist/tools/file/apply-patch.js +164 -70
- package/dist/tools/index.js +1 -0
- package/dist/tools/schema-depth.js +67 -0
- package/dist/tui/index.js +1 -0
- package/dist/tui/limits-panel.js +6 -0
- package/dist/tui/panels.js +20 -1
- package/dist/tui/permissions-view.js +30 -0
- package/dist/tui/renderer.js +47 -15
- package/dist/usage/collect.js +3 -0
- package/dist/usage/types.js +10 -0
- package/package.json +2 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { runAgent } from "../agent/loop.js";
|
|
3
|
-
import { CruxyError, ErrorCode, messageOf, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
4
|
+
import { classifyProviderError, CruxyError, ErrorCode, messageOf, sessionBudgetExhausted, subagentDepthExceeded, subagentScopeOverlap, } from "../errors/index.js";
|
|
5
|
+
import { UsageCollector } from "../usage/index.js";
|
|
4
6
|
import { UNRESOLVED_TIER, } from "../budget/index.js";
|
|
5
7
|
import { resolveTaskModel } from "../routing/index.js";
|
|
6
8
|
import { Workspace } from "../workspace/index.js";
|
|
@@ -52,10 +54,12 @@ export class SubagentOrchestrator {
|
|
|
52
54
|
* withheld at the cap, so this throw is the fail-loud backstop.
|
|
53
55
|
*
|
|
54
56
|
* Never rejects on a *child* failure — provider or tool crashes come back as
|
|
55
|
-
* `status: "failed"` for the parent to reason over. The
|
|
56
|
-
* do propagate: the depth cap (above)
|
|
57
|
+
* `status: "failed"` for the parent to reason over. The three exceptions that
|
|
58
|
+
* do propagate: the depth cap (above), `CRUXY_E_APPROVAL_REQUIRED`
|
|
57
59
|
* (non-interactive default-deny must reach the boundary, U.3 — a subagent is
|
|
58
|
-
* not a way to swallow it)
|
|
60
|
+
* not a way to swallow it), and `CRUXY_E_BUDGET_EXHAUSTED` (the weighted pool
|
|
61
|
+
* refused this child — see the catch block for why that one is not a partial
|
|
62
|
+
* result).
|
|
59
63
|
*/
|
|
60
64
|
async spawn(spec, parentDepth, opts = {}) {
|
|
61
65
|
const { deps } = this;
|
|
@@ -110,6 +114,57 @@ export class SubagentOrchestrator {
|
|
|
110
114
|
// parent's messages are never in scope here, and this array dies with the
|
|
111
115
|
// spawn — only the structured result below leaves this function.
|
|
112
116
|
const messages = [{ role: "user", content: spec.task }];
|
|
117
|
+
// Per-request usage for THIS child, collected exactly as the parent's turn
|
|
118
|
+
// collects its own (`agent/session.ts`) — the loop reports one entry per
|
|
119
|
+
// completed request, with the tier that served it. Two things need it and
|
|
120
|
+
// neither can be got from `AgentResult.usage`: the weighted-pool arithmetic
|
|
121
|
+
// is per-tier, and a run that THREW has no `AgentResult` at all while still
|
|
122
|
+
// having spent whatever it spent before it died.
|
|
123
|
+
const usage = new UsageCollector();
|
|
124
|
+
const startedAt = new Date().toISOString();
|
|
125
|
+
try {
|
|
126
|
+
return await this.runChild({
|
|
127
|
+
spec,
|
|
128
|
+
opts,
|
|
129
|
+
messages,
|
|
130
|
+
registry,
|
|
131
|
+
budget,
|
|
132
|
+
ctx,
|
|
133
|
+
artifacts,
|
|
134
|
+
label,
|
|
135
|
+
noun,
|
|
136
|
+
tag,
|
|
137
|
+
usage,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
// CHILD SPEND IS SESSION SPEND (cli#212). `budget.record` had exactly one
|
|
142
|
+
// call site — the parent's own turn — so a fan-out drew on the pool and
|
|
143
|
+
// left the numerator where it found it. The next admission check then
|
|
144
|
+
// divided by an allowance that had already been spent, which made the
|
|
145
|
+
// bound weakest at the moment it mattered most: the second fan-out of a
|
|
146
|
+
// session that had just dispatched three children.
|
|
147
|
+
//
|
|
148
|
+
// In the `finally` because the tokens are spent on every path out of here,
|
|
149
|
+
// including the two that throw. A child that died mid-run, or that a
|
|
150
|
+
// sibling's pool denial cancelled, still drew whatever it drew before it
|
|
151
|
+
// stopped, and the record is what it reported.
|
|
152
|
+
//
|
|
153
|
+
// NOT ALSO WRITTEN TO THE USAGE STORE. Child requests have never appeared
|
|
154
|
+
// in `/usage` and this does not change that — `/budget` now counts them
|
|
155
|
+
// and `/usage` still does not, so the two figures can differ by a
|
|
156
|
+
// fan-out's worth. That gap is real and pre-dates this; closing it means
|
|
157
|
+
// persisting child runs, which is a change to what is on disk and belongs
|
|
158
|
+
// in its own right rather than smuggled in behind an admission fix.
|
|
159
|
+
this.deps.budget?.record(usage.toRecord(randomUUID(), undefined, startedAt));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** One child's run, from dispatch to structured result. Split out only so the
|
|
163
|
+
* usage fold above can be a `finally` over every path this can leave by. */
|
|
164
|
+
async runChild(args) {
|
|
165
|
+
const { deps } = this;
|
|
166
|
+
const { spec, opts, messages, registry, budget, ctx, artifacts } = args;
|
|
167
|
+
const { label, noun, tag, usage } = args;
|
|
113
168
|
let run;
|
|
114
169
|
try {
|
|
115
170
|
run = await runAgent({
|
|
@@ -128,6 +183,7 @@ export class SubagentOrchestrator {
|
|
|
128
183
|
router: deps.router,
|
|
129
184
|
taskClass: spec.taskClass ?? "subagent",
|
|
130
185
|
signal: opts.signal,
|
|
186
|
+
onRequestUsage: (req) => usage.record(req),
|
|
131
187
|
});
|
|
132
188
|
}
|
|
133
189
|
catch (err) {
|
|
@@ -136,6 +192,26 @@ export class SubagentOrchestrator {
|
|
|
136
192
|
deps.renderer?.setPhase(null);
|
|
137
193
|
throw err;
|
|
138
194
|
}
|
|
195
|
+
// THE WEIGHTED POOL REFUSED THIS CHILD (429 `budget_exhausted`), and that
|
|
196
|
+
// is not a per-child outcome (cli#212). The pool is one denominator shared
|
|
197
|
+
// by every sibling, every other surface on the account, and the parent's
|
|
198
|
+
// own next request: nothing that is about to run can succeed, so folding
|
|
199
|
+
// this into a `failed` result would let each sibling walk into its own
|
|
200
|
+
// refusal — N denials for one fact, each after its tokens were spent.
|
|
201
|
+
//
|
|
202
|
+
// Rethrown as the TYPED error rather than the raw one so `code`,
|
|
203
|
+
// `window`, `resetAt` and `miraAvailable` survive to the boundary. The
|
|
204
|
+
// last of those is the only step that unblocks the user now rather than
|
|
205
|
+
// telling them when to come back, and flattening it into a message string
|
|
206
|
+
// is exactly how it used to be lost.
|
|
207
|
+
const denial = poolDenial(err);
|
|
208
|
+
if (denial) {
|
|
209
|
+
if (deps.renderer) {
|
|
210
|
+
deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} stopped — ${denial.title}`);
|
|
211
|
+
}
|
|
212
|
+
deps.renderer?.setPhase(null);
|
|
213
|
+
throw denial;
|
|
214
|
+
}
|
|
139
215
|
if (deps.renderer) {
|
|
140
216
|
deps.renderer.note(`${deps.renderer.theme.glyph.failure} ${noun} failed: ${label}`);
|
|
141
217
|
}
|
|
@@ -146,13 +222,20 @@ export class SubagentOrchestrator {
|
|
|
146
222
|
...artifactsField(artifacts),
|
|
147
223
|
error: `${ErrorCode.SubagentFailed}: ${messageOf(err) ?? "unknown error"}`,
|
|
148
224
|
iterations: 0,
|
|
149
|
-
|
|
225
|
+
// WHAT IT ACTUALLY SPENT, which is not zero and was reported as zero
|
|
226
|
+
// until now. A child that failed on its third request had two requests'
|
|
227
|
+
// tokens deducted from a pool the parent then went on to fan out
|
|
228
|
+
// against; a fabricated 0 put that spend nowhere. When NOTHING reported
|
|
229
|
+
// usage — the request died before the provider said anything — the
|
|
230
|
+
// field is absent, which the parent reads as unknown. Zero is reserved
|
|
231
|
+
// for a child that genuinely drew nothing.
|
|
232
|
+
...usageField(usage),
|
|
150
233
|
};
|
|
151
234
|
}
|
|
152
235
|
deps.renderer?.setPhase(null);
|
|
153
236
|
const result = this.toResult(run, artifacts, label, noun);
|
|
154
237
|
deps.logger.debug(`${noun} ${result.status}: ${result.iterations} turn(s), tokens in/out ` +
|
|
155
|
-
`${result.usage
|
|
238
|
+
`${describeUsage(result.usage)} — ${label}`);
|
|
156
239
|
return result;
|
|
157
240
|
}
|
|
158
241
|
/**
|
|
@@ -168,11 +251,18 @@ export class SubagentOrchestrator {
|
|
|
168
251
|
*
|
|
169
252
|
* Cancellation: children share one {@link AbortController}. A child returning a
|
|
170
253
|
* `failed`/`budget-exceeded` result is a normal PARTIAL outcome — siblings run
|
|
171
|
-
* on. But a *fatal* throw from any child (non-interactive default-deny
|
|
172
|
-
* abort on `opts.signal` (Ctrl-C) aborts the
|
|
173
|
-
* its next turn boundary and its in-flight
|
|
174
|
-
* fan-out leaves no orphan. All children are
|
|
175
|
-
* throw propagates — never a detached,
|
|
254
|
+
* on. But a *fatal* throw from any child (non-interactive default-deny, or a
|
|
255
|
+
* weighted-pool denial) or an abort on `opts.signal` (Ctrl-C) aborts the
|
|
256
|
+
* controller: every sibling stops at its next turn boundary and its in-flight
|
|
257
|
+
* shell child is kill-tree'd, so the fan-out leaves no orphan. All children are
|
|
258
|
+
* awaited to settle before a fatal throw propagates — never a detached,
|
|
259
|
+
* still-running sibling.
|
|
260
|
+
*
|
|
261
|
+
* THE POOL DENIAL IS THE ONE WORTH NAMING (cli#212). `budget_exhausted` is a
|
|
262
|
+
* statement about a denominator every sibling shares, so the first 429 is the
|
|
263
|
+
* whole batch's answer: without the abort, each remaining child walks into its
|
|
264
|
+
* own refusal and the parent gets N reports of one fact — each one arriving
|
|
265
|
+
* after that child had already spent what it spent getting there.
|
|
176
266
|
*/
|
|
177
267
|
async spawnMany(specs, parentDepth, opts = {}) {
|
|
178
268
|
const { maxDepth } = this.deps.config.subagent;
|
|
@@ -230,8 +320,11 @@ export class SubagentOrchestrator {
|
|
|
230
320
|
});
|
|
231
321
|
}
|
|
232
322
|
catch (err) {
|
|
233
|
-
// A fatal throw (non-interactive default-deny
|
|
234
|
-
// fan-out — no sibling is left running —
|
|
323
|
+
// A fatal throw (non-interactive default-deny, or a weighted-pool
|
|
324
|
+
// denial) cancels the whole fan-out — no sibling is left running —
|
|
325
|
+
// then propagates. The abort is what makes the FIRST 429 the
|
|
326
|
+
// batch's answer: siblings stop at their next turn boundary rather
|
|
327
|
+
// than each spending its way into the same refusal.
|
|
235
328
|
controller.abort();
|
|
236
329
|
throw err;
|
|
237
330
|
}
|
|
@@ -396,6 +489,57 @@ function taskLabel(task) {
|
|
|
396
489
|
function isWriter(spec) {
|
|
397
490
|
return (spec.tools ?? []).some((t) => SUBAGENT_WRITE_TOOLS.has(t));
|
|
398
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* The typed pool denial behind an error, or `null` if it is not one.
|
|
494
|
+
*
|
|
495
|
+
* Two shapes reach here and both are the same fact: the raw SDK
|
|
496
|
+
* `BudgetExhaustedError` from this child's own request, and — when a child that
|
|
497
|
+
* itself spawned re-throws — the `CruxyError` a nested `spawn` already
|
|
498
|
+
* converted. Mapping goes through `classifyProviderError` so there is still ONE
|
|
499
|
+
* place that knows which SDK class means what.
|
|
500
|
+
*/
|
|
501
|
+
function poolDenial(err) {
|
|
502
|
+
if (CruxyError.is(err)) {
|
|
503
|
+
return err.code === ErrorCode.BudgetExhausted ? err : null;
|
|
504
|
+
}
|
|
505
|
+
const typed = classifyProviderError(err);
|
|
506
|
+
return typed?.code === ErrorCode.BudgetExhausted ? typed : null;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* The tokens a child is KNOWN to have spent, or `undefined` when no request
|
|
510
|
+
* reported any.
|
|
511
|
+
*
|
|
512
|
+
* The loop fires `onRequestUsage` only for a request that COMPLETED, and when
|
|
513
|
+
* it does the provider's counts arrive as a pair or not at all — so an entry
|
|
514
|
+
* carries both figures or neither, and summing the ones that have them is a
|
|
515
|
+
* real measurement rather than a partial one. No entry at all is the genuinely
|
|
516
|
+
* unknown case: the request died before the provider reported anything, which
|
|
517
|
+
* does NOT mean the gateway metered nothing for it.
|
|
518
|
+
*/
|
|
519
|
+
function usageField(collected) {
|
|
520
|
+
const entries = collected.toRecord("", undefined, "").entries;
|
|
521
|
+
const total = sumReported(entries);
|
|
522
|
+
return total ? { usage: total } : {};
|
|
523
|
+
}
|
|
524
|
+
function sumReported(entries) {
|
|
525
|
+
let input = 0;
|
|
526
|
+
let output = 0;
|
|
527
|
+
let reported = false;
|
|
528
|
+
for (const e of entries) {
|
|
529
|
+
if (e.inputTokens === undefined && e.outputTokens === undefined)
|
|
530
|
+
continue;
|
|
531
|
+
input += e.inputTokens ?? 0;
|
|
532
|
+
output += e.outputTokens ?? 0;
|
|
533
|
+
reported = true;
|
|
534
|
+
}
|
|
535
|
+
return reported ? { input_tokens: input, output_tokens: output } : undefined;
|
|
536
|
+
}
|
|
537
|
+
/** `in/out`, or the word for an absent figure — never a stand-in `0/0`. */
|
|
538
|
+
function describeUsage(usage) {
|
|
539
|
+
return usage
|
|
540
|
+
? `${usage.input_tokens}/${usage.output_tokens}`
|
|
541
|
+
: "unknown (nothing reported)";
|
|
542
|
+
}
|
|
399
543
|
/** The honest result for a child cancelled before it could produce anything —
|
|
400
544
|
* used when a fatal sibling / Ctrl-C fired before this slot even dispatched. */
|
|
401
545
|
function cancelledResult() {
|
|
@@ -8,6 +8,26 @@ import { SPAWN_SUBAGENT_TOOL_NAME, SPAWN_SUBAGENTS_TOOL_NAME, } from "./registry
|
|
|
8
8
|
* on structurally encodes how much deeper nesting may go: at the configured
|
|
9
9
|
* cap the tool simply isn't registered.
|
|
10
10
|
*/
|
|
11
|
+
/**
|
|
12
|
+
* Errors a spawn tool must NOT convert into a tool result.
|
|
13
|
+
*
|
|
14
|
+
* A tool error is an invitation to the model to try again differently, so this
|
|
15
|
+
* set is exactly the errors for which trying again differently is not a thing
|
|
16
|
+
* the model can do:
|
|
17
|
+
*
|
|
18
|
+
* - `APPROVAL_REQUIRED` — non-interactive default-deny (U.3). There is no
|
|
19
|
+
* variation of the call that would be approved; the boundary reports it.
|
|
20
|
+
* - `BUDGET_EXHAUSTED` — the weighted pool refused (cli#212). Retrying, in any
|
|
21
|
+
* shape, is a request to a gate that will refuse again, and the parent's own
|
|
22
|
+
* next request would hit the same 429 regardless. Propagating it means the
|
|
23
|
+
* user reads what the gateway actually said — which window, when it recovers,
|
|
24
|
+
* and whether mira is still open — instead of the model narrating a tool
|
|
25
|
+
* failure and reaching for another spawn.
|
|
26
|
+
*/
|
|
27
|
+
const PROPAGATE = new Set([
|
|
28
|
+
ErrorCode.ApprovalRequired,
|
|
29
|
+
ErrorCode.BudgetExhausted,
|
|
30
|
+
]);
|
|
11
31
|
const parameters = z.object({
|
|
12
32
|
task: z
|
|
13
33
|
.string()
|
|
@@ -42,10 +62,12 @@ function resultPayload(result) {
|
|
|
42
62
|
...(result.artifacts ? { artifacts: result.artifacts } : {}),
|
|
43
63
|
...(result.error ? { error: result.error } : {}),
|
|
44
64
|
iterations: result.iterations,
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
65
|
+
// `"unknown"` rather than an omitted field or a `0/0` pair: the parent model
|
|
66
|
+
// reasons about what a child cost, and a missing key reads as nothing spent
|
|
67
|
+
// to anything skimming the payload. See `SubagentResult.usage`.
|
|
68
|
+
tokens: result.usage
|
|
69
|
+
? { input: result.usage.input_tokens, output: result.usage.output_tokens }
|
|
70
|
+
: "unknown",
|
|
49
71
|
};
|
|
50
72
|
}
|
|
51
73
|
/** The compact wire shape fed back to the parent model. */
|
|
@@ -84,10 +106,10 @@ export function makeSpawnSubagentTool(orchestrator, depth) {
|
|
|
84
106
|
}
|
|
85
107
|
catch (err) {
|
|
86
108
|
// Non-interactive default-deny propagates to the boundary (U.3) —
|
|
87
|
-
// same behavior as every other gated tool.
|
|
88
|
-
|
|
109
|
+
// same behavior as every other gated tool. So does a weighted-pool
|
|
110
|
+
// denial (cli#212): see `PROPAGATE`.
|
|
111
|
+
if (CruxyError.is(err) && PROPAGATE.has(err.code))
|
|
89
112
|
throw err;
|
|
90
|
-
}
|
|
91
113
|
// Depth-exceed and scope violations are the model's to correct: feed
|
|
92
114
|
// the coded, actionable message back as a tool error.
|
|
93
115
|
return { ok: false, error: toolErrorMessage(err) };
|
|
@@ -171,9 +193,8 @@ export function makeSpawnSubagentsTool(orchestrator, depth) {
|
|
|
171
193
|
}
|
|
172
194
|
catch (err) {
|
|
173
195
|
// Non-interactive default-deny propagates to the boundary (U.3).
|
|
174
|
-
if (CruxyError.is(err) && err.code
|
|
196
|
+
if (CruxyError.is(err) && PROPAGATE.has(err.code))
|
|
175
197
|
throw err;
|
|
176
|
-
}
|
|
177
198
|
// Scope overlap / depth-exceed are the model's to correct — coded error.
|
|
178
199
|
return { ok: false, error: toolErrorMessage(err) };
|
|
179
200
|
}
|
|
@@ -5,34 +5,93 @@ import { resolveToolPath, toPosix } from "./paths.js";
|
|
|
5
5
|
import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
|
|
6
6
|
/** How many leading lines of a created file the approval preview shows. */
|
|
7
7
|
const PREVIEW_LINES = 20;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* ONE FLAT OPERATION SHAPE — deliberately flat, and it must stay that way.
|
|
10
|
+
*
|
|
11
|
+
* The provider rejects a tool schema nested 8 or more levels deep, and it
|
|
12
|
+
* rejects the entire REQUEST when any tool trips it: one over-deep schema kills
|
|
13
|
+
* every turn of every session, which is exactly how 1.7.0 died in the field.
|
|
14
|
+
* JSON Schema's own wrapper keys (`properties`, `items`, `anyOf`) are containers
|
|
15
|
+
* too, so every semantic level an author writes costs two, and a union costs two
|
|
16
|
+
* more on top. The previous shape — a discriminated union of operations, each
|
|
17
|
+
* carrying an array of hunk OBJECTS — rendered 11 levels deep.
|
|
18
|
+
*
|
|
19
|
+
* Neither lever alone was enough (both measured, see schema-depth.test.ts):
|
|
20
|
+
* keeping the union and flattening hunks still renders 8; dropping the union and
|
|
21
|
+
* keeping the hunk array renders 9. So both go. The discriminator survives as a
|
|
22
|
+
* plain `type` enum, and the per-variant field requirements the union used to
|
|
23
|
+
* encode are enforced by {@link refineOperation} below — `superRefine` is a
|
|
24
|
+
* runtime check that adds NO depth to the rendered schema, so the model still
|
|
25
|
+
* gets a precise rejection for a malformed operation, just from zod instead of
|
|
26
|
+
* from the schema's shape.
|
|
27
|
+
*
|
|
28
|
+
* The multi-hunk capability is NOT lost: a path may appear in as many `update`
|
|
29
|
+
* operations as it likes, and they apply in order against the running content —
|
|
30
|
+
* the same semantics the `hunks` array had, spelled one hunk per operation.
|
|
31
|
+
*/
|
|
32
|
+
const OperationSchema = z
|
|
33
|
+
.object({
|
|
34
|
+
type: z
|
|
35
|
+
.enum(["update", "create", "delete"])
|
|
36
|
+
.describe('What to do: "update" replaces oldStr with newStr in an existing file; ' +
|
|
37
|
+
'"create" writes a new file from content; "delete" removes an existing file.'),
|
|
38
|
+
path: z.string().describe("Path to the file, relative to the root."),
|
|
9
39
|
oldStr: z
|
|
10
40
|
.string()
|
|
11
41
|
.min(1)
|
|
12
|
-
.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
]
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("update only — exact text to replace; must occur exactly once in the " +
|
|
44
|
+
"file as it stands when this operation runs."),
|
|
45
|
+
newStr: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("update only — the replacement text (may be empty to delete)."),
|
|
49
|
+
content: z
|
|
50
|
+
.string()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("create only — full UTF-8 contents of the new file."),
|
|
53
|
+
})
|
|
54
|
+
.superRefine(refineOperation);
|
|
55
|
+
/**
|
|
56
|
+
* The per-variant requirements the discriminated union used to express in the
|
|
57
|
+
* schema. Enforced here so a malformed operation is still rejected before
|
|
58
|
+
* `execute` runs, with a message naming the exact field.
|
|
59
|
+
*/
|
|
60
|
+
function refineOperation(op, ctx) {
|
|
61
|
+
const needs = (field) => {
|
|
62
|
+
if (op[field] === undefined) {
|
|
63
|
+
ctx.addIssue({
|
|
64
|
+
code: z.ZodIssueCode.custom,
|
|
65
|
+
path: [field],
|
|
66
|
+
message: `"${field}" is required when type is "${op.type}"`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const forbid = (field) => {
|
|
71
|
+
if (op[field] !== undefined) {
|
|
72
|
+
ctx.addIssue({
|
|
73
|
+
code: z.ZodIssueCode.custom,
|
|
74
|
+
path: [field],
|
|
75
|
+
message: `"${field}" is not allowed when type is "${op.type}"`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
if (op.type === "update") {
|
|
80
|
+
needs("oldStr");
|
|
81
|
+
needs("newStr");
|
|
82
|
+
forbid("content");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (op.type === "create") {
|
|
86
|
+
needs("content");
|
|
87
|
+
forbid("oldStr");
|
|
88
|
+
forbid("newStr");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
forbid("oldStr");
|
|
92
|
+
forbid("newStr");
|
|
93
|
+
forbid("content");
|
|
94
|
+
}
|
|
36
95
|
const parameters = z.object({
|
|
37
96
|
operations: z
|
|
38
97
|
.array(OperationSchema)
|
|
@@ -50,14 +109,19 @@ export const applyPatchTool = {
|
|
|
50
109
|
name: "apply_patch",
|
|
51
110
|
description: "Apply multiple edits across one or more files in a single, atomic, reviewed change — preferred over many edit_file calls for multi-file or multi-hunk work. " +
|
|
52
111
|
"Input is { operations: [...] } where each operation is one of: " +
|
|
53
|
-
'{ "type":"update", "path", "
|
|
112
|
+
'{ "type":"update", "path", "oldStr", "newStr" } — replace oldStr (which must match EXACTLY ONCE in the file, like edit_file) with newStr; ' +
|
|
54
113
|
'{ "type":"create", "path", "content" } — create a new file (must not already exist); ' +
|
|
55
114
|
'{ "type":"delete", "path" } — delete an existing file. ' +
|
|
115
|
+
"To make several edits to the SAME file, list several update operations with the same path: they apply in order, each one matching against the result of the previous. " +
|
|
116
|
+
"A path used by a create or a delete may appear only once. " +
|
|
56
117
|
"The whole patch is validated before anything is written: if any operation is invalid, nothing is applied and the failing operation is reported.",
|
|
57
118
|
parameters,
|
|
58
119
|
async execute(input, ctx) {
|
|
59
|
-
|
|
60
|
-
|
|
120
|
+
// One track per path, in first-touch order. Repeated `update` operations on
|
|
121
|
+
// a path fold into its track, each hunk matching against the running content
|
|
122
|
+
// — so a file is still written exactly once, from one final byte string.
|
|
123
|
+
const tracks = new Map();
|
|
124
|
+
const order = [];
|
|
61
125
|
for (let i = 0; i < input.operations.length; i++) {
|
|
62
126
|
const op = input.operations[i];
|
|
63
127
|
let abs;
|
|
@@ -67,18 +131,29 @@ export const applyPatchTool = {
|
|
|
67
131
|
catch (err) {
|
|
68
132
|
return { ok: false, error: opError(i, op, err.message) };
|
|
69
133
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
134
|
+
const existing = tracks.get(abs);
|
|
135
|
+
if (existing) {
|
|
136
|
+
// Only an update chain may share a path. A create or a delete alongside
|
|
137
|
+
// anything else on the same path is an order-dependent muddle, and the
|
|
138
|
+
// shape that preceded this one couldn't express it either.
|
|
139
|
+
if (existing.kind !== "update" || op.type !== "update") {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
error: opError(i, op, `path already used by operation ${existing.firstOp + 1}; only repeated "update" operations may share a path`),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const failure = applyHunk(i, op, existing);
|
|
146
|
+
if (failure)
|
|
147
|
+
return { ok: false, error: failure };
|
|
148
|
+
continue;
|
|
75
149
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
150
|
+
const opened = await openTrack(i, op, abs, ctx);
|
|
151
|
+
if (!opened.ok)
|
|
152
|
+
return opened;
|
|
153
|
+
tracks.set(abs, opened.track);
|
|
154
|
+
order.push(abs);
|
|
81
155
|
}
|
|
156
|
+
const planned = order.map((abs) => toPlanned(tracks.get(abs)));
|
|
82
157
|
// One approval for the whole patch — denial writes nothing.
|
|
83
158
|
const decision = await ctx.requestApproval({
|
|
84
159
|
kind: "patch",
|
|
@@ -114,27 +189,31 @@ export const applyPatchTool = {
|
|
|
114
189
|
return { ok: true, output: `applied patch:\n${applied.join("\n")}` };
|
|
115
190
|
},
|
|
116
191
|
};
|
|
117
|
-
/** Validate
|
|
118
|
-
async function
|
|
192
|
+
/** Validate the FIRST operation on a path and open its track. */
|
|
193
|
+
async function openTrack(i, op, abs, ctx) {
|
|
119
194
|
// Forward-slash for model-facing output (the `applied` lines and error
|
|
120
195
|
// messages), consistent with every other path tool — see {@link toPosix}.
|
|
121
196
|
const rel = toPosix(path.relative(ctx.cwd, abs));
|
|
197
|
+
const base = { abs, rel, firstOp: i, hunks: [] };
|
|
122
198
|
if (op.type === "create") {
|
|
123
199
|
if (await exists(abs)) {
|
|
124
200
|
return { ok: false, error: opError(i, op, "file already exists") };
|
|
125
201
|
}
|
|
202
|
+
const content = op.content ?? "";
|
|
126
203
|
return {
|
|
127
204
|
ok: true,
|
|
128
|
-
|
|
205
|
+
track: { ...base, kind: "create", content, eol: detectEol(content) },
|
|
129
206
|
};
|
|
130
207
|
}
|
|
131
208
|
if (op.type === "delete") {
|
|
132
209
|
if (!(await exists(abs))) {
|
|
133
210
|
return { ok: false, error: opError(i, op, "file not found") };
|
|
134
211
|
}
|
|
135
|
-
return {
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
track: { ...base, kind: "delete", content: "", eol: "\n" },
|
|
215
|
+
};
|
|
136
216
|
}
|
|
137
|
-
// update: read, then apply each hunk in order against the running content.
|
|
138
217
|
let content;
|
|
139
218
|
try {
|
|
140
219
|
content = await fs.readFile(abs, "utf8");
|
|
@@ -145,34 +224,49 @@ async function planOp(i, op, abs, ctx) {
|
|
|
145
224
|
}
|
|
146
225
|
return { ok: false, error: opError(i, op, err.message) };
|
|
147
226
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const match = findMatch(content, oldStr);
|
|
154
|
-
if (match.kind === "none") {
|
|
155
|
-
return {
|
|
156
|
-
ok: false,
|
|
157
|
-
error: opError(i, op, `hunk ${h + 1}: oldStr not found`),
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
if (match.kind === "ambiguous") {
|
|
161
|
-
return {
|
|
162
|
-
ok: false,
|
|
163
|
-
error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${match.count} matches${tierLabel(match.tier)})`),
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
// Splice by offset so `$` patterns in newStr aren't interpreted.
|
|
167
|
-
content =
|
|
168
|
-
content.slice(0, match.start) +
|
|
169
|
-
applyEol(newStr, fileEol) +
|
|
170
|
-
content.slice(match.end);
|
|
171
|
-
}
|
|
172
|
-
return {
|
|
173
|
-
ok: true,
|
|
174
|
-
planned: { op: "update", abs, rel, content, hunks: op.hunks },
|
|
227
|
+
const track = {
|
|
228
|
+
...base,
|
|
229
|
+
kind: "update",
|
|
230
|
+
content,
|
|
231
|
+
eol: detectEol(content),
|
|
175
232
|
};
|
|
233
|
+
const failure = applyHunk(i, op, track);
|
|
234
|
+
return failure ? { ok: false, error: failure } : { ok: true, track };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Apply one update operation's hunk to its track's running content. Returns an
|
|
238
|
+
* error string on failure, or `undefined` on success (the track is mutated).
|
|
239
|
+
*/
|
|
240
|
+
function applyHunk(i, op, track) {
|
|
241
|
+
const { oldStr, newStr } = op;
|
|
242
|
+
// Guaranteed present by `refineOperation`; re-checked so the narrowing is
|
|
243
|
+
// structural rather than a cast, and a schema regression fails loud.
|
|
244
|
+
if (oldStr === undefined || newStr === undefined) {
|
|
245
|
+
return opError(i, op, 'update requires both "oldStr" and "newStr"');
|
|
246
|
+
}
|
|
247
|
+
const match = findMatch(track.content, oldStr);
|
|
248
|
+
if (match.kind === "none") {
|
|
249
|
+
return opError(i, op, "oldStr not found");
|
|
250
|
+
}
|
|
251
|
+
if (match.kind === "ambiguous") {
|
|
252
|
+
return opError(i, op, `oldStr not unique (${match.count} matches${tierLabel(match.tier)})`);
|
|
253
|
+
}
|
|
254
|
+
// Splice by offset so `$` patterns in newStr aren't interpreted.
|
|
255
|
+
track.content =
|
|
256
|
+
track.content.slice(0, match.start) +
|
|
257
|
+
applyEol(newStr, track.eol) +
|
|
258
|
+
track.content.slice(match.end);
|
|
259
|
+
track.hunks.push({ oldStr, newStr });
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
/** Collapse a finished track into the single write it represents. */
|
|
263
|
+
function toPlanned(track) {
|
|
264
|
+
const { kind, abs, rel, content, hunks } = track;
|
|
265
|
+
if (kind === "delete")
|
|
266
|
+
return { op: "delete", abs, rel };
|
|
267
|
+
if (kind === "create")
|
|
268
|
+
return { op: "create", abs, rel, content };
|
|
269
|
+
return { op: "update", abs, rel, content, hunks };
|
|
176
270
|
}
|
|
177
271
|
/** Shape a planned op into its approval-preview form. */
|
|
178
272
|
function toPreview(p) {
|