@stigmer/runner 3.2.1 → 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 (85) 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/index.d.ts +12 -0
  9. package/dist/activities/execute-cursor/index.js +53 -12
  10. package/dist/activities/execute-cursor/index.js.map +1 -1
  11. package/dist/activities/execute-cursor/prompt-builder.d.ts +19 -0
  12. package/dist/activities/execute-cursor/prompt-builder.js +21 -0
  13. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +19 -0
  15. package/dist/activities/execute-deep-agent/prompt-builder.js +12 -0
  16. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  17. package/dist/activities/execute-deep-agent/setup.js +4 -0
  18. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  19. package/dist/activities/workflow-event-activities.js +2 -0
  20. package/dist/activities/workflow-event-activities.js.map +1 -1
  21. package/dist/config.d.ts +17 -0
  22. package/dist/config.js +14 -0
  23. package/dist/config.js.map +1 -1
  24. package/dist/runner-manager.d.ts +2 -0
  25. package/dist/runner-manager.js +2 -1
  26. package/dist/runner-manager.js.map +1 -1
  27. package/dist/runner.d.ts +2 -0
  28. package/dist/runner.js +2 -1
  29. package/dist/runner.js.map +1 -1
  30. package/dist/shared/context-bridge.d.ts +30 -0
  31. package/dist/shared/context-bridge.js +45 -0
  32. package/dist/shared/context-bridge.js.map +1 -0
  33. package/dist/shared/sender-identity.d.ts +50 -0
  34. package/dist/shared/sender-identity.js +69 -0
  35. package/dist/shared/sender-identity.js.map +1 -0
  36. package/dist/shared/with-timeout.d.ts +17 -0
  37. package/dist/shared/with-timeout.js +34 -0
  38. package/dist/shared/with-timeout.js.map +1 -0
  39. package/dist/workflow-engine/do-executor.d.ts +7 -0
  40. package/dist/workflow-engine/do-executor.js +59 -1
  41. package/dist/workflow-engine/do-executor.js.map +1 -1
  42. package/dist/workflow-engine/tasks/call-function.js +68 -1
  43. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  44. package/dist/workflow-engine/types.d.ts +26 -0
  45. package/dist/workflow-engine/types.js.map +1 -1
  46. package/dist/workflows/engine-core.js +5 -1
  47. package/dist/workflows/engine-core.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/__tests__/config.test.ts +8 -0
  50. package/src/activities/__tests__/call-validate.test.ts +137 -0
  51. package/src/activities/__tests__/classify-tool-approvals.test.ts +1 -0
  52. package/src/activities/__tests__/discover-mcp-server.test.ts +1 -0
  53. package/src/activities/__tests__/workflow-event-activities.test.ts +60 -0
  54. package/src/activities/call-transform.ts +20 -2
  55. package/src/activities/call-validate.ts +27 -2
  56. package/src/activities/discover-mcp-server.ts +7 -28
  57. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +65 -0
  58. package/src/activities/execute-cursor/index.ts +71 -15
  59. package/src/activities/execute-cursor/prompt-builder.ts +44 -0
  60. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  61. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  62. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  63. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  64. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +58 -0
  65. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  66. package/src/activities/execute-deep-agent/prompt-builder.ts +35 -0
  67. package/src/activities/execute-deep-agent/setup.ts +4 -0
  68. package/src/activities/workflow-event-activities.ts +2 -0
  69. package/src/config.ts +23 -0
  70. package/src/runner-manager.ts +6 -1
  71. package/src/runner.ts +6 -1
  72. package/src/shared/__tests__/artifact-storage.test.ts +1 -0
  73. package/src/shared/__tests__/context-bridge.test.ts +51 -0
  74. package/src/shared/__tests__/sender-identity.test.ts +92 -0
  75. package/src/shared/__tests__/with-timeout.test.ts +45 -0
  76. package/src/shared/context-bridge.ts +51 -0
  77. package/src/shared/sender-identity.ts +85 -0
  78. package/src/shared/with-timeout.ts +39 -0
  79. package/src/workflow-engine/__tests__/do-executor.test.ts +155 -0
  80. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +94 -0
  81. package/src/workflow-engine/do-executor.ts +65 -1
  82. package/src/workflow-engine/tasks/call-function.ts +84 -3
  83. package/src/workflow-engine/types.ts +27 -0
  84. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +1 -0
  85. package/src/workflows/engine-core.ts +5 -1
