@cruxy/cli 0.27.0 → 0.28.1
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/dist/agent/session.js +6 -0
- package/dist/cli/commands/run.js +20 -5
- package/dist/config/schema.d.ts +10 -10
- package/dist/errors/constructors.d.ts +16 -0
- package/dist/errors/constructors.js +47 -0
- package/dist/errors/types.d.ts +5 -0
- package/dist/errors/types.js +10 -0
- package/dist/jobs/dispatch-tool.d.ts +2 -2
- package/dist/subagent/spawn-tool.d.ts +8 -8
- package/dist/testing/run-tests-tool.d.ts +23 -5
- package/dist/testing/run-tests-tool.js +32 -9
- package/dist/tools/file/apply-patch.js +12 -8
- package/dist/tools/file/edit-file.d.ts +0 -2
- package/dist/tools/file/edit-file.js +10 -19
- package/dist/tools/file/match.d.ts +43 -0
- package/dist/tools/file/match.js +127 -0
- package/dist/tools/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/agent/session.js
CHANGED
|
@@ -99,6 +99,12 @@ export class Session {
|
|
|
99
99
|
* history is unaffected.
|
|
100
100
|
*/
|
|
101
101
|
async send(userPrompt, renderer) {
|
|
102
|
+
// Per-turn tool lifecycle (C.13): re-arm any per-episode tool state (e.g. the
|
|
103
|
+
// run_tests consecutive-failure breaker) at the top of the turn, so a fresh
|
|
104
|
+
// user instruction starts clean. The breaker still latches across the many
|
|
105
|
+
// model iterations WITHIN this turn — it just never leaks into the next one.
|
|
106
|
+
for (const tool of this.args.registry.list())
|
|
107
|
+
tool.onTurnStart?.();
|
|
102
108
|
this.messages.push({ role: "user", content: userPrompt });
|
|
103
109
|
// Usage telemetry (C.22): one collector per run. `onReq` is threaded into
|
|
104
110
|
// every real model request this turn drives — the main loop, compaction, and
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { logger } from "../../utils/logger.js";
|
|
3
3
|
import { loadConfig, resolveApiKey } from "../../config/index.js";
|
|
4
|
-
import { authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
|
|
4
|
+
import { agentIncomplete, authMissingKey, shouldUseColor, usageError, } from "../../errors/index.js";
|
|
5
5
|
import { createRenderer } from "../../render/index.js";
|
|
6
6
|
import { themeForColor } from "../../theme/index.js";
|
|
7
7
|
import { summarizeRuns, renderSummary, } from "../../usage/index.js";
|
|
@@ -217,8 +217,9 @@ export function runCommand() {
|
|
|
217
217
|
// Provider/network/auth failures propagate to the top-level boundary,
|
|
218
218
|
// which classifies them (e.g. CRUXY_E_GATEWAY_UNREACHABLE) and exits with
|
|
219
219
|
// the matching code — a one-shot run must fail non-zero on error.
|
|
220
|
+
let result;
|
|
220
221
|
try {
|
|
221
|
-
|
|
222
|
+
result = await session.send(prompt, renderer);
|
|
222
223
|
logger.debug(`agent finished: ${result.stop} after ${result.iterations} turn(s); ` +
|
|
223
224
|
`tokens in/out ${result.usage.input_tokens}/${result.usage.output_tokens}`);
|
|
224
225
|
}
|
|
@@ -235,12 +236,26 @@ export function runCommand() {
|
|
|
235
236
|
await resetMcpServices();
|
|
236
237
|
}
|
|
237
238
|
// End-of-run usage summary (C.22): honest tokens + per-tier breakdown +
|
|
238
|
-
// cost (only when priced). Printed after the live region is torn down
|
|
239
|
-
//
|
|
240
|
-
//
|
|
239
|
+
// cost (only when priced). Printed after the live region is torn down, for
|
|
240
|
+
// BOTH a completed run and one that gave up below — the tokens burned are
|
|
241
|
+
// useful either way. A thrown run (provider error) propagates past it.
|
|
241
242
|
if (config.usage.enabled && session.lastRun) {
|
|
242
243
|
printRunUsage(session.lastRun, config);
|
|
243
244
|
}
|
|
245
|
+
// Fail loud on a non-completed stop (#3/#5): a one-shot run that hit the
|
|
246
|
+
// iteration cap or a token budget (or was cancelled) MUST exit non-zero —
|
|
247
|
+
// otherwise CI reads a gave-up run as success. The partial history already
|
|
248
|
+
// streamed to stdout stands; the boundary prints the coded reason to stderr
|
|
249
|
+
// and exits with CRUXY_E_AGENT_INCOMPLETE's code. `result` is always set
|
|
250
|
+
// here — a thrown send would have propagated past this point.
|
|
251
|
+
if (result && result.stop !== "completed") {
|
|
252
|
+
throw agentIncomplete({
|
|
253
|
+
stop: result.stop,
|
|
254
|
+
iterations: result.iterations,
|
|
255
|
+
reason: result.stopReason,
|
|
256
|
+
maxIterations: config.agent.maxIterations,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
244
259
|
});
|
|
245
260
|
}
|
|
246
261
|
/** Render the just-finished run's usage as a single themed line (C.22). */
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -266,20 +266,20 @@ export declare const SubagentConfigSchema: z.ZodObject<{
|
|
|
266
266
|
/** Optional wall-clock cap; unset means no time limit. */
|
|
267
267
|
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
268
268
|
}, "strict", z.ZodTypeAny, {
|
|
269
|
-
maxTokens: number;
|
|
270
269
|
maxIterations: number;
|
|
270
|
+
maxTokens: number;
|
|
271
271
|
timeoutMs?: number | undefined;
|
|
272
272
|
}, {
|
|
273
273
|
timeoutMs?: number | undefined;
|
|
274
|
-
maxTokens?: number | undefined;
|
|
275
274
|
maxIterations?: number | undefined;
|
|
275
|
+
maxTokens?: number | undefined;
|
|
276
276
|
}>>;
|
|
277
277
|
}, "strict", z.ZodTypeAny, {
|
|
278
278
|
maxDepth: number;
|
|
279
279
|
maxConcurrency: number;
|
|
280
280
|
defaultBudget: {
|
|
281
|
-
maxTokens: number;
|
|
282
281
|
maxIterations: number;
|
|
282
|
+
maxTokens: number;
|
|
283
283
|
timeoutMs?: number | undefined;
|
|
284
284
|
};
|
|
285
285
|
}, {
|
|
@@ -287,8 +287,8 @@ export declare const SubagentConfigSchema: z.ZodObject<{
|
|
|
287
287
|
maxConcurrency?: number | undefined;
|
|
288
288
|
defaultBudget?: {
|
|
289
289
|
timeoutMs?: number | undefined;
|
|
290
|
-
maxTokens?: number | undefined;
|
|
291
290
|
maxIterations?: number | undefined;
|
|
291
|
+
maxTokens?: number | undefined;
|
|
292
292
|
} | undefined;
|
|
293
293
|
}>;
|
|
294
294
|
/**
|
|
@@ -1217,20 +1217,20 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1217
1217
|
/** Optional wall-clock cap; unset means no time limit. */
|
|
1218
1218
|
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
1219
1219
|
}, "strict", z.ZodTypeAny, {
|
|
1220
|
-
maxTokens: number;
|
|
1221
1220
|
maxIterations: number;
|
|
1221
|
+
maxTokens: number;
|
|
1222
1222
|
timeoutMs?: number | undefined;
|
|
1223
1223
|
}, {
|
|
1224
1224
|
timeoutMs?: number | undefined;
|
|
1225
|
-
maxTokens?: number | undefined;
|
|
1226
1225
|
maxIterations?: number | undefined;
|
|
1226
|
+
maxTokens?: number | undefined;
|
|
1227
1227
|
}>>;
|
|
1228
1228
|
}, "strict", z.ZodTypeAny, {
|
|
1229
1229
|
maxDepth: number;
|
|
1230
1230
|
maxConcurrency: number;
|
|
1231
1231
|
defaultBudget: {
|
|
1232
|
-
maxTokens: number;
|
|
1233
1232
|
maxIterations: number;
|
|
1233
|
+
maxTokens: number;
|
|
1234
1234
|
timeoutMs?: number | undefined;
|
|
1235
1235
|
};
|
|
1236
1236
|
}, {
|
|
@@ -1238,8 +1238,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1238
1238
|
maxConcurrency?: number | undefined;
|
|
1239
1239
|
defaultBudget?: {
|
|
1240
1240
|
timeoutMs?: number | undefined;
|
|
1241
|
-
maxTokens?: number | undefined;
|
|
1242
1241
|
maxIterations?: number | undefined;
|
|
1242
|
+
maxTokens?: number | undefined;
|
|
1243
1243
|
} | undefined;
|
|
1244
1244
|
}>>;
|
|
1245
1245
|
jobs: z.ZodDefault<z.ZodObject<{
|
|
@@ -1720,8 +1720,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1720
1720
|
maxDepth: number;
|
|
1721
1721
|
maxConcurrency: number;
|
|
1722
1722
|
defaultBudget: {
|
|
1723
|
-
maxTokens: number;
|
|
1724
1723
|
maxIterations: number;
|
|
1724
|
+
maxTokens: number;
|
|
1725
1725
|
timeoutMs?: number | undefined;
|
|
1726
1726
|
};
|
|
1727
1727
|
};
|
|
@@ -1873,8 +1873,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
|
|
|
1873
1873
|
maxConcurrency?: number | undefined;
|
|
1874
1874
|
defaultBudget?: {
|
|
1875
1875
|
timeoutMs?: number | undefined;
|
|
1876
|
-
maxTokens?: number | undefined;
|
|
1877
1876
|
maxIterations?: number | undefined;
|
|
1877
|
+
maxTokens?: number | undefined;
|
|
1878
1878
|
} | undefined;
|
|
1879
1879
|
} | undefined;
|
|
1880
1880
|
model?: {
|
|
@@ -362,6 +362,22 @@ export declare function webFetchFailed(url: string, underlying?: unknown): Cruxy
|
|
|
362
362
|
*/
|
|
363
363
|
export declare function webBlockedHost(url: string, reason: string): CruxyError;
|
|
364
364
|
export declare function internal(underlying?: unknown): CruxyError;
|
|
365
|
+
/**
|
|
366
|
+
* A one-shot `cruxy run` ended without completing: the agent loop hit a hard stop
|
|
367
|
+
* — the iteration cap (`max_iterations`) or a token budget (`budget`) — or was
|
|
368
|
+
* cancelled (`aborted`). We fail loud with a non-zero exit so CI never reads a
|
|
369
|
+
* gave-up run as success; the partial work already streamed to stdout is preserved
|
|
370
|
+
* and the specific reason is named. `stop` is taken as a plain string so this
|
|
371
|
+
* (low-level) module needn't depend on the agent's result type.
|
|
372
|
+
*/
|
|
373
|
+
export declare function agentIncomplete(info: {
|
|
374
|
+
stop: string;
|
|
375
|
+
iterations: number;
|
|
376
|
+
/** The loop's stopReason (the concrete budget message), when `stop === "budget"`. */
|
|
377
|
+
reason?: string;
|
|
378
|
+
/** The turn ceiling in force, surfaced for `max_iterations`. */
|
|
379
|
+
maxIterations?: number;
|
|
380
|
+
}): CruxyError;
|
|
365
381
|
/**
|
|
366
382
|
* Map a known provider/transport error (from `@cruxy/sdk`) to a typed
|
|
367
383
|
* {@link CruxyError}, or `null` if it isn't one. Order matters: specific
|
|
@@ -1193,6 +1193,53 @@ export function internal(underlying) {
|
|
|
1193
1193
|
underlying,
|
|
1194
1194
|
});
|
|
1195
1195
|
}
|
|
1196
|
+
// ── one-shot run outcome (exit 20) ───────────────────────────────────────────
|
|
1197
|
+
/**
|
|
1198
|
+
* A one-shot `cruxy run` ended without completing: the agent loop hit a hard stop
|
|
1199
|
+
* — the iteration cap (`max_iterations`) or a token budget (`budget`) — or was
|
|
1200
|
+
* cancelled (`aborted`). We fail loud with a non-zero exit so CI never reads a
|
|
1201
|
+
* gave-up run as success; the partial work already streamed to stdout is preserved
|
|
1202
|
+
* and the specific reason is named. `stop` is taken as a plain string so this
|
|
1203
|
+
* (low-level) module needn't depend on the agent's result type.
|
|
1204
|
+
*/
|
|
1205
|
+
export function agentIncomplete(info) {
|
|
1206
|
+
const { stop, iterations, reason, maxIterations } = info;
|
|
1207
|
+
const turns = `${iterations} turn${iterations === 1 ? "" : "s"}`;
|
|
1208
|
+
const meta = { stop, iterations };
|
|
1209
|
+
if (stop === "budget") {
|
|
1210
|
+
return new CruxyError({
|
|
1211
|
+
code: ErrorCode.AgentIncomplete,
|
|
1212
|
+
title: "cruxy stopped: token budget reached before the task finished",
|
|
1213
|
+
cause: `${reason ?? "the token budget was exhausted"} (after ${turns}). The work so far is shown above.`,
|
|
1214
|
+
nextSteps: [
|
|
1215
|
+
"review the partial output above, then re-run with a narrower prompt",
|
|
1216
|
+
"raise or unset `agent.maxTokensPerTurn` if the task legitimately needs more",
|
|
1217
|
+
],
|
|
1218
|
+
meta,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
if (stop === "aborted") {
|
|
1222
|
+
return new CruxyError({
|
|
1223
|
+
code: ErrorCode.AgentIncomplete,
|
|
1224
|
+
title: "cruxy stopped: the run was cancelled before the task finished",
|
|
1225
|
+
cause: `cancelled after ${turns}. The work so far is shown above.`,
|
|
1226
|
+
nextSteps: ["re-run to continue in a fresh session"],
|
|
1227
|
+
meta,
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
// max_iterations (and any other non-completed stop, defensively).
|
|
1231
|
+
const cap = maxIterations !== undefined ? ` (${maxIterations})` : "";
|
|
1232
|
+
return new CruxyError({
|
|
1233
|
+
code: ErrorCode.AgentIncomplete,
|
|
1234
|
+
title: "cruxy stopped: iteration cap reached before the task finished",
|
|
1235
|
+
cause: `reached the turn limit${cap} after ${turns} without completing. The work so far is shown above.`,
|
|
1236
|
+
nextSteps: [
|
|
1237
|
+
"review the partial output above, then re-run with a narrower prompt",
|
|
1238
|
+
"raise `agent.maxIterations` if the task legitimately needs more turns",
|
|
1239
|
+
],
|
|
1240
|
+
meta,
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1196
1243
|
/**
|
|
1197
1244
|
* Map a known provider/transport error (from `@cruxy/sdk`) to a typed
|
|
1198
1245
|
* {@link CruxyError}, or `null` if it isn't one. Order matters: specific
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -201,6 +201,11 @@ export declare const ErrorCode: {
|
|
|
201
201
|
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
202
202
|
* rather than pretending there are simply no jobs. */
|
|
203
203
|
readonly JobsDisabled: "CRUXY_E_JOBS_DISABLED";
|
|
204
|
+
/** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
|
|
205
|
+
* a hard stop (the iteration cap or a token budget) or was cancelled, rather
|
|
206
|
+
* than finishing on its own. Fail loud with a non-zero exit so CI never reads a
|
|
207
|
+
* gave-up run as success — the partial work already streamed to stdout stands. */
|
|
208
|
+
readonly AgentIncomplete: "CRUXY_E_AGENT_INCOMPLETE";
|
|
204
209
|
};
|
|
205
210
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
206
211
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -222,6 +222,12 @@ export const ErrorCode = {
|
|
|
222
222
|
* `jobs.enabled` is false. The feature is opt-in; surfaced with how to enable it
|
|
223
223
|
* rather than pretending there are simply no jobs. */
|
|
224
224
|
JobsDisabled: "CRUXY_E_JOBS_DISABLED",
|
|
225
|
+
// one-shot run outcome (exit 20)
|
|
226
|
+
/** A one-shot `cruxy run` ended WITHOUT completing the task: the agent loop hit
|
|
227
|
+
* a hard stop (the iteration cap or a token budget) or was cancelled, rather
|
|
228
|
+
* than finishing on its own. Fail loud with a non-zero exit so CI never reads a
|
|
229
|
+
* gave-up run as success — the partial work already streamed to stdout stands. */
|
|
230
|
+
AgentIncomplete: "CRUXY_E_AGENT_INCOMPLETE",
|
|
225
231
|
};
|
|
226
232
|
/**
|
|
227
233
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -343,6 +349,10 @@ const EXIT_CODES = {
|
|
|
343
349
|
[ErrorCode.JobLimit]: 2,
|
|
344
350
|
[ErrorCode.JobNotFound]: 19,
|
|
345
351
|
[ErrorCode.JobsDisabled]: 19,
|
|
352
|
+
// One-shot run outcome. A run that hit the iteration cap / token budget (or was
|
|
353
|
+
// cancelled) without completing gets its own greppable exit code, so CI can tell
|
|
354
|
+
// "the agent gave up" apart from a provider/auth/config failure.
|
|
355
|
+
[ErrorCode.AgentIncomplete]: 20,
|
|
346
356
|
};
|
|
347
357
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
348
358
|
export function exitCodeFor(code) {
|
|
@@ -19,14 +19,14 @@ declare const parameters: z.ZodObject<{
|
|
|
19
19
|
}, "strip", z.ZodTypeAny, {
|
|
20
20
|
task: string;
|
|
21
21
|
root?: string | undefined;
|
|
22
|
-
maxTokens?: number | undefined;
|
|
23
22
|
maxIterations?: number | undefined;
|
|
23
|
+
maxTokens?: number | undefined;
|
|
24
24
|
tools?: [string, ...string[]] | undefined;
|
|
25
25
|
}, {
|
|
26
26
|
task: string;
|
|
27
27
|
root?: string | undefined;
|
|
28
|
-
maxTokens?: number | undefined;
|
|
29
28
|
maxIterations?: number | undefined;
|
|
29
|
+
maxTokens?: number | undefined;
|
|
30
30
|
tools?: [string, ...string[]] | undefined;
|
|
31
31
|
}>;
|
|
32
32
|
/** Build the `run_in_background` tool bound to the session's job manager. */
|
|
@@ -15,13 +15,13 @@ declare const parameters: z.ZodObject<{
|
|
|
15
15
|
maxTokens: z.ZodOptional<z.ZodNumber>;
|
|
16
16
|
}, "strip", z.ZodTypeAny, {
|
|
17
17
|
task: string;
|
|
18
|
-
maxTokens?: number | undefined;
|
|
19
18
|
maxIterations?: number | undefined;
|
|
19
|
+
maxTokens?: number | undefined;
|
|
20
20
|
tools?: [string, ...string[]] | undefined;
|
|
21
21
|
}, {
|
|
22
22
|
task: string;
|
|
23
|
-
maxTokens?: number | undefined;
|
|
24
23
|
maxIterations?: number | undefined;
|
|
24
|
+
maxTokens?: number | undefined;
|
|
25
25
|
tools?: [string, ...string[]] | undefined;
|
|
26
26
|
}>;
|
|
27
27
|
/** Build a `spawn_subagent` tool bound to `orchestrator` at `depth`. */
|
|
@@ -36,42 +36,42 @@ declare const batchParameters: z.ZodObject<{
|
|
|
36
36
|
}, "strip", z.ZodTypeAny, {
|
|
37
37
|
task: string;
|
|
38
38
|
root?: string | undefined;
|
|
39
|
-
maxTokens?: number | undefined;
|
|
40
39
|
maxIterations?: number | undefined;
|
|
40
|
+
maxTokens?: number | undefined;
|
|
41
41
|
tools?: [string, ...string[]] | undefined;
|
|
42
42
|
}, {
|
|
43
43
|
task: string;
|
|
44
44
|
root?: string | undefined;
|
|
45
|
-
maxTokens?: number | undefined;
|
|
46
45
|
maxIterations?: number | undefined;
|
|
46
|
+
maxTokens?: number | undefined;
|
|
47
47
|
tools?: [string, ...string[]] | undefined;
|
|
48
48
|
}>, "atleastone">;
|
|
49
49
|
}, "strip", z.ZodTypeAny, {
|
|
50
50
|
tasks: [{
|
|
51
51
|
task: string;
|
|
52
52
|
root?: string | undefined;
|
|
53
|
-
maxTokens?: number | undefined;
|
|
54
53
|
maxIterations?: number | undefined;
|
|
54
|
+
maxTokens?: number | undefined;
|
|
55
55
|
tools?: [string, ...string[]] | undefined;
|
|
56
56
|
}, ...{
|
|
57
57
|
task: string;
|
|
58
58
|
root?: string | undefined;
|
|
59
|
-
maxTokens?: number | undefined;
|
|
60
59
|
maxIterations?: number | undefined;
|
|
60
|
+
maxTokens?: number | undefined;
|
|
61
61
|
tools?: [string, ...string[]] | undefined;
|
|
62
62
|
}[]];
|
|
63
63
|
}, {
|
|
64
64
|
tasks: [{
|
|
65
65
|
task: string;
|
|
66
66
|
root?: string | undefined;
|
|
67
|
-
maxTokens?: number | undefined;
|
|
68
67
|
maxIterations?: number | undefined;
|
|
68
|
+
maxTokens?: number | undefined;
|
|
69
69
|
tools?: [string, ...string[]] | undefined;
|
|
70
70
|
}, ...{
|
|
71
71
|
task: string;
|
|
72
72
|
root?: string | undefined;
|
|
73
|
-
maxTokens?: number | undefined;
|
|
74
73
|
maxIterations?: number | undefined;
|
|
74
|
+
maxTokens?: number | undefined;
|
|
75
75
|
tools?: [string, ...string[]] | undefined;
|
|
76
76
|
}[]];
|
|
77
77
|
}>;
|
|
@@ -10,19 +10,37 @@ import type { TestCommand, TestRunner } from "./types.js";
|
|
|
10
10
|
*/
|
|
11
11
|
/**
|
|
12
12
|
* Counts consecutive FAILING test executions; a green run resets it. When the
|
|
13
|
-
* count reaches the cap,
|
|
14
|
-
* CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* count reaches the cap, `trip` LATCHES: every further attempt is refused with
|
|
14
|
+
* the coded CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes) for the rest
|
|
15
|
+
* of the episode. Because a refused attempt never runs, {@link record} is never
|
|
16
|
+
* reached and the counter stays pinned at the cap — the breaker cannot re-arm
|
|
17
|
+
* itself mid-episode. (This replaces a bug where `trip` zeroed the counter as it
|
|
18
|
+
* tripped, so the very next call ran again and the "cap" was a speed bump, not a
|
|
19
|
+
* stop: the model could burn the whole turn re-running a failing suite.)
|
|
20
|
+
*
|
|
21
|
+
* The latch is cleared by {@link reset}, called at the top of each user turn
|
|
22
|
+
* (see `Session.send`) — so a fresh instruction starts with the full budget,
|
|
23
|
+
* but the model cannot bypass the cap by simply calling the tool again within
|
|
24
|
+
* one episode. A green run mid-streak still re-arms the budget via {@link record}.
|
|
17
25
|
* Per-turn work stays bounded regardless via `agent.maxIterations`.
|
|
18
26
|
*/
|
|
19
27
|
export declare class TestIterationBudget {
|
|
20
28
|
private failedRuns;
|
|
21
29
|
/** Runs already spent in the current failing streak. */
|
|
22
30
|
get spent(): number;
|
|
23
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* True when the next run must be refused. Latches at the cap: once the streak
|
|
33
|
+
* reaches `maxIterations` this keeps returning true (the refused call never
|
|
34
|
+
* executes, so {@link record} never runs and the counter stays pinned) until
|
|
35
|
+
* {@link reset} clears it at the next user turn.
|
|
36
|
+
*/
|
|
24
37
|
trip(maxIterations: number): boolean;
|
|
25
38
|
record(passed: boolean): void;
|
|
39
|
+
/**
|
|
40
|
+
* Clear the failing streak, re-arming the breaker. Called at the start of each
|
|
41
|
+
* user turn so a new instruction starts fresh; within one turn the latch holds.
|
|
42
|
+
*/
|
|
43
|
+
reset(): void;
|
|
26
44
|
}
|
|
27
45
|
declare const parameters: z.ZodObject<{
|
|
28
46
|
command: z.ZodOptional<z.ZodString>;
|
|
@@ -12,10 +12,18 @@ import { SandboxTestRunner } from "./sandbox-runner.js";
|
|
|
12
12
|
*/
|
|
13
13
|
/**
|
|
14
14
|
* Counts consecutive FAILING test executions; a green run resets it. When the
|
|
15
|
-
* count reaches the cap,
|
|
16
|
-
* CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
15
|
+
* count reaches the cap, `trip` LATCHES: every further attempt is refused with
|
|
16
|
+
* the coded CRUXY_E_TEST_ITERATION_LIMIT result (nothing executes) for the rest
|
|
17
|
+
* of the episode. Because a refused attempt never runs, {@link record} is never
|
|
18
|
+
* reached and the counter stays pinned at the cap — the breaker cannot re-arm
|
|
19
|
+
* itself mid-episode. (This replaces a bug where `trip` zeroed the counter as it
|
|
20
|
+
* tripped, so the very next call ran again and the "cap" was a speed bump, not a
|
|
21
|
+
* stop: the model could burn the whole turn re-running a failing suite.)
|
|
22
|
+
*
|
|
23
|
+
* The latch is cleared by {@link reset}, called at the top of each user turn
|
|
24
|
+
* (see `Session.send`) — so a fresh instruction starts with the full budget,
|
|
25
|
+
* but the model cannot bypass the cap by simply calling the tool again within
|
|
26
|
+
* one episode. A green run mid-streak still re-arms the budget via {@link record}.
|
|
19
27
|
* Per-turn work stays bounded regardless via `agent.maxIterations`.
|
|
20
28
|
*/
|
|
21
29
|
export class TestIterationBudget {
|
|
@@ -24,16 +32,25 @@ export class TestIterationBudget {
|
|
|
24
32
|
get spent() {
|
|
25
33
|
return this.failedRuns;
|
|
26
34
|
}
|
|
27
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* True when the next run must be refused. Latches at the cap: once the streak
|
|
37
|
+
* reaches `maxIterations` this keeps returning true (the refused call never
|
|
38
|
+
* executes, so {@link record} never runs and the counter stays pinned) until
|
|
39
|
+
* {@link reset} clears it at the next user turn.
|
|
40
|
+
*/
|
|
28
41
|
trip(maxIterations) {
|
|
29
|
-
|
|
30
|
-
return false;
|
|
31
|
-
this.failedRuns = 0;
|
|
32
|
-
return true;
|
|
42
|
+
return this.failedRuns >= maxIterations;
|
|
33
43
|
}
|
|
34
44
|
record(passed) {
|
|
35
45
|
this.failedRuns = passed ? 0 : this.failedRuns + 1;
|
|
36
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Clear the failing streak, re-arming the breaker. Called at the start of each
|
|
49
|
+
* user turn so a new instruction starts fresh; within one turn the latch holds.
|
|
50
|
+
*/
|
|
51
|
+
reset() {
|
|
52
|
+
this.failedRuns = 0;
|
|
53
|
+
}
|
|
37
54
|
}
|
|
38
55
|
const parameters = z.object({
|
|
39
56
|
command: z
|
|
@@ -69,6 +86,12 @@ export function makeRunTestsTool(deps = {}) {
|
|
|
69
86
|
"Use it to verify changes: run, read the failures, fix, re-run. The edit→re-run loop is " +
|
|
70
87
|
"capped — when the iteration limit trips, stop, summarize the remaining failures, and ask the user.",
|
|
71
88
|
parameters,
|
|
89
|
+
// Per-turn lifecycle (C.13): a new user instruction re-arms the failing-run
|
|
90
|
+
// breaker. Within one turn the latch holds (the model can't bypass the cap by
|
|
91
|
+
// re-calling); across turns a deliberate retry starts fresh.
|
|
92
|
+
onTurnStart() {
|
|
93
|
+
budget.reset();
|
|
94
|
+
},
|
|
72
95
|
async execute(input, ctx) {
|
|
73
96
|
// Resolve the command first: detection failure needs no approval and
|
|
74
97
|
// must be a coded, actionable error — never an invented command.
|
|
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { resolveToolPath } from "./paths.js";
|
|
5
|
-
import {
|
|
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
|
const HunkSchema = z.object({
|
|
@@ -143,25 +143,29 @@ async function planOp(i, op, abs, ctx) {
|
|
|
143
143
|
}
|
|
144
144
|
return { ok: false, error: opError(i, op, err.message) };
|
|
145
145
|
}
|
|
146
|
+
// Detect the file's line ending once, from the original bytes, so every hunk
|
|
147
|
+
// re-encodes newStr to the same convention as content mutates across hunks.
|
|
148
|
+
const fileEol = detectEol(content);
|
|
146
149
|
for (let h = 0; h < op.hunks.length; h++) {
|
|
147
150
|
const { oldStr, newStr } = op.hunks[h];
|
|
148
|
-
const
|
|
149
|
-
if (
|
|
151
|
+
const match = findMatch(content, oldStr);
|
|
152
|
+
if (match.kind === "none") {
|
|
150
153
|
return {
|
|
151
154
|
ok: false,
|
|
152
155
|
error: opError(i, op, `hunk ${h + 1}: oldStr not found`),
|
|
153
156
|
};
|
|
154
157
|
}
|
|
155
|
-
if (
|
|
158
|
+
if (match.kind === "ambiguous") {
|
|
156
159
|
return {
|
|
157
160
|
ok: false,
|
|
158
|
-
error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${
|
|
161
|
+
error: opError(i, op, `hunk ${h + 1}: oldStr not unique (${match.count} matches${tierLabel(match.tier)})`),
|
|
159
162
|
};
|
|
160
163
|
}
|
|
161
|
-
//
|
|
162
|
-
const idx = content.indexOf(oldStr);
|
|
164
|
+
// Splice by offset so `$` patterns in newStr aren't interpreted.
|
|
163
165
|
content =
|
|
164
|
-
content.slice(0,
|
|
166
|
+
content.slice(0, match.start) +
|
|
167
|
+
applyEol(newStr, fileEol) +
|
|
168
|
+
content.slice(match.end);
|
|
165
169
|
}
|
|
166
170
|
return {
|
|
167
171
|
ok: true,
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { Tool } from "../types.js";
|
|
3
|
-
/** Count non-overlapping exact occurrences of `needle` in `haystack`. */
|
|
4
|
-
export declare function countOccurrences(haystack: string, needle: string): number;
|
|
5
3
|
/**
|
|
6
4
|
* Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
|
|
7
5
|
* The uniqueness requirement is checked before approval so the model can fix an
|
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { resolveToolPath } from "./paths.js";
|
|
4
|
-
|
|
5
|
-
export function countOccurrences(haystack, needle) {
|
|
6
|
-
let count = 0;
|
|
7
|
-
let i = haystack.indexOf(needle);
|
|
8
|
-
while (i !== -1) {
|
|
9
|
-
count++;
|
|
10
|
-
i = haystack.indexOf(needle, i + needle.length);
|
|
11
|
-
}
|
|
12
|
-
return count;
|
|
13
|
-
}
|
|
4
|
+
import { applyEol, detectEol, findMatch, tierLabel } from "./match.js";
|
|
14
5
|
/**
|
|
15
6
|
* Replace one exact, unique occurrence of `old_str` with `new_str` in a file.
|
|
16
7
|
* The uniqueness requirement is checked before approval so the model can fix an
|
|
@@ -47,14 +38,14 @@ export const editFileTool = {
|
|
|
47
38
|
}
|
|
48
39
|
return { ok: false, error: err.message };
|
|
49
40
|
}
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
41
|
+
const match = findMatch(content, input.old_str);
|
|
42
|
+
if (match.kind === "none") {
|
|
52
43
|
return { ok: false, error: `old_str not found in ${input.path}` };
|
|
53
44
|
}
|
|
54
|
-
if (
|
|
45
|
+
if (match.kind === "ambiguous") {
|
|
55
46
|
return {
|
|
56
47
|
ok: false,
|
|
57
|
-
error: `old_str not unique (${
|
|
48
|
+
error: `old_str not unique (${match.count} matches${tierLabel(match.tier)}); add surrounding context to disambiguate`,
|
|
58
49
|
};
|
|
59
50
|
}
|
|
60
51
|
const decision = await ctx.requestApproval({
|
|
@@ -68,11 +59,11 @@ export const editFileTool = {
|
|
|
68
59
|
error: decision.feedback ?? `edit to ${input.path} denied`,
|
|
69
60
|
};
|
|
70
61
|
}
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
const updated = content.slice(0,
|
|
74
|
-
input.new_str +
|
|
75
|
-
content.slice(
|
|
62
|
+
// Splice the matched span by offset (avoids `$`-pattern interpretation) and
|
|
63
|
+
// re-encode new_str to the file's line ending so a CRLF file stays CRLF.
|
|
64
|
+
const updated = content.slice(0, match.start) +
|
|
65
|
+
applyEol(input.new_str, detectEol(content)) +
|
|
66
|
+
content.slice(match.end);
|
|
76
67
|
try {
|
|
77
68
|
await fs.writeFile(abs, updated, "utf8");
|
|
78
69
|
return { ok: true, output: `edited ${input.path}` };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating `old_str` inside a file for edit_file / apply_patch.
|
|
3
|
+
*
|
|
4
|
+
* Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
|
|
5
|
+
* checkouts/editors) — a byte-exact `indexOf` then never matches and the model
|
|
6
|
+
* can't replace existing code. We match in ordered tiers, tightest first, and
|
|
7
|
+
* stop at the first tier that finds anything:
|
|
8
|
+
*
|
|
9
|
+
* 0. exact — raw bytes; every currently-working edit takes this path
|
|
10
|
+
* 1. eol — CRLF and lone CR treated as LF (comparison only)
|
|
11
|
+
* 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
|
|
12
|
+
*
|
|
13
|
+
* Looser tiers compare against a normalized copy but return offsets into the
|
|
14
|
+
* ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
|
|
15
|
+
* file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
|
|
16
|
+
* more than one match at a tier is a hard error, and we do not fall through to a
|
|
17
|
+
* looser tier (looser can only be more ambiguous, never less).
|
|
18
|
+
*/
|
|
19
|
+
export type Tier = "exact" | "eol" | "eol+trailws";
|
|
20
|
+
export type MatchResult = {
|
|
21
|
+
kind: "found";
|
|
22
|
+
start: number;
|
|
23
|
+
end: number;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "ambiguous";
|
|
26
|
+
count: number;
|
|
27
|
+
tier: Tier;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "none";
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Find the single occurrence of `oldStr` in `content`, tolerating line-ending
|
|
33
|
+
* and trailing-whitespace differences. Returns original-string byte offsets on
|
|
34
|
+
* a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
|
|
35
|
+
* `none` if no tier matched.
|
|
36
|
+
*/
|
|
37
|
+
export declare function findMatch(content: string, oldStr: string): MatchResult;
|
|
38
|
+
/** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
|
|
39
|
+
export declare function detectEol(content: string): "\r\n" | "\n";
|
|
40
|
+
/** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
|
|
41
|
+
export declare function applyEol(text: string, eol: "\r\n" | "\n"): string;
|
|
42
|
+
/** Human phrase for the tier named in an ambiguity error. */
|
|
43
|
+
export declare function tierLabel(tier: Tier): string;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locating `old_str` inside a file for edit_file / apply_patch.
|
|
3
|
+
*
|
|
4
|
+
* Models emit `\n` line endings, but a file on disk may be `\r\n` (Windows
|
|
5
|
+
* checkouts/editors) — a byte-exact `indexOf` then never matches and the model
|
|
6
|
+
* can't replace existing code. We match in ordered tiers, tightest first, and
|
|
7
|
+
* stop at the first tier that finds anything:
|
|
8
|
+
*
|
|
9
|
+
* 0. exact — raw bytes; every currently-working edit takes this path
|
|
10
|
+
* 1. eol — CRLF and lone CR treated as LF (comparison only)
|
|
11
|
+
* 2. eol+trailws — tier 1, plus trailing whitespace ignored per line
|
|
12
|
+
*
|
|
13
|
+
* Looser tiers compare against a normalized copy but return offsets into the
|
|
14
|
+
* ORIGINAL string, so the splice preserves every unmatched byte verbatim (the
|
|
15
|
+
* file is never wholesale re-encoded). Ambiguity is never resolved by guessing:
|
|
16
|
+
* more than one match at a tier is a hard error, and we do not fall through to a
|
|
17
|
+
* looser tier (looser can only be more ambiguous, never less).
|
|
18
|
+
*/
|
|
19
|
+
const TIERS = ["exact", "eol", "eol+trailws"];
|
|
20
|
+
/**
|
|
21
|
+
* Find the single occurrence of `oldStr` in `content`, tolerating line-ending
|
|
22
|
+
* and trailing-whitespace differences. Returns original-string byte offsets on
|
|
23
|
+
* a unique match, `ambiguous` if the tightest matching tier had >1 hit, or
|
|
24
|
+
* `none` if no tier matched.
|
|
25
|
+
*/
|
|
26
|
+
export function findMatch(content, oldStr) {
|
|
27
|
+
for (const tier of TIERS) {
|
|
28
|
+
const occ = occurrences(content, oldStr, tier);
|
|
29
|
+
if (occ.length === 1) {
|
|
30
|
+
return { kind: "found", start: occ[0].start, end: occ[0].end };
|
|
31
|
+
}
|
|
32
|
+
if (occ.length > 1) {
|
|
33
|
+
return { kind: "ambiguous", count: occ.length, tier };
|
|
34
|
+
}
|
|
35
|
+
// 0 matches → try the next, looser tier.
|
|
36
|
+
}
|
|
37
|
+
return { kind: "none" };
|
|
38
|
+
}
|
|
39
|
+
/** Non-overlapping occurrences of `oldStr` under `tier`, as original offsets. */
|
|
40
|
+
function occurrences(content, oldStr, tier) {
|
|
41
|
+
const out = [];
|
|
42
|
+
if (tier === "exact") {
|
|
43
|
+
let i = content.indexOf(oldStr);
|
|
44
|
+
while (i !== -1) {
|
|
45
|
+
out.push({ start: i, end: i + oldStr.length });
|
|
46
|
+
i = content.indexOf(oldStr, i + oldStr.length);
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
const { norm, map } = normalize(content, tier);
|
|
51
|
+
const { norm: needle } = normalize(oldStr, tier);
|
|
52
|
+
// An all-whitespace old_str can normalize to empty under eol+trailws; refuse
|
|
53
|
+
// to "match everywhere" rather than delete at an arbitrary point.
|
|
54
|
+
if (needle.length === 0)
|
|
55
|
+
return out;
|
|
56
|
+
let i = norm.indexOf(needle);
|
|
57
|
+
while (i !== -1) {
|
|
58
|
+
out.push({ start: map[i], end: map[i + needle.length] });
|
|
59
|
+
i = norm.indexOf(needle, i + needle.length);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build a normalized view of `s` plus `map`, where `map[k]` is the original
|
|
65
|
+
* offset of the k-th normalized char and `map[norm.length]` is a sentinel
|
|
66
|
+
* (`s.length`). This lets a match found in normalized space splice back into
|
|
67
|
+
* the original bytes exactly.
|
|
68
|
+
*/
|
|
69
|
+
function normalize(s, tier) {
|
|
70
|
+
const stripTrail = tier === "eol+trailws";
|
|
71
|
+
let norm = "";
|
|
72
|
+
const map = [];
|
|
73
|
+
// Whitespace whose trailing-vs-not status isn't known yet: flushed when real
|
|
74
|
+
// content follows on the line, dropped when a newline / EOF follows.
|
|
75
|
+
const pending = [];
|
|
76
|
+
const emit = (ch, off) => {
|
|
77
|
+
norm += ch;
|
|
78
|
+
map.push(off);
|
|
79
|
+
};
|
|
80
|
+
const flushPending = () => {
|
|
81
|
+
for (const p of pending)
|
|
82
|
+
emit(p.ch, p.off);
|
|
83
|
+
pending.length = 0;
|
|
84
|
+
};
|
|
85
|
+
let i = 0;
|
|
86
|
+
while (i < s.length) {
|
|
87
|
+
const c = s[i];
|
|
88
|
+
if (c === "\r" || c === "\n") {
|
|
89
|
+
pending.length = 0; // any pending whitespace was trailing → drop it
|
|
90
|
+
emit("\n", i);
|
|
91
|
+
i += c === "\r" && s[i + 1] === "\n" ? 2 : 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (stripTrail && (c === " " || c === "\t")) {
|
|
95
|
+
pending.push({ ch: c, off: i });
|
|
96
|
+
i += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
flushPending();
|
|
100
|
+
emit(c, i);
|
|
101
|
+
i += 1;
|
|
102
|
+
}
|
|
103
|
+
// Trailing whitespace at end-of-string (in `pending`) is intentionally dropped.
|
|
104
|
+
map.push(s.length);
|
|
105
|
+
return { norm, map };
|
|
106
|
+
}
|
|
107
|
+
/** The file's line ending: CRLF if the first newline is `\r\n`, else LF. */
|
|
108
|
+
export function detectEol(content) {
|
|
109
|
+
const i = content.indexOf("\n");
|
|
110
|
+
return i > 0 && content[i - 1] === "\r" ? "\r\n" : "\n";
|
|
111
|
+
}
|
|
112
|
+
/** Re-encode `text`'s line endings to `eol` so an edit matches the file. */
|
|
113
|
+
export function applyEol(text, eol) {
|
|
114
|
+
const lf = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
115
|
+
return eol === "\r\n" ? lf.replace(/\n/g, "\r\n") : lf;
|
|
116
|
+
}
|
|
117
|
+
/** Human phrase for the tier named in an ambiguity error. */
|
|
118
|
+
export function tierLabel(tier) {
|
|
119
|
+
switch (tier) {
|
|
120
|
+
case "exact":
|
|
121
|
+
return "";
|
|
122
|
+
case "eol":
|
|
123
|
+
return " after end-of-line normalization";
|
|
124
|
+
case "eol+trailws":
|
|
125
|
+
return " after end-of-line + trailing-whitespace normalization";
|
|
126
|
+
}
|
|
127
|
+
}
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -241,6 +241,16 @@ export interface Tool<Schema extends ZodTypeAny = ZodTypeAny> {
|
|
|
241
241
|
* leave it unset and are advertised from their zod schema as before.
|
|
242
242
|
*/
|
|
243
243
|
rawInputSchema?: Record<string, unknown>;
|
|
244
|
+
/**
|
|
245
|
+
* Optional per-turn lifecycle hook (C.13). {@link Session.send} calls it on
|
|
246
|
+
* every tool in the session registry at the start of each user turn, before the
|
|
247
|
+
* model runs. A tool that carries per-episode state — e.g. `run_tests`' consecutive-
|
|
248
|
+
* failure breaker — resets it here so a fresh instruction starts clean, while that
|
|
249
|
+
* state still latches across the many model iterations *within* one turn. Tools
|
|
250
|
+
* with no per-turn state omit it (a subagent/one-shot run is a single episode, so
|
|
251
|
+
* a missing hook simply means the state lives for that whole run).
|
|
252
|
+
*/
|
|
253
|
+
onTurnStart?(): void;
|
|
244
254
|
/** Run the tool against validated `input` and the ambient `ctx`. */
|
|
245
255
|
execute(input: z.infer<Schema>, ctx: ToolContext): Promise<ToolResult>;
|
|
246
256
|
}
|