@stigmer/runner 3.2.0 → 3.2.2

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 (95) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-transform.js +12 -2
  3. package/dist/activities/call-transform.js.map +1 -1
  4. package/dist/activities/call-validate.js +20 -2
  5. package/dist/activities/call-validate.js.map +1 -1
  6. package/dist/activities/discover-mcp-server.js +6 -20
  7. package/dist/activities/discover-mcp-server.js.map +1 -1
  8. package/dist/activities/execute-cursor/cost-guard.d.ts +41 -0
  9. package/dist/activities/execute-cursor/cost-guard.js +50 -0
  10. package/dist/activities/execute-cursor/cost-guard.js.map +1 -0
  11. package/dist/activities/execute-cursor/index.d.ts +12 -0
  12. package/dist/activities/execute-cursor/index.js +83 -12
  13. package/dist/activities/execute-cursor/index.js.map +1 -1
  14. package/dist/activities/execute-cursor/prompt-builder.d.ts +19 -0
  15. package/dist/activities/execute-cursor/prompt-builder.js +21 -0
  16. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  17. package/dist/activities/execute-cursor/turn-stream.d.ts +10 -1
  18. package/dist/activities/execute-cursor/turn-stream.js +42 -9
  19. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  20. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +19 -0
  21. package/dist/activities/execute-deep-agent/prompt-builder.js +12 -0
  22. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/setup.js +4 -0
  24. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  25. package/dist/activities/workflow-event-activities.js +2 -0
  26. package/dist/activities/workflow-event-activities.js.map +1 -1
  27. package/dist/config.d.ts +17 -0
  28. package/dist/config.js +14 -0
  29. package/dist/config.js.map +1 -1
  30. package/dist/runner-manager.d.ts +2 -0
  31. package/dist/runner-manager.js +2 -1
  32. package/dist/runner-manager.js.map +1 -1
  33. package/dist/runner.d.ts +2 -0
  34. package/dist/runner.js +2 -1
  35. package/dist/runner.js.map +1 -1
  36. package/dist/shared/context-bridge.d.ts +30 -0
  37. package/dist/shared/context-bridge.js +45 -0
  38. package/dist/shared/context-bridge.js.map +1 -0
  39. package/dist/shared/sender-identity.d.ts +50 -0
  40. package/dist/shared/sender-identity.js +69 -0
  41. package/dist/shared/sender-identity.js.map +1 -0
  42. package/dist/shared/with-timeout.d.ts +17 -0
  43. package/dist/shared/with-timeout.js +34 -0
  44. package/dist/shared/with-timeout.js.map +1 -0
  45. package/dist/workflow-engine/do-executor.d.ts +7 -0
  46. package/dist/workflow-engine/do-executor.js +59 -1
  47. package/dist/workflow-engine/do-executor.js.map +1 -1
  48. package/dist/workflow-engine/tasks/call-function.js +68 -1
  49. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  50. package/dist/workflow-engine/types.d.ts +26 -0
  51. package/dist/workflow-engine/types.js.map +1 -1
  52. package/dist/workflows/engine-core.js +5 -1
  53. package/dist/workflows/engine-core.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/__tests__/config.test.ts +8 -0
  56. package/src/activities/__tests__/call-validate.test.ts +137 -0
  57. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  58. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  59. package/src/activities/__tests__/workflow-event-activities.test.ts +60 -0
  60. package/src/activities/call-transform.ts +20 -2
  61. package/src/activities/call-validate.ts +27 -2
  62. package/src/activities/discover-mcp-server.ts +7 -28
  63. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +65 -0
  64. package/src/activities/execute-cursor/__tests__/cost-guard.test.ts +64 -0
  65. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +118 -0
  66. package/src/activities/execute-cursor/cost-guard.ts +56 -0
  67. package/src/activities/execute-cursor/index.ts +101 -15
  68. package/src/activities/execute-cursor/prompt-builder.ts +44 -0
  69. package/src/activities/execute-cursor/turn-stream.ts +61 -10
  70. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  71. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  72. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  73. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  74. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +58 -0
  75. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  76. package/src/activities/execute-deep-agent/prompt-builder.ts +35 -0
  77. package/src/activities/execute-deep-agent/setup.ts +4 -0
  78. package/src/activities/workflow-event-activities.ts +2 -0
  79. package/src/config.ts +23 -0
  80. package/src/runner-manager.ts +6 -1
  81. package/src/runner.ts +6 -1
  82. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  83. package/src/shared/__tests__/context-bridge.test.ts +51 -0
  84. package/src/shared/__tests__/sender-identity.test.ts +92 -0
  85. package/src/shared/__tests__/with-timeout.test.ts +45 -0
  86. package/src/shared/context-bridge.ts +51 -0
  87. package/src/shared/sender-identity.ts +85 -0
  88. package/src/shared/with-timeout.ts +39 -0
  89. package/src/workflow-engine/__tests__/do-executor.test.ts +155 -0
  90. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +94 -0
  91. package/src/workflow-engine/do-executor.ts +65 -1
  92. package/src/workflow-engine/tasks/call-function.ts +84 -3
  93. package/src/workflow-engine/types.ts +27 -0
  94. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
  95. package/src/workflows/engine-core.ts +5 -1
