@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-transform.js +12 -2
- package/dist/activities/call-transform.js.map +1 -1
- package/dist/activities/call-validate.js +20 -2
- package/dist/activities/call-validate.js.map +1 -1
- package/dist/activities/discover-mcp-server.js +6 -20
- package/dist/activities/discover-mcp-server.js.map +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.d.ts +12 -0
- package/dist/activities/execute-cursor/index.js +83 -12
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +19 -0
- package/dist/activities/execute-cursor/prompt-builder.js +21 -0
- package/dist/activities/execute-cursor/prompt-builder.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/prompt-builder.d.ts +19 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js +12 -0
- package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +4 -0
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/workflow-event-activities.js +2 -0
- package/dist/activities/workflow-event-activities.js.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/runner-manager.d.ts +2 -0
- package/dist/runner-manager.js +2 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +2 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/context-bridge.d.ts +30 -0
- package/dist/shared/context-bridge.js +45 -0
- package/dist/shared/context-bridge.js.map +1 -0
- package/dist/shared/sender-identity.d.ts +50 -0
- package/dist/shared/sender-identity.js +69 -0
- package/dist/shared/sender-identity.js.map +1 -0
- package/dist/shared/with-timeout.d.ts +17 -0
- package/dist/shared/with-timeout.js +34 -0
- package/dist/shared/with-timeout.js.map +1 -0
- package/dist/workflow-engine/do-executor.d.ts +7 -0
- package/dist/workflow-engine/do-executor.js +59 -1
- package/dist/workflow-engine/do-executor.js.map +1 -1
- package/dist/workflow-engine/tasks/call-function.js +68 -1
- package/dist/workflow-engine/tasks/call-function.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +26 -0
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/engine-core.js +5 -1
- package/dist/workflows/engine-core.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/config.test.ts +8 -0
- package/src/activities/__tests__/call-validate.test.ts +137 -0
- package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
- package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
- package/src/activities/__tests__/workflow-event-activities.test.ts +60 -0
- package/src/activities/call-transform.ts +20 -2
- package/src/activities/call-validate.ts +27 -2
- package/src/activities/discover-mcp-server.ts +7 -28
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +65 -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 +101 -15
- package/src/activities/execute-cursor/prompt-builder.ts +44 -0
- package/src/activities/execute-cursor/turn-stream.ts +61 -10
- package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
- package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +58 -0
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
- package/src/activities/execute-deep-agent/prompt-builder.ts +35 -0
- package/src/activities/execute-deep-agent/setup.ts +4 -0
- package/src/activities/workflow-event-activities.ts +2 -0
- package/src/config.ts +23 -0
- package/src/runner-manager.ts +6 -1
- package/src/runner.ts +6 -1
- package/src/shared/__tests__/artifact-storage.test.ts +1 -0
- package/src/shared/__tests__/context-bridge.test.ts +51 -0
- package/src/shared/__tests__/sender-identity.test.ts +92 -0
- package/src/shared/__tests__/with-timeout.test.ts +45 -0
- package/src/shared/context-bridge.ts +51 -0
- package/src/shared/sender-identity.ts +85 -0
- package/src/shared/with-timeout.ts +39 -0
- package/src/workflow-engine/__tests__/do-executor.test.ts +155 -0
- package/src/workflow-engine/__tests__/tasks/call-function.test.ts +94 -0
- package/src/workflow-engine/do-executor.ts +65 -1
- package/src/workflow-engine/tasks/call-function.ts +84 -3
- package/src/workflow-engine/types.ts +27 -0
- package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
- 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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
-
//
|
|
963
|
-
// (
|
|
964
|
-
//
|
|
965
|
-
|
|
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
|
|
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";
|
|
@@ -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",
|