@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
@@ -2,6 +2,16 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { ensure, initialize } from "@desplega.ai/business-use";
4
4
  import type { TemplateResponse } from "../../templates/schema.ts";
5
+ import {
6
+ type Attributes,
7
+ initOtel,
8
+ injectTraceContext,
9
+ isPollTracingEnabled,
10
+ type SwarmSpan,
11
+ startSpan,
12
+ withSpan,
13
+ withSpanContext,
14
+ } from "../otel.ts";
5
15
  import { type BasePromptArgs, getBasePrompt } from "../prompts/base-prompt.ts";
6
16
  import {
7
17
  generateDefaultClaudeMd,
@@ -15,12 +25,14 @@ import { authJsonToCredentialSelection } from "../providers/codex-oauth/auth-jso
15
25
  import {
16
26
  type CostData,
17
27
  createProviderAdapter,
28
+ type ProviderEvent,
18
29
  type ProviderResult,
19
30
  type ProviderSession,
20
31
  type ProviderSessionConfig,
21
32
  } from "../providers/index.ts";
22
33
  import { initTelemetry, telemetry } from "../telemetry.ts";
23
34
  import type { ProviderName, RepoGuidelines } from "../types.ts";
35
+ import { getApiKey } from "../utils/api-key.ts";
24
36
  import { computeBudgetBackoffMs } from "../utils/budget-backoff.ts";
25
37
  import { getContextWindowSize } from "../utils/context-window.ts";
26
38
  import { type CredentialSelection, resolveCredentialPools } from "../utils/credentials.ts";
@@ -402,6 +414,12 @@ const SWARM_TOOL_LABELS: Record<string, string | null> = {
402
414
  "list-services": "🔌 Listing services",
403
415
  "unregister-service": "🔌 Unregistering service",
404
416
  "update-service-status": "🔌 Updating service status",
417
+ // Reusable scripts
418
+ "script-search": "📜 Searching scripts",
419
+ "script-run": "📜 Running script",
420
+ "script-upsert": "📜 Saving script",
421
+ "script-delete": "📜 Deleting script",
422
+ "script-query-types": "📜 Reading script types",
405
423
  };
406
424
 
407
425
  /** Convert kebab-case to sentence case: "get-task-details" → "Get task details" */
@@ -477,13 +495,11 @@ export function toolCallToProgress(toolName: string, args: unknown): string | nu
477
495
  }
478
496
 
479
497
  // Pi-mono exposes tools from the built-in swarm MCP endpoint as bare
480
- // names ("store-progress", "send-task", ...), not as mcp__ names.
498
+ // names ("store-progress", "send-task", "script-run", ...), not as mcp__ names.
481
499
  // Treat those names as agent-swarm tools so activity stays readable.
482
- if (toolName.includes("-")) {
483
- const label = SWARM_TOOL_LABELS[toolName];
484
- if (label === null) return null;
485
- if (label) return label;
486
- }
500
+ const label = SWARM_TOOL_LABELS[toolName];
501
+ if (label === null) return null;
502
+ if (label) return label;
487
503
 
488
504
  return `🔧 ${toolName}`;
489
505
  }