@@ -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.";
@@ -45,6 +45,8 @@ import { CursorMode } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_p
45
45
  import { determineCursorMode, isCloudMode } from "./cursor-mode.js";
46
46
  import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantToolCallTwins } from "./message-translator.js";
47
47
  import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
48
+ import { readContextBridge } from "../../shared/context-bridge.js";
49
+ import { readSenderIdentity } from "../../shared/sender-identity.js";
48
50
  import { withholdSecretContentFromMessages } from "../../shared/tool-row.js";
49
51
  import { StallTimeoutError, formatStallFailure } from "../../shared/stall-watchdog.js";
50
52
  import { resolveUsableArtifactStorage, loadArtifactStorageConfig, type ArtifactStorage } from "../../shared/artifact-storage.js";
@@ -90,6 +92,7 @@ import {
90
92
  type CursorTurnStreamDeps,
91
93
  type TurnOnDeltaDeps,
92
94
  } from "./turn-stream.js";
95
+ import { formatCostLimitError, COST_LIMIT_USER_COPY } from "./cost-guard.js";
93
96
  import {
94
97
  captureFileChangeProgress,
95
98
  newProgressCaptureState,
@@ -114,6 +117,7 @@ import type { ClassifiedError } from "./error-classifier.js";
114
117
  import { createAgent, createCloudAgent } from "./session-lifecycle.js";
115
118
  import { setMaxListeners } from "node:events";
116
119
  import { startHeartbeat } from "../../shared/heartbeat.js";
120
+ import { withTimeout } from "../../shared/with-timeout.js";
117
121
  import { getShutdownSignalForQueue } from "../../runner-manager.js";
118
122
 
119
123
  /**
@@ -256,6 +260,25 @@ async function executeCursorInner(
256
260
  // can be classified with the same context as the run.wait() error path.
257
261
  let errorContext = { model: "default", mode: "local", agentId: "" };
258
262
 
263
+ // Periodic heartbeat for the ENTIRE activity, started before any phase runs.
264
+ // Setup phases make network calls (blueprint resolution, workspace clone, MCP
265
+ // backfill, Agent.create) that can stall; the scattered manual heartbeat()
266
+ // pulses between them leave every individual call uncovered. The production
267
+ // stale-proxy incident hung inside Agent.create with zero heartbeats and
268
+ // surfaced as an opaque 5-minute Temporal timeout. The label names the
269
+ // current phase so a stall is attributed in Temporal heartbeat details, and
270
+ // cancellation stays observable throughout. Safe ONLY because every SDK call
271
+ // below is itself bounded (agentResolveTimeoutMs, stall watchdog) — an
272
+ // unbounded hang under a live heartbeat would keep a dead activity alive
273
+ // forever.
274
+ let heartbeatPhase = "setup";
275
+ const taskQueue = Context.current().info.taskQueue;
276
+ const shutdownSignal = getShutdownSignalForQueue(taskQueue);
277
+ periodicHeartbeat = startHeartbeat(30_000, () => ({
278
+ phase: heartbeatPhase,
279
+ execution: executionId,
280
+ }), { shutdownSignal });
281
+
259
282
  try {
260
283
  // Phase 1: Hydrate execution from DB
261
284
  await reportSetupProgress(client, executionId, "Fetching execution");
@@ -269,6 +292,7 @@ async function executeCursorInner(
269
292
  const blueprint = await resolveBlueprint(client, session, config.workspaceRootDir);
270
293
 
271
294
  // Phase 2b: Resolve execution environment (MCP server credentials)
295
+ heartbeatPhase = "resolving_environment";
272
296
  await reportSetupProgress(client, executionId, "Resolving environment");
273
297
  const { envVars, secretKeys } = await resolveExecutionEnv(client, executionId);
274
298
  heartbeat();
@@ -279,6 +303,7 @@ async function executeCursorInner(
279
303
  // disabled the runner must provision the workspace itself, mirroring the
280
304
  // native harness. Git provisioning is idempotent across multi-turn and
281
305
  // HITL reinvocations.
306
+ heartbeatPhase = "provisioning_workspace";
282
307
  await reportSetupProgress(client, executionId, "Provisioning workspace");
283
308
  const workspaceProvision = await provisionCursorWorkspace(
284
309
  config, session, envVars, sessionId ?? "",
@@ -553,6 +578,7 @@ async function executeCursorInner(
553
578
  );
554
579
 
555
580
  // Phase 4a: Connect backfill for undiscovered MCP servers
581
+ heartbeatPhase = "resolving_mcp_servers";
556
582
  const sessionOrg = session.metadata?.org ?? "";
557
583
  mcpResolution = await backfillMcpServersIfNeeded(
558
584
  client, mcpResolution, blueprint.mergedMcpServerUsages, envVars, sessionOrg,
@@ -814,10 +840,22 @@ async function executeCursorInner(
814
840
  agents: cursorSubAgents,
815
841
  };
816
842
 
817
- let resolution: AgentResolution = await resolveAgent(
818
- threadId,
819
- createOptions,
820
- agentMode,
843
+ // Agent.create/Agent.resume have no timeout of their own — a degraded
844
+ // transport (dead proxy connection, stale HTTP/2 session) hangs them
845
+ // forever, which the periodic heartbeat would happily keep alive. The
846
+ // bound converts that hang into an immediate, named transport failure.
847
+ // The message carries "timed out" so the error classifier's network
848
+ // patterns mark it retryable.
849
+ heartbeatPhase = "resolving_agent";
850
+ const resolveTimeoutSeconds = Math.round(config.agentResolveTimeoutMs / 1000);
851
+ let resolution: AgentResolution = await withTimeout(
852
+ config.agentResolveTimeoutMs,
853
+ () =>
854
+ `Cursor agent ${threadId ? "resume" : "create"} timed out after ${resolveTimeoutSeconds}s ` +
855
+ `(${config.proxyEndpoint ? `via proxy ${config.proxyEndpoint}` : "direct Cursor API connection"}). ` +
856
+ `The transport connection is likely dead. Retry the message; if this persists, ` +
857
+ `check proxy and network health.`,
858
+ () => resolveAgent(threadId, createOptions, agentMode),
821
859
  );
822
860
 
823
861
  console.log(
@@ -877,6 +915,8 @@ async function executeCursorInner(
877
915
  appliedToolCallIds,
878
916
  interactionMode,
879
917
  buildFromPlan,
918
+ contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
919
+ senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
880
920
  });
881
921
 
882
922
  // Phase 10a: Inject structured output instruction for Cursor harness
@@ -950,6 +990,7 @@ async function executeCursorInner(
950
990
  // The shared onDelta only needs the usage/enricher/heartbeat/state subset,
951
991
  // and it is wired at SEND time — before the accumulator exists — so it takes
952
992
  // the narrow deps. The primary send and both retry sends reuse this object.
993
+ const maxCostUsd = spec.executionConfig?.maxCostUsd ?? 0;
953
994
  const onDeltaDeps: TurnOnDeltaDeps = {
954
995
  usageAccumulator,
955
996
  deltaEnricher,
@@ -957,18 +998,13 @@ async function executeCursorInner(
957
998
  promptEstimatedTokens,
958
999
  executionId,
959
1000
  state: turnState,
1001
+ maxCostUsd,
960
1002
  };
961
1003
 
962
- // Periodic heartbeat keeps Temporal informed during silent SDK operations
963
- // (e.g. long tool calls, MCP requests, model thinking). Without this,
964
- // the 2-minute heartbeat timeout can cancel the activity and mislabel
965
- // the execution as "paused by user".
966
- const taskQueue = Context.current().info.taskQueue;
967
- const shutdownSignal = getShutdownSignalForQueue(taskQueue);
968
- periodicHeartbeat = startHeartbeat(30_000, () => ({
969
- phase: "cursor_streaming",
970
- execution: executionId,
971
- }), { shutdownSignal });
1004
+ // The activity-wide periodic heartbeat (started at entry) keeps Temporal
1005
+ // informed during silent SDK operations (long tool calls, MCP requests,
1006
+ // model thinking); relabel it for the streaming phase.
1007
+ heartbeatPhase = "cursor_streaming";
972
1008
 
973
1009
  // The Cursor SDK registers abort listeners on the cancellation signal for
974
1010
  // each concurrent tool call (fetch, MCP, shell). With 10+ parallel tools,
@@ -1067,6 +1103,7 @@ async function executeCursorInner(
1067
1103
  turnState.pauseDetected ||
1068
1104
  workerShutdownDetected ||
1069
1105
  turnState.stallDetected ||
1106
+ turnState.costCapExceeded ||
1070
1107
  Context.current().cancellationSignal.aborted
1071
1108
  ) {
1072
1109
  accumulator.cancelInProgressSubAgents();
@@ -1117,6 +1154,32 @@ async function executeCursorInner(
1117
1154
  return { kind: "return" };
1118
1155
  }
1119
1156
 
1157
+ // Cost cap (cost-guard.ts): onDelta flagged the overrun and the loop
1158
+ // cancelled the run. EXECUTION_TERMINATED, not FAILED — the platform
1159
+ // deliberately stopped the run, work is checkpointed, and the
1160
+ // conversation continues on the next message (the recursion-limit
1161
+ // precedent in execute-deep-agent/streaming-terminal.ts). RETURN (not
1162
+ // throw): a Temporal retry would re-run the identical prompt and burn
1163
+ // the same budget again.
1164
+ if (turnState.costCapExceeded) {
1165
+ const estimated = usageAccumulator.snapshot().estimatedCostUsd;
1166
+ status.phase = ExecutionPhase.EXECUTION_TERMINATED;
1167
+ status.error = formatCostLimitError(maxCostUsd, estimated);
1168
+ status.completedAt = utcTimestamp();
1169
+ status.messages.push(create(AgentMessageSchema, {
1170
+ type: MessageType.MESSAGE_SYSTEM,
1171
+ content: COST_LIMIT_USER_COPY,
1172
+ timestamp: utcTimestamp(),
1173
+ }));
1174
+ await persist(status);
1175
+ try { resolution.agent.close(); } catch { /* best effort */ }
1176
+ console.warn(
1177
+ `ExecuteCursor terminated (cost cap): execution=${executionId}, ` +
1178
+ `estimatedCostUsd=${estimated.toFixed(4)}, maxCostUsd=${maxCostUsd.toFixed(2)}`,
1179
+ );
1180
+ return { kind: "return" };
1181
+ }
1182
+
1120
1183
  // Worker shutdown: the runner-manager aborted the shutdown signal. NOT a
