@desplega.ai/agent-swarm 1.98.1 → 1.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/openapi.json +20 -1
- package/package.json +3 -3
- package/src/be/memory/link-resolver.ts +226 -0
- package/src/be/memory/providers/sqlite-store.ts +4 -2
- package/src/be/memory/raters/retrieval.ts +15 -4
- package/src/be/memory/raters/store.ts +4 -2
- package/src/be/memory/types.ts +1 -0
- package/src/be/migrations/096_memory_graph_phase1.sql +50 -0
- package/src/be/scripts/typecheck.ts +3 -2
- package/src/commands/runner.ts +12 -2
- package/src/e2b/dispatch.ts +5 -0
- package/src/http/memory.ts +116 -7
- package/src/providers/claude-adapter.ts +13 -2
- package/src/providers/types.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +5 -1
- package/src/scripts-runtime/types/stdlib.d.ts +2 -1
- package/src/scripts-runtime/types/swarm-sdk.d.ts +2 -1
- package/src/tests/internal-ai/complete-structured.test.ts +34 -1
- package/src/tests/memory-http-recall-gating.test.ts +172 -0
- package/src/tests/memory-link-resolver.test.ts +92 -0
- package/src/tests/opencode-adapter.test.ts +3 -0
- package/src/tests/profile-sync.test.ts +1 -1
- package/src/tests/scripts-mcp-e2e.test.ts +1 -1
- package/src/tools/memory-get.ts +22 -1
- package/src/tools/memory-search.ts +8 -1
- package/src/tools/utils.ts +10 -0
- package/src/utils/internal-ai/complete-structured.ts +10 -1
- package/tsconfig.json +1 -0
package/src/http/memory.ts
CHANGED
|
@@ -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 {
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
package/src/providers/types.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveLinks } from "../be/memory/link-resolver";
|
|
3
|
+
|
|
4
|
+
describe("resolveLinks", () => {
|
|
5
|
+
test("extracts wikilinks from content", () => {
|
|
6
|
+
const links = resolveLinks("See [[auth-fix-pattern]] and [[pr585-codex-binary]] for context.");
|
|
7
|
+
expect(links).toHaveLength(2);
|
|
8
|
+
expect(links[0]).toMatchObject({
|
|
9
|
+
linkType: "wikilink",
|
|
10
|
+
targetKind: "memory",
|
|
11
|
+
targetId: "auth-fix-pattern",
|
|
12
|
+
resolver: "wikilink",
|
|
13
|
+
});
|
|
14
|
+
expect(links[1]).toMatchObject({
|
|
15
|
+
linkType: "wikilink",
|
|
16
|
+
targetKind: "memory",
|
|
17
|
+
targetId: "pr585-codex-binary",
|
|
18
|
+
resolver: "wikilink",
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("extracts PR references with hash notation", () => {
|
|
23
|
+
const links = resolveLinks("Fixed in #696 and PR #470.");
|
|
24
|
+
const prLinks = links.filter((l) => l.linkType === "pr");
|
|
25
|
+
expect(prLinks.length).toBeGreaterThanOrEqual(2);
|
|
26
|
+
const ids = prLinks.map((l) => l.targetId);
|
|
27
|
+
expect(ids).toContain("pr:696");
|
|
28
|
+
expect(ids).toContain("pr:470");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("extracts full GitHub PR URLs", () => {
|
|
32
|
+
const links = resolveLinks(
|
|
33
|
+
"See https://github.com/desplega-ai/agent-swarm/pull/763 for the fix.",
|
|
34
|
+
);
|
|
35
|
+
const prLinks = links.filter((l) => l.linkType === "pr");
|
|
36
|
+
expect(prLinks).toHaveLength(1);
|
|
37
|
+
expect(prLinks[0]).toMatchObject({
|
|
38
|
+
linkType: "pr",
|
|
39
|
+
targetKind: "pr",
|
|
40
|
+
targetId: "github:desplega-ai/agent-swarm#763",
|
|
41
|
+
resolver: "pr-url",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("extracts agent-fs paths", () => {
|
|
46
|
+
const links = resolveLinks(
|
|
47
|
+
"Plan at live.agent-fs.dev/file/~/648a5f3c-35c8-4f11-8673-b89de52cd6bd/2faf73ba-4eee-4472-8b3b-359c4ed6bfbb/thoughts/plan.md",
|
|
48
|
+
);
|
|
49
|
+
const fsLinks = links.filter((l) => l.linkType === "agent-fs-file");
|
|
50
|
+
expect(fsLinks).toHaveLength(1);
|
|
51
|
+
expect(fsLinks[0]!.targetKind).toBe("agent-fs-file");
|
|
52
|
+
expect(fsLinks[0]!.resolver).toBe("agent-fs-path");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("extracts agent-ui page links", () => {
|
|
56
|
+
const links = resolveLinks(
|
|
57
|
+
"See app.agent-swarm.dev/pages/abc12345-1234-1234-1234-123456789abc",
|
|
58
|
+
);
|
|
59
|
+
const uiLinks = links.filter((l) => l.linkType === "agent-ui");
|
|
60
|
+
expect(uiLinks).toHaveLength(1);
|
|
61
|
+
expect(uiLinks[0]).toMatchObject({
|
|
62
|
+
targetKind: "agent-ui",
|
|
63
|
+
targetId: "page:abc12345-1234-1234-1234-123456789abc",
|
|
64
|
+
resolver: "agent-ui-page",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("deduplicates PR references", () => {
|
|
69
|
+
const links = resolveLinks("PR #696, see also PR #696 again, and #696 once more.");
|
|
70
|
+
const prLinks = links.filter((l) => l.linkType === "pr");
|
|
71
|
+
const ids696 = prLinks.filter((l) => l.targetId === "pr:696");
|
|
72
|
+
expect(ids696).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("returns empty array for content without links", () => {
|
|
76
|
+
const links = resolveLinks("This is plain text with no links or references.");
|
|
77
|
+
expect(links).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("handles mixed content with multiple link types", () => {
|
|
81
|
+
const content = `
|
|
82
|
+
See [[memory-search-fix]] for context.
|
|
83
|
+
PR #696 fixed the embedding issue.
|
|
84
|
+
Plan: live.agent-fs.dev/file/~/648a5f3c-35c8-4f11-8673-b89de52cd6bd/2faf73ba/thoughts/plan.md
|
|
85
|
+
`;
|
|
86
|
+
const links = resolveLinks(content);
|
|
87
|
+
const types = new Set(links.map((l) => l.linkType));
|
|
88
|
+
expect(types.has("wikilink")).toBe(true);
|
|
89
|
+
expect(types.has("pr")).toBe(true);
|
|
90
|
+
expect(types.has("agent-fs-file")).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -682,12 +682,15 @@ describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
|
|
|
682
682
|
|
|
683
683
|
expect(lastCreateOpencodeConfig).toBeDefined();
|
|
684
684
|
const opts = lastCreateOpencodeConfig as {
|
|
685
|
+
timeout?: number;
|
|
685
686
|
config?: {
|
|
686
687
|
model?: string;
|
|
687
688
|
mcp?: Record<string, unknown>;
|
|
688
689
|
permission?: Record<string, string>;
|
|
689
690
|
};
|
|
690
691
|
};
|
|
692
|
+
// Server-start timeout must override the SDK's 5s default (E2B cold-start flake)
|
|
693
|
+
expect(opts.timeout).toBe(30_000);
|
|
691
694
|
expect(opts.config?.model).toBe("claude-sonnet-4-6");
|
|
692
695
|
expect(opts.config?.mcp?.swarm).toBeDefined();
|
|
693
696
|
const swarm = opts.config?.mcp?.swarm as {
|
|
@@ -453,7 +453,7 @@ describe("collectProfilePayloads (baseline integration)", () => {
|
|
|
453
453
|
[SOUL_MD_PATH]: LONG,
|
|
454
454
|
[IDENTITY_MD_PATH]: LONG,
|
|
455
455
|
[TOOLS_MD_PATH]: "tools",
|
|
456
|
-
|
|
456
|
+
"/workspace/HEARTBEAT.md": "heartbeat",
|
|
457
457
|
[IDENTITY_BASELINES_PATH]: JSON.stringify(baselines),
|
|
458
458
|
});
|
|
459
459
|
|