@desplega.ai/agent-swarm 1.100.3 → 1.101.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/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.101.0",
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.101.0",
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>",
@@ -149,7 +149,7 @@ export interface SwarmSdk {
149
149
  workflow_patch(args: Record<string, unknown>): Promise<unknown>;
150
150
  workflow_patchNode(args: Record<string, unknown>): Promise<unknown>;
151
151
  workflow_delete(args: { id: string }): Promise<unknown>;
152
- workflow_trigger(args: { id: string; input?: Record<string, unknown> }): Promise<unknown>;
152
+ workflow_trigger(args: { id: string; triggerData?: Record<string, unknown> }): Promise<unknown>;
153
153
  workflow_retryRun(args: { id: string }): Promise<unknown>;
154
154
  workflow_cancelRun(args: { id: string }): Promise<unknown>;
155
155
 
@@ -182,7 +182,7 @@ Usage:
182
182
  Options:
183
183
  --api-url <url> Swarm API URL (default: MCP_BASE_URL or http://localhost:3013)
184
184
  --api-key <key> Swarm API key (default: API_KEY or 123123)
185
- --slot <n> Credential pool slot (0-9, default: next free slot)
185
+ --slot <n> Credential pool slot (0-100, default: next free slot)
186
186
  -h, --help Show this help
187
187
 
188
188
  Without flags, the command prompts interactively for the target API URL and
@@ -198,7 +198,7 @@ Deployed Codex workers automatically restore these credentials at boot.
198
198
  `);
199
199
  }
200
200
 
201
- const MAX_SLOT = 9;
201
+ const MAX_SLOT = 100;
202
202
 
203
203
  export async function runCodexLogin(args: string[], deps: RunCodexLoginDeps = {}): Promise<void> {
204
204
  const resolveConfig = deps.resolveConfig ?? resolveCodexLoginConfig;
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { z } from "zod";
4
4
  import { createServer } from "@/server";
5
- import { isSdkToolAllowed } from "../scripts-runtime/sdk-allowlist";
5
+ import { isMcpToolAllowedForScripts } from "../scripts-runtime/sdk-allowlist";
6
6
  import { route } from "./route-def";
7
7
  import { json, jsonError } from "./utils";
8
8
 
@@ -55,7 +55,7 @@ export async function handleMcpBridge(
55
55
 
56
56
  const { tool: toolName, args } = parsed.body;
57
57
 
58
- if (!isSdkToolAllowed(toolName)) {
58
+ if (!isMcpToolAllowedForScripts(toolName)) {
59
59
  jsonError(res, `Tool '${toolName}' is not in the SDK allowlist`, 403);
60
60
  return true;
61
61
  }
@@ -1,3 +1,4 @@
1
+ import { mcpToolNameForSdkMethod } from "../scripts-runtime/sdk-allowlist";
1
2
  import { stdlib } from "../scripts-runtime/stdlib";
2
3
 
3
4
  type StepStatusResponse =
@@ -161,7 +162,7 @@ export function buildWorkflowCtx(input: {
161
162
  return (args?: unknown) =>
162
163
  fetchJson("/api/mcp-bridge", {
163
164
  method: "POST",
164
- body: JSON.stringify({ tool: prop.replaceAll("_", "-"), args: args ?? {} }),
165
+ body: JSON.stringify({ tool: mcpToolNameForSdkMethod(prop), args: args ?? {} }),
165
166
  });
166
167
  },
167
168
  });
@@ -145,10 +145,18 @@ export const SDK_ALLOWLIST = Object.keys(SDK_TOOL_NAME_MAP) as Array<
145
145
  keyof typeof SDK_TOOL_NAME_MAP
146
146
  >;
147
147
 
148
+ /** Set of MCP tool names (values of SDK_TOOL_NAME_MAP) that scripts may call via the bridge. */
149
+ const MCP_TOOL_NAMES: ReadonlySet<string> = new Set<string>(Object.values(SDK_TOOL_NAME_MAP));
150
+
148
151
  export function isSdkToolAllowed(name: string): boolean {
149
152
  return (SDK_ALLOWLIST as readonly string[]).includes(name);
150
153
  }
151
154
 
155
+ /** True if `mcpToolName` (e.g. "trigger-workflow") corresponds to an allowlisted SDK method. */
156
+ export function isMcpToolAllowedForScripts(mcpToolName: string): boolean {
157
+ return MCP_TOOL_NAMES.has(mcpToolName);
158
+ }
159
+
152
160
  export function mcpToolNameForSdkMethod(name: string): string {
153
161
  return SDK_TOOL_NAME_MAP[name as keyof typeof SDK_TOOL_NAME_MAP] ?? name;
154
162
  }
@@ -243,7 +243,7 @@ declare module "swarm-sdk" {
243
243
  workflow_patch(args: Record<string, unknown>): Promise<unknown>;
244
244
  workflow_patchNode(args: Record<string, unknown>): Promise<unknown>;
245
245
  workflow_delete(args: { id: string }): Promise<unknown>;
246
- workflow_trigger(args: { id: string; input?: Record<string, unknown> }): Promise<unknown>;
246
+ workflow_trigger(args: { id: string; triggerData?: Record<string, unknown> }): Promise<unknown>;
247
247
  workflow_retryRun(args: { id: string }): Promise<unknown>;
248
248
  workflow_cancelRun(args: { id: string }): Promise<unknown>;
249
249
 
@@ -225,7 +225,7 @@ declare module "swarm-sdk" {
225
225
  workflow_patch(args: Record<string, unknown>): Promise<unknown>;
226
226
  workflow_patchNode(args: Record<string, unknown>): Promise<unknown>;
227
227
  workflow_delete(args: { id: string }): Promise<unknown>;
228
- workflow_trigger(args: { id: string; input?: Record<string, unknown> }): Promise<unknown>;
228
+ workflow_trigger(args: { id: string; triggerData?: Record<string, unknown> }): Promise<unknown>;
229
229
  workflow_retryRun(args: { id: string }): Promise<unknown>;
230
230
  workflow_cancelRun(args: { id: string }): Promise<unknown>;
231
231
 
@@ -246,7 +246,7 @@ describe("runCodexLogin", () => {
246
246
  resolveConfig: async () => ({
247
247
  apiUrl: "http://localhost:3013",
248
248
  apiKey: "test-key",
249
- slot: 15,
249
+ slot: 101,
250
250
  }),
251
251
  login: mock(async () => {
252
252
  throw new Error("should not start oauth");
@@ -259,18 +259,46 @@ describe("runCodexLogin", () => {
259
259
  });
260
260
 
261
261
  expect(error).toHaveBeenCalledWith(
262
- expect.stringContaining("--slot must be an integer between 0 and 9"),
262
+ expect.stringContaining("--slot must be an integer between 0 and 100"),
263
263
  );
264
264
  expect(exit).toHaveBeenCalledWith(1);
265
265
  expect(store).not.toHaveBeenCalled();
266
266
  });
267
267
 
268
+ it("accepts slot 10 (previously rejected by the old 0-9 cap)", async () => {
269
+ let storedSlot: number | undefined;
270
+ const store = mock(async (_apiUrl: string, _apiKey: string, _creds: unknown, slot?: number) => {
271
+ storedSlot = slot;
272
+ });
273
+
274
+ await runCodexLogin([], {
275
+ resolveConfig: async () => ({
276
+ apiUrl: "http://localhost:3013",
277
+ apiKey: "test-key",
278
+ slot: 10,
279
+ }),
280
+ login: mock(async () => ({
281
+ access: "at_test",
282
+ refresh: "rt_test",
283
+ expires: Date.now() + 3600000,
284
+ accountId: "acc-test",
285
+ })),
286
+ store,
287
+ loadAllSlots: mock(async () => []),
288
+ log: () => {},
289
+ error: () => {},
290
+ exit: () => {},
291
+ });
292
+
293
+ expect(storedSlot).toBe(10);
294
+ });
295
+
268
296
  it("errors when all slots are occupied", async () => {
269
297
  const error = mock(() => {});
270
298
  const exit = mock(() => {});
271
299
 
272
- // All 10 slots occupied
273
- const allSlots = Array.from({ length: 10 }, (_, i) => ({
300
+ // All 101 slots occupied (0-100)
301
+ const allSlots = Array.from({ length: 101 }, (_, i) => ({
274
302
  slot: i,
275
303
  creds: { access: "", refresh: "", expires: 0, accountId: "" },
276
304
  }));
@@ -1,7 +1,14 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
3
5
  import { closeDb, initDb } from "../be/db";
4
- import { mcpToolNameForSdkMethod, SDK_ALLOWLIST } from "../scripts-runtime/sdk-allowlist";
6
+ import { handleMcpBridge } from "../http/mcp-bridge";
7
+ import {
8
+ isMcpToolAllowedForScripts,
9
+ mcpToolNameForSdkMethod,
10
+ SDK_ALLOWLIST,
11
+ } from "../scripts-runtime/sdk-allowlist";
5
12
  import type { SwarmConfig } from "../scripts-runtime/swarm-config";
6
13
  import { createSwarmSdk } from "../scripts-runtime/swarm-sdk";
7
14
  import { createServer } from "../server";
@@ -56,4 +63,119 @@ describe("script SDK allowlist", () => {
56
63
  expect(types).not.toContain("join_swarm(");
57
64
  expect(types).not.toContain("start_worker(");
58
65
  });
66
+
67
+ test("isMcpToolAllowedForScripts accepts every MCP name in the allowlist", () => {
68
+ for (const sdkName of SDK_ALLOWLIST) {
69
+ const mcpName = mcpToolNameForSdkMethod(sdkName);
70
+ expect(isMcpToolAllowedForScripts(mcpName)).toBe(true);
71
+ }
72
+ });
73
+
74
+ test("isMcpToolAllowedForScripts rejects non-mapped MCP names", () => {
75
+ // SDK method names (underscores) are not MCP names — must be rejected
76
+ expect(isMcpToolAllowedForScripts("workflow_trigger")).toBe(false);
77
+ expect(isMcpToolAllowedForScripts("slack_post")).toBe(false);
78
+ // Completely unknown tool names
79
+ expect(isMcpToolAllowedForScripts("tool-does-not-exist")).toBe(false);
80
+ expect(isMcpToolAllowedForScripts("start-worker")).toBe(false);
81
+ });
82
+
83
+ test("bundled swarm-sdk.d.ts uses triggerData (not input) for workflow_trigger", async () => {
84
+ const types = await Bun.file("src/scripts-runtime/types/swarm-sdk.d.ts").text();
85
+ expect(types).toContain("workflow_trigger(args: { id: string; triggerData?");
86
+ expect(types).not.toContain("workflow_trigger(args: { id: string; input?");
87
+ });
88
+ });
89
+
90
+ describe("mcp-bridge allowlist gate", () => {
91
+ const TEST_DB_PATH = "./test-sdk-allowlist-bridge.sqlite";
92
+ const API_KEY = "test-mcp-bridge-key-1234567890";
93
+ let prevApiKey: string | undefined;
94
+
95
+ async function removeDbFiles(path: string): Promise<void> {
96
+ for (const suffix of ["", "-wal", "-shm"]) {
97
+ try {
98
+ await unlink(path + suffix);
99
+ } catch (error) {
100
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
101
+ }
102
+ }
103
+ }
104
+
105
+ beforeAll(async () => {
106
+ await removeDbFiles(TEST_DB_PATH);
107
+ initDb(TEST_DB_PATH);
108
+ prevApiKey = process.env.AGENT_SWARM_API_KEY;
109
+ process.env.AGENT_SWARM_API_KEY = API_KEY;
110
+ });
111
+
112
+ afterAll(async () => {
113
+ closeDb();
114
+ await removeDbFiles(TEST_DB_PATH);
115
+ if (prevApiKey === undefined) {
116
+ delete process.env.AGENT_SWARM_API_KEY;
117
+ } else {
118
+ process.env.AGENT_SWARM_API_KEY = prevApiKey;
119
+ }
120
+ });
121
+
122
+ async function postBridge(
123
+ body: Record<string, unknown>,
124
+ ): Promise<{ status: number; body: unknown }> {
125
+ const raw = JSON.stringify(body);
126
+ const req = Readable.from([Buffer.from(raw)]) as IncomingMessage;
127
+ req.method = "POST";
128
+ req.url = "/api/mcp-bridge";
129
+ req.headers = {
130
+ authorization: `Bearer ${API_KEY}`,
131
+ "content-type": "application/json",
132
+ "x-agent-id": "test-agent-bridge",
133
+ };
134
+
135
+ let status = 200;
136
+ let text = "";
137
+ const res = {
138
+ headersSent: false,
139
+ writableEnded: false,
140
+ setHeader() {},
141
+ writeHead(code: number) {
142
+ status = code;
143
+ this.headersSent = true;
144
+ return this;
145
+ },
146
+ end(chunk?: unknown) {
147
+ if (chunk !== undefined) text += String(chunk);
148
+ this.writableEnded = true;
149
+ return this;
150
+ },
151
+ } as unknown as ServerResponse;
152
+
153
+ await handleMcpBridge(
154
+ req,
155
+ res,
156
+ ["api", "mcp-bridge"],
157
+ new URLSearchParams(),
158
+ "test-agent-bridge",
159
+ );
160
+ return { status, body: text ? JSON.parse(text) : {} };
161
+ }
162
+
163
+ test("trigger-workflow is NOT rejected with 403 (reaches tool handler)", async () => {
164
+ const result = await postBridge({
165
+ tool: "trigger-workflow",
166
+ args: { id: "00000000-0000-0000-0000-000000000001" },
167
+ });
168
+ // Must not be an allowlist 403; may be 404/500 (non-existent workflow) — that's fine.
169
+ expect(result.status).not.toBe(403);
170
+ });
171
+
172
+ test("genuinely non-mapped MCP names still return 403", async () => {
173
+ // SDK method names (underscores) are not valid MCP names — must 403
174
+ const sdkNameResult = await postBridge({ tool: "workflow_trigger", args: { id: "x" } });
175
+ expect(sdkNameResult.status).toBe(403);
176
+
177
+ // Completely unknown tool names
178
+ const unknownResult = await postBridge({ tool: "start-worker", args: {} });
179
+ expect(unknownResult.status).toBe(403);
180
+ });
59
181
  });
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildWorkflowCtx } from "../script-workflows/workflow-ctx";
3
+
4
+ describe("workflow-ctx: ctx.swarm proxy tool name resolution", () => {
5
+ test("non-mechanical SDK→MCP mappings are routed correctly", async () => {
6
+ const captured: string[] = [];
7
+ const origFetch = globalThis.fetch;
8
+
9
+ globalThis.fetch = async (url: unknown, init?: RequestInit) => {
10
+ if (typeof url === "string" && url.includes("/api/mcp-bridge")) {
11
+ const body = JSON.parse((init?.body as string) ?? "{}");
12
+ captured.push(body.tool);
13
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
14
+ }
15
+ return origFetch(url as URL, init);
16
+ };
17
+
18
+ try {
19
+ const ctx = buildWorkflowCtx({
20
+ runId: "test-run",
21
+ agentId: "test-agent",
22
+ apiKey: "test-key",
23
+ baseUrl: "http://localhost:9999",
24
+ args: {},
25
+ });
26
+
27
+ // Non-mechanical: SDK method name ≠ kebab-cased MCP name
28
+ await ctx.swarm.workflow_trigger({ id: "wf-1" }); // → "trigger-workflow" (not "workflow-trigger")
29
+ await ctx.swarm.page_create({ title: "T" }); // → "create_page" (not "page-create")
30
+ await ctx.swarm.memory_rate({ id: "x" }); // → "memory_rate" (not "memory-rate")
31
+
32
+ // Mechanical: verify mechanical mappings still work
33
+ await ctx.swarm.memory_search({ query: "q" }); // → "memory-search"
34
+ } finally {
35
+ globalThis.fetch = origFetch;
36
+ }
37
+
38
+ expect(captured[0]).toBe("trigger-workflow");
39
+ expect(captured[1]).toBe("create_page");
40
+ expect(captured[2]).toBe("memory_rate");
41
+ expect(captured[3]).toBe("memory-search");
42
+ });
43
+ });
@@ -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;