1121
1184
  // user pause. Checked via the shutdown signal directly so a retry (whose
1122
1185
  // periodic heartbeat is already stopped) still classifies it correctly.
@@ -1430,6 +1493,8 @@ async function executeCursorInner(
1430
1493
  attachmentPaths,
1431
1494
  pendingApprovals: adjudicatedApprovals,
1432
1495
  interactionMode,
1496
+ contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
1497
+ senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1433
1498
  });
1434
1499
 
1435
1500
  console.log(
@@ -1836,6 +1901,12 @@ async function executeCursorInner(
1836
1901
 
1837
1902
  return slimStatus(status);
1838
1903
  } finally {
1904
+ // Stop the activity-wide periodic heartbeat on EVERY exit path
1905
+ // (idempotent). The epilogue and catch stop it at the pause/shutdown
1906
+ // disambiguation points; this covers early returns (e.g. the
1907
+ // pure-reconcile resume) so no orphaned timer survives the activity.
1908
+ periodicHeartbeat?.stop();
1909
+
1839
1910
  // End the OTel turn span + record metrics with the final token snapshot on
1840
1911
  // EVERY exit path (idempotent). Placed here so the span covers any recovery
1841
1912
  // retry (whose tokens accrue after the primary stream) and never leaks on an
@@ -1989,6 +2060,18 @@ export interface BuildPromptInput {
1989
2060
  * .build_from_plan): both prompt paths carry the implement-plan directive.
1990
2061
  */
1991
2062
  buildFromPlan?: boolean;
2063
+ /**
2064
+ * Rollover context bridge from `SessionSpec.metadata` (cloud DD-013).
2065
+ * Only the enhanced-prompt path consumes it — a resumed agent's native
2066
+ * context IS the previous conversation, so it needs no bridge.
2067
+ */
2068
+ contextBridge?: string;
2069
+ /**
2070
+ * Channel sender identity from `SessionSpec.metadata`. Like the bridge,
2071
+ * only the enhanced-prompt path consumes it — a resumed agent's native
2072
+ * context already carries it from the session's first turn.
2073
+ */
2074
+ senderIdentity?: import("../../shared/sender-identity.js").SenderIdentity;
1992
2075
  }
1993
2076
 
1994
2077
  /**
@@ -2053,7 +2136,8 @@ export function buildPrompt(input: BuildPromptInput): string {
2053
2136
  }
2054
2137
 
2055
2138
  // First execution, or a fresh agent created after a resume failure: there is
2056
- // no prior conversation to inherit, so start a new turn with full context.
2139
+ // no prior conversation to inherit, so start a new turn with full context
2140
+ // including the rollover bridge, when the session carries one.
2057
2141
  return buildEnhancedPrompt({
2058
2142
  instructions,
2059
2143
  userMessage,
@@ -2064,6 +2148,8 @@ export function buildPrompt(input: BuildPromptInput): string {
2064
2148
  attachmentPaths,
2065
2149
  interactionMode,
2066
2150
  buildFromPlan,
2151
+ contextBridge: input.contextBridge,
2152
+ senderIdentity: input.senderIdentity,
2067
2153
  });
2068
2154
  }
2069
2155
 
@@ -20,6 +20,11 @@ import { resolve } from "node:path";
20
20
  import type { SubAgent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
21
21
  import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
22
22
  import { ApprovalAction, InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
23
+ import { formatContextBridgeText } from "../../shared/context-bridge.js";
24
+ import {
25
+ formatSenderIdentityText,
26
+ type SenderIdentity,
27
+ } from "../../shared/sender-identity.js";
23
28
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
24
29
  import {
25
30
  buildImplementPlanDirective,
@@ -60,6 +65,22 @@ export interface EnhancedPromptOptions {
60
65
  * The user message itself is just a short label ("Build from plan").
61
66
  */
62
67
  buildFromPlan?: boolean;
68
+ /**
69
+ * Rollover context bridge (cloud DD-013): a digest of the previous
70
+ * session's conversation, read from `SessionSpec.metadata`. Lands in the
71
+ * first message, so it persists in the cursor agent's own conversation
72
+ * store for the session's lifetime — buildEnhancedPrompt firing only on
73
+ * first/fresh executions is exactly the right delivery.
74
+ */
75
+ contextBridge?: string;
76
+ /**
77
+ * Channel sender identity (attribution, not authorization): the
78
+ * provider-verified identifier of the person on the channel, read from
79
+ * `SessionSpec.metadata`. Like the bridge, it lands in the first message
80
+ * and persists in the cursor agent's own conversation store — identity
81
+ * is constant per conversation (channel sessions are keyed per-sender).
82
+ */
83
+ senderIdentity?: SenderIdentity;
63
84
  }
