@desplega.ai/agent-swarm 1.79.4 → 1.80.1

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 (130) hide show
  1. package/openapi.json +496 -32
  2. package/package.json +14 -6
  3. package/src/artifact-sdk/server.ts +2 -1
  4. package/src/be/db.ts +102 -31
  5. package/src/be/migrations/063_cost_context_schema_relax.sql +133 -0
  6. package/src/be/migrations/064_scripts.sql +39 -0
  7. package/src/be/migrations/065_script_embeddings.sql +7 -0
  8. package/src/be/pricing-normalize.ts +81 -0
  9. package/src/be/scripts/db.ts +391 -0
  10. package/src/be/scripts/embeddings.ts +231 -0
  11. package/src/be/scripts/maintenance.ts +9 -0
  12. package/src/be/scripts/typecheck.ts +193 -0
  13. package/src/be/seed-pricing.ts +293 -0
  14. package/src/cli.tsx +22 -5
  15. package/src/commands/artifact.ts +3 -2
  16. package/src/commands/claude-managed-setup.ts +21 -4
  17. package/src/commands/codex-login.ts +5 -3
  18. package/src/commands/onboard.tsx +2 -1
  19. package/src/commands/runner.ts +663 -246
  20. package/src/commands/setup.tsx +5 -3
  21. package/src/hooks/hook.ts +4 -3
  22. package/src/http/context.ts +6 -2
  23. package/src/http/index.ts +126 -68
  24. package/src/http/memory.ts +28 -0
  25. package/src/http/openapi.ts +1 -0
  26. package/src/http/page-proxy.ts +2 -1
  27. package/src/http/route-def.ts +1 -0
  28. package/src/http/schedules.ts +37 -0
  29. package/src/http/scripts.ts +381 -0
  30. package/src/http/session-data.ts +74 -23
  31. package/src/linear/outbound.ts +9 -2
  32. package/src/otel-impl.ts +200 -0
  33. package/src/otel.ts +132 -0
  34. package/src/providers/claude-adapter.ts +52 -6
  35. package/src/providers/claude-managed-adapter.ts +43 -17
  36. package/src/providers/claude-managed-pricing.ts +34 -0
  37. package/src/providers/codex-adapter.ts +38 -27
  38. package/src/providers/codex-models.ts +22 -3
  39. package/src/providers/devin-adapter.ts +11 -0
  40. package/src/providers/opencode-adapter.ts +31 -7
  41. package/src/providers/pi-mono-adapter.ts +39 -7
  42. package/src/providers/pricing-sources.md +52 -0
  43. package/src/providers/swarm-events-shared.ts +8 -4
  44. package/src/providers/types.ts +33 -10
  45. package/src/scripts-runtime/ctx.ts +23 -0
  46. package/src/scripts-runtime/eval-harness.ts +39 -0
  47. package/src/scripts-runtime/executors/native.ts +229 -0
  48. package/src/scripts-runtime/executors/registry.ts +16 -0
  49. package/src/scripts-runtime/executors/types.ts +63 -0
  50. package/src/scripts-runtime/extract-signature.ts +81 -0
  51. package/src/scripts-runtime/import-allowlist.ts +109 -0
  52. package/src/scripts-runtime/loader.ts +96 -0
  53. package/src/scripts-runtime/redacted.ts +48 -0
  54. package/src/scripts-runtime/sdk-allowlist.ts +29 -0
  55. package/src/scripts-runtime/stdlib/fetch.ts +46 -0
  56. package/src/scripts-runtime/stdlib/glob.ts +8 -0
  57. package/src/scripts-runtime/stdlib/grep.ts +34 -0
  58. package/src/scripts-runtime/stdlib/index.ts +16 -0
  59. package/src/scripts-runtime/stdlib/table.ts +17 -0
  60. package/src/scripts-runtime/swarm-config.ts +35 -0
  61. package/src/scripts-runtime/swarm-sdk.ts +197 -0
  62. package/src/scripts-runtime/types/stdlib.d.ts +104 -0
  63. package/src/scripts-runtime/types/swarm-sdk.d.ts +86 -0
  64. package/src/server.ts +18 -0
  65. package/src/tests/api-key.test.ts +33 -0
  66. package/src/tests/claude-managed-adapter.test.ts +17 -3
  67. package/src/tests/claude-managed-setup.test.ts +10 -1
  68. package/src/tests/codex-adapter.test.ts +20 -19
  69. package/src/tests/codex-login.test.ts +1 -1
  70. package/src/tests/context-snapshot.test.ts +2 -2
  71. package/src/tests/context-window.test.ts +65 -1
  72. package/src/tests/devin-adapter.test.ts +2 -0
  73. package/src/tests/http/context-routes.test.ts +161 -0
  74. package/src/tests/linear-outbound-sync.test.ts +109 -0
  75. package/src/tests/mcp-tools.test.ts +69 -0
  76. package/src/tests/migration-063-schema-relax.test.ts +109 -0
  77. package/src/tests/opencode-adapter.test.ts +146 -1
  78. package/src/tests/otel-impl-secret-scrubbing.test.ts +33 -0
  79. package/src/tests/pages-view-count.test.ts +30 -5
  80. package/src/tests/providers/codex-cost.test.ts +18 -0
  81. package/src/tests/providers/opencode-cost.test.ts +74 -0
  82. package/src/tests/providers/pi-cost.test.ts +128 -0
  83. package/src/tests/redacted.test.ts +29 -0
  84. package/src/tests/runner-tool-spans.test.ts +268 -0
  85. package/src/tests/script-executor-conformance.test.ts +142 -0
  86. package/src/tests/script-executor-registry.test.ts +17 -0
  87. package/src/tests/scripts-db.test.ts +329 -0
  88. package/src/tests/scripts-embeddings.test.ts +291 -0
  89. package/src/tests/scripts-extract-signature.test.ts +47 -0
  90. package/src/tests/scripts-http.test.ts +350 -0
  91. package/src/tests/scripts-import-allowlist.test.ts +55 -0
  92. package/src/tests/scripts-mcp-e2e.test.ts +269 -0
  93. package/src/tests/scripts-runtime-secret-egress.test.ts +44 -0
  94. package/src/tests/scripts-runtime.test.ts +289 -0
  95. package/src/tests/sdk-allowlist.test.ts +59 -0
  96. package/src/tests/secret-scrubber.test.ts +54 -1
  97. package/src/tests/session-costs-codex-recompute.test.ts +35 -22
  98. package/src/tests/session-costs-model-key-normalize.test.ts +271 -0
  99. package/src/tests/session-costs-recompute-all-providers.test.ts +170 -0
  100. package/src/tests/store-progress-cost.test.ts +6 -1
  101. package/src/tests/swarm-config.test.ts +38 -0
  102. package/src/tests/tool-annotations.test.ts +2 -2
  103. package/src/tests/tool-call-progress.test.ts +30 -0
  104. package/src/tests/workflow-e2e.test.ts +218 -0
  105. package/src/tests/workflow-executors.test.ts +32 -2
  106. package/src/tests/workflow-input-redaction.test.ts +232 -0
  107. package/src/tests/workflow-swarm-script.test.ts +273 -0
  108. package/src/tools/memory-rate.ts +2 -1
  109. package/src/tools/script-common.ts +88 -0
  110. package/src/tools/script-delete.ts +35 -0
  111. package/src/tools/script-query-types.ts +37 -0
  112. package/src/tools/script-run.ts +43 -0
  113. package/src/tools/script-search.ts +32 -0
  114. package/src/tools/script-upsert.ts +43 -0
  115. package/src/tools/store-progress.ts +16 -60
  116. package/src/tools/tool-config.ts +7 -0
  117. package/src/tools/utils.ts +65 -12
  118. package/src/types.ts +122 -10
  119. package/src/utils/api-key.ts +28 -0
  120. package/src/utils/context-window.ts +104 -4
  121. package/src/utils/page-session.ts +8 -6
  122. package/src/utils/secret-scrubber.ts +29 -1
  123. package/src/workflows/engine.ts +12 -4
  124. package/src/workflows/executors/index.ts +1 -0
  125. package/src/workflows/executors/registry.ts +2 -0
  126. package/src/workflows/executors/script.ts +12 -1
  127. package/src/workflows/executors/swarm-script.ts +170 -0
  128. package/src/workflows/input.ts +65 -0
  129. package/src/workflows/recovery.ts +31 -3
  130. package/src/workflows/resume.ts +43 -5
