@stigmer/runner 3.6.0 → 3.8.0

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 (100) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-agent.js +85 -10
  3. package/dist/activities/call-agent.js.map +1 -1
  4. package/dist/activities/execute-cursor/index.d.ts +12 -0
  5. package/dist/activities/execute-cursor/index.js +80 -10
  6. package/dist/activities/execute-cursor/index.js.map +1 -1
  7. package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
  8. package/dist/activities/execute-cursor/model-pricing.js +19 -0
  9. package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
  10. package/dist/activities/execute-cursor/prompt-builder.d.ts +11 -0
  11. package/dist/activities/execute-cursor/prompt-builder.js +11 -0
  12. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  13. package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
  14. package/dist/activities/execute-cursor/service-tier.js +187 -0
  15. package/dist/activities/execute-cursor/service-tier.js.map +1 -0
  16. package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
  17. package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
  18. package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
  19. package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
  20. package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
  21. package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
  22. package/dist/activities/execute-deep-agent/mcp-gate.d.ts +28 -0
  23. package/dist/activities/execute-deep-agent/mcp-gate.js +22 -0
  24. package/dist/activities/execute-deep-agent/mcp-gate.js.map +1 -0
  25. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +11 -0
  26. package/dist/activities/execute-deep-agent/prompt-builder.js +16 -0
  27. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/setup.js +30 -4
  29. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  30. package/dist/client/stigmer-client.d.ts +6 -1
  31. package/dist/client/stigmer-client.js +5 -2
  32. package/dist/client/stigmer-client.js.map +1 -1
  33. package/dist/main.js +18 -0
  34. package/dist/main.js.map +1 -1
  35. package/dist/runner.js +48 -0
  36. package/dist/runner.js.map +1 -1
  37. package/dist/sandbox-token-renewal.d.ts +65 -0
  38. package/dist/sandbox-token-renewal.js +169 -0
  39. package/dist/sandbox-token-renewal.js.map +1 -0
  40. package/dist/shared/artifact-storage.d.ts +17 -3
  41. package/dist/shared/artifact-storage.js +22 -4
  42. package/dist/shared/artifact-storage.js.map +1 -1
  43. package/dist/shared/channel-attachment.d.ts +3 -1
  44. package/dist/shared/channel-attachment.js +3 -1
  45. package/dist/shared/channel-attachment.js.map +1 -1
  46. package/dist/shared/conversation-attachment.d.ts +81 -0
  47. package/dist/shared/conversation-attachment.js +102 -0
  48. package/dist/shared/conversation-attachment.js.map +1 -0
  49. package/dist/shared/conversation-catchup.d.ts +33 -0
  50. package/dist/shared/conversation-catchup.js +53 -0
  51. package/dist/shared/conversation-catchup.js.map +1 -0
  52. package/dist/workflow-engine/loader.js +99 -2
  53. package/dist/workflow-engine/loader.js.map +1 -1
  54. package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
  55. package/dist/workflow-engine/tasks/call-agent.js +0 -2
  56. package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
  57. package/dist/workflow-engine/types.d.ts +39 -7
  58. package/dist/workflow-engine/types.js.map +1 -1
  59. package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
  60. package/dist/workflows/call-agent-orchestrator.js +8 -2
  61. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  62. package/package.json +2 -2
  63. package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
  64. package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
  65. package/src/activities/__tests__/call-agent.test.ts +219 -4
  66. package/src/activities/call-agent.ts +94 -10
  67. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +79 -0
  68. package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
  69. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
  70. package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
  71. package/src/activities/execute-cursor/index.ts +111 -11
  72. package/src/activities/execute-cursor/model-pricing.ts +23 -0
  73. package/src/activities/execute-cursor/prompt-builder.ts +23 -0
  74. package/src/activities/execute-cursor/service-tier.ts +244 -0
  75. package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
  76. package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
  77. package/src/activities/execute-deep-agent/__tests__/mcp-gate.test.ts +42 -0
  78. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +39 -1
  79. package/src/activities/execute-deep-agent/mcp-gate.ts +37 -0
  80. package/src/activities/execute-deep-agent/prompt-builder.ts +22 -2
  81. package/src/activities/execute-deep-agent/setup.ts +40 -4
  82. package/src/client/stigmer-client.ts +11 -4
  83. package/src/main.ts +20 -0
  84. package/src/runner.ts +62 -0
  85. package/src/sandbox-token-renewal.ts +212 -0
  86. package/src/shared/__tests__/channel-attachment.test.ts +3 -3
  87. package/src/shared/__tests__/conversation-attachment.test.ts +138 -0
  88. package/src/shared/__tests__/conversation-catchup.test.ts +70 -0
  89. package/src/shared/__tests__/synthesized-attachment.test.ts +120 -0
  90. package/src/shared/artifact-storage.ts +32 -7
  91. package/src/shared/channel-attachment.ts +3 -1
  92. package/src/shared/conversation-attachment.ts +115 -0
  93. package/src/shared/conversation-catchup.ts +60 -0
  94. package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
  95. package/src/workflow-engine/__tests__/loader.test.ts +192 -7
  96. package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
  97. package/src/workflow-engine/loader.ts +113 -2
  98. package/src/workflow-engine/tasks/call-agent.ts +0 -2
  99. package/src/workflow-engine/types.ts +40 -7
  100. package/src/workflows/call-agent-orchestrator.ts +8 -2
