@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 CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.73.1",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.73.1",
3
+ "version": "1.73.3",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
@@ -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
- try {
46
- await refreshAccessToken(config, tokens.refreshToken);
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
  }
@@ -1,26 +1,36 @@
1
- import { ensureToken } from "./ensure-token";
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
- console.log("[OAuth Keepalive] Running scheduled token refresh for linear...");
15
- try {
16
- await ensureToken("linear", THIRTEEN_HOURS_MS);
17
- console.log("[OAuth Keepalive] linear token check completed successfully");
18
- } catch (err) {
19
- const message = err instanceof Error ? err.message : String(err);
20
- console.error(`[OAuth Keepalive] Failed to refresh linear token: ${message}`);
21
- await notifySlack(
22
- `⚠️ *OAuth Keepalive Failed*\nProvider: \`linear\`\nError: ${message}\n\nManual re-authorization may be required.`,
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
+ });