@@ -0,0 +1,200 @@
1
+ import {
2
+ context,
3
+ propagation,
4
+ ROOT_CONTEXT,
5
+ type Span,
6
+ SpanStatusCode,
7
+ trace,
8
+ } from "@opentelemetry/api";
9
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
10
+ import { resourceFromAttributes } from "@opentelemetry/resources";
11
+ import { NodeSDK } from "@opentelemetry/sdk-node";
12
+ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions";
13
+ import pkg from "../package.json";
14
+ import type { SwarmSpan } from "./otel";
15
+ import { scrubSecrets } from "./utils/secret-scrubber";
16
+
17
+ type AttributeValue = string | number | boolean | string[] | number[] | boolean[];
18
+ type Attributes = Record<string, AttributeValue | undefined>;
19
+
20
+ const TRACER_NAME = "agent-swarm";
21
+ const RAW_SPAN = Symbol("agent-swarm.raw-span");
22
+
23
+ let sdk: NodeSDK | undefined;
24
+
25
+ function decodeResourceAttributeValue(value: string): string {
26
+ try {
27
+ return decodeURIComponent(value);
28
+ } catch {
29
+ return value;
30
+ }
31
+ }
32
+
33
+ function parseResourceAttributes(value = process.env.OTEL_RESOURCE_ATTRIBUTES): Attributes {
34
+ if (!value) return {};
35
+ const attributes: Attributes = {};
36
+ for (const pair of value.split(",")) {
37
+ const [rawKey, ...rawValueParts] = pair.split("=");
38
+ const key = rawKey?.trim();
39
+ if (!key) continue;
40
+ const rawValue = rawValueParts.join("=").trim();
41
+ if (!rawValue) continue;
42
+ attributes[key] = decodeResourceAttributeValue(rawValue);
43
+ }
44
+ return attributes;
45
+ }
46
+
47
+ function cleanAttributes(attributes?: Attributes): Record<string, AttributeValue> | undefined {
48
+ if (!attributes) return undefined;
49
+ const cleaned: Record<string, AttributeValue> = {};
50
+ for (const [key, value] of Object.entries(attributes)) {
51
+ if (value !== undefined) cleaned[key] = value;
52
+ }
53
+ return cleaned;
54
+ }
55
+
56
+ export function scrubOtelException(error: unknown): Error | string {
57
+ if (!(error instanceof Error)) {
58
+ return scrubSecrets(String(error));
59
+ }
60
+
61
+ const scrubbed = new Error(scrubSecrets(error.message));
62
+ scrubbed.name = error.name;
63
+ if (error.stack) {
64
+ scrubbed.stack = scrubSecrets(error.stack);
65
+ }
66
+ return scrubbed;
67
+ }
68
+
69
+ export function scrubOtelStatus(status: { code: number; message?: string }) {
70
+ return status.message === undefined
71
+ ? status
72
+ : {
73
+ ...status,
74
+ message: scrubSecrets(status.message),
75
+ };
76
+ }
77
+
78
+ type AdaptedSwarmSpan = SwarmSpan & { [RAW_SPAN]: Span };
79
+
80
+ function spanAdapter(span: Span): AdaptedSwarmSpan {
81
+ return {
82
+ [RAW_SPAN]: span,
83
+ setAttribute(key, value) {
84
+ span.setAttribute(key, value);
85
+ return this;
86
+ },
87
+ setAttributes(attributes) {
88
+ const cleaned = cleanAttributes(attributes);
89
+ if (cleaned) span.setAttributes(cleaned);
90
+ return this;
91
+ },
92
+ addEvent(name, attributes) {
93
+ const cleaned = cleanAttributes(attributes);
94
+ span.addEvent(name, cleaned);
95
+ return this;
96
+ },
97
+ recordException(error) {
98
+ span.recordException(scrubOtelException(error));
99
+ },
100
+ setStatus(status) {
101
+ span.setStatus(scrubOtelStatus(status));
102
+ return this;
103
+ },
104
+ end() {
105
+ span.end();
106
+ },
107
+ };
108
+ }
109
+
110
+ export async function boot(serviceRole: string): Promise<void> {
111
+ if (sdk) return;
112
+
113
+ const configuredResourceAttributes = parseResourceAttributes();
114
+ const deploymentEnvironment =
115
+ configuredResourceAttributes["deployment.environment"] || process.env.NODE_ENV || "development";
116
+ const serviceName =
117
+ process.env.OTEL_SERVICE_NAME ||
118
+ (serviceRole === "api" ? "agent-swarm-api" : "agent-swarm-worker");
119
+ sdk = new NodeSDK({
120
+ resource: resourceFromAttributes({
121
+ ...configuredResourceAttributes,
122
+ [ATTR_SERVICE_NAME]: serviceName,
123
+ [ATTR_SERVICE_VERSION]: pkg.version,
124
+ "service.namespace": configuredResourceAttributes["service.namespace"] || "agent-swarm",
125
+ "service.instance.id": process.env.AGENT_ID || crypto.randomUUID(),
126
+ "deployment.environment": deploymentEnvironment,
127
+ env: configuredResourceAttributes.env || deploymentEnvironment,
128
+ "agentswarm.service.role": serviceRole,
129
+ }),
130
+ traceExporter: new OTLPTraceExporter(),
131
+ });
132
+
133
+ sdk.start();
134
+
135
+ const shutdown = async () => {
136
+ try {
137
+ await sdk?.shutdown();
138
+ } catch {
139
+ // Best-effort flush during process shutdown.
140
+ }
141
+ };
142
+
143
+ process.once("SIGTERM", shutdown);
144
+ process.once("SIGINT", shutdown);
145
+ }
146
+
147
+ export async function shutdown(): Promise<void> {
148
+ await sdk?.shutdown();
149
+ sdk = undefined;
150
+ }
151
+
152
+ export async function withSpan<T>(
153
+ name: string,
154
+ fn: (span: SwarmSpan) => Promise<T> | T,
155
+ attributes?: Attributes,
156
+ ): Promise<T> {
157
+ const tracer = trace.getTracer(TRACER_NAME);
158
+ return tracer.startActiveSpan(name, { attributes: cleanAttributes(attributes) }, async (span) => {
159
+ try {
160
+ const result = await fn(spanAdapter(span));
161
+ span.setStatus({ code: SpanStatusCode.OK });
162
+ return result;
163
+ } catch (error) {
164
+ span.recordException(scrubOtelException(error));
165
+ span.setStatus({
166
+ code: SpanStatusCode.ERROR,
167
+ message: scrubSecrets(error instanceof Error ? error.message : String(error)),
168
+ });
169
+ throw error;
170
+ } finally {
171
+ span.end();
172
+ }
173
+ });
174
+ }
175
+
176
+ export function startSpan(name: string, attributes?: Attributes): SwarmSpan {
177
+ const span = trace.getTracer(TRACER_NAME).startSpan(name, {
178
+ attributes: cleanAttributes(attributes),
179
+ });
180
+ return spanAdapter(span);
181
+ }
182
+
183
+ export function withSpanContext<T>(span: SwarmSpan, fn: () => T): T {
184
+ const rawSpan = (span as Partial<AdaptedSwarmSpan>)[RAW_SPAN];
185
+ if (!rawSpan) return fn();
186
+ return context.with(trace.setSpan(context.active(), rawSpan), fn);
187
+ }
188
+
189
+ export async function withRemoteContext<T>(
190
+ carrier: Record<string, unknown>,
191
+ fn: () => Promise<T> | T,
192
+ ): Promise<T> {
193
+ const remoteContext = propagation.extract(ROOT_CONTEXT, carrier);
194
+ return context.with(remoteContext, fn);
195
+ }
196
+
197
+ export function injectTraceContext(headers: Record<string, string>): Record<string, string> {
198
+ propagation.inject(context.active(), headers);
199
+ return headers;
200
+ }
package/src/otel.ts ADDED
@@ -0,0 +1,132 @@
1
+ export type AttributeValue = string | number | boolean | string[] | number[] | boolean[];
2
+ export type Attributes = Record<string, AttributeValue | undefined>;
3
+
4
+ type SpanStatus = {
5
+ code: number;
6
+ message?: string;
7
+ };
8
+
9
+ export type SwarmSpan = {
10
+ setAttribute: (key: string, value: AttributeValue) => SwarmSpan;
11
+ setAttributes: (attributes: Attributes) => SwarmSpan;
12
+ addEvent: (name: string, attributes?: Attributes) => SwarmSpan;
13
+ recordException: (error: unknown) => void;
14
+ setStatus: (status: SpanStatus) => SwarmSpan;
15
+ end: () => void;
16
+ };
17
+
18
+ const enabled = Boolean(process.env.OTEL_EXPORTER_OTLP_ENDPOINT);
19
+
20
+ const NOOP_SPAN: SwarmSpan = {
21
+ setAttribute: () => NOOP_SPAN,
22
+ setAttributes: () => NOOP_SPAN,
23
+ addEvent: () => NOOP_SPAN,
24
+ recordException: () => {},
25
+ setStatus: () => NOOP_SPAN,
26
+ end: () => {},
27
+ };
28
+
29
+ let initialized = false;
30
+ let realWithSpan:
31
+ | (<T>(
32
+ name: string,
33
+ fn: (span: SwarmSpan) => Promise<T> | T,
34
+ attributes?: Attributes,
35
+ ) => Promise<T>)
36
+ | undefined;
37
+ let realStartSpan: ((name: string, attributes?: Attributes) => SwarmSpan) | undefined;
38
+ let realWithRemoteContext:
39
+ | (<T>(carrier: Record<string, unknown>, fn: () => Promise<T> | T) => Promise<T>)
40
+ | undefined;
41
+ let realWithSpanContext: (<T>(span: SwarmSpan, fn: () => T) => T) | undefined;
42
+ let realInjectTraceContext:
43
+ | ((headers: Record<string, string>) => Record<string, string>)
44
+ | undefined;
45
+ let realShutdown: (() => Promise<void>) | undefined;
46
+
47
+ export function isOtelEnabled(): boolean {
48
+ return enabled;
49
+ }
50
+
51
+ export function isPollTracingEnabled(): boolean {
52
+ const v = (process.env.OTEL_TRACE_POLL ?? "").toLowerCase();
53
+ return v === "1" || v === "true" || v === "yes" || v === "on";
54
+ }
55
+
56
+ export async function initOtel(serviceRole = process.env.AGENT_ROLE || "api"): Promise<void> {
57
+ if (!enabled || initialized) return;
58
+ initialized = true;
59
+
60
+ try {
61
+ const impl = await import("./otel-impl");
62
+ await impl.boot(serviceRole);
63
+ realWithSpan = impl.withSpan;
64
+ realStartSpan = impl.startSpan;
65
+ realWithRemoteContext = impl.withRemoteContext;
66
+ realWithSpanContext = impl.withSpanContext;
67
+ realInjectTraceContext = impl.injectTraceContext;
68
+ realShutdown = impl.shutdown;
69
+ console.log(
70
+ `[OTel] enabled for ${process.env.OTEL_SERVICE_NAME ?? "agent-swarm"} (${serviceRole})`,
71
+ );
72
+ } catch (error) {
73
+ console.warn(`[OTel] disabled after initialization failure: ${error}`);
74
+ }
75
+ }
76
+
77
+ export async function withSpan<T>(
78
+ name: string,
79
+ fn: (span: SwarmSpan) => Promise<T> | T,
80
+ attributes?: Attributes,
81
+ ): Promise<T> {
82
+ if (!enabled || !realWithSpan) {
83
+ return fn(NOOP_SPAN);
84
+ }
85
+ return realWithSpan(name, fn, attributes);
86
+ }
87
+
88
+ export function startSpan(name: string, attributes?: Attributes): SwarmSpan {
89
+ if (!enabled || !realStartSpan) {
90
+ return NOOP_SPAN;
91
+ }
92
+ return realStartSpan(name, attributes);
93
+ }
94
+
95
+ export function withSpanContext<T>(span: SwarmSpan, fn: () => T): T {
96
+ if (!enabled || !realWithSpanContext) {
97
+ return fn();
98
+ }
99
+ return realWithSpanContext(span, fn);
100
+ }
101
+
102
+ export async function withRemoteContext<T>(
103
+ carrier: Record<string, unknown>,
104
+ fn: () => Promise<T> | T,
105
+ ): Promise<T> {
106
+ if (!enabled || !realWithRemoteContext) {
107
+ return fn();
108
+ }
109
+ return realWithRemoteContext(carrier, fn);
110
+ }
111
+
112
+ export function injectTraceContext(headers: Record<string, string>): Record<string, string> {
113
+ if (!enabled || !realInjectTraceContext) {
114
+ return headers;
115
+ }
116
+ return realInjectTraceContext(headers);
117
+ }
118
+
119
+ export async function shutdownOtel(): Promise<void> {
120
+ if (!realShutdown) return;
121
+ await realShutdown();
122
+ }
123
+
124
+ export function _resetOtelForTests() {
125
+ initialized = false;
126
+ realWithSpan = undefined;
127
+ realStartSpan = undefined;
128
+ realWithRemoteContext = undefined;
129
+ realWithSpanContext = undefined;
130
+ realInjectTraceContext = undefined;
131
+ realShutdown = undefined;
132
+ }
@@ -1,7 +1,12 @@
1
1
  import { readFile, unlink, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { computeContextUsed, getContextWindowSize } from "../utils/context-window";
4
+ import {
5
+ CONTEXT_FORMULA,
6
+ clampContextPercent,
7
+ computeContextUsedUnified,
8
+ getContextWindowSize,
9
+ } from "../utils/context-window";
5
10
  import { validateClaudeCredentials } from "../utils/credentials";
6
11
  import {
7
12
  parseStderrForErrors,
@@ -465,6 +470,10 @@ class ClaudeSession implements ProviderSession {
465
470
  this._sessionId = json.session_id;
466
471
  this.emit({ type: "session_init", sessionId: json.session_id, provider: "claude" });
467
472
  if (json.model) {
473
+ // Phase 4: the CLI's `init.model` reflects the actual model after any
474
+ // backoff/fallback. Update `this.model` so subsequent CostData rows
475
+ // (and the pricing lookup the API runs) use the right rate.
476
+ this.model = json.model;
468
477
  this.contextWindowSize = getContextWindowSize(json.model);
469
478
  }
470
479
  }
@@ -487,6 +496,10 @@ class ClaudeSession implements ProviderSession {
487
496
  output_tokens?: number;
488
497
  cache_read_input_tokens?: number;
489
498
  cache_creation_input_tokens?: number;
499
+ // Phase 4: claude extended-thinking flows surface this — the
500
+ // CLI emits `thinking_input_tokens` when the model produced
501
+ // thinking content during the turn.
502
+ thinking_input_tokens?: number;
490
503
  }
491
504
  | undefined;
492
505
 
@@ -499,8 +512,12 @@ class ClaudeSession implements ProviderSession {
499
512
  outputTokens: usage?.output_tokens ?? 0,
500
513
  cacheReadTokens: usage?.cache_read_input_tokens ?? 0,
501
514
  cacheWriteTokens: usage?.cache_creation_input_tokens ?? 0,
515
+ // Phase 4: surface thinking tokens; previously dropped on the floor.
516
+ thinkingTokens: usage?.thinking_input_tokens ?? 0,
502
517
  durationMs: json.duration_ms || 0,
503
- numTurns: json.num_turns || 1,
518
+ // Phase 4: honest null when the CLI omits num_turns instead of a
519
+ // faked `1` (would have under-counted in dashboards).
520
+ numTurns: json.num_turns ?? null,
504
521
  model: this.model,
505
522
  isError: json.is_error || false,
506
523
  provider: "claude",
@@ -524,8 +541,29 @@ class ClaudeSession implements ProviderSession {
524
541
  // Tool use from assistant messages — emit tool_start for auto-progress
525
542
  if (json.type === "assistant" && json.message) {
526
543
  const message = json.message as {
527
- content?: Array<{ type: string; name?: string; id?: string; input?: unknown }>;
544
+ content?: Array<{
545
+ type: string;
546
+ name?: string;
547
+ id?: string;
548
+ input?: unknown;
549
+ text?: string;
550
+ }>;
528
551
  };
552
+
553
+ // Emit a `message` event BEFORE any tool_start events for this turn.
554
+ // The runner uses this as an "assistant turn boundary" to implicit-close
555
+ // any worker.tool spans left open by the previous turn (the Claude CLI
556
+ // doesn't emit per-tool completion events for harness-side tools like
557
+ // Bash/Read/Edit, so without this boundary their spans would stay open
558
+ // until session shutdown and report inflated duration_ms).
559
+ const text = Array.isArray(message.content)
560
+ ? message.content
561
+ .filter((b) => b.type === "text" && typeof b.text === "string")
562
+ .map((b) => b.text as string)
563
+ .join("")
564
+ : "";
565
+ this.emit({ type: "message", role: "assistant", content: text });
566
+
529
567
  if (message.content) {
530
568
  for (const block of message.content) {
531
569
  if (block.type === "tool_use" && block.name) {
@@ -539,18 +577,26 @@ class ClaudeSession implements ProviderSession {
539
577
  }
540
578
  }
541
579
 
542
- // Context usage extraction from assistant message usage
580
+ // Context usage extraction from assistant message usage.
581
+ // Phase 9: unified `input + cache + output` formula across every
582
+ // provider so cross-provider percent comparisons are meaningful.
543
583
  if (json.message.usage) {
544
584
  const usage = json.message.usage;
545
- const contextUsed = computeContextUsed(usage);
585
+ const contextUsed = computeContextUsedUnified({
586
+ inputTokens: usage.input_tokens,
587
+ cacheReadTokens: usage.cache_read_input_tokens,
588
+ cacheCreateTokens: usage.cache_creation_input_tokens,
589
+ outputTokens: usage.output_tokens,
590
+ });
546
591
  const contextTotal = this.contextWindowSize;
547
592
 
548
593
  this.emit({
549
594
  type: "context_usage",
550
595
  contextUsedTokens: contextUsed,
551
596
  contextTotalTokens: contextTotal,
552
- contextPercent: contextTotal > 0 ? (contextUsed / contextTotal) * 100 : 0,
597
+ contextPercent: clampContextPercent(contextUsed, contextTotal) ?? 0,
553
598
  outputTokens: usage.output_tokens ?? 0,
599
+ contextFormula: CONTEXT_FORMULA,
554
600
  });
555
601
  }
556
602
  }
@@ -59,8 +59,15 @@ import type {
59
59
  import type { SkillCreateResponse as Skill } from "@anthropic-ai/sdk/resources/beta/skills";
60
60
 
61
61
  import { checkToolLoop } from "../hooks/tool-loop-detection";
62
+ import {
63
+ CONTEXT_FORMULA,
64
+ clampContextPercent,
65
+ computeContextUsedUnified,
66
+ getContextWindowSize,
67
+ } from "../utils/context-window";
62
68
  import { scrubSecrets } from "../utils/secret-scrubber";
63
69
  import { computeClaudeManagedCostUsd } from "./claude-managed-models";
70
+ import { getRuntimeFeePerHour } from "./claude-managed-pricing";
64
71
  import { createClaudeManagedSwarmEventHandler } from "./claude-managed-swarm-events";
65
72
  import type {
66
73
  CostData,
@@ -113,13 +120,10 @@ const REQUIRED_ENV_VARS = [
113
120
  "MANAGED_ENVIRONMENT_ID",
114
121
  ] as const;
115
122
 
116
- /**
117
- * Default context window for managed Claude sessions when we don't have a
118
- * model-specific override. Sized to match Sonnet 4.x (1M extended-context
119
- * variant). The Phase 4 pricing-table commit will replace this with a
120
- * per-model lookup.
121
- */
122
- const DEFAULT_CONTEXT_TOTAL_TOKENS = 1_000_000;
123
+ // Phase 5: removed the hardcoded `DEFAULT_CONTEXT_TOTAL_TOKENS = 1_000_000`.
124
+ // The adapter now calls `getContextWindowSize(this.model)` from
125
+ // `src/utils/context-window.ts`, which resolves shortnames + dated full ids
126
+ // so haiku-4-5 sessions don't pretend to have a 1M window.
123
127
 
124
128
  /**
125
129
  * Compose the per-session user-message content blocks. Returns two blocks:
@@ -187,6 +191,8 @@ function emptyCost(config: ProviderSessionConfig, model: string): CostData {
187
191
  numTurns: 0,
188
192
  model,
189
193
  isError: false,
194
+ // Phase 3 — tag every emitted CostData so the API's recompute path engages.
195
+ provider: "claude-managed",
190
196
  };
191
197
  }
192
198
 
@@ -374,6 +380,11 @@ class ClaudeManagedSession implements ProviderSession {
374
380
  * 2. Anthropic's $0.08/session-hour runtime fee — billed continuously by
375
381
  * Anthropic regardless of model usage, so we add it here to surface in
376
382
  * the swarm's per-session cost UI.
383
+ *
384
+ * Phase 5: the harness-local USD is still computed here, but the server-side
385
+ * recompute path (`POST /api/session-costs` after Phase 2) will reprice the
386
+ * row against the seeded pricing-table values and tag `costSource='pricing-table'`.
387
+ * The runtime fee comes from the same table now (`token_class='runtime_hour'`).
377
388
  */
378
389
  private snapshotCost(isError: boolean): CostData {
379
390
  const durationMs = Date.now() - this.startedAt;
@@ -384,9 +395,11 @@ class ClaudeManagedSession implements ProviderSession {
384
395
  this.cost.cacheReadTokens ?? 0,
385
396
  this.cost.cacheWriteTokens ?? 0,
386
397
  );
387
- // $0.08 / session-hour. Sandbox runtime is billed by wallclock, so we
388
- // amortize linearly across the session's `durationMs`.
389
- const runtimeFeeUsd = (durationMs / 3_600_000) * 0.08;
398
+ // Phase 5: read the runtime fee from the pricing table when available so
399
+ // we have one source of truth. Falls back to the historical $0.08/hr
400
+ // constant if the row hasn't been seeded yet (e.g. on a fresh DB before
401
+ // seed-pricing.ts ran).
402
+ const runtimeFeeUsd = (durationMs / 3_600_000) * getRuntimeFeePerHour();
390
403
  return {
391
404
  ...this.cost,
392
405
  durationMs,
@@ -506,12 +519,15 @@ class ClaudeManagedSession implements ProviderSession {
506
519
  // this event. Emit a `compaction` ProviderEvent with the values we
507
520
  // *do* know; consumers that need richer data can subscribe to
508
521
  // `raw_log` for the original payload.
522
+ // Phase 5 — pre-compact tokens are an inferred proxy (running input
523
+ // total); flag the compactTrigger as 'auto-inferred' so downstream
524
+ // dashboards can distinguish a real trigger value from our guess.
509
525
  const _cc = event as BetaManagedAgentsAgentThreadContextCompactedEvent;
510
526
  this.emit({
511
527
  type: "compaction",
512
528
  preCompactTokens: this.cost.inputTokens ?? 0,
513
- compactTrigger: "auto",
514
- contextTotalTokens: DEFAULT_CONTEXT_TOTAL_TOKENS,
529
+ compactTrigger: "auto-inferred",
530
+ contextTotalTokens: getContextWindowSize(this.cost.model),
515
531
  });
516
532
  return { terminal: false, isError: false };
517
533
  }
@@ -524,16 +540,26 @@ class ClaudeManagedSession implements ProviderSession {
524
540
  (this.cost.cacheReadTokens ?? 0) + usage.cache_read_input_tokens;
525
541
  this.cost.cacheWriteTokens =
526
542
  (this.cost.cacheWriteTokens ?? 0) + usage.cache_creation_input_tokens;
527
- this.cost.numTurns += 1;
528
-
529
- const used = (this.cost.inputTokens ?? 0) + (this.cost.outputTokens ?? 0);
530
- const total = DEFAULT_CONTEXT_TOTAL_TOKENS;
543
+ this.cost.numTurns = (this.cost.numTurns ?? 0) + 1;
544
+
545
+ // Phase 5 + Phase 9: unified `input + cache + output` formula AND a
546
+ // per-model window via `getContextWindowSize`. Previously this used
547
+ // a hardcoded 1M window and ignored cache — fine for sonnet/opus,
548
+ // wrong for haiku and any future smaller-window model.
549
+ const used = computeContextUsedUnified({
550
+ inputTokens: this.cost.inputTokens,
551
+ cacheReadTokens: this.cost.cacheReadTokens,
552
+ cacheCreateTokens: this.cost.cacheWriteTokens,
553
+ outputTokens: this.cost.outputTokens,
554
+ });
555
+ const total = getContextWindowSize(this.cost.model);
531
556
  this.emit({
532
557
  type: "context_usage",
533
558
  contextUsedTokens: used,
534
559
  contextTotalTokens: total,
535
- contextPercent: Math.min(100, (used / total) * 100),
560
+ contextPercent: clampContextPercent(used, total),
536
561
  outputTokens: this.cost.outputTokens ?? 0,
562
+ contextFormula: CONTEXT_FORMULA,
537
563
  });
538
564
  return { terminal: false, isError: false };
539
565
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Phase 5 — small adapter-side pricing constants for claude-managed.
3
+ *
4
+ * The API server's pricing table is the canonical store (seeded by
5
+ * `src/be/seed-pricing.ts`). Workers can't touch the DB directly (DB
6
+ * boundary), so the adapter keeps a local constant for the runtime fee
7
+ * and lets the API-side recompute path (Phase 2) override the resulting
8
+ * `totalCostUsd` with the canonical figure. The constant here is what
9
+ * shows up in the worker's local logs before the row hits the server.
10
+ *
11
+ * If/when we plumb pricing through the worker bootstrap (HTTP fetch of
12
+ * `/api/pricing` at session start), this module is the place to swap.
13
+ */
14
+
15
+ /**
16
+ * USD per session-hour for managed claude runtime. Source:
17
+ * https://docs.claude.com/en/api/agent-sdk/managed-runtime#pricing
18
+ * (verified 2026-04-28). Override at runtime via env for ops bumps without
19
+ * a redeploy.
20
+ */
21
+ export const RUNTIME_FEE_USD_PER_HOUR = (() => {
22
+ const raw = process.env.CLAUDE_MANAGED_RUNTIME_FEE_USD_PER_HOUR;
23
+ const n = raw ? Number(raw) : NaN;
24
+ if (Number.isFinite(n) && n >= 0) return n;
25
+ return 0.08;
26
+ })();
27
+
28
+ /**
29
+ * Adapter helper. Always returns a finite number — never crashes the
30
+ * cost snapshot.
31
+ */
32
+ export function getRuntimeFeePerHour(): number {
33
+ return RUNTIME_FEE_USD_PER_HOUR;
34
+ }