@cruxy/cli 0.28.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/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.
|
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
|
}
|