@desplega.ai/agent-swarm 1.75.0 → 1.76.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +973 -36
  3. package/package.json +2 -2
  4. package/src/be/db.ts +527 -9
  5. package/src/be/memory/raters/llm-summarizer.ts +218 -0
  6. package/src/be/memory/raters/llm.ts +56 -75
  7. package/src/be/memory/retrieval-store.ts +21 -0
  8. package/src/be/migrations/054_agent_harness_provider.sql +21 -0
  9. package/src/be/migrations/055_agent_cred_status.sql +15 -0
  10. package/src/be/migrations/056_drop_agent_tasks_source_check.sql +139 -0
  11. package/src/be/migrations/057_inbox_item_state.sql +27 -0
  12. package/src/be/migrations/058_task_templates.sql +31 -0
  13. package/src/be/swarm-config-guard.ts +24 -0
  14. package/src/commands/credential-wait.ts +1 -1
  15. package/src/commands/provider-credentials.ts +434 -0
  16. package/src/commands/runner.ts +229 -42
  17. package/src/hooks/hook.ts +115 -95
  18. package/src/http/agents.ts +82 -2
  19. package/src/http/config.ts +11 -1
  20. package/src/http/inbox-state.ts +89 -0
  21. package/src/http/index.ts +10 -0
  22. package/src/http/sessions.ts +86 -0
  23. package/src/http/status.ts +665 -0
  24. package/src/http/task-templates.ts +51 -0
  25. package/src/http/tasks.ts +85 -5
  26. package/src/http/users.ts +134 -0
  27. package/src/providers/claude-adapter.ts +5 -0
  28. package/src/providers/codex-adapter.ts +1 -1
  29. package/src/providers/index.ts +1 -1
  30. package/src/slack/handlers.ts +0 -1
  31. package/src/tests/agents-harness-provider.test.ts +333 -0
  32. package/src/tests/credential-check.test.ts +32 -1
  33. package/src/tests/credential-status-api.test.ts +42 -0
  34. package/src/tests/harness-provider-resolution.test.ts +242 -0
  35. package/src/tests/jira-sync.test.ts +1 -1
  36. package/src/tests/memory-rater-llm-summarizer.test.ts +317 -0
  37. package/src/tests/memory-rater-llm.test.ts +265 -107
  38. package/src/tests/migration-runner-regressions.test.ts +17 -2
  39. package/src/tests/sessions.test.ts +141 -0
  40. package/src/tests/status.test.ts +843 -0
  41. package/src/tests/stop-hook-task-resolution.test.ts +98 -0
  42. package/src/tests/template-recommendations.test.ts +148 -0
  43. package/src/tests/use-dismissible-card.test.ts +140 -0
  44. package/src/tools/swarm-config/set-config.ts +17 -1
  45. package/src/types.ts +117 -0
  46. package/src/utils/harness-provider.ts +32 -0
  47. package/tsconfig.json +0 -2
  48. package/src/providers/credentials.ts +0 -74
