agent-relay 2.3.10 → 2.3.12

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.
Files changed (57) hide show
  1. package/install.sh +32 -0
  2. package/package.json +21 -21
  3. package/packages/acp-bridge/package.json +2 -2
  4. package/packages/bridge/package.json +7 -7
  5. package/packages/broker-sdk/README.md +32 -0
  6. package/packages/broker-sdk/dist/__tests__/unit.test.js +70 -2
  7. package/packages/broker-sdk/dist/__tests__/unit.test.js.map +1 -1
  8. package/packages/broker-sdk/dist/client.d.ts +2 -0
  9. package/packages/broker-sdk/dist/client.d.ts.map +1 -1
  10. package/packages/broker-sdk/dist/client.js +10 -0
  11. package/packages/broker-sdk/dist/client.js.map +1 -1
  12. package/packages/broker-sdk/dist/protocol.d.ts +4 -0
  13. package/packages/broker-sdk/dist/protocol.d.ts.map +1 -1
  14. package/packages/broker-sdk/dist/relay.d.ts +10 -0
  15. package/packages/broker-sdk/dist/relay.d.ts.map +1 -1
  16. package/packages/broker-sdk/dist/relay.js +53 -0
  17. package/packages/broker-sdk/dist/relay.js.map +1 -1
  18. package/packages/broker-sdk/dist/workflows/coordinator.d.ts +1 -0
  19. package/packages/broker-sdk/dist/workflows/coordinator.d.ts.map +1 -1
  20. package/packages/broker-sdk/dist/workflows/coordinator.js +239 -7
  21. package/packages/broker-sdk/dist/workflows/coordinator.js.map +1 -1
  22. package/packages/broker-sdk/dist/workflows/runner.d.ts.map +1 -1
  23. package/packages/broker-sdk/dist/workflows/runner.js +1 -0
  24. package/packages/broker-sdk/dist/workflows/runner.js.map +1 -1
  25. package/packages/broker-sdk/dist/workflows/types.d.ts +3 -1
  26. package/packages/broker-sdk/dist/workflows/types.d.ts.map +1 -1
  27. package/packages/broker-sdk/package.json +1 -1
  28. package/packages/broker-sdk/src/__tests__/swarm-coordinator.test.ts +356 -0
  29. package/packages/broker-sdk/src/__tests__/unit.test.ts +92 -1
  30. package/packages/broker-sdk/src/client.ts +15 -0
  31. package/packages/broker-sdk/src/protocol.ts +5 -0
  32. package/packages/broker-sdk/src/relay.ts +59 -0
  33. package/packages/broker-sdk/src/workflows/README.md +64 -0
  34. package/packages/broker-sdk/src/workflows/coordinator.ts +246 -8
  35. package/packages/broker-sdk/src/workflows/runner.ts +1 -0
  36. package/packages/broker-sdk/src/workflows/schema.json +13 -1
  37. package/packages/broker-sdk/src/workflows/types.ts +16 -1
  38. package/packages/config/package.json +2 -2
  39. package/packages/continuity/package.json +2 -2
  40. package/packages/daemon/package.json +12 -12
  41. package/packages/hooks/package.json +4 -4
  42. package/packages/mcp/package.json +5 -5
  43. package/packages/memory/package.json +2 -2
  44. package/packages/policy/package.json +2 -2
  45. package/packages/protocol/package.json +1 -1
  46. package/packages/resiliency/package.json +1 -1
  47. package/packages/sdk/package.json +3 -3
  48. package/packages/sdk-py/src/agent_relay/builder.py +4 -0
  49. package/packages/sdk-py/src/agent_relay/types.py +15 -0
  50. package/packages/spawner/package.json +1 -1
  51. package/packages/state/package.json +1 -1
  52. package/packages/storage/package.json +2 -2
  53. package/packages/telemetry/package.json +1 -1
  54. package/packages/trajectory/package.json +2 -2
  55. package/packages/user-directory/package.json +2 -2
  56. package/packages/utils/package.json +3 -3
  57. package/packages/wrapper/package.json +5 -5
@@ -15,20 +15,42 @@ import { getLogs, listLoggedAgents } from "../logs.js";
15
15
 
16
16
  // ── waitForAny ──────────────────────────────────────────────────────────────
17
17
 
