@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.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/execute-cursor/cost-guard.d.ts +41 -0
  4. package/dist/activities/execute-cursor/cost-guard.js +50 -0
  5. package/dist/activities/execute-cursor/cost-guard.js.map +1 -0
  6. package/dist/activities/execute-cursor/index.js +30 -0
  7. package/dist/activities/execute-cursor/index.js.map +1 -1
  8. package/dist/activities/execute-cursor/turn-stream.d.ts +10 -1
  9. package/dist/activities/execute-cursor/turn-stream.js +42 -9
  10. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  11. package/dist/activities/execute-deep-agent/setup.js +13 -3
  12. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/streaming-terminal.d.ts +9 -0
  14. package/dist/activities/execute-deep-agent/streaming-terminal.js +10 -1
  15. package/dist/activities/execute-deep-agent/streaming-terminal.js.map +1 -1
  16. package/dist/main.js +17 -0
  17. package/dist/main.js.map +1 -1
  18. package/dist/preflight.d.ts +46 -0
  19. package/dist/preflight.js +55 -0
  20. package/dist/preflight.js.map +1 -0
  21. package/dist/shared/tool-rounds.d.ts +30 -0
  22. package/dist/shared/tool-rounds.js +49 -0
  23. package/dist/shared/tool-rounds.js.map +1 -0
  24. package/package.json +2 -2
  25. package/src/__tests__/preflight.test.ts +41 -0
  26. package/src/activities/execute-cursor/__tests__/cost-guard.test.ts +64 -0
  27. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +118 -0
  28. package/src/activities/execute-cursor/cost-guard.ts +56 -0
  29. package/src/activities/execute-cursor/index.ts +30 -0
  30. package/src/activities/execute-cursor/turn-stream.ts +61 -10
  31. package/src/activities/execute-deep-agent/__tests__/streaming-terminal.test.ts +40 -0
  32. package/src/activities/execute-deep-agent/setup.ts +16 -3
  33. package/src/activities/execute-deep-agent/streaming-terminal.ts +11 -1
  34. package/src/main.ts +17 -0
  35. package/src/preflight.ts +59 -0
  36. package/src/shared/__tests__/tool-rounds.test.ts +57 -0
  37. 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 or the first-denial stop can make
401
- // the stream iterator reject as it tears down; that is the expected teardown
402
- // for both, so swallow it and fall through. Anything else is a genuine stream
403
- // failure — rethrow it to the activity's error handler.
404
- if (!state.stallDetected && !state.firstDenialDetected) throw streamErr;
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 proceed/return outcomes; a
416
- // pause is the fallback for a cancellation with no other cause.
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: execConfig?.maxToolRounds
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
- `Agent reached the tool-call limit after processing ${eventsProcessed} events. ` +
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();
@@ -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
+ }