@desplega.ai/agent-swarm 1.52.1 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/openapi.json +1517 -488
  2. package/package.json +5 -2
  3. package/src/be/db.ts +530 -0
  4. package/src/be/events.ts +322 -0
  5. package/src/be/migrations/021_events.sql +24 -0
  6. package/src/be/migrations/022_context_usage.sql +34 -0
  7. package/src/be/migrations/023_mcp_servers.sql +44 -0
  8. package/src/commands/runner.ts +348 -1
  9. package/src/http/context.ts +118 -0
  10. package/src/http/events.ts +188 -0
  11. package/src/http/index.ts +6 -0
  12. package/src/http/mcp-servers.ts +364 -0
  13. package/src/http/tasks.ts +33 -0
  14. package/src/linear/outbound.ts +8 -1
  15. package/src/linear/sync.ts +3 -0
  16. package/src/oauth/ensure-token.ts +50 -0
  17. package/src/prompts/base-prompt.ts +7 -0
  18. package/src/providers/claude-adapter.ts +156 -15
  19. package/src/providers/pi-mono-adapter.ts +68 -0
  20. package/src/providers/pi-mono-extension.ts +56 -2
  21. package/src/providers/pi-mono-mcp-client.ts +10 -1
  22. package/src/providers/types.ts +14 -1
  23. package/src/server.ts +19 -0
  24. package/src/tests/context-window.test.ts +66 -0
  25. package/src/tests/ensure-token.test.ts +170 -0
  26. package/src/tests/events-db.test.ts +314 -0
  27. package/src/tests/events-http.test.ts +267 -0
  28. package/src/tests/prompt-template-remaining.test.ts +5 -5
  29. package/src/tests/tool-annotations.test.ts +2 -2
  30. package/src/tests/vcs-tracking.test.ts +176 -0
  31. package/src/tests/workflow-executors.test.ts +8 -1
  32. package/src/tools/mcp-servers/index.ts +7 -0
  33. package/src/tools/mcp-servers/mcp-server-create.ts +138 -0
  34. package/src/tools/mcp-servers/mcp-server-delete.ts +72 -0
  35. package/src/tools/mcp-servers/mcp-server-get.ts +80 -0
  36. package/src/tools/mcp-servers/mcp-server-install.ts +110 -0
  37. package/src/tools/mcp-servers/mcp-server-list.ts +67 -0
  38. package/src/tools/mcp-servers/mcp-server-uninstall.ts +71 -0
  39. package/src/tools/mcp-servers/mcp-server-update.ts +120 -0
  40. package/src/tools/tool-config.ts +9 -0
  41. package/src/types.ts +153 -0
  42. package/src/utils/context-window.ts +41 -0
  43. package/src/workflows/executors/base.ts +9 -1
@@ -17,6 +17,7 @@ import {
17
17
  type ProviderSession,
18
18
  type ProviderSessionConfig,
19
19
  } from "../providers/index.ts";
20
+ import { getContextWindowSize } from "../utils/context-window.ts";
20
21
  import { resolveCredentialPools } from "../utils/credentials.ts";
21
22
  import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
22
23
  import { detectVcsProvider } from "../vcs/index.ts";
@@ -772,6 +773,8 @@ interface RunningTask {
772
773
  result: ProviderResult | null;
773
774
  /** Deferred cursor updates for channel_activity triggers — committed after success */
774
775
  cursorUpdates?: Array<{ channelId: string; ts: string }>;
776
+ /** Resolved working directory for VCS detection */
777
+ workingDir?: string;
775
778
  }
776
779
 
777
780
  /** Runner state for tracking concurrent tasks */
@@ -882,6 +885,90 @@ async function saveProviderSessionId(
882
885
  });
883
886
  }
884
887
 
