@desplega.ai/agent-swarm 1.51.2 → 1.52.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.
Files changed (55) hide show
  1. package/README.md +131 -0
  2. package/openapi.json +767 -4
  3. package/package.json +3 -1
  4. package/src/be/db.ts +669 -0
  5. package/src/be/migrations/019_skills.sql +65 -0
  6. package/src/be/migrations/020_approval_requests.sql +41 -0
  7. package/src/be/migrations/runner.ts +4 -4
  8. package/src/be/skill-parser.ts +70 -0
  9. package/src/be/skill-sync.ts +106 -0
  10. package/src/commands/runner.ts +299 -52
  11. package/src/http/agents.ts +29 -0
  12. package/src/http/approval-requests.ts +310 -0
  13. package/src/http/config.ts +3 -3
  14. package/src/http/index.ts +26 -2
  15. package/src/http/poll.ts +15 -0
  16. package/src/http/skills.ts +479 -0
  17. package/src/http/tasks.ts +94 -0
  18. package/src/linear/outbound.ts +12 -12
  19. package/src/prompts/base-prompt.ts +8 -0
  20. package/src/providers/claude-adapter.ts +19 -3
  21. package/src/scheduler/scheduler.ts +24 -1
  22. package/src/server.ts +29 -0
  23. package/src/slack/blocks.ts +1 -1
  24. package/src/tests/approval-requests.test.ts +948 -0
  25. package/src/tests/skill-parser.test.ts +178 -0
  26. package/src/tests/skill-sync.test.ts +171 -0
  27. package/src/tests/slack-blocks.test.ts +3 -2
  28. package/src/tests/structured-output.test.ts +1 -0
  29. package/src/tests/tool-annotations.test.ts +2 -1
  30. package/src/tests/tool-call-progress.test.ts +207 -0
  31. package/src/tests/tool-registrar-no-input.test.ts +114 -0
  32. package/src/tests/update-profile-auth.test.ts +1 -0
  33. package/src/tests/workflow-executors.test.ts +4 -2
  34. package/src/tools/request-human-input.ts +117 -0
  35. package/src/tools/skills/index.ts +11 -0
  36. package/src/tools/skills/skill-create.ts +105 -0
  37. package/src/tools/skills/skill-delete.ts +67 -0
  38. package/src/tools/skills/skill-get.ts +75 -0
  39. package/src/tools/skills/skill-install-remote.ts +152 -0
  40. package/src/tools/skills/skill-install.ts +101 -0
  41. package/src/tools/skills/skill-list.ts +77 -0
  42. package/src/tools/skills/skill-publish.ts +123 -0
  43. package/src/tools/skills/skill-search.ts +43 -0
  44. package/src/tools/skills/skill-sync-remote.ts +128 -0
  45. package/src/tools/skills/skill-uninstall.ts +60 -0
  46. package/src/tools/skills/skill-update.ts +128 -0
  47. package/src/tools/store-progress.ts +31 -0
  48. package/src/tools/templates.ts +28 -0
  49. package/src/tools/tool-config.ts +16 -0
  50. package/src/tools/utils.ts +9 -7
  51. package/src/types.ts +54 -0
  52. package/src/workflows/executors/human-in-the-loop.ts +273 -0
  53. package/src/workflows/executors/registry.ts +2 -0
  54. package/src/workflows/recovery.ts +72 -0
  55. package/src/workflows/resume.ts +65 -1
