@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.
- package/README.md +1 -0
- package/openapi.json +20 -1
- package/package.json +3 -3
- package/src/be/boot-scrub-logs.ts +79 -20
- 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/commands/runner.ts
CHANGED
|
@@ -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,
|
package/src/e2b/dispatch.ts
CHANGED
|
@@ -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
|
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
|
+
});
|