@@ -14,7 +14,11 @@
14
14
  */
15
15
 
16
16
  import { ApplicationFailure } from "@temporalio/activity";
17
- import { evaluateExpression } from "../workflow-engine/expression.js";
17
+ import {
18
+ evaluateExpression,
19
+ isStrictExpr,
20
+ sanitizeExpr,
21
+ } from "../workflow-engine/expression.js";
18
22
 
19
23
  export interface TransformConfig {
20
24
  readonly engine: string;
@@ -38,6 +42,13 @@ export async function transformAction(
38
42
  "TRANSFORM_MISSING_EXPRESSION",
39
43
  );
40
44
  }
45
+ if (typeof config.expression !== "string") {
46
+ throw ApplicationFailure.nonRetryable(
47
+ `transform: 'expression' must be a jq string, got ${typeof config.expression}. ` +
48
+ "Fix the expression in the workflow's transform task_config.",
49
+ "TRANSFORM_INVALID_EXPRESSION",
50
+ );
51
+ }
41
52
 
42
53
  const engine = normalizeEngine(config.engine || "JQ");
43
54
 
@@ -50,5 +61,12 @@ export async function transformAction(
50
61
 
51
62
  const data = config.input !== undefined ? config.input : taskInput;
52
63
 
53
- return evaluateExpression(config.expression, data, {});
64
+ // The expression arrives unresolved (deferred code — see the
65
+ // call-function builder). Accept both the strict `${ ... }` wrapper
66
+ // and the bare jq form the converter emits.
67
+ const expression = isStrictExpr(config.expression)
68
+ ? sanitizeExpr(config.expression)
69
+ : config.expression;
70
+
71
+ return evaluateExpression(expression, data, {});
54
72
  }
@@ -18,7 +18,11 @@
18
18
  */
19
19
 
20
20
  import { ApplicationFailure } from "@temporalio/activity";
21
- import { evaluateExpression } from "../workflow-engine/expression.js";
21
+ import {
22
+ evaluateExpression,
23
+ isStrictExpr,
24
+ sanitizeExpr,
25
+ } from "../workflow-engine/expression.js";
22
26
 
