@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,244 @@
1
+ /**
2
+ * Service-tier → Cursor variant-parameter translation (stigmer/stigmer#357).
3
+ *
4
+ * The platform contract: an execution's model selection is ALWAYS explicit.
5
+ * A bare `{ id }` lets the Cursor catalog's default variant decide the price
6
+ * (observed 2026-08-06: composer-2.5 defaults to fast=true at ~4x base
7
+ * rates, claude-haiku-4-5 to thinking=true), and that default follows an
8
+ * out-of-band account setting. This module pins every price-bearing variant
9
+ * parameter the model declares, so the billed variant is a deterministic
10
+ * function of ExecutionConfig.service_tier:
11
+ *
12
+ * - STANDARD: every price-bearing boolean pinned to its base value
13
+ * (fast=false, thinking=false where the parameter exists).
14
+ * - FAST: fast=true, thinking still pinned false.
15
+ * - Price-neutral parameters (e.g. effort) are deliberately NOT pinned —
16
+ * they follow the catalog default and do not change the bill.
17
+ *
18
+ * Parameter bundles come from Cursor.models.list() (worker-cached): the
19
+ * catalog is the only source of a model's parameter ids, and the fetch
20
+ * rides the same proxy fetch-interceptor as every other SDK call, so it
21
+ * works identically in proxy and direct modes.
22
+ *
23
+ * UNSPECIFIED resolves to STANDARD here and ONLY here — every upstream
24
+ * layer preserves the caller's raw enum so "user chose standard" stays
25
+ * distinguishable from "platform default" all the way to the ledger.
26
+ */
27
+
28
+ import { Cursor } from "@cursor/sdk";
29
+ import type { ModelListItem, ModelParameterValue } from "@cursor/sdk";
30
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
31
+
32
+ /**
33
+ * The effective tier after platform-default resolution: never UNSPECIFIED.
34
+ */
35
+ export type EffectiveServiceTier = ServiceTier.STANDARD | ServiceTier.FAST;
36
+
37
+ /**
38
+ * Catalog ids that mean "Cursor picks the model" (Auto). Auto's single
39
+ * catalog variant has an empty parameter set — there is no tier dimension
40
+ * to pin, a documented v1 limitation. Create-time validation refuses FAST
41
+ * without a pinned model, so FAST can only reach Auto through registry
42
+ * drift — handled as a loud failure below.
43
+ */
44
+ const AUTO_MODEL_IDS = new Set(["default", "auto"]);
45
+
46
+ /**
47
+ * Variant parameter ids that change the per-token price. Pinning exactly
48
+ * these keeps the bill deterministic while leaving latency/effort knobs on
49
+ * their catalog defaults. Sourced from the Cursor catalog survey
50
+ * (stigmer-cloud _projects/2026-08/20260806.04.model-service-tier).
51
+ */
52
+ const FAST_PARAM_ID = "fast";
53
+ const THINKING_PARAM_ID = "thinking";
54
+
55
+ const CATALOG_CACHE_TTL_MS = 3_600_000; // 1 hour, matching the pricing table TTL
56
+
57
+ interface CatalogCache {
58
+ readonly apiKey: string;
59
+ readonly models: readonly ModelListItem[];
60
+ readonly expiresAt: number;
61
+ }
62
+
63
+ let catalogCache: CatalogCache | null = null;
64
+ // Keyed by apiKey: a worker normally serves one org's key, but nothing
65
+ // enforces that — handing execution A a catalog fetched under execution B's
66
+ // key would leak another account's model availability into A's translation.
67
+ let inflightCatalogFetch: { readonly apiKey: string; readonly promise: Promise<readonly ModelListItem[]> } | null = null;
68
+
69
+ /** Test-only: drop the worker-level catalog cache. */
70
+ export function resetCatalogCacheForTests(): void {
71
+ catalogCache = null;
72
+ inflightCatalogFetch = null;
73
+ }
74
+
75
+ /**
76
+ * Resolve the configured tier to its effective value. The single place in
77
+ * the platform where UNSPECIFIED becomes STANDARD.
78
+ */
79
+ export function resolveEffectiveServiceTier(
80
+ configured: ServiceTier | undefined,
81
+ ): EffectiveServiceTier {
82
+ return configured === ServiceTier.FAST ? ServiceTier.FAST : ServiceTier.STANDARD;
83
+ }
84
+
85
+ /** Human-readable tier label for logs and error messages. */
86
+ export function serviceTierLabel(tier: ServiceTier): string {
87
+ switch (tier) {
88
+ case ServiceTier.FAST:
89
+ return "fast";
90
+ case ServiceTier.STANDARD:
91
+ return "standard";
92
+ default:
93
+ return "unspecified";
94
+ }
95
+ }
96
+
97
+ async function listCatalogModels(apiKey: string): Promise<readonly ModelListItem[]> {
98
+ const now = Date.now();
99
+ if (catalogCache && catalogCache.apiKey === apiKey && catalogCache.expiresAt > now) {
100
+ return catalogCache.models;
101
+ }
102
+ if (inflightCatalogFetch?.apiKey === apiKey) return inflightCatalogFetch.promise;
103
+
104
+ let entry: { apiKey: string; promise: Promise<readonly ModelListItem[]> } | null = null;
105
+ const promise = (async () => {
106
+ try {
107
+ const models = await Cursor.models.list({ apiKey });
108
+ catalogCache = { apiKey, models, expiresAt: Date.now() + CATALOG_CACHE_TTL_MS };
109
+ return models;
110
+ } finally {
111
+ // Clear only our own entry — a concurrent fetch under another key may
112
+ // have replaced it.
113
+ if (inflightCatalogFetch === entry) {
114
+ inflightCatalogFetch = null;
115
+ }
116
+ }
117
+ })();
118
+ entry = { apiKey, promise };
119
+ inflightCatalogFetch = entry;
120
+ return promise;
121
+ }
122
+
123
+ function findCatalogModel(
124
+ models: readonly ModelListItem[],
125
+ modelId: string,
126
+ ): ModelListItem | undefined {
127
+ return models.find((m) => m.id === modelId || m.aliases?.includes(modelId));
128
+ }
129
+
130
+ export interface ResolveServiceTierParamsOptions {
131
+ readonly apiKey: string;
132
+ /** Validated model id the execution runs on (may be "default" for Auto). */
133
+ readonly modelId: string;
134
+ readonly tier: EffectiveServiceTier;
135
+ /** For log correlation only. */
136
+ readonly executionId: string;
137
+ }
138
+
139
+ /**
140
+ * Translate the effective tier into the explicit variant parameters to send
141
+ * with every Agent.create/resume for this execution.
142
+ *
143
+ * Fail-closed posture: FAST with no pinnable fast dimension is an error,
144
+ * never a silent downgrade — create-time validation makes this unreachable
145
+ * unless the registry and the provider catalog have drifted, and that drift
146
+ * must be heard about, not absorbed.
147
+ *
148
+ * STANDARD degrades to empty params on catalog failures rather than failing
149
+ * the execution — but be clear about what that costs: an unpinned selection
150
+ * falls to the catalog default variant, which for several models IS the
151
+ * fast/thinking variant at multiples of base rates (the incident this module
152
+ * exists to prevent). Failing every standard execution whenever the catalog
153
+ * endpoint blips would be the worse trade; the WARN below plus billing's
154
+ * requested-vs-billed mismatch alarm (which catches exactly this window)
155
+ * are the compensating controls.
156
+ */
157
+ export async function resolveServiceTierParams(
158
+ options: ResolveServiceTierParamsOptions,
159
+ ): Promise<ModelParameterValue[]> {
160
+ const { apiKey, modelId, tier, executionId } = options;
161
+ const tierName = serviceTierLabel(tier);
162
+
163
+ if (AUTO_MODEL_IDS.has(modelId)) {
164
+ if (tier === ServiceTier.FAST) {
165
+ throw new Error(
166
+ `service_tier=fast requires a pinned model — Auto ("${modelId}") has no ` +
167
+ `tier dimension. Execution ${executionId} should have been refused at ` +
168
+ `create time; the model registry and provider catalog may have drifted.`,
169
+ );
170
+ }
171
+ console.log(
172
+ `ServiceTier: execution=${executionId} model=${modelId} tier=${tierName} — ` +
173
+ `Auto has no variant parameters; Cursor picks the model and variant ` +
174
+ `(documented v1 limitation).`,
175
+ );
176
+ return [];
177
+ }
178
+
179
+ let models: readonly ModelListItem[];
180
+ try {
181
+ models = await listCatalogModels(apiKey);
182
+ } catch (err) {
183
+ if (tier === ServiceTier.FAST) {
184
+ throw new Error(
185
+ `service_tier=fast for execution ${executionId} needs the Cursor model ` +
186
+ `catalog to resolve variant params for "${modelId}", and the catalog ` +
187
+ `fetch failed: ${err instanceof Error ? err.message : String(err)}`,
188
+ );
189
+ }
190
+ console.warn(
191
+ `ServiceTier UNPINNED: execution=${executionId} model=${modelId} tier=${tierName} — ` +
192
+ `catalog fetch failed (${err instanceof Error ? err.message : err}); ` +
193
+ `sending no variant params, so the catalog DEFAULT variant decides the ` +
194
+ `price for this execution (fast/thinking on several models — the ` +
195
+ `expensive direction). Billing's requested-vs-billed mismatch alarm ` +
196
+ `covers this window.`,
197
+ );
198
+ return [];
199
+ }
200
+
201
+ const model = findCatalogModel(models, modelId);
202
+ if (!model) {
203
+ if (tier === ServiceTier.FAST) {
204
+ throw new Error(
205
+ `service_tier=fast requested for "${modelId}" (execution ${executionId}) ` +
206
+ `but the Cursor catalog does not list that model — cannot pin a fast ` +
207
+ `variant. The model registry and provider catalog have drifted.`,
208
+ );
209
+ }
210
+ console.warn(
211
+ `ServiceTier UNPINNED: execution=${executionId} model=${modelId} tier=${tierName} — ` +
212
+ `model not in the Cursor catalog; sending no variant params, so the ` +
213
+ `catalog DEFAULT variant decides the price for this execution.`,
214
+ );
215
+ return [];
216
+ }
217
+
218
+ const params: ModelParameterValue[] = [];
219
+ for (const def of model.parameters ?? []) {
220
+ if (def.id === FAST_PARAM_ID) {
221
+ params.push({ id: FAST_PARAM_ID, value: tier === ServiceTier.FAST ? "true" : "false" });
222
+ } else if (def.id === THINKING_PARAM_ID) {
223
+ params.push({ id: THINKING_PARAM_ID, value: "false" });
224
+ }
225
+ // Any other parameter (e.g. effort) is price-neutral: left to the
226
+ // catalog default variant on purpose.
227
+ }
228
+
229
+ if (tier === ServiceTier.FAST && !params.some((p) => p.id === FAST_PARAM_ID)) {
230
+ throw new Error(
231
+ `service_tier=fast requested for "${modelId}" (execution ${executionId}) ` +
232
+ `but the Cursor catalog declares no "fast" parameter for it. The model ` +
233
+ `registry prices a fast variant the provider no longer offers — refusing ` +
234
+ `rather than silently billing an unknown variant.`,
235
+ );
236
+ }
237
+
238
+ params.sort((a, b) => a.id.localeCompare(b.id));
239
+ console.log(
240
+ `ServiceTier: execution=${executionId} model=${modelId} tier=${tierName} ` +
241
+ `params=${JSON.stringify(params)}`,
242
+ );
243
+ return params;
244
+ }
@@ -40,7 +40,12 @@ import { mkdirSync } from "node:fs";
40
40
  import { join } from "node:path";