@@ -0,0 +1,170 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
3
+
4
+ /**
5
+ * Verifies the service-tier → Cursor variant-parameter translation (#357):
6
+ * the runner must always send an explicit selection whose price-bearing
7
+ * parameters are a deterministic function of the requested tier — never
8
+ * the catalog's (account-influenced) default variant.
9
+ *
10
+ * Catalog fixtures mirror the real shapes observed 2026-08-06: composer
11
+ * has a `fast` bool (default fast=true), haiku has a `thinking` bool
12
+ * (default thinking=true), grok has `effort` x `fast`, and Auto
13
+ * ("default") has a single variant with no parameters.
14
+ */
15
+ const listMock = vi.hoisted(() => vi.fn());
16
+
17
+ vi.mock("@cursor/sdk", () => ({
18
+ Cursor: { models: { list: listMock } },
19
+ }));
20
+
21
+ import {
22
+ resolveEffectiveServiceTier,
23
+ resolveServiceTierParams,
24
+ resetCatalogCacheForTests,
25
+ } from "../service-tier.js";
26
+
27
+ const CATALOG = [
28
+ {
29
+ id: "composer-2.5",
30
+ displayName: "Composer 2.5",
31
+ aliases: ["composer-latest", "composer"],
32
+ parameters: [
33
+ { id: "fast", values: [{ value: "false" }, { value: "true" }] },
34
+ ],
35
+ },
36
+ {
37
+ id: "claude-haiku-4-5",
38
+ displayName: "Haiku 4.5",
39
+ aliases: ["haiku"],
40
+ parameters: [
41
+ { id: "thinking", values: [{ value: "false" }, { value: "true" }] },
42
+ ],
43
+ },
44
+ {
45
+ id: "grok-4.5",
46
+ displayName: "Cursor Grok 4.5",
47
+ parameters: [
48
+ { id: "effort", values: [{ value: "low" }, { value: "medium" }, { value: "high" }] },
49
+ { id: "fast", values: [{ value: "false" }, { value: "true" }] },
50
+ ],
51
+ },
52
+ {
53
+ id: "claude-opus-4-8",
54
+ displayName: "Opus 4.8",
55
+ parameters: [
56
+ { id: "thinking", values: [{ value: "false" }, { value: "true" }] },
57
+ { id: "effort", values: [{ value: "low" }, { value: "high" }] },
58
+ { id: "fast", values: [{ value: "false" }, { value: "true" }] },
59
+ ],
60
+ },
61
+ {
62
+ id: "default",
63
+ displayName: "Auto",
64
+ aliases: ["auto"],
65
+ variants: [{ params: [], displayName: "Auto", isDefault: true }],
66
+ },
67
+ ];
68
+
69
+ function opts(modelId: string, tier: ServiceTier.STANDARD | ServiceTier.FAST) {
70
+ return { apiKey: "key-1", modelId, tier, executionId: "aex_test" };
71
+ }
72
+
73
+ beforeEach(() => {
74
+ resetCatalogCacheForTests();
75
+ listMock.mockReset();
76
+ listMock.mockResolvedValue(CATALOG);
77
+ });
78
+
79
+ describe("resolveEffectiveServiceTier", () => {
80
+ it("resolves UNSPECIFIED to STANDARD — never the account default", () => {
81
+ expect(resolveEffectiveServiceTier(ServiceTier.UNSPECIFIED)).toBe(ServiceTier.STANDARD);
82
+ expect(resolveEffectiveServiceTier(undefined)).toBe(ServiceTier.STANDARD);
83
+ });
84
+
85
+ it("preserves explicit STANDARD and FAST", () => {
86
+ expect(resolveEffectiveServiceTier(ServiceTier.STANDARD)).toBe(ServiceTier.STANDARD);
87
+ expect(resolveEffectiveServiceTier(ServiceTier.FAST)).toBe(ServiceTier.FAST);
88
+ });
89
+ });
90
+
91
+ describe("resolveServiceTierParams", () => {
92
+ it("STANDARD pins fast=false on a fast-capable model", async () => {
93
+ const params = await resolveServiceTierParams(opts("composer-2.5", ServiceTier.STANDARD));
94
+ expect(params).toEqual([{ id: "fast", value: "false" }]);
95
+ });
96
+
97
+ it("FAST pins fast=true on a fast-capable model", async () => {
98
+ const params = await resolveServiceTierParams(opts("composer-2.5", ServiceTier.FAST));
99
+ expect(params).toEqual([{ id: "fast", value: "true" }]);
100
+ });
101
+
102
+ it("STANDARD pins thinking=false on a thinking-capable model (the haiku drift)", async () => {
103
+ const params = await resolveServiceTierParams(opts("claude-haiku-4-5", ServiceTier.STANDARD));
104
+ expect(params).toEqual([{ id: "thinking", value: "false" }]);
105
+ });
106
+
107
+ it("FAST on a model with no fast parameter fails loudly, never downgrades", async () => {
108
+ await expect(
109
+ resolveServiceTierParams(opts("claude-haiku-4-5", ServiceTier.FAST)),
110
+ ).rejects.toThrow(/no "fast" parameter/);
111
+ });
112
+
113
+ it("leaves price-neutral parameters (effort) to the catalog default", async () => {
114
+ const params = await resolveServiceTierParams(opts("grok-4.5", ServiceTier.STANDARD));
115
+ expect(params).toEqual([{ id: "fast", value: "false" }]);
116
+ });
117
+
118
+ it("pins every price-bearing parameter, sorted, on multi-dimension models", async () => {
119
+ const params = await resolveServiceTierParams(opts("claude-opus-4-8", ServiceTier.FAST));
120
+ expect(params).toEqual([
121
+ { id: "fast", value: "true" },
122
+ { id: "thinking", value: "false" },
123
+ ]);
124
+ });
125
+
126
+ it("resolves models referenced by alias", async () => {
127
+ const params = await resolveServiceTierParams(opts("composer", ServiceTier.STANDARD));
128
+ expect(params).toEqual([{ id: "fast", value: "false" }]);
129
+ });
130
+
131
+ it("Auto has no tier dimension: STANDARD sends no params", async () => {
132
+ const params = await resolveServiceTierParams(opts("default", ServiceTier.STANDARD));
133
+ expect(params).toEqual([]);
134
+ // No catalog fetch needed for Auto — nothing to look up.
135
+ expect(listMock).not.toHaveBeenCalled();
136
+ });
137
+
138
+ it("Auto + FAST is a loud failure (registry/catalog drift, not a silent no-op)", async () => {
139
+ await expect(
140
+ resolveServiceTierParams(opts("default", ServiceTier.FAST)),
141
+ ).rejects.toThrow(/requires a pinned model/);
142
+ });
143
+
144
+ it("unknown model: STANDARD degrades to no params, FAST fails loudly", async () => {
145
+ await expect(
146
+ resolveServiceTierParams(opts("not-a-model", ServiceTier.STANDARD)),
147
+ ).resolves.toEqual([]);
148
+ await expect(
149
+ resolveServiceTierParams(opts("not-a-model", ServiceTier.FAST)),
150
+ ).rejects.toThrow(/does not list that model/);
151
+ });
152
+
153
+ it("catalog fetch failure: STANDARD degrades, FAST fails loudly", async () => {
154
+ listMock.mockRejectedValue(new Error("proxy down"));
155
+ await expect(
156
+ resolveServiceTierParams(opts("composer-2.5", ServiceTier.STANDARD)),
157
+ ).resolves.toEqual([]);
158
+ resetCatalogCacheForTests();
159
+ listMock.mockRejectedValue(new Error("proxy down"));
160
+ await expect(
161
+ resolveServiceTierParams(opts("composer-2.5", ServiceTier.FAST)),
162
+ ).rejects.toThrow(/catalog fetch failed/);
163
+ });
164
+
165
+ it("caches the catalog per worker — one fetch for repeated resolutions", async () => {
166
+ await resolveServiceTierParams(opts("composer-2.5", ServiceTier.STANDARD));
167
+ await resolveServiceTierParams(opts("claude-haiku-4-5", ServiceTier.STANDARD));
168
+ expect(listMock).toHaveBeenCalledTimes(1);
169
+ });
170
+ });
@@ -1,7 +1,45 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi, beforeAll } from "vitest";
2
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
2
3
 
