@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
|
@@ -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
|
}),
|
package/src/config.ts
CHANGED
|
@@ -37,6 +37,17 @@ import { homedir, tmpdir } from "node:os";
|
|
|
37
37
|
*/
|
|
38
38
|
export const DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS = 180_000;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Default bound for Cursor Agent.create/Agent.resume (ms). These SDK calls
|
|
42
|
+
* have no timeout of their own; a degraded transport (dead proxy connection,
|
|
43
|
+
* stale HTTP/2 session) hangs them forever — which surfaces as an opaque
|
|
44
|
+
* Temporal heartbeat timeout minutes later instead of an actionable error.
|
|
45
|
+
* 120s comfortably covers a slow create (token exchange + workspace
|
|
46
|
+
* registration) while failing fast enough to diagnose. Single source of truth
|
|
47
|
+
* for env-loaded and options-mapped config (mirrors the stall timeout above).
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS = 120_000;
|
|
50
|
+
|
|
40
51
|
// Re-exported so the runner/manager options mappers default the lock-wait
|
|
41
52
|
// bound from the same constant the lock module owns (no drift across the
|
|
42
53
|
// three construction sites — mirrors DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS).
|
|
@@ -89,6 +100,13 @@ export interface Config {
|
|
|
89
100
|
* heartbeatTimeout (process liveness) and the 30s keep-alive heartbeat.
|
|
90
101
|
*/
|
|
91
102
|
readonly cursorStreamStallTimeoutMs: number;
|
|
103
|
+
/**
|
|
104
|
+
* Bound (milliseconds) for Cursor Agent.create/Agent.resume. On expiry the
|
|
105
|
+
* execution fails fast with an explicit transport diagnosis instead of
|
|
106
|
+
* hanging into Temporal's heartbeat timeout. See
|
|
107
|
+
* {@link DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS} for why this exists.
|
|
108
|
+
*/
|
|
109
|
+
readonly agentResolveTimeoutMs: number;
|
|
92
110
|
/**
|
|
93
111
|
* Max wait (milliseconds) for the per-workspace turn lock before failing
|
|
94
112
|
* the execution with a "workspace is in use by another session" error.
|
|
@@ -174,6 +192,10 @@ export function loadConfig(): Config {
|
|
|
174
192
|
? parseInt(process.env.WORKSPACE_LOCK_TIMEOUT_MS, 10)
|
|
175
193
|
: DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS;
|
|
176
194
|
|
|
195
|
+
const agentResolveTimeoutMs = process.env.CURSOR_AGENT_RESOLVE_TIMEOUT_MS
|
|
196
|
+
? parseInt(process.env.CURSOR_AGENT_RESOLVE_TIMEOUT_MS, 10)
|
|
197
|
+
: DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS;
|
|
198
|
+
|
|
177
199
|
return {
|
|
178
200
|
taskQueue,
|
|
179
201
|
temporalAddress,
|
|
@@ -191,6 +213,7 @@ export function loadConfig(): Config {
|
|
|
191
213
|
checkpointerProxyEndpoint,
|
|
192
214
|
primaryModel,
|
|
193
215
|
cursorStreamStallTimeoutMs,
|
|
216
|
+
agentResolveTimeoutMs,
|
|
194
217
|
workspaceLockTimeoutMs,
|
|
195
218
|
};
|
|
196
219
|
}
|
package/src/runner-manager.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from "@temporalio/worker";
|
|
25
25
|
import type { PayloadCodec } from "@temporalio/common";
|
|
26
26
|
import type { Config } from "./config.js";
|
|
27
|
-
import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
|
|
27
|
+
import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
|
|
28
28
|
import type { WorkerActivities } from "./worker.js";
|
|
29
29
|
import { resolveWorkflowSource, OTEL_WORKFLOW_INTERCEPTOR_MODULE } from "./workflow-source.js";
|
|
30
30
|
import { resolveRunnerBootstrap, refreshRunnerAccessToken } from "./bootstrap.js";
|
|
@@ -91,6 +91,9 @@ export interface RunnerManagerOptions {
|
|
|
91
91
|
/** No-progress bound for the Cursor harness stream (ms). @default 180000 */
|
|
92
92
|
readonly cursorStreamStallTimeoutMs?: number;
|
|
93
93
|
|
|
94
|
+
/** Bound for Cursor Agent.create/resume (ms). @default 120000 */
|
|
95
|
+
readonly agentResolveTimeoutMs?: number;
|
|
96
|
+
|
|
94
97
|
/** Max wait for the per-workspace turn lock (ms). @default 900000 */
|
|
95
98
|
readonly workspaceLockTimeoutMs?: number;
|
|
96
99
|
|
|
@@ -598,6 +601,8 @@ export function mapManagerOptionsToConfig(
|
|
|
598
601
|
primaryModel: options.primaryModel ?? "gpt-4.1",
|
|
599
602
|
cursorStreamStallTimeoutMs:
|
|
600
603
|
options.cursorStreamStallTimeoutMs ?? DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
|
|
604
|
+
agentResolveTimeoutMs:
|
|
605
|
+
options.agentResolveTimeoutMs ?? DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS,
|
|
601
606
|
workspaceLockTimeoutMs:
|
|
602
607
|
options.workspaceLockTimeoutMs ?? DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
|
|
603
608
|
};
|
package/src/runner.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { join } from "node:path";
|
|
|
17
17
|
import { homedir, tmpdir } from "node:os";
|
|
18
18
|
import type { PayloadCodec } from "@temporalio/common";
|
|
19
19
|
import type { Config } from "./config.js";
|
|
20
|
-
import { DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
|
|
20
|
+
import { DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS, DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS, DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./config.js";
|
|
21
21
|
import type { WorkerActivities } from "./worker.js";
|
|
22
22
|
import { resolveRunnerBootstrap } from "./bootstrap.js";
|
|
23
23
|
|
|
@@ -69,6 +69,9 @@ export interface StigmerRunnerOptions {
|
|
|
69
69
|
/** No-progress bound for the Cursor harness stream (ms). @default 180000 */
|
|
70
70
|
readonly cursorStreamStallTimeoutMs?: number;
|
|
71
71
|
|
|
72
|
+
/** Bound for Cursor Agent.create/resume (ms). @default 120000 */
|
|
73
|
+
readonly agentResolveTimeoutMs?: number;
|
|
74
|
+
|
|
72
75
|
/** Max wait for the per-workspace turn lock (ms). @default 900000 */
|
|
73
76
|
readonly workspaceLockTimeoutMs?: number;
|
|
74
77
|
|
|
@@ -282,6 +285,8 @@ export function mapOptionsToConfig(options: StigmerRunnerOptions): Config {
|
|
|
282
285
|
primaryModel: options.primaryModel ?? "gpt-4.1",
|
|
283
286
|
cursorStreamStallTimeoutMs:
|
|
284
287
|
options.cursorStreamStallTimeoutMs ?? DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS,
|
|
288
|
+
agentResolveTimeoutMs:
|
|
289
|
+
options.agentResolveTimeoutMs ?? DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS,
|
|
285
290
|
workspaceLockTimeoutMs:
|
|
286
291
|
options.workspaceLockTimeoutMs ?? DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS,
|
|
287
292
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the rollover context bridge (cloud DD-013): the pinned
|
|
3
|
+
* cross-repo metadata key, the read semantics, and the shared framing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
CONTEXT_BRIDGE_METADATA_KEY,
|
|
9
|
+
formatContextBridgeText,
|
|
10
|
+
readContextBridge,
|
|
11
|
+
} from "../context-bridge.js";
|
|
12
|
+
|
|
13
|
+
describe("CONTEXT_BRIDGE_METADATA_KEY", () => {
|
|
14
|
+
it("is pinned verbatim to the cloud broker's constant (mirror guard)", () => {
|
|
15
|
+
// Pinned to ChannelRuntimeConstants.CONTEXT_BRIDGE_METADATA_KEY in
|
|
16
|
+
// stigmer-cloud. Changing either side alone silently disables the
|
|
17
|
+
// bridge (a plain context reset); change BOTH together.
|
|
18
|
+
expect(CONTEXT_BRIDGE_METADATA_KEY).toBe("stigmer.ai/context-bridge");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("readContextBridge", () => {
|
|
23
|
+
it("reads the digest from the session spec metadata map", () => {
|
|
24
|
+
expect(
|
|
25
|
+
readContextBridge({ [CONTEXT_BRIDGE_METADATA_KEY]: "User: hi\nAssistant: hello" }),
|
|
26
|
+
).toBe("User: hi\nAssistant: hello");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns undefined for an absent map", () => {
|
|
30
|
+
expect(readContextBridge(undefined)).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns undefined when the key is absent", () => {
|
|
34
|
+
expect(readContextBridge({ other: "value" })).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns undefined for a blank value — a blank bridge is no bridge", () => {
|
|
38
|
+
expect(readContextBridge({ [CONTEXT_BRIDGE_METADATA_KEY]: " " })).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("formatContextBridgeText", () => {
|
|
43
|
+
it("frames the digest as already-known background", () => {
|
|
44
|
+
const framed = formatContextBridgeText("Subject: Orders\nUser: hi\nAssistant: hello");
|
|
45
|
+
|
|
46
|
+
expect(framed).toContain("previous conversation");
|
|
47
|
+
expect(framed).toContain("Do not repeat it back");
|
|
48
|
+
expect(framed).toContain("Subject: Orders");
|
|
49
|
+
expect(framed.endsWith("Assistant: hello")).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the channel sender identity: the pinned cross-repo
|
|
3
|
+
* metadata keys, the read semantics, and the shared framing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
SENDER_IDENTITY_METADATA_KEY,
|
|
9
|
+
SENDER_KIND_METADATA_KEY,
|
|
10
|
+
formatSenderIdentityText,
|
|
11
|
+
readSenderIdentity,
|
|
12
|
+
} from "../sender-identity.js";
|
|
13
|
+
|
|
14
|
+
describe("sender identity metadata keys", () => {
|
|
15
|
+
it("are pinned verbatim to the cloud broker's constants (mirror guard)", () => {
|
|
16
|
+
// Pinned to ChannelRuntimeConstants.SENDER_IDENTITY_METADATA_KEY /
|
|
17
|
+
// SENDER_KIND_METADATA_KEY in stigmer-cloud. Changing either side alone
|
|
18
|
+
// silently blinds the agent to the sender; change BOTH together.
|
|
19
|
+
expect(SENDER_IDENTITY_METADATA_KEY).toBe("stigmer.ai/channel-sender-identity");
|
|
20
|
+
expect(SENDER_KIND_METADATA_KEY).toBe("stigmer.ai/channel-sender-kind");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("readSenderIdentity", () => {
|
|
25
|
+
it("reads value + kind from the session spec metadata map", () => {
|
|
26
|
+
expect(
|
|
27
|
+
readSenderIdentity({
|
|
28
|
+
[SENDER_IDENTITY_METADATA_KEY]: "15550001111",
|
|
29
|
+
[SENDER_KIND_METADATA_KEY]: "whatsapp_phone",
|
|
30
|
+
}),
|
|
31
|
+
).toEqual({ value: "15550001111", kind: "whatsapp_phone" });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns undefined for an absent map", () => {
|
|
35
|
+
expect(readSenderIdentity(undefined)).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns undefined when either key is absent — identity is value AND kind", () => {
|
|
39
|
+
expect(
|
|
40
|
+
readSenderIdentity({ [SENDER_IDENTITY_METADATA_KEY]: "15550001111" }),
|
|
41
|
+
).toBeUndefined();
|
|
42
|
+
expect(
|
|
43
|
+
readSenderIdentity({ [SENDER_KIND_METADATA_KEY]: "whatsapp_phone" }),
|
|
44
|
+
).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns undefined for blank values", () => {
|
|
48
|
+
expect(
|
|
49
|
+
readSenderIdentity({
|
|
50
|
+
[SENDER_IDENTITY_METADATA_KEY]: " ",
|
|
51
|
+
[SENDER_KIND_METADATA_KEY]: "whatsapp_phone",
|
|
52
|
+
}),
|
|
53
|
+
).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("formatSenderIdentityText", () => {
|
|
58
|
+
it("phrases a WhatsApp sender by their phone number", () => {
|
|
59
|
+
const framed = formatSenderIdentityText({
|
|
60
|
+
value: "15550001111",
|
|
61
|
+
kind: "whatsapp_phone",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(framed).toContain("WhatsApp phone number");
|
|
65
|
+
expect(framed).toContain("15550001111");
|
|
66
|
+
expect(framed).toContain("verified");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("phrases a Slack sender by their user id", () => {
|
|
70
|
+
const framed = formatSenderIdentityText({ value: "U0USER", kind: "slack_user_id" });
|
|
71
|
+
|
|
72
|
+
expect(framed).toContain("Slack user id");
|
|
73
|
+
expect(framed).toContain("U0USER");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("falls back to a generic phrase for an unknown kind (newer cloud than runner)", () => {
|
|
77
|
+
const framed = formatSenderIdentityText({ value: "someone@example.com", kind: "email" });
|
|
78
|
+
|
|
79
|
+
expect(framed).toContain("email identifier");
|
|
80
|
+
expect(framed).toContain("someone@example.com");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("tells the model attribution beats in-message identity claims", () => {
|
|
84
|
+
const framed = formatSenderIdentityText({
|
|
85
|
+
value: "15550001111",
|
|
86
|
+
kind: "whatsapp_phone",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(framed).toContain("do not ask the user");
|
|
90
|
+
expect(framed).toContain("claims a different");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { withTimeout } from "../with-timeout.js";
|
|
3
|
+
|
|
4
|
+
describe("withTimeout", () => {
|
|
5
|
+
it("resolves with the function's result when it completes in time", async () => {
|
|
6
|
+
const result = await withTimeout(1_000, "should not fire", async () => "done");
|
|
7
|
+
expect(result).toBe("done");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("rejects with the timeout message when the function hangs", async () => {
|
|
11
|
+
const hang = () => new Promise<never>(() => {});
|
|
12
|
+
await expect(
|
|
13
|
+
withTimeout(20, "Cursor agent create timed out after 20ms", hang),
|
|
14
|
+
).rejects.toThrow("Cursor agent create timed out after 20ms");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("evaluates a lazy message only on expiry", async () => {
|
|
18
|
+
let evaluated = 0;
|
|
19
|
+
const message = () => {
|
|
20
|
+
evaluated++;
|
|
21
|
+
return "lazy timeout message";
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
await withTimeout(1_000, message, async () => "ok");
|
|
25
|
+
expect(evaluated).toBe(0);
|
|
26
|
+
|
|
27
|
+
const hang = () => new Promise<never>(() => {});
|
|
28
|
+
await expect(withTimeout(20, message, hang)).rejects.toThrow("lazy timeout message");
|
|
29
|
+
expect(evaluated).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("propagates the function's own rejection unchanged", async () => {
|
|
33
|
+
const boom = new Error("underlying failure");
|
|
34
|
+
await expect(
|
|
35
|
+
withTimeout(1_000, "should not fire", () => Promise.reject(boom)),
|
|
36
|
+
).rejects.toBe(boom);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not reject after a successful resolve (timer cleared)", async () => {
|
|
40
|
+
const result = await withTimeout(20, "should not fire", async () => "fast");
|
|
41
|
+
expect(result).toBe("fast");
|
|
42
|
+
// If the timer leaked, the unhandled rejection would fail the test run.
|
|
43
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rollover context bridge (stigmer-cloud DD-013).
|
|
3
|
+
*
|
|
4
|
+
* When a channel conversation's session hits its bounds (turn cap or
|
|
5
|
+
* inactivity window), the cloud broker rolls it over into a fresh session
|
|
6
|
+
* and stamps a compact digest of the previous conversation — subject plus
|
|
7
|
+
* the newest user/assistant exchanges — into the fresh session's
|
|
8
|
+
* `SessionSpec.metadata` under {@link CONTEXT_BRIDGE_METADATA_KEY}. Both
|
|
9
|
+
* harnesses read it here and present it as prompt context, so the user
|
|
10
|
+
* does not experience total amnesia at the rotation.
|
|
11
|
+
*
|
|
12
|
+
* The cloud composes the CONTENT; this module owns the PRESENTATION
|
|
13
|
+
* framing. Each harness wraps {@link formatContextBridgeText} in its own
|
|
14
|
+
* section syntax (XML tag for cursor's first-message prompt, markdown
|
|
15
|
+
* heading for the native system prompt).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `SessionSpec.metadata` key carrying the bridge digest. Pinned verbatim
|
|
20
|
+
* to `ChannelRuntimeConstants.CONTEXT_BRIDGE_METADATA_KEY` in
|
|
21
|
+
* stigmer-cloud, with mirror guard tests on both sides — a drift degrades
|
|
22
|
+
* to a plain context reset (the bridge is simply never read), never worse.
|
|
23
|
+
*/
|
|
24
|
+
export const CONTEXT_BRIDGE_METADATA_KEY = "stigmer.ai/context-bridge";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How the digest is introduced to the model, shared by both harnesses so
|
|
28
|
+
* the behavioral contract ("known background, don't announce it") cannot
|
|
29
|
+
* drift between them.
|
|
30
|
+
*/
|
|
31
|
+
const CONTEXT_BRIDGE_PREAMBLE =
|
|
32
|
+
"Background from your previous conversation with this user, carried over " +
|
|
33
|
+
"when the conversation was rotated. Treat it as context you already " +
|
|
34
|
+
"know; the user may continue as if nothing changed. Do not repeat it " +
|
|
35
|
+
"back or mention the rotation unless asked.";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read the bridge digest from a session's spec metadata map. Returns
|
|
39
|
+
* undefined when absent or blank — the caller renders no section.
|
|
40
|
+
*/
|
|
41
|
+
export function readContextBridge(
|
|
42
|
+
metadata: Record<string, string> | undefined,
|
|
43
|
+
): string | undefined {
|
|
44
|
+
const value = metadata?.[CONTEXT_BRIDGE_METADATA_KEY]?.trim();
|
|
45
|
+
return value ? value : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The framed bridge body (preamble + digest), ready for section wrapping. */
|
|
49
|
+
export function formatContextBridgeText(bridge: string): string {
|
|
50
|
+
return `${CONTEXT_BRIDGE_PREAMBLE}\n\n${bridge.trim()}`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The channel sender identity (stigmer-cloud's channel-sender-identity keys).
|
|
3
|
+
*
|
|
4
|
+
* When a conversation reaches an agent through an AgentChannel, the cloud
|
|
5
|
+
* broker stamps the provider-verified sender identity — the WhatsApp phone
|
|
6
|
+
* number (wa_id), the Slack user id — into the session's
|
|
7
|
+
* `SessionSpec.metadata` at session creation, together with a KIND token
|
|
8
|
+
* that says what sort of identifier it is. Both harnesses read it here and
|
|
9
|
+
* present it as prompt context, so the agent can attribute records to the
|
|
10
|
+
* person it is talking to without asking them to re-state (and possibly
|
|
11
|
+
* fake or mistype) their own identifier.
|
|
12
|
+
*
|
|
13
|
+
* Attribution, NOT authorization: privileges are enforced by the channel
|
|
14
|
+
* topology and the credentials bound to it, never by what the model reads
|
|
15
|
+
* here. The cloud writes the CONTENT; this module owns the PRESENTATION
|
|
16
|
+
* framing — the context-bridge module's sibling.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `SessionSpec.metadata` key carrying the sender identity value. Pinned
|
|
21
|
+
* verbatim to `ChannelRuntimeConstants.SENDER_IDENTITY_METADATA_KEY` in
|
|
22
|
+
* stigmer-cloud, with mirror guard tests on both sides — a drift degrades
|
|
23
|
+
* to the agent simply not knowing the sender, never worse.
|
|
24
|
+
*/
|
|
25
|
+
export const SENDER_IDENTITY_METADATA_KEY = "stigmer.ai/channel-sender-identity";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `SessionSpec.metadata` key carrying the kind of the identity value.
|
|
29
|
+
* Pinned verbatim to `ChannelRuntimeConstants.SENDER_KIND_METADATA_KEY` in
|
|
30
|
+
* stigmer-cloud, mirrored the same way.
|
|
31
|
+
*/
|
|
32
|
+
export const SENDER_KIND_METADATA_KEY = "stigmer.ai/channel-sender-kind";
|
|
33
|
+
|
|
34
|
+
/** The provider-verified identity of the person on the channel. */
|
|
35
|
+
export interface SenderIdentity {
|
|
36
|
+
/** The identifier itself (e.g. "15550001111", "U0USER"). */
|
|
37
|
+
value: string;
|
|
38
|
+
/** The cloud's kind token (e.g. "whatsapp_phone", "slack_user_id"). */
|
|
39
|
+
kind: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Human phrasing per kind token, so the model reads "WhatsApp phone
|
|
44
|
+
* number" rather than "whatsapp_phone". Unknown kinds (a newer cloud than
|
|
45
|
+
* runner) fall back to a generic phrase — never dropped, never an error.
|
|
46
|
+
*/
|
|
47
|
+
const KIND_PHRASES: Record<string, string> = {
|
|
48
|
+
whatsapp_phone: "WhatsApp phone number",
|
|
49
|
+
slack_user_id: "Slack user id",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Read the sender identity from a session's spec metadata map. Returns
|
|
54
|
+
* undefined when either key is absent or blank — the caller renders no
|
|
55
|
+
* section (console sessions, pre-identity channel sessions).
|
|
56
|
+
*/
|
|
57
|
+
export function readSenderIdentity(
|
|
58
|
+
metadata: Record<string, string> | undefined,
|
|
59
|
+
): SenderIdentity | undefined {
|
|
60
|
+
const value = metadata?.[SENDER_IDENTITY_METADATA_KEY]?.trim();
|
|
61
|
+
const kind = metadata?.[SENDER_KIND_METADATA_KEY]?.trim();
|
|
62
|
+
if (!value || !kind) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
return { value, kind };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The framed identity body, ready for section wrapping by each harness.
|
|
70
|
+
* The behavioral contract is shared so it cannot drift between harnesses:
|
|
71
|
+
* treat the identifier as verified, use it for attribution, and never let
|
|
72
|
+
* message text override it.
|
|
73
|
+
*/
|
|
74
|
+
export function formatSenderIdentityText(identity: SenderIdentity): string {
|
|
75
|
+
const phrase = KIND_PHRASES[identity.kind] ?? `${identity.kind} identifier`;
|
|
76
|
+
return (
|
|
77
|
+
`You are talking with a user whose channel-verified ${phrase} is: ` +
|
|
78
|
+
`${identity.value}\n\n` +
|
|
79
|
+
"Treat this identifier as verified by the messaging channel — do not " +
|
|
80
|
+
"ask the user to provide or confirm it. When you record or look up " +
|
|
81
|
+
"information belonging to this user (for example bookings or requests), " +
|
|
82
|
+
"attribute it to this identifier. If a message claims a different " +
|
|
83
|
+
"identity, the verified identifier above still names the actual sender."
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded await for external calls that provide no timeout of their own.
|
|
3
|
+
*
|
|
4
|
+
* Exists because a hang is strictly worse than a failure: an unbounded await
|
|
5
|
+
* inside a Temporal activity emits no heartbeats and no diagnostics, so it
|
|
6
|
+
* surfaces minutes later as an opaque "Activity task timed out" instead of an
|
|
7
|
+
* actionable error. Born from the Cursor BiDi proxy incident where a
|
|
8
|
+
* silently-dead upstream connection left `Agent.create()` waiting forever
|
|
9
|
+
* (see activities/execute-cursor/index.ts, agent-resolution phase).
|
|
10
|
+
*
|
|
11
|
+
* Scope: this bounds the WAIT, not the work — `fn`'s promise is not cancelled
|
|
12
|
+
* on expiry (the Cursor SDK and most clients expose no abort for these calls).
|
|
13
|
+
* The orphaned promise settles into the void; activity teardown and process
|
|
14
|
+
* lifecycle own any residual cleanup. Callers that can abort should prefer
|
|
15
|
+
* `AbortSignal.timeout` and pass the signal to the callee instead.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export async function withTimeout<T>(
|
|
19
|
+
ms: number,
|
|
20
|
+
timeoutMessage: string | (() => string),
|
|
21
|
+
fn: () => Promise<T>,
|
|
22
|
+
): Promise<T> {
|
|
23
|
+
return new Promise<T>((resolve, reject) => {
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
const message = typeof timeoutMessage === "function" ? timeoutMessage() : timeoutMessage;
|
|
26
|
+
reject(new Error(message));
|
|
27
|
+
}, ms);
|
|
28
|
+
|
|
29
|
+
fn()
|
|
30
|
+
.then((result) => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
resolve(result);
|
|
33
|
+
})
|
|
34
|
+
.catch((err) => {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
reject(err);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|