@desplega.ai/agent-swarm 1.9.0 → 1.10.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.
- package/Dockerfile +43 -5
- package/README.md +16 -0
- package/UI.md +40 -0
- package/deploy/prod-db.ts +2 -2
- package/docker-compose.example.yml +49 -50
- package/docker-entrypoint.sh +4 -1
- package/package.json +1 -1
- package/plugin/commands/review-offered-task.md +45 -0
- package/plugin/commands/start-leader.md +7 -5
- package/plugin/commands/start-worker.md +5 -0
- package/src/cli.tsx +44 -5
- package/src/commands/lead.ts +2 -0
- package/src/commands/runner.ts +273 -45
- package/src/commands/worker.ts +2 -0
- package/src/http.ts +145 -1
- package/src/prompts/base-prompt.ts +17 -3
- package/src/tests/runner-polling-api.test.ts +456 -0
- package/src/utils/pretty-print.ts +137 -120
- package/thoughts/shared/plans/2025-12-23-runner-level-polling.md +934 -0
- package/thoughts/shared/research/2025-12-22-runner-loop-architecture.md +582 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
4
|
+
import {
|
|
5
|
+
closeDb,
|
|
6
|
+
createAgent,
|
|
7
|
+
createTaskExtended,
|
|
8
|
+
getAgentById,
|
|
9
|
+
getDb,
|
|
10
|
+
getInboxSummary,
|
|
11
|
+
getOfferedTasksForAgent,
|
|
12
|
+
getPendingTaskForAgent,
|
|
13
|
+
getUnassignedTasksCount,
|
|
14
|
+
initDb,
|
|
15
|
+
updateAgentStatus,
|
|
16
|
+
} from "../be/db";
|
|
17
|
+
|
|
18
|
+
const TEST_DB_PATH = "./test-runner-polling.sqlite";
|
|
19
|
+
const TEST_PORT = 13013;
|
|
20
|
+
|
|
21
|
+
// Helper to parse path segments
|
|
22
|
+
function getPathSegments(url: string): string[] {
|
|
23
|
+
const pathEnd = url.indexOf("?");
|
|
24
|
+
const path = pathEnd === -1 ? url : url.slice(0, pathEnd);
|
|
25
|
+
return path.split("/").filter(Boolean);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Minimal HTTP handler for the endpoints we're testing
|
|
29
|
+
async function handleRequest(
|
|
30
|
+
req: { method: string; url: string; headers: { get: (key: string) => string | null } },
|
|
31
|
+
body: string,
|
|
32
|
+
): Promise<{ status: number; body: unknown }> {
|
|
33
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
34
|
+
const myAgentId = req.headers.get("x-agent-id");
|
|
35
|
+
|
|
36
|
+
// POST /api/agents - Register a new agent
|
|
37
|
+
if (
|
|
38
|
+
req.method === "POST" &&
|
|
39
|
+
pathSegments[0] === "api" &&
|
|
40
|
+
pathSegments[1] === "agents" &&
|
|
41
|
+
!pathSegments[2]
|
|
42
|
+
) {
|
|
43
|
+
const parsedBody = JSON.parse(body);
|
|
44
|
+
|
|
45
|
+
if (!parsedBody.name || typeof parsedBody.name !== "string") {
|
|
46
|
+
return { status: 400, body: { error: "Missing or invalid 'name' field" } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const agentId = myAgentId || crypto.randomUUID();
|
|
50
|
+
|
|
51
|
+
const result = getDb().transaction(() => {
|
|
52
|
+
const existingAgent = getAgentById(agentId);
|
|
53
|
+
if (existingAgent) {
|
|
54
|
+
if (existingAgent.status === "offline") {
|
|
55
|
+
updateAgentStatus(existingAgent.id, "idle");
|
|
56
|
+
}
|
|
57
|
+
return { agent: getAgentById(agentId), created: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const agent = createAgent({
|
|
61
|
+
id: agentId,
|
|
62
|
+
name: parsedBody.name,
|
|
63
|
+
isLead: parsedBody.isLead ?? false,
|
|
64
|
+
status: "idle",
|
|
65
|
+
description: parsedBody.description,
|
|
66
|
+
role: parsedBody.role,
|
|
67
|
+
capabilities: parsedBody.capabilities,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return { agent, created: true };
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
return { status: result.created ? 201 : 200, body: result.agent };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// GET /api/poll - Poll for triggers
|
|
77
|
+
if (req.method === "GET" && pathSegments[0] === "api" && pathSegments[1] === "poll") {
|
|
78
|
+
if (!myAgentId) {
|
|
79
|
+
return { status: 400, body: { error: "Missing X-Agent-ID header" } };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const result = getDb().transaction(() => {
|
|
83
|
+
const agent = getAgentById(myAgentId);
|
|
84
|
+
if (!agent) {
|
|
85
|
+
return { error: "Agent not found", status: 404 };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const offeredTasks = getOfferedTasksForAgent(myAgentId);
|
|
89
|
+
const firstOfferedTask = offeredTasks[0];
|
|
90
|
+
if (firstOfferedTask) {
|
|
91
|
+
return {
|
|
92
|
+
trigger: {
|
|
93
|
+
type: "task_offered",
|
|
94
|
+
taskId: firstOfferedTask.id,
|
|
95
|
+
task: firstOfferedTask,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pendingTask = getPendingTaskForAgent(myAgentId);
|
|
101
|
+
if (pendingTask) {
|
|
102
|
+
return {
|
|
103
|
+
trigger: {
|
|
104
|
+
type: "task_assigned",
|
|
105
|
+
taskId: pendingTask.id,
|
|
106
|
+
task: pendingTask,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (agent.isLead) {
|
|
112
|
+
const inbox = getInboxSummary(myAgentId);
|
|
113
|
+
if (inbox.mentionsCount > 0) {
|
|
114
|
+
return {
|
|
115
|
+
trigger: {
|
|
116
|
+
type: "unread_mentions",
|
|
117
|
+
mentionsCount: inbox.mentionsCount,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const unassignedCount = getUnassignedTasksCount();
|
|
123
|
+
if (unassignedCount > 0) {
|
|
124
|
+
return {
|
|
125
|
+
trigger: {
|
|
126
|
+
type: "pool_tasks_available",
|
|
127
|
+
count: unassignedCount,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { trigger: null };
|
|
134
|
+
})();
|
|
135
|
+
|
|
136
|
+
if ("error" in result) {
|
|
137
|
+
return { status: result.status ?? 500, body: { error: result.error } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { status: 200, body: result };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { status: 404, body: { error: "Not found" } };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Create test HTTP server
|
|
147
|
+
function createTestServer(): Server {
|
|
148
|
+
return createHttpServer(async (req, res) => {
|
|
149
|
+
res.setHeader("Content-Type", "application/json");
|
|
150
|
+
|
|
151
|
+
const chunks: Buffer[] = [];
|
|
152
|
+
for await (const chunk of req) {
|
|
153
|
+
chunks.push(chunk);
|
|
154
|
+
}
|
|
155
|
+
const body = Buffer.concat(chunks).toString();
|
|
156
|
+
|
|
157
|
+
const headers = {
|
|
158
|
+
get: (key: string) => req.headers[key.toLowerCase()] as string | null,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const result = await handleRequest(
|
|
162
|
+
{ method: req.method || "GET", url: req.url || "/", headers },
|
|
163
|
+
body,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
res.writeHead(result.status);
|
|
167
|
+
res.end(JSON.stringify(result.body));
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe("Runner-Level Polling API", () => {
|
|
172
|
+
let server: Server;
|
|
173
|
+
const baseUrl = `http://localhost:${TEST_PORT}`;
|
|
174
|
+
|
|
175
|
+
beforeAll(async () => {
|
|
176
|
+
// Clean up any existing test database
|
|
177
|
+
try {
|
|
178
|
+
await unlink(TEST_DB_PATH);
|
|
179
|
+
} catch {
|
|
180
|
+
// File doesn't exist, that's fine
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Initialize test database
|
|
184
|
+
initDb(TEST_DB_PATH);
|
|
185
|
+
|
|
186
|
+
// Start test server
|
|
187
|
+
server = createTestServer();
|
|
188
|
+
await new Promise<void>((resolve) => {
|
|
189
|
+
server.listen(TEST_PORT, () => {
|
|
190
|
+
console.log(`Test server listening on port ${TEST_PORT}`);
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
afterAll(async () => {
|
|
197
|
+
// Close server
|
|
198
|
+
await new Promise<void>((resolve) => {
|
|
199
|
+
server.close(() => resolve());
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Close database
|
|
203
|
+
closeDb();
|
|
204
|
+
|
|
205
|
+
// Clean up test database file
|
|
206
|
+
try {
|
|
207
|
+
await unlink(TEST_DB_PATH);
|
|
208
|
+
await unlink(`${TEST_DB_PATH}-wal`);
|
|
209
|
+
await unlink(`${TEST_DB_PATH}-shm`);
|
|
210
|
+
} catch {
|
|
211
|
+
// Files may not exist
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("POST /api/agents", () => {
|
|
216
|
+
test("should create a new agent with provided ID", async () => {
|
|
217
|
+
const agentId = "test-agent-001";
|
|
218
|
+
const response = await fetch(`${baseUrl}/api/agents`, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: {
|
|
221
|
+
"Content-Type": "application/json",
|
|
222
|
+
"X-Agent-ID": agentId,
|
|
223
|
+
},
|
|
224
|
+
body: JSON.stringify({ name: "Test Agent 1" }),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(response.status).toBe(201);
|
|
228
|
+
const data = await response.json();
|
|
229
|
+
expect(data.id).toBe(agentId);
|
|
230
|
+
expect(data.name).toBe("Test Agent 1");
|
|
231
|
+
expect(data.status).toBe("idle");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("should return existing agent if already registered", async () => {
|
|
235
|
+
const agentId = "test-agent-001";
|
|
236
|
+
const response = await fetch(`${baseUrl}/api/agents`, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: {
|
|
239
|
+
"Content-Type": "application/json",
|
|
240
|
+
"X-Agent-ID": agentId,
|
|
241
|
+
},
|
|
242
|
+
body: JSON.stringify({ name: "Test Agent 1 Updated" }),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(response.status).toBe(200); // Not 201 since it exists
|
|
246
|
+
const data = await response.json();
|
|
247
|
+
expect(data.id).toBe(agentId);
|
|
248
|
+
expect(data.name).toBe("Test Agent 1"); // Original name preserved
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("should generate UUID if no X-Agent-ID header", async () => {
|
|
252
|
+
const response = await fetch(`${baseUrl}/api/agents`, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: {
|
|
255
|
+
"Content-Type": "application/json",
|
|
256
|
+
},
|
|
257
|
+
body: JSON.stringify({ name: "Auto-ID Agent" }),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(response.status).toBe(201);
|
|
261
|
+
const data = await response.json();
|
|
262
|
+
expect(data.id).toBeDefined();
|
|
263
|
+
expect(data.id.length).toBe(36); // UUID format
|
|
264
|
+
expect(data.name).toBe("Auto-ID Agent");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("should return 400 if name is missing", async () => {
|
|
268
|
+
const response = await fetch(`${baseUrl}/api/agents`, {
|
|
269
|
+
method: "POST",
|
|
270
|
+
headers: {
|
|
271
|
+
"Content-Type": "application/json",
|
|
272
|
+
"X-Agent-ID": "test-agent-bad",
|
|
273
|
+
},
|
|
274
|
+
body: JSON.stringify({}),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(response.status).toBe(400);
|
|
278
|
+
const data = await response.json();
|
|
279
|
+
expect(data.error).toContain("name");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("should create lead agent with isLead flag", async () => {
|
|
283
|
+
const agentId = "test-lead-001";
|
|
284
|
+
const response = await fetch(`${baseUrl}/api/agents`, {
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: {
|
|
287
|
+
"Content-Type": "application/json",
|
|
288
|
+
"X-Agent-ID": agentId,
|
|
289
|
+
},
|
|
290
|
+
body: JSON.stringify({ name: "Lead Agent", isLead: true }),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(response.status).toBe(201);
|
|
294
|
+
const data = await response.json();
|
|
295
|
+
expect(data.id).toBe(agentId);
|
|
296
|
+
expect(data.isLead).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("GET /api/poll", () => {
|
|
301
|
+
test("should return 400 if X-Agent-ID header is missing", async () => {
|
|
302
|
+
const response = await fetch(`${baseUrl}/api/poll`);
|
|
303
|
+
|
|
304
|
+
expect(response.status).toBe(400);
|
|
305
|
+
const data = await response.json();
|
|
306
|
+
expect(data.error).toContain("X-Agent-ID");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("should return 404 if agent does not exist", async () => {
|
|
310
|
+
const response = await fetch(`${baseUrl}/api/poll`, {
|
|
311
|
+
headers: {
|
|
312
|
+
"X-Agent-ID": "non-existent-agent",
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(response.status).toBe(404);
|
|
317
|
+
const data = await response.json();
|
|
318
|
+
expect(data.error).toContain("not found");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("should return null trigger when no work available", async () => {
|
|
322
|
+
const response = await fetch(`${baseUrl}/api/poll`, {
|
|
323
|
+
headers: {
|
|
324
|
+
"X-Agent-ID": "test-agent-001",
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(response.status).toBe(200);
|
|
329
|
+
const data = await response.json();
|
|
330
|
+
expect(data.trigger).toBeNull();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("should return task_assigned trigger when pending task exists", async () => {
|
|
334
|
+
const agentId = "test-worker-with-task";
|
|
335
|
+
|
|
336
|
+
// Create agent first
|
|
337
|
+
await fetch(`${baseUrl}/api/agents`, {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: {
|
|
340
|
+
"Content-Type": "application/json",
|
|
341
|
+
"X-Agent-ID": agentId,
|
|
342
|
+
},
|
|
343
|
+
body: JSON.stringify({ name: "Worker With Task" }),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Create a pending task assigned to this agent
|
|
347
|
+
const task = createTaskExtended("Test task for worker", {
|
|
348
|
+
agentId,
|
|
349
|
+
creatorAgentId: "test-lead-001",
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const response = await fetch(`${baseUrl}/api/poll`, {
|
|
353
|
+
headers: {
|
|
354
|
+
"X-Agent-ID": agentId,
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(response.status).toBe(200);
|
|
359
|
+
const data = await response.json();
|
|
360
|
+
expect(data.trigger).not.toBeNull();
|
|
361
|
+
expect(data.trigger.type).toBe("task_assigned");
|
|
362
|
+
expect(data.trigger.taskId).toBe(task.id);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("should return task_offered trigger when offered task exists", async () => {
|
|
366
|
+
const agentId = "test-worker-with-offer";
|
|
367
|
+
|
|
368
|
+
// Create agent first
|
|
369
|
+
await fetch(`${baseUrl}/api/agents`, {
|
|
370
|
+
method: "POST",
|
|
371
|
+
headers: {
|
|
372
|
+
"Content-Type": "application/json",
|
|
373
|
+
"X-Agent-ID": agentId,
|
|
374
|
+
},
|
|
375
|
+
body: JSON.stringify({ name: "Worker With Offer" }),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Create an offered task for this agent (using offeredTo sets status to "offered")
|
|
379
|
+
const task = createTaskExtended("Offered task for worker", {
|
|
380
|
+
offeredTo: agentId,
|
|
381
|
+
creatorAgentId: "test-lead-001",
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const response = await fetch(`${baseUrl}/api/poll`, {
|
|
385
|
+
headers: {
|
|
386
|
+
"X-Agent-ID": agentId,
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(response.status).toBe(200);
|
|
391
|
+
const data = await response.json();
|
|
392
|
+
expect(data.trigger).not.toBeNull();
|
|
393
|
+
expect(data.trigger.type).toBe("task_offered");
|
|
394
|
+
expect(data.trigger.taskId).toBe(task.id);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("should return pool_tasks_available for lead when unassigned tasks exist", async () => {
|
|
398
|
+
const leadId = "test-lead-poll";
|
|
399
|
+
|
|
400
|
+
// Create lead agent
|
|
401
|
+
await fetch(`${baseUrl}/api/agents`, {
|
|
402
|
+
method: "POST",
|
|
403
|
+
headers: {
|
|
404
|
+
"Content-Type": "application/json",
|
|
405
|
+
"X-Agent-ID": leadId,
|
|
406
|
+
},
|
|
407
|
+
body: JSON.stringify({ name: "Lead For Poll Test", isLead: true }),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// Create an unassigned task (no agentId means status = "unassigned")
|
|
411
|
+
createTaskExtended("Unassigned task in pool", {
|
|
412
|
+
creatorAgentId: leadId,
|
|
413
|
+
// No agentId = unassigned
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const response = await fetch(`${baseUrl}/api/poll`, {
|
|
417
|
+
headers: {
|
|
418
|
+
"X-Agent-ID": leadId,
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
expect(response.status).toBe(200);
|
|
423
|
+
const data = await response.json();
|
|
424
|
+
expect(data.trigger).not.toBeNull();
|
|
425
|
+
expect(data.trigger.type).toBe("pool_tasks_available");
|
|
426
|
+
expect(data.trigger.count).toBeGreaterThan(0);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("worker should NOT see pool_tasks_available trigger", async () => {
|
|
430
|
+
const workerId = "test-worker-no-pool";
|
|
431
|
+
|
|
432
|
+
// Create worker agent (not lead)
|
|
433
|
+
await fetch(`${baseUrl}/api/agents`, {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: {
|
|
436
|
+
"Content-Type": "application/json",
|
|
437
|
+
"X-Agent-ID": workerId,
|
|
438
|
+
},
|
|
439
|
+
body: JSON.stringify({ name: "Worker No Pool Access", isLead: false }),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// There's already an unassigned task from previous test
|
|
443
|
+
|
|
444
|
+
const response = await fetch(`${baseUrl}/api/poll`, {
|
|
445
|
+
headers: {
|
|
446
|
+
"X-Agent-ID": workerId,
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
expect(response.status).toBe(200);
|
|
451
|
+
const data = await response.json();
|
|
452
|
+
// Worker should NOT see pool tasks
|
|
453
|
+
expect(data.trigger).toBeNull();
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
});
|