@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.57.2",
3
+ "version": "1.57.4",
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>",
@@ -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
- async function handleStructuredOutputFallback(
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<{ output?: string; failReason?: string } | null> {
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 null;
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
- task?: {
423
- task?: string;
424
- output?: string;
425
- outputSchema?: Record<string, unknown>;
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
- const task = taskData.task;
431
- if (!task?.outputSchema) return null; // No schema no fallback needed
432
- if (task.output) return null; // Agent already stored valid output
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
- ${task.task || "(no description)"}
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(task.outputSchema, null, 2)}
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(task.outputSchema);
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 null;
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
- if (fallback?.output) {
510
- body.output = fallback.output;
511
- } else if (fallback?.failReason) {
512
- status = "failed";
513
- body.status = "failed";
514
- body.failureReason = fallback.failReason;
515
- } else {
516
- body.output =
517
- "Process completed (runner wrapper fallback - agent may have provided explicit output)";
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
+ });