@desplega.ai/agent-swarm 1.57.2 → 1.57.4
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/package.json
CHANGED
package/src/commands/runner.ts
CHANGED
|
@@ -128,7 +128,7 @@ async function ensureRepoForTask(
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/** API configuration for ping/close */
|
|
131
|
-
interface ApiConfig {
|
|
131
|
+
export interface ApiConfig {
|
|
132
132
|
apiUrl: string;
|
|
133
133
|
apiKey: string;
|
|
134
134
|
agentId: string;
|
|
@@ -401,11 +401,18 @@ async function updateProgressViaAPI(
|
|
|
401
401
|
* - Claude adapter: runs a fallback extraction via `claude -p --json-schema`
|
|
402
402
|
* - Pi-mono adapter: returns an error (no fallback available)
|
|
403
403
|
*/
|
|
404
|
-
|
|
404
|
+
export type FallbackResult =
|
|
405
|
+
| { kind: "extracted"; output: string }
|
|
406
|
+
| { kind: "already-has-output" }
|
|
407
|
+
| { kind: "no-schema"; lastProgress?: string }
|
|
408
|
+
| { kind: "schema-fail"; failReason: string }
|
|
409
|
+
| { kind: "fetch-error"; error: string };
|
|
410
|
+
|
|
411
|
+
export async function handleStructuredOutputFallback(
|
|
405
412
|
config: ApiConfig,
|
|
406
413
|
taskId: string,
|
|
407
414
|
adapterType: string,
|
|
408
|
-
): Promise<
|
|
415
|
+
): Promise<FallbackResult> {
|
|
409
416
|
const headers: Record<string, string> = {
|
|
410
417
|
"Content-Type": "application/json",
|
|
411
418
|
};
|
|
@@ -416,23 +423,33 @@ async function handleStructuredOutputFallback(
|
|
|
416
423
|
try {
|
|
417
424
|
// Fetch the task to check for outputSchema
|
|
418
425
|
const taskRes = await fetch(`${config.apiUrl}/api/tasks/${taskId}`, { headers });
|
|
419
|
-
if (!taskRes.ok) return
|
|
426
|
+
if (!taskRes.ok) return { kind: "fetch-error", error: `HTTP ${taskRes.status}` };
|
|
420
427
|
|
|
428
|
+
// Response is a flat spread of task fields + logs (see src/http/tasks.ts)
|
|
421
429
|
const taskData = (await taskRes.json()) as {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
430
|
+
id?: string;
|
|
431
|
+
task?: string;
|
|
432
|
+
status?: string;
|
|
433
|
+
output?: string;
|
|
434
|
+
progress?: string;
|
|
435
|
+
outputSchema?: Record<string, unknown>;
|
|
427
436
|
logs?: Array<{ eventType: string; newValue?: string; createdAt?: string }>;
|
|
428
437
|
};
|
|
429
438
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
439
|
+
if (!taskData.outputSchema) {
|
|
440
|
+
// No structured output required — extract last progress as context
|
|
441
|
+
const lastProgressLog = (taskData.logs ?? [])
|
|
442
|
+
.filter((l) => l.eventType === "task_progress")
|
|
443
|
+
.sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))[0];
|
|
444
|
+
const lastProgress = lastProgressLog?.newValue ?? taskData.progress;
|
|
445
|
+
return { kind: "no-schema", lastProgress: lastProgress || undefined };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (taskData.output) return { kind: "already-has-output" };
|
|
433
449
|
|
|
434
450
|
if (adapterType !== "claude") {
|
|
435
451
|
return {
|
|
452
|
+
kind: "schema-fail",
|
|
436
453
|
failReason:
|
|
437
454
|
"Structured output required by outputSchema but not provided via store-progress",
|
|
438
455
|
};
|
|
@@ -450,36 +467,37 @@ async function handleStructuredOutputFallback(
|
|
|
450
467
|
const extractionPrompt = `Extract structured data from this task's execution history.
|
|
451
468
|
|
|
452
469
|
## Task Description
|
|
453
|
-
${
|
|
470
|
+
${taskData.task || "(no description)"}
|
|
454
471
|
|
|
455
472
|
## Progress Updates (chronological)
|
|
456
473
|
${progressEntries || "(no progress recorded)"}
|
|
457
474
|
|
|
458
475
|
## Required Output Schema
|
|
459
|
-
${JSON.stringify(
|
|
476
|
+
${JSON.stringify(taskData.outputSchema, null, 2)}
|
|
460
477
|
|
|
461
478
|
Extract the structured data from the progress updates above. Return ONLY valid JSON matching the schema.`;
|
|
462
479
|
|
|
463
|
-
const schemaJson = JSON.stringify(
|
|
480
|
+
const schemaJson = JSON.stringify(taskData.outputSchema);
|
|
464
481
|
const result =
|
|
465
482
|
await Bun.$`claude -p ${extractionPrompt} --json-schema ${schemaJson} --output-format json --model sonnet`
|
|
466
483
|
.json()
|
|
467
484
|
.catch(() => null);
|
|
468
485
|
|
|
469
486
|
if (result && typeof result === "object") {
|
|
470
|
-
return { output: JSON.stringify(result) };
|
|
487
|
+
return { kind: "extracted", output: JSON.stringify(result) };
|
|
471
488
|
}
|
|
472
489
|
|
|
473
490
|
return {
|
|
491
|
+
kind: "schema-fail",
|
|
474
492
|
failReason: "Structured output extraction fallback failed — could not produce valid JSON",
|
|
475
493
|
};
|
|
476
494
|
} catch (err) {
|
|
477
495
|
console.warn(`[runner] Structured output fallback failed for task ${taskId}: ${err}`);
|
|
478
|
-
return
|
|
496
|
+
return { kind: "fetch-error", error: String(err) };
|
|
479
497
|
}
|
|
480
498
|
}
|
|
481
499
|
|
|
482
|
-
async function ensureTaskFinished(
|
|
500
|
+
export async function ensureTaskFinished(
|
|
483
501
|
config: ApiConfig,
|
|
484
502
|
role: string,
|
|
485
503
|
taskId: string,
|
|
@@ -506,15 +524,31 @@ async function ensureTaskFinished(
|
|
|
506
524
|
const adapterType = process.env.HARNESS_PROVIDER || "claude";
|
|
507
525
|
const fallback = await handleStructuredOutputFallback(config, taskId, adapterType);
|
|
508
526
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
527
|
+
console.log(`[${role}] Task ${taskId.slice(0, 8)} fallback result: ${fallback.kind}`);
|
|
528
|
+
|
|
529
|
+
switch (fallback.kind) {
|
|
530
|
+
case "extracted":
|
|
531
|
+
body.output = fallback.output;
|
|
532
|
+
break;
|
|
533
|
+
case "already-has-output":
|
|
534
|
+
body.output = "Process completed successfully";
|
|
535
|
+
break;
|
|
536
|
+
case "no-schema": {
|
|
537
|
+
if (fallback.lastProgress) {
|
|
538
|
+
body.output = fallback.lastProgress.slice(0, 2000);
|
|
539
|
+
} else {
|
|
540
|
+
body.output = "Process completed successfully (no output captured)";
|
|
541
|
+
}
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
case "schema-fail":
|
|
545
|
+
status = "failed";
|
|
546
|
+
body.status = "failed";
|
|
547
|
+
body.failureReason = fallback.failReason;
|
|
548
|
+
break;
|
|
549
|
+
case "fetch-error":
|
|
550
|
+
body.output = `Process completed (could not verify task state: ${fallback.error})`;
|
|
551
|
+
break;
|
|
518
552
|
}
|
|
519
553
|
}
|
|
520
554
|
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
3
|
+
import {
|
|
4
|
+
type ApiConfig,
|
|
5
|
+
ensureTaskFinished,
|
|
6
|
+
type FallbackResult,
|
|
7
|
+
handleStructuredOutputFallback,
|
|
8
|
+
} from "../commands/runner";
|
|
9
|
+
|
|
10
|
+
const TEST_PORT = 13099;
|
|
11
|
+
|
|
12
|
+
// Configurable mock responses per test
|
|
13
|
+
let mockGetTask: Record<string, unknown> | null = null;
|
|
14
|
+
let mockGetTaskStatus = 200;
|
|
15
|
+
let lastFinishBody: Record<string, unknown> | null = null;
|
|
16
|
+
let mockFinishResponse: Record<string, unknown> = { success: true };
|
|
17
|
+
|
|
18
|
+
function resetMocks() {
|
|
19
|
+
mockGetTask = null;
|
|
20
|
+
mockGetTaskStatus = 200;
|
|
21
|
+
lastFinishBody = null;
|
|
22
|
+
mockFinishResponse = { success: true };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let server: Server;
|
|
26
|
+
|
|
27
|
+
function makeConfig(port = TEST_PORT): ApiConfig {
|
|
28
|
+
return {
|
|
29
|
+
apiUrl: `http://localhost:${port}`,
|
|
30
|
+
apiKey: "test-key",
|
|
31
|
+
agentId: "test-agent-id",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
server = createHttpServer(async (req, res) => {
|
|
37
|
+
const chunks: Buffer[] = [];
|
|
38
|
+
for await (const chunk of req) {
|
|
39
|
+
chunks.push(chunk);
|
|
40
|
+
}
|
|
41
|
+
const body = Buffer.concat(chunks).toString();
|
|
42
|
+
const url = req.url || "";
|
|
43
|
+
|
|
44
|
+
// GET /api/tasks/:id
|
|
45
|
+
if (req.method === "GET" && /^\/api\/tasks\/[^/]+$/.test(url)) {
|
|
46
|
+
if (!mockGetTask) {
|
|
47
|
+
res.writeHead(mockGetTaskStatus);
|
|
48
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
res.writeHead(mockGetTaskStatus, { "Content-Type": "application/json" });
|
|
52
|
+
res.end(JSON.stringify(mockGetTask));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// POST /api/tasks/:id/finish
|
|
57
|
+
if (req.method === "POST" && /^\/api\/tasks\/[^/]+\/finish$/.test(url)) {
|
|
58
|
+
lastFinishBody = body ? JSON.parse(body) : null;
|
|
59
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
60
|
+
res.end(JSON.stringify(mockFinishResponse));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
res.writeHead(404);
|
|
65
|
+
res.end("Not found");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
await new Promise<void>((resolve) => {
|
|
69
|
+
server.listen(TEST_PORT, () => resolve());
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterAll(() => {
|
|
74
|
+
server.close();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("handleStructuredOutputFallback", () => {
|
|
78
|
+
test("returns no-schema with lastProgress when task has progress logs", async () => {
|
|
79
|
+
resetMocks();
|
|
80
|
+
mockGetTask = {
|
|
81
|
+
id: "task-1",
|
|
82
|
+
task: "Do something",
|
|
83
|
+
status: "in_progress",
|
|
84
|
+
output: null,
|
|
85
|
+
progress: "older progress",
|
|
86
|
+
logs: [
|
|
87
|
+
{ eventType: "task_progress", newValue: "first update", createdAt: "2025-01-01T00:00:00Z" },
|
|
88
|
+
{
|
|
89
|
+
eventType: "task_progress",
|
|
90
|
+
newValue: "latest update",
|
|
91
|
+
createdAt: "2025-01-01T01:00:00Z",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
eventType: "task_status_change",
|
|
95
|
+
newValue: "in_progress",
|
|
96
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const result = await handleStructuredOutputFallback(makeConfig(), "task-1", "claude");
|
|
102
|
+
expect(result).toEqual({ kind: "no-schema", lastProgress: "latest update" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns no-schema with progress field when no progress logs exist", async () => {
|
|
106
|
+
resetMocks();
|
|
107
|
+
mockGetTask = {
|
|
108
|
+
id: "task-2",
|
|
109
|
+
task: "Do something",
|
|
110
|
+
status: "in_progress",
|
|
111
|
+
output: null,
|
|
112
|
+
progress: "some progress text",
|
|
113
|
+
logs: [],
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const result = await handleStructuredOutputFallback(makeConfig(), "task-2", "claude");
|
|
117
|
+
expect(result).toEqual({ kind: "no-schema", lastProgress: "some progress text" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("returns no-schema without lastProgress when no progress at all", async () => {
|
|
121
|
+
resetMocks();
|
|
122
|
+
mockGetTask = {
|
|
123
|
+
id: "task-3",
|
|
124
|
+
task: "Do something",
|
|
125
|
+
status: "in_progress",
|
|
126
|
+
output: null,
|
|
127
|
+
progress: null,
|
|
128
|
+
logs: [],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const result = await handleStructuredOutputFallback(makeConfig(), "task-3", "claude");
|
|
132
|
+
expect(result).toEqual({ kind: "no-schema", lastProgress: undefined });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("returns already-has-output when task has output and outputSchema", async () => {
|
|
136
|
+
resetMocks();
|
|
137
|
+
mockGetTask = {
|
|
138
|
+
id: "task-4",
|
|
139
|
+
task: "Do something",
|
|
140
|
+
status: "completed",
|
|
141
|
+
output: '{"result": "done"}',
|
|
142
|
+
outputSchema: { type: "object", properties: { result: { type: "string" } } },
|
|
143
|
+
logs: [],
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const result = await handleStructuredOutputFallback(makeConfig(), "task-4", "claude");
|
|
147
|
+
expect(result).toEqual({ kind: "already-has-output" });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("returns fetch-error when API returns non-200", async () => {
|
|
151
|
+
resetMocks();
|
|
152
|
+
mockGetTask = null;
|
|
153
|
+
mockGetTaskStatus = 500;
|
|
154
|
+
|
|
155
|
+
const result = await handleStructuredOutputFallback(makeConfig(), "task-5", "claude");
|
|
156
|
+
expect(result).toEqual({ kind: "fetch-error", error: "HTTP 500" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("returns schema-fail for non-claude adapter with outputSchema", async () => {
|
|
160
|
+
resetMocks();
|
|
161
|
+
mockGetTask = {
|
|
162
|
+
id: "task-6",
|
|
163
|
+
task: "Do something",
|
|
164
|
+
status: "in_progress",
|
|
165
|
+
output: null,
|
|
166
|
+
outputSchema: { type: "object", properties: { result: { type: "string" } } },
|
|
167
|
+
logs: [],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const result = await handleStructuredOutputFallback(makeConfig(), "task-6", "pi-mono");
|
|
171
|
+
expect(result).toEqual({
|
|
172
|
+
kind: "schema-fail",
|
|
173
|
+
failReason: "Structured output required by outputSchema but not provided via store-progress",
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("returns fetch-error on network error", async () => {
|
|
178
|
+
resetMocks();
|
|
179
|
+
// Use a port that nothing listens on
|
|
180
|
+
const badConfig = makeConfig(19999);
|
|
181
|
+
|
|
182
|
+
const result = await handleStructuredOutputFallback(badConfig, "task-7", "claude");
|
|
183
|
+
expect(result.kind).toBe("fetch-error");
|
|
184
|
+
expect((result as { kind: "fetch-error"; error: string }).error).toBeTruthy();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("ensureTaskFinished", () => {
|
|
189
|
+
test("sets output to last progress for no-schema fallback", async () => {
|
|
190
|
+
resetMocks();
|
|
191
|
+
mockGetTask = {
|
|
192
|
+
id: "task-10",
|
|
193
|
+
task: "Do work",
|
|
194
|
+
status: "in_progress",
|
|
195
|
+
output: null,
|
|
196
|
+
progress: null,
|
|
197
|
+
logs: [
|
|
198
|
+
{
|
|
199
|
+
eventType: "task_progress",
|
|
200
|
+
newValue: "Did some work here",
|
|
201
|
+
createdAt: "2025-01-01T00:00:00Z",
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
await ensureTaskFinished(makeConfig(), "worker", "task-10", 0);
|
|
207
|
+
|
|
208
|
+
expect(lastFinishBody).toBeTruthy();
|
|
209
|
+
expect(lastFinishBody!.status).toBe("completed");
|
|
210
|
+
expect(lastFinishBody!.output).toBe("Did some work here");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("sets generic message when no-schema and no progress", async () => {
|
|
214
|
+
resetMocks();
|
|
215
|
+
mockGetTask = {
|
|
216
|
+
id: "task-11",
|
|
217
|
+
task: "Do work",
|
|
218
|
+
status: "in_progress",
|
|
219
|
+
output: null,
|
|
220
|
+
progress: null,
|
|
221
|
+
logs: [],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
await ensureTaskFinished(makeConfig(), "worker", "task-11", 0);
|
|
225
|
+
|
|
226
|
+
expect(lastFinishBody).toBeTruthy();
|
|
227
|
+
expect(lastFinishBody!.status).toBe("completed");
|
|
228
|
+
expect(lastFinishBody!.output).toBe("Process completed successfully (no output captured)");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("sets failed status for schema-fail fallback", async () => {
|
|
232
|
+
resetMocks();
|
|
233
|
+
mockGetTask = {
|
|
234
|
+
id: "task-12",
|
|
235
|
+
task: "Do work",
|
|
236
|
+
status: "in_progress",
|
|
237
|
+
output: null,
|
|
238
|
+
outputSchema: { type: "object" },
|
|
239
|
+
logs: [],
|
|
240
|
+
};
|
|
241
|
+
// Force non-claude adapter via env
|
|
242
|
+
const origProvider = process.env.HARNESS_PROVIDER;
|
|
243
|
+
process.env.HARNESS_PROVIDER = "pi-mono";
|
|
244
|
+
|
|
245
|
+
await ensureTaskFinished(makeConfig(), "worker", "task-12", 0);
|
|
246
|
+
|
|
247
|
+
process.env.HARNESS_PROVIDER = origProvider;
|
|
248
|
+
|
|
249
|
+
expect(lastFinishBody).toBeTruthy();
|
|
250
|
+
expect(lastFinishBody!.status).toBe("failed");
|
|
251
|
+
expect(lastFinishBody!.failureReason).toContain("outputSchema");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("handles alreadyFinished gracefully", async () => {
|
|
255
|
+
resetMocks();
|
|
256
|
+
mockGetTask = {
|
|
257
|
+
id: "task-13",
|
|
258
|
+
task: "Do work",
|
|
259
|
+
status: "in_progress",
|
|
260
|
+
output: null,
|
|
261
|
+
progress: null,
|
|
262
|
+
logs: [],
|
|
263
|
+
};
|
|
264
|
+
mockFinishResponse = { success: true, alreadyFinished: true, task: { status: "completed" } };
|
|
265
|
+
|
|
266
|
+
// Should not throw
|
|
267
|
+
await ensureTaskFinished(makeConfig(), "worker", "task-13", 0);
|
|
268
|
+
expect(lastFinishBody).toBeTruthy();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("sends failure reason when exit code is non-zero", async () => {
|
|
272
|
+
resetMocks();
|
|
273
|
+
|
|
274
|
+
await ensureTaskFinished(makeConfig(), "worker", "task-14", 1, "Out of memory");
|
|
275
|
+
|
|
276
|
+
expect(lastFinishBody).toBeTruthy();
|
|
277
|
+
expect(lastFinishBody!.status).toBe("failed");
|
|
278
|
+
expect(lastFinishBody!.failureReason).toBe("Out of memory");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("truncates long progress to 2000 chars", async () => {
|
|
282
|
+
resetMocks();
|
|
283
|
+
const longProgress = "x".repeat(3000);
|
|
284
|
+
mockGetTask = {
|
|
285
|
+
id: "task-15",
|
|
286
|
+
task: "Do work",
|
|
287
|
+
status: "in_progress",
|
|
288
|
+
output: null,
|
|
289
|
+
progress: longProgress,
|
|
290
|
+
logs: [],
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
await ensureTaskFinished(makeConfig(), "worker", "task-15", 0);
|
|
294
|
+
|
|
295
|
+
expect(lastFinishBody).toBeTruthy();
|
|
296
|
+
expect((lastFinishBody!.output as string).length).toBe(2000);
|
|
297
|
+
});
|
|
298
|
+
});
|