3
4
  import { UsageAccumulator } from "../usage-accumulator.js";
4
5
 
6
+ // The accumulator's per-turn estimate reads the worker's pricing table
7
+ // (model-pricing.ts); load it from a stubbed registry so the fast-rate
8
+ // assertions run against known prices (the model-pricing.test.ts pattern).
9
+ beforeAll(async () => {
10
+ const registry = {
11
+ models: [
12
+ {
13
+ id: "composer-2.5",
14
+ displayName: "Composer 2.5",
15
+ provider: "cursor",
16
+ harness: "cursor",
17
+ costTier: "economy",
18
+ pricing: {
19
+ inputPricePerMillion: 0.5,
20
+ outputPricePerMillion: 2.5,
21
+ cacheWritePricePerMillion: 0,
22
+ cacheReadPricePerMillion: 0.2,
23
+ },
24
+ pricingVariants: {
25
+ fast: {
26
+ inputPricePerMillion: 3.0,
27
+ outputPricePerMillion: 15.0,
28
+ cacheWritePricePerMillion: 0,
29
+ cacheReadPricePerMillion: 0.2,
30
+ },
31
+ },
32
+ },
33
+ ],
34
+ };
35
+ vi.stubGlobal(
36
+ "fetch",
37
+ vi.fn(async () => ({ ok: true, status: 200, json: async () => registry })),
38
+ );
39
+ process.env.STIGMER_TOKEN = "test-token";
40
+ await (await import("../model-pricing.js")).ensureLoaded();
41
+ });
42
+
5
43
  /**
6
44
  * Guards the token-accounting convention the Usage widget and the billing
7
45
  * parity test both depend on: the Cursor SDK's inputTokens already INCLUDES
@@ -43,4 +81,52 @@ describe("UsageAccumulator", () => {
43
81
  expect(snap.outputTokens).toBe(0n);
44
82
  expect(snap.totalTokens).toBe(42n);
45
83
  });
84
+
85
+ it("records the requested tier and params into the snapshot (#357 audit trail)", () => {
86
+ const acc = new UsageAccumulator(
87
+ "composer-2.5",
88
+ ServiceTier.FAST,
89
+ [{ id: "fast", value: "true" }],
90
+ );
91
+ acc.addTurn({ inputTokens: 10, outputTokens: 5 });
92
+ const snap = acc.snapshot();
93
+ expect(snap.requestedServiceTier).toBe(ServiceTier.FAST);
94
+ expect(snap.requestedModelParams).toBe('[{"id":"fast","value":"true"}]');
95
+ });
96
+
97
+ it("records an empty params string when the runner sent none", () => {
98
+ const acc = new UsageAccumulator("default", ServiceTier.STANDARD, []);
99
+ acc.addTurn({ inputTokens: 1 });
100
+ const snap = acc.snapshot();
101
+ expect(snap.requestedServiceTier).toBe(ServiceTier.STANDARD);
102
+ expect(snap.requestedModelParams).toBe("");
103
+ });
104
+
105
+ it("estimates FAST runs at fast-variant rates, not base rates (#357)", () => {
106
+ // Revert guard for the tier→pricing wiring in addTurn: a FAST run
107
+ // priced at base rates would understate the display estimate ~6x
108
+ // relative to the authoritative bill. Rates from the stubbed registry:
109
+ // base $0.5/$2.5 per M, fast $3/$15 per M.
110
+ const turn = { inputTokens: 1_000_000, outputTokens: 1_000_000 };
111
+
112
+ const standard = new UsageAccumulator("composer-2.5", ServiceTier.STANDARD);
113
+ standard.addTurn(turn);
114
+ const fast = new UsageAccumulator("composer-2.5", ServiceTier.FAST);
115
+ fast.addTurn(turn);
116
+
117
+ expect(standard.snapshot().estimatedCostUsd).toBeCloseTo(3.0, 6);
118
+ expect(fast.snapshot().estimatedCostUsd).toBeCloseTo(18.0, 6);
119
+ });
120
+
121
+ it("estimates UNSPECIFIED at base rates (resolves to standard)", () => {
122
+ const turn = { inputTokens: 1_000_000, outputTokens: 1_000_000 };
123
+
124
+ const unspecified = new UsageAccumulator("composer-2.5");
125
+ unspecified.addTurn(turn);
126
+ const standard = new UsageAccumulator("composer-2.5", ServiceTier.STANDARD);
127
+ standard.addTurn(turn);
128
+
129
+ expect(unspecified.snapshot().estimatedCostUsd)
130
+ .toBe(standard.snapshot().estimatedCostUsd);
131
+ });
46
132
  });
@@ -48,6 +48,7 @@ import { MessageAccumulator, cancelInProgressSubAgentProtos, collapseRedundantTo
48
48
  import { utcTimestamp, persistStatus, reportSetupProgress, slimStatus } from "../../shared/status.js";
49
49
  import { TimingRecorder, emitTimingLog } from "../../shared/cold-start-timing.js";
50
50
  import { readContextBridge } from "../../shared/context-bridge.js";
51
+ import { readConversationCatchup } from "../../shared/conversation-catchup.js";
51
52
  import { readSenderIdentity } from "../../shared/sender-identity.js";
52
53
  import {
53
54
  injectCallerIdentityEnv,
@@ -69,6 +70,10 @@ import {
69
70
  discoverChannelMessaging,
70
71
  synthesizeChannelAttachment,
71
72
  } from "../../shared/channel-attachment.js";
73
+ import {
74
+ readChannelConversationId,
75
+ synthesizeConversationAttachment,
76
+ } from "../../shared/conversation-attachment.js";
72
77
  import { injectSynthesizedAttachment } from "../../shared/synthesized-attachment.js";
73
78
  import { mergeApprovalPolicies } from "./approval-policy.js";
74
79
  import { deriveActiveLeases, isUnattendedApprovalMode } from "../../shared/approval-policy.js";
@@ -79,7 +84,7 @@ import { buildCursorSubAgentDefinitions } from "./subagent-config.js";
79
84
  import { resolveSkills } from "./skill-resolver.js";
80
85
  import { removeStigmerSymlink } from "../../shared/workspace/stigmer-link.js";
81
86
  import { resolveAttachments } from "./attachment-resolver.js";
82
- import { buildEnhancedPrompt, buildReinvocationPrompt, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
87
+ import { buildEnhancedPrompt, buildReinvocationPrompt, formatConversationCatchupSection, formatInteractionModePrefix, formatImplementPlanSection } from "./prompt-builder.js";
83
88
  import { installHitlGate, removeHitlGate } from "./workspace-setup.js";
84
89
  import { ensureHitlDir } from "../../shared/workspace/platform-dir.js";
85
90
  import {
@@ -121,6 +126,7 @@ import { statusProtoWriter } from "../../shared/execution-status-writer.js";
121
126
  import { setInterceptorExecutionId, runWithExecutionContext } from "./fetch-interceptor.js";
122
127
  import { closeProxySessions } from "./http2-interceptor.js";
123
128
  import { resolveModelId, ensureLoaded as ensurePricingLoaded } from "./model-pricing.js";
129
+ import { resolveEffectiveServiceTier, resolveServiceTierParams } from "./service-tier.js";
124
130
  import { UsageAccumulator } from "./usage-accumulator.js";
125
131
  import { StreamingUsageSummarySchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/usage_pb";
126
132
  import { activityStarted, activityFinished } from "../../idle-watchdog.js";
@@ -684,6 +690,31 @@ async function executeCursorInner(
684
690
  };
685
691
  }
686
692
  }
693
+
694
+ // Phase 4a4: Synthesize the conversation participation attachment
695
+ // (channel-conversations DD-008 D-c) — the third sibling. The
696
+ // channel-id session label IS the attachment decision (stamped
697
+ // server-side on every channel session; a free local read, unlike
698
+ // the channels discovery RPC above). HTTP-only: synthesize answers
699
+ // undefined with no bridge endpoint by design (see
700
+ // shared/conversation-attachment.ts).
701
+ const conversationAttachment = synthesizeConversationAttachment(
702
+ readChannelConversationId(session.metadata?.labels),
703
+ {
704
+ bridgeEndpoint: config.mcpBridgeEndpoint,
705
+ credential: attachmentCredential,
706
+ backendEndpoint: config.stigmerBackendEndpoint,
707
+ },
708
+ );
709
+ if (conversationAttachment) {
710
+ const resolvedServers = injectSynthesizedAttachment(
711
+ mcpResolution.resolvedServers, conversationAttachment, "conversation participation",
712
+ );
713
+ mcpResolution = {
714
+ resolvedServers,
715
+ cursorConfig: toCursorMcpConfig(resolvedServers),
716
+ };
717
+ }
687
718
  const mcpConfig = mcpResolution.cursorConfig;
688
719
 
689
720
  // Phase 4b: Merge approval policies from all layers.
@@ -889,7 +920,9 @@ async function executeCursorInner(
889
920
  await ensurePricingLoaded();
890
921
  setupTiming.mark("load_pricing");
891
922
 
892
- // Phase 6: Validate model selection
923
+ // Phase 6: Validate model selection and resolve the service tier.
924
+ // UNSPECIFIED → STANDARD resolves here and nowhere else (#357): every
925
+ // upstream layer preserves the caller's raw enum value.
893
926
  const requestedModel = spec.executionConfig?.modelName || "default";
894
927
  const validatedModel = resolveModelId(requestedModel);
895
928
  if (validatedModel !== requestedModel) {
@@ -897,6 +930,7 @@ async function executeCursorInner(
897
930
  `ExecuteCursor model resolved: execution=${executionId}, requested="${requestedModel}", using="${validatedModel}"`,
898
931
  );
899
932
  }
933
+ const requestedServiceTier = resolveEffectiveServiceTier(spec.executionConfig?.serviceTier);
900
934
 
901
935
  heartbeat();
902
936
 
@@ -931,10 +965,21 @@ async function executeCursorInner(
931
965
  );
932
966
  }
933
967
 
968
+ // Translate the tier into the explicit variant params sent with every
969
+ // create/resume. Never a bare { id }: the catalog's default variant is
970
+ // account-influenced and picks the price (#357).
971
+ const modelParams = await resolveServiceTierParams({
972
+ apiKey: effectiveApiKey,
973
+ modelId: validatedModel,
974
+ tier: requestedServiceTier,
975
+ executionId,
976
+ });
977
+
934
978
  const createOptions: CreateAgentOptions | CreateCloudAgentOptions = agentMode === "cloud"
935
979
  ? {
936
980
  apiKey: effectiveApiKey,
937
981
  model: validatedModel || undefined,
982
+ modelParams,
938
983
  repos: blueprint.cloudRepos,
939
984
  sessionId,
940
985
  mcpServers: mcpConfig,
@@ -943,6 +988,7 @@ async function executeCursorInner(
943
988
  : {
944
989
  apiKey: effectiveApiKey,
945
990
  model: validatedModel,
991
+ modelParams,
946
992
  workspaceDirs: blueprint.workspaceDirs,
947
993
  sessionId,
948
994
  workspaceRootDir: config.workspaceRootDir,
@@ -1055,6 +1101,7 @@ async function executeCursorInner(
1055
1101
  contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
1056
1102
  senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1057
1103
  sessionContext: readSessionContext(blueprint.sessionSpec.metadata),
1104
+ conversationCatchup: readConversationCatchup(spec.conversationCatchup),
1058
1105
  });
1059
1106
 
1060
1107
  // Phase 10a: Inject structured output instruction for Cursor harness
@@ -1075,7 +1122,11 @@ async function executeCursorInner(
1075
1122
 
1076
1123
  // Phase 10b: Initialize usage accumulator for runner-side token tracking
1077
1124
  await ensurePricingLoaded();
1078
- const usageAccumulator = new UsageAccumulator(validatedModel);
1125
+ const usageAccumulator = new UsageAccumulator(
1126
+ validatedModel,
1127
+ requestedServiceTier,
1128
+ modelParams,
1129
+ );
1079
1130
 
1080
1131
  // Phase 10c: Start OTel turn span. Coarse-grained — spans the whole turn
1081
1132
  // (agent.send + stream + any recovery retry + the turn boundary), ended once
@@ -1577,14 +1628,36 @@ async function executeCursorInner(
1577
1628
 
1578
1629
  // Phase 13: Map final result
1579
1630
  const result = await run.wait();
1580
- const sdkResolvedModel = result.model?.id || undefined;
1581
1631
  console.log(
1582
1632
  `ExecuteCursor run.wait() result: execution=${executionId}, result=${JSON.stringify(result)}`,
1583
1633
  );
1584
- if (sdkResolvedModel && sdkResolvedModel !== validatedModel) {
1585
- console.log(
1586
- `ExecuteCursor model divergence: execution=${executionId}, requested=${validatedModel}, sdkResolved=${sdkResolvedModel}`,
1587
- );
1634
+ // Echo sanity check only: result.model ECHOES the requested selection —
1635
+ // the SDK never reports the variant that actually served the call
1636
+ // (verified against the billing ledger, #357). A mismatch here means the
1637
+ // SDK rewrote our selection (contract change), not variant drift; the
1638
+ // authoritative requested-vs-billed reconciliation is the cloud billing
1639
+ // handler's pricing_variant mismatch metric.
1640
+ const echoedSelection = result.model;
1641
+ if (echoedSelection) {
1642
+ const idMatches = echoedSelection.id === validatedModel;
1643
+ // Compare id/value pairs explicitly, never serialized objects: the SDK
1644
+ // may add fields to ModelParameterValue or reorder keys, and neither
1645
+ // is contract drift.
1646
+ const echoedParams = [...(echoedSelection.params ?? [])]
1647
+ .sort((a, b) => a.id.localeCompare(b.id));
1648
+ const paramsMatch =
1649
+ echoedParams.length === modelParams.length &&
1650
+ echoedParams.every(
1651
+ (p, i) => p.id === modelParams[i].id && p.value === modelParams[i].value,
1652
+ );
1653
+ if (!idMatches || !paramsMatch) {
1654
+ console.warn(
1655
+ `ExecuteCursor model selection echo mismatch (SDK contract drift?): ` +
1656
+ `execution=${executionId}, ` +
1657
+ `requested=${JSON.stringify({ id: validatedModel, params: modelParams })}, ` +
1658
+ `echoed=${JSON.stringify(echoedSelection)}`,
1659
+ );
1660
+ }
1588
1661
  }
1589
1662
  status.completedAt = utcTimestamp();
1590
1663
 
@@ -1664,9 +1737,15 @@ async function executeCursorInner(
1664
1737
  attachmentPaths,
1665
1738
  pendingApprovals: adjudicatedApprovals,
1666
1739
  interactionMode,
1740
+ // buildFromPlan was silently dropped here until T03 Sitting 3 —
1741
+ // a build turn that hit handle recovery lost its directive. The
1742
+ // fresh prompt must carry every per-turn directive the original
1743
+ // did.
1744
+ buildFromPlan,
1667
1745
  contextBridge: readContextBridge(blueprint.sessionSpec.metadata),
1668
1746
  senderIdentity: readSenderIdentity(blueprint.sessionSpec.metadata),
1669
1747
  sessionContext: readSessionContext(blueprint.sessionSpec.metadata),
1748
+ conversationCatchup: readConversationCatchup(spec.conversationCatchup),
1670
1749
  });
1671
1750
 
1672
1751
  console.log(
@@ -2263,6 +2342,18 @@ export interface BuildPromptInput {
2263
2342
  * turn.
2264
2343
  */