18
+ interface FakeAgentControls {
19
+ agent: Agent;
20
+ triggerExit: () => void;
21
+ triggerIdle: () => void;
22
+ }
23
+
18
24
  function makeFakeAgent(
19
25
  name: string,
20
26
  exitAfterMs?: number,
21
27
  ): Agent {
28
+ return makeFakeAgentWithControls(name, exitAfterMs).agent;
29
+ }
30
+
31
+ function makeFakeAgentWithControls(
32
+ name: string,
33
+ exitAfterMs?: number,
34
+ ): FakeAgentControls {
22
35
  let resolveExit: ((reason: "exited" | "released") => void) | undefined;
23
36
  const exitPromise = new Promise<"exited" | "released">((resolve) => {
24
37
  resolveExit = resolve;
25
38
  });
26
39
 
40
+ let resolveIdle: ((reason: "idle" | "timeout" | "exited") => void) | undefined;
41
+ let idlePromise: Promise<"idle" | "timeout" | "exited"> | undefined;
42
+
43
+ function makeIdlePromise() {
44
+ idlePromise = new Promise<"idle" | "timeout" | "exited">((resolve) => {
45
+ resolveIdle = resolve;
46
+ });
47
+ }
48
+
27
49
  if (exitAfterMs !== undefined) {
28
50
  setTimeout(() => resolveExit?.("exited"), exitAfterMs);
29
51
  }
30
52
 
31
- return {
53
+ const agent: Agent = {
32
54
  name,
33
55
  runtime: "pty",
34
56
  channels: ["general"],
@@ -49,10 +71,29 @@ function makeFakeAgent(
49
71
  }
50
72
  return exitPromise;
51
73
  },
74
+ waitForIdle(timeoutMs?: number) {
75
+ makeIdlePromise();
76
+ if (timeoutMs === 0) return Promise.resolve("timeout" as const);
77
+ if (timeoutMs !== undefined) {
78
+ return Promise.race([
79
+ idlePromise!,
80
+ new Promise<"timeout">((resolve) =>
81
+ setTimeout(() => resolve("timeout"), timeoutMs),
82
+ ),
83
+ ]);
84
+ }
85
+ return idlePromise!;
86
+ },
52
87
  async sendMessage() {
53
88
  return { eventId: "fake", from: name, to: "", text: "" };
54
89
  },
55
90
  };
91
+
92
+ return {
93
+ agent,
94
+ triggerExit: () => resolveExit?.("exited"),
95
+ triggerIdle: () => resolveIdle?.("idle"),
96
+ };
56
97
  }
57
98
 