888
+ /** Cache of tasks that already have VCS linked — prevents repeated gh pr list calls */
889
+ const vcsDetectedTasks = new Set<string>();
890
+
891
+ /** Throttle timestamps for periodic VCS checks per task */
892
+ const vcsCheckTimestamps = new Map<string, number>();
893
+ const VCS_CHECK_INTERVAL = 60_000; // 60 seconds
894
+
895
+ /**
896
+ * Detect if the task's working directory has an open PR for the current branch.
897
+ * If found, report VCS info to the API so webhook events can link back to this task.
898
+ */
899
+ async function detectVcsForTask(
900
+ apiUrl: string,
901
+ apiKey: string,
902
+ taskId: string,
903
+ workingDir: string,
904
+ ): Promise<void> {
905
+ try {
906
+ // 1. Check if inside a git repo
907
+ const isGit = await Bun.$`git -C ${workingDir} rev-parse --is-inside-work-tree`.quiet().text();
908
+ if (isGit.trim() !== "true") return;
909
+
910
+ // 2. Get current branch
911
+ const branch = (await Bun.$`git -C ${workingDir} branch --show-current`.quiet().text()).trim();
912
+ if (!branch || branch === "main" || branch === "master") return;
913
+
914
+ // 3. Get remote URL to determine provider and repo
915
+ const remoteUrl = (
916
+ await Bun.$`git -C ${workingDir} remote get-url origin`.quiet().text()
917
+ ).trim();
918
+
919
+ // 4. Detect provider and check for PR/MR
920
+ let vcsProvider: "github" | "gitlab";
921
+ let prJson: string;
922
+
923
+ if (remoteUrl.includes("github.com") || remoteUrl.includes("github")) {
924
+ vcsProvider = "github";
925
+ prJson = (
926
+ await Bun.$`gh pr list --head ${branch} --json number,url --limit 1`.quiet().text()
927
+ ).trim();
928
+ } else if (remoteUrl.includes("gitlab")) {
929
+ vcsProvider = "gitlab";
930
+ prJson = (
931
+ await Bun.$`glab mr list --source-branch ${branch} --json iid,web_url --per-page 1`
932
+ .quiet()
933
+ .text()
934
+ ).trim();
935
+ } else {
936
+ return; // Unknown provider
937
+ }
938
+
939
+ // 5. Parse result
940
+ const prs = JSON.parse(prJson);
941
+ if (!Array.isArray(prs) || prs.length === 0) return;
942
+
943
+ const pr = prs[0];
944
+ const vcsNumber = pr.number ?? pr.iid;
945
+ const vcsUrl = pr.url ?? pr.web_url;
946
+ if (!vcsNumber || !vcsUrl) return;
947
+
948
+ // 6. Extract repo from remote URL
949
+ const repoMatch = remoteUrl.match(/[:/]([^/]+\/[^/.]+?)(?:\.git)?$/);
950
+ if (!repoMatch) return;
951
+ const vcsRepo = repoMatch[1];
952
+
953
+ // 7. Report to API
954
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
955
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
956
+
957
+ await fetch(`${apiUrl}/api/tasks/${taskId}/vcs`, {
958
+ method: "PATCH",
959
+ headers,
960
+ body: JSON.stringify({ vcsProvider, vcsRepo, vcsNumber, vcsUrl }),
961
+ });
962
+
963
+ vcsDetectedTasks.add(taskId);
964
+ console.log(
965
+ `[VCS] Linked task ${taskId.slice(0, 8)} to ${vcsProvider} ${vcsRepo}#${vcsNumber}`,
966
+ );
967
+ } catch {
968
+ // Fire-and-forget — detection failure should never block task execution
969
+ }
970
+ }
971
+
885
972
  /** Save provider session ID on the active session (for pool tasks where realTaskId is unknown) */