2265
2344
  sessionContext?: string;
2345
+ /**
2346
+ * Conversation catchup from the execution spec's `conversation_catchup`
2347
+ * (cloud DD-006): what happened on the channel conversation that the
2348
+ * agent has not seen. PER-TURN, so unlike the three standing values
2349
+ * above it rides BOTH prompt paths — the enhanced prompt and a resumed
2350
+ * turn's prefix (the `interaction_mode` shape). Handback lands
2351
+ * mid-session on a resumed agent: the resumed path is the one that
2352
+ * matters. Once delivered, the digest persists in the agent's own
2353
+ * conversation store; the next turn's field is composed fresh and is
2354
+ * usually blank.
2355
+ */
2356
+ conversationCatchup?: string;
2266
2357
  }
2267
2358
 
2268
2359
  /**
@@ -2294,6 +2385,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2294
2385
  attachmentPaths,
2295
2386
  interactionMode,
2296
2387
  buildFromPlan,
2388
+ conversationCatchup,
2297
2389
  } = input;
2298
2390
 
2299
2391
  const isHitlReinvocation = approvalDecisions !== undefined && approvalDecisions.size > 0;
@@ -2311,15 +2403,22 @@ export function buildPrompt(input: BuildPromptInput): string {
2311
2403
 
2312
2404
  // A successfully resumed agent carries its own conversation context via the
2313
2405
  // SDK's native store — send the raw user message with no preamble. The
2314
- // exceptions are the per-EXECUTION directives, which never inherit from the
2406
+ // exceptions are the per-EXECUTION values, which never inherit from the
2315
2407
  // session's first turn: the interaction-mode prefix (a follow-up can switch
2316
2408
  // Agent→Plan mid-session, and for Cursor the prompt is the only plan-mode
2317
- // enforcement) and the implement-plan directive (the build turn is usually
2318
- // a follow-up on a resumed agent).
2409
+ // enforcement), the implement-plan directive (the build turn is usually a
2410
+ // follow-up on a resumed agent), and the conversation catchup (handback
2411
+ // ALWAYS lands mid-session on a resumed agent — this prefix is the property
2412
+ // the metadata lane structurally cannot deliver, cloud DD-006). Catchup
2413
+ // last: it is context, and context sits closest to the task (the enhanced
2414
+ // prompt's own ordering doctrine).
2319
2415
  if (resolution.reason === "resumed_successfully") {
2320
2416
  const prefixes = [
2321
2417
  formatInteractionModePrefix(interactionMode),
2322
2418
  formatImplementPlanSection(buildFromPlan, attachmentPaths),
2419
+ conversationCatchup !== undefined
2420
+ ? formatConversationCatchupSection(conversationCatchup)
2421
+ : undefined,
2323
2422
  ].filter((p): p is string => p !== undefined);
2324
2423
  return prefixes.length > 0
2325
2424
  ? [...prefixes, userMessage].join("\n\n")
@@ -2344,6 +2443,7 @@ export function buildPrompt(input: BuildPromptInput): string {
2344
2443
  contextBridge: input.contextBridge,
2345
2444
  senderIdentity: input.senderIdentity,
2346
2445
  sessionContext: input.sessionContext,
2446
+ conversationCatchup,
2347
2447
  });
2348
2448
  }
2349
2449
 
@@ -139,6 +139,29 @@ export function getCursorModelPricing(model: string): CursorModelPricing {
139
139
  return { ...DEFAULT_PRICING, model };
140
140
  }
141
141
 
142
+ /**
143
+ * Look up pricing for a Cursor model under an explicitly requested speed
144
+ * variant (stigmer/stigmer#357). Unlike the suffix inference above — which
145
+ * derives the variant from a wire id like "composer-2.5-fast" — this is for
146
+ * callers that KNOW the variant because they requested it. Falls back to
147
+ * base rates (with a warning) when the registry prices no such variant;
148
+ * create-time validation makes that unreachable short of registry drift.
149
+ */
150
+ export function getCursorModelPricingForVariant(
151
+ model: string,
152
+ variant: "fast" | null,
153
+ ): CursorModelPricing {
154
+ const base = getCursorModelPricing(model);
155
+ if (variant !== "fast") return base;
156
+ const fast = applyFastVariant(base, model);
157
+ if (fast) return fast;
158
+ console.warn(
159
+ `Requested fast-variant pricing for "${model}" but the registry prices no fast variant — ` +
160
+ `estimating at base rates (billing reconciliation remains authoritative)`,
161
+ );
162
+ return base;
163
+ }
164
+
142
165
  /**
143
166
  * Compute USD cost for a single turn.
144
167
  *
@@ -21,6 +21,7 @@ import type { DatastoreUsage, SubAgent } from "@stigmer/protos/ai/stigmer/agenti
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
23
  import { formatContextBridgeText } from "../../shared/context-bridge.js";
24
+ import { formatConversationCatchupText } from "../../shared/conversation-catchup.js";
24
25
  import { formatDatastoresSection } from "../../shared/datastore-attachment.js";
25
26
  import {
26
27
  formatChannelTemplatesSection,
@@ -107,6 +108,16 @@ export interface EnhancedPromptOptions {
107
108
  * store — the context is constant for the session's lifetime.
108
109
  */