@@ -0,0 +1,310 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { z } from "zod";
3
+ import {
4
+ createApprovalRequest,
5
+ createTaskExtended,
6
+ getApprovalRequestById,
7
+ getTaskById,
8
+ listApprovalRequests,
9
+ resolveApprovalRequest,
10
+ } from "../be/db";
11
+ import { resolveTemplate } from "../prompts/resolver";
12
+ import { workflowEventBus } from "../workflows/event-bus";
13
+ import { route } from "./route-def";
14
+ import { json, jsonError } from "./utils";
15
+
16
+ // ─── Route Definitions ───────────────────────────────────────────────────────
17
+
18
+ const QuestionSchema = z.object({
19
+ id: z.string(),
20
+ type: z.enum(["approval", "text", "single-select", "multi-select", "boolean"]),
21
+ label: z.string(),
22
+ required: z.boolean().optional(),
23
+ description: z.string().optional(),
24
+ placeholder: z.string().optional(),
25
+ multiline: z.boolean().optional(),
26
+ options: z
27
+ .array(
28
+ z.object({
29
+ value: z.string(),
30
+ label: z.string(),
31
+ description: z.string().optional(),
32
+ }),
33
+ )
34
+ .optional(),
35
+ minSelections: z.number().int().min(0).optional(),
36
+ maxSelections: z.number().int().min(1).optional(),
37
+ defaultValue: z.boolean().optional(),
38
+ });
39
+
40
+ const createRoute = route({
41
+ method: "post",
42
+ path: "/api/approval-requests",
43
+ pattern: ["api", "approval-requests"],
44
+ summary: "Create a new approval request",
45
+ tags: ["ApprovalRequests"],
46
+ body: z.object({
47
+ title: z.string().min(1),
48
+ questions: z.array(QuestionSchema).min(1),
49
+ approvers: z.object({
50
+ users: z.array(z.string()).optional(),
51
+ roles: z.array(z.string()).optional(),
52
+ policy: z.union([
53
+ z.literal("any"),
54
+ z.literal("all"),
55
+ z.object({ min: z.number().int().min(1) }),
56
+ ]),
57
+ }),
58
+ workflowRunId: z.string().uuid().optional(),
59
+ workflowRunStepId: z.string().uuid().optional(),
60
+ sourceTaskId: z.string().uuid().optional(),
61
+ timeoutSeconds: z.number().int().min(1).optional(),
62
+ notifications: z
63
+ .array(
64
+ z.object({
65
+ channel: z.enum(["slack", "email"]),
66
+ target: z.string(),
67
+ }),
68
+ )
69
+ .optional(),
70
+ }),
71
+ responses: {
72
+ 201: { description: "Approval request created" },
73
+ 400: { description: "Validation error" },
74
+ },
75
+ auth: { apiKey: true },
76
+ });
77
+
78
+ const getByIdRoute = route({
79
+ method: "get",
80
+ path: "/api/approval-requests/{id}",
81
+ pattern: ["api", "approval-requests", null],
82
+ summary: "Get approval request details",
83
+ tags: ["ApprovalRequests"],
84
+ params: z.object({ id: z.string().uuid() }),
85
+ responses: {
86
+ 200: { description: "Approval request details" },
87
+ 404: { description: "Not found" },
88
+ },
89
+ auth: { apiKey: true },
90
+ });
91
+
92
+ const respondRoute = route({
93
+ method: "post",
94
+ path: "/api/approval-requests/{id}/respond",
95
+ pattern: ["api", "approval-requests", null, "respond"],
96
+ summary: "Submit a response to an approval request",
97
+ tags: ["ApprovalRequests"],
98
+ params: z.object({ id: z.string().uuid() }),
99
+ body: z.object({
100
+ responses: z.record(z.string(), z.unknown()),
101
+ respondedBy: z.string().optional(),
102
+ }),
103
+ responses: {
104
+ 200: { description: "Response recorded" },
105
+ 400: { description: "Validation error" },
106
+ 404: { description: "Not found" },
107
+ 409: { description: "Already resolved" },
108
+ },
109
+ auth: { apiKey: true },
110
+ });
111
+
112
+ const listRoute = route({
113
+ method: "get",
114
+ path: "/api/approval-requests",
115
+ pattern: ["api", "approval-requests"],
116
+ summary: "List approval requests with optional filters",
117
+ tags: ["ApprovalRequests"],
118
+ query: z.object({
119
+ status: z.string().optional(),
120
+ workflowRunId: z.string().optional(),
121
+ limit: z.coerce.number().optional(),
122
+ }),
123
+ responses: {
124
+ 200: { description: "List of approval requests" },
125
+ },
126
+ auth: { apiKey: true },
127
+ });
128
+
129
+ // ─── Handler ─────────────────────────────────────────────────────────────────
130
+
131
+ export async function handleApprovalRequests(
132
+ req: IncomingMessage,
133
+ res: ServerResponse,
134
+ pathSegments: string[],
135
+ queryParams: URLSearchParams,
136
+ ): Promise<boolean> {
137
+ // 4-segment: POST /api/approval-requests/{id}/respond
138
+ if (respondRoute.match(req.method, pathSegments)) {
139
+ const parsed = await respondRoute.parse(req, res, pathSegments, queryParams);
140
+ if (!parsed) return true;
141
+
142
+ const existing = getApprovalRequestById(parsed.params.id);
143
+ if (!existing) {
144
+ jsonError(res, "Approval request not found", 404);
145
+ return true;
146
+ }
147
+
148
+ if (existing.status !== "pending") {
149
+ jsonError(res, `Approval request already resolved with status: ${existing.status}`, 409);
150
+ return true;
151
+ }
152
+
153
+ // Determine status from responses: if any approval question has approved: false → rejected
154
+ const questions = existing.questions as Array<{ id: string; type: string }>;
155
+ let status: "approved" | "rejected" = "approved";
156
+ for (const q of questions) {
157
+ if (q.type === "approval") {
158
+ const answer = parsed.body.responses[q.id] as { approved?: boolean } | undefined;
159
+ if (answer && answer.approved === false) {
160
+ status = "rejected";
161
+ break;
162
+ }
163
+ }
164
+ }
165
+
166
+ const updated = resolveApprovalRequest(parsed.params.id, {
167
+ status,
168
+ responses: parsed.body.responses,
169
+ resolvedBy: parsed.body.respondedBy,
170
+ });
171
+
172
+ if (!updated) {
173
+ jsonError(
174
+ res,
175
+ "Failed to resolve approval request (may have been resolved concurrently)",
176
+ 409,
177
+ );
178
+ return true;
179
+ }
180
+
181
+ // Emit event for workflow resume
182
+ if (updated.workflowRunId && updated.workflowRunStepId) {
183
+ workflowEventBus.emit("approval.resolved", {
184
+ requestId: updated.id,
185
+ status: updated.status,
186
+ responses: updated.responses,
187
+ workflowRunId: updated.workflowRunId,
188
+ workflowRunStepId: updated.workflowRunStepId,
189
+ });
190
+ }
191
+
192
+ // For standalone (non-workflow) requests, create a follow-up task
193
+ // so the requesting agent is notified of the human's response
194
+ if (!updated.workflowRunId && updated.sourceTaskId) {
195
+ const sourceTask = getTaskById(updated.sourceTaskId);
196
+ if (sourceTask) {
197
+ // Format responses for the template
198
+ const formattedResponses = formatResponses(
199
+ updated.questions as Array<{ id: string; type: string; label: string }>,
200
+ updated.responses as Record<string, unknown>,
201
+ );
202
+
203
+ const { text: taskText } = resolveTemplate("hitl.follow_up", {
204
+ request_id: updated.id,
205
+ title: updated.title,
206
+ status: updated.status,
207
+ responses: formattedResponses,
208
+ });
209
+
210
+ createTaskExtended(taskText, {
211
+ agentId: sourceTask.agentId,
212
+ parentTaskId: updated.sourceTaskId,
213
+ source: "system",
214
+ taskType: "hitl-follow-up",
215
+ tags: ["hitl", "follow-up"],
216
+ // Explicit Slack metadata — parentTaskId auto-inherits too,
217
+ // but being explicit ensures the follow-up task always gets
218
+ // the right thread context even if inheritance logic changes.
219
+ slackChannelId: sourceTask.slackChannelId ?? undefined,
220
+ slackThreadTs: sourceTask.slackThreadTs ?? undefined,
221
+ slackUserId: sourceTask.slackUserId ?? undefined,
222
+ });
223
+ }
224
+ }
225
+
226
+ json(res, { approvalRequest: updated });
227
+ return true;
228
+ }
229
+
230
+ // 3-segment with param: GET /api/approval-requests/{id}
231
+ if (getByIdRoute.match(req.method, pathSegments)) {
232
+ const parsed = await getByIdRoute.parse(req, res, pathSegments, queryParams);
233
+ if (!parsed) return true;
234
+
235
+ const request = getApprovalRequestById(parsed.params.id);
236
+ if (!request) {
237
+ jsonError(res, "Approval request not found", 404);
238
+ return true;
239
+ }
240
+
241
+ json(res, { approvalRequest: request });
242
+ return true;
243
+ }
244
+
245
+ // 2-segment: POST /api/approval-requests (create)
246
+ if (createRoute.match(req.method, pathSegments)) {
247
+ const parsed = await createRoute.parse(req, res, pathSegments, queryParams);
248
+ if (!parsed) return true;
249
+
250
+ const id = crypto.randomUUID();
251
+ const request = createApprovalRequest({
252
+ id,
253
+ title: parsed.body.title,
254
+ questions: parsed.body.questions,
255
+ approvers: parsed.body.approvers,
256
+ workflowRunId: parsed.body.workflowRunId,
257
+ workflowRunStepId: parsed.body.workflowRunStepId,
258
+ sourceTaskId: parsed.body.sourceTaskId,
259
+ timeoutSeconds: parsed.body.timeoutSeconds,
260
+ notificationChannels: parsed.body.notifications,
261
+ });
262
+
263
+ res.writeHead(201, { "Content-Type": "application/json" });
264
+ res.end(JSON.stringify({ approvalRequest: request }));
265
+ return true;
266
+ }
267
+
268
+ // 2-segment: GET /api/approval-requests (list)
269
+ if (listRoute.match(req.method, pathSegments)) {
270
+ const parsed = await listRoute.parse(req, res, pathSegments, queryParams);
271
+ if (!parsed) return true;
272
+
273
+ const requests = listApprovalRequests({
274
+ status: parsed.query.status || undefined,
275
+ workflowRunId: parsed.query.workflowRunId || undefined,
276
+ limit: parsed.query.limit || undefined,
277
+ });
278
+
279
+ json(res, { approvalRequests: requests });
280
+ return true;
281
+ }
282
+
283
+ return false;
284
+ }
285
+
286
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
287
+
288
+ function formatResponses(
289
+ questions: Array<{ id: string; type: string; label: string }>,
290
+ responses: Record<string, unknown>,
291
+ ): string {
292
+ return questions
293
+ .map((q) => {
294
+ const answer = responses[q.id];
295
+ let answerText: string;
296
+ if (answer == null) {
297
+ answerText = "(no answer)";
298
+ } else if (q.type === "approval") {
299
+ const a = answer as { approved?: boolean; comment?: string };
300
+ answerText = a.approved ? "Approved" : "Rejected";
301
+ if (a.comment) answerText += ` — ${a.comment}`;
302
+ } else if (typeof answer === "object") {
303
+ answerText = JSON.stringify(answer);
304
+ } else {
305
+ answerText = String(answer);
306
+ }
307
+ return `- ${q.label}: ${answerText}`;
308
+ })
309
+ .join("\n");
310
+ }
@@ -69,12 +69,12 @@ const upsertConfig = route({
69
69
  tags: ["Config"],
70
70
  body: z.object({
71
71
  scope: z.enum(["global", "agent", "repo"]),
72
- scopeId: z.string().optional(),
72
+ scopeId: z.string().nullish(),
73
73
  key: z.string().min(1),
74
74
  value: z.unknown(),
75
75
  isSecret: z.boolean().optional(),
76
- envPath: z.string().optional(),
77
- description: z.string().optional(),
76
+ envPath: z.string().nullish(),
77
+ description: z.string().nullish(),
78
78
  }),
79
79
  responses: {
80
80
  200: { description: "Config entry upserted" },
package/src/http/index.ts CHANGED
@@ -4,8 +4,9 @@ import {
4
4
  type Server,
5
5
  type ServerResponse,
6
6
  } from "node:http";
7
+ import { assert, initialize } from "@desplega.ai/business-use";
7
8
  import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
- import { hasCapability } from "@/server";
9
+ import { getEnabledCapabilities, hasCapability } from "@/server";
9
10
  import { initAgentMail } from "../agentmail";
10
11
  import { closeDb } from "../be/db";
11
12
  import { initGitHub } from "../github";
@@ -16,6 +17,7 @@ import { startSlackApp, stopSlackApp } from "../slack";
16
17
  import { initWorkflows } from "../workflows";
17
18
  import { handleActiveSessions } from "./active-sessions";
18
19
  import { handleAgentRegister, handleAgentsRest } from "./agents";
20
+ import { handleApprovalRequests } from "./approval-requests";
19
21
  import { handleConfig } from "./config";
20
22
  import { handleCore, loadGlobalConfigsIntoEnv } from "./core";
21
23
  import { handleDbQuery } from "./db-query";
@@ -28,6 +30,7 @@ import { handlePromptTemplates } from "./prompt-templates";
28
30
  import { handleRepos } from "./repos";
29
31
  import { handleSchedules } from "./schedules";
30
32
  import { handleSessionData } from "./session-data";
33
+ import { handleSkills } from "./skills";
31
34
  import { handleStats } from "./stats";
32
35
  import { handleTasks } from "./tasks";
33
36
  import { handleTrackers } from "./trackers";
@@ -43,6 +46,7 @@ const globalState = globalThis as typeof globalThis & {
43
46
  __httpServer?: Server<typeof IncomingMessage, typeof ServerResponse>;
44
47
  __transports?: Record<string, StreamableHTTPServerTransport>;
45
48
  __sigintRegistered?: boolean;
49
+ __runId?: string;
46
50
  };
47
51
 
48
52
  // Clean up previous server on hot reload
@@ -104,10 +108,12 @@ const httpServer = createHttpServer(async (req, res) => {
104
108
  () => handleEpics(req, res, pathSegments, queryParams, myAgentId),
105
109
  () => handleSchedules(req, res, pathSegments, queryParams, myAgentId),
106
110
  () => handleWorkflows(req, res, pathSegments, queryParams, myAgentId),
111
+ () => handleApprovalRequests(req, res, pathSegments, queryParams),
107
112
  () => handleConfig(req, res, pathSegments, queryParams),
108
113
  () => handlePromptTemplates(req, res, pathSegments, queryParams),
109
114
  () => handleDbQuery(req, res, pathSegments, queryParams),
110
115
  () => handleRepos(req, res, pathSegments, queryParams),
116
+ () => handleSkills(req, res, pathSegments, queryParams, myAgentId),
111
117
  () => handleMemory(req, res, pathSegments, myAgentId),
112
118
  () => handleMcp(req, res, transports),
113
119
  ];
@@ -163,10 +169,26 @@ if (!globalState.__sigintRegistered) {
163
169
  process.on("SIGTERM", shutdown);
164
170
  }
165
171
 
172
+ if (!globalState.__runId) {
173
+ globalState.__runId = `run_${Date.now()}`;
174
+ }
175
+
176
+ // business-use initialization (no-op if envs not set)
177
+ initialize();
178
+
166
179
  httpServer
167
180
  .listen(port, async () => {
168
181
  console.log(`MCP HTTP server running on http://localhost:${port}/mcp`);
169
182
 
183
+ assert({
184
+ id: "listen",
185
+ flow: "api",
186
+ runId: globalState.__runId!,
187
+ data: {
188
+ capabilities: getEnabledCapabilities(),
189
+ },
190
+ });
191
+
170
192
  // Load global swarm configs into process.env (so integrations can read them)
171
193
  // Infrastructure-level env vars take precedence — only missing keys are filled.
172
194
  try {
@@ -201,7 +223,9 @@ httpServer
201
223
  const { startScheduler } = await import("../scheduler");
202
224
  const { getExecutorRegistry } = await import("../workflows");
203
225
  const intervalMs = Number(process.env.SCHEDULER_INTERVAL_MS) || 10000;
204
- startScheduler(getExecutorRegistry(), intervalMs);
226
+ startScheduler(getExecutorRegistry(), intervalMs, {
227
+ runId: globalState.__runId!,
228
+ });
205
229
  }
206
230
 
207
231
  // Start heartbeat triage (unless disabled)
package/src/http/poll.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { ensure } from "@desplega.ai/business-use";
2
3
  import { z } from "zod";
3
4
  import {
4
5
  claimMentions,
@@ -128,6 +129,20 @@ export async function handlePoll(
128
129
  if (pendingTask) {
129
130
  // Mark task as in_progress immediately to prevent duplicate polling
130
131
  startTask(pendingTask.id);
132
+
133
+ ensure({
134
+ id: "started",
135
+ flow: "task",
136
+ runId: pendingTask.id,
137
+ depIds: ["created"],
138
+ data: {
139
+ taskId: pendingTask.id,
140
+ agentId: myAgentId,
141
+ previousStatus: pendingTask.status,
142
+ },
143
+ validator: (data) => data.previousStatus === "pending",
144
+ });
145
+
131
146
  return {
132
147
  trigger: {
133
148
  type: "task_assigned",