886
973
  async function saveProviderSessionIdOnActiveSession(
887
974
  apiUrl: string,
@@ -1381,6 +1468,32 @@ async function fetchEpicTaskContext(
1381
1468
  }
1382
1469
 
1383
1470
  /** Spawn a provider session without blocking - returns immediately with tracking info */
1471
+ /**
1472
+ * Extract a key field from tool arguments for event tracking.
1473
+ * Returns a single-entry record with the most identifying arg for the tool.
1474
+ */
1475
+ function extractToolKey(toolName: string, args: unknown): Record<string, string | undefined> {
1476
+ const a = args as Record<string, unknown>;
1477
+ switch (toolName) {
1478
+ case "Read":
1479
+ case "Edit":
1480
+ case "Write":
1481
+ return { filePath: a.file_path as string | undefined };
1482
+ case "Bash":
1483
+ return { description: a.description as string | undefined };
1484
+ case "Grep":
1485
+ return { pattern: a.pattern as string | undefined };
1486
+ case "Glob":
1487
+ return { pattern: a.pattern as string | undefined };
1488
+ case "Skill":
1489
+ return { skillName: a.skill as string | undefined };
1490
+ case "Agent":
1491
+ return { description: a.description as string | undefined };
1492
+ default:
1493
+ return {};
1494
+ }
1495
+ }
1496
+
1384
1497
  async function spawnProviderProcess(
1385
1498
  adapter: ReturnType<typeof createProviderAdapter>,
1386
1499
  opts: {
@@ -1439,10 +1552,61 @@ async function spawnProviderProcess(
1439
1552
  const logBuffer: LogBuffer = { lines: [], lastFlush: Date.now(), partialLine: "" };
1440
1553
  const shouldStream = opts.apiUrl && opts.runnerSessionId && opts.iteration;
1441
1554
 
1555
+ // Event buffer (flushes to API periodically)
1556
+ interface BufferedEvent {
1557
+ category: string;
1558
+ event: string;
1559
+ status?: string;
1560
+ source: string;
1561
+ agentId?: string;
1562
+ taskId?: string;
1563
+ sessionId?: string;
1564
+ parentEventId?: string;
1565
+ numericValue?: number;
1566
+ durationMs?: number;
1567
+ data?: Record<string, unknown>;
1568
+ }
1569
+
1570
+ const eventBuffer: BufferedEvent[] = [];
1571
+ const EVENT_FLUSH_INTERVAL_MS = 5000;
1572
+ const EVENT_BUFFER_MAX = 50;
1573
+
1574
+ function bufferEvent(evt: BufferedEvent) {
1575
+ eventBuffer.push(evt);
1576
+ if (eventBuffer.length >= EVENT_BUFFER_MAX) {
1577
+ flushEvents();
1578
+ }
1579
+ }
1580
+
1581
+ async function flushEvents() {
1582
+ if (eventBuffer.length === 0) return;
1583
+ const batch = eventBuffer.splice(0);
1584
+ try {
1585
+ await fetch(`${opts.apiUrl}/api/events/batch`, {
1586
+ method: "POST",
1587
+ headers: {
1588
+ "Content-Type": "application/json",
1589
+ Authorization: `Bearer ${opts.apiKey}`,
1590
+ "X-Agent-ID": opts.agentId,
1591
+ },
1592
+ body: JSON.stringify({ events: batch }),
1593
+ });
1594
+ } catch {
1595
+ // Non-blocking — event loss is acceptable
1596
+ }
1597
+ }
1598
+
1599
+ const eventFlushTimer = setInterval(flushEvents, EVENT_FLUSH_INTERVAL_MS);
1600
+ const sessionStartTime = Date.now();
1601
+
1442
1602
  // Auto-progress throttle: don't update more than once per 3 seconds
1443
1603
  let lastProgressTime = 0;
1444
1604
  const PROGRESS_THROTTLE_MS = 3000;
1445
1605
 
1606
+ // Context usage throttle: max 1 snapshot per 30 seconds
1607
+ let lastContextPostTime = 0;
1608
+ const CONTEXT_THROTTLE_MS = 30_000;
1609
+
1446
1610
  session.onEvent((event) => {
1447
1611
  switch (event.type) {
1448
1612
  case "session_init":
@@ -1462,6 +1626,16 @@ async function spawnProviderProcess(
1462
1626
  console.warn(`[runner] Failed to save provider session on active session: ${err}`),
1463
1627
  );
1464
1628
  }
1629
+
1630
+ // Buffer session start event
1631
+ bufferEvent({
1632
+ category: "session",
1633
+ event: "session.start",
1634
+ source: "worker",
1635
+ agentId: opts.agentId,
1636
+ taskId: effectiveTaskId,
1637
+ sessionId: event.sessionId,
1638
+ });
1465
1639
  break;
1466
1640
  case "tool_start": {
1467
1641
  // Auto-progress: report tool activity as task progress (throttled)
@@ -1475,13 +1649,105 @@ async function spawnProviderProcess(
1475
1649
  );
1476
1650
  }
1477
1651
  }
1652
+
1653
+ // Buffer tool event
1654
+ bufferEvent({
1655
+ category: "tool",
1656
+ event: "tool.start",
1657
+ source: "worker",
1658
+ agentId: opts.agentId,
1659
+ taskId: effectiveTaskId,
1660
+ sessionId: opts.runnerSessionId,
1661
+ data: {
1662
+ toolName: event.toolName,
1663
+ toolCallId: event.toolCallId,
1664
+ ...extractToolKey(event.toolName, event.args),
1665
+ clientTimestamp: new Date().toISOString(),
1666
+ },
1667
+ });
1668
+
1669
+ // Also emit skill event when tool is Skill
1670
+ if (event.toolName === "Skill") {
1671
+ const args = event.args as Record<string, unknown>;
1672
+ bufferEvent({
1673
+ category: "skill",
1674
+ event: "skill.invoke",
1675
+ source: "worker",
1676
+ agentId: opts.agentId,
1677
+ taskId: effectiveTaskId,
1678
+ sessionId: opts.runnerSessionId,
1679
+ data: {
1680
+ skillName: args.skill as string,
1681
+ clientTimestamp: new Date().toISOString(),
1682
+ },
1683
+ });
1684
+ }
1478
1685
  break;
1479
1686
  }
1480
1687
  case "result":
1481
1688
  // Cost save is handled in waitForCompletion().then() to ensure
1482
1689
  // it completes before the process exits (fire-and-forget here
1483
1690
  // races with container shutdown).
1691
+
1692
+ // Buffer session end event
1693
+ bufferEvent({
1694
+ category: "session",
1695
+ event: "session.end",
1696
+ source: "worker",
1697
+ agentId: opts.agentId,
1698
+ taskId: effectiveTaskId,
1699
+ sessionId: opts.runnerSessionId,
1700
+ status: event.isError ? "error" : "ok",
1701
+ durationMs: Date.now() - sessionStartTime,
1702
+ data: {
1703
+ model: event.cost.model,
1704
+ totalCostUsd: event.cost.totalCostUsd,
1705
+ inputTokens: event.cost.inputTokens,
1706
+ outputTokens: event.cost.outputTokens,
1707
+ },
1708
+ });
1709
+ break;
1710
+ case "context_usage": {
1711
+ const now2 = Date.now();
1712
+ if (now2 - lastContextPostTime >= CONTEXT_THROTTLE_MS) {
1713
+ lastContextPostTime = now2;
1714
+ fetch(`${opts.apiUrl}/api/tasks/${realTaskId}/context`, {
1715
+ method: "POST",
1716
+ headers: {
1717
+ "Content-Type": "application/json",
1718
+ "X-Agent-ID": opts.agentId,
1719
+ Authorization: `Bearer ${opts.apiKey}`,
1720
+ },
1721
+ body: JSON.stringify({
1722
+ eventType: "progress",
1723
+ sessionId: opts.runnerSessionId,
1724
+ contextUsedTokens: event.contextUsedTokens,
1725
+ contextTotalTokens: event.contextTotalTokens,
1726
+ contextPercent: event.contextPercent,
1727
+ }),
1728
+ }).catch(() => {});
1729
+ }
1730
+ break;
1731
+ }
1732
+ case "compaction": {
1733
+ // Always record compaction events (no throttle)
1734
+ fetch(`${opts.apiUrl}/api/tasks/${realTaskId}/context`, {
1735
+ method: "POST",
1736
+ headers: {
1737
+ "Content-Type": "application/json",
1738
+ "X-Agent-ID": opts.agentId,
1739
+ Authorization: `Bearer ${opts.apiKey}`,
1740
+ },
1741
+ body: JSON.stringify({
1742
+ eventType: "compaction",
1743
+ sessionId: opts.runnerSessionId,
1744
+ preCompactTokens: event.preCompactTokens,
1745
+ compactTrigger: event.compactTrigger,
1746
+ contextTotalTokens: event.contextTotalTokens,
1747
+ }),
1748
+ }).catch(() => {});
1484
1749
  break;
1750
+ }
1485
1751
  case "raw_log":
1486
1752
  prettyPrintLine(event.content, opts.role);
1487
1753
  if (shouldStream) {
@@ -1510,6 +1776,10 @@ async function spawnProviderProcess(
1510
1776
 
1511
1777
  // Create promise that handles completion
1512
1778
  const promise: Promise<ProviderResult> = session.waitForCompletion().then(async (result) => {
1779
+ // Stop event flush timer and do a final flush
1780
+ clearInterval(eventFlushTimer);
1781
+ await flushEvents();
1782
+
1513
1783
  // Final log flush
1514
1784
  if (shouldStream && logBuffer.lines.length > 0) {
1515
1785
  await flushLogBuffer(logBuffer, {
@@ -1562,6 +1832,25 @@ async function spawnProviderProcess(
1562
1832
  }
1563
1833
  }
1564
1834
 
1835
+ // Post completion context usage snapshot
1836
+ if (result.cost && realTaskId) {
1837
+ fetch(`${opts.apiUrl}/api/tasks/${realTaskId}/context`, {
1838
+ method: "POST",
1839
+ headers: {
1840
+ "Content-Type": "application/json",
1841
+ "X-Agent-ID": opts.agentId,
1842
+ Authorization: `Bearer ${opts.apiKey}`,
1843
+ },
1844
+ body: JSON.stringify({
1845
+ eventType: "completion",
1846
+ sessionId: opts.runnerSessionId,
1847
+ cumulativeInputTokens: result.cost.inputTokens ?? 0,
1848
+ cumulativeOutputTokens: result.cost.outputTokens ?? 0,
1849
+ contextTotalTokens: getContextWindowSize(result.cost.model || "default"),
1850
+ }),
1851
+ }).catch(() => {});
1852
+ }
1853
+
1565
1854
  return result;
1566
1855
  });
1567
1856
 
@@ -1646,6 +1935,7 @@ async function checkCompletedProcesses(
1646
1935
  result: ProviderResult;
1647
1936
  triggerType?: string;
1648
1937
  cursorUpdates?: Array<{ channelId: string; ts: string }>;
1938
+ workingDir?: string;
1649
1939
  }> = [];
1650
1940
 
1651
1941
  for (const [taskId, task] of state.activeTasks) {
@@ -1659,18 +1949,24 @@ async function checkCompletedProcesses(
1659
1949
  result: task.result,
1660
1950
  triggerType: task.triggerType,
1661
1951
  cursorUpdates: task.cursorUpdates,
1952
+ workingDir: task.workingDir,
1662
1953
  });
1663
1954
  }
1664
1955
  }
1665
1956
 
1666
1957
  // Remove completed tasks from the map and ensure they're marked as finished
1667
- for (const { taskId, result, cursorUpdates } of completedTasks) {
1958
+ for (const { taskId, result, cursorUpdates, workingDir } of completedTasks) {
1668
1959
  state.activeTasks.delete(taskId);
1669
1960
 
1670
1961
  if (apiConfig) {
1671
1962
  removeActiveSession(apiConfig, taskId);
1672
1963
  }
1673
1964
 
1965
+ // Detect VCS before finishing — last chance to link a PR
1966
+ if (apiConfig && workingDir && !vcsDetectedTasks.has(taskId)) {
1967
+ await detectVcsForTask(apiConfig.apiUrl, apiConfig.apiKey, taskId, workingDir);
1968
+ }
1969
+
1674
1970
  // Call the finish API to ensure task status is updated
1675
1971
  // This is idempotent - if the agent already marked it, this is a no-op
1676
1972
  if (apiConfig) {
@@ -1820,6 +2116,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1820
2116
  let agentProfileName: string | undefined;
1821
2117
  let agentDescription: string | undefined;
1822
2118
  let agentSkillsSummary: { name: string; description: string }[] | undefined;
2119
+ let agentMcpServersSummary: string | undefined;
1823
2120
 
1824
2121
  // Per-task repo context — set when processing a task with githubRepo
1825
2122
  let currentRepoContext: BasePromptArgs["repoContext"] | undefined;
@@ -1839,6 +2136,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
1839
2136
  claudeMd: agentClaudeMd,
1840
2137
  repoContext: currentRepoContext,
1841
2138
  skillsSummary: agentSkillsSummary,
2139
+ mcpServersSummary: agentMcpServersSummary,
1842
2140
  });
1843
2141
  };
1844
2142
 
@@ -2094,6 +2392,42 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2094
2392
  // Non-fatal — skills are optional
2095
2393
  }
2096
2394
 
2395
+ // Fetch installed MCP servers for system prompt
2396
+ try {
2397
+ const mcpServersResp = await fetch(`${apiUrl}/api/agents/${agentId}/mcp-servers`, {
2398
+ headers: {
2399
+ Authorization: `Bearer ${apiKey}`,
2400
+ "X-Agent-ID": agentId,
2401
+ },
2402
+ });
2403
+ if (mcpServersResp.ok) {
2404
+ const mcpServersData = (await mcpServersResp.json()) as {
2405
+ servers: {
2406
+ name: string;
2407
+ transport: string;
2408
+ description: string | null;
2409
+ isActive: boolean;
2410
+ isEnabled: boolean;
2411
+ }[];
2412
+ };
2413
+ const activeMcpServers = mcpServersData.servers.filter(
2414
+ (s) => s.isActive && s.isEnabled,
2415
+ );
2416
+ if (activeMcpServers.length > 0) {
2417
+ agentMcpServersSummary = activeMcpServers
2418
+ .map(
2419
+ (s) => `- **${s.name}** (${s.transport}): ${s.description || "No description"}`,
2420
+ )
2421
+ .join("\n");
2422
+ console.log(
2423
+ `[${role}] Loaded ${activeMcpServers.length} MCP servers for system prompt`,
2424
+ );
2425
+ }
2426
+ }
2427
+ } catch {
2428
+ // Non-fatal — MCP servers are optional
2429
+ }
2430
+
2097
2431
  // Rebuild system prompt with identity
2098
2432
  basePrompt = await buildSystemPrompt();
2099
2433
  resolvedSystemPrompt = additionalSystemPrompt
@@ -2377,6 +2711,18 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2377
2711
  // Check for completed processes first and ensure tasks are marked as finished
2378
2712
  await checkCompletedProcesses(state, role, apiConfig);
2379
2713
 
2714
+ // Periodic VCS detection for running tasks (fire-and-forget, throttled per task)
2715
+ const now = Date.now();
2716
+ for (const [taskId, task] of state.activeTasks) {
2717
+ if (vcsDetectedTasks.has(taskId)) continue;
2718
+ const lastCheck = vcsCheckTimestamps.get(taskId) ?? 0;
2719
+ if (now - lastCheck < VCS_CHECK_INTERVAL) continue;
2720
+ if (!task.workingDir) continue;
2721
+
2722
+ vcsCheckTimestamps.set(taskId, now);
2723
+ detectVcsForTask(apiUrl, apiKey, taskId, task.workingDir);
2724
+ }
2725
+
2380
2726
  // Check for cancelled tasks and signal their subprocesses
2381
2727
  if (state.activeTasks.size > 0) {
2382
2728
  for (const [taskId, task] of state.activeTasks) {
@@ -2678,6 +3024,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
2678
3024
 
2679
3025
  // Attach trigger metadata for logging
2680
3026
  runningTask.triggerType = trigger.type;
3027
+ runningTask.workingDir = effectiveCwd;
2681
3028
 
2682
3029
  // Attach deferred cursor updates for channel_activity triggers
2683
3030
  if (trigger.type === "channel_activity" && trigger.cursorUpdates) {
@@ -0,0 +1,118 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ createContextSnapshot,
5
+ getContextSnapshotsByTaskId,
6
+ getContextSummaryByTaskId,
7
+ getTaskById,
8
+ } from "../be/db";
9
+ import { ContextSnapshotEventTypeSchema } from "../types";
10
+ import { route } from "./route-def";
11
+ import { json, jsonError } from "./utils";
12
+
13
+ // ─── Route Definitions ───────────────────────────────────────────────────────
14
+
15
+ const postContext = route({
16
+ method: "post",
17
+ path: "/api/tasks/{id}/context",
18
+ pattern: ["api", "tasks", null, "context"],
19
+ summary: "Record a context usage snapshot for a task",
20
+ tags: ["Tasks"],
21
+ params: z.object({ id: z.string() }),
22
+ body: z.object({
23
+ eventType: ContextSnapshotEventTypeSchema,
24
+ sessionId: z.string(),
25
+ contextUsedTokens: z.number().int().min(0).optional(),
26
+ contextTotalTokens: z.number().int().min(0).optional(),
27
+ contextPercent: z.number().min(0).max(100).optional(),
28
+ compactTrigger: z.enum(["auto", "manual"]).optional(),
29
+ preCompactTokens: z.number().int().min(0).optional(),
30
+ cumulativeInputTokens: z.number().int().min(0).optional(),
31
+ cumulativeOutputTokens: z.number().int().min(0).optional(),
32
+ }),
33
+ responses: {
34
+ 200: { description: "Snapshot recorded" },
35
+ 400: { description: "Validation error" },
36
+ 404: { description: "Task not found" },
37
+ },
38
+ auth: { apiKey: true, agentId: true },
39
+ });
40
+
41
+ const getContext = route({
42
+ method: "get",
43
+ path: "/api/tasks/{id}/context",
44
+ pattern: ["api", "tasks", null, "context"],
45
+ summary: "Get context usage history for a task",
46
+ tags: ["Tasks"],
47
+ params: z.object({ id: z.string() }),
48
+ query: z.object({
49
+ limit: z.coerce.number().int().min(1).max(500).default(100),
50
+ }),
51
+ responses: {
52
+ 200: { description: "Context snapshot history" },
53
+ 404: { description: "Task not found" },
54
+ },
55
+ auth: { apiKey: true },
56
+ });
57
+
58
+ // ─── Handler ─────────────────────────────────────────────────────────────────
59
+
60
+ export async function handleContext(
61
+ req: IncomingMessage,
62
+ res: ServerResponse,
63
+ pathSegments: string[],
64
+ queryParams: URLSearchParams,
65
+ myAgentId: string | undefined,
66
+ ): Promise<boolean> {
67
+ if (postContext.match(req.method, pathSegments)) {
68
+ if (!myAgentId) {
69
+ jsonError(res, "Missing X-Agent-ID header", 400);
70
+ return true;
71
+ }
72
+
73
+ const parsed = await postContext.parse(req, res, pathSegments, queryParams);
74
+ if (!parsed) return true;
75
+
76
+ const task = getTaskById(parsed.params.id);
77
+ if (!task) {
78
+ jsonError(res, "Task not found", 404);
79
+ return true;
80
+ }
81
+
82
+ const snapshot = createContextSnapshot({
83
+ taskId: parsed.params.id,
84
+ agentId: myAgentId,
85
+ sessionId: parsed.body.sessionId,
86
+ eventType: parsed.body.eventType,
87
+ contextUsedTokens: parsed.body.contextUsedTokens,
88
+ contextTotalTokens: parsed.body.contextTotalTokens,
89
+ contextPercent: parsed.body.contextPercent,
90
+ compactTrigger: parsed.body.compactTrigger,
91
+ preCompactTokens: parsed.body.preCompactTokens,
92
+ cumulativeInputTokens: parsed.body.cumulativeInputTokens,
93
+ cumulativeOutputTokens: parsed.body.cumulativeOutputTokens,
94
+ });
95
+
96
+ json(res, { ok: true, snapshotId: snapshot.id });
97
+ return true;
98
+ }
99
+
100
+ if (getContext.match(req.method, pathSegments)) {
101
+ const parsed = await getContext.parse(req, res, pathSegments, queryParams);
102
+ if (!parsed) return true;
103
+
104
+ const task = getTaskById(parsed.params.id);
105
+ if (!task) {
106
+ jsonError(res, "Task not found", 404);
107
+ return true;
108
+ }
109
+
110
+ const snapshots = getContextSnapshotsByTaskId(parsed.params.id, parsed.query.limit);
111
+ const summary = getContextSummaryByTaskId(parsed.params.id);
112
+
113
+ json(res, { snapshots, summary });
114
+ return true;
115
+ }
116
+
117
+ return false;
118
+ }