109
110
  sessionContext?: string;
111
+ /**
112
+ * Conversation catchup (cloud DD-006): what happened on the channel
113
+ * conversation that the agent has not seen, read from the execution
114
+ * spec's `conversation_catchup`. PER-TURN, unlike the three standing
115
+ * siblings above: it rides BOTH prompt paths — this enhanced prompt and
116
+ * a resumed turn's prefix (the `interaction_mode` shape) — because
117
+ * handback lands mid-session on a resumed agent, the exact case the
118
+ * metadata lane cannot reach.
119
+ */
120
+ conversationCatchup?: string;
110
121
  }
111
122
 
112
123
  /**
@@ -200,6 +211,14 @@ export function buildEnhancedPrompt(options: EnhancedPromptOptions): string {
200
211
  sections.push(formatContextBridgeSection(options.contextBridge));
201
212
  }
202
213
 
214
+ // Catchup after the bridge (DD-007 D-d: bridge first, catchup second) —
215
+ // the bridge carries the pre-takeover conversation, the catchup the human
216
+ // episode, strictly newer by construction; recency puts it closer to the
217
+ // task.
218
+ if (options.conversationCatchup) {
219
+ sections.push(formatConversationCatchupSection(options.conversationCatchup));
220
+ }
221
+
203
222
  // Always last before the task: the platform's tool-approval protocol. Placed
204
223
  // here for recency so it outweighs any "ask the user first" guidance Cursor
205
224
  // surfaces from a connected MCP server (see formatToolApprovalProtocol).
@@ -345,6 +364,10 @@ export function formatSessionContextSection(context: string): string {
345
364
  return `<session_context>\n${formatSessionContextText(context)}\n</session_context>`;
346
365
  }
347
366
 
367
+ export function formatConversationCatchupSection(digest: string): string {
368
+ return `<conversation_catchup>\n${formatConversationCatchupText(digest)}\n</conversation_catchup>`;
369
+ }
370
+
348
371
  export function formatSkillsSection(skills: SkillMetadata[]): string {
349
372
  const entries = skills.map(
350
373
  (s) => `- **${s.name}**: ${s.description}\n Path: \`${s.path}\``,