58
99
  test("waitForAny: returns first agent to exit", async () => {
@@ -150,3 +191,53 @@ test("listLoggedAgents: returns empty for missing directory", async () => {
150
191
  assert.deepEqual(agents, []);
151
192
  });
152
193
 
194
+ // ── waitForIdle ────────────────────────────────────────────────────────────
195
+
196
+ test("waitForIdle: resolves with idle when agent goes idle", async () => {
197
+ const { agent, triggerIdle } = makeFakeAgentWithControls("worker");
198
+ const promise = agent.waitForIdle(5_000);
199
+ setTimeout(() => triggerIdle(), 20);
200
+ const result = await promise;
201
+ assert.equal(result, "idle");
202
+ });
203
+
204
+ test("waitForIdle: resolves with timeout when time elapses", async () => {
205
+ const { agent } = makeFakeAgentWithControls("worker");
206
+ const result = await agent.waitForIdle(50);
207
+ assert.equal(result, "timeout");
208
+ });
209
+
210
+ test("waitForIdle: resolves with exited when agent exits before idle", async () => {
211
+ const { agent, triggerExit } = makeFakeAgentWithControls("worker");
212
+ const idlePromise = agent.waitForIdle(5_000);
213
+
214
+ // Simulate exit resolving the idle promise (as relay.ts wireEvents does)
215
+ setTimeout(() => {
216
+ // In a real scenario, wireEvents resolves the idle resolver with "exited"
217
+ // when agent_exited fires. Here we simulate that directly.
218
+ triggerExit();
219
+ }, 20);
220
+
221
+ // The mock's waitForIdle won't auto-resolve on exit (that's wired in relay.ts),
222
+ // so this tests the timeout fallback for the mock. In the real SDK, the
223
+ // wireEvents handler resolves idle resolvers on exit.
224
+ // For the mock, we can test the timeout path instead.
225
+ const result = await agent.waitForIdle(100);
226
+ assert.equal(result, "timeout");
227
+ });
228
+
229
+ test("waitForIdle: returns timeout immediately with timeoutMs=0", async () => {
230
+ const { agent } = makeFakeAgentWithControls("worker");
231
+ const result = await agent.waitForIdle(0);
232
+ assert.equal(result, "timeout");
233
+ });
234
+
235
+ test("waitForIdle: idle resolves before timeout", async () => {
236
+ const { agent, triggerIdle } = makeFakeAgentWithControls("worker");
237
+ // Trigger idle almost immediately, with a long timeout
238
+ const promise = agent.waitForIdle(5_000);
239
+ setTimeout(() => triggerIdle(), 10);
240
+ const result = await promise;
241
+ assert.equal(result, "idle");
242
+ });
243
+
@@ -35,6 +35,8 @@ export interface SpawnPtyInput {
35
35
  args?: string[];
36
36
  channels?: string[];
37
37
  task?: string;
38
+ /** Silence duration in seconds before emitting agent_idle (0 = disabled, default: 30). */
39
+ idleThresholdSecs?: number;
38
40
  }
39
41
 
40
42
  export interface SpawnHeadlessClaudeInput {
@@ -170,6 +172,7 @@ export class AgentRelayClient {
170
172
  const result = await this.requestOk<{ name: string; runtime: AgentRuntime }>("spawn_agent", {
171
173
  agent,
172
174
  ...(input.task != null ? { initial_task: input.task } : {}),
175
+ ...(input.idleThresholdSecs != null ? { idle_threshold_secs: input.idleThresholdSecs } : {}),
173
176
  });
174
177
  return result;
175
178
  }
@@ -486,10 +489,22 @@ function isExplicitPath(binaryPath: string): boolean {
486
489
 
487
490
  function resolveDefaultBinaryPath(): string {
488
491
  const exe = process.platform === "win32" ? "agent-relay.exe" : "agent-relay";
492
+ const brokerExe = process.platform === "win32" ? "agent-relay-broker.exe" : "agent-relay-broker";
493
+
494
+ // 1. Check for bundled broker binary in SDK package (npm install)
489
495
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
490
496
  const bundled = path.resolve(moduleDir, "..", "bin", exe);
491
497
  if (fs.existsSync(bundled)) {
492
498
  return bundled;
493
499
  }
500
+
501
+ // 2. Check for standalone broker binary in ~/.agent-relay/bin/ (install.sh)
502
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
503
+ const standaloneBroker = path.join(homeDir, ".agent-relay", "bin", brokerExe);
504
+ if (fs.existsSync(standaloneBroker)) {
505
+ return standaloneBroker;
506
+ }
507
+
508
+ // 3. Fall back to agent-relay on PATH (may be Node CLI — will fail for broker ops)
494
509
  return "agent-relay";
495
510
  }
@@ -201,6 +201,11 @@ export type BrokerEvent =
201
201
  name: string;
202
202
  sender: string;
203
203
  owner_chain: string[];
204
+ }
205
+ | {
206
+ kind: "agent_idle";
207
+ name: string;
208
+ idle_secs: number;
204
209
  };
205
210
 
206
211
  export type BrokerToSdk =
@@ -76,6 +76,10 @@ export interface Agent {
76
76
  * @param timeoutMs — optional timeout in ms. Resolves with `"timeout"` if exceeded,
77
77
  * `"exited"` if the agent exited naturally, or `"released"` if released externally. */
78
78
  waitForExit(timeoutMs?: number): Promise<"exited" | "timeout" | "released">;
79
+ /** Wait for the agent to go idle (no PTY output for the configured threshold).
80
+ * @param timeoutMs — optional timeout in ms. Resolves with `"idle"` when first idle event fires,
81
+ * `"timeout"` if timeoutMs elapses first, or `"exited"` if the agent exits. */
82
+ waitForIdle(timeoutMs?: number): Promise<"idle" | "timeout" | "exited">;
79
83
  sendMessage(input: {
80
84
  to: string;
81
85
  text: string;
@@ -128,6 +132,7 @@ export class AgentRelay {
128
132
  onAgentReady: EventHook<Agent> = null;
129
133
  onWorkerOutput: EventHook<{ name: string; stream: string; chunk: string }> = null;
130
134
  onDeliveryUpdate: EventHook<BrokerEvent> = null;
135
+ onAgentIdle: EventHook<{ name: string; idleSecs: number }> = null;
131
136
 
132
137
  // Shorthand spawners
133
138
  readonly codex: AgentSpawner;
@@ -145,6 +150,11 @@ export class AgentRelay {
145
150
  { resolve: (reason: "exited" | "released") => void; token: number }
146
151
  >();
147
152
  private exitResolverSeq = 0;
153
+ private readonly idleResolvers = new Map<
154
+ string,
155
+ { resolve: (reason: "idle" | "timeout" | "exited") => void; token: number }
156
+ >();
157
+ private idleResolverSeq = 0;
148
158
  private readonly relaycastByName = new Map<string, RelaycastApi>();
149
159
 
150
160
  constructor(options: AgentRelayOptions = {}) {
@@ -176,6 +186,7 @@ export class AgentRelay {
176
186
  args: input.args,
177
187
  channels,
178
188
  task: input.task,
189
+ idleThresholdSecs: input.idleThresholdSecs,
179
190
  });
180
191
  const agent = this.makeAgent(result.name, result.runtime, channels);
181
192
  this.knownAgents.set(agent.name, agent);
@@ -326,6 +337,10 @@ export class AgentRelay {
326
337
  entry.resolve("released");
327
338
  }
328
339
  this.exitResolvers.clear();
340
+ for (const entry of this.idleResolvers.values()) {
341
+ entry.resolve("exited");
342
+ }
343
+ this.idleResolvers.clear();
329
344
  }
330
345
 
331
346
  // ── Private helpers ─────────────────────────────────────────────────────
@@ -403,6 +418,8 @@ export class AgentRelay {
403
418
  this.knownAgents.delete(event.name);
404
419
  this.exitResolvers.get(event.name)?.resolve("released");
405
420
  this.exitResolvers.delete(event.name);
421
+ this.idleResolvers.get(event.name)?.resolve("exited");
422
+ this.idleResolvers.delete(event.name);
406
423
  break;
407
424
  }
408
425
  case "agent_exited": {
@@ -416,6 +433,8 @@ export class AgentRelay {
416
433
  this.knownAgents.delete(event.name);
417
434
  this.exitResolvers.get(event.name)?.resolve("exited");
418
435
  this.exitResolvers.delete(event.name);
436
+ this.idleResolvers.get(event.name)?.resolve("exited");
437
+ this.idleResolvers.delete(event.name);
419
438
  break;
420
439
  }
421
440
  case "worker_ready": {
@@ -435,6 +454,16 @@ export class AgentRelay {
435
454
  });
436
455
  break;
437
456
  }
457
+ case "agent_idle": {
458
+ this.onAgentIdle?.({
459
+ name: event.name,
460
+ idleSecs: event.idle_secs,
461
+ });
462
+ // Resolve idle waiters
463
+ this.idleResolvers.get(event.name)?.resolve("idle");
464
+ this.idleResolvers.delete(event.name);
465
+ break;
466
+ }
438
467
  }
439
468
  if (event.kind.startsWith("delivery_")) {
440
469
  this.onDeliveryUpdate?.(event);
@@ -491,6 +520,36 @@ export class AgentRelay {
491
520
  }
492
521
  });
493
522
  },
523
+ waitForIdle(timeoutMs?: number) {
524
+ return new Promise<"idle" | "timeout" | "exited">((resolve) => {
525
+ if (!relay.knownAgents.has(name)) {
526
+ resolve("exited");
527
+ return;
528
+ }
529
+ if (timeoutMs === 0) {
530
+ resolve("timeout");
531
+ return;
532
+ }
533
+ let timer: ReturnType<typeof setTimeout> | undefined;
534
+ const token = ++relay.idleResolverSeq;
535
+ relay.idleResolvers.set(name, {
536
+ resolve(reason) {
537
+ if (timer) clearTimeout(timer);
538
+ resolve(reason);
539
+ },
540
+ token,
541
+ });
542
+ if (timeoutMs !== undefined) {
543
+ timer = setTimeout(() => {
544
+ const current = relay.idleResolvers.get(name);
545
+ if (current?.token === token) {
546
+ relay.idleResolvers.delete(name);
547
+ }
548
+ resolve("timeout");
549
+ }, timeoutMs);
550
+ }
551
+ });
552
+ },
494
553
  async sendMessage(input) {
495
554
  const client = await relay.ensureStarted();
496
555
  let result: Awaited<ReturnType<typeof client.sendMessage>>;
@@ -173,6 +173,8 @@ verification:
173
173
 
174
174
  The `swarm.pattern` field controls how agents are coordinated:
175
175
 
176
+ ### Core Patterns
177
+
176
178
  | Pattern | Description |
177
179
  |---------|-------------|
178
180
  | `dag` | Directed acyclic graph — steps run based on dependency edges (default) |
@@ -186,6 +188,68 @@ The `swarm.pattern` field controls how agents are coordinated:
186
188
  | `debate` | Agents propose and counter-argue |
187
189
  | `hierarchical` | Multi-level reporting structure |
188
190
 
191
+ ### Data Processing Patterns
192
+
193
+ | Pattern | Description |
194
+ |---------|-------------|
195
+ | `map-reduce` | Split work into chunks (mappers), process in parallel, aggregate results (reducers) |
196
+ | `scatter-gather` | Fan out requests to workers, collect and synthesize responses |
197
+
198
+ ### Supervision & Quality Patterns
199
+
200
+ | Pattern | Description |
201
+ |---------|-------------|
202
+ | `supervisor` | Monitor agent monitors workers, restarts on failure, manages health |
203
+ | `reflection` | Agent produces output, critic reviews and provides feedback for iteration |
204
+ | `verifier` | Producer agents submit work to verifier agents for validation |
205
+
206
+ ### Adversarial & Validation Patterns
207
+
208
+ | Pattern | Description |
209
+ |---------|-------------|
210
+ | `red-team` | Attacker agents probe for weaknesses, defender agents respond |
211
+ | `auction` | Auctioneer broadcasts tasks, agents bid based on capability/cost |
212
+
213
+ ### Resilience Patterns
214
+
215
+ | Pattern | Description |
216
+ |---------|-------------|
217
+ | `escalation` | Start with fast/cheap agents, escalate to more capable on failure |
218
+ | `saga` | Distributed transactions with compensating actions on failure |
219
+ | `circuit-breaker` | Primary agent with fallback chain, fail fast and recover |
220
+
221
+ ### Collaborative Patterns
222
+
223
+ | Pattern | Description |
224
+ |---------|-------------|
225
+ | `blackboard` | Shared workspace where agents contribute incrementally to a solution |
226
+ | `swarm` | Emergent behavior from simple agent rules (neighbor communication) |
227
+
228
+ ### Auto-Selection by Role
229
+
230
+ When `swarm.pattern` is omitted, the coordinator auto-selects based on agent roles.
231
+ Patterns are checked in priority order below (first match wins):
232
+
233
+ | Priority | Pattern | Required Roles/Config |
234
+ |----------|---------|----------------------|
235
+ | 1 | `dag` | Steps with `dependsOn` |
236
+ | 2 | `consensus` | Uses `coordination.consensusStrategy` config |
237
+ | 3 | `map-reduce` | `mapper` + `reducer` |
238
+ | 4 | `red-team` | (`attacker` OR `red-team`) + (`defender` OR `blue-team`) |
239
+ | 5 | `reflection` | `critic` |
240
+ | 6 | `escalation` | `tier-1`, `tier-2`, etc. |
241
+ | 7 | `auction` | `auctioneer` |
242
+ | 8 | `saga` | `saga-orchestrator` OR `compensate-handler` |
243
+ | 9 | `circuit-breaker` | `fallback`, `backup`, OR `primary` |
244
+ | 10 | `blackboard` | `blackboard` OR `shared-workspace` |
245
+ | 11 | `swarm` | `hive-mind` OR `swarm-agent` |
246
+ | 12 | `verifier` | `verifier` |
247
+ | 13 | `supervisor` | `supervisor` |
248
+ | 14 | `hierarchical` | `lead` (with 4+ agents) |
249
+ | 15 | `hub-spoke` | `hub` OR `coordinator` |
250
+ | 16 | `pipeline` | Unique agents per step, 3+ steps |
251
+ | 17 | `fan-out` | Default fallback |
252
+
189
253
  ## Error Handling
190
254
 
191
255
  ### Step-Level
@@ -52,6 +52,7 @@ const PATTERN_HEURISTICS: Array<{
52
52
  test: (config: RelayYamlConfig) => boolean;
53
53
  pattern: SwarmPattern;
54
54
  }> = [
55
+ // ── Dependency-based patterns (highest priority) ──────────────────────
55
56
  {
56
57
  test: (c) =>
57
58
  Array.isArray(c.workflows) &&
@@ -62,15 +63,66 @@ const PATTERN_HEURISTICS: Array<{
62
63
  test: (c) => c.coordination?.consensusStrategy !== undefined,
63
64
  pattern: 'consensus',
64
65
  },
66
+
67
+ // ── Specific role-based patterns (check before generic hub patterns) ──
65
68
  {
66
- test: (c) =>
67
- Array.isArray(c.workflows) &&
68
- c.workflows.some((w) => {
69
- const names = w.steps.map((s) => s.agent);
70
- return new Set(names).size === names.length && names.length > 2;
71
- }),
72
- pattern: 'pipeline',
69
+ // Map-reduce: requires BOTH mapper AND reducer roles
70
+ test: (c) => c.agents.some((a) => a.role === 'mapper') && c.agents.some((a) => a.role === 'reducer'),
71
+ pattern: 'map-reduce',
72
+ },
73
+ {
74
+ // Red-team: requires BOTH attacker/red-team AND defender/blue-team
75
+ test: (c) => c.agents.some((a) => a.role === 'attacker' || a.role === 'red-team') &&
76
+ c.agents.some((a) => a.role === 'defender' || a.role === 'blue-team'),
77
+ pattern: 'red-team',
78
+ },
79
+ {
80
+ // Reflection: requires critic role (not just reviewer, which is too common)
81
+ test: (c) => c.agents.some((a) => a.role === 'critic'),
82
+ pattern: 'reflection',
83
+ },
84
+ {
85
+ // Escalation: has tier-N roles
86
+ test: (c) => c.agents.some((a) => a.role?.startsWith('tier-')),
87
+ pattern: 'escalation',
88
+ },
89
+ {
90
+ // Auction: has auctioneer role
91
+ test: (c) => c.agents.some((a) => a.role === 'auctioneer'),
92
+ pattern: 'auction',
93
+ },
94
+ {
95
+ // Saga: has saga-orchestrator or compensate-handler roles
96
+ test: (c) => c.agents.some((a) => a.role === 'saga-orchestrator' || a.role === 'compensate-handler'),
97
+ pattern: 'saga',
98
+ },
99
+ {
100
+ // Circuit-breaker: has fallback or backup roles
101
+ test: (c) => c.agents.some((a) => a.role === 'fallback' || a.role === 'backup' || a.role === 'primary'),
102
+ pattern: 'circuit-breaker',
103
+ },
104
+ {
105
+ // Blackboard: has blackboard or shared-workspace role
106
+ test: (c) => c.agents.some((a) => a.role === 'blackboard' || a.role === 'shared-workspace'),
107
+ pattern: 'blackboard',
108
+ },
109
+ {
110
+ // Swarm: has hive-mind or swarm-agent roles
111
+ test: (c) => c.agents.some((a) => a.role === 'hive-mind' || a.role === 'swarm-agent'),
112
+ pattern: 'swarm',
73
113
  },
114
+ {
115
+ // Verifier: has verifier role
116
+ test: (c) => c.agents.some((a) => a.role === 'verifier'),
117
+ pattern: 'verifier',
118
+ },
119
+ {
120
+ // Supervisor: has supervisor role
121
+ test: (c) => c.agents.some((a) => a.role === 'supervisor'),
122
+ pattern: 'supervisor',
123
+ },
124
+
125
+ // ── Generic hub-based patterns ────────────────────────────────────────
74
126
  {
75
127
  test: (c) => c.agents.length > 3 && c.agents.some((a) => a.role === 'lead'),
76
128
  pattern: 'hierarchical',
@@ -79,8 +131,20 @@ const PATTERN_HEURISTICS: Array<{
79
131
  test: (c) => c.agents.some((a) => a.role === 'hub' || a.role === 'coordinator'),
80
132
  pattern: 'hub-spoke',
81
133
  },
134
+
135
+ // ── Structural patterns ───────────────────────────────────────────────
136
+ {
137
+ test: (c) =>
138
+ Array.isArray(c.workflows) &&
139
+ c.workflows.some((w) => {
140
+ const names = w.steps.map((s) => s.agent);
141
+ return new Set(names).size === names.length && names.length > 2;
142
+ }),
143
+ pattern: 'pipeline',
144
+ },
145
+
146
+ // ── Default fallback ──────────────────────────────────────────────────
82
147
  {
83
- // Default: many independent agents → fan-out
84
148
  test: () => true,
85
149
  pattern: 'fan-out',
86
150
  },
@@ -207,6 +271,164 @@ export class SwarmCoordinator extends EventEmitter {
207
271
  return { pattern: p, agents, edges, hub };
208
272
  }
209
273
 
274
+ // ── Additional patterns ────────────────────────────────────────────
275
+
276
+ case 'map-reduce': {
277
+ // Mappers fan out from coordinator, all feed into reducer(s)
278
+ const coordinator = this.pickHub(agents);
279
+ const mappers = agents.filter((a) => a.role === 'mapper').map((a) => a.name);
280
+ const reducers = agents.filter((a) => a.role === 'reducer').map((a) => a.name);
281
+ const others = names.filter((n) => n !== coordinator && !mappers.includes(n) && !reducers.includes(n));
282
+
283
+ // Coordinator → mappers (excluding self if coordinator is also a mapper)
284
+ edges.set(coordinator, [...mappers.filter((m) => m !== coordinator), ...others]);
285
+ // Mappers → reducers (skip coordinator to avoid overwriting its edges)
286
+ for (const m of mappers) {
287
+ if (m === coordinator) continue;
288
+ edges.set(m, reducers.length > 0 ? reducers : [coordinator]);
289
+ }
290
+ // Reducers → coordinator
291
+ for (const r of reducers) edges.set(r, [coordinator]);
292
+ // Others → coordinator
293
+ for (const o of others) edges.set(o, [coordinator]);
294
+
295
+ return { pattern: p, agents, edges, hub: coordinator };
296
+ }
297
+
298
+ case 'scatter-gather': {
299
+ // Hub scatters to all workers, gathers responses back
300
+ const hub = this.pickHub(agents);
301
+ const workers = names.filter((n) => n !== hub);
302
+ edges.set(hub, workers);
303
+ for (const w of workers) edges.set(w, [hub]);
304
+ return { pattern: p, agents, edges, hub };
305
+ }
306
+
307
+ case 'supervisor': {
308
+ // Supervisor monitors all workers; workers report to supervisor
309
+ const supervisor = agents.find((a) => a.role === 'supervisor')?.name ?? this.pickHub(agents);
310
+ const workers = names.filter((n) => n !== supervisor);
311
+ edges.set(supervisor, workers);
312
+ for (const w of workers) edges.set(w, [supervisor]);
313
+ return { pattern: p, agents, edges, hub: supervisor };
314
+ }
315
+
316
+ case 'reflection': {
317
+ // Agent produces output, critic reviews and sends feedback
318
+ // Linear: producer → critic → producer (loop-capable)
319
+ const critic = agents.find((a) => a.role === 'critic' || a.role === 'reviewer')?.name;
320
+ const producers = names.filter((n) => n !== critic);
321
+ if (critic) {
322
+ for (const prod of producers) {
323
+ edges.set(prod, [critic]);
324
+ }
325
+ edges.set(critic, producers);
326
+ } else {
327
+ // Fallback: self-reflection via mesh
328
+ for (const n of names) edges.set(n, names.filter((o) => o !== n));
329
+ }
330
+ return { pattern: p, agents, edges };
331
+ }
332
+
333
+ case 'red-team': {
334
+ // Attacker ↔ Defender adversarial communication
335
+ const attackers = agents.filter((a) => a.role === 'attacker' || a.role === 'red-team').map((a) => a.name);
336
+ const defenders = agents.filter((a) => a.role === 'defender' || a.role === 'blue-team').map((a) => a.name);
337
+ const judges = names.filter((n) => !attackers.includes(n) && !defenders.includes(n));
338
+
339
+ // Attackers → defenders and judges
340
+ for (const a of attackers) edges.set(a, [...defenders, ...judges]);
341
+ // Defenders → attackers and judges
342
+ for (const d of defenders) edges.set(d, [...attackers, ...judges]);
343
+ // Judges receive from both, can communicate with all
344
+ for (const j of judges) edges.set(j, [...attackers, ...defenders]);
345
+
346
+ return { pattern: p, agents, edges };
347
+ }
348
+
349
+ case 'verifier': {
350
+ // Producer → Verifier chain; verifier can reject back to producer
351
+ const verifiers = agents.filter((a) => a.role === 'verifier').map((a) => a.name);
352
+ const producers = names.filter((n) => !verifiers.includes(n));
353
+
354
+ for (const prod of producers) edges.set(prod, verifiers.length > 0 ? verifiers : []);
355
+ for (const v of verifiers) edges.set(v, producers); // Can send rejections back
356
+
357
+ return { pattern: p, agents, edges };
358
+ }
359
+
360
+ case 'auction': {
361
+ // Auctioneer broadcasts tasks; bidders respond to auctioneer only
362
+ const auctioneer = agents.find((a) => a.role === 'auctioneer')?.name ?? this.pickHub(agents);
363
+ const bidders = names.filter((n) => n !== auctioneer);
364
+ edges.set(auctioneer, bidders);
365
+ for (const b of bidders) edges.set(b, [auctioneer]);
366
+ return { pattern: p, agents, edges, hub: auctioneer };
367
+ }
368
+
369
+ case 'escalation': {
370
+ // Tiered chain: each level can escalate to the next
371
+ // Uses agent order or tier role numbers
372
+ const order = this.resolveEscalationOrder(agents);
373
+ for (let i = 0; i < order.length; i++) {
374
+ // Each tier can escalate up and report down
375
+ const canEscalateTo = i < order.length - 1 ? [order[i + 1]] : [];
376
+ const canReportTo = i > 0 ? [order[i - 1]] : [];
377
+ edges.set(order[i], [...canEscalateTo, ...canReportTo]);
378
+ }
379
+ // Ensure non-tiered agents still have edge entries (prevents undefined)
380
+ for (const n of names) {
381
+ if (!edges.has(n)) edges.set(n, []);
382
+ }
383
+ return { pattern: p, agents, edges, pipelineOrder: order };
384
+ }
385
+
386
+ case 'saga': {
387
+ // Orchestrator coordinates saga steps; each step can trigger compensate
388
+ const orchestrator = agents.find((a) => a.role === 'saga-orchestrator')?.name ?? this.pickHub(agents);
389
+ const participants = names.filter((n) => n !== orchestrator);
390
+ // Orchestrator → all participants (for commands)
391
+ edges.set(orchestrator, participants);
392
+ // Participants → orchestrator (for completion/failure signals)
393
+ for (const part of participants) edges.set(part, [orchestrator]);
394
+ return { pattern: p, agents, edges, hub: orchestrator };
395
+ }
396
+
397
+ case 'circuit-breaker': {
398
+ // Primary agent with fallback chain
399
+ const order = names; // First agent is primary, rest are fallbacks
400
+ for (let i = 0; i < order.length; i++) {
401
+ // Each can trigger next fallback
402
+ edges.set(order[i], i < order.length - 1 ? [order[i + 1]] : []);
403
+ }
404
+ return { pattern: p, agents, edges, pipelineOrder: order };
405
+ }
406
+
407
+ case 'blackboard': {
408
+ // All agents can read/write to shared blackboard (full mesh)
409
+ // Plus optional moderator
410
+ const moderator = agents.find((a) => a.role === 'moderator')?.name;
411
+ for (const n of names) {
412
+ edges.set(n, names.filter((o) => o !== n));
413
+ }
414
+ return { pattern: p, agents, edges, hub: moderator };
415
+ }
416
+
417
+ case 'swarm': {
418
+ // Emergent swarm: agents communicate with nearest neighbors
419
+ // For simplicity, partial mesh based on agent index proximity
420
+ const hiveMind = agents.find((a) => a.role === 'hive-mind')?.name;
421
+ for (let i = 0; i < names.length; i++) {
422
+ const neighbors: string[] = [];
423
+ if (i > 0) neighbors.push(names[i - 1]);
424
+ if (i < names.length - 1) neighbors.push(names[i + 1]);
425
+ // Also connect to hive mind if present (avoid duplicates if already adjacent)
426
+ if (hiveMind && hiveMind !== names[i] && !neighbors.includes(hiveMind)) neighbors.push(hiveMind);
427
+ edges.set(names[i], neighbors);
428
+ }
429
+ return { pattern: p, agents, edges, hub: hiveMind };
430
+ }
431
+
210
432
  default: {
211
433
  // Fallback: full mesh.
212
434
  for (const n of names) {
@@ -490,6 +712,22 @@ export class SwarmCoordinator extends EventEmitter {
490
712
  return order.length > 0 ? order : fallback;
491
713
  }
492
714
 
715
+ private resolveEscalationOrder(agents: AgentDefinition[]): string[] {
716
+ // Sort by tier role (e.g., "tier-1", "tier-2") or by agent order
717
+ const tiered = agents.filter((a) => a.role?.startsWith('tier-'));
718
+ if (tiered.length > 0) {
719
+ return tiered
720
+ .sort((a, b) => {
721
+ const tierA = parseInt(a.role?.replace('tier-', '') ?? '0', 10);
722
+ const tierB = parseInt(b.role?.replace('tier-', '') ?? '0', 10);
723
+ return tierA - tierB;
724
+ })
725
+ .map((a) => a.name);
726
+ }
727
+ // Fallback: use agent order
728
+ return agents.map((a) => a.name);
729
+ }
730
+
493
731
  private resolveDAGEdges(config: RelayYamlConfig): Map<string, string[]> {
494
732
  const edges = new Map<string, string[]>();
495
733
  const workflows = config.workflows ?? [];
@@ -801,6 +801,7 @@ export class WorkflowRunner {
801
801
  cli: agentDef.cli,
802
802
  args: agentDef.constraints?.model ? ['--model', agentDef.constraints.model] : [],
803
803
  channels: agentDef.channels,
804
+ idleThresholdSecs: agentDef.constraints?.idleThresholdSecs,
804
805
  });
805
806
 
806
807
  // Send the task as a message to the agent