23
27
  export interface ValidateConfig {
24
28
  readonly input: unknown;
@@ -160,7 +164,28 @@ async function validateRules(
160
164
  const errors: ValidationError[] = [];
161
165
 
162
166
  for (const rule of rules) {
163
- const result = await evaluateExpression(rule.expression, data, {});
167
+ // Config guard: the expression must be a jq string. Anything else is
168
+ // a workflow-definition defect (or an upstream resolution bug) — name
169
+ // the rule and what to fix instead of crashing in the jq engine.
170
+ if (typeof rule.expression !== "string" || rule.expression.length === 0) {
171
+ errors.push({
172
+ rule: rule.name,
173
+ message:
174
+ `Rule '${rule.name}' has an invalid 'expression': expected a jq ` +
175
+ `predicate string, got ${typeof rule.expression}. Fix the rule in ` +
176
+ `the workflow's validate task_config.`,
177
+ });
178
+ continue;
179
+ }
180
+
181
+ // Rule expressions arrive unresolved (deferred code — see the
182
+ // call-function builder). Accept both the strict `${ ... }` wrapper
183
+ // and a bare jq predicate.
184
+ const expr = isStrictExpr(rule.expression)
185
+ ? sanitizeExpr(rule.expression)
186
+ : rule.expression;
187
+
188
+ const result = await evaluateExpression(expr, data, {});
164
189
  if (!result) {
165
190
  errors.push({
166
191
  rule: rule.name,
@@ -25,6 +25,7 @@ import { StigmerClient } from "../client/stigmer-client.js";
25
25
  import { mcpServerToResolved } from "../shared/mcp-resolver.js";
26
26
  import { toMcpClientConfig } from "../shared/mcp-manager.js";
27
27
  import { detectOAuthChallenge } from "../shared/mcp-oauth-detect.js";
28
+ import { withTimeout } from "../shared/with-timeout.js";
28
29
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
29
30
  import type { Config } from "../config.js";
30
31
 
@@ -326,7 +327,12 @@ async function connectAndDiscover(
326
327
  const resourceTemplates: DiscoveredResourceTemplateResult[] = [];
327
328
 
328
329
  try {
329
- await withTimeout(SESSION_INIT_TIMEOUT_MS, slug, async () => {
330
+ const timeoutMessage =
331
+ `MCP server '${slug}' did not respond within ` +
332
+ `${Math.round(SESSION_INIT_TIMEOUT_MS / 1000)}s. If this server requires compilation or ` +
333
+ `package installation on first run (e.g. go run, npx), the cold ` +
334
+ `start may have exceeded the discovery timeout.`;
335
+ await withTimeout(SESSION_INIT_TIMEOUT_MS, timeoutMessage, async () => {
330
336
  await client.initializeConnections();
331
337
 
332
338
  const mcpClient = await client.getClient(slug);
@@ -411,33 +417,6 @@ async function classifyHttpOAuthFailure(
411
417
  return detectOAuthChallenge(connection.url, connection.headers, slug);
412
418
  }
413
419
 
414
- async function withTimeout<T>(
415
- ms: number,
416
- serverSlug: string,
417
- fn: () => Promise<T>,
418
- ): Promise<T> {
419
- return new Promise<T>((resolve, reject) => {
420
- const timer = setTimeout(() => {
421
- reject(new Error(
422
- `MCP server '${serverSlug}' did not respond within ` +
423
- `${Math.round(ms / 1000)}s. If this server requires compilation or ` +
424
- `package installation on first run (e.g. go run, npx), the cold ` +
425
- `start may have exceeded the discovery timeout.`,
426
- ));
427
- }, ms);
428
-
429
- fn()
430
- .then((result) => {
431
- clearTimeout(timer);
432
- resolve(result);
433
- })
434
- .catch((err) => {
435
- clearTimeout(timer);
436
- reject(err);
437
- });
438
- });
439
- }
440
-
441
420
  // ─────────────────────────────────────────────────────────────────────────────
442
421
  // Temporal Activity Factory
443
422
  // ─────────────────────────────────────────────────────────────────────────────
@@ -87,6 +87,71 @@ describe("buildPrompt", () => {
87
87
  expect(prompt).toContain(USER_MESSAGE);
88
88
  });
89
89
 
90
+ it("carries the rollover context bridge on the first execution (DD-013)", () => {
91
+ const prompt = buildPrompt(
92
+ input({
93
+ resolution: resolution("local", "created_first_execution"),
94
+ contextBridge: "Subject: Orders\nUser: where is my order?\nAssistant: Shipped.",
95
+ }),
96
+ );
97
+ expect(prompt).toContain("<previous_conversation_context>");
98
+ expect(prompt).toContain("User: where is my order?");
99
+ // The bridge is CONTEXT; the approval protocol keeps its pinned
100
+ // last-before-task slot so instructions outweigh it.
101
+ expect(prompt.indexOf("<previous_conversation_context>"))
102
+ .toBeLessThan(prompt.indexOf("<tool_approval_protocol>"));
103
+ });
104
+
105
+ it("never bridges a successfully resumed agent — its native context IS the conversation", () => {
106
+ const prompt = buildPrompt(
107
+ input({
108
+ resolution: resolution("local", "resumed_successfully"),
109
+ contextBridge: "Subject: Orders\nUser: hi\nAssistant: hello",
110
+ }),
111
+ );
112
+ expect(prompt).toBe(USER_MESSAGE);
113
+ });
114
+
115
+ it("omits the bridge section when the session carries none", () => {
116
+ const prompt = buildPrompt(
117
+ input({ resolution: resolution("local", "created_first_execution") }),
118
+ );
119
+ expect(prompt).not.toContain("<previous_conversation_context>");
120
+ });
121
+
122
+ it("carries the channel sender identity on the first execution", () => {
123
+ const prompt = buildPrompt(
124
+ input({
125
+ resolution: resolution("local", "created_first_execution"),
126
+ senderIdentity: { value: "15550001111", kind: "whatsapp_phone" },
127
+ }),
128
+ );
129
+ expect(prompt).toContain("<conversation_sender>");
130
+ expect(prompt).toContain("WhatsApp phone number");
131
+ expect(prompt).toContain("15550001111");
132
+ // Identity is CONTEXT like the bridge; the approval protocol keeps its
133
+ // pinned last-before-task slot.
134
+ expect(prompt.indexOf("<conversation_sender>"))
135
+ .toBeLessThan(prompt.indexOf("<tool_approval_protocol>"));
136
+ });
137
+
138
+ it("never re-sends the identity to a successfully resumed agent — its native context carries it", () => {
139
+ const prompt = buildPrompt(
140
+ input({
141
+ resolution: resolution("local", "resumed_successfully"),
142
+ senderIdentity: { value: "U0USER", kind: "slack_user_id" },
143
+ }),
144
+ );
145
+ expect(prompt).toBe(USER_MESSAGE);
146
+ });
147
+
148
+ it("omits the sender section when the session carries no identity (console sessions)", () => {
149
+ const prompt = buildPrompt(
150
+ input({ resolution: resolution("local", "created_first_execution") }),
151
+ );
152
+ expect(prompt).not.toContain("<conversation_sender>");
153
+ });
154
+
90
155
  it("uses the reinvocation prompt for a HITL reinvocation (human-meaningful, no opaque ids)", () => {
91
156
  const approvalDecisions = new Map<string, ApprovalAction>([
92
157
  ["tool-call-1", ApprovalAction.APPROVE],
@@ -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";
@@ -115,6 +117,7 @@ import type { ClassifiedError } from "./error-classifier.js";
115
117
  import { createAgent, createCloudAgent } from "./session-lifecycle.js";
116
118
  import { setMaxListeners } from "node:events";
117
119
  import { startHeartbeat } from "../../shared/heartbeat.js";
120
+ import { withTimeout } from "../../shared/with-timeout.js";
118
121
  import { getShutdownSignalForQueue } from "../../runner-manager.js";
119
122
 
120
123
  /**
@@ -257,6 +260,25 @@ async function executeCursorInner(
257
260
  // can be classified with the same context as the run.wait() error path.
258
261
  let errorContext = { model: "default", mode: "local", agentId: "" };
259
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
+
260
282
  try {
261
283
  // Phase 1: Hydrate execution from DB
262
284
  await reportSetupProgress(client, executionId, "Fetching execution");
@@ -270,6 +292,7 @@ async function executeCursorInner(
270
292
  const blueprint = await resolveBlueprint(client, session, config.workspaceRootDir);
271
293
 
272
294
  // Phase 2b: Resolve execution environment (MCP server credentials)
295
+ heartbeatPhase = "resolving_environment";
273
296
  await reportSetupProgress(client, executionId, "Resolving environment");
274
297
  const { envVars, secretKeys } = await resolveExecutionEnv(client, executionId);
275
298
  heartbeat();
@@ -280,6 +303,7 @@ async function executeCursorInner(
280
303
  // disabled the runner must provision the workspace itself, mirroring the
281
304
  // native harness. Git provisioning is idempotent across multi-turn and
282
305
  // HITL reinvocations.
306
+ heartbeatPhase = "provisioning_workspace";
283
307
  await reportSetupProgress(client, executionId, "Provisioning workspace");
284
308
  const workspaceProvision = await provisionCursorWorkspace(
285
309
  config, session, envVars, sessionId ?? "",
@@ -554,6 +578,7 @@ async function executeCursorInner(
554
578
  );
555
579
 
556
580
  // Phase 4a: Connect backfill for undiscovered MCP servers
581
+ heartbeatPhase = "resolving_mcp_servers";
557
582
  const sessionOrg = session.metadata?.org ?? "";
558
583
  mcpResolution = await backfillMcpServersIfNeeded(
559
584
  client, mcpResolution, blueprint.mergedMcpServerUsages, envVars, sessionOrg,
@@ -815,10 +840,22 @@ async function executeCursorInner(
815
840
  agents: cursorSubAgents,
816
841
  };
817
842
 
818
- let resolution: AgentResolution = await resolveAgent(
819
- threadId,
820
- createOptions,
821
- 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),
822
859
  );
823
860
 
824
861
  console.log(
@@ -878,6 +915,8 @@ async function executeCursorInner(
878
915
  appliedToolCallIds,
879
916
  interactionMode,
880
917
  buildFromPlan,
918
+ contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
919
+ senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
881
920
  });
882
921
 
883
922
  // Phase 10a: Inject structured output instruction for Cursor harness
@@ -962,16 +1001,10 @@ async function executeCursorInner(
962
1001
  maxCostUsd,
963
1002
  };
964
1003
 
965
- // Periodic heartbeat keeps Temporal informed during silent SDK operations
966
- // (e.g. long tool calls, MCP requests, model thinking). Without this,
967
- // the 2-minute heartbeat timeout can cancel the activity and mislabel
968
- // the execution as "paused by user".
969
- const taskQueue = Context.current().info.taskQueue;
970
- const shutdownSignal = getShutdownSignalForQueue(taskQueue);
971
- periodicHeartbeat = startHeartbeat(30_000, () => ({
972
- phase: "cursor_streaming",
973
- execution: executionId,
974
- }), { 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";
975
1008
 
976
1009
  // The Cursor SDK registers abort listeners on the cancellation signal for
977
1010
  // each concurrent tool call (fetch, MCP, shell). With 10+ parallel tools,
@@ -1460,6 +1493,8 @@ async function executeCursorInner(
1460
1493
  attachmentPaths,
1461
1494
  pendingApprovals: adjudicatedApprovals,
1462
1495
  interactionMode,
1496
+ contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
1497
+ senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1463
1498
  });
1464
1499
 
1465
1500
  console.log(
@@ -1866,6 +1901,12 @@ async function executeCursorInner(
1866
1901
 
1867
1902
  return slimStatus(status);
1868
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
+
1869
1910
  // End the OTel turn span + record metrics with the final token snapshot on
1870
1911
  // EVERY exit path (idempotent). Placed here so the span covers any recovery
1871
1912
  // retry (whose tokens accrue after the primary stream) and never leaks on an
@@ -2019,6 +2060,18 @@ export interface BuildPromptInput {
2019
2060
  * .build_from_plan): both prompt paths carry the implement-plan directive.
2020
2061
  */
2021
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;
2022
2075
  }
2023
2076
 
2024
2077
  /**
@@ -2083,7 +2136,8 @@ export function buildPrompt(input: BuildPromptInput): string {
2083
2136
  }
2084
2137
 
2085
2138
  // First execution, or a fresh agent created after a resume failure: there is
2086
- // 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.
2087
2141
  return buildEnhancedPrompt({
2088
2142
  instructions,
2089
2143
  userMessage,
@@ -2094,6 +2148,8 @@ export function buildPrompt(input: BuildPromptInput): string {
2094
2148
  attachmentPaths,
2095
2149
  interactionMode,
2096
2150
  buildFromPlan,
2151
+ contextBridge: input.contextBridge,
2152
+ senderIdentity: input.senderIdentity,
2097
2153
  });
2098
2154
  }
2099
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}\``,
@@ -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
 
@@ -10,6 +10,11 @@ import { relative } from "node:path";
10
10
  import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
11
11
  import type { ProvisionResult, GitMetadata } from "../../shared/workspace/types.js";
12
12
  import { SourceType } from "../../shared/workspace/types.js";
13
+ import { formatContextBridgeText } from "../../shared/context-bridge.js";
14
+ import {
15
+ formatSenderIdentityText,
16
+ type SenderIdentity,
17
+ } from "../../shared/sender-identity.js";
13
18
  import { PLAN_MODE_DIRECTIVE } from "../../shared/plan-mode-prompt.js";
14
19
  import {
15
20
  buildImplementPlanDirective,
@@ -106,6 +111,24 @@ export interface PromptBuilderInput {
106
111
  * message itself is just a short label ("Build from plan").
107
112
  */
108
113
  buildFromPlan?: boolean;
114
+ /**
115
+ * Rollover context bridge (cloud DD-013): a digest of the previous
116
+ * session's conversation, read from `SessionSpec.metadata`. Injected on
117
+ * EVERY turn by design: the native system prompt is rebuilt per
118
+ * invocation (never checkpointed with the message history), so a
119
+ * first-turn-only injection would vanish from turn 2 onward. The bridge
120
+ * is standing session context, like skills.
121
+ */
122
+ contextBridge?: string;
123
+ /**
124
+ * Channel sender identity (attribution, not authorization): the
125
+ * provider-verified identifier of the person on the channel, read from
126
+ * `SessionSpec.metadata`. Injected on EVERY turn like the bridge — the
127
+ * native system prompt is rebuilt per invocation, and the sender is
128
+ * constant for the session's lifetime (channel sessions are keyed
129
+ * per-sender).
130
+ */
131
+ senderIdentity?: SenderIdentity;
109
132
  }
110
133
 
111
134
  export interface InjectedFile {
@@ -147,6 +170,18 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
147
170
  prompt += buildInjectedFilesSection(input.injectedFiles);
148
171
  }
149
172
 
173
+ if (input.senderIdentity) {
174
+ prompt +=
175
+ "\n\n## Conversation sender\n\n" +
176
+ formatSenderIdentityText(input.senderIdentity);
177
+ }
178
+
179
+ if (input.contextBridge) {
180
+ prompt +=
181
+ "\n\n## Previous conversation context\n\n" +
182
+ formatContextBridgeText(input.contextBridge);
183
+ }
184
+
150
185
  prompt += RESPONSE_RULES;
151
186
  prompt += SUB_AGENT_RULES;
152
187
 
@@ -23,6 +23,8 @@ import type { DynamicStructuredTool } from "@langchain/core/tools";
23
23
  import type { Config } from "../../config.js";
24
24
  import type { StigmerClient } from "../../client/stigmer-client.js";
25
25
  import { createCheckpointer } from "../../shared/checkpointer/factory.js";
26
+ import { readContextBridge } from "../../shared/context-bridge.js";
27
+ import { readSenderIdentity } from "../../shared/sender-identity.js";
26
28
  import { connectMcpServers, type McpConnectionResult } from "../../shared/mcp-manager.js";
27
29
  import { resolveMcpServers } from "../../shared/mcp-resolver.js";
28
30
  import { backfillMcpServersIfNeeded } from "../../shared/connect-backfill.js";
@@ -386,6 +388,8 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
386
388
  injectedFiles,
387
389
  interactionMode: execution.spec!.executionConfig?.interactionMode,
388
390
  buildFromPlan: execution.spec!.executionConfig?.buildFromPlan,
391
+ contextBridge: readContextBridge(session.spec!.metadata),
392
+ senderIdentity: readSenderIdentity(session.spec!.metadata),
389
393
  });
390
394
 
391
395
  // Step 9: Construct the LLM model. Resolution to the provider API id
@@ -221,6 +221,7 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
221
221
  case: "taskStarted",
222
222
  value: create(TaskStartedPayloadSchema, {
223
223
  taskKind: TASK_KIND_MAP[desc.taskKind] ?? 0,
224
+ inputSummary: toJsonObject(desc.inputSummary),
224
225
  attemptNumber: desc.attemptNumber,
225
226
  }),
226
227
  };
@@ -233,6 +234,7 @@ export function toProtoEvent(desc: WorkflowEventDescriptor): WorkflowExecutionEv
233
234
  value: create(TaskCompletedPayloadSchema, {
234
235
  taskKind: TASK_KIND_MAP[desc.taskKind] ?? 0,
235
236
  durationMs: BigInt(desc.durationMs),
237
+ outputSummary: toJsonObject(desc.outputSummary),
236
238
  costMicros: BigInt(desc.costMicros),
237
239
  tokensUsed: BigInt(desc.tokensUsed),
238
240
  }),