64
85
 
65
86
  /**
@@ -119,6 +140,21 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
119
140
  sections.push(formatResponseRules());
120
141
  }
121
142
 
143
+ // The sender identity precedes the bridge: it is standing context about
144
+ // WHO the conversation is with, which the carried conversation may refer
145
+ // back to.
146
+ if (options.senderIdentity) {
147
+ sections.push(formatSenderIdentitySection(options.senderIdentity));
148
+ }
149
+
150
+ // The rollover context bridge sits directly before the protocol + task so
151
+ // the carried conversation is the freshest CONTEXT the model reads —
152
+ // while the approval protocol keeps its pinned last-before-task slot (it
153
+ // is an INSTRUCTION and must outweigh everything, including this bridge).
154
+ if (options.contextBridge) {
155
+ sections.push(formatContextBridgeSection(options.contextBridge));
156
+ }
157
+
122
158
  // Always last before the task: the platform's tool-approval protocol. Placed
123
159
  // here for recency so it outweighs any "ask the user first" guidance Cursor
124
160
  // surfaces from a connected MCP server (see formatToolApprovalProtocol).
@@ -252,6 +288,14 @@ export function formatInstructions(instructions: string): string {
252
288
  return `<agent_instructions>\n${instructions}\n</agent_instructions>`;
253
289
  }
254
290
 
291
+ export function formatContextBridgeSection(bridge: string): string {
292
+ return `<previous_conversation_context>\n${formatContextBridgeText(bridge)}\n</previous_conversation_context>`;
293
+ }
294
+
295
+ export function formatSenderIdentitySection(identity: SenderIdentity): string {
296
+ return `<conversation_sender>\n${formatSenderIdentityText(identity)}\n</conversation_sender>`;
297
+ }
298
+
255
299
  export function formatSkillsSection(skills: SkillMetadata[]): string {
256
300
  const entries = skills.map(
257
301
  (s) => `- **${s.name}**: ${s.description}\n Path: \`${s.path}\``,
@@ -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";
@@ -251,6 +251,7 @@ const httpConfig: Config = {
251
251
  checkpointerProxyEndpoint: "http://localhost:7234",
252
252
  primaryModel: "claude-sonnet",
253
253
  cursorStreamStallTimeoutMs: 180000,
254
+ agentResolveTimeoutMs: 120000,
254
255
  workspaceLockTimeoutMs: 900000,
255
256
  };
256
257
 
@@ -296,6 +296,7 @@ const baseConfig: Config = {
296
296
  checkpointerProxyEndpoint: "http://localhost:7234",
297
297
  primaryModel: "claude-sonnet",
298
298
  cursorStreamStallTimeoutMs: 180000,
299
+ agentResolveTimeoutMs: 120000,
299
300
  workspaceLockTimeoutMs: 900000,
300
301
  };
301
302
 
@@ -250,6 +250,7 @@ const baseConfig: Config = {
250
250
  checkpointerProxyEndpoint: "http://localhost:7234",
251
251
  primaryModel: "claude-sonnet",
252
252
  cursorStreamStallTimeoutMs: 180000,
253
+ agentResolveTimeoutMs: 120000,
253
254
  workspaceLockTimeoutMs: 900000,
254
255
  };
255
256
 
@@ -47,6 +47,7 @@ describe("ExecuteDeepAgent activity", () => {
47
47
  checkpointerProxyEndpoint: null,
48
48
  primaryModel: "gpt-4.1",
49
49
  cursorStreamStallTimeoutMs: 180000,
50
+ agentResolveTimeoutMs: 120000,
50
51
  workspaceLockTimeoutMs: 900000,
51
52
  };
52
53
 
@@ -208,6 +208,64 @@ describe("buildEnhancedSystemPrompt", () => {
208
208
  expect(prompt).not.toContain("## Workspace");
209
209
  });
210
210
 
211
+ describe("rollover context bridge (DD-013)", () => {
212
+ const base = {
213
+ instructions: "Test",
214
+ provisionResults: [],
215
+ containerRoot: "",
216
+ skillsPromptSection: "",
217
+ workspaceFileRefs: [],
218
+ workspaceRoot: "",
219
+ injectedFiles: [],
220
+ };
221
+
222
+ it("appends the bridge as standing session context (every-turn injection)", () => {
223
+ const prompt = buildEnhancedSystemPrompt({
224
+ ...base,
225
+ contextBridge: "Subject: Orders\nUser: where is my order?\nAssistant: Shipped.",
226
+ });
227
+
228
+ expect(prompt).toContain("## Previous conversation context");
229
+ expect(prompt).toContain("User: where is my order?");
230
+ expect(prompt).toContain("Do not repeat it back");
231
+ });
232
+
233
+ it("omits the section when the session carries no bridge", () => {
234
+ const prompt = buildEnhancedSystemPrompt(base);
235
+
236
+ expect(prompt).not.toContain("## Previous conversation context");
237
+ });
238
+ });
239
+
240
+ describe("channel sender identity", () => {
241
+ const base = {
242
+ instructions: "Test",
243
+ provisionResults: [],
244
+ containerRoot: "",
245
+ skillsPromptSection: "",
246
+ workspaceFileRefs: [],
247
+ workspaceRoot: "",
248
+ injectedFiles: [],
249
+ };
250
+
251
+ it("appends the sender as standing session context (every-turn injection)", () => {
252
+ const prompt = buildEnhancedSystemPrompt({
253
+ ...base,
254
+ senderIdentity: { value: "15550001111", kind: "whatsapp_phone" },
255
+ });
256
+
257
+ expect(prompt).toContain("## Conversation sender");
258
+ expect(prompt).toContain("WhatsApp phone number");
259
+ expect(prompt).toContain("15550001111");
260
+ });
261
+
262
+ it("omits the section when the session carries no identity (console sessions)", () => {
263
+ const prompt = buildEnhancedSystemPrompt(base);
264
+
265
+ expect(prompt).not.toContain("## Conversation sender");
266
+ });
267
+ });
268
+
211
269
  describe("plan mode", () => {
212
270
  const base = {
213
271
  instructions: "Test",
@@ -256,6 +256,7 @@ const memoryConfig: Config = {
256
256
  checkpointerProxyEndpoint: null,
257
257
  primaryModel: "claude-sonnet",
258
258
  cursorStreamStallTimeoutMs: 180000,
259
+ agentResolveTimeoutMs: 120000,
259
260
  workspaceLockTimeoutMs: 900000,
260
261
  };
261
262