@stigmer/runner 3.1.17 → 3.2.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/README.md +1 -1
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/cost-guard.d.ts +41 -0
- package/dist/activities/execute-cursor/cost-guard.js +50 -0
- package/dist/activities/execute-cursor/cost-guard.js.map +1 -0
- package/dist/activities/execute-cursor/index.js +30 -0
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/turn-stream.d.ts +10 -1
- package/dist/activities/execute-cursor/turn-stream.js +42 -9
- package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +13 -3
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-terminal.d.ts +9 -0
- package/dist/activities/execute-deep-agent/streaming-terminal.js +10 -1
- package/dist/activities/execute-deep-agent/streaming-terminal.js.map +1 -1
- package/dist/main.js +17 -0
- package/dist/main.js.map +1 -1
- package/dist/preflight.d.ts +46 -0
- package/dist/preflight.js +55 -0
- package/dist/preflight.js.map +1 -0
- package/dist/shared/tool-rounds.d.ts +30 -0
- package/dist/shared/tool-rounds.js +49 -0
- package/dist/shared/tool-rounds.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/preflight.test.ts +41 -0
- package/src/activities/execute-cursor/__tests__/cost-guard.test.ts +64 -0
- package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +118 -0
- package/src/activities/execute-cursor/cost-guard.ts +56 -0
- package/src/activities/execute-cursor/index.ts +30 -0
- package/src/activities/execute-cursor/turn-stream.ts +61 -10
- package/src/activities/execute-deep-agent/__tests__/streaming-terminal.test.ts +40 -0
- package/src/activities/execute-deep-agent/setup.ts +16 -3
- package/src/activities/execute-deep-agent/streaming-terminal.ts +11 -1
- package/src/main.ts +17 -0
- package/src/preflight.ts +59 -0
- package/src/shared/__tests__/tool-rounds.test.ts +57 -0
- package/src/shared/tool-rounds.ts +56 -0
|
@@ -111,6 +111,7 @@ function buildDeps(overrides: Partial<CursorTurnStreamDeps> = {}): BuiltDeps {
|
|
|
111
111
|
promptEstimatedTokens: 100,
|
|
112
112
|
executionId: "exec-test",
|
|
113
113
|
state,
|
|
114
|
+
maxCostUsd: 0,
|
|
114
115
|
// CursorTurnStreamDeps
|
|
115
116
|
status,
|
|
116
117
|
accumulator,
|
|
@@ -261,6 +262,78 @@ describe("consumeCursorTurnStream", () => {
|
|
|
261
262
|
});
|
|
262
263
|
});
|
|
263
264
|
|
|
265
|
+
describe("cost-cap stop", () => {
|
|
266
|
+
it("cancels the run, stops the stream, and returns 'cost-cap' when onDelta flagged the overrun", async () => {
|
|
267
|
+
const state = newTurnStreamState();
|
|
268
|
+
state.costCapExceeded = true; // onDelta's write, simulated
|
|
269
|
+
const { deps, accumulator } = buildDeps({ state });
|
|
270
|
+
const run = mockRun([ev({ type: "assistant" }), ev({ type: "assistant" })]);
|
|
271
|
+
|
|
272
|
+
const reason = await consumeCursorTurnStream(run, deps);
|
|
273
|
+
|
|
274
|
+
expect(reason).toBe("cost-cap");
|
|
275
|
+
expect(run.cancel).toHaveBeenCalled();
|
|
276
|
+
// The break happens before the event is processed — no post-cap content.
|
|
277
|
+
expect(accumulator.processEvent).not.toHaveBeenCalled();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("detects the flag mid-stream when onDelta sets it between events", async () => {
|
|
281
|
+
const state = newTurnStreamState();
|
|
282
|
+
const { deps, accumulator } = buildDeps({ state });
|
|
283
|
+
// Simulate onDelta's write landing between event 1 and event 2 — the
|
|
284
|
+
// realistic shape: a turn-ended usage delta crosses the cap while the
|
|
285
|
+
// loop is between stream events.
|
|
286
|
+
async function* gen(): AsyncIterable<SDKMessage> {
|
|
287
|
+
yield ev({ type: "assistant" });
|
|
288
|
+
state.costCapExceeded = true;
|
|
289
|
+
yield ev({ type: "assistant" });
|
|
290
|
+
}
|
|
291
|
+
const run: MockRun = {
|
|
292
|
+
stream: () => gen(),
|
|
293
|
+
supports: () => true,
|
|
294
|
+
cancel: vi.fn(async () => {}),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const reason = await consumeCursorTurnStream(run, deps);
|
|
298
|
+
|
|
299
|
+
expect(reason).toBe("cost-cap");
|
|
300
|
+
expect(run.cancel).toHaveBeenCalled();
|
|
301
|
+
// Event 1 was processed; event 2 hit the break before processing.
|
|
302
|
+
expect(accumulator.processEvent).toHaveBeenCalledTimes(1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("swallows the cancel-induced teardown rejection (expected, not a failure)", async () => {
|
|
306
|
+
const state = newTurnStreamState();
|
|
307
|
+
state.costCapExceeded = true;
|
|
308
|
+
const { deps } = buildDeps({ state });
|
|
309
|
+
// The break exits the for-await, which invokes the iterator's return();
|
|
310
|
+
// a cancelled SDK run can reject there. The catch must exempt cost-cap
|
|
311
|
+
// teardown exactly like stall/first-denial teardown.
|
|
312
|
+
const events = [ev({ type: "assistant" })];
|
|
313
|
+
let i = 0;
|
|
314
|
+
const run: MockRun = {
|
|
315
|
+
stream: () => ({
|
|
316
|
+
[Symbol.asyncIterator]: () => ({
|
|
317
|
+
next: async () =>
|
|
318
|
+
i < events.length
|
|
319
|
+
? { value: events[i++], done: false as const }
|
|
320
|
+
: { value: undefined, done: true as const },
|
|
321
|
+
return: async () => {
|
|
322
|
+
throw new Error("run cancelled");
|
|
323
|
+
},
|
|
324
|
+
}),
|
|
325
|
+
}),
|
|
326
|
+
supports: () => true,
|
|
327
|
+
cancel: vi.fn(async () => {}),
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const reason = await consumeCursorTurnStream(run, deps);
|
|
331
|
+
|
|
332
|
+
expect(reason).toBe("cost-cap");
|
|
333
|
+
expect(run.cancel).toHaveBeenCalled();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
264
337
|
it("returns 'stalled' and cancels the run when the stall watchdog fires", async () => {
|
|
265
338
|
vi.useFakeTimers();
|
|
266
339
|
// The generator yields one event, then awaits a promise resolved only by
|
|
@@ -305,6 +378,7 @@ describe("makeCursorTurnOnDelta", () => {
|
|
|
305
378
|
promptEstimatedTokens: 10,
|
|
306
379
|
executionId: "e",
|
|
307
380
|
state,
|
|
381
|
+
maxCostUsd: 0,
|
|
308
382
|
});
|
|
309
383
|
|
|
310
384
|
expect(() => onDelta({ update: { type: "text" } as never })).not.toThrow();
|
|
@@ -322,6 +396,7 @@ describe("makeCursorTurnOnDelta", () => {
|
|
|
322
396
|
promptEstimatedTokens: 10,
|
|
323
397
|
executionId: "e",
|
|
324
398
|
state,
|
|
399
|
+
maxCostUsd: 0,
|
|
325
400
|
});
|
|
326
401
|
|
|
327
402
|
expect(() => onDelta({ update: { type: "text" } as never })).toThrow("boom");
|
|
@@ -338,6 +413,7 @@ describe("makeCursorTurnOnDelta", () => {
|
|
|
338
413
|
promptEstimatedTokens: 10,
|
|
339
414
|
executionId: "e",
|
|
340
415
|
state,
|
|
416
|
+
maxCostUsd: 0,
|
|
341
417
|
});
|
|
342
418
|
|
|
343
419
|
onDelta({ update: { type: "turn-ended", usage: { inputTokens: 100 } } as never });
|
|
@@ -346,4 +422,46 @@ describe("makeCursorTurnOnDelta", () => {
|
|
|
346
422
|
expect(usageAccumulator.addTurn).toHaveBeenCalledTimes(2);
|
|
347
423
|
expect(state.firstTurnAttributionLogged).toBe(true);
|
|
348
424
|
});
|
|
425
|
+
|
|
426
|
+
it("flags costCapExceeded when a turn-ended usage delta pushes the estimate past the cap", () => {
|
|
427
|
+
const state = newTurnStreamState();
|
|
428
|
+
const usageAccumulator = {
|
|
429
|
+
...stubUsageAccumulator(),
|
|
430
|
+
snapshot: vi.fn(() => ({ estimatedCostUsd: 0.51 })),
|
|
431
|
+
};
|
|
432
|
+
const onDelta = makeCursorTurnOnDelta({
|
|
433
|
+
usageAccumulator: usageAccumulator as never,
|
|
434
|
+
deltaEnricher: stubEnricher() as never,
|
|
435
|
+
heartbeat: vi.fn(),
|
|
436
|
+
promptEstimatedTokens: 10,
|
|
437
|
+
executionId: "e",
|
|
438
|
+
state,
|
|
439
|
+
maxCostUsd: 0.5,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
onDelta({ update: { type: "turn-ended", usage: { inputTokens: 100 } } as never });
|
|
443
|
+
|
|
444
|
+
expect(state.costCapExceeded).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("never flags costCapExceeded when no cap is configured", () => {
|
|
448
|
+
const state = newTurnStreamState();
|
|
449
|
+
const usageAccumulator = {
|
|
450
|
+
...stubUsageAccumulator(),
|
|
451
|
+
snapshot: vi.fn(() => ({ estimatedCostUsd: 999 })),
|
|
452
|
+
};
|
|
453
|
+
const onDelta = makeCursorTurnOnDelta({
|
|
454
|
+
usageAccumulator: usageAccumulator as never,
|
|
455
|
+
deltaEnricher: stubEnricher() as never,
|
|
456
|
+
heartbeat: vi.fn(),
|
|
457
|
+
promptEstimatedTokens: 10,
|
|
458
|
+
executionId: "e",
|
|
459
|
+
state,
|
|
460
|
+
maxCostUsd: 0,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
onDelta({ update: { type: "turn-ended", usage: { inputTokens: 100 } } as never });
|
|
464
|
+
|
|
465
|
+
expect(state.costCapExceeded).toBe(false);
|
|
466
|
+
});
|
|
349
467
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExecutionConfig.max_cost_usd enforcement for the Cursor harness.
|
|
3
|
+
*
|
|
4
|
+
* The native harness enforces the cap in middleware (cost-cap.ts): tools are
|
|
5
|
+
* blocked at the threshold and the model gets one final tool-free round to
|
|
6
|
+
* summarize. Cursor's only control point is cancelling the in-flight run, so
|
|
7
|
+
* here the cap is a HARD stop — the shared onDelta flags the overrun when a
|
|
8
|
+
* turn-ended usage delta lands, and the stream loop ends the run through the
|
|
9
|
+
* same clean-cancel pattern the stall watchdog and first-denial stop use.
|
|
10
|
+
* Slightly harsher than native by construction; both harnesses terminate with
|
|
11
|
+
* the same honesty semantics (EXECUTION_TERMINATED, work checkpointed, the
|
|
12
|
+
* conversation continues on the next message — the recursion-limit precedent
|
|
13
|
+
* in execute-deep-agent/streaming-terminal.ts).
|
|
14
|
+
*
|
|
15
|
+
* The running figure is the usage accumulator's local pricing-table estimate
|
|
16
|
+
* (authoritative billing is the BiDi proxy). That is the same estimation
|
|
17
|
+
* basis the native cost-cap middleware uses — acceptable for a safety net.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether the per-execution cost budget has been exhausted.
|
|
22
|
+
*
|
|
23
|
+
* `maxCostUsd <= 0` means "no cap" (the proto contract: 0/unset disables the
|
|
24
|
+
* ceiling). The boundary is inclusive: reaching the cap exactly stops the run,
|
|
25
|
+
* matching the native middleware's `runningCost >= maxCostUsd` check.
|
|
26
|
+
*/
|
|
27
|
+
export function costCapExceeded(maxCostUsd: number, estimatedCostUsd: number): boolean {
|
|
28
|
+
return maxCostUsd > 0 && estimatedCostUsd >= maxCostUsd;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Stable prefix of the cost-limit terminal error. Mirrors the cross-repo
|
|
33
|
+
* pattern of TOOL_CALL_LIMIT_ERROR_PREFIX (streaming-terminal.ts): consumers
|
|
34
|
+
* that need to distinguish "ran out of cost budget" from other TERMINATED
|
|
35
|
+
* causes can match on this prefix, because AgentExecutionStatus carries no
|
|
36
|
+
* structured termination reason. Do not reword without checking consumers.
|
|
37
|
+
*/
|
|
38
|
+
export const COST_LIMIT_ERROR_PREFIX = "Agent reached the cost limit";
|
|
39
|
+
|
|
40
|
+
/** The terminal `status.error` for a cost-cap stop. */
|
|
41
|
+
export function formatCostLimitError(maxCostUsd: number, estimatedCostUsd: number): string {
|
|
42
|
+
return (
|
|
43
|
+
`${COST_LIMIT_ERROR_PREFIX} for this message ` +
|
|
44
|
+
`(~$${estimatedCostUsd.toFixed(4)} of the $${maxCostUsd.toFixed(2)} budget). ` +
|
|
45
|
+
`Send another message to continue.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* User-facing system message for a cost-cap stop. Parallel to the
|
|
51
|
+
* recursion-limit copy: honest about the limit, clear that nothing is lost.
|
|
52
|
+
*/
|
|
53
|
+
export const COST_LIMIT_USER_COPY =
|
|
54
|
+
"The agent reached the cost limit for this message. " +
|
|
55
|
+
"Work completed so far has been saved. " +
|
|
56
|
+
"Send another message to continue where the agent left off.";
|
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
type CursorTurnStreamDeps,
|
|
91
91
|
type TurnOnDeltaDeps,
|
|
92
92
|
} from "./turn-stream.js";
|
|
93
|
+
import { formatCostLimitError, COST_LIMIT_USER_COPY } from "./cost-guard.js";
|
|
93
94
|
import {
|
|
94
95
|
captureFileChangeProgress,
|
|
95
96
|
newProgressCaptureState,
|
|
@@ -950,6 +951,7 @@ async function executeCursorInner(
|
|
|
950
951
|
// The shared onDelta only needs the usage/enricher/heartbeat/state subset,
|
|
951
952
|
// and it is wired at SEND time — before the accumulator exists — so it takes
|
|
952
953
|
// the narrow deps. The primary send and both retry sends reuse this object.
|
|
954
|
+
const maxCostUsd = spec.executionConfig?.maxCostUsd ?? 0;
|
|
953
955
|
const onDeltaDeps: TurnOnDeltaDeps = {
|
|
954
956
|
usageAccumulator,
|
|
955
957
|
deltaEnricher,
|
|
@@ -957,6 +959,7 @@ async function executeCursorInner(
|
|
|
957
959
|
promptEstimatedTokens,
|
|
958
960
|
executionId,
|
|
959
961
|
state: turnState,
|
|
962
|
+
maxCostUsd,
|
|
960
963
|
};
|
|
961
964
|
|
|
962
965
|
// Periodic heartbeat keeps Temporal informed during silent SDK operations
|
|
@@ -1067,6 +1070,7 @@ async function executeCursorInner(
|
|
|
1067
1070
|
turnState.pauseDetected ||
|
|
1068
1071
|
workerShutdownDetected ||
|
|
1069
1072
|
turnState.stallDetected ||
|
|
1073
|
+
turnState.costCapExceeded ||
|
|
1070
1074
|
Context.current().cancellationSignal.aborted
|
|
1071
1075
|
) {
|
|
1072
1076
|
accumulator.cancelInProgressSubAgents();
|
|
@@ -1117,6 +1121,32 @@ async function executeCursorInner(
|
|
|
1117
1121
|
return { kind: "return" };
|
|
1118
1122
|
}
|
|
1119
1123
|
|
|
1124
|
+
// Cost cap (cost-guard.ts): onDelta flagged the overrun and the loop
|
|
1125
|
+
// cancelled the run. EXECUTION_TERMINATED, not FAILED — the platform
|
|
1126
|
+
// deliberately stopped the run, work is checkpointed, and the
|
|
1127
|
+
// conversation continues on the next message (the recursion-limit
|
|
1128
|
+
// precedent in execute-deep-agent/streaming-terminal.ts). RETURN (not
|
|
1129
|
+
// throw): a Temporal retry would re-run the identical prompt and burn
|
|
1130
|
+
// the same budget again.
|
|
1131
|
+
if (turnState.costCapExceeded) {
|
|
1132
|
+
const estimated = usageAccumulator.snapshot().estimatedCostUsd;
|
|
1133
|
+
status.phase = ExecutionPhase.EXECUTION_TERMINATED;
|
|
1134
|
+
status.error = formatCostLimitError(maxCostUsd, estimated);
|
|
1135
|
+
status.completedAt = utcTimestamp();
|
|
1136
|
+
status.messages.push(create(AgentMessageSchema, {
|
|
1137
|
+
type: MessageType.MESSAGE_SYSTEM,
|
|
1138
|
+
content: COST_LIMIT_USER_COPY,
|
|
1139
|
+
timestamp: utcTimestamp(),
|
|
1140
|
+
}));
|
|
1141
|
+
await persist(status);
|
|
1142
|
+
try { resolution.agent.close(); } catch { /* best effort */ }
|
|
1143
|
+
console.warn(
|
|
1144
|
+
`ExecuteCursor terminated (cost cap): execution=${executionId}, ` +
|
|
1145
|
+
`estimatedCostUsd=${estimated.toFixed(4)}, maxCostUsd=${maxCostUsd.toFixed(2)}`,
|
|
1146
|
+
);
|
|
1147
|
+
return { kind: "return" };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1120
1150
|
// Worker shutdown: the runner-manager aborted the shutdown signal. NOT a
|
|
1121
1151
|
// user pause. Checked via the shutdown signal directly so a retry (whose
|
|
1122
1152
|
// periodic heartbeat is already stopped) still classifies it correctly.
|
|
@@ -45,6 +45,7 @@ import type { TodoTracker } from "./todo-tracker.js";
|
|
|
45
45
|
import type { StreamingUpdateScheduler } from "../../shared/streaming-scheduler.js";
|
|
46
46
|
import type { UsageAccumulator } from "./usage-accumulator.js";
|
|
47
47
|
import type { createCursorEventRecorder } from "./cursor-event-recorder.js";
|
|
48
|
+
import { costCapExceeded } from "./cost-guard.js";
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* The subset of the Cursor SDK `Run` the stream phase consumes. Kept structural
|
|
@@ -69,7 +70,8 @@ export type TurnStreamReason =
|
|
|
69
70
|
| "paused"
|
|
70
71
|
| "stalled"
|
|
71
72
|
| "first-denial"
|
|
72
|
-
| "platform-stop"
|
|
73
|
+
| "platform-stop"
|
|
74
|
+
| "cost-cap";
|
|
73
75
|
|
|
74
76
|
/**
|
|
75
77
|
* Single owner for every flag the turn's stream produces. Before this, these
|
|
@@ -97,6 +99,8 @@ export interface TurnStreamState {
|
|
|
97
99
|
denialCancelSettled: Promise<void> | undefined;
|
|
98
100
|
/** Written by: the loop (a platform STOP signal from persist). Read by: the loop + epilogue. */
|
|
99
101
|
platformStopSignaled: boolean;
|
|
102
|
+
/** Written by: onDelta (a turn-ended usage delta pushed the estimate past max_cost_usd). Read by: the loop (cancel + break) + epilogue. */
|
|
103
|
+
costCapExceeded: boolean;
|
|
100
104
|
/** Written by: the loop (a stream ERROR status event) + the retry setup (reset). Read by: error classification. */
|
|
101
105
|
streamErrorMessage: string | undefined;
|
|
102
106
|
/** Written by: the loop (each tool_call). Read by: the stall-watchdog (message enrichment). */
|
|
@@ -118,6 +122,7 @@ export function newTurnStreamState(): TurnStreamState {
|
|
|
118
122
|
denialLedgerDirty: false,
|
|
119
123
|
denialCancelSettled: undefined,
|
|
120
124
|
platformStopSignaled: false,
|
|
125
|
+
costCapExceeded: false,
|
|
121
126
|
streamErrorMessage: undefined,
|
|
122
127
|
lastToolName: undefined,
|
|
123
128
|
eventCount: 0,
|
|
@@ -139,6 +144,13 @@ export interface TurnOnDeltaDeps {
|
|
|
139
144
|
readonly promptEstimatedTokens: number;
|
|
140
145
|
readonly executionId: string;
|
|
141
146
|
readonly state: TurnStreamState;
|
|
147
|
+
/**
|
|
148
|
+
* ExecutionConfig.max_cost_usd — 0/unset disables the cap. Checked by
|
|
149
|
+
* onDelta after each turn-ended usage delta lands (the only point the
|
|
150
|
+
* running estimate advances); the loop performs the actual run.cancel().
|
|
151
|
+
* See cost-guard.ts for the semantics.
|
|
152
|
+
*/
|
|
153
|
+
readonly maxCostUsd: number;
|
|
142
154
|
}
|
|
143
155
|
|
|
144
156
|
export interface CursorTurnStreamDeps extends TurnOnDeltaDeps {
|
|
@@ -170,7 +182,7 @@ export interface CursorTurnStreamDeps extends TurnOnDeltaDeps {
|
|
|
170
182
|
export function makeCursorTurnOnDelta(
|
|
171
183
|
deps: TurnOnDeltaDeps,
|
|
172
184
|
): (event: { update: InteractionUpdate }) => void {
|
|
173
|
-
const { usageAccumulator, deltaEnricher, heartbeat, promptEstimatedTokens, executionId, state } =
|
|
185
|
+
const { usageAccumulator, deltaEnricher, heartbeat, promptEstimatedTokens, executionId, state, maxCostUsd } =
|
|
174
186
|
deps;
|
|
175
187
|
return ({ update }) => {
|
|
176
188
|
// Reset the stall timer on the delta channel too: a long model generation
|
|
@@ -180,6 +192,19 @@ export function makeCursorTurnOnDelta(
|
|
|
180
192
|
if (update.type === "turn-ended" && update.usage) {
|
|
181
193
|
usageAccumulator.addTurn(update.usage);
|
|
182
194
|
|
|
195
|
+
// max_cost_usd enforcement (cost-guard.ts): the running estimate only
|
|
196
|
+
// advances here, so this is the single check point. onDelta cannot reach
|
|
197
|
+
// the run — the loop reads the flag and performs the clean cancel.
|
|
198
|
+
if (!state.costCapExceeded
|
|
199
|
+
&& costCapExceeded(maxCostUsd, usageAccumulator.snapshot().estimatedCostUsd)) {
|
|
200
|
+
state.costCapExceeded = true;
|
|
201
|
+
console.warn(
|
|
202
|
+
`ExecuteCursor cost cap exceeded: execution=${executionId}, ` +
|
|
203
|
+
`estimatedCostUsd=${usageAccumulator.snapshot().estimatedCostUsd.toFixed(4)}, ` +
|
|
204
|
+
`maxCostUsd=${maxCostUsd.toFixed(2)}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
183
208
|
if (!state.firstTurnAttributionLogged) {
|
|
184
209
|
state.firstTurnAttributionLogged = true;
|
|
185
210
|
const sdkInputTokens = update.usage.inputTokens ?? 0;
|
|
@@ -272,6 +297,26 @@ export async function consumeCursorTurnStream(
|
|
|
272
297
|
}
|
|
273
298
|
if (state.stallDetected) break;
|
|
274
299
|
|
|
300
|
+
// Cost cap (cost-guard.ts): onDelta flagged the overrun on a turn-ended
|
|
301
|
+
// usage delta; end the run through the same clean-cancel pattern as the
|
|
302
|
+
// stall watchdog and the first-denial stop. Fire-and-forget is correct
|
|
303
|
+
// here — nothing downstream waits on this turn's agent (unlike the
|
|
304
|
+
// first-denial stop, whose ledger read needs a stopped agent).
|
|
305
|
+
if (state.costCapExceeded) {
|
|
306
|
+
console.log(
|
|
307
|
+
`ExecuteCursor stopping stream at cost cap: execution=${executionId}`,
|
|
308
|
+
);
|
|
309
|
+
if (run.supports?.("cancel")) {
|
|
310
|
+
void run.cancel().catch((cancelErr) => {
|
|
311
|
+
console.warn(
|
|
312
|
+
`ExecuteCursor run.cancel() after cost cap failed (non-fatal): execution=${executionId}, ` +
|
|
313
|
+
`error=${cancelErr instanceof Error ? cancelErr.message : cancelErr}`,
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
275
320
|
// Progress: reset the stall timer on every stream event.
|
|
276
321
|
state.stallWatchdog.recordActivity();
|
|
277
322
|
if (event.type === "tool_call" && typeof event.name === "string") {
|
|
@@ -397,14 +442,18 @@ export async function consumeCursorTurnStream(
|
|
|
397
442
|
}
|
|
398
443
|
}
|
|
399
444
|
} catch (streamErr) {
|
|
400
|
-
// run.cancel() — from the stall watchdog
|
|
401
|
-
// the stream iterator reject as it tears down;
|
|
402
|
-
// for
|
|
403
|
-
// failure — rethrow it to the
|
|
404
|
-
|
|
445
|
+
// run.cancel() — from the stall watchdog, the first-denial stop, or the
|
|
446
|
+
// cost-cap stop — can make the stream iterator reject as it tears down;
|
|
447
|
+
// that is the expected teardown for all three, so swallow it and fall
|
|
448
|
+
// through. Anything else is a genuine stream failure — rethrow it to the
|
|
449
|
+
// activity's error handler.
|
|
450
|
+
if (!state.stallDetected && !state.firstDenialDetected && !state.costCapExceeded) {
|
|
451
|
+
throw streamErr;
|
|
452
|
+
}
|
|
405
453
|
console.warn(
|
|
406
454
|
`ExecuteCursor stream ended via cancel: execution=${executionId}, ` +
|
|
407
|
-
`stall=${state.stallDetected}, firstDenial=${state.firstDenialDetected}
|
|
455
|
+
`stall=${state.stallDetected}, firstDenial=${state.firstDenialDetected}, ` +
|
|
456
|
+
`costCap=${state.costCapExceeded}`,
|
|
408
457
|
);
|
|
409
458
|
} finally {
|
|
410
459
|
state.stallWatchdog.stop();
|
|
@@ -412,10 +461,12 @@ export async function consumeCursorTurnStream(
|
|
|
412
461
|
|
|
413
462
|
// Report why the stream ended, in the same precedence the activity's epilogue
|
|
414
463
|
// applies: a stall (an EXECUTION_FAILED terminal) outranks everything; a
|
|
415
|
-
// platform stop and a first denial are distinct
|
|
416
|
-
// pause is the fallback for a cancellation with
|
|
464
|
+
// platform stop, a cost-cap stop, and a first denial are distinct
|
|
465
|
+
// return/proceed outcomes; a pause is the fallback for a cancellation with
|
|
466
|
+
// no other cause.
|
|
417
467
|
if (state.stallDetected) return "stalled";
|
|
418
468
|
if (state.platformStopSignaled) return "platform-stop";
|
|
469
|
+
if (state.costCapExceeded) return "cost-cap";
|
|
419
470
|
if (state.firstDenialDetected) return "first-denial";
|
|
420
471
|
if (state.pauseDetected || isCancelled()) return "paused";
|
|
421
472
|
return "completed";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { create } from "@bufbuild/protobuf";
|
|
3
|
+
import { AgentExecutionStatusSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
4
|
+
import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
|
+
import {
|
|
6
|
+
TOOL_CALL_LIMIT_ERROR_PREFIX,
|
|
7
|
+
handleRecursionLimit,
|
|
8
|
+
} from "../streaming-terminal.js";
|
|
9
|
+
import type { ExecutionStatusWriter } from "../../../shared/execution-status-writer.js";
|
|
10
|
+
|
|
11
|
+
function writerWith(status = create(AgentExecutionStatusSchema, {})): ExecutionStatusWriter {
|
|
12
|
+
return { currentStatus: status } as ExecutionStatusWriter;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("handleRecursionLimit", () => {
|
|
16
|
+
it("terminates with work saved and a continue prompt", () => {
|
|
17
|
+
const writer = writerWith();
|
|
18
|
+
|
|
19
|
+
const result = handleRecursionLimit(writer, 42, [], []);
|
|
20
|
+
|
|
21
|
+
expect(writer.currentStatus.phase).toBe(ExecutionPhase.EXECUTION_TERMINATED);
|
|
22
|
+
expect(writer.currentStatus.completedAt).toBeDefined();
|
|
23
|
+
expect(result.terminalStatus).toBeDefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("pins the cross-repo error prefix that Stigmer Cloud's channel delivery matches on", () => {
|
|
27
|
+
// ChannelReplyExtractor (stigmer-cloud) distinguishes budget-limit
|
|
28
|
+
// TERMINATED from other causes by this prefix — there is no structured
|
|
29
|
+
// termination reason on AgentExecutionStatus. A reword here silently
|
|
30
|
+
// downgrades channel users from the friendly limit copy to generic
|
|
31
|
+
// error copy, so the prefix is pinned on both sides.
|
|
32
|
+
expect(TOOL_CALL_LIMIT_ERROR_PREFIX).toBe("Agent reached the tool-call limit");
|
|
33
|
+
|
|
34
|
+
const writer = writerWith();
|
|
35
|
+
handleRecursionLimit(writer, 7, [], []);
|
|
36
|
+
|
|
37
|
+
expect(writer.currentStatus.error).toMatch(/^Agent reached the tool-call limit/);
|
|
38
|
+
expect(writer.currentStatus.error).toContain("Send another message to continue.");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -73,6 +73,10 @@ import {
|
|
|
73
73
|
import { filterSkills, SKILL_COUNT_THRESHOLD } from "../../shared/skill-relevance.js";
|
|
74
74
|
import { injectAttachments } from "./attachment-injector.js";
|
|
75
75
|
import { transformAndCompileSubagents } from "./subagent-transformer.js";
|
|
76
|
+
import {
|
|
77
|
+
resolveRecursionLimit,
|
|
78
|
+
UNBOUNDED_ADVISORY_RECURSION_LIMIT,
|
|
79
|
+
} from "../../shared/tool-rounds.js";
|
|
76
80
|
|
|
77
81
|
export interface SetupResult {
|
|
78
82
|
readonly agentGraph: AgentGraph;
|
|
@@ -472,6 +476,11 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
472
476
|
: null;
|
|
473
477
|
|
|
474
478
|
const maxCostUsd = execConfig?.maxCostUsd ?? 0;
|
|
479
|
+
// max_tool_rounds → recursion limit (proto contract: 0/unset = unlimited,
|
|
480
|
+
// set = clamped 10–1000 and enforced). One resolved value feeds BOTH the
|
|
481
|
+
// hard stop on the invoke config below and the budget middleware's ~80%
|
|
482
|
+
// wrap-up advisory, so the warning and the enforcement can never disagree.
|
|
483
|
+
const recursionLimit = resolveRecursionLimit(execConfig?.maxToolRounds);
|
|
475
484
|
const { middleware, gracefulStop, costCap: costCapMiddleware } = buildMiddlewareStack({
|
|
476
485
|
loopDetection: {
|
|
477
486
|
historySize: 20,
|
|
@@ -479,9 +488,7 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
479
488
|
totalThreshold: 20,
|
|
480
489
|
},
|
|
481
490
|
executionBudget: {
|
|
482
|
-
recursionLimit:
|
|
483
|
-
? execConfig.maxToolRounds * 6
|
|
484
|
-
: 6000,
|
|
491
|
+
recursionLimit: recursionLimit ?? UNBOUNDED_ADVISORY_RECURSION_LIMIT,
|
|
485
492
|
warningPct: 80,
|
|
486
493
|
},
|
|
487
494
|
toolTruncation: {
|
|
@@ -600,6 +607,12 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
600
607
|
|
|
601
608
|
const langgraphConfig: Record<string, unknown> = {
|
|
602
609
|
configurable: { thread_id: threadId },
|
|
610
|
+
// Hard enforcement of max_tool_rounds (null = unlimited, the default):
|
|
611
|
+
// when the graph exhausts this, the streaming loop's GraphRecursionError
|
|
612
|
+
// handler terminates gracefully with work saved ("send another message
|
|
613
|
+
// to continue"). The middleware's wrap-up advisory fires at ~80% of the
|
|
614
|
+
// same value, so a healthy run finishes normally before ever hitting it.
|
|
615
|
+
...(recursionLimit !== null ? { recursionLimit } : {}),
|
|
603
616
|
};
|
|
604
617
|
|
|
605
618
|
const streamVersion: "v2" | "v3" =
|
|
@@ -57,6 +57,16 @@ export function handleStop(
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Stable prefix of the recursion-limit terminal error. This is a cross-repo
|
|
62
|
+
* contract: consumers that need to distinguish "ran out of tool-call budget"
|
|
63
|
+
* from other TERMINATED causes (e.g. Stigmer Cloud's ChannelReplyExtractor,
|
|
64
|
+
* which shows channel users a friendly limit message instead of generic
|
|
65
|
+
* error copy) match on this prefix, because AgentExecutionStatus carries no
|
|
66
|
+
* structured termination reason. Do not reword without updating them.
|
|
67
|
+
*/
|
|
68
|
+
export const TOOL_CALL_LIMIT_ERROR_PREFIX = "Agent reached the tool-call limit";
|
|
69
|
+
|
|
60
70
|
export function handleRecursionLimit(
|
|
61
71
|
writer: ExecutionStatusWriter,
|
|
62
72
|
eventsProcessed: number,
|
|
@@ -67,7 +77,7 @@ export function handleRecursionLimit(
|
|
|
67
77
|
status.phase = ExecutionPhase.EXECUTION_TERMINATED;
|
|
68
78
|
status.completedAt = utcTimestamp();
|
|
69
79
|
status.error =
|
|
70
|
-
|
|
80
|
+
`${TOOL_CALL_LIMIT_ERROR_PREFIX} after processing ${eventsProcessed} events. ` +
|
|
71
81
|
`Send another message to continue.`;
|
|
72
82
|
status.messages.push(create(AgentMessageSchema, {
|
|
73
83
|
type: MessageType.MESSAGE_SYSTEM,
|
package/src/main.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { createHash } from "node:crypto";
|
|
|
22
22
|
import { readFileSync, readdirSync } from "node:fs";
|
|
23
23
|
import { join, relative } from "node:path";
|
|
24
24
|
import { createInterface } from "node:readline";
|
|
25
|
+
import { preflightNodeRuntime } from "./preflight.js";
|
|
25
26
|
import { loadConfig } from "./config.js";
|
|
26
27
|
import { initTracing, initMetrics } from "./otel.js";
|
|
27
28
|
import { createStigmerRunner } from "./runner.js";
|
|
@@ -283,6 +284,22 @@ function collectTsFiles(dir: string, files: string[] = []): string[] {
|
|
|
283
284
|
// ─── Entry Point ─────────────────────────────────────────────────────────────
|
|
284
285
|
|
|
285
286
|
async function main(): Promise<void> {
|
|
287
|
+
// Runtime capability gate FIRST: everything below (config load, manager /
|
|
288
|
+
// static init) eventually imports node:sqlite via the checkpointer chain,
|
|
289
|
+
// and the raw ERR_UNKNOWN_BUILTIN_MODULE from that import is inscrutable.
|
|
290
|
+
// In manager mode the host reads the first stdout line as the handshake, so
|
|
291
|
+
// a fatal IPC error here surfaces verbatim in the host's UI (see
|
|
292
|
+
// negotiate_ready in crates/stigmer-runner-host/src/host.rs).
|
|
293
|
+
const preflightError = preflightNodeRuntime();
|
|
294
|
+
if (preflightError !== null) {
|
|
295
|
+
if (process.env.STIGMER_RUNNER_MODE === "manager") {
|
|
296
|
+
sendIpc({ type: "error", message: preflightError, fatal: true });
|
|
297
|
+
} else {
|
|
298
|
+
writeStderr(`${preflightError}\n`);
|
|
299
|
+
}
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
|
|
286
303
|
checkBuildFreshness();
|
|
287
304
|
|
|
288
305
|
const config = loadConfig();
|
package/src/preflight.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boot preflight: verify the Node runtime can run this runner BEFORE the deep
|
|
3
|
+
* import chains load.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: the durable local checkpointer (shared/checkpointer/
|
|
6
|
+
* sqlite-saver.ts) imports Node's built-in `node:sqlite` at module load time,
|
|
7
|
+
* and both the manager and the static runner eagerly import that chain during
|
|
8
|
+
* initialization. On a Node without unflagged `node:sqlite` — anything below
|
|
9
|
+
* 22.13, and 23.0–23.3 (the 23.x line only gained it in 23.4) — that import
|
|
10
|
+
* throws a raw `ERR_UNKNOWN_BUILTIN_MODULE` deep inside init, which surfaces
|
|
11
|
+
* to hosts (e.g. the desktop Run dialog) as an inscrutable internal error.
|
|
12
|
+
* This gate turns it into an actionable "your Node cannot run the runner"
|
|
13
|
+
* message on the very first line of output.
|
|
14
|
+
*
|
|
15
|
+
* Why a capability probe and not a version check: version gating is exactly
|
|
16
|
+
* what let this failure ship — the CLI's node resolver encoded a 22.13 floor
|
|
17
|
+
* but its table missed the 23.0–23.3 gap. Probing the capability itself
|
|
18
|
+
* ("can this binary provide node:sqlite?") cannot drift. The version floors
|
|
19
|
+
* appear only in the human-facing message, where staleness is harmless.
|
|
20
|
+
*
|
|
21
|
+
* The gate is unconditional (not checkpointer-type-dependent): the eager
|
|
22
|
+
* import chain needs `node:sqlite` to LOAD regardless of configuration, so
|
|
23
|
+
* the gate mirrors the actual load-time requirement.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when this Node binary provides the built-in `node:sqlite`.
|
|
28
|
+
*
|
|
29
|
+
* Probe notes (each verified against real binaries; do not "simplify"):
|
|
30
|
+
* - `process.getBuiltinModule(id)` returns the module when present and
|
|
31
|
+
* `undefined` when absent — no throw. The `?.` guard covers Nodes older
|
|
32
|
+
* than 22.3, where `getBuiltinModule` itself does not exist (there
|
|
33
|
+
* `node:sqlite` is absent too, so reporting "unsupported" is correct).
|
|
34
|
+
* - `module.builtinModules` is NOT a valid alternative: it omits experimental
|
|
35
|
+
* builtins, so it reports no `sqlite` even on Nodes where `node:sqlite`
|
|
36
|
+
* loads fine (e.g. 22.13+). Gating on it would reject every supported Node.
|
|
37
|
+
* - The probe loads the module, which on 22.x emits a one-time
|
|
38
|
+
* `ExperimentalWarning` on stderr. The runner loads `node:sqlite` eagerly
|
|
39
|
+
* moments later anyway, so this only moves that warning to boot.
|
|
40
|
+
*/
|
|
41
|
+
export function isNodeSqliteAvailable(): boolean {
|
|
42
|
+
return process.getBuiltinModule?.("node:sqlite") !== undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check the current runtime, returning `null` when it can run the runner or
|
|
47
|
+
* an actionable operator-facing message when it cannot. The probe is
|
|
48
|
+
* injectable so tests can exercise the failure path on a supported Node.
|
|
49
|
+
*/
|
|
50
|
+
export function preflightNodeRuntime(
|
|
51
|
+
isSqliteAvailable: () => boolean = isNodeSqliteAvailable,
|
|
52
|
+
): string | null {
|
|
53
|
+
if (isSqliteAvailable()) return null;
|
|
54
|
+
return (
|
|
55
|
+
`Node v${process.versions.node} does not provide the built-in node:sqlite ` +
|
|
56
|
+
`module required by the runner's durable checkpointer. ` +
|
|
57
|
+
`Use Node >= 22.13 (22.x line) or >= 23.4 (23.x and later).`
|
|
58
|
+
);
|
|
59
|
+
}
|