@@ -0,0 +1,51 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { listTaskTemplates } from "../be/db";
4
+ import { TaskTemplateKindSchema } from "../types";
5
+ import { route } from "./route-def";
6
+ import { json } from "./utils";
7
+
8
+ // ─── Route Definitions ───────────────────────────────────────────────────────
9
+
10
+ const listTemplates = route({
11
+ method: "get",
12
+ path: "/api/task-templates",
13
+ pattern: ["api", "task-templates"],
14
+ summary: "List task templates ('To start' bucket)",
15
+ tags: ["Task Templates"],
16
+ query: z.object({
17
+ category: z.string().optional(),
18
+ /** v2 hook — v1 callers always pass `kind=task` (or omit). */
19
+ kind: TaskTemplateKindSchema.optional(),
20
+ /** Case-insensitive LIKE match against `title` OR `description`. */
21
+ query: z.string().optional(),
22
+ }),
23
+ responses: {
24
+ 200: { description: "Task template list" },
25
+ 401: { description: "Unauthorized" },
26
+ },
27
+ auth: { apiKey: true },
28
+ });
29
+
30
+ // ─── Handler ─────────────────────────────────────────────────────────────────
31
+
32
+ export async function handleTaskTemplates(
33
+ req: IncomingMessage,
34
+ res: ServerResponse,
35
+ pathSegments: string[],
36
+ queryParams: URLSearchParams,
37
+ ): Promise<boolean> {
38
+ if (listTemplates.match(req.method, pathSegments)) {
39
+ const parsed = await listTemplates.parse(req, res, pathSegments, queryParams);
40
+ if (!parsed) return true;
41
+ const templates = listTaskTemplates({
42
+ category: parsed.query.category,
43
+ kind: parsed.query.kind,
44
+ query: parsed.query.query,
45
+ });
46
+ json(res, { templates });
47
+ return true;
48
+ }
49
+
50
+ return false;
51
+ }
package/src/http/tasks.ts CHANGED
@@ -7,10 +7,12 @@ import {
7
7
  failTask,
8
8
  getAllTasks,
9
9
  getDb,
10
+ getLeadAgent,
10
11
  getLogsByTaskId,
11
12
  getPausedTasksForAgent,
12
13
  getTaskById,
13
14
  getTasksCount,
15
+ getUserById,
14
16
  pauseTask,
15
17
  resumeTask,
16
18
  updateAgentStatusFromCapacity,
@@ -20,7 +22,13 @@ import {
20
22
  } from "../be/db";
21
23
  import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
22
24
  import { telemetry } from "../telemetry";
23
- import { ProviderNameSchema } from "../types";
25
+ import {
26
+ type AgentTaskSource,
27
+ AgentTaskSourceSchema,
28
+ type AgentTaskStatus,
29
+ AgentTaskStatusSchema,
30
+ ProviderNameSchema,
31
+ } from "../types";
24
32
  import { route } from "./route-def";
25
33
  import { json, jsonError } from "./utils";
26
34
 
@@ -33,16 +41,22 @@ const listTasks = route({
33
41
  summary: "List tasks with filters",
34
42
  tags: ["Tasks"],
35
43
  query: z.object({
44
+ /** Single status, or comma-separated list (e.g. "failed,cancelled"). */
36
45
  status: z.string().optional(),
37
46
  agentId: z.string().optional(),
38
47
  scheduleId: z.string().optional(),
39
48
  search: z.string().optional(),
40
49
  includeHeartbeat: z.enum(["true", "false"]).optional(),
50
+ /** ISO 8601 — return only tasks created on/after this timestamp. */
51
+ createdAfter: z.string().datetime().optional(),
52
+ /** Comma-separated source filter (e.g. `ui,slack`). Omit to include all. */
53
+ source: z.string().optional(),
41
54
  limit: z.coerce.number().int().optional(),
42
55
  offset: z.coerce.number().int().optional(),
43
56
  }),
44
57
  responses: {
45
58
  200: { description: "Paginated task list" },
59
+ 400: { description: "Validation error (e.g. unknown status token)" },
46
60
  },
47
61
  });
48
62
 
@@ -62,9 +76,10 @@ const createTask = route({
62
76
  offeredTo: z.string().optional(),
63
77
  dir: z.string().optional(),
64
78
  parentTaskId: z.string().optional(),
65
- source: z.string().optional(),
79
+ source: AgentTaskSourceSchema.optional(),
66
80
  outputSchema: z.record(z.string(), z.unknown()).optional(),
67
81
  contextKey: z.string().optional(),
82
+ requestedByUserId: z.string().optional(),
68
83
  }),
69
84
  responses: {
70
85
  201: { description: "Task created" },
@@ -239,12 +254,53 @@ export async function handleTasks(
239
254
  if (listTasks.match(req.method, pathSegments)) {
240
255
  const parsed = await listTasks.parse(req, res, pathSegments, queryParams);
241
256
  if (!parsed) return true;
257
+
258
+ // Multi-status CSV: split on `,` and validate each token against the
259
+ // canonical enum. Empty / single-status callers still work.
260
+ let status: AgentTaskStatus | AgentTaskStatus[] | undefined;
261
+ if (parsed.query.status) {
262
+ const tokens = parsed.query.status
263
+ .split(",")
264
+ .map((s) => s.trim())
265
+ .filter(Boolean);
266
+ const validated: AgentTaskStatus[] = [];
267
+ for (const tok of tokens) {
268
+ const result = AgentTaskStatusSchema.safeParse(tok);
269
+ if (!result.success) {
270
+ jsonError(res, `Invalid status token: ${tok}`, 400);
271
+ return true;
272
+ }
273
+ validated.push(result.data);
274
+ }
275
+ status = validated.length === 1 ? validated[0] : validated;
276
+ }
277
+
278
+ let source: AgentTaskSource[] | undefined;
279
+ if (parsed.query.source) {
280
+ const tokens = parsed.query.source
281
+ .split(",")
282
+ .map((s) => s.trim())
283
+ .filter(Boolean);
284
+ const validated: AgentTaskSource[] = [];
285
+ for (const tok of tokens) {
286
+ const result = AgentTaskSourceSchema.safeParse(tok);
287
+ if (!result.success) {
288
+ jsonError(res, `Invalid source token: ${tok}`, 400);
289
+ return true;
290
+ }
291
+ validated.push(result.data);
292
+ }
293
+ if (validated.length > 0) source = validated;
294
+ }
295
+
242
296
  const filters = {
243
- status: (parsed.query.status as import("../types").AgentTaskStatus) || undefined,
297
+ status,
244
298
  agentId: parsed.query.agentId || undefined,
245
299
  scheduleId: parsed.query.scheduleId || undefined,
246
300
  search: parsed.query.search || undefined,
247
301
  includeHeartbeat: parsed.query.includeHeartbeat === "true" || undefined,
302
+ createdAfter: parsed.query.createdAfter || undefined,
303
+ source,
248
304
  limit: parsed.query.limit,
249
305
  offset: parsed.query.offset,
250
306
  };
@@ -258,9 +314,32 @@ export async function handleTasks(
258
314
  const parsed = await createTask.parse(req, res, pathSegments, queryParams);
259
315
  if (!parsed) return true;
260
316
 
317
+ // Tolerant `requestedByUserId`: prevent the deleted-user race from
318
+ // becoming a 500 — if the referenced user doesn't exist, log and drop
319
+ // the field rather than letting the FK fail at INSERT.
320
+ let requestedByUserId = parsed.body.requestedByUserId || undefined;
321
+ if (requestedByUserId && !getUserById(requestedByUserId)) {
322
+ console.warn(
323
+ `[tasks] requestedByUserId ${requestedByUserId} does not exist — coercing to NULL`,
324
+ );
325
+ requestedByUserId = undefined;
326
+ }
327
+
328
+ // Default agent for ingress-created tasks: when no explicit `agentId` is
329
+ // provided, route to the lead so the task has an owner immediately
330
+ // (regardless of whether it's a root or a follow-up under a parentTaskId).
331
+ // Without this, UI composer follow-ups land unassigned and never get
332
+ // picked up. Mirrors Slack's pattern (slack/actions.ts uses lead?.id when
333
+ // there's no working agent).
334
+ let defaultAgentId = parsed.body.agentId || undefined;
335
+ if (!defaultAgentId) {
336
+ const lead = getLeadAgent();
337
+ if (lead) defaultAgentId = lead.id;
338
+ }
339
+
261
340
  try {
262
341
  const task = createTaskWithSiblingAwareness(parsed.body.task, {
263
- agentId: parsed.body.agentId || undefined,
342
+ agentId: defaultAgentId,
264
343
  creatorAgentId: myAgentId || undefined,
265
344
  taskType: parsed.body.taskType || undefined,
266
345
  tags: parsed.body.tags || undefined,
@@ -269,9 +348,10 @@ export async function handleTasks(
269
348
  offeredTo: parsed.body.offeredTo || undefined,
270
349
  dir: parsed.body.dir || undefined,
271
350
  parentTaskId: parsed.body.parentTaskId || undefined,
272
- source: (parsed.body.source as import("../types").AgentTaskSource) || "api",
351
+ source: parsed.body.source || "api",
273
352
  outputSchema: parsed.body.outputSchema || undefined,
274
353
  contextKey: parsed.body.contextKey || undefined,
354
+ requestedByUserId,
275
355
  });
276
356
 
277
357
  ensure({
@@ -0,0 +1,134 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import { createUser, getAllUsers, getUserById, updateUser } from "../be/db";
4
+ import { route } from "./route-def";
5
+ import { json, jsonError } from "./utils";
6
+
7
+ // ─── Route Definitions ───────────────────────────────────────────────────────
8
+
9
+ const listUsers = route({
10
+ method: "get",
11
+ path: "/api/users",
12
+ pattern: ["api", "users"],
13
+ summary: "List all users",
14
+ tags: ["Users"],
15
+ responses: {
16
+ 200: { description: "List of users" },
17
+ 401: { description: "Unauthorized" },
18
+ },
19
+ auth: { apiKey: true },
20
+ });
21
+
22
+ const createUserRoute = route({
23
+ method: "post",
24
+ path: "/api/users",
25
+ pattern: ["api", "users"],
26
+ summary: "Create a new user",
27
+ tags: ["Users"],
28
+ body: z.object({
29
+ name: z.string().min(1),
30
+ email: z.string().optional(),
31
+ role: z.string().optional(),
32
+ notes: z.string().optional(),
33
+ slackUserId: z.string().optional(),
34
+ linearUserId: z.string().optional(),
35
+ githubUsername: z.string().optional(),
36
+ gitlabUsername: z.string().optional(),
37
+ emailAliases: z.array(z.string()).optional(),
38
+ preferredChannel: z.string().optional(),
39
+ timezone: z.string().optional(),
40
+ }),
41
+ responses: {
42
+ 200: { description: "User created" },
43
+ 400: { description: "Validation error" },
44
+ 401: { description: "Unauthorized" },
45
+ },
46
+ auth: { apiKey: true },
47
+ });
48
+
49
+ const updateUserRoute = route({
50
+ method: "put",
51
+ path: "/api/users/{id}",
52
+ pattern: ["api", "users", null],
53
+ summary: "Update an existing user (partial — at least one field required)",
54
+ tags: ["Users"],
55
+ params: z.object({ id: z.string() }),
56
+ body: z
57
+ .object({
58
+ name: z.string().min(1).optional(),
59
+ email: z.string().optional(),
60
+ role: z.string().optional(),
61
+ notes: z.string().optional(),
62
+ slackUserId: z.string().optional(),
63
+ linearUserId: z.string().optional(),
64
+ githubUsername: z.string().optional(),
65
+ gitlabUsername: z.string().optional(),
66
+ emailAliases: z.array(z.string()).optional(),
67
+ preferredChannel: z.string().optional(),
68
+ timezone: z.string().optional(),
69
+ })
70
+ .refine((v) => Object.keys(v).length > 0, {
71
+ message: "At least one field must be provided",
72
+ }),
73
+ responses: {
74
+ 200: { description: "User updated" },
75
+ 400: { description: "Validation error or empty body" },
76
+ 401: { description: "Unauthorized" },
77
+ 404: { description: "User not found" },
78
+ },
79
+ auth: { apiKey: true },
80
+ });
81
+
82
+ // ─── Handler ─────────────────────────────────────────────────────────────────
83
+
84
+ export async function handleUsers(
85
+ req: IncomingMessage,
86
+ res: ServerResponse,
87
+ pathSegments: string[],
88
+ queryParams: URLSearchParams,
89
+ ): Promise<boolean> {
90
+ if (listUsers.match(req.method, pathSegments)) {
91
+ const parsed = await listUsers.parse(req, res, pathSegments, queryParams);
92
+ if (!parsed) return true;
93
+ const users = getAllUsers();
94
+ json(res, { users });
95
+ return true;
96
+ }
97
+
98
+ if (createUserRoute.match(req.method, pathSegments)) {
99
+ const parsed = await createUserRoute.parse(req, res, pathSegments, queryParams);
100
+ if (!parsed) return true;
101
+ try {
102
+ const user = createUser(parsed.body);
103
+ json(res, { user });
104
+ } catch (err) {
105
+ jsonError(res, err instanceof Error ? err.message : "Failed to create user", 500);
106
+ }
107
+ return true;
108
+ }
109
+
110
+ if (updateUserRoute.match(req.method, pathSegments)) {
111
+ const parsed = await updateUserRoute.parse(req, res, pathSegments, queryParams);
112
+ if (!parsed) return true;
113
+
114
+ // 404 if user not found before update — keeps the contract honest.
115
+ if (!getUserById(parsed.params.id)) {
116
+ jsonError(res, "User not found", 404);
117
+ return true;
118
+ }
119
+
120
+ try {
121
+ const user = updateUser(parsed.params.id, parsed.body);
122
+ if (!user) {
123
+ jsonError(res, "User not found", 404);
124
+ return true;
125
+ }
126
+ json(res, { user });
127
+ } catch (err) {
128
+ jsonError(res, err instanceof Error ? err.message : "Failed to update user", 500);
129
+ }
130
+ return true;
131
+ }
132
+
133
+ return false;
134
+ }
@@ -194,6 +194,11 @@ class ClaudeSession implements ProviderSession {
194
194
  ENABLE_PROMPT_CACHING_1H: "1",
195
195
  ...(config.env || process.env),
196
196
  TASK_FILE: taskFilePath,
197
+ // Belt-and-braces: TASK_FILE on disk can disappear mid-session (race
198
+ // with task lifecycle), which silently drops the Stop-hook memory
199
+ // rater. The hook prefers these env vars when present. See PR #444.
200
+ AGENT_SWARM_TASK_ID: config.taskId,
201
+ AGENT_SWARM_AGENT_ID: config.agentId,
197
202
  } as Record<string, string>,
198
203
  stdout: "pipe",
199
204
  stderr: "pipe",
@@ -690,7 +690,7 @@ class CodexSession implements ProviderSession {
690
690
  // `contextPercent` is on a 0-100 scale across all providers — claude
691
691
  // emits `(used / total) * 100`, pi-mono passes through `usage.percent`
692
692
  // which is already 0-100. The dashboard at
693
- // new-ui/src/pages/tasks/[id]/page.tsx renders it via `.toFixed(0)`
693
+ // ui/src/pages/tasks/[id]/page.tsx renders it via `.toFixed(0)`
694
694
  // expecting an integer percent, so a 0-1 fraction would render as
695
695
  // "0%" instead of e.g. "40%".
696
696
  this.emit({
@@ -2,7 +2,7 @@ export {
2
2
  checkProviderCredentials,
3
3
  REQUIRED_CRED_VARS_BY_PROVIDER,
4
4
  type SupportedProvider,
5
- } from "./credentials";
5
+ } from "../commands/provider-credentials";
6
6
  export type {
7
7
  CostData,
8
8
  CredCheckOptions,
@@ -1,7 +1,6 @@
1
1
  import type { App } from "@slack/bolt";
2
2
  import type { WebClient } from "@slack/web-api";
3
3
  import {
4
- createTaskExtended,
5
4
  getAgentById,
6
5
  getAgentWorkingOnThread,
7
6
  getLeadAgent,