@stigmer/runner 3.6.0 → 3.7.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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/call-agent.js +85 -10
- package/dist/activities/call-agent.js.map +1 -1
- package/dist/activities/execute-cursor/index.js +39 -5
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/model-pricing.d.ts +9 -0
- package/dist/activities/execute-cursor/model-pricing.js +19 -0
- package/dist/activities/execute-cursor/model-pricing.js.map +1 -1
- package/dist/activities/execute-cursor/service-tier.d.ts +68 -0
- package/dist/activities/execute-cursor/service-tier.js +187 -0
- package/dist/activities/execute-cursor/service-tier.js.map +1 -0
- package/dist/activities/execute-cursor/session-lifecycle.d.ts +16 -1
- package/dist/activities/execute-cursor/session-lifecycle.js +12 -4
- package/dist/activities/execute-cursor/session-lifecycle.js.map +1 -1
- package/dist/activities/execute-cursor/usage-accumulator.d.ts +21 -1
- package/dist/activities/execute-cursor/usage-accumulator.js +23 -3
- package/dist/activities/execute-cursor/usage-accumulator.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +6 -1
- package/dist/client/stigmer-client.js +5 -2
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/main.js +18 -0
- package/dist/main.js.map +1 -1
- package/dist/runner.js +48 -0
- package/dist/runner.js.map +1 -1
- package/dist/sandbox-token-renewal.d.ts +65 -0
- package/dist/sandbox-token-renewal.js +169 -0
- package/dist/sandbox-token-renewal.js.map +1 -0
- package/dist/shared/artifact-storage.d.ts +17 -3
- package/dist/shared/artifact-storage.js +22 -4
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/workflow-engine/loader.js +99 -2
- package/dist/workflow-engine/loader.js.map +1 -1
- package/dist/workflow-engine/tasks/call-agent.d.ts +0 -2
- package/dist/workflow-engine/tasks/call-agent.js +0 -2
- package/dist/workflow-engine/tasks/call-agent.js.map +1 -1
- package/dist/workflow-engine/types.d.ts +39 -7
- package/dist/workflow-engine/types.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.d.ts +3 -2
- package/dist/workflows/call-agent-orchestrator.js +8 -2
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/sandbox-token-renewal.test.ts +174 -0
- package/src/activities/__tests__/call-agent-contracts.test.ts +4 -4
- package/src/activities/__tests__/call-agent.test.ts +219 -4
- package/src/activities/call-agent.ts +94 -10
- package/src/activities/execute-cursor/__tests__/model-pricing.test.ts +20 -0
- package/src/activities/execute-cursor/__tests__/service-tier.test.ts +170 -0
- package/src/activities/execute-cursor/__tests__/usage-accumulator.test.ts +87 -1
- package/src/activities/execute-cursor/index.ts +49 -7
- package/src/activities/execute-cursor/model-pricing.ts +23 -0
- package/src/activities/execute-cursor/service-tier.ts +244 -0
- package/src/activities/execute-cursor/session-lifecycle.ts +33 -5
- package/src/activities/execute-cursor/usage-accumulator.ts +35 -3
- package/src/client/stigmer-client.ts +11 -4
- package/src/main.ts +20 -0
- package/src/runner.ts +62 -0
- package/src/sandbox-token-renewal.ts +212 -0
- package/src/shared/artifact-storage.ts +32 -7
- package/src/workflow-engine/__tests__/golden-execution.test.ts +8 -8
- package/src/workflow-engine/__tests__/loader.test.ts +192 -7
- package/src/workflow-engine/__tests__/tasks/call-agent.test.ts +9 -9
- package/src/workflow-engine/loader.ts +113 -2
- package/src/workflow-engine/tasks/call-agent.ts +0 -2
- package/src/workflow-engine/types.ts +40 -7
- package/src/workflows/call-agent-orchestrator.ts +8 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
3
|
+
import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
3
4
|
|
|
4
5
|
let mockGetAgentByReference: ReturnType<typeof vi.fn>;
|
|
5
6
|
let mockCreateSession: ReturnType<typeof vi.fn>;
|
|
@@ -93,11 +94,11 @@ describe("callAgentAction", () => {
|
|
|
93
94
|
expect(ref.slug).toBe("my-agent");
|
|
94
95
|
});
|
|
95
96
|
|
|
96
|
-
it("
|
|
97
|
+
it("org/slug agent reference overrides the workflow org for lookup only", async () => {
|
|
97
98
|
await expect(
|
|
98
99
|
callAgentAction(
|
|
99
|
-
{ agent: "my-agent", message: "Hello"
|
|
100
|
-
{ __stigmer_org_id: "
|
|
100
|
+
{ agent: "explicit-org/my-agent", message: "Hello" },
|
|
101
|
+
{ __stigmer_org_id: "workflow-org" },
|
|
101
102
|
"wfl_parent789",
|
|
102
103
|
),
|
|
103
104
|
).rejects.toThrow("CompleteAsyncError");
|
|
@@ -106,6 +107,11 @@ describe("callAgentAction", () => {
|
|
|
106
107
|
expect(ref.org).toBe("explicit-org");
|
|
107
108
|
expect(ref.slug).toBe("my-agent");
|
|
108
109
|
expect(ref.kind).toBe(ApiResourceKind.agent);
|
|
110
|
+
|
|
111
|
+
// The execution itself is still created in the workflow's org — the
|
|
112
|
+
// cross-org reference changes agent lookup, never the billing org.
|
|
113
|
+
const execution = mockCreateAgentExecution.mock.calls[0][0];
|
|
114
|
+
expect(execution.metadata?.org).toBe("workflow-org");
|
|
109
115
|
});
|
|
110
116
|
});
|
|
111
117
|
|
|
@@ -120,7 +126,7 @@ describe("callAgentAction", () => {
|
|
|
120
126
|
).rejects.toThrow("call:agent requires an organization context");
|
|
121
127
|
});
|
|
122
128
|
|
|
123
|
-
it("
|
|
129
|
+
it("uses __stigmer_org_id from runtime env", async () => {
|
|
124
130
|
await expect(
|
|
125
131
|
callAgentAction(
|
|
126
132
|
{ agent: "my-agent", message: "Hello" },
|
|
@@ -260,4 +266,213 @@ describe("callAgentAction", () => {
|
|
|
260
266
|
expect(execution.spec.activityTaskQueue).toBe("");
|
|
261
267
|
});
|
|
262
268
|
});
|
|
269
|
+
|
|
270
|
+
describe("run_config → ExecutionConfig mapping (#358)", () => {
|
|
271
|
+
it("maps model_name, max_cost_usd, and max_tool_rounds onto ExecutionConfig", async () => {
|
|
272
|
+
await expect(
|
|
273
|
+
callAgentAction(
|
|
274
|
+
{
|
|
275
|
+
agent: "my-agent",
|
|
276
|
+
message: "Hello",
|
|
277
|
+
run_config: {
|
|
278
|
+
model_name: "claude-sonnet-4-6",
|
|
279
|
+
max_cost_usd: 0.75,
|
|
280
|
+
max_tool_rounds: 15,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{ __stigmer_org_id: "test-org" },
|
|
284
|
+
"wfl_parent",
|
|
285
|
+
),
|
|
286
|
+
).rejects.toThrow("CompleteAsyncError");
|
|
287
|
+
|
|
288
|
+
const execution = mockCreateAgentExecution.mock.calls[0][0];
|
|
289
|
+
expect(execution.spec.executionConfig).toBeDefined();
|
|
290
|
+
expect(execution.spec.executionConfig.modelName).toBe("claude-sonnet-4-6");
|
|
291
|
+
expect(execution.spec.executionConfig.maxCostUsd).toBe(0.75);
|
|
292
|
+
expect(execution.spec.executionConfig.maxToolRounds).toBe(15);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("maps a canonical service_tier onto ExecutionConfig.serviceTier (#357)", async () => {
|
|
296
|
+
await expect(
|
|
297
|
+
callAgentAction(
|
|
298
|
+
{
|
|
299
|
+
agent: "my-agent",
|
|
300
|
+
message: "Hello",
|
|
301
|
+
run_config: { service_tier: "SERVICE_TIER_FAST" },
|
|
302
|
+
},
|
|
303
|
+
{ __stigmer_org_id: "test-org" },
|
|
304
|
+
"wfl_parent",
|
|
305
|
+
),
|
|
306
|
+
).rejects.toThrow("CompleteAsyncError");
|
|
307
|
+
|
|
308
|
+
const execution = mockCreateAgentExecution.mock.calls[0][0];
|
|
309
|
+
expect(execution.spec.executionConfig).toBeDefined();
|
|
310
|
+
expect(execution.spec.executionConfig.serviceTier).toBe(ServiceTier.FAST);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("fails loudly on a service_tier value with no proto mapping", async () => {
|
|
314
|
+
// The loader canonicalizes tiers; an unmapped value reaching the
|
|
315
|
+
// activity means loader/activity drift. A pricing directive must
|
|
316
|
+
// never be silently dropped.
|
|
317
|
+
await expect(
|
|
318
|
+
callAgentAction(
|
|
319
|
+
{
|
|
320
|
+
agent: "my-agent",
|
|
321
|
+
message: "Hello",
|
|
322
|
+
run_config: { service_tier: "SERVICE_TIER_TURBO" },
|
|
323
|
+
},
|
|
324
|
+
{ __stigmer_org_id: "test-org" },
|
|
325
|
+
"wfl_parent",
|
|
326
|
+
),
|
|
327
|
+
).rejects.toThrow(
|
|
328
|
+
"call:agent run_config.service_tier 'SERVICE_TIER_TURBO' has no proto mapping",
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("omits ExecutionConfig entirely when run_config and output are absent", async () => {
|
|
333
|
+
await expect(
|
|
334
|
+
callAgentAction(
|
|
335
|
+
{ agent: "my-agent", message: "Hello" },
|
|
336
|
+
{ __stigmer_org_id: "test-org" },
|
|
337
|
+
"wfl_parent",
|
|
338
|
+
),
|
|
339
|
+
).rejects.toThrow("CompleteAsyncError");
|
|
340
|
+
|
|
341
|
+
const execution = mockCreateAgentExecution.mock.calls[0][0];
|
|
342
|
+
expect(execution.spec.executionConfig).toBeUndefined();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("treats zero bounds as no override", async () => {
|
|
346
|
+
await expect(
|
|
347
|
+
callAgentAction(
|
|
348
|
+
{
|
|
349
|
+
agent: "my-agent",
|
|
350
|
+
message: "Hello",
|
|
351
|
+
run_config: { model_name: "claude-sonnet-4-6", max_cost_usd: 0, max_tool_rounds: 0 },
|
|
352
|
+
},
|
|
353
|
+
{ __stigmer_org_id: "test-org" },
|
|
354
|
+
"wfl_parent",
|
|
355
|
+
),
|
|
356
|
+
).rejects.toThrow("CompleteAsyncError");
|
|
357
|
+
|
|
358
|
+
const execution = mockCreateAgentExecution.mock.calls[0][0];
|
|
359
|
+
expect(execution.spec.executionConfig.modelName).toBe("claude-sonnet-4-6");
|
|
360
|
+
// Proto zero values: unset numerics read back as 0, but nothing was
|
|
361
|
+
// deliberately written — the guards must not have set them from a
|
|
362
|
+
// zero "no override" input.
|
|
363
|
+
expect(execution.spec.executionConfig.maxCostUsd).toBe(0);
|
|
364
|
+
expect(execution.spec.executionConfig.maxToolRounds).toBe(0);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("workspace entries and workflow provenance (#358 Phase 2)", () => {
|
|
369
|
+
it("maps git workspace entries onto the created session's spec", async () => {
|
|
370
|
+
await expect(
|
|
371
|
+
callAgentAction(
|
|
372
|
+
{
|
|
373
|
+
agent: "my-agent",
|
|
374
|
+
message: "Hello",
|
|
375
|
+
workspace_entries: [
|
|
376
|
+
{ name: "app", source: { git_repo: { url: "https://github.com/acme/app", branch: "main" } } },
|
|
377
|
+
{ source: { git_repo: { url: "https://github.com/acme/lib" } } },
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
{ __stigmer_org_id: "test-org" },
|
|
381
|
+
"wfl_parent",
|
|
382
|
+
),
|
|
383
|
+
).rejects.toThrow("CompleteAsyncError");
|
|
384
|
+
|
|
385
|
+
const session = mockCreateSession.mock.calls[0][0];
|
|
386
|
+
const entries = session.spec.workspaceEntries;
|
|
387
|
+
expect(entries).toHaveLength(2);
|
|
388
|
+
expect(entries[0].name).toBe("app");
|
|
389
|
+
expect(entries[0].source.source.case).toBe("gitRepo");
|
|
390
|
+
expect(entries[0].source.source.value.url).toBe("https://github.com/acme/app");
|
|
391
|
+
expect(entries[0].source.source.value.branch).toBe("main");
|
|
392
|
+
expect(entries[1].source.source.value.url).toBe("https://github.com/acme/lib");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("creates sessions without workspace entries when none are configured", async () => {
|
|
396
|
+
await expect(
|
|
397
|
+
callAgentAction(
|
|
398
|
+
{ agent: "my-agent", message: "Hello" },
|
|
399
|
+
{ __stigmer_org_id: "test-org" },
|
|
400
|
+
"wfl_parent",
|
|
401
|
+
),
|
|
402
|
+
).rejects.toThrow("CompleteAsyncError");
|
|
403
|
+
|
|
404
|
+
const session = mockCreateSession.mock.calls[0][0];
|
|
405
|
+
expect(session.spec.workspaceEntries).toHaveLength(0);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("stamps workflow provenance labels the server keys environment resolution on", async () => {
|
|
409
|
+
await expect(
|
|
410
|
+
callAgentAction(
|
|
411
|
+
{
|
|
412
|
+
agent: "my-agent",
|
|
413
|
+
message: "Hello",
|
|
414
|
+
__wfExecId: "wex_prov1",
|
|
415
|
+
__taskName: "triage",
|
|
416
|
+
} as any,
|
|
417
|
+
{ __stigmer_org_id: "test-org" },
|
|
418
|
+
"wfl_parent",
|
|
419
|
+
),
|
|
420
|
+
).rejects.toThrow("CompleteAsyncError");
|
|
421
|
+
|
|
422
|
+
const execution = mockCreateAgentExecution.mock.calls[0][0];
|
|
423
|
+
expect(execution.metadata.labels["stigmer.ai/workflow-execution-id"]).toBe("wex_prov1");
|
|
424
|
+
expect(execution.metadata.labels["stigmer.ai/workflow-task"]).toBe("triage");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("stamps no provenance labels without workflow context", async () => {
|
|
428
|
+
await expect(
|
|
429
|
+
callAgentAction(
|
|
430
|
+
{ agent: "my-agent", message: "Hello" },
|
|
431
|
+
{ __stigmer_org_id: "test-org" },
|
|
432
|
+
"wfl_parent",
|
|
433
|
+
),
|
|
434
|
+
).rejects.toThrow("CompleteAsyncError");
|
|
435
|
+
|
|
436
|
+
const execution = mockCreateAgentExecution.mock.calls[0][0];
|
|
437
|
+
expect(Object.keys(execution.metadata.labels ?? {})).toHaveLength(0);
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("env secret marking", () => {
|
|
442
|
+
it("preserves the agent-declared secret flag when a task env override supplies the value", async () => {
|
|
443
|
+
// The agent declares API_TOKEN as secret. A task-level env override
|
|
444
|
+
// provides the value — the secret marking must survive (#358: the
|
|
445
|
+
// override used to hardcode isSecret:false, a redaction downgrade).
|
|
446
|
+
mockGetAgentByReference.mockResolvedValue({
|
|
447
|
+
metadata: { id: "agt_test123" },
|
|
448
|
+
status: { defaultInstanceId: "ain_default456" },
|
|
449
|
+
spec: {
|
|
450
|
+
env: {
|
|
451
|
+
API_TOKEN: { isSecret: true },
|
|
452
|
+
REGION: { isSecret: false },
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
await expect(
|
|
458
|
+
callAgentAction(
|
|
459
|
+
{
|
|
460
|
+
agent: "my-agent",
|
|
461
|
+
message: "Hello",
|
|
462
|
+
env: { API_TOKEN: "resolved-secret-value", REGION: "us-east-1", EXTRA: "plain" },
|
|
463
|
+
},
|
|
464
|
+
{ __stigmer_org_id: "test-org" },
|
|
465
|
+
"wfl_parent",
|
|
466
|
+
),
|
|
467
|
+
).rejects.toThrow("CompleteAsyncError");
|
|
468
|
+
|
|
469
|
+
const execution = mockCreateAgentExecution.mock.calls[0][0];
|
|
470
|
+
const runtimeEnv = execution.spec.runtimeEnv;
|
|
471
|
+
expect(runtimeEnv.API_TOKEN.value).toBe("resolved-secret-value");
|
|
472
|
+
expect(runtimeEnv.API_TOKEN.isSecret).toBe(true);
|
|
473
|
+
expect(runtimeEnv.REGION.isSecret).toBe(false);
|
|
474
|
+
// Keys the agent never declared stay non-secret.
|
|
475
|
+
expect(runtimeEnv.EXTRA.isSecret).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
263
478
|
});
|
|
@@ -31,6 +31,13 @@ import { AgentExecutionSpecSchema, ExecutionConfigSchema } from "@stigmer/protos
|
|
|
31
31
|
import { SessionSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/api_pb";
|
|
32
32
|
import { SessionSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/session/v1/spec_pb";
|
|
33
33
|
import { Harness, ExecutionTarget } from "@stigmer/protos/ai/stigmer/agentic/session/v1/enum_pb";
|
|
34
|
+
import { ServiceTier } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
35
|
+
import {
|
|
36
|
+
WorkspaceEntrySchema,
|
|
37
|
+
WorkspaceSourceSchema,
|
|
38
|
+
GitRepoSourceSchema,
|
|
39
|
+
type WorkspaceEntry,
|
|
40
|
+
} from "@stigmer/protos/ai/stigmer/agentic/session/v1/workspace_pb";
|
|
34
41
|
import { AgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
35
42
|
import { ExecutionValueSchema } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
|
|
36
43
|
import type { ExecutionValue } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
|
|
@@ -62,14 +69,15 @@ export async function callAgentAction(
|
|
|
62
69
|
);
|
|
63
70
|
}
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
72
|
+
// The execution is always created in the workflow's org — the workflow
|
|
73
|
+
// owner pays for the run. A cross-org agent reference ("org/slug") only
|
|
74
|
+
// changes where the agent blueprint is looked up, never the billing org.
|
|
75
|
+
const orgId = (runtimeEnv["__stigmer_org_id"] as string | undefined) ?? "";
|
|
68
76
|
|
|
69
77
|
if (!orgId) {
|
|
70
78
|
throw new Error(
|
|
71
79
|
"call:agent requires an organization context. " +
|
|
72
|
-
"
|
|
80
|
+
"Ensure '__stigmer_org_id' is in the workflow environment.",
|
|
73
81
|
);
|
|
74
82
|
}
|
|
75
83
|
|
|
@@ -142,6 +150,7 @@ export async function callAgentAction(
|
|
|
142
150
|
harness,
|
|
143
151
|
executionTarget,
|
|
144
152
|
subject: "Auto-created session",
|
|
153
|
+
workspaceEntries: buildWorkspaceEntries(resolved.workspace_entries),
|
|
145
154
|
}),
|
|
146
155
|
}),
|
|
147
156
|
);
|
|
@@ -170,10 +179,17 @@ export async function callAgentAction(
|
|
|
170
179
|
);
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
// Task-config-level env takes precedence over auto-forwarded values
|
|
182
|
+
// Task-config-level env takes precedence over auto-forwarded values.
|
|
183
|
+
// The agent's declared secret marking survives the override: a key the
|
|
184
|
+
// agent declares secret stays secret no matter which channel supplied
|
|
185
|
+
// the value, so an explicit task-level `env:` entry cannot downgrade
|
|
186
|
+
// redaction (issue #358 — the override used to hardcode isSecret:false).
|
|
174
187
|
if (resolved.env) {
|
|
175
188
|
for (const [key, value] of Object.entries(resolved.env)) {
|
|
176
|
-
executionRuntimeEnv[key] = {
|
|
189
|
+
executionRuntimeEnv[key] = {
|
|
190
|
+
value: String(value),
|
|
191
|
+
isSecret: agentEnvDecls[key]?.isSecret ?? false,
|
|
192
|
+
};
|
|
177
193
|
}
|
|
178
194
|
}
|
|
179
195
|
|
|
@@ -196,13 +212,41 @@ export async function callAgentAction(
|
|
|
196
212
|
const parentQueue = runtimeEnv["__stigmer_activity_task_queue"] as string | undefined;
|
|
197
213
|
const activityTaskQueue = parentQueue?.startsWith("wfexec:") ? parentQueue : "";
|
|
198
214
|
|
|
199
|
-
|
|
215
|
+
// Honest RunConfig → ExecutionConfig mapping (issue #358): every field
|
|
216
|
+
// the author may set is forwarded to a field the runner enforces.
|
|
217
|
+
// model_name replaces the agent's default outright; max_cost_usd feeds
|
|
218
|
+
// the harness-generic cost guards (cost-cap middleware / cursor
|
|
219
|
+
// cost-guard); max_tool_rounds feeds resolveRecursionLimit (native
|
|
220
|
+
// harness only); service_tier feeds the cursor harness's explicit
|
|
221
|
+
// variant selection (issue #357). Zero/unset means "no override" and is
|
|
222
|
+
// omitted.
|
|
223
|
+
const runConfig = resolved.run_config;
|
|
224
|
+
const hasModel = !!runConfig?.model_name;
|
|
225
|
+
const hasCostCap = (runConfig?.max_cost_usd ?? 0) > 0;
|
|
226
|
+
const hasToolRounds = (runConfig?.max_tool_rounds ?? 0) > 0;
|
|
227
|
+
// Loader guarantees a canonical enum name; an unknown one here means the
|
|
228
|
+
// loader and this mapping drifted — fail the task, never silently drop a
|
|
229
|
+
// pricing directive.
|
|
230
|
+
const SERVICE_TIER_BY_NAME: Record<string, ServiceTier> = {
|
|
231
|
+
SERVICE_TIER_STANDARD: ServiceTier.STANDARD,
|
|
232
|
+
SERVICE_TIER_FAST: ServiceTier.FAST,
|
|
233
|
+
};
|
|
234
|
+
const serviceTier = runConfig?.service_tier
|
|
235
|
+
? SERVICE_TIER_BY_NAME[runConfig.service_tier]
|
|
236
|
+
: undefined;
|
|
237
|
+
if (runConfig?.service_tier && serviceTier === undefined) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`call:agent run_config.service_tier '${runConfig.service_tier}' has no proto mapping`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const hasServiceTier = serviceTier !== undefined;
|
|
200
243
|
const hasOutputSchema = !!resolved.output?.schema;
|
|
201
244
|
|
|
202
245
|
console.log(
|
|
203
246
|
`[CallAgent] schema propagation diagnostic: ` +
|
|
204
247
|
`hasOutputSchema=${hasOutputSchema}, ` +
|
|
205
|
-
`hasModel=${hasModel}, ` +
|
|
248
|
+
`hasModel=${hasModel}, hasCostCap=${hasCostCap}, hasToolRounds=${hasToolRounds}, ` +
|
|
249
|
+
`hasServiceTier=${hasServiceTier}, ` +
|
|
206
250
|
`configKeys=[${Object.keys(resolved).join(",")}], ` +
|
|
207
251
|
`hasOutput=${resolved.output !== undefined}, ` +
|
|
208
252
|
`outputKeys=${resolved.output ? JSON.stringify(Object.keys(resolved.output)) : "N/A"}, ` +
|
|
@@ -220,15 +264,29 @@ export async function callAgentAction(
|
|
|
220
264
|
runtimeEnv: runtimeEnvProto,
|
|
221
265
|
});
|
|
222
266
|
|
|
223
|
-
if (hasModel || hasOutputSchema) {
|
|
267
|
+
if (hasModel || hasCostCap || hasToolRounds || hasServiceTier || hasOutputSchema) {
|
|
224
268
|
const execConfig = create(ExecutionConfigSchema, {});
|
|
225
|
-
if (hasModel) execConfig.modelName =
|
|
269
|
+
if (hasModel) execConfig.modelName = runConfig!.model_name!;
|
|
270
|
+
if (hasCostCap) execConfig.maxCostUsd = runConfig!.max_cost_usd!;
|
|
271
|
+
if (hasToolRounds) execConfig.maxToolRounds = runConfig!.max_tool_rounds!;
|
|
272
|
+
if (hasServiceTier) execConfig.serviceTier = serviceTier!;
|
|
226
273
|
if (hasOutputSchema) {
|
|
227
274
|
execConfig.structuredOutputSchema = resolved.output!.schema as JsonObject;
|
|
228
275
|
}
|
|
229
276
|
executionSpec.executionConfig = execConfig;
|
|
230
277
|
}
|
|
231
278
|
|
|
279
|
+
// Workflow provenance labels: the server's CreateExecutionContextStep
|
|
280
|
+
// keys the agent_call environment_refs resolution on these (the
|
|
281
|
+
// schedule-label lineage). The cloud edition additionally gates the
|
|
282
|
+
// branch on the trusted runner caller identity, so the labels are only
|
|
283
|
+
// load-bearing inside that trust boundary.
|
|
284
|
+
const labels: Record<string, string> = {};
|
|
285
|
+
if (wfExecId && taskName) {
|
|
286
|
+
labels["stigmer.ai/workflow-execution-id"] = wfExecId;
|
|
287
|
+
labels["stigmer.ai/workflow-task"] = taskName;
|
|
288
|
+
}
|
|
289
|
+
|
|
232
290
|
await client.createAgentExecution(
|
|
233
291
|
create(AgentExecutionSchema, {
|
|
234
292
|
apiVersion: "agentic.stigmer.ai/v1",
|
|
@@ -236,6 +294,7 @@ export async function callAgentAction(
|
|
|
236
294
|
metadata: create(ApiResourceMetadataSchema, {
|
|
237
295
|
name: executionName,
|
|
238
296
|
org: orgId,
|
|
297
|
+
labels,
|
|
239
298
|
}),
|
|
240
299
|
spec: executionSpec,
|
|
241
300
|
}),
|
|
@@ -244,6 +303,31 @@ export async function callAgentAction(
|
|
|
244
303
|
throw new CompleteAsyncError();
|
|
245
304
|
}
|
|
246
305
|
|
|
306
|
+
// buildWorkspaceEntries maps the task's git-only workspace entries onto the
|
|
307
|
+
// shared session WorkspaceEntry proto. Provisioning credentials are NOT the
|
|
308
|
+
// runner's concern here: the provisioner resolves GITHUB_TOKEN from the
|
|
309
|
+
// merged environment (DD-018 D-4), which the task's environment_refs feed
|
|
310
|
+
// via server-side resolution.
|
|
311
|
+
function buildWorkspaceEntries(
|
|
312
|
+
entries: AgentCallConfig["workspace_entries"],
|
|
313
|
+
): WorkspaceEntry[] {
|
|
314
|
+
if (!entries || entries.length === 0) return [];
|
|
315
|
+
return entries.map((entry) =>
|
|
316
|
+
create(WorkspaceEntrySchema, {
|
|
317
|
+
name: entry.name ?? "",
|
|
318
|
+
source: create(WorkspaceSourceSchema, {
|
|
319
|
+
source: {
|
|
320
|
+
case: "gitRepo",
|
|
321
|
+
value: create(GitRepoSourceSchema, {
|
|
322
|
+
url: entry.source.git_repo.url,
|
|
323
|
+
branch: entry.source.git_repo.branch ?? "",
|
|
324
|
+
}),
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
247
331
|
function parseAgentReference(
|
|
248
332
|
agentStr: string,
|
|
249
333
|
defaultOrg: string,
|
|
@@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeAll } from "vitest";
|
|
|
7
7
|
*/
|
|
8
8
|
describe("getCursorModelPricing — speed variant resolution", () => {
|
|
9
9
|
let getCursorModelPricing: typeof import("../model-pricing.js").getCursorModelPricing;
|
|
10
|
+
let getCursorModelPricingForVariant: typeof import("../model-pricing.js").getCursorModelPricingForVariant;
|
|
10
11
|
let computeTurnCost: typeof import("../model-pricing.js").computeTurnCost;
|
|
11
12
|
|
|
12
13
|
beforeAll(async () => {
|
|
@@ -58,6 +59,7 @@ describe("getCursorModelPricing — speed variant resolution", () => {
|
|
|
58
59
|
const mod = await import("../model-pricing.js");
|
|
59
60
|
await mod.ensureLoaded();
|
|
60
61
|
getCursorModelPricing = mod.getCursorModelPricing;
|
|
62
|
+
getCursorModelPricingForVariant = mod.getCursorModelPricingForVariant;
|
|
61
63
|
computeTurnCost = mod.computeTurnCost;
|
|
62
64
|
});
|
|
63
65
|
|
|
@@ -89,4 +91,22 @@ describe("getCursorModelPricing — speed variant resolution", () => {
|
|
|
89
91
|
const cost = computeTurnCost(p, 266_945, 7_069, 0, 222_432);
|
|
90
92
|
expect(cost).toBeCloseTo(0.28406, 5);
|
|
91
93
|
});
|
|
94
|
+
|
|
95
|
+
it("getCursorModelPricingForVariant('fast') prices a base id at fast rates (#357)", () => {
|
|
96
|
+
// The explicit-tier path: the caller KNOWS the variant (it requested
|
|
97
|
+
// it) — no wire-id suffix inference involved.
|
|
98
|
+
const p = getCursorModelPricingForVariant("composer-2.5", "fast");
|
|
99
|
+
expect(p.inputPricePerMillion).toBe(3.0);
|
|
100
|
+
expect(p.outputPricePerMillion).toBe(15.0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("getCursorModelPricingForVariant(null) keeps base rates", () => {
|
|
104
|
+
const p = getCursorModelPricingForVariant("composer-2.5", null);
|
|
105
|
+
expect(p.inputPricePerMillion).toBe(0.5);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("getCursorModelPricingForVariant('fast') falls back to base rates when unpriced", () => {
|
|
109
|
+
const p = getCursorModelPricingForVariant("claude-opus-4-6", "fast");
|
|
110
|
+
expect(p.inputPricePerMillion).toBe(5.0);
|
|
111
|
+
});
|
|
92
112
|
});
|
|
@@ -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
|
+
});
|