41
41
 
42
42
  import { Agent } from "@cursor/sdk";
43
- import type { SDKAgent, CursorAgentPlatformOptions, AgentDefinition } from "@cursor/sdk";
43
+ import type {
44
+ SDKAgent,
45
+ CursorAgentPlatformOptions,
46
+ AgentDefinition,
47
+ ModelParameterValue,
48
+ } from "@cursor/sdk";
44
49
  import { withTimeout, TimeoutError } from "../../shared/with-timeout.js";
45
50
  import type { CursorMcpServerConfig } from "./mcp-resolver.js";
46
51
 
@@ -83,6 +88,15 @@ const LOCAL_SETTING_SOURCES = ["project"] as const;
83
88
  export interface CreateAgentOptions {
84
89
  apiKey: string;
85
90
  model: string;
91
+ /**
92
+ * Explicit variant parameters sent with the model selection on every
93
+ * create AND resume (stigmer/stigmer#357). A bare `{ id }` lets the
94
+ * Cursor catalog's default variant pick the price, so the caller always
95
+ * supplies the pinned params from resolveServiceTierParams — possibly
96
+ * empty (Auto, or a model with no price-bearing parameters), but never
97
+ * absent by accident.
98
+ */
99
+ modelParams?: ModelParameterValue[];
86
100
  workspaceDirs: string[];
87
101
  sessionId: string;
88
102
  /** Durable workspace volume root; the SDK state store lives under it. */
@@ -112,6 +126,8 @@ export interface ResumeAgentOptions {
112
126
  /** Durable workspace volume root; the SDK state store lives under it. */
113
127
  workspaceRootDir: string;
114
128
  model?: string;
129
+ /** Explicit variant parameters — see {@link CreateAgentOptions.modelParams}. */
130
+ modelParams?: ModelParameterValue[];
115
131
  mcpServers?: Record<string, CursorMcpServerConfig>;
116
132
  /** Custom sub-agents — see {@link CreateAgentOptions.agents}. */
117
133
  agents?: Record<string, AgentDefinition>;
@@ -129,6 +145,8 @@ export interface CloudRepo {
129
145
  export interface CreateCloudAgentOptions {
130
146
  apiKey: string;
131
147
  model?: string;
148
+ /** Explicit variant parameters — see {@link CreateAgentOptions.modelParams}. */
149
+ modelParams?: ModelParameterValue[];
132
150
  repos: CloudRepo[];
133
151
  sessionId: string;
134
152
  mcpServers?: Record<string, CursorMcpServerConfig>;
@@ -140,6 +158,8 @@ export interface ResumeCloudAgentOptions {
140
158
  apiKey: string;
141
159
  agentId: string;
142
160
  model?: string;
161
+ /** Explicit variant parameters — see {@link CreateAgentOptions.modelParams}. */
162
+ modelParams?: ModelParameterValue[];
143
163
  mcpServers?: Record<string, CursorMcpServerConfig>;
144
164
  /** Custom sub-agents — see {@link CreateAgentOptions.agents}. */
145
165
  agents?: Record<string, AgentDefinition>;
@@ -245,7 +265,9 @@ export async function createAgent(options: CreateAgentOptions): Promise<SDKAgent
245
265
 
246
266
  return Agent.create({
247
267
  apiKey: options.apiKey,
248
- model: { id: options.model },
268
+ // Always a full selection — id AND params. A bare { id } would let the
269
+ // catalog's default variant (account-influenced) pick the price (#357).
270
+ model: { id: options.model, params: options.modelParams },
249
271
  local: { cwd, settingSources: [...LOCAL_SETTING_SOURCES] },
250
272
  mcpServers: options.mcpServers as Record<string, any>,
251
273
  agents: options.agents,
@@ -273,7 +295,11 @@ export async function resumeAgent(options: ResumeAgentOptions): Promise<SDKAgent
273
295
 
274
296
  return Agent.resume(options.agentId, {
275
297
  apiKey: options.apiKey,
276
- model: options.model ? { id: options.model } : undefined,
298
+ // Variant params must be re-supplied on resume exactly like mcpServers:
299
+ // explicit params hold across resume (verified against the billing
300
+ // ledger, #357), but an id-only resume would fall back to the catalog
301
+ // default variant for the new turns.
302
+ model: options.model ? { id: options.model, params: options.modelParams } : undefined,
277
303
  // Neither cwd nor settingSources survive Agent.resume(); both must be
278
304
  // re-supplied every turn. Omitting cwd makes the SDK fall back to
279
305
  // process.cwd(), which re-roots the agent in the runner's own working
@@ -307,7 +333,7 @@ export async function createCloudAgent(options: CreateCloudAgentOptions): Promis
307
333
 
308
334
  return Agent.create({
309
335
  apiKey: options.apiKey,
310
- model: options.model ? { id: options.model } : undefined,
336
+ model: options.model ? { id: options.model, params: options.modelParams } : undefined,
311
337
  cloud: { repos: options.repos },
312
338
  mcpServers: options.mcpServers as Record<string, any>,
313
339
  agents: options.agents,
@@ -328,7 +354,7 @@ export async function resumeCloudAgent(options: ResumeCloudAgentOptions): Promis
328
354
 
329
355
  return Agent.resume(options.agentId, {
330
356
  apiKey: options.apiKey,
331
- model: options.model ? { id: options.model } : undefined,
357
+ model: options.model ? { id: options.model, params: options.modelParams } : undefined,
332
358
  mcpServers: options.mcpServers as Record<string, any>,
333
359
  agents: options.agents,
334
360
  });
@@ -372,6 +398,7 @@ export async function resolveAgent(
372
398
  apiKey: options.apiKey,
373
399
  agentId: harnessStateId,
374
400
  model: options.model,
401
+ modelParams: options.modelParams,
375
402
  mcpServers: options.mcpServers,
376
403
  agents: options.agents,
377
404
  })
@@ -382,6 +409,7 @@ export async function resolveAgent(
382
409
  workspaceDirs: (options as CreateAgentOptions).workspaceDirs,
383
410
  workspaceRootDir: (options as CreateAgentOptions).workspaceRootDir,
384
411
  model: options.model,
412
+ modelParams: options.modelParams,
385
413
  mcpServers: options.mcpServers,
386
414
  agents: options.agents,
387
415
  });
@@ -13,7 +13,10 @@
13
13
  * wire via ProxyUsageReporter once traffic routing (Task 5B) is complete.
14
14
  */
15
15
 
16
- import { getCursorModelPricing, computeTurnCost } from "./model-pricing.js";
16
+ import type { ModelParameterValue } from "@cursor/sdk";
17
+ import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
18
+
19
+ import { getCursorModelPricingForVariant, computeTurnCost } from "./model-pricing.js";
17
20
 
18
21
  export interface TurnUsage {
19
22
  readonly inputTokens?: number;
@@ -32,6 +35,10 @@ export interface UsageSnapshot {
32
35
  readonly estimatedCostUsd: number;
33
36
  readonly model: string;
34
37
  readonly observedAt: string;
38
+ /** Tier the runner requested — always explicit post-translation (#357). */
39
+ readonly requestedServiceTier: ServiceTier;
40
+ /** JSON-encoded ModelSelection.params the runner sent; "" when none. */
41
+ readonly requestedModelParams: string;
35
42
  }
36
43
 
37
44
  const EMPTY_SNAPSHOT: UsageSnapshot = {
@@ -44,6 +51,8 @@ const EMPTY_SNAPSHOT: UsageSnapshot = {
44
51
  estimatedCostUsd: 0,
45
52
  model: "",
46
53
  observedAt: "",
54
+ requestedServiceTier: ServiceTier.UNSPECIFIED,
55
+ requestedModelParams: "",
47
56
  };
48
57
 
49
58
  export interface TurnRecord {
@@ -64,7 +73,22 @@ export class UsageAccumulator {
64
73
  private observedAt = "";
65
74
  private readonly turnRecords: TurnRecord[] = [];
66
75
 
67
- constructor(private readonly model: string) {}
76
+ /** JSON-encoded params the runner sent with the model selection (#357). */
77
+ private readonly requestedModelParams: string;
78
+
79
+ constructor(
80
+ private readonly model: string,
81
+ /**
82
+ * The explicit tier the runner requested from the provider. Recorded
83
+ * verbatim into status as the audit trail that the account default was
84
+ * never left in control (#357).
85
+ */
86
+ private readonly requestedServiceTier: ServiceTier = ServiceTier.UNSPECIFIED,
87
+ requestedModelParams: readonly ModelParameterValue[] = [],
88
+ ) {
89
+ this.requestedModelParams =
90
+ requestedModelParams.length > 0 ? JSON.stringify(requestedModelParams) : "";
91
+ }
68
92
 
69
93
  addTurn(usage: TurnUsage): void {
70
94
  const input = usage.inputTokens ?? 0;
@@ -86,7 +110,13 @@ export class UsageAccumulator {
86
110
  cacheWriteTokens: cacheWrite,
87
111
  });
88
112
 
89
- const pricing = getCursorModelPricing(this.model);
113
+ // Estimate at the rates of the variant we explicitly requested — a
114
+ // FAST run priced at base rates would understate the display estimate
115
+ // ~4x relative to the authoritative bill (#357).
116
+ const pricing = getCursorModelPricingForVariant(
117
+ this.model,
118
+ this.requestedServiceTier === ServiceTier.FAST ? "fast" : null,
119
+ );
90
120
  this.estimatedCostUsd += computeTurnCost(
91
121
  pricing, input, output, cacheWrite, cacheRead,
92
122
  );
@@ -122,6 +152,8 @@ export class UsageAccumulator {
122
152
  estimatedCostUsd: this.estimatedCostUsd,
123
153
  model: this.model,
124
154
  observedAt: this.observedAt,
155
+ requestedServiceTier: this.requestedServiceTier,
156
+ requestedModelParams: this.requestedModelParams,
125
157
  };
126
158
  }
127
159
  }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The deep-agent MCP gate, arm by arm. A missing arm here means a tool
3
+ * source silently dropped for exactly the agents whose only source it
4
+ * is — the failure DD-006 D7 documented for channel messaging and the
5
+ * reason the predicate lives in its own testable module instead of
6
+ * inline in setup.ts (whose import graph forbids a setup.test.ts).
7
+ */
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { shouldConnectMcp } from "../mcp-gate.js";
12
+
13
+ const nothing = {
14
+ mcpServerUsageCount: 0,
15
+ datastoreUsageCount: 0,
16
+ channelMessagingCount: 0,
17
+ conversationChannelId: undefined,
18
+ };
19
+
20
+ describe("shouldConnectMcp", () => {
21
+ it("skips the MCP block when no tool source exists", () => {
22
+ expect(shouldConnectMcp(nothing)).toBe(false);
23
+ });
24
+
25
+ it("enters on declared MCP server usages alone", () => {
26
+ expect(shouldConnectMcp({ ...nothing, mcpServerUsageCount: 1 })).toBe(true);
27
+ });
28
+
29
+ it("enters on datastore usages alone (the records attachment)", () => {
30
+ expect(shouldConnectMcp({ ...nothing, datastoreUsageCount: 1 })).toBe(true);
31
+ });
32
+
33
+ it("enters on a serving proactive channel alone (the channels attachment)", () => {
34
+ expect(shouldConnectMcp({ ...nothing, channelMessagingCount: 1 })).toBe(true);
35
+ });
36
+
37
+ it("enters on a channel conversation alone (the conversation attachment)", () => {
38
+ // The reply-only pilot shape: no declared servers, no datastores,
39
+ // no proactive channel — the escalation tool is the ONLY source.
40
+ expect(shouldConnectMcp({ ...nothing, conversationChannelId: "agch_1" })).toBe(true);
41
+ });
42
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
3
- import { buildEnhancedSystemPrompt } from "../prompt-builder.js";
3
+ import { buildEnhancedSystemPrompt, composeUserMessage } from "../prompt-builder.js";
4
4
  import { PLAN_MODE_DIRECTIVE } from "../../../shared/plan-mode-prompt.js";
5
5
  import { SourceType } from "../../../shared/workspace/types.js";
6
6
  import type { ProvisionResult } from "../../../shared/workspace/types.js";
@@ -408,3 +408,41 @@ describe("buildEnhancedSystemPrompt", () => {
408
408
  });
409
409
  });
410
410
  });
411
+
412
+ describe("composeUserMessage (conversation catchup, cloud DD-006 / A27)", () => {
413
+ const MESSAGE = "where is my order?";
414
+ const DIGEST =
415
+ "Customer: I want a refund\nTeammate: I've refunded you in full.";
416
+
417
+ it("prepends the framed catchup to the turn's user message — history durability rides the checkpointer, not the rebuilt system prompt", () => {
418
+ const composed = composeUserMessage(MESSAGE, DIGEST);
419
+
420
+ expect(composed.endsWith(MESSAGE)).toBe(true);
421
+ expect(composed).toContain(DIGEST);
422
+ expect(composed).toContain("you have not seen");
423
+ expect(composed.indexOf(DIGEST)).toBeLessThan(composed.indexOf(MESSAGE));
424
+ });
425
+
426
+ it("separates the catchup from the customer's message with a horizontal rule", () => {
427
+ expect(composeUserMessage(MESSAGE, DIGEST)).toContain("\n\n---\n\n");
428
+ });
429
+
430
+ it("leaves the message untouched when there is no catchup — most turns carry none", () => {
431
+ expect(composeUserMessage(MESSAGE, undefined)).toBe(MESSAGE);
432
+ });
433
+
434
+ it("never renders in the system prompt — the rebuilt-per-invocation lane would forget the digest one turn later", () => {
435
+ const prompt = buildEnhancedSystemPrompt({
436
+ instructions: "Test",
437
+ provisionResults: [],
438
+ containerRoot: "",
439
+ skillsPromptSection: "",
440
+ workspaceFileRefs: [],
441
+ workspaceRoot: "/workspace",
442
+ injectedFiles: [],
443
+ });
444
+
445
+ expect(prompt).not.toContain("Conversation catchup");
446
+ expect(prompt).not.toContain("you have not seen");
447
+ });
448
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The deep-agent harness's MCP gate: whether this execution enters MCP
3
+ * resolution at all (resolve, backfill, synthesized-attachment
4
+ * injection, connect). Unlike the Cursor harness — which resolves MCP
5
+ * unconditionally — deep-agent skips the whole block when no tool
6
+ * source exists, so EVERY tool source must appear here or its tools are
7
+ * silently dropped for exactly the agents whose only source it is
8
+ * (proactive-messaging DD-006 D7 learned this for channel messaging).
9
+ *
10
+ * Extracted from setup.ts as a pure function because setup.ts is
11
+ * untestable at file load (its import graph is why no setup.test.ts
12
+ * exists); the gate is the one piece whose regression is silent, so it
13
+ * gets its own module and an arm-by-arm test.
14
+ */
15
+
16
+ /** One flag per tool source. Adding a source? It gates here or it is dropped. */
17
+ export interface McpToolSources {
18
+ /** Declared MCP server usages (agent spec + session spec). */
19
+ readonly mcpServerUsageCount: number;
20
+ /** Declared datastore usages (the records attachment, T05). */
21
+ readonly datastoreUsageCount: number;
22
+ /** Serving proactive-messaging channels (the channels attachment, DD-006). */
23
+ readonly channelMessagingCount: number;
24
+ /** The serving channel id when this session IS a live channel
25
+ * conversation (the conversation attachment, DD-008). */
26
+ readonly conversationChannelId: string | undefined;
27
+ }
28
+
29
+ /** True when any tool source demands MCP resolution and connect. */
30
+ export function shouldConnectMcp(sources: McpToolSources): boolean {
31
+ return (
32
+ sources.mcpServerUsageCount > 0 ||
33
+ sources.datastoreUsageCount > 0 ||
34
+ sources.channelMessagingCount > 0 ||
35
+ sources.conversationChannelId !== undefined
36
+ );
37
+ }
@@ -11,6 +11,7 @@ import { InteractionMode } from "@stigmer/protos/ai/stigmer/agentic/agentexecuti
11
11
  import type { ProvisionResult, GitMetadata } from "../../shared/workspace/types.js";
12
12
  import { SourceType } from "../../shared/workspace/types.js";
13
13
  import { formatContextBridgeText } from "../../shared/context-bridge.js";
14
+ import { formatConversationCatchupText } from "../../shared/conversation-catchup.js";
14
15
  import {
15
16
  formatSenderIdentityText,
16
17
  type SenderIdentity,
@@ -241,9 +242,28 @@ export function buildEnhancedSystemPrompt(input: PromptBuilderInput): string {
241
242
  return prompt;
242
243
  }
243
244
 
245
+ /**
246
+ * Compose the turn's USER MESSAGE for the graph invocation: the framed
247
+ * conversation catchup (cloud DD-006), when present, prepended to the
248
+ * customer's message. In the user message and never the system prompt (A27):
249
+ * the system prompt is rebuilt per invocation and would forget the digest one
250
+ * turn later, while a message enters the checkpointer with the turn and
251
+ * persists in history — the same durability the cursor harness gets from its
252
+ * prompt prefix. The caller's `spec.message` is never mutated; the prepend
253
+ * exists only in the graph input.
254
+ */
255
+ export function composeUserMessage(
256
+ message: string,
257
+ conversationCatchup: string | undefined,
258
+ ): string {
259
+ return conversationCatchup
260
+ ? `${formatConversationCatchupText(conversationCatchup)}\n\n---\n\n${message}`
261
+ : message;
262
+ }
263
+
244
264
  function buildWorkspacePromptSection(
245
- provisionResults: ProvisionResult[],
246
- containerRoot: string,
265
+ provisionResults: ProvisionResult[],
266
+ containerRoot: string,
247
267
  ): string {
248
268
  if (provisionResults.length === 0) return "";
249
269