@desplega.ai/agent-swarm 1.100.3 → 1.100.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/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.100.3",
5
+ "version": "1.100.4",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.100.3",
3
+ "version": "1.100.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>",
@@ -255,6 +255,231 @@ describe("SwarmScriptExecutor", () => {
255
255
  expect(scriptStep?.output).toMatchObject({ result: { seen: "mapped-value" } });
256
256
  });
257
257
 
258
+ test("exact object token outside swarm-script args is still stringified", async () => {
259
+ const wf = makeWorkflow({
260
+ nodes: [
261
+ {
262
+ id: "echo",
263
+ type: "echo",
264
+ config: { value: "{{trigger.payload}}" },
265
+ },
266
+ ],
267
+ });
268
+
269
+ const runId = await startWorkflowExecution(wf, { payload: { a: 1 } }, registry);
270
+ const run = getWorkflowRun(runId);
271
+ const steps = getWorkflowRunStepsByRunId(runId);
272
+ const echoStep = steps.find((step) => step.nodeId === "echo");
273
+
274
+ expect(run?.status).toBe("completed");
275
+ expect(echoStep?.status).toBe("completed");
276
+ expect(echoStep?.output).toEqual({ value: '{"a":1}' });
277
+ });
278
+
279
+ test("{{path}} args: object arg is injected as raw object, not JSON string", async () => {
280
+ await saveScript(
281
+ "echo-obj",
282
+ `export default async (args: { data: Record<string, unknown> }) => ({ isObject: typeof args.data === "object" && !Array.isArray(args.data), keys: Object.keys(args.data ?? {}) });`,
283
+ );
284
+ const wf = makeWorkflow({
285
+ nodes: [
286
+ {
287
+ id: "script",
288
+ type: "swarm-script",
289
+ config: {
290
+ scriptName: "echo-obj",
291
+ args: { data: "{{trigger.payload}}" },
292
+ },
293
+ },
294
+ ],
295
+ });
296
+
297
+ const runId = await startWorkflowExecution(wf, { payload: { a: 1, b: 2 } }, registry);
298
+ const run = getWorkflowRun(runId);
299
+ const steps = getWorkflowRunStepsByRunId(runId);
300
+ const scriptStep = steps.find((step) => step.nodeId === "script");
301
+
302
+ expect(run?.status).toBe("completed");
303
+ expect(scriptStep?.status).toBe("completed");
304
+ expect(scriptStep?.output).toMatchObject({ result: { isObject: true, keys: ["a", "b"] } });
305
+ });
306
+
307
+ test("{{path}} args: array arg is injected as raw array, not JSON string", async () => {
308
+ await saveScript(
309
+ "echo-arr",
310
+ `export default async (args: { items: string[] }) => ({ isArray: Array.isArray(args.items), length: args.items.length });`,
311
+ );
312
+ const wf = makeWorkflow({
313
+ nodes: [
314
+ {
315
+ id: "script",
316
+ type: "swarm-script",
317
+ config: {
318
+ scriptName: "echo-arr",
319
+ args: { items: "{{trigger.list}}" },
320
+ },
321
+ },
322
+ ],
323
+ });
324
+
325
+ const runId = await startWorkflowExecution(wf, { list: ["x", "y", "z"] }, registry);
326
+ const run = getWorkflowRun(runId);
327
+ const steps = getWorkflowRunStepsByRunId(runId);
328
+ const scriptStep = steps.find((step) => step.nodeId === "script");
329
+
330
+ expect(run?.status).toBe("completed");
331
+ expect(scriptStep?.status).toBe("completed");
332
+ expect(scriptStep?.output).toMatchObject({ result: { isArray: true, length: 3 } });
333
+ });
334
+
335
+ test("{{path}} args: empty array is injected as raw empty array with length 0, not '[]' string", async () => {
336
+ await saveScript(
337
+ "echo-empty-arr",
338
+ `export default async (args: { items: string[] }) => ({ isArray: Array.isArray(args.items), length: args.items.length });`,
339
+ );
340
+ const wf = makeWorkflow({
341
+ nodes: [
342
+ {
343
+ id: "script",
344
+ type: "swarm-script",
345
+ config: {
346
+ scriptName: "echo-empty-arr",
347
+ args: { items: "{{trigger.empty}}" },
348
+ },
349
+ },
350
+ ],
351
+ });
352
+
353
+ const runId = await startWorkflowExecution(wf, { empty: [] }, registry);
354
+ const run = getWorkflowRun(runId);
355
+ const steps = getWorkflowRunStepsByRunId(runId);
356
+ const scriptStep = steps.find((step) => step.nodeId === "script");
357
+
358
+ expect(run?.status).toBe("completed");
359
+ expect(scriptStep?.status).toBe("completed");
360
+ expect(scriptStep?.output).toMatchObject({ result: { isArray: true, length: 0 } });
361
+ });
362
+
363
+ test("{{path}} args: string scalar arg is injected as the string value", async () => {
364
+ await saveScript(
365
+ "echo-str",
366
+ `export default async (args: { name: string }) => ({ isString: typeof args.name === "string", value: args.name });`,
367
+ );
368
+ const wf = makeWorkflow({
369
+ nodes: [
370
+ {
371
+ id: "script",
372
+ type: "swarm-script",
373
+ config: {
374
+ scriptName: "echo-str",
375
+ args: { name: "{{trigger.ruleName}}" },
376
+ },
377
+ },
378
+ ],
379
+ });
380
+
381
+ const runId = await startWorkflowExecution(
382
+ wf,
383
+ { ruleName: "local-rules/cognitive-complexity" },
384
+ registry,
385
+ );
386
+ const run = getWorkflowRun(runId);
387
+ const steps = getWorkflowRunStepsByRunId(runId);
388
+ const scriptStep = steps.find((step) => step.nodeId === "script");
389
+
390
+ expect(run?.status).toBe("completed");
391
+ expect(scriptStep?.status).toBe("completed");
392
+ expect(scriptStep?.output).toMatchObject({
393
+ result: { isString: true, value: "local-rules/cognitive-complexity" },
394
+ });
395
+ });
396
+
397
+ test("{{path}} args: number scalar arg is injected as a number, not a string", async () => {
398
+ await saveScript(
399
+ "echo-num",
400
+ `export default async (args: { count: number }) => ({ isNumber: typeof args.count === "number", value: args.count });`,
401
+ );
402
+ const wf = makeWorkflow({
403
+ nodes: [
404
+ {
405
+ id: "script",
406
+ type: "swarm-script",
407
+ config: {
408
+ scriptName: "echo-num",
409
+ args: { count: "{{trigger.maxFiles}}" },
410
+ },
411
+ },
412
+ ],
413
+ });
414
+
415
+ const runId = await startWorkflowExecution(wf, { maxFiles: 3 }, registry);
416
+ const run = getWorkflowRun(runId);
417
+ const steps = getWorkflowRunStepsByRunId(runId);
418
+ const scriptStep = steps.find((step) => step.nodeId === "script");
419
+
420
+ expect(run?.status).toBe("completed");
421
+ expect(scriptStep?.status).toBe("completed");
422
+ expect(scriptStep?.output).toMatchObject({ result: { isNumber: true, value: 3 } });
423
+ });
424
+
425
+ test("{{path}} args: boolean scalar arg is injected as a boolean, not a string", async () => {
426
+ await saveScript(
427
+ "echo-bool",
428
+ `export default async (args: { enabled: boolean }) => ({ isBoolean: typeof args.enabled === "boolean", value: args.enabled });`,
429
+ );
430
+ const wf = makeWorkflow({
431
+ nodes: [
432
+ {
433
+ id: "script",
434
+ type: "swarm-script",
435
+ config: {
436
+ scriptName: "echo-bool",
437
+ args: { enabled: "{{trigger.enabled}}" },
438
+ },
439
+ },
440
+ ],
441
+ });
442
+
443
+ const runId = await startWorkflowExecution(wf, { enabled: false }, registry);
444
+ const run = getWorkflowRun(runId);
445
+ const steps = getWorkflowRunStepsByRunId(runId);
446
+ const scriptStep = steps.find((step) => step.nodeId === "script");
447
+
448
+ expect(run?.status).toBe("completed");
449
+ expect(scriptStep?.status).toBe("completed");
450
+ expect(scriptStep?.output).toMatchObject({ result: { isBoolean: true, value: false } });
451
+ });
452
+
453
+ test("{{path}} args: mixed string template still produces a string via interpolation", async () => {
454
+ await saveScript(
455
+ "echo-mixed",
456
+ `export default async (args: { label: string }) => ({ isString: typeof args.label === "string", value: args.label });`,
457
+ );
458
+ const wf = makeWorkflow({
459
+ nodes: [
460
+ {
461
+ id: "script",
462
+ type: "swarm-script",
463
+ config: {
464
+ scriptName: "echo-mixed",
465
+ args: { label: "rule-{{trigger.ruleName}}" },
466
+ },
467
+ },
468
+ ],
469
+ });
470
+
471
+ const runId = await startWorkflowExecution(wf, { ruleName: "no-explicit-any" }, registry);
472
+ const run = getWorkflowRun(runId);
473
+ const steps = getWorkflowRunStepsByRunId(runId);
474
+ const scriptStep = steps.find((step) => step.nodeId === "script");
475
+
476
+ expect(run?.status).toBe("completed");
477
+ expect(scriptStep?.status).toBe("completed");
478
+ expect(scriptStep?.output).toMatchObject({
479
+ result: { isString: true, value: "rule-no-explicit-any" },
480
+ });
481
+ });
482
+
258
483
  test("fsMode workspace-rw is rejected at config validation with a clear error message", async () => {
259
484
  await saveScript("noop", `export default async () => ({ ok: true });`);
260
485
  const executor = new SwarmScriptExecutor(deps);
@@ -205,6 +205,23 @@ describe("deepInterpolate", () => {
205
205
  expect(unresolved).toEqual([]);
206
206
  });
207
207
 
208
+ test("exact object token is stringified by default", () => {
209
+ const { value, unresolved } = deepInterpolate("{{body}}", { body: { message: "hello" } });
210
+ expect(value).toBe('{"message":"hello"}');
211
+ expect(unresolved).toEqual([]);
212
+ });
213
+
214
+ test("exact object token preserves raw value when requested", () => {
215
+ const body = { message: "hello" };
216
+ const { value, unresolved } = deepInterpolate(
217
+ "{{body}}",
218
+ { body },
219
+ { preserveRawTokens: true },
220
+ );
221
+ expect(value).toBe(body);
222
+ expect(unresolved).toEqual([]);
223
+ });
224
+
208
225
  test("mixed array (string + number + boolean)", () => {
209
226
  const { value, unresolved } = deepInterpolate(["{{name}}", 42, true, null], {
210
227
  name: "Test",
@@ -494,7 +494,7 @@ async function executeStep(
494
494
  }
495
495
 
496
496
  // 4. Deep-interpolate config using local context (not global ctx)
497
- const { value: interpolatedValue, unresolved } = deepInterpolate(node.config, interpolationCtx);
497
+ const { value: interpolatedValue, unresolved } = interpolateNodeConfig(node, interpolationCtx);
498
498
  const interpolatedConfig = interpolatedValue as Record<string, unknown>;
499
499
  const executionCtx: Record<string, unknown> = { ...ctx, ...interpolationCtx };
500
500
 
@@ -709,6 +709,27 @@ export function findReadyNodes(
709
709
  });
710
710
  }
711
711
 
712
+ export function interpolateNodeConfig(
713
+ node: Pick<WorkflowNode, "type" | "config">,
714
+ interpolationCtx: Record<string, unknown>,
715
+ ): { value: unknown; unresolved: string[] } {
716
+ if (node.type !== "swarm-script" || !Object.hasOwn(node.config, "args")) {
717
+ return deepInterpolate(node.config, interpolationCtx);
718
+ }
719
+
720
+ const { args, ...configWithoutArgs } = node.config;
721
+ const configResult = deepInterpolate(configWithoutArgs, interpolationCtx);
722
+ const argsResult = deepInterpolate(args, interpolationCtx, { preserveRawTokens: true });
723
+
724
+ return {
725
+ value: {
726
+ ...(configResult.value as Record<string, unknown>),
727
+ args: argsResult.value,
728
+ },
729
+ unresolved: [...configResult.unresolved, ...argsResult.unresolved],
730
+ };
731
+ }
732
+
712
733
  // ─── Helpers ───────────────────────────────────────────────
713
734
 
714
735
  function timeoutPromise(ms: number): Promise<never> {
@@ -8,9 +8,8 @@ import {
8
8
  import type { RetryPolicy } from "../types";
9
9
  import { checkpointStep, checkpointStepFailure } from "./checkpoint";
10
10
  import { getSuccessors } from "./definition";
11
- import { walkGraph } from "./engine";
11
+ import { interpolateNodeConfig, walkGraph } from "./engine";
12
12
  import type { ExecutorRegistry } from "./executors/registry";
13
- import { deepInterpolate } from "./template";
14
13
  import { runStepValidation } from "./validation";
15
14
 
16
15
  let pollerTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -62,7 +61,7 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
62
61
  const ctx = (run.context ?? {}) as Record<string, unknown>;
63
62
 
64
63
  // Deep-interpolate config
65
- const { value: interpolatedValue } = deepInterpolate(node.config, ctx);
64
+ const { value: interpolatedValue } = interpolateNodeConfig(node, ctx);
66
65
  const interpolatedConfig = interpolatedValue as Record<string, unknown>;
67
66
 
68
67
  // Get executor and re-run
@@ -10,6 +10,10 @@ export interface InterpolateResult {
10
10
  unresolved: string[];
11
11
  }
12
12
 
13
+ export interface DeepInterpolateOptions {
14
+ preserveRawTokens?: boolean;
15
+ }
16
+
13
17
  export function interpolate(template: string, ctx: Record<string, unknown>): InterpolateResult {
14
18
  const unresolved: string[] = [];
15
19
  const result = template.replace(/\{\{([^}]+)\}\}/g, (_match, path: string) => {
@@ -43,18 +47,62 @@ function safeStringify(value: unknown): string {
43
47
  }
44
48
  }
45
49
 
50
+ /** Matches a string that is EXACTLY one {{path}} token with no surrounding text. */
51
+ const EXACT_TOKEN_RE = /^\{\{([^}]+)\}\}$/;
52
+
53
+ /**
54
+ * Resolve a dot-separated path against a context object.
55
+ * Returns `{ found: true, value }` on success, `{ found: false }` when any
56
+ * segment is missing or the traversal hits a non-object.
57
+ */
58
+ function resolvePath(
59
+ path: string,
60
+ ctx: Record<string, unknown>,
61
+ ): { found: true; value: unknown } | { found: false } {
62
+ const keys = path.trim().split(".");
63
+ let value: unknown = ctx;
64
+ for (const key of keys) {
65
+ if (value == null || typeof value !== "object") return { found: false };
66
+ value = (value as Record<string, unknown>)[key];
67
+ }
68
+ if (value === undefined) return { found: false };
69
+ return { found: true, value };
70
+ }
71
+
46
72
  /**
47
73
  * Deep-interpolate an arbitrary value tree (objects, arrays, strings).
74
+ *
75
+ * When `preserveRawTokens` is true and a string value is **exactly** one
76
+ * `{{path}}` token with no surrounding text, the resolved value is returned
77
+ * as-is (preserving object / array / number / boolean types). This is the
78
+ * "raw injection" path used by `swarm-script` node `config.args`.
79
+ *
80
+ * When a string contains multiple tokens or surrounding text (e.g.
81
+ * `"prefix-{{x}}"`) the existing string-interpolation path is used so the
82
+ * result remains a string.
83
+ *
48
84
  * Non-string leaves are passed through unchanged.
49
85
  */
50
86
  export function deepInterpolate(
51
87
  value: unknown,
52
88
  ctx: Record<string, unknown>,
89
+ options: DeepInterpolateOptions = {},
53
90
  ): { value: unknown; unresolved: string[] } {
54
91
  const allUnresolved: string[] = [];
55
92
 
56
93
  function walk(v: unknown): unknown {
57
94
  if (typeof v === "string") {
95
+ const exactMatch = options.preserveRawTokens ? EXACT_TOKEN_RE.exec(v) : null;
96
+ if (exactMatch?.[1]) {
97
+ const path = exactMatch[1].trim();
98
+ const resolved = resolvePath(path, ctx);
99
+ if (!resolved.found) {
100
+ allUnresolved.push(path);
101
+ return "";
102
+ }
103
+ return resolved.value;
104
+ }
105
+ // Multi-token or mixed string - fall back to string interpolation.
58
106
  const { result, unresolved } = interpolate(v, ctx);
59
107
  allUnresolved.push(...unresolved);
60
108
  return result;