@desplega.ai/agent-swarm 1.73.1 → 1.73.3
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/openapi.json +107 -1
- package/package.json +1 -1
- package/src/http/memory.ts +172 -0
- package/src/oauth/ensure-token.ts +26 -9
- package/src/oauth/keepalive.ts +21 -11
- package/src/tests/ensure-token.test.ts +33 -1
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.73.
|
|
5
|
+
"version": "1.73.3",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
|
@@ -2914,6 +2914,112 @@
|
|
|
2914
2914
|
}
|
|
2915
2915
|
}
|
|
2916
2916
|
},
|
|
2917
|
+
"/api/memory/list": {
|
|
2918
|
+
"post": {
|
|
2919
|
+
"summary": "List or semantically search memories across all agents (debug/admin)",
|
|
2920
|
+
"tags": [
|
|
2921
|
+
"Memory"
|
|
2922
|
+
],
|
|
2923
|
+
"security": [
|
|
2924
|
+
{
|
|
2925
|
+
"bearerAuth": []
|
|
2926
|
+
}
|
|
2927
|
+
],
|
|
2928
|
+
"requestBody": {
|
|
2929
|
+
"content": {
|
|
2930
|
+
"application/json": {
|
|
2931
|
+
"schema": {
|
|
2932
|
+
"type": "object",
|
|
2933
|
+
"properties": {
|
|
2934
|
+
"query": {
|
|
2935
|
+
"type": "string",
|
|
2936
|
+
"description": "Natural-language query. If present, runs semantic search; otherwise lists by recency."
|
|
2937
|
+
},
|
|
2938
|
+
"agentId": {
|
|
2939
|
+
"type": "string",
|
|
2940
|
+
"format": "uuid",
|
|
2941
|
+
"description": "Filter to a single agent. Omit for all."
|
|
2942
|
+
},
|
|
2943
|
+
"scope": {
|
|
2944
|
+
"type": "string",
|
|
2945
|
+
"enum": [
|
|
2946
|
+
"agent",
|
|
2947
|
+
"swarm",
|
|
2948
|
+
"all"
|
|
2949
|
+
],
|
|
2950
|
+
"default": "all"
|
|
2951
|
+
},
|
|
2952
|
+
"source": {
|
|
2953
|
+
"type": "string",
|
|
2954
|
+
"enum": [
|
|
2955
|
+
"manual",
|
|
2956
|
+
"file_index",
|
|
2957
|
+
"session_summary",
|
|
2958
|
+
"task_completion"
|
|
2959
|
+
]
|
|
2960
|
+
},
|
|
2961
|
+
"sourcePath": {
|
|
2962
|
+
"type": "string",
|
|
2963
|
+
"description": "Substring match against sourcePath (case-insensitive). Useful for file_index memories."
|
|
2964
|
+
},
|
|
2965
|
+
"limit": {
|
|
2966
|
+
"type": "integer",
|
|
2967
|
+
"minimum": 1,
|
|
2968
|
+
"maximum": 100,
|
|
2969
|
+
"default": 20
|
|
2970
|
+
},
|
|
2971
|
+
"offset": {
|
|
2972
|
+
"type": "integer",
|
|
2973
|
+
"minimum": 0,
|
|
2974
|
+
"default": 0
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
},
|
|
2981
|
+
"responses": {
|
|
2982
|
+
"200": {
|
|
2983
|
+
"description": "Memory list / search results"
|
|
2984
|
+
},
|
|
2985
|
+
"400": {
|
|
2986
|
+
"description": "Validation error"
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
},
|
|
2991
|
+
"/api/memory/{id}": {
|
|
2992
|
+
"delete": {
|
|
2993
|
+
"summary": "Delete a single memory by ID (debug/admin)",
|
|
2994
|
+
"tags": [
|
|
2995
|
+
"Memory"
|
|
2996
|
+
],
|
|
2997
|
+
"security": [
|
|
2998
|
+
{
|
|
2999
|
+
"bearerAuth": []
|
|
3000
|
+
}
|
|
3001
|
+
],
|
|
3002
|
+
"parameters": [
|
|
3003
|
+
{
|
|
3004
|
+
"schema": {
|
|
3005
|
+
"type": "string",
|
|
3006
|
+
"format": "uuid"
|
|
3007
|
+
},
|
|
3008
|
+
"required": true,
|
|
3009
|
+
"name": "id",
|
|
3010
|
+
"in": "path"
|
|
3011
|
+
}
|
|
3012
|
+
],
|
|
3013
|
+
"responses": {
|
|
3014
|
+
"200": {
|
|
3015
|
+
"description": "Memory deleted"
|
|
3016
|
+
},
|
|
3017
|
+
"404": {
|
|
3018
|
+
"description": "Memory not found"
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
},
|
|
2917
3023
|
"/api/prompt-templates/resolved": {
|
|
2918
3024
|
"get": {
|
|
2919
3025
|
"summary": "Resolve a prompt template for a given event type and scope chain",
|
package/package.json
CHANGED
package/src/http/memory.ts
CHANGED
|
@@ -69,6 +69,52 @@ const reEmbedMemory = route({
|
|
|
69
69
|
},
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
const listMemory = route({
|
|
73
|
+
method: "post",
|
|
74
|
+
path: "/api/memory/list",
|
|
75
|
+
pattern: ["api", "memory", "list"],
|
|
76
|
+
summary: "List or semantically search memories across all agents (debug/admin)",
|
|
77
|
+
tags: ["Memory"],
|
|
78
|
+
auth: { apiKey: true },
|
|
79
|
+
body: z.object({
|
|
80
|
+
query: z
|
|
81
|
+
.string()
|
|
82
|
+
.optional()
|
|
83
|
+
.describe(
|
|
84
|
+
"Natural-language query. If present, runs semantic search; otherwise lists by recency.",
|
|
85
|
+
),
|
|
86
|
+
agentId: z.string().uuid().optional().describe("Filter to a single agent. Omit for all."),
|
|
87
|
+
scope: z.enum(["agent", "swarm", "all"]).default("all"),
|
|
88
|
+
source: AgentMemorySourceSchema.optional(),
|
|
89
|
+
sourcePath: z
|
|
90
|
+
.string()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe(
|
|
93
|
+
"Substring match against sourcePath (case-insensitive). Useful for file_index memories.",
|
|
94
|
+
),
|
|
95
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
96
|
+
offset: z.number().int().min(0).default(0),
|
|
97
|
+
}),
|
|
98
|
+
responses: {
|
|
99
|
+
200: { description: "Memory list / search results" },
|
|
100
|
+
400: { description: "Validation error" },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const deleteMemoryById = route({
|
|
105
|
+
method: "delete",
|
|
106
|
+
path: "/api/memory/{id}",
|
|
107
|
+
pattern: ["api", "memory", null],
|
|
108
|
+
summary: "Delete a single memory by ID (debug/admin)",
|
|
109
|
+
tags: ["Memory"],
|
|
110
|
+
auth: { apiKey: true },
|
|
111
|
+
params: z.object({ id: z.string().uuid() }),
|
|
112
|
+
responses: {
|
|
113
|
+
200: { description: "Memory deleted" },
|
|
114
|
+
404: { description: "Memory not found" },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
72
118
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
73
119
|
|
|
74
120
|
export async function handleMemory(
|
|
@@ -182,6 +228,132 @@ export async function handleMemory(
|
|
|
182
228
|
return true;
|
|
183
229
|
}
|
|
184
230
|
|
|
231
|
+
if (listMemory.match(req.method, pathSegments)) {
|
|
232
|
+
const parsed = await listMemory.parse(req, res, pathSegments, new URLSearchParams());
|
|
233
|
+
if (!parsed) return true;
|
|
234
|
+
|
|
235
|
+
const { query, agentId, scope, source, sourcePath, limit, offset } = parsed.body;
|
|
236
|
+
const store = getMemoryStore();
|
|
237
|
+
const pathNeedle = sourcePath?.trim().toLowerCase();
|
|
238
|
+
const matchesPath = (p: string | null) =>
|
|
239
|
+
!pathNeedle || (p?.toLowerCase().includes(pathNeedle) ?? false);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
if (query && query.trim().length > 0) {
|
|
243
|
+
const provider = getEmbeddingProvider();
|
|
244
|
+
const queryEmbedding = await provider.embed(query.trim());
|
|
245
|
+
|
|
246
|
+
if (!queryEmbedding) {
|
|
247
|
+
json(res, { results: [], total: 0, mode: "semantic" });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const candidateLimit = Math.min(limit, 100) * CANDIDATE_SET_MULTIPLIER;
|
|
252
|
+
let candidates = store.search(queryEmbedding, agentId ?? "", {
|
|
253
|
+
scope,
|
|
254
|
+
limit: candidateLimit,
|
|
255
|
+
isLead: true,
|
|
256
|
+
source,
|
|
257
|
+
});
|
|
258
|
+
if (agentId) {
|
|
259
|
+
candidates = candidates.filter((c) => c.agentId === agentId);
|
|
260
|
+
}
|
|
261
|
+
if (pathNeedle) {
|
|
262
|
+
candidates = candidates.filter((c) => matchesPath(c.sourcePath));
|
|
263
|
+
}
|
|
264
|
+
const ranked = rerank(candidates, { limit: Math.min(limit, 100) });
|
|
265
|
+
|
|
266
|
+
json(res, {
|
|
267
|
+
results: ranked.map((r) => ({
|
|
268
|
+
id: r.id,
|
|
269
|
+
name: r.name,
|
|
270
|
+
content: r.content,
|
|
271
|
+
agentId: r.agentId,
|
|
272
|
+
scope: r.scope,
|
|
273
|
+
source: r.source,
|
|
274
|
+
similarity: r.similarity,
|
|
275
|
+
createdAt: r.createdAt,
|
|
276
|
+
accessedAt: r.accessedAt,
|
|
277
|
+
accessCount: r.accessCount ?? 0,
|
|
278
|
+
expiresAt: r.expiresAt ?? null,
|
|
279
|
+
embeddingModel: r.embeddingModel ?? null,
|
|
280
|
+
sourceTaskId: r.sourceTaskId,
|
|
281
|
+
sourcePath: r.sourcePath,
|
|
282
|
+
chunkIndex: r.chunkIndex,
|
|
283
|
+
totalChunks: r.totalChunks,
|
|
284
|
+
tags: r.tags,
|
|
285
|
+
})),
|
|
286
|
+
total: ranked.length,
|
|
287
|
+
mode: "semantic",
|
|
288
|
+
});
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// When filtering by sourcePath, over-fetch then post-filter so the visible
|
|
293
|
+
// page isn't gutted by the in-memory filter.
|
|
294
|
+
const fetchLimit = pathNeedle
|
|
295
|
+
? Math.min(500, Math.max(limit * 10, 100))
|
|
296
|
+
: Math.min(limit, 100);
|
|
297
|
+
let rows = store.list(agentId ?? "", {
|
|
298
|
+
scope,
|
|
299
|
+
limit: fetchLimit,
|
|
300
|
+
offset,
|
|
301
|
+
isLead: true,
|
|
302
|
+
});
|
|
303
|
+
if (agentId) {
|
|
304
|
+
rows = rows.filter((r) => r.agentId === agentId);
|
|
305
|
+
}
|
|
306
|
+
if (source) {
|
|
307
|
+
rows = rows.filter((r) => r.source === source);
|
|
308
|
+
}
|
|
309
|
+
if (pathNeedle) {
|
|
310
|
+
rows = rows.filter((r) => matchesPath(r.sourcePath));
|
|
311
|
+
}
|
|
312
|
+
rows = rows.slice(0, Math.min(limit, 100));
|
|
313
|
+
|
|
314
|
+
json(res, {
|
|
315
|
+
results: rows.map((r) => ({
|
|
316
|
+
id: r.id,
|
|
317
|
+
name: r.name,
|
|
318
|
+
content: r.content,
|
|
319
|
+
agentId: r.agentId,
|
|
320
|
+
scope: r.scope,
|
|
321
|
+
source: r.source,
|
|
322
|
+
createdAt: r.createdAt,
|
|
323
|
+
accessedAt: r.accessedAt,
|
|
324
|
+
accessCount: r.accessCount ?? 0,
|
|
325
|
+
expiresAt: r.expiresAt ?? null,
|
|
326
|
+
embeddingModel: r.embeddingModel ?? null,
|
|
327
|
+
sourceTaskId: r.sourceTaskId,
|
|
328
|
+
sourcePath: r.sourcePath,
|
|
329
|
+
chunkIndex: r.chunkIndex,
|
|
330
|
+
totalChunks: r.totalChunks,
|
|
331
|
+
tags: r.tags,
|
|
332
|
+
})),
|
|
333
|
+
total: rows.length,
|
|
334
|
+
mode: "list",
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
console.error("[memory-list] Error:", (err as Error).message);
|
|
338
|
+
jsonError(res, "Memory list failed", 500);
|
|
339
|
+
}
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (deleteMemoryById.match(req.method, pathSegments)) {
|
|
344
|
+
const parsed = await deleteMemoryById.parse(req, res, pathSegments, new URLSearchParams());
|
|
345
|
+
if (!parsed) return true;
|
|
346
|
+
|
|
347
|
+
const store = getMemoryStore();
|
|
348
|
+
const deleted = store.delete(parsed.params.id);
|
|
349
|
+
if (!deleted) {
|
|
350
|
+
jsonError(res, "Memory not found", 404);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
json(res, { deleted: true });
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
|
|
185
357
|
if (reEmbedMemory.match(req.method, pathSegments)) {
|
|
186
358
|
const parsed = await reEmbedMemory.parse(req, res, pathSegments, new URLSearchParams());
|
|
187
359
|
if (!parsed) return true;
|
|
@@ -26,11 +26,35 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
|
|
|
26
26
|
* If the token is expiring soon, attempt to refresh it.
|
|
27
27
|
* Call this before any API interaction with an OAuth-protected service.
|
|
28
28
|
*
|
|
29
|
+
* Reactive variant — never throws. Refresh failures are logged so a single
|
|
30
|
+
* dead-token incident doesn't tear down an unrelated request path. Use
|
|
31
|
+
* {@link ensureTokenOrThrow} from keepalive contexts where you want a dead
|
|
32
|
+
* refresh token to surface as an alert.
|
|
33
|
+
*
|
|
29
34
|
* @param bufferMs - How far ahead to check for expiry. Default 5 min (reactive use).
|
|
30
35
|
* Keepalive callers should pass a larger value (e.g. 13h) to force
|
|
31
36
|
* a proactive refresh well before the token actually expires.
|
|
32
37
|
*/
|
|
33
38
|
export async function ensureToken(provider: string, bufferMs?: number): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
await ensureTokenOrThrow(provider, bufferMs);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(
|
|
43
|
+
`[OAuth] Failed to refresh ${provider} token:`,
|
|
44
|
+
err instanceof Error ? err.message : err,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strict variant of {@link ensureToken}: throws on refresh failure of a
|
|
51
|
+
* configured provider so callers (keepalive, alerting) can react.
|
|
52
|
+
*
|
|
53
|
+
* Stays silent (no throw) when the provider isn't configured or no refresh
|
|
54
|
+
* token is stored — those are "not connected" states, not failures, and
|
|
55
|
+
* shouldn't page anyone.
|
|
56
|
+
*/
|
|
57
|
+
export async function ensureTokenOrThrow(provider: string, bufferMs?: number): Promise<void> {
|
|
34
58
|
if (!isTokenExpiringSoon(provider, bufferMs)) return;
|
|
35
59
|
|
|
36
60
|
const config = getOAuthConfig(provider);
|
|
@@ -42,13 +66,6 @@ export async function ensureToken(provider: string, bufferMs?: number): Promise<
|
|
|
42
66
|
return;
|
|
43
67
|
}
|
|
44
68
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
console.log(`[OAuth] ${provider} token refreshed successfully`);
|
|
48
|
-
} catch (err) {
|
|
49
|
-
console.error(
|
|
50
|
-
`[OAuth] Failed to refresh ${provider} token:`,
|
|
51
|
-
err instanceof Error ? err.message : err,
|
|
52
|
-
);
|
|
53
|
-
}
|
|
69
|
+
await refreshAccessToken(config, tokens.refreshToken);
|
|
70
|
+
console.log(`[OAuth] ${provider} token refreshed successfully`);
|
|
54
71
|
}
|
package/src/oauth/keepalive.ts
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ensureTokenOrThrow } from "./ensure-token";
|
|
2
2
|
|
|
3
3
|
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
|
|
4
4
|
const THIRTEEN_HOURS_MS = 13 * 60 * 60 * 1000;
|
|
5
5
|
const SLACK_ALERTS_CHANNEL = process.env.SLACK_ALERTS_CHANNEL || "C08JCRURPBV";
|
|
6
6
|
|
|
7
|
+
const KEEPALIVE_PROVIDERS = ["linear", "jira"] as const;
|
|
8
|
+
|
|
7
9
|
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Proactively refresh OAuth tokens on a schedule to prevent expiry.
|
|
11
13
|
* If refresh fails, posts a Slack notification so someone can re-auth manually.
|
|
14
|
+
*
|
|
15
|
+
* Why both Linear and Jira: Atlassian rotates refresh tokens and expires them
|
|
16
|
+
* after 90 days of inactivity, so a swarm that doesn't touch Jira for a long
|
|
17
|
+
* stretch will silently lose the ability to refresh. Touching the token every
|
|
18
|
+
* 12h keeps the refresh token alive and surfaces a dead one as an alert
|
|
19
|
+
* instead of a runtime 401.
|
|
12
20
|
*/
|
|
13
21
|
async function runKeepalive(): Promise<void> {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
for (const provider of KEEPALIVE_PROVIDERS) {
|
|
23
|
+
console.log(`[OAuth Keepalive] Running scheduled token refresh for ${provider}...`);
|
|
24
|
+
try {
|
|
25
|
+
await ensureTokenOrThrow(provider, THIRTEEN_HOURS_MS);
|
|
26
|
+
console.log(`[OAuth Keepalive] ${provider} token check completed successfully`);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
29
|
+
console.error(`[OAuth Keepalive] Failed to refresh ${provider} token: ${message}`);
|
|
30
|
+
await notifySlack(
|
|
31
|
+
`⚠️ *OAuth Keepalive Failed*\nProvider: \`${provider}\`\nError: ${message}\n\nManual re-authorization may be required.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
24
34
|
}
|
|
25
35
|
}
|
|
26
36
|
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
storeOAuthTokens,
|
|
8
8
|
upsertOAuthApp,
|
|
9
9
|
} from "../be/db-queries/oauth";
|
|
10
|
-
import { ensureToken } from "../oauth/ensure-token";
|
|
10
|
+
import { ensureToken, ensureTokenOrThrow } from "../oauth/ensure-token";
|
|
11
11
|
|
|
12
12
|
const TEST_DB_PATH = "./test-ensure-token.sqlite";
|
|
13
13
|
|
|
@@ -202,3 +202,35 @@ describe("ensureToken", () => {
|
|
|
202
202
|
expect(fetchSpy).not.toHaveBeenCalled();
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
describe("ensureTokenOrThrow", () => {
|
|
207
|
+
test("throws when refresh fails for a configured provider (so keepalive can alert)", async () => {
|
|
208
|
+
storeOAuthTokens("test-provider", {
|
|
209
|
+
accessToken: "old-token",
|
|
210
|
+
refreshToken: "refresh-token",
|
|
211
|
+
expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
globalThis.fetch = mock(() =>
|
|
215
|
+
Promise.resolve(
|
|
216
|
+
new Response('{"error":"invalid_grant"}', {
|
|
217
|
+
status: 400,
|
|
218
|
+
headers: { "Content-Type": "application/json" },
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
await expect(ensureTokenOrThrow("test-provider")).rejects.toThrow(/Token refresh failed/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("stays silent (no throw) when no refresh token is stored", async () => {
|
|
227
|
+
deleteOAuthTokens("test-provider");
|
|
228
|
+
|
|
229
|
+
// "Not connected" should not page anyone
|
|
230
|
+
await expect(ensureTokenOrThrow("test-provider")).resolves.toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("stays silent (no throw) when provider is not configured", async () => {
|
|
234
|
+
await expect(ensureTokenOrThrow("nonexistent-provider")).resolves.toBeUndefined();
|
|
235
|
+
});
|
|
236
|
+
});
|