@desplega.ai/agent-swarm 1.98.1 → 1.99.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.
@@ -1317,6 +1317,7 @@ async function getPausedTasksFromAPI(config: ApiConfig): Promise<
1317
1317
  finishedAt?: string;
1318
1318
  output?: string;
1319
1319
  status?: string;
1320
+ contextKey?: string;
1320
1321
  }>
1321
1322
  > {
1322
1323
  const headers: Record<string, string> = {
@@ -2329,6 +2330,7 @@ async function fetchRelevantMemories(
2329
2330
  agentId: string,
2330
2331
  taskDescription: string,
2331
2332
  taskId?: string,
2333
+ contextKey?: string,
2332
2334
  ): Promise<string | null> {
2333
2335
  try {
2334
2336
  const headers: Record<string, string> = {
@@ -2341,11 +2343,12 @@ async function fetchRelevantMemories(
2341
2343
  // memories they surface against this task's session_logs at completion.
2342
2344
  // Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-2.md §2
2343
2345
  if (taskId) headers["X-Source-Task-ID"] = taskId;
2346
+ if (contextKey) headers["X-Context-Key"] = contextKey;
2344
2347
 
2345
2348
  const response = await fetch(`${apiUrl}/api/memory/search`, {
2346
2349
  method: "POST",
2347
2350
  headers,
2348
- body: JSON.stringify({ query: taskDescription, limit: 5 }),
2351
+ body: JSON.stringify({ query: taskDescription, limit: 5, intent: "pre-task memory recall" }),
2349
2352
  });
2350
2353
 
2351
2354
  if (!response.ok) return null;
@@ -2600,6 +2603,7 @@ async function spawnProviderProcess(
2600
2603
  harnessProvider: ProviderName;
2601
2604
  cwd?: string;
2602
2605
  vcsRepo?: string;
2606
+ contextKey?: string;
2603
2607
  },
2604
2608
  logDir: string,
2605
2609
  isYolo: boolean,
@@ -2688,6 +2692,7 @@ async function spawnProviderProcess(
2688
2692
  // Propagate the selected OAuth slot so the adapter refreshes back to the
2689
2693
  // correct pool key. Undefined for non-codex providers and single-cred deploys.
2690
2694
  codexSlot: oauthSelection?.index,
2695
+ contextKey: opts.contextKey,
2691
2696
  };
2692
2697
 
2693
2698
  // Create the long-lived `worker.session` span up front so the provider
@@ -4412,6 +4417,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4412
4417
  agentId,
4413
4418
  task.task,
4414
4419
  task.id,
4420
+ (task as { contextKey?: string }).contextKey,
4415
4421
  );
4416
4422
  if (resumeMemoryContext) {
4417
4423
  resumePrompt += resumeMemoryContext;
@@ -4537,6 +4543,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4537
4543
  harnessProvider: state.harnessProvider,
4538
4544
  cwd: resumeCwd,
4539
4545
  vcsRepo: task.vcsRepo,
4546
+ contextKey: (task as { contextKey?: string }).contextKey,
4540
4547
  },
4541
4548
  logDir,
4542
4549
  isYolo,
@@ -4802,7 +4809,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4802
4809
  if (trigger.type === "task_assigned" || trigger.type === "task_offered") {
4803
4810
  const task =
4804
4811
  trigger.task && typeof trigger.task === "object" && "task" in trigger.task
4805
- ? (trigger.task as { task: string; id?: string })
4812
+ ? (trigger.task as { task: string; id?: string; contextKey?: string })
4806
4813
  : null;
4807
4814
  if (task?.task) {
4808
4815
  const memoryContext = await fetchRelevantMemories(
@@ -4811,6 +4818,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4811
4818
  agentId,
4812
4819
  task.task,
4813
4820
  task.id,
4821
+ task.contextKey,
4814
4822
  );
4815
4823
  if (memoryContext) {
4816
4824
  triggerPrompt += memoryContext;
@@ -4870,6 +4878,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
4870
4878
  // Extract model from task data for per-task model selection
4871
4879
  const taskModel = (trigger.task as { model?: string } | undefined)?.model;
4872
4880
  const taskModelTier = (trigger.task as { modelTier?: string } | undefined)?.modelTier;
4881
+ const taskContextKey = (trigger.task as { contextKey?: string } | undefined)?.contextKey;
4873
4882
 
4874
4883
  // Detect Slack context for conditional prompt sections
4875
4884
  const taskSlackChannelId = (trigger.task as { slackChannelId?: string } | undefined)
@@ -5016,6 +5025,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
5016
5025
  harnessProvider: state.harnessProvider,
5017
5026
  cwd: effectiveCwd,
5018
5027
  vcsRepo: taskVcsRepo,
5028
+ contextKey: taskContextKey,
5019
5029
  },
5020
5030
  logDir,
5021
5031
  isYolo,
@@ -361,6 +361,11 @@ export async function startDetachedProcess(opts: StartDetachedOptions): Promise<
361
361
  cwd: opts.cwd ?? "/",
362
362
  envs: opts.env,
363
363
  background: true,
364
+ // CRITICAL: the SDK's default `timeoutMs` is 60s and applies to background
365
+ // commands too — envd kills the whole tracked tree (entrypoint + children)
366
+ // when it expires, silently stopping the worker runner ~60s after boot.
367
+ // 0 disables the limit; sandbox lifetime is governed by its own TTL.
368
+ timeoutMs: 0,
364
369
  });
365
370
 
366
371
  // Early liveness poll: give the entrypoint a moment to fault, then check the
@@ -1,10 +1,11 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
3
  import { chunkContent } from "../be/chunking";
4
- import { getTaskById } from "../be/db";
4
+ import { getDb, getTaskById } from "../be/db";
5
5
  import { getEmbeddingProvider, getMemoryStore } from "../be/memory";
6
6
  import { CANDIDATE_SET_MULTIPLIER } from "../be/memory/constants";
7
7
  import { listEdgesForAgent } from "../be/memory/edges-store";
8
+ import { storeLinks } from "../be/memory/link-resolver";
8
9
  import { recordRetrievals } from "../be/memory/raters/retrieval";
9
10
  import { applyRating, ExplicitSelfDuplicateError } from "../be/memory/raters/store";
10
11
  import {
@@ -37,6 +38,7 @@ const indexMemory = route({
37
38
  sourcePath: z.string().optional(),
38
39
  tags: z.array(z.string()).optional(),
39
40
  persistMemory: z.boolean().optional(),
41
+ contextKey: z.string().optional(),
40
42
  }),
41
43
  responses: {
42
44
  202: { description: "Content queued for embedding" },
@@ -53,6 +55,13 @@ const searchMemory = route({
53
55
  auth: { apiKey: true, agentId: true },
54
56
  body: z.object({
55
57
  query: z.string().min(1),
58
+ intent: z
59
+ .string()
60
+ .min(1)
61
+ .optional()
62
+ .describe(
63
+ "Why you are searching. Required for agent recall-edge tracking; omit for UI browse/search calls.",
64
+ ),
56
65
  limit: z.number().int().min(1).max(20).default(5),
57
66
  scope: z.enum(["agent", "swarm", "all"]).default("all"),
58
67
  source: z.enum(["manual", "file_index", "session_summary", "task_completion"]).optional(),
@@ -149,6 +158,15 @@ const getMemoryById = route({
149
158
  tags: ["Memory"],
150
159
  auth: { apiKey: true, agentId: true },
151
160
  params: z.object({ id: z.string().uuid() }),
161
+ query: z.object({
162
+ intent: z
163
+ .string()
164
+ .min(1)
165
+ .optional()
166
+ .describe(
167
+ "Why you are retrieving this memory. Required for agent recall-edge tracking; omit for UI browse calls.",
168
+ ),
169
+ }),
152
170
  responses: {
153
171
  200: { description: "Memory details" },
154
172
  404: { description: "Memory not found" },
@@ -266,8 +284,18 @@ export async function handleMemory(
266
284
  const parsed = await indexMemory.parse(req, res, pathSegments, new URLSearchParams());
267
285
  if (!parsed) return true;
268
286
 
269
- const { agentId, content, name, scope, source, sourceTaskId, sourcePath, tags, persistMemory } =
270
- parsed.body;
287
+ const {
288
+ agentId,
289
+ content,
290
+ name,
291
+ scope,
292
+ source,
293
+ sourceTaskId,
294
+ sourcePath,
295
+ tags,
296
+ persistMemory,
297
+ contextKey,
298
+ } = parsed.body;
271
299
 
272
300
  if (source === "session_summary" && sourceTaskId) {
273
301
  const sourceTask = getTaskById(sourceTaskId);
@@ -296,6 +324,13 @@ export async function handleMemory(
296
324
  store.deleteBySourcePath(sourcePath, agentId);
297
325
  }
298
326
 
327
+ // Derive contextKey from body or X-Context-Key header
328
+ const headerContextKey = req.headers["x-context-key"];
329
+ const resolvedContextKey =
330
+ contextKey ??
331
+ (Array.isArray(headerContextKey) ? headerContextKey[0] : headerContextKey) ??
332
+ undefined;
333
+
299
334
  // Atomic batch insert — all chunks or none
300
335
  const memories = store.storeBatch(
301
336
  contentChunks.map((chunk) => ({
@@ -309,9 +344,24 @@ export async function handleMemory(
309
344
  chunkIndex: chunk.chunkIndex,
310
345
  totalChunks: chunk.totalChunks,
311
346
  tags: tags || [],
347
+ contextKey: resolvedContextKey ?? null,
312
348
  })),
313
349
  );
314
350
 
351
+ // Resolve and store deterministic links (wikilinks, PR refs, agent-fs paths)
352
+ if (agentId) {
353
+ for (const memory of memories) {
354
+ try {
355
+ storeLinks(memory.id, agentId, memory.content);
356
+ } catch (err) {
357
+ console.error(
358
+ `[memory] Link resolution failed for ${memory.id}:`,
359
+ (err as Error).message,
360
+ );
361
+ }
362
+ }
363
+ }
364
+
315
365
  // Async batch embed (fire and forget)
316
366
  (async () => {
317
367
  try {
@@ -339,7 +389,7 @@ export async function handleMemory(
339
389
  const parsed = await searchMemory.parse(req, res, pathSegments, new URLSearchParams());
340
390
  if (!parsed) return true;
341
391
 
342
- const { query, limit, scope, source } = parsed.body;
392
+ const { query, intent, limit, scope, source } = parsed.body;
343
393
 
344
394
  try {
345
395
  const provider = getEmbeddingProvider();
@@ -369,12 +419,16 @@ export async function handleMemory(
369
419
  const sourceTaskId = Array.isArray(sourceTaskIdHeader)
370
420
  ? sourceTaskIdHeader[0]
371
421
  : sourceTaskIdHeader;
372
- if (sourceTaskId) {
422
+ const contextKeyHeader = req.headers["x-context-key"];
423
+ const contextKey = Array.isArray(contextKeyHeader) ? contextKeyHeader[0] : contextKeyHeader;
424
+ if (sourceTaskId && intent) {
373
425
  try {
374
426
  recordRetrievals(
375
427
  sourceTaskId,
376
428
  myAgentId,
377
429
  ranked.map((r) => ({ memoryId: r.id, similarity: r.similarity })),
430
+ undefined,
431
+ { intent, contextKey, eventType: "search" },
378
432
  );
379
433
  } catch (err) {
380
434
  console.error("[memory-search] recordRetrievals failed:", (err as Error).message);
@@ -620,7 +674,11 @@ export async function handleMemory(
620
674
  reasoning: e.reasoning,
621
675
  ...(e.referencesSource !== undefined ? { referencesSource: e.referencesSource } : {}),
622
676
  }));
623
- const result = applyRating(ratingEvents, { taskId });
677
+ const rateContextKeyHeader = req.headers["x-context-key"];
678
+ const rateContextKey = Array.isArray(rateContextKeyHeader)
679
+ ? rateContextKeyHeader[0]
680
+ : rateContextKeyHeader;
681
+ const result = applyRating(ratingEvents, { taskId, contextKey: rateContextKey });
624
682
  applied += result.applied;
625
683
  for (const r of result.rejected) {
626
684
  rejected.push({ memoryId: r.event.memoryId, reason: r.reason });
@@ -671,7 +729,8 @@ export async function handleMemory(
671
729
  }
672
730
 
673
731
  if (getMemoryById.match(req.method, pathSegments)) {
674
- const parsed = await getMemoryById.parse(req, res, pathSegments, new URLSearchParams());
732
+ const queryParams = parseQueryParams(req.url || "");
733
+ const parsed = await getMemoryById.parse(req, res, pathSegments, queryParams);
675
734
  if (!parsed) return true;
676
735
 
677
736
  const memory = getMemoryStore().get(parsed.params.id);
@@ -680,6 +739,27 @@ export async function handleMemory(
680
739
  return true;
681
740
  }
682
741
 
742
+ const { intent } = parsed.query;
743
+ const sourceTaskIdHeader = req.headers["x-source-task-id"];
744
+ const sourceTaskId = Array.isArray(sourceTaskIdHeader)
745
+ ? sourceTaskIdHeader[0]
746
+ : sourceTaskIdHeader;
747
+ const contextKeyHeader = req.headers["x-context-key"];
748
+ const contextKey = Array.isArray(contextKeyHeader) ? contextKeyHeader[0] : contextKeyHeader;
749
+ if (sourceTaskId && myAgentId && intent) {
750
+ try {
751
+ recordRetrievals(
752
+ sourceTaskId,
753
+ myAgentId,
754
+ [{ memoryId: memory.id, similarity: 1.0 }],
755
+ undefined,
756
+ { intent, contextKey, eventType: "get" },
757
+ );
758
+ } catch (err) {
759
+ console.error("[memory-get] recordRetrievals failed:", (err as Error).message);
760
+ }
761
+ }
762
+
683
763
  json(res, { memory });
684
764
  return true;
685
765
  }
@@ -692,6 +772,23 @@ export async function handleMemory(
692
772
  const MEMORY_GC_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
693
773
  let memoryGcTimer: ReturnType<typeof setInterval> | null = null;
694
774
 
775
+ const SEARCH_RETRIEVAL_TTL_DAYS = 90;
776
+
777
+ function purgeStaleSearchRetrievals(): number {
778
+ try {
779
+ const cutoff = new Date(
780
+ Date.now() - SEARCH_RETRIEVAL_TTL_DAYS * 24 * 60 * 60 * 1000,
781
+ ).toISOString();
782
+ const result = getDb()
783
+ .prepare("DELETE FROM memory_retrieval WHERE eventType = 'search' AND retrievedAt < ?")
784
+ .run(cutoff);
785
+ return result.changes;
786
+ } catch (err) {
787
+ console.error("[memory-gc] Search retrieval purge failed:", (err as Error).message);
788
+ return 0;
789
+ }
790
+ }
791
+
695
792
  export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
696
793
  if (memoryGcTimer) return;
697
794
 
@@ -701,6 +798,12 @@ export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
701
798
  if (purged > 0) {
702
799
  console.log(`[memory-gc] Initial purge removed ${purged} expired memory row(s)`);
703
800
  }
801
+ const searchPurged = purgeStaleSearchRetrievals();
802
+ if (searchPurged > 0) {
803
+ console.log(
804
+ `[memory-gc] Initial purge removed ${searchPurged} stale search retrieval row(s)`,
805
+ );
806
+ }
704
807
  } catch (err) {
705
808
  console.error("[memory-gc] Initial purge failed:", err);
706
809
  }
@@ -711,6 +814,12 @@ export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
711
814
  if (purged > 0) {
712
815
  console.log(`[memory-gc] Periodic purge removed ${purged} expired memory row(s)`);
713
816
  }
817
+ const searchPurged = purgeStaleSearchRetrievals();
818
+ if (searchPurged > 0) {
819
+ console.log(
820
+ `[memory-gc] Periodic purge removed ${searchPurged} stale search retrieval row(s)`,
821
+ );
822
+ }
714
823
  } catch (err) {
715
824
  console.error("[memory-gc] Periodic purge failed:", err);
716
825
  }
@@ -248,6 +248,7 @@ export function mergeMcpConfig(
248
248
  baseConfig: { mcpServers?: Record<string, unknown> } | null,
249
249
  installedServers: Record<string, Record<string, unknown>> | null,
250
250
  taskId: string,
251
+ contextKey?: string,
251
252
  ): { mcpServers: Record<string, unknown> } {
252
253
  const config: { mcpServers: Record<string, unknown> } = {
253
254
  mcpServers: { ...(baseConfig?.mcpServers ?? {}) },
@@ -273,6 +274,9 @@ export function mergeMcpConfig(
273
274
  const server = config.mcpServers[serverKey] as Record<string, unknown>;
274
275
  if (!server.headers) server.headers = {};
275
276
  (server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
277
+ if (contextKey) {
278
+ (server.headers as Record<string, string>)["X-Context-Key"] = contextKey;
279
+ }
276
280
  }
277
281
 
278
282
  return config;
@@ -291,6 +295,7 @@ export async function createSessionMcpConfig(
291
295
  cwd: string,
292
296
  taskId: string,
293
297
  installedServers?: Record<string, Record<string, unknown>> | null,
298
+ contextKey?: string,
294
299
  ): Promise<string | null> {
295
300
  // Collect every .mcp.json from cwd up to filesystem root. Stopping at the first
296
301
  // match silently drops the swarm-managed /workspace/.mcp.json when the cloned
@@ -341,7 +346,12 @@ export async function createSessionMcpConfig(
341
346
  }
342
347
 
343
348
  try {
344
- const config = mergeMcpConfig({ mcpServers: mergedServers }, installedServers ?? null, taskId);
349
+ const config = mergeMcpConfig(
350
+ { mcpServers: mergedServers },
351
+ installedServers ?? null,
352
+ taskId,
353
+ contextKey,
354
+ );
345
355
  const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
346
356
  await writeFile(sessionConfigPath, JSON.stringify(config, null, 2));
347
357
  return sessionConfigPath;
@@ -950,11 +960,12 @@ export class ClaudeAdapter implements ProviderAdapter {
950
960
  );
951
961
  }
952
962
 
953
- // Create per-session MCP config with X-Source-Task-Id header + installed servers (no shared-file race condition)
963
+ // Create per-session MCP config with X-Source-Task-Id + X-Context-Key headers + installed servers (no shared-file race condition)
954
964
  const sessionMcpConfig = await createSessionMcpConfig(
955
965
  config.cwd,
956
966
  config.taskId,
957
967
  installedServers,
968
+ config.contextKey,
958
969
  );
959
970
 
960
971
  // Stage the system prompt on disk so it can be passed as a file path
@@ -92,6 +92,7 @@ export interface ProviderSessionConfig {
92
92
  apiKey: string;
93
93
  cwd: string;
94
94
  vcsRepo?: string;
95
+ contextKey?: string;
95
96
  /**
96
97
  * @deprecated Never set by the runner — native session resume was removed in
97
98
  * the 2026-05-28 plan. Adapters log + ignore any stray value. Follow-up
@@ -56,7 +56,11 @@ function bridgeRequestFor(name: string, args: unknown): BridgeRequest | null {
56
56
  case "memory_get": {
57
57
  const memoryId = typeof body.memoryId === "string" ? body.memoryId : undefined;
58
58
  if (!memoryId) throw new Error("memory_get requires string `memoryId`");
59
- return { method: "GET", path: `/api/memory/${encodeURIComponent(memoryId)}` };
59
+ const getIntent = typeof body.intent === "string" ? body.intent : "script-sdk";
60
+ return {
61
+ method: "GET",
62
+ path: `/api/memory/${encodeURIComponent(memoryId)}?intent=${encodeURIComponent(getIntent)}`,
63
+ };
60
64
  }
61
65
  case "memory_rate": {
62
66
  const event = {
@@ -50,11 +50,12 @@ declare module "swarm-sdk" {
50
50
  // --- memory ---
51
51
  memory_search(args: {
52
52
  query: string;
53
+ intent: string;
53
54
  scope?: "all" | "agent" | "swarm";
54
55
  limit?: number;
55
56
  source?: string;
56
57
  }): Promise<unknown>;
57
- memory_get(args: { memoryId: string }): Promise<unknown>;
58
+ memory_get(args: { memoryId: string; intent: string }): Promise<unknown>;
58
59
  memory_rate(args: { id: string; useful: boolean; note?: string }): Promise<unknown>;
59
60
  // --- tasks ---
60
61
  task_list(args?: Record<string, unknown>): Promise<unknown>;
@@ -32,11 +32,12 @@ declare module "swarm-sdk" {
32
32
  // --- memory ---
33
33
  memory_search(args: {
34
34
  query: string;
35
+ intent: string;
35
36
  scope?: "all" | "agent" | "swarm";
36
37
  limit?: number;
37
38
  source?: string;
38
39
  }): Promise<unknown>;
39
- memory_get(args: { memoryId: string }): Promise<unknown>;
40
+ memory_get(args: { memoryId: string; intent: string }): Promise<unknown>;
40
41
  memory_rate(args: { id: string; useful: boolean; note?: string }): Promise<unknown>;
41
42
  // --- tasks ---
42
43
  task_list(args?: Record<string, unknown>): Promise<unknown>;
@@ -1,7 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Type } from "typebox";
3
3
  import { z } from "zod";
4
- import { completeStructured } from "../../utils/internal-ai/complete-structured.js";
4
+ import {
5
+ completeStructured,
6
+ defaultSpawnClaudeCli,
7
+ } from "../../utils/internal-ai/complete-structured.js";
5
8
  import type { ResolvedCredential } from "../../utils/internal-ai/credentials.js";
6
9
 
7
10
  const ResultZodSchema = z.object({
@@ -274,3 +277,33 @@ describe("completeStructured", () => {
274
277
  expect(match).toBeDefined();
275
278
  });
276
279
  });
280
+
281
+ describe("defaultSpawnClaudeCli", () => {
282
+ test("sets SKIP_SESSION_SUMMARY=1 in the child env (Stop-hook recursion guard)", async () => {
283
+ // Without this guard, the spawned `claude -p` summarizer session fires the
284
+ // same global Stop hook on exit, which spawns another summarizer claude,
285
+ // recursively — observed OOM-wedging 8GB E2B worker sandboxes.
286
+ const fakeBinary = `/tmp/fake-claude-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`;
287
+ await Bun.write(
288
+ fakeBinary,
289
+ '#!/usr/bin/env bash\ncat >/dev/null\nprintf \'{"result":"SKIP_SESSION_SUMMARY=%s"}\' "$SKIP_SESSION_SUMMARY"\n',
290
+ );
291
+ const savedBinary = process.env.CLAUDE_BINARY;
292
+ const savedSkip = process.env.SKIP_SESSION_SUMMARY;
293
+ const savedBridge = process.env.SWARM_USE_CLAUDE_BRIDGE;
294
+ process.env.CLAUDE_BINARY = `bash ${fakeBinary}`;
295
+ // Ensure a false-pass via inheritance is impossible.
296
+ delete process.env.SKIP_SESSION_SUMMARY;
297
+ delete process.env.SWARM_USE_CLAUDE_BRIDGE;
298
+ try {
299
+ const out = await defaultSpawnClaudeCli("prompt", "haiku");
300
+ expect(out).toBe("SKIP_SESSION_SUMMARY=1");
301
+ } finally {
302
+ if (savedBinary === undefined) delete process.env.CLAUDE_BINARY;
303
+ else process.env.CLAUDE_BINARY = savedBinary;
304
+ if (savedSkip !== undefined) process.env.SKIP_SESSION_SUMMARY = savedSkip;
305
+ if (savedBridge !== undefined) process.env.SWARM_USE_CLAUDE_BRIDGE = savedBridge;
306
+ await Bun.$`rm -f ${fakeBinary}`.quiet();
307
+ }
308
+ });
309
+ });
@@ -0,0 +1,172 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { randomUUID } from "node:crypto";
3
+ import { unlink } from "node:fs/promises";
4
+ import type { IncomingMessage, ServerResponse } from "node:http";
5
+ import { Readable } from "node:stream";
6
+ import { closeDb, createAgent, getDb, initDb } from "../be/db";
7
+ import type { AgentMemory } from "../types";
8
+
9
+ const memoryId = randomUUID();
10
+ const agentId = randomUUID();
11
+ const sourceTaskId = randomUUID();
12
+ const TEST_DB_PATH = "./test-memory-http-recall-gating.sqlite";
13
+
14
+ const memory: AgentMemory = {
15
+ id: memoryId,
16
+ agentId,
17
+ content: "UI browse/search memory fixture",
18
+ name: "ui-memory-fixture",
19
+ scope: "agent",
20
+ source: "manual",
21
+ sourcePath: null,
22
+ sourceTaskId: null,
23
+ chunkIndex: 0,
24
+ totalChunks: 1,
25
+ tags: [],
26
+ contextKey: null,
27
+ createdAt: new Date("2026-06-14T00:00:00.000Z").toISOString(),
28
+ updatedAt: new Date("2026-06-14T00:00:00.000Z").toISOString(),
29
+ };
30
+
31
+ mock.module("../be/memory", () => ({
32
+ getEmbeddingProvider: () => ({
33
+ name: "test-embedding",
34
+ dimensions: 3,
35
+ embed: async () => new Float32Array([1, 0, 0]),
36
+ embedBatch: async (texts: string[]) => texts.map(() => new Float32Array([1, 0, 0])),
37
+ }),
38
+ getMemoryStore: () => ({
39
+ get: () => memory,
40
+ search: () => [
41
+ {
42
+ ...memory,
43
+ similarity: 0.95,
44
+ rawSimilarity: 0.95,
45
+ compositeScore: 0.95,
46
+ accessCount: 0,
47
+ expiresAt: null,
48
+ embeddingModel: "test-embedding",
49
+ alpha: 1,
50
+ beta: 1,
51
+ },
52
+ ],
53
+ }),
54
+ }));
55
+
56
+ const { handleMemory } = await import("../http/memory");
57
+
58
+ type ResponseCapture = {
59
+ statusCode: number;
60
+ body: any;
61
+ };
62
+
63
+ function makeReq(
64
+ method: string,
65
+ url: string,
66
+ body?: unknown,
67
+ headers: Record<string, string> = {},
68
+ ): IncomingMessage {
69
+ const chunks = body === undefined ? [] : [Buffer.from(JSON.stringify(body))];
70
+ const req = Readable.from(chunks) as IncomingMessage;
71
+ req.method = method;
72
+ req.url = url;
73
+ req.headers = headers;
74
+ return req;
75
+ }
76
+
77
+ function makeRes(capture: ResponseCapture): ServerResponse {
78
+ return {
79
+ writeHead(statusCode: number) {
80
+ capture.statusCode = statusCode;
81
+ return this;
82
+ },
83
+ end(chunk?: unknown) {
84
+ capture.body = typeof chunk === "string" ? JSON.parse(chunk) : chunk;
85
+ return this;
86
+ },
87
+ } as ServerResponse;
88
+ }
89
+
90
+ async function callMemoryRoute(
91
+ method: string,
92
+ url: string,
93
+ pathSegments: string[],
94
+ body?: unknown,
95
+ headers: Record<string, string> = {},
96
+ ): Promise<ResponseCapture> {
97
+ const capture: ResponseCapture = { statusCode: 0, body: null };
98
+ const handled = await handleMemory(
99
+ makeReq(method, url, body, headers),
100
+ makeRes(capture),
101
+ pathSegments,
102
+ agentId,
103
+ );
104
+ expect(handled).toBe(true);
105
+ return capture;
106
+ }
107
+
108
+ function countRetrievals(): number {
109
+ return getDb().prepare<{ n: number }, []>("SELECT COUNT(*) AS n FROM memory_retrieval").get()!.n;
110
+ }
111
+
112
+ beforeAll(async () => {
113
+ for (const suffix of ["", "-wal", "-shm"]) {
114
+ try {
115
+ await unlink(TEST_DB_PATH + suffix);
116
+ } catch {}
117
+ }
118
+
119
+ initDb(TEST_DB_PATH);
120
+ createAgent({ id: agentId, name: "HTTP Memory Gating Agent", isLead: false, status: "idle" });
121
+ const nowIso = new Date().toISOString();
122
+ getDb()
123
+ .prepare(
124
+ `INSERT INTO agent_tasks (id, agentId, task, status, source, createdAt, lastUpdatedAt)
125
+ VALUES (?, ?, ?, 'in_progress', 'mcp', ?, ?)`,
126
+ )
127
+ .run(sourceTaskId, agentId, "HTTP memory recall gating task", nowIso, nowIso);
128
+ });
129
+
130
+ beforeEach(() => {
131
+ getDb().run("DELETE FROM memory_retrieval");
132
+ });
133
+
134
+ afterAll(async () => {
135
+ closeDb();
136
+ for (const suffix of ["", "-wal", "-shm"]) {
137
+ try {
138
+ await unlink(TEST_DB_PATH + suffix);
139
+ } catch {}
140
+ }
141
+ });
142
+
143
+ describe("memory HTTP recall capture gating", () => {
144
+ test("POST /api/memory/search accepts UI calls without intent and does not record retrievals", async () => {
145
+ const response = await callMemoryRoute(
146
+ "POST",
147
+ "/api/memory/search",
148
+ ["api", "memory", "search"],
149
+ { query: "UI browse/search", limit: 5 },
150
+ { "x-source-task-id": sourceTaskId, "x-context-key": "task:ui-browse" },
151
+ );
152
+
153
+ expect(response.statusCode).toBe(200);
154
+ expect(response.body.results).toHaveLength(1);
155
+ expect(response.body.results[0].id).toBe(memoryId);
156
+ expect(countRetrievals()).toBe(0);
157
+ });
158
+
159
+ test("GET /api/memory/:id accepts UI calls without intent and does not record retrievals", async () => {
160
+ const response = await callMemoryRoute(
161
+ "GET",
162
+ `/api/memory/${memoryId}`,
163
+ ["api", "memory", memoryId],
164
+ undefined,
165
+ { "x-source-task-id": sourceTaskId, "x-context-key": "task:ui-browse" },
166
+ );
167
+
168
+ expect(response.statusCode).toBe(200);
169
+ expect(response.body.memory.id).toBe(memoryId);
170
+ expect(countRetrievals()).toBe(0);
171
+ });
172
+ });