@@ -1503,6 +1519,25 @@ async function registerAgent(opts: {
1503
1519
 
1504
1520
  /** Poll for triggers via HTTP API */
1505
1521
  async function pollForTrigger(opts: PollOptions): Promise<Trigger | null> {
1522
+ if (!isPollTracingEnabled()) {
1523
+ return pollForTriggerOnce(opts);
1524
+ }
1525
+ return withSpan(
1526
+ "worker.poll",
1527
+ async (span) => {
1528
+ const trigger = await pollForTriggerOnce(opts);
1529
+ span.setAttribute("agentswarm.poll.result", trigger ? trigger.type : "empty");
1530
+ return trigger;
1531
+ },
1532
+ {
1533
+ "agent.id": opts.agentId,
1534
+ "agentswarm.worker.poll_timeout_ms": opts.pollTimeout,
1535
+ "agentswarm.worker.poll_interval_ms": opts.pollInterval,
1536
+ },
1537
+ );
1538
+ }
1539
+
1540
+ async function pollForTriggerOnce(opts: PollOptions): Promise<Trigger | null> {
1506
1541
  const startTime = Date.now();
1507
1542
  const headers: Record<string, string> = {
1508
1543
  "X-Agent-ID": opts.agentId,
@@ -1510,6 +1545,7 @@ async function pollForTrigger(opts: PollOptions): Promise<Trigger | null> {
1510
1545
  if (opts.apiKey) {
1511
1546
  headers.Authorization = `Bearer ${opts.apiKey}`;
1512
1547
  }
1548
+ injectTraceContext(headers);
1513
1549
 
1514
1550
  while (Date.now() - startTime < opts.pollTimeout) {
1515
1551
  try {
@@ -1739,6 +1775,198 @@ function extractToolKey(toolName: string, args: unknown): Record<string, string
1739
1775
  }
1740
1776
  }
1741
1777
 
1778
+ const OTEL_PREVIEW_LIMIT = 500;
1779
+
1780
+ function telemetryPreview(value: unknown): string | undefined {
1781
+ if (value === undefined) return undefined;
1782
+ try {
1783
+ const serialized = typeof value === "string" ? value : JSON.stringify(value);
1784
+ if (!serialized) return undefined;
1785
+ const scrubbed = scrubSecrets(serialized);
1786
+ return scrubbed.length > OTEL_PREVIEW_LIMIT
1787
+ ? `${scrubbed.slice(0, OTEL_PREVIEW_LIMIT)}...`
1788
+ : scrubbed;
1789
+ } catch {
1790
+ return "[unserializable]";
1791
+ }
1792
+ }
1793
+
1794
+ type ToolTelemetry = {
1795
+ kind: "mcp" | "harness" | "skill" | "agent" | "shell" | "file" | "unknown";
1796
+ name: string;
1797
+ normalizedName: string;
1798
+ mcpServer?: string;
1799
+ mcpTool?: string;
1800
+ };
1801
+
1802
+ function classifyTool(toolName: string, args: unknown): ToolTelemetry {
1803
+ const argRecord = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
1804
+
1805
+ if (toolName.startsWith("mcp__")) {
1806
+ const [, server, ...toolParts] = toolName.split("__");
1807
+ const mcpTool = toolParts.join("__") || undefined;
1808
+ return {
1809
+ kind: "mcp",
1810
+ name: toolName,
1811
+ normalizedName: server && mcpTool ? `${server}.${mcpTool}` : toolName,
1812
+ mcpServer: server,
1813
+ mcpTool,
1814
+ };
1815
+ }
1816
+
1817
+ if (typeof argRecord.server === "string" && typeof argRecord.tool === "string") {
1818
+ return {
1819
+ kind: "mcp",
1820
+ name: toolName,
1821
+ normalizedName: `${argRecord.server}.${argRecord.tool}`,
1822
+ mcpServer: argRecord.server,
1823
+ mcpTool: argRecord.tool,
1824
+ };
1825
+ }
1826
+
1827
+ if (toolName.includes(":")) {
1828
+ const [server, ...toolParts] = toolName.split(":");
1829
+ const mcpTool = toolParts.join(":") || undefined;
1830
+ return {
1831
+ kind: "mcp",
1832
+ name: toolName,
1833
+ normalizedName: server && mcpTool ? `${server}.${mcpTool}` : toolName,
1834
+ mcpServer: server,
1835
+ mcpTool,
1836
+ };
1837
+ }
1838
+
1839
+ switch (toolName) {
1840
+ case "Bash":
1841
+ case "bash":
1842
+ case "command_execution":
1843
+ return { kind: "shell", name: toolName, normalizedName: toolName };
1844
+ case "Read":
1845
+ case "Edit":
1846
+ case "Write":
1847
+ case "Delete":
1848
+ case "Grep":
1849
+ case "Glob":
1850
+ case "file_change":
1851
+ return { kind: "file", name: toolName, normalizedName: toolName };
1852
+ case "Skill":
1853
+ return { kind: "skill", name: toolName, normalizedName: toolName };
1854
+ case "Agent":
1855
+ return { kind: "agent", name: toolName, normalizedName: toolName };
1856
+ default:
1857
+ return { kind: "harness", name: toolName, normalizedName: toolName };
1858
+ }
1859
+ }
1860
+
1861
+ function providerEventAttributes(event: ProviderEvent): Attributes {
1862
+ switch (event.type) {
1863
+ case "session_init":
1864
+ return {
1865
+ "agentswarm.provider.session_id": event.sessionId,
1866
+ "agentswarm.provider.name": event.provider,
1867
+ "agentswarm.provider.meta_preview": telemetryPreview(event.providerMeta),
1868
+ };
1869
+ case "message":
1870
+ return {
1871
+ "gen_ai.message.role": event.role,
1872
+ "gen_ai.message.content_preview": telemetryPreview(event.content),
1873
+ };
1874
+ case "tool_start":
1875
+ case "tool_end": {
1876
+ const tool = classifyTool(
1877
+ event.toolName,
1878
+ event.type === "tool_start" ? event.args : undefined,
1879
+ );
1880
+ return {
1881
+ "agentswarm.tool.name": tool.name,
1882
+ "agentswarm.tool.normalized_name": tool.normalizedName,
1883
+ "agentswarm.tool.kind": tool.kind,
1884
+ "agentswarm.tool.call_id": event.toolCallId,
1885
+ "mcp.server.name": tool.mcpServer,
1886
+ "mcp.tool.name": tool.mcpTool,
1887
+ };
1888
+ }
1889
+ case "result":
1890
+ return {
1891
+ "agentswarm.session.outcome": event.isError ? "error" : "ok",
1892
+ "agentswarm.error.category": event.errorCategory,
1893
+ "gen_ai.response.model": event.cost.model,
1894
+ "gen_ai.usage.input_tokens": event.cost.inputTokens ?? 0,
1895
+ "gen_ai.usage.output_tokens": event.cost.outputTokens ?? 0,
1896
+ "agentswarm.cost.total_usd": event.cost.totalCostUsd ?? 0,
1897
+ };
1898
+ case "error":
1899
+ return {
1900
+ "agentswarm.error.category": event.category,
1901
+ "exception.message": telemetryPreview(event.message),
1902
+ };
1903
+ case "progress":
1904
+ return { "agentswarm.progress.message": telemetryPreview(event.message) };
1905
+ case "context_usage":
1906
+ return {
1907
+ "agentswarm.context.used_tokens": event.contextUsedTokens ?? undefined,
1908
+ "agentswarm.context.total_tokens": event.contextTotalTokens ?? undefined,
1909
+ "agentswarm.context.percent": event.contextPercent ?? undefined,
1910
+ "gen_ai.usage.output_tokens": event.outputTokens ?? undefined,
1911
+ };
1912
+ case "compaction":
1913
+ return {
1914
+ "agentswarm.compaction.trigger": event.compactTrigger,
1915
+ "agentswarm.compaction.pre_tokens": event.preCompactTokens,
1916
+ "agentswarm.context.total_tokens": event.contextTotalTokens,
1917
+ };
1918
+ case "custom":
1919
+ return {
1920
+ "agentswarm.provider.event_name": event.name,
1921
+ "agentswarm.provider.event_data_preview": telemetryPreview(event.data),
1922
+ };
1923
+ case "raw_log":
1924
+ case "raw_stderr":
1925
+ return {};
1926
+ }
1927
+ }
1928
+
1929
+ /**
1930
+ * Entry shape for the `activeToolSpans` map maintained by `runWithSession`.
1931
+ * Exported for unit tests that exercise `implicitCloseActiveToolSpans`.
1932
+ */
1933
+ export type ActiveToolSpanEntry = {
1934
+ span: SwarmSpan;
1935
+ startedAt: number;
1936
+ };
1937
+
1938
+ /**
1939
+ * Closes any still-open tool spans (both `worker.tool` and `worker.mcp.tool`)
1940
+ * in `activeToolSpans` at the assistant-message boundary, tagging them with
1941
+ * `agentswarm.tool.implicit_close=true` and removing them from the map.
1942
+ *
1943
+ * The Claude SDK adapter doesn't emit per-tool completion events for any
1944
+ * tool kind, MCP or harness-side — so the assistant-message boundary serves
1945
+ * as an implicit `tool_end` for everything. Spans closed here are still
1946
+ * successful (code 1, OK); the boundary is just a signal that the prior
1947
+ * tool turn is done.
1948
+ *
1949
+ * Returns the number of spans closed (handy for tests / metrics).
1950
+ */
1951
+ export function implicitCloseActiveToolSpans(
1952
+ activeToolSpans: Map<string, ActiveToolSpanEntry>,
1953
+ now: number = Date.now(),
1954
+ ): number {
1955
+ let closed = 0;
1956
+ for (const [toolCallId, active] of activeToolSpans) {
1957
+ active.span.setAttributes({
1958
+ "agentswarm.tool.duration_ms": now - active.startedAt,
1959
+ "agentswarm.tool.implicit_close": true,
1960
+ "agentswarm.tool.call_id": toolCallId,
1961
+ });
1962
+ active.span.setStatus({ code: 1 });
1963
+ active.span.end();
1964
+ activeToolSpans.delete(toolCallId);
1965
+ closed++;
1966
+ }
1967
+ return closed;
1968
+ }
1969
+
1742
1970
  async function spawnProviderProcess(
1743
1971
  adapter: ReturnType<typeof createProviderAdapter>,
1744
1972
  opts: {
@@ -1806,7 +2034,25 @@ async function spawnProviderProcess(
1806
2034
  env: freshEnv as Record<string, string>,
1807
2035
  };
1808
2036
 
1809
- const session = await adapter.createSession(config);
2037
+ const session = await withSpan(
2038
+ "worker.session.create",
2039
+ async (span) => {
2040
+ const createdSession = await adapter.createSession(config);
2041
+ span.setAttribute("agentswarm.provider.session_id", createdSession.sessionId || "pending");
2042
+ return createdSession;
2043
+ },
2044
+ {
2045
+ "agent.id": opts.agentId,
2046
+ "agentswarm.task.id": effectiveTaskId,
2047
+ "agentswarm.task.real_id": realTaskId,
2048
+ "agentswarm.agent.role": opts.role,
2049
+ "agentswarm.harness_provider": opts.harnessProvider,
2050
+ "gen_ai.request.model": model || undefined,
2051
+ "agentswarm.session.cwd": config.cwd,
2052
+ "agentswarm.session.vcs_repo": opts.vcsRepo,
2053
+ "agentswarm.session.additional_args_count": opts.additionalArgs?.length ?? 0,
2054
+ },
2055
+ );
1810
2056
  const initialModelReport = buildLatestModelReport({
1811
2057
  model,
1812
2058
  taskModel: opts.model,
@@ -1884,6 +2130,25 @@ async function spawnProviderProcess(
1884
2130
 
1885
2131
  const eventFlushTimer = setInterval(flushEvents, EVENT_FLUSH_INTERVAL_MS);
1886
2132
  const sessionStartTime = Date.now();
2133
+ let providerSessionId = session.sessionId;
2134
+ const activeToolSpans = new Map<
2135
+ string,
2136
+ {
2137
+ span: SwarmSpan;
2138
+ startedAt: number;
2139
+ }
2140
+ >();
2141
+ const sessionSpan = startSpan("worker.session", {
2142
+ "agent.id": opts.agentId,
2143
+ "agentswarm.task.id": effectiveTaskId,
2144
+ "agentswarm.task.real_id": realTaskId,
2145
+ "agentswarm.agent.role": opts.role,
2146
+ "agentswarm.harness_provider": opts.harnessProvider,
2147
+ "agentswarm.provider.session_id": providerSessionId,
2148
+ "agentswarm.session.cwd": config.cwd,
2149
+ "agentswarm.session.vcs_repo": opts.vcsRepo,
2150
+ "gen_ai.request.model": model || undefined,
2151
+ });
1887
2152
 
1888
2153
  // Auto-progress throttle: don't update more than once per 3 seconds
1889
2154
  let lastProgressTime = 0;
@@ -1892,130 +2157,248 @@ async function spawnProviderProcess(
1892
2157
  let lastContextPostTime = 0;
1893
2158
  const CONTEXT_THROTTLE_MS = 30_000;
1894
2159
 
1895
- session.onEvent((event) => {
1896
- switch (event.type) {
1897
- case "session_init":
1898
- if (realTaskId) {
1899
- saveProviderSessionId(
1900
- opts.apiUrl,
1901
- opts.apiKey,
1902
- realTaskId,
1903
- event.sessionId,
1904
- event.provider,
1905
- event.providerMeta,
1906
- model,
1907
- ).catch((err) => console.warn(`[runner] Failed to save session ID: ${err}`));
1908
- } else {
1909
- // Pool task: save provider session ID on active session so it can be
1910
- // propagated to the real task when the agent claims one
1911
- saveProviderSessionIdOnActiveSession(
1912
- opts.apiUrl,
1913
- opts.apiKey,
1914
- effectiveTaskId,
1915
- event.sessionId,
1916
- ).catch((err) =>
1917
- console.warn(`[runner] Failed to save provider session on active session: ${err}`),
1918
- );
1919
- }
1920
-
1921
- // Buffer session start event
1922
- bufferEvent({
1923
- category: "session",
1924
- event: "session.start",
1925
- source: "worker",
1926
- agentId: opts.agentId,
1927
- taskId: effectiveTaskId,
1928
- sessionId: event.sessionId,
1929
- });
1930
- break;
1931
- case "tool_start": {
1932
- // Auto-progress: report tool activity as task progress (throttled)
1933
- const now = Date.now();
1934
- if (effectiveTaskId && opts.apiUrl && now - lastProgressTime >= PROGRESS_THROTTLE_MS) {
1935
- const progress = toolCallToProgress(event.toolName, event.args);
1936
- if (progress) {
1937
- lastProgressTime = now;
1938
- updateProgressViaAPI(opts.apiUrl, opts.apiKey, effectiveTaskId, progress).catch(
1939
- () => {},
2160
+ // Phase 10: accumulate per-turn output tokens across the session so progress
2161
+ // snapshots carry a real `cumulativeOutputTokens` (was 0 until completion).
2162
+ let cumulativeProgressOutputTokens = 0;
2163
+
2164
+ session.onEvent((event) =>
2165
+ withSpanContext(sessionSpan, () => {
2166
+ sessionSpan.addEvent(`provider.${event.type}`, providerEventAttributes(event));
2167
+ switch (event.type) {
2168
+ case "session_init":
2169
+ providerSessionId = event.sessionId;
2170
+ sessionSpan.setAttributes({
2171
+ "agentswarm.provider.session_id": providerSessionId,
2172
+ "agentswarm.provider.name": event.provider,
2173
+ "agentswarm.provider.meta_preview": telemetryPreview(event.providerMeta),
2174
+ });
2175
+ if (realTaskId) {
2176
+ saveProviderSessionId(
2177
+ opts.apiUrl,
2178
+ opts.apiKey,
2179
+ realTaskId,
2180
+ event.sessionId,
2181
+ event.provider,
2182
+ event.providerMeta,
2183
+ model,
2184
+ ).catch((err) => console.warn(`[runner] Failed to save session ID: ${err}`));
2185
+ } else {
2186
+ // Pool task: save provider session ID on active session so it can be
2187
+ // propagated to the real task when the agent claims one
2188
+ saveProviderSessionIdOnActiveSession(
2189
+ opts.apiUrl,
2190
+ opts.apiKey,
2191
+ effectiveTaskId,
2192
+ event.sessionId,
2193
+ ).catch((err) =>
2194
+ console.warn(`[runner] Failed to save provider session on active session: ${err}`),
1940
2195
  );
1941
2196
  }
2197
+
2198
+ // Buffer session start event
2199
+ bufferEvent({
2200
+ category: "session",
2201
+ event: "session.start",
2202
+ source: "worker",
2203
+ agentId: opts.agentId,
2204
+ taskId: effectiveTaskId,
2205
+ sessionId: event.sessionId,
2206
+ });
2207
+ break;
2208
+ case "message": {
2209
+ // Assistant-message boundary acts as an implicit tool_end for ALL
2210
+ // still-open tool spans (both `worker.tool` and `worker.mcp.tool`).
2211
+ // The Claude SDK adapter doesn't emit per-tool completion events for
2212
+ // any tool kind, so without this their spans would only close at
2213
+ // session shutdown via `closeActiveToolSpans` and report wall-clock
2214
+ // duration from tool_start to session end.
2215
+ if (event.role === "assistant") {
2216
+ implicitCloseActiveToolSpans(activeToolSpans);
2217
+ }
2218
+ break;
1942
2219
  }
2220
+ case "tool_start": {
2221
+ const tool = classifyTool(event.toolName, event.args);
2222
+ const toolSpan = startSpan(tool.kind === "mcp" ? "worker.mcp.tool" : "worker.tool", {
2223
+ "agent.id": opts.agentId,
2224
+ "agentswarm.task.id": effectiveTaskId,
2225
+ "agentswarm.task.real_id": realTaskId,
2226
+ "agentswarm.agent.role": opts.role,
2227
+ "agentswarm.harness_provider": opts.harnessProvider,
2228
+ "agentswarm.provider.session_id": providerSessionId,
2229
+ "agentswarm.tool.name": tool.name,
2230
+ "agentswarm.tool.normalized_name": tool.normalizedName,
2231
+ "agentswarm.tool.kind": tool.kind,
2232
+ "agentswarm.tool.call_id": event.toolCallId,
2233
+ "mcp.server.name": tool.mcpServer,
2234
+ "mcp.tool.name": tool.mcpTool,
2235
+ "agentswarm.tool.args_preview": telemetryPreview(event.args),
2236
+ });
2237
+ activeToolSpans.set(event.toolCallId, {
2238
+ span: toolSpan,
2239
+ startedAt: Date.now(),
2240
+ });
1943
2241
 
1944
- // Buffer tool event
1945
- bufferEvent({
1946
- category: "tool",
1947
- event: "tool.start",
1948
- source: "worker",
1949
- agentId: opts.agentId,
1950
- taskId: effectiveTaskId,
1951
- sessionId: opts.runnerSessionId,
1952
- data: {
1953
- toolName: event.toolName,
1954
- toolCallId: event.toolCallId,
1955
- ...extractToolKey(event.toolName, event.args),
1956
- clientTimestamp: new Date().toISOString(),
1957
- },
1958
- });
2242
+ // Auto-progress: report tool activity as task progress (throttled)
2243
+ const now = Date.now();
2244
+ if (effectiveTaskId && opts.apiUrl && now - lastProgressTime >= PROGRESS_THROTTLE_MS) {
2245
+ const progress = toolCallToProgress(event.toolName, event.args);
2246
+ if (progress) {
2247
+ lastProgressTime = now;
2248
+ updateProgressViaAPI(opts.apiUrl, opts.apiKey, effectiveTaskId, progress).catch(
2249
+ () => {},
2250
+ );
2251
+ }
2252
+ }
1959
2253
 
1960
- // Also emit skill event when tool is Skill
1961
- if (event.toolName === "Skill") {
1962
- const args = event.args as Record<string, unknown>;
2254
+ // Buffer tool event
1963
2255
  bufferEvent({
1964
- category: "skill",
1965
- event: "skill.invoke",
2256
+ category: "tool",
2257
+ event: "tool.start",
1966
2258
  source: "worker",
1967
2259
  agentId: opts.agentId,
1968
2260
  taskId: effectiveTaskId,
1969
2261
  sessionId: opts.runnerSessionId,
1970
2262
  data: {
1971
- skillName: args.skill as string,
2263
+ toolName: event.toolName,
2264
+ toolCallId: event.toolCallId,
2265
+ ...extractToolKey(event.toolName, event.args),
1972
2266
  clientTimestamp: new Date().toISOString(),
1973
2267
  },
1974
2268
  });
2269
+
2270
+ // Also emit skill event when tool is Skill
2271
+ if (event.toolName === "Skill") {
2272
+ const args = event.args as Record<string, unknown>;
2273
+ bufferEvent({
2274
+ category: "skill",
2275
+ event: "skill.invoke",
2276
+ source: "worker",
2277
+ agentId: opts.agentId,
2278
+ taskId: effectiveTaskId,
2279
+ sessionId: opts.runnerSessionId,
2280
+ data: {
2281
+ skillName: args.skill as string,
2282
+ clientTimestamp: new Date().toISOString(),
2283
+ },
2284
+ });
2285
+ }
2286
+ break;
1975
2287
  }
1976
- break;
1977
- }
1978
- case "result":
1979
- {
1980
- const latestModel = buildLatestModelReport({
1981
- model: event.cost.model,
1982
- taskModel: opts.model,
1983
- configModel,
1984
- taskId: realTaskId,
1985
- harnessProvider: opts.harnessProvider,
2288
+ case "tool_end": {
2289
+ const active = activeToolSpans.get(event.toolCallId);
2290
+ const now = Date.now();
2291
+ if (active) {
2292
+ active.span.setAttributes({
2293
+ "agentswarm.tool.duration_ms": now - active.startedAt,
2294
+ "agentswarm.tool.result_preview": telemetryPreview(event.result),
2295
+ });
2296
+ active.span.setStatus({ code: 1 });
2297
+ active.span.end();
2298
+ activeToolSpans.delete(event.toolCallId);
2299
+ } else {
2300
+ const tool = classifyTool(event.toolName, undefined);
2301
+ const span = startSpan(tool.kind === "mcp" ? "worker.mcp.tool" : "worker.tool", {
2302
+ "agent.id": opts.agentId,
2303
+ "agentswarm.task.id": effectiveTaskId,
2304
+ "agentswarm.task.real_id": realTaskId,
2305
+ "agentswarm.agent.role": opts.role,
2306
+ "agentswarm.harness_provider": opts.harnessProvider,
2307
+ "agentswarm.provider.session_id": providerSessionId,
2308
+ "agentswarm.tool.name": tool.name,
2309
+ "agentswarm.tool.normalized_name": tool.normalizedName,
2310
+ "agentswarm.tool.kind": tool.kind,
2311
+ "agentswarm.tool.call_id": event.toolCallId,
2312
+ "mcp.server.name": tool.mcpServer,
2313
+ "mcp.tool.name": tool.mcpTool,
2314
+ "agentswarm.tool.result_preview": telemetryPreview(event.result),
2315
+ "agentswarm.tool.missing_start": true,
2316
+ });
2317
+ span.end();
2318
+ }
2319
+ break;
2320
+ }
2321
+ case "result":
2322
+ {
2323
+ const latestModel = buildLatestModelReport({
2324
+ model: event.cost.model,
2325
+ taskModel: opts.model,
2326
+ configModel,
2327
+ taskId: realTaskId,
2328
+ harnessProvider: opts.harnessProvider,
2329
+ });
2330
+ if (latestModel) {
2331
+ reportLatestModel(opts.apiUrl, opts.apiKey, opts.agentId, latestModel).catch((err) =>
2332
+ console.warn(`[runner] Failed to report latest model: ${err}`),
2333
+ );
2334
+ }
2335
+ }
2336
+ // Cost save is handled in waitForCompletion().then() to ensure
2337
+ // it completes before the process exits (fire-and-forget here
2338
+ // races with container shutdown).
2339
+
2340
+ // Buffer session end event
2341
+ bufferEvent({
2342
+ category: "session",
2343
+ event: "session.end",
2344
+ source: "worker",
2345
+ agentId: opts.agentId,
2346
+ taskId: effectiveTaskId,
2347
+ sessionId: opts.runnerSessionId,
2348
+ status: event.isError ? "error" : "ok",
2349
+ durationMs: Date.now() - sessionStartTime,
2350
+ data: {
2351
+ model: event.cost.model,
2352
+ totalCostUsd: event.cost.totalCostUsd,
2353
+ inputTokens: event.cost.inputTokens,
2354
+ outputTokens: event.cost.outputTokens,
2355
+ },
1986
2356
  });
1987
- if (latestModel) {
1988
- reportLatestModel(opts.apiUrl, opts.apiKey, opts.agentId, latestModel).catch((err) =>
1989
- console.warn(`[runner] Failed to report latest model: ${err}`),
1990
- );
2357
+ break;
2358
+ case "error":
2359
+ sessionSpan.setStatus({
2360
+ code: 2,
2361
+ message: event.message,
2362
+ });
2363
+ break;
2364
+ case "context_usage": {
2365
+ const now2 = Date.now();
2366
+ if (now2 - lastContextPostTime >= CONTEXT_THROTTLE_MS) {
2367
+ lastContextPostTime = now2;
2368
+ // Phase 10: track cumulative output tokens on the worker side and
2369
+ // forward them on every progress snapshot. Previously these were
2370
+ // 0 until the `completion` snapshot at session end, so the
2371
+ // dashboard's "tokens consumed" line was a flat zero throughout.
2372
+ cumulativeProgressOutputTokens += event.outputTokens ?? 0;
2373
+ // For inputs we don't get a per-turn delta on the `context_usage`
2374
+ // event (the unified formula bakes it into contextUsedTokens), so
2375
+ // we report the latest contextUsedTokens as the running input proxy.
2376
+ // The DB column is `cumulativeInputTokens` but the semantic on
2377
+ // progress rows is "running used-tokens" — both inputs and outputs
2378
+ // contribute, exact decomposition lives on the cost row.
2379
+ fetch(`${opts.apiUrl}/api/tasks/${realTaskId}/context`, {
2380
+ method: "POST",
2381
+ headers: {
2382
+ "Content-Type": "application/json",
2383
+ "X-Agent-ID": opts.agentId,
2384
+ Authorization: `Bearer ${opts.apiKey}`,
2385
+ },
2386
+ body: JSON.stringify({
2387
+ eventType: "progress",
2388
+ sessionId: opts.runnerSessionId,
2389
+ contextUsedTokens: event.contextUsedTokens,
2390
+ contextTotalTokens: event.contextTotalTokens,
2391
+ contextPercent: event.contextPercent,
2392
+ cumulativeInputTokens: event.contextUsedTokens,
2393
+ cumulativeOutputTokens: cumulativeProgressOutputTokens,
2394
+ contextFormula: event.contextFormula,
2395
+ }),
2396
+ }).catch(() => {});
1991
2397
  }
2398
+ break;
1992
2399
  }
1993
- // Cost save is handled in waitForCompletion().then() to ensure
1994
- // it completes before the process exits (fire-and-forget here
1995
- // races with container shutdown).
1996
-
1997
- // Buffer session end event
1998
- bufferEvent({
1999
- category: "session",
2000
- event: "session.end",
2001
- source: "worker",
2002
- agentId: opts.agentId,
2003
- taskId: effectiveTaskId,
2004
- sessionId: opts.runnerSessionId,
2005
- status: event.isError ? "error" : "ok",
2006
- durationMs: Date.now() - sessionStartTime,
2007
- data: {
2008
- model: event.cost.model,
2009
- totalCostUsd: event.cost.totalCostUsd,
2010
- inputTokens: event.cost.inputTokens,
2011
- outputTokens: event.cost.outputTokens,
2012
- },
2013
- });
2014
- break;
2015
- case "context_usage": {
2016
- const now2 = Date.now();
2017
- if (now2 - lastContextPostTime >= CONTEXT_THROTTLE_MS) {
2018
- lastContextPostTime = now2;
2400
+ case "compaction": {
2401
+ // Always record compaction events (no throttle)
2019
2402
  fetch(`${opts.apiUrl}/api/tasks/${realTaskId}/context`, {
2020
2403
  method: "POST",
2021
2404
  headers: {
@@ -2024,153 +2407,186 @@ async function spawnProviderProcess(
2024
2407
  Authorization: `Bearer ${opts.apiKey}`,
2025
2408
  },
2026
2409
  body: JSON.stringify({
2027
- eventType: "progress",
2410
+ eventType: "compaction",
2028
2411
  sessionId: opts.runnerSessionId,
2029
- contextUsedTokens: event.contextUsedTokens,
2412
+ preCompactTokens: event.preCompactTokens,
2413
+ compactTrigger: event.compactTrigger,
2030
2414
  contextTotalTokens: event.contextTotalTokens,
2031
- contextPercent: event.contextPercent,
2032
2415
  }),
2033
2416
  }).catch(() => {});
2417
+ break;
2034
2418
  }
2035
- break;
2036
- }
2037
- case "compaction": {
2038
- // Always record compaction events (no throttle)
2039
- fetch(`${opts.apiUrl}/api/tasks/${realTaskId}/context`, {
2040
- method: "POST",
2041
- headers: {
2042
- "Content-Type": "application/json",
2043
- "X-Agent-ID": opts.agentId,
2044
- Authorization: `Bearer ${opts.apiKey}`,
2045
- },
2046
- body: JSON.stringify({
2047
- eventType: "compaction",
2048
- sessionId: opts.runnerSessionId,
2049
- preCompactTokens: event.preCompactTokens,
2050
- compactTrigger: event.compactTrigger,
2051
- contextTotalTokens: event.contextTotalTokens,
2052
- }),
2053
- }).catch(() => {});
2054
- break;
2055
- }
2056
- case "raw_log":
2057
- prettyPrintLine(event.content, opts.role);
2058
- if (shouldStream) {
2059
- logBuffer.lines.push(event.content);
2060
- const shouldFlush =
2061
- logBuffer.lines.length >= LOG_BUFFER_SIZE ||
2062
- Date.now() - logBuffer.lastFlush >= LOG_FLUSH_INTERVAL_MS;
2063
- if (shouldFlush) {
2064
- flushLogBuffer(logBuffer, {
2065
- apiUrl: opts.apiUrl,
2066
- apiKey: opts.apiKey,
2067
- agentId: opts.agentId,
2068
- sessionId: opts.runnerSessionId,
2069
- iteration: opts.iteration,
2070
- taskId: effectiveTaskId,
2071
- cli: adapter.name,
2072
- }).catch(() => {});
2419
+ case "raw_log":
2420
+ prettyPrintLine(event.content, opts.role);
2421
+ if (shouldStream) {
2422
+ logBuffer.lines.push(event.content);
2423
+ const shouldFlush =
2424
+ logBuffer.lines.length >= LOG_BUFFER_SIZE ||
2425
+ Date.now() - logBuffer.lastFlush >= LOG_FLUSH_INTERVAL_MS;
2426
+ if (shouldFlush) {
2427
+ flushLogBuffer(logBuffer, {
2428
+ apiUrl: opts.apiUrl,
2429
+ apiKey: opts.apiKey,
2430
+ agentId: opts.agentId,
2431
+ sessionId: opts.runnerSessionId,
2432
+ iteration: opts.iteration,
2433
+ taskId: effectiveTaskId,
2434
+ cli: adapter.name,
2435
+ }).catch(() => {});
2436
+ }
2073
2437
  }
2074
- }
2075
- break;
2076
- case "raw_stderr":
2077
- prettyPrintStderr(event.content, opts.role);
2078
- break;
2079
-
2080
- case "progress": {
2081
- if (effectiveTaskId && opts.apiUrl) {
2082
- const now = Date.now();
2083
- if (now - lastProgressTime >= PROGRESS_THROTTLE_MS) {
2084
- lastProgressTime = now;
2085
- updateProgressViaAPI(opts.apiUrl, opts.apiKey, effectiveTaskId, event.message).catch(
2086
- () => {},
2087
- );
2438
+ break;
2439
+ case "raw_stderr":
2440
+ prettyPrintStderr(event.content, opts.role);
2441
+ break;
2442
+
2443
+ case "progress": {
2444
+ if (effectiveTaskId && opts.apiUrl) {
2445
+ const now = Date.now();
2446
+ if (now - lastProgressTime >= PROGRESS_THROTTLE_MS) {
2447
+ lastProgressTime = now;
2448
+ updateProgressViaAPI(opts.apiUrl, opts.apiKey, effectiveTaskId, event.message).catch(
2449
+ () => {},
2450
+ );
2451
+ }
2088
2452
  }
2453
+ break;
2089
2454
  }
2090
- break;
2091
2455
  }
2092
- }
2093
- });
2456
+ }),
2457
+ );
2094
2458
 
2095
- // Create promise that handles completion
2096
- const promise: Promise<ProviderResult> = session.waitForCompletion().then(async (result) => {
2097
- // Stop event flush timer and do a final flush
2098
- clearInterval(eventFlushTimer);
2099
- await flushEvents();
2100
-
2101
- // Final log flush
2102
- if (shouldStream && logBuffer.lines.length > 0) {
2103
- await flushLogBuffer(logBuffer, {
2104
- apiUrl: opts.apiUrl,
2105
- apiKey: opts.apiKey,
2106
- agentId: opts.agentId,
2107
- sessionId: opts.runnerSessionId,
2108
- iteration: opts.iteration,
2109
- taskId: effectiveTaskId,
2110
- cli: adapter.name,
2459
+ function closeActiveToolSpans(status: "ok" | "error", message?: string) {
2460
+ for (const [toolCallId, active] of activeToolSpans) {
2461
+ active.span.setAttributes({
2462
+ "agentswarm.tool.duration_ms": Date.now() - active.startedAt,
2463
+ "agentswarm.tool.unclosed": true,
2464
+ "agentswarm.tool.call_id": toolCallId,
2111
2465
  });
2466
+ if (status === "error") {
2467
+ active.span.setStatus({ code: 2, message: message || "session ended before tool_end" });
2468
+ }
2469
+ active.span.end();
2470
+ activeToolSpans.delete(toolCallId);
2112
2471
  }
2472
+ }
2113
2473
 
2114
- // Error logging for non-zero exit
2115
- if (result.exitCode !== 0) {
2116
- const errorLog = {
2117
- timestamp: new Date().toISOString(),
2118
- iteration: opts.iteration,
2119
- exitCode: result.exitCode,
2120
- taskId: effectiveTaskId,
2121
- error: true,
2122
- };
2474
+ // Create promise that handles completion
2475
+ const promise: Promise<ProviderResult> = session
2476
+ .waitForCompletion()
2477
+ .then((result) =>
2478
+ withSpanContext(sessionSpan, async () => {
2479
+ // Stop event flush timer and do a final flush
2480
+ clearInterval(eventFlushTimer);
2481
+ await flushEvents();
2482
+
2483
+ // Final log flush
2484
+ if (shouldStream && logBuffer.lines.length > 0) {
2485
+ await flushLogBuffer(logBuffer, {
2486
+ apiUrl: opts.apiUrl,
2487
+ apiKey: opts.apiKey,
2488
+ agentId: opts.agentId,
2489
+ sessionId: opts.runnerSessionId,
2490
+ iteration: opts.iteration,
2491
+ taskId: effectiveTaskId,
2492
+ cli: adapter.name,
2493
+ });
2494
+ }
2123
2495
 
2124
- const errorsFile = `${logDir}/errors.jsonl`;
2125
- const errorsFileRef = Bun.file(errorsFile);
2126
- const existingErrors = (await errorsFileRef.exists()) ? await errorsFileRef.text() : "";
2127
- await Bun.write(errorsFile, `${existingErrors}${JSON.stringify(errorLog)}\n`);
2496
+ // Error logging for non-zero exit
2497
+ if (result.exitCode !== 0) {
2498
+ const errorLog = {
2499
+ timestamp: new Date().toISOString(),
2500
+ iteration: opts.iteration,
2501
+ exitCode: result.exitCode,
2502
+ taskId: effectiveTaskId,
2503
+ error: true,
2504
+ };
2128
2505
 
2129
- if (!isYolo) {
2130
- console.error(
2131
- `[${opts.role}] Task ${effectiveTaskId.slice(0, 8)} exited with code ${result.exitCode}.`,
2132
- );
2133
- } else {
2134
- console.warn(
2135
- `[${opts.role}] Task ${effectiveTaskId.slice(0, 8)} exited with code ${result.exitCode}. YOLO mode - continuing...`,
2136
- );
2137
- }
2138
- }
2506
+ const errorsFile = `${logDir}/errors.jsonl`;
2507
+ const errorsFileRef = Bun.file(errorsFile);
2508
+ const existingErrors = (await errorsFileRef.exists()) ? await errorsFileRef.text() : "";
2509
+ await Bun.write(errorsFile, `${existingErrors}${JSON.stringify(errorLog)}\n`);
2139
2510
 
2140
- // Save cost data (awaited to ensure it completes before container exits)
2141
- if (result.cost) {
2142
- try {
2143
- await saveCostData(
2144
- { ...result.cost, taskId: realTaskId, sessionId: opts.runnerSessionId },
2145
- opts.apiUrl,
2146
- opts.apiKey,
2147
- );
2148
- } catch (err) {
2149
- console.warn(`[runner] Failed to save cost: ${err}`);
2150
- }
2151
- }
2511
+ if (!isYolo) {
2512
+ console.error(
2513
+ `[${opts.role}] Task ${effectiveTaskId.slice(0, 8)} exited with code ${result.exitCode}.`,
2514
+ );
2515
+ } else {
2516
+ console.warn(
2517
+ `[${opts.role}] Task ${effectiveTaskId.slice(0, 8)} exited with code ${result.exitCode}. YOLO mode - continuing...`,
2518
+ );
2519
+ }
2520
+ }
2152
2521
 
2153
- // Post completion context usage snapshot
2154
- if (result.cost && realTaskId) {
2155
- fetch(`${opts.apiUrl}/api/tasks/${realTaskId}/context`, {
2156
- method: "POST",
2157
- headers: {
2158
- "Content-Type": "application/json",
2159
- "X-Agent-ID": opts.agentId,
2160
- Authorization: `Bearer ${opts.apiKey}`,
2161
- },
2162
- body: JSON.stringify({
2163
- eventType: "completion",
2164
- sessionId: opts.runnerSessionId,
2165
- cumulativeInputTokens: result.cost.inputTokens ?? 0,
2166
- cumulativeOutputTokens: result.cost.outputTokens ?? 0,
2167
- contextTotalTokens: getContextWindowSize(result.cost.model || "default"),
2168
- }),
2169
- }).catch(() => {});
2170
- }
2522
+ // Save cost data (awaited to ensure it completes before container exits)
2523
+ if (result.cost) {
2524
+ sessionSpan.setAttributes({
2525
+ "gen_ai.response.model": result.cost.model,
2526
+ "gen_ai.usage.input_tokens": result.cost.inputTokens ?? 0,
2527
+ "gen_ai.usage.output_tokens": result.cost.outputTokens ?? 0,
2528
+ "agentswarm.cost.total_usd": result.cost.totalCostUsd ?? 0,
2529
+ });
2530
+ try {
2531
+ await saveCostData(
2532
+ { ...result.cost, taskId: realTaskId, sessionId: opts.runnerSessionId },
2533
+ opts.apiUrl,
2534
+ opts.apiKey,
2535
+ );
2536
+ } catch (err) {
2537
+ console.warn(`[runner] Failed to save cost: ${err}`);
2538
+ }
2539
+ }
2171
2540
 
2172
- return result;
2173
- });
2541
+ // Post completion context usage snapshot
2542
+ if (result.cost && realTaskId) {
2543
+ fetch(`${opts.apiUrl}/api/tasks/${realTaskId}/context`, {
2544
+ method: "POST",
2545
+ headers: {
2546
+ "Content-Type": "application/json",
2547
+ "X-Agent-ID": opts.agentId,
2548
+ Authorization: `Bearer ${opts.apiKey}`,
2549
+ },
2550
+ body: JSON.stringify({
2551
+ eventType: "completion",
2552
+ sessionId: opts.runnerSessionId,
2553
+ cumulativeInputTokens: result.cost.inputTokens ?? 0,
2554
+ cumulativeOutputTokens: result.cost.outputTokens ?? 0,
2555
+ contextTotalTokens: getContextWindowSize(result.cost.model || "default"),
2556
+ }),
2557
+ }).catch(() => {});
2558
+ }
2559
+
2560
+ sessionSpan.setAttributes({
2561
+ "agentswarm.session.duration_ms": Date.now() - sessionStartTime,
2562
+ "agentswarm.session.exit_code": result.exitCode,
2563
+ "agentswarm.session.outcome": result.exitCode === 0 ? "ok" : "error",
2564
+ });
2565
+ if (result.exitCode !== 0) {
2566
+ sessionSpan.setStatus({
2567
+ code: 2,
2568
+ message: result.failureReason || `Provider exited with ${result.exitCode}`,
2569
+ });
2570
+ }
2571
+ closeActiveToolSpans(result.exitCode === 0 ? "ok" : "error", result.failureReason);
2572
+ sessionSpan.end();
2573
+
2574
+ return result;
2575
+ }),
2576
+ )
2577
+ .catch((error) =>
2578
+ withSpanContext(sessionSpan, () => {
2579
+ clearInterval(eventFlushTimer);
2580
+ sessionSpan.recordException(error);
2581
+ sessionSpan.setStatus({
2582
+ code: 2,
2583
+ message: error instanceof Error ? error.message : String(error),
2584
+ });
2585
+ closeActiveToolSpans("error", error instanceof Error ? error.message : String(error));
2586
+ sessionSpan.end();
2587
+ throw error;
2588
+ }),
2589
+ );
2174
2590
 
2175
2591
  // Build credential info for rate limit tracking
2176
2592
  const primarySelection = credentialSelections[0] ?? oauthSelection;
@@ -2402,6 +2818,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2402
2818
 
2403
2819
  // Initialize Business-Use SDK for worker-side instrumentation
2404
2820
  initialize();
2821
+ await initOtel(role);
2405
2822
 
2406
2823
  const sessionId = process.env.SESSION_ID || crypto.randomUUID().slice(0, 8);
2407
2824
  const baseLogDir = opts.logsDir || process.env.LOG_DIR || "/logs";
@@ -2417,7 +2834,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2417
2834
 
2418
2835
  const apiUrl = process.env.MCP_BASE_URL || `http://localhost:${process.env.PORT || "3013"}`;
2419
2836
  const swarmUrl = process.env.SWARM_URL || "localhost";
2420
- const apiKey = process.env.API_KEY || "";
2837
+ const apiKey = getApiKey();
2421
2838
 
2422
2839
  // Resolve the boot harness provider from swarm_config (repo > agent > global,
2423
2840
  // overlaid on top of `process.env`). This is what selects the adapter for
@@ -2443,8 +2860,8 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2443
2860
  let adapter = createProviderAdapter(bootProvider);
2444
2861
 
2445
2862
  // Configure HTTP-based template resolution (workers resolve via API, not local DB)
2446
- if (process.env.API_KEY) {
2447
- configureHttpResolver(apiUrl, process.env.API_KEY);
2863
+ if (apiKey) {
2864
+ configureHttpResolver(apiUrl, apiKey);
2448
2865
  }
2449
2866
 
2450
2867
  // Initialize anonymized telemetry (opt-out via ANONYMIZED_TELEMETRY=false).
@@ -2455,7 +2872,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2455
2872
  // skips telemetry instead of minting a fresh `install_<hex>` ID per
2456
2873
  // restart, which floods prod metrics with phantom installs.
2457
2874
  {
2458
- const telemetryApiKey = process.env.API_KEY;
2875
+ const telemetryApiKey = apiKey || undefined;
2459
2876
  await initTelemetry(
2460
2877
  "worker",
2461
2878
  async (key) => {