agent-relay 3.2.0 → 3.2.2

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 (79) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +1421 -246
  6. package/dist/src/cli/commands/core.d.ts +1 -0
  7. package/dist/src/cli/commands/core.d.ts.map +1 -1
  8. package/dist/src/cli/commands/core.js +18 -0
  9. package/dist/src/cli/commands/core.js.map +1 -1
  10. package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
  11. package/dist/src/cli/lib/broker-lifecycle.js +16 -13
  12. package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
  13. package/dist/src/cli/relaycast-mcp.d.ts +4 -0
  14. package/dist/src/cli/relaycast-mcp.d.ts.map +1 -1
  15. package/dist/src/cli/relaycast-mcp.js +4 -4
  16. package/dist/src/cli/relaycast-mcp.js.map +1 -1
  17. package/package.json +8 -8
  18. package/packages/acp-bridge/package.json +2 -2
  19. package/packages/config/package.json +1 -1
  20. package/packages/hooks/package.json +4 -4
  21. package/packages/memory/package.json +2 -2
  22. package/packages/openclaw/README.md +2 -2
  23. package/packages/openclaw/dist/identity/files.js +2 -2
  24. package/packages/openclaw/dist/identity/files.js.map +1 -1
  25. package/packages/openclaw/dist/setup.js +2 -2
  26. package/packages/openclaw/package.json +2 -2
  27. package/packages/openclaw/skill/SKILL.md +8 -8
  28. package/packages/openclaw/src/identity/files.ts +2 -2
  29. package/packages/openclaw/src/setup.ts +2 -2
  30. package/packages/openclaw/templates/SOUL.md.template +2 -2
  31. package/packages/policy/package.json +2 -2
  32. package/packages/sdk/dist/__tests__/completion-pipeline.test.d.ts +14 -0
  33. package/packages/sdk/dist/__tests__/completion-pipeline.test.d.ts.map +1 -0
  34. package/packages/sdk/dist/__tests__/completion-pipeline.test.js +1476 -0
  35. package/packages/sdk/dist/__tests__/completion-pipeline.test.js.map +1 -0
  36. package/packages/sdk/dist/__tests__/e2e-owner-review.test.js +2 -2
  37. package/packages/sdk/dist/__tests__/e2e-owner-review.test.js.map +1 -1
  38. package/packages/sdk/dist/examples/example.js +1 -1
  39. package/packages/sdk/dist/examples/example.js.map +1 -1
  40. package/packages/sdk/dist/relay-adapter.js +4 -4
  41. package/packages/sdk/dist/relay-adapter.js.map +1 -1
  42. package/packages/sdk/dist/workflows/builder.d.ts +18 -3
  43. package/packages/sdk/dist/workflows/builder.d.ts.map +1 -1
  44. package/packages/sdk/dist/workflows/builder.js +24 -12
  45. package/packages/sdk/dist/workflows/builder.js.map +1 -1
  46. package/packages/sdk/dist/workflows/runner.d.ts +55 -2
  47. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  48. package/packages/sdk/dist/workflows/runner.js +1370 -108
  49. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  50. package/packages/sdk/dist/workflows/trajectory.d.ts +6 -2
  51. package/packages/sdk/dist/workflows/trajectory.d.ts.map +1 -1
  52. package/packages/sdk/dist/workflows/trajectory.js +37 -2
  53. package/packages/sdk/dist/workflows/trajectory.js.map +1 -1
  54. package/packages/sdk/dist/workflows/types.d.ts +88 -0
  55. package/packages/sdk/dist/workflows/types.d.ts.map +1 -1
  56. package/packages/sdk/dist/workflows/types.js.map +1 -1
  57. package/packages/sdk/dist/workflows/validator.js +1 -1
  58. package/packages/sdk/dist/workflows/validator.js.map +1 -1
  59. package/packages/sdk/package.json +2 -2
  60. package/packages/sdk/src/__tests__/completion-pipeline.test.ts +1820 -0
  61. package/packages/sdk/src/__tests__/e2e-owner-review.test.ts +2 -2
  62. package/packages/sdk/src/__tests__/idle-nudge.test.ts +68 -0
  63. package/packages/sdk/src/__tests__/workflow-runner.test.ts +113 -4
  64. package/packages/sdk/src/examples/example.ts +1 -1
  65. package/packages/sdk/src/relay-adapter.ts +4 -4
  66. package/packages/sdk/src/workflows/README.md +43 -11
  67. package/packages/sdk/src/workflows/builder.ts +38 -11
  68. package/packages/sdk/src/workflows/runner.ts +1860 -127
  69. package/packages/sdk/src/workflows/schema.json +6 -0
  70. package/packages/sdk/src/workflows/trajectory.ts +52 -3
  71. package/packages/sdk/src/workflows/types.ts +149 -0
  72. package/packages/sdk/src/workflows/validator.ts +1 -1
  73. package/packages/sdk-py/pyproject.toml +1 -1
  74. package/packages/telemetry/package.json +1 -1
  75. package/packages/trajectory/package.json +2 -2
  76. package/packages/user-directory/package.json +2 -2
  77. package/packages/utils/package.json +2 -2
  78. package/relay-snippets/agent-relay-protocol.md +4 -4
  79. package/relay-snippets/agent-relay-snippet.md +9 -9
@@ -762,12 +762,12 @@ describe('PR #511 E2E: Auto Step Owner + Review Gating', () => {
762
762
  // ── Scenario 9: Owner completion marker validation ─────────────────────
763
763
 
764
764
  describe('Scenario 9: Owner completion marker', () => {
765
- it('should fail when owner does not produce STEP_COMPLETE marker', async () => {
765
+ it('should fail when owner does not provide a marker, decision, or evidence', async () => {
766
766
  mockSpawnOutputs = ['The work is done but I forgot the sentinel.\n'];
767
767
 
768
768
  const run = await runner.execute(makeConfig(), 'default');
769
769
  expect(run.status).toBe('failed');
770
- expect(run.error).toContain('owner completion marker');
770
+ expect(run.error).toContain('owner completion decision missing');
771
771
  }, 15000);
772
772
 
773
773
  it('should succeed when owner produces correct STEP_COMPLETE:step-name', async () => {
@@ -340,6 +340,43 @@ describe('Idle Nudge Detection', () => {
340
340
  expect(run.status).toBe('failed');
341
341
  expect(run.error).toContain('timed out');
342
342
  });
343
+
344
+ it('keeps a supervising lead alive after idle nudges are exhausted', async () => {
345
+ let exitCallCount = 0;
346
+ waitForExitFn = vi.fn().mockImplementation(() => {
347
+ exitCallCount++;
348
+ return Promise.resolve(exitCallCount < 3 ? 'timeout' : 'exited');
349
+ });
350
+
351
+ const config = makeConfig({
352
+ swarm: {
353
+ pattern: 'hub-spoke',
354
+ idleNudge: { nudgeAfterMs: 50, escalateAfterMs: 50, maxNudges: 1 },
355
+ channel: 'lead-supervision',
356
+ },
357
+ });
358
+ const agentDef = { name: 'team-lead', cli: 'claude', role: 'Lead coordinator' };
359
+ const step = {
360
+ name: 'step-1',
361
+ agent: 'team-lead',
362
+ task: 'Monitor #lead-supervision for WORKER_DONE, wait for the handoff, then exit.',
363
+ };
364
+
365
+ (runner as any).currentConfig = config;
366
+ expect((runner as any).shouldPreserveIdleSupervisor(agentDef, step)).toBe(true);
367
+
368
+ const result = await (runner as any).waitForExitWithIdleNudging(
369
+ mockAgent,
370
+ agentDef,
371
+ step,
372
+ 500,
373
+ true
374
+ );
375
+
376
+ expect(result).toBe('exited');
377
+ expect(waitForExitFn).toHaveBeenCalledTimes(3);
378
+ expect(mockRelease).not.toHaveBeenCalled();
379
+ });
343
380
  });
344
381
 
345
382
  describe('Idle = done (no idleNudge config)', () => {
@@ -369,6 +406,37 @@ describe('Idle Nudge Detection', () => {
369
406
  expect(mockRelease).not.toHaveBeenCalled();
370
407
  });
371
408
 
409
+ it('does not treat supervisory lead idleness as completion', async () => {
410
+ waitForExitFn = vi.fn().mockResolvedValue('exited');
411
+ waitForIdleFn = vi.fn().mockResolvedValue('idle');
412
+
413
+ const config = makeConfig({
414
+ swarm: { pattern: 'hub-spoke', channel: 'lead-supervision' },
415
+ });
416
+ const agentDef = { name: 'team-lead', cli: 'claude', role: 'Lead coordinator' };
417
+ const step = {
418
+ name: 'step-1',
419
+ agent: 'team-lead',
420
+ task: 'Wait on #lead-supervision for WORKER_DONE before handing off.',
421
+ };
422
+
423
+ (runner as any).currentConfig = config;
424
+ expect((runner as any).shouldPreserveIdleSupervisor(agentDef, step)).toBe(true);
425
+
426
+ const result = await (runner as any).waitForExitWithIdleNudging(
427
+ mockAgent,
428
+ agentDef,
429
+ step,
430
+ 500,
431
+ true
432
+ );
433
+
434
+ expect(result).toBe('exited');
435
+ expect(waitForExitFn).toHaveBeenCalledTimes(1);
436
+ expect(waitForIdleFn).not.toHaveBeenCalled();
437
+ expect(mockRelease).not.toHaveBeenCalled();
438
+ });
439
+
372
440
  it('both timeout: fails step with timeout error', async () => {
373
441
  waitForExitFn = vi.fn().mockResolvedValue('timeout');
374
442
  waitForIdleFn = vi.fn().mockResolvedValue('timeout');
@@ -543,11 +543,11 @@ agents:
543
543
  expect(run.status, run.error).toBe('completed');
544
544
  });
545
545
 
546
- it('should fail when owner response does not include completion marker', async () => {
546
+ it('should fail when owner response provides no decision, marker, or evidence', async () => {
547
547
  mockSpawnOutputs = ['Owner completed work but forgot sentinel\n'];
548
548
  const run = await runner.execute(makeConfig(), 'default');
549
549
  expect(run.status).toBe('failed');
550
- expect(run.error).toContain('owner completion marker');
550
+ expect(run.error).toContain('owner completion decision missing');
551
551
  });
552
552
 
553
553
  it('should run specialist work in a separate process and mirror worker output to the channel', async () => {
@@ -564,8 +564,11 @@ agents:
564
564
  expect(spawnCalls[0][0].name).toContain('step-1-worker');
565
565
  expect(spawnCalls[1][0].name).toContain('step-1-owner');
566
566
  expect(spawnCalls[0][0].task).not.toContain('STEP_COMPLETE:step-1');
567
+ expect(spawnCalls[0][0].task).toContain('WORKER COMPLETION CONTRACT');
568
+ expect(spawnCalls[0][0].task).toContain('WORKER_DONE: <brief summary>');
567
569
  expect(spawnCalls[1][0].task).toContain('You are the step owner/supervisor for step "step-1".');
568
570
  expect(spawnCalls[1][0].task).toContain('runtime: step-1-worker');
571
+ expect(spawnCalls[1][0].task).toContain('LEAD_DONE: <brief summary>');
569
572
 
570
573
  const channelMessages = (mockRelaycastAgent.send as any).mock.calls.map(
571
574
  ([, text]: [string, string]) => text
@@ -574,6 +577,112 @@ agents:
574
577
  expect(channelMessages.some((text: string) => text.includes('worker finished'))).toBe(true);
575
578
  });
576
579
 
580
+ it('should apply verification fallback for self-owned interactive steps', async () => {
581
+ mockSpawnOutputs = [
582
+ 'LEAD_DONE\n',
583
+ 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: verified\n',
584
+ ];
585
+
586
+ const run = await runner.execute(
587
+ makeConfig({
588
+ agents: [{ name: 'team-lead', cli: 'claude', role: 'Lead coordinator' }],
589
+ workflows: [
590
+ {
591
+ name: 'default',
592
+ steps: [
593
+ {
594
+ name: 'lead-step',
595
+ agent: 'team-lead',
596
+ task: 'Output exactly:\nLEAD_DONE\n/exit',
597
+ verification: { type: 'exit_code', value: 0 },
598
+ },
599
+ ],
600
+ },
601
+ ],
602
+ }),
603
+ 'default'
604
+ );
605
+
606
+ expect(run.status, run.error).toBe('completed');
607
+ const steps = await db.getStepsByRunId(run.id);
608
+ expect(steps[0]?.completionReason).toBe('completed_verified');
609
+ });
610
+
611
+ it('should keep explicit interactive workers self-owned without extra supervisor/reviewer spawns', async () => {
612
+ const ownerAssignments: Array<{ owner: string; specialist: string }> = [];
613
+ runner.on((event) => {
614
+ if (event.type === 'step:owner-assigned') {
615
+ ownerAssignments.push({ owner: event.ownerName, specialist: event.specialistName });
616
+ }
617
+ });
618
+
619
+ mockSpawnOutputs = ['STEP_COMPLETE:worker-step\nWORKER_DONE_LOCAL\n'];
620
+
621
+ const run = await runner.execute(
622
+ makeConfig({
623
+ agents: [
624
+ { name: 'team-lead', cli: 'claude', role: 'Lead coordinator', preset: 'lead' },
625
+ { name: 'relay-worker', cli: 'codex', preset: 'worker', interactive: true },
626
+ ],
627
+ workflows: [
628
+ {
629
+ name: 'default',
630
+ steps: [
631
+ {
632
+ name: 'worker-step',
633
+ agent: 'relay-worker',
634
+ task: 'Output exactly:\nWORKER_DONE_LOCAL\n/exit',
635
+ verification: { type: 'output_contains', value: 'WORKER_DONE_LOCAL' },
636
+ },
637
+ ],
638
+ },
639
+ ],
640
+ }),
641
+ 'default'
642
+ );
643
+
644
+ expect(ownerAssignments).toContainEqual({ owner: 'relay-worker', specialist: 'relay-worker' });
645
+ expect(run.status).toBe('failed');
646
+ expect(run.error).toContain('verification failed');
647
+
648
+ const spawnCalls = (mockRelayInstance.spawnPty as any).mock.calls;
649
+ expect(spawnCalls).toHaveLength(1);
650
+ expect(spawnCalls[0][0].task).toContain('STEP OWNER CONTRACT');
651
+ expect(spawnCalls[0][0].name).not.toContain('-owner-');
652
+ expect(spawnCalls[0][0].name).not.toContain('-review-');
653
+ });
654
+
655
+ it('should pass canonical bypass args to interactive codex PTY spawns', async () => {
656
+ mockSpawnOutputs = [
657
+ 'LEAD_DONE\n',
658
+ 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: verified\n',
659
+ ];
660
+
661
+ const run = await runner.execute(
662
+ makeConfig({
663
+ agents: [{ name: 'lead', cli: 'codex', role: 'Lead coordinator' }],
664
+ workflows: [
665
+ {
666
+ name: 'default',
667
+ steps: [
668
+ {
669
+ name: 'lead-step',
670
+ agent: 'lead',
671
+ task: 'Output exactly:\nLEAD_DONE\n/exit',
672
+ verification: { type: 'exit_code', value: 0 },
673
+ },
674
+ ],
675
+ },
676
+ ],
677
+ }),
678
+ 'default'
679
+ );
680
+
681
+ expect(run.status, run.error).toBe('completed');
682
+ const spawnCalls = (mockRelayInstance.spawnPty as any).mock.calls;
683
+ expect(spawnCalls[0][0].args).toEqual(['--dangerously-bypass-approvals-and-sandbox']);
684
+ });
685
+
577
686
  it('should let the owner complete after checking file-based artifacts', async () => {
578
687
  const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'relay-owner-file-'));
579
688
  const artifact = path.join(tmpDir, 'artifact.txt');
@@ -615,8 +724,8 @@ agents:
615
724
  expect(stepRows[0].output).not.toContain('Worker already exited; artifacts look correct');
616
725
  });
617
726
 
618
- it('should fail closed when review response is malformed', async () => {
619
- mockSpawnOutputs = ['STEP_COMPLETE:step-1\n', 'REVIEW_REASON: looks fine\n'];
727
+ it('should fail when review response lacks any usable decision signal', async () => {
728
+ mockSpawnOutputs = ['STEP_COMPLETE:step-1\n', 'I need more context before deciding.\n'];
620
729
  const run = await runner.execute(makeConfig(), 'default');
621
730
  expect(run.status).toBe('failed');
622
731
  expect(run.error).toContain('review response malformed');
@@ -78,7 +78,7 @@ async function main(): Promise<void> {
78
78
  });
79
79
 
80
80
  console.log(
81
- `[${now()}] workers spawned. send kickoff via Relaycast (MCP mcp__relaycast__dm_send) and watch events here (Ctrl+C to stop).`,
81
+ `[${now()}] workers spawned. send kickoff via Relaycast (MCP mcp__relaycast__message_dm_send) and watch events here (Ctrl+C to stop).`,
82
82
  );
83
83
  await new Promise<void>(() => {
84
84
  // keep process alive while events stream
@@ -33,16 +33,16 @@ const WORKFLOW_BOOTSTRAP_TASK =
33
33
 
34
34
  const WORKFLOW_CONVENTIONS = [
35
35
  'Messaging requirements:',
36
- '- When you receive `Relay message from <sender> ...`, reply using `mcp__relaycast__dm_send(to: "<sender>", text: "...")`.',
36
+ '- When you receive `Relay message from <sender> ...`, reply using `mcp__relaycast__message_dm_send(to: "<sender>", text: "...")`.',
37
37
  '- Send `ACK: ...` when you receive a task.',
38
38
  '- Send `DONE: ...` when the task is complete.',
39
- '- Do not reply only in terminal text; send the response via mcp__relaycast__dm_send.',
40
- '- Use mcp__relaycast__inbox_check() and mcp__relaycast__agent_list() when context is missing.',
39
+ '- Do not reply only in terminal text; send the response via mcp__relaycast__message_dm_send.',
40
+ '- Use mcp__relaycast__message_inbox_check() and mcp__relaycast__agent_list() when context is missing.',
41
41
  ].join('\n');
42
42
 
43
43
  function hasWorkflowConventions(task: string): boolean {
44
44
  const lower = task.toLowerCase();
45
- return lower.includes('mcp__relaycast__dm_send(') || lower.includes('relay_send(') || (lower.includes('ack:') && lower.includes('done:'));
45
+ return lower.includes('mcp__relaycast__message_dm_send(') || lower.includes('relay_send(') || (lower.includes('ack:') && lower.includes('done:'));
46
46
  }
47
47
 
48
48
  function buildSpawnTask(
@@ -104,8 +104,8 @@ workflows:
104
104
  agent: backend
105
105
  task: "Build the REST API endpoints for user management"
106
106
  verification:
107
- type: output_contains
108
- value: "BUILD_COMPLETE"
107
+ type: file_exists
108
+ value: "src/api/users.ts"
109
109
  retries: 1
110
110
 
111
111
  - name: write-tests
@@ -154,22 +154,50 @@ await runWorkflow("workflow.yaml", {
154
154
 
155
155
  ### Verification Checks
156
156
 
157
- Each step can include a verification check that must pass for the step to be considered complete:
157
+ Each step can include a verification check. Verification is one input to the runner's **completion decision pipeline** — when verification passes, the step completes even without a sentinel marker.
158
158
 
159
159
  | Type | Description |
160
160
  |------|-------------|
161
- | `output_contains` | Step output must contain the specified string |
162
- | `exit_code` | Agent must exit with the specified code |
161
+ | `exit_code` | Agent must exit with the specified code (preferred for code-editing steps) |
163
162
  | `file_exists` | A file must exist at the specified path after the step |
163
+ | `output_contains` | Step output must contain the specified string (optional accelerator) |
164
164
  | `custom` | No-op in the runner; handled by external callers |
165
165
 
166
166
  ```yaml
167
+ # Preferred — deterministic verification
168
+ verification:
169
+ type: exit_code
170
+ value: "0"
171
+ description: "Process exited successfully"
172
+
173
+ # Also valid — output_contains as an optional accelerator
167
174
  verification:
168
175
  type: output_contains
169
176
  value: "IMPLEMENTATION_COMPLETE"
170
- description: "Agent must confirm completion"
177
+ description: "Agent confirms completion (optional fast-path)"
171
178
  ```
172
179
 
180
+ ### Completion Decision Pipeline
181
+
182
+ The runner uses a multi-signal pipeline to decide step completion:
183
+
184
+ 1. **Deterministic verification** — if a verification check passes, the step completes immediately (`completed_verified`)
185
+ 2. **Owner decision** — the step owner can issue `OWNER_DECISION: COMPLETE|INCOMPLETE_RETRY|INCOMPLETE_FAIL` (`completed_by_owner_decision`)
186
+ 3. **Evidence-based completion** — channel messages, file artifacts, and exit codes are collected as evidence (`completed_by_evidence`)
187
+ 4. **Marker fast-path** — `STEP_COMPLETE:<step-name>` still works as an accelerator but is never required
188
+
189
+ | Completion State | Meaning |
190
+ |---|---|
191
+ | `completed_verified` | Deterministic verification passed |
192
+ | `completed_by_owner_decision` | Owner approved the step |
193
+ | `completed_by_evidence` | Evidence-based completion |
194
+ | `retry_requested_by_owner` | Owner requested retry |
195
+ | `failed_verification` | Verification explicitly failed |
196
+ | `failed_owner_decision` | Owner rejected the step |
197
+ | `failed_no_evidence` | No verification, no owner decision, no evidence |
198
+
199
+ **Review parsing is tolerant:** The runner accepts semantically equivalent outputs like "Approved", "Complete", "LGTM" — not just exact `REVIEW_DECISION: APPROVE` strings.
200
+
173
201
  ## Swarm Patterns
174
202
 
175
203
  The `swarm.pattern` field controls how agents are coordinated:
@@ -642,12 +670,16 @@ The runner emits two new events for idle nudging:
642
670
 
643
671
  ## Automatic Step Owner and Review
644
672
 
645
- For interactive agent steps, the runner now hardens handoffs automatically:
673
+ For interactive agent steps, the runner uses a point-person-led completion model:
674
+
675
+ 1. **Elects a step owner** (prefers lead/coordinator-style agents, falls back to the step agent)
676
+ 2. **Runs a completion decision pipeline** — checks deterministic verification first, then owner judgment, then evidence
677
+ 3. **Owner can issue structured decisions** via `OWNER_DECISION: COMPLETE|INCOMPLETE_RETRY|INCOMPLETE_FAIL|NEEDS_CLARIFICATION` with optional `REASON: <text>`
678
+ 4. **Review parsing is tolerant** — accepts "Approved", "Complete", "LGTM", not just exact `REVIEW_DECISION: APPROVE`
679
+ 5. **Markers are optional accelerators** — `STEP_COMPLETE:<step-name>` still works as a fast-path but is never required
680
+ 6. Stores primary output plus review output in the step artifact
646
681
 
647
- 1. Elects a step owner (prefers lead/coordinator-style agents, falls back to the step agent)
648
- 2. Requires the owner to provide an explicit completion signal (`STEP_COMPLETE:<step-name>`)
649
- 3. Runs a review pass before marking the step complete (prefers reviewer-style agents when present)
650
- 4. Stores primary output plus review output in the step artifact
682
+ **Evidence-based completion:** The runner collects channel messages, file artifacts, process exit codes, and coordination signals (e.g., WORKER_DONE posted in channel) as completion evidence. When sufficient evidence exists, the step completes without requiring any sentinel marker.
651
683
 
652
684
  Deterministic and worktree steps are unchanged and do not require owner/review delegation.
653
685
 
@@ -4,6 +4,7 @@ import type { AgentRelayOptions } from '../relay.js';
4
4
  import type {
5
5
  AgentCli,
6
6
  AgentDefinition,
7
+ AgentPreset,
7
8
  DryRunReport,
8
9
  ErrorHandlingConfig,
9
10
  IdleNudgeConfig,
@@ -33,9 +34,12 @@ export interface AgentOptions {
33
34
  /** When false, the agent runs as a non-interactive subprocess (no PTY, no relay messaging).
34
35
  * Default: true. */
35
36
  interactive?: boolean;
37
+ /** Agent preset: 'lead' (interactive PTY), 'worker' | 'reviewer' | 'analyst' (non-interactive subprocess). */
38
+ preset?: AgentPreset;
36
39
  }
37
40
 
38
- export interface StepOptions {
41
+ /** Options for agent steps (default). */
42
+ export interface AgentStepOptions {
39
43
  agent: string;
40
44
  task: string;
41
45
  dependsOn?: string[];
@@ -44,6 +48,20 @@ export interface StepOptions {
44
48
  retries?: number;
45
49
  }
46
50
 
51
+ /** Options for deterministic (shell command) steps. */
52
+ export interface DeterministicStepOptions {
53
+ type: 'deterministic';
54
+ command: string;
55
+ dependsOn?: string[];
56
+ /** Fail if command exit code is non-zero. Default: true. */
57
+ failOnError?: boolean;
58
+ /** Capture stdout as step output for downstream steps. Default: true. */
59
+ captureOutput?: boolean;
60
+ timeoutMs?: number;
61
+ }
62
+
63
+ export type StepOptions = AgentStepOptions | DeterministicStepOptions;
64
+
47
65
  export interface ErrorOptions {
48
66
  maxRetries?: number;
49
67
  retryDelayMs?: number;
@@ -146,6 +164,7 @@ export class WorkflowBuilder {
146
164
  if (options.role !== undefined) def.role = options.role;
147
165
  if (options.task !== undefined) def.task = options.task;
148
166
  if (options.channels !== undefined) def.channels = options.channels;
167
+ if (options.preset !== undefined) def.preset = options.preset;
149
168
  if (options.interactive !== undefined) def.interactive = options.interactive;
150
169
 
151
170
  if (
@@ -168,18 +187,25 @@ export class WorkflowBuilder {
168
187
  return this;
169
188
  }
170
189
 
171
- /** Add a workflow step. */
190
+ /** Add a workflow step (agent or deterministic). */
172
191
  step(name: string, options: StepOptions): this {
173
- const step: WorkflowStep = {
174
- name,
175
- agent: options.agent,
176
- task: options.task,
177
- };
192
+ const step: WorkflowStep = { name };
193
+
194
+ if ('type' in options && options.type === 'deterministic') {
195
+ step.type = 'deterministic';
196
+ step.command = options.command;
197
+ if (options.failOnError !== undefined) step.failOnError = options.failOnError;
198
+ if (options.captureOutput !== undefined) step.captureOutput = options.captureOutput;
199
+ } else {
200
+ const agentOpts = options as AgentStepOptions;
201
+ step.agent = agentOpts.agent;
202
+ step.task = agentOpts.task;
203
+ if (agentOpts.verification !== undefined) step.verification = agentOpts.verification;
204
+ if (agentOpts.retries !== undefined) step.retries = agentOpts.retries;
205
+ }
178
206
 
179
207
  if (options.dependsOn !== undefined) step.dependsOn = options.dependsOn;
180
- if (options.verification !== undefined) step.verification = options.verification;
181
208
  if (options.timeoutMs !== undefined) step.timeoutMs = options.timeoutMs;
182
- if (options.retries !== undefined) step.retries = options.retries;
183
209
 
184
210
  this._steps.push(step);
185
211
  return this;
@@ -196,8 +222,9 @@ export class WorkflowBuilder {
196
222
 
197
223
  /** Build and return the RelayYamlConfig object. */
198
224
  toConfig(): RelayYamlConfig {
199
- if (this._agents.length === 0) {
200
- throw new Error('Workflow must have at least one agent');
225
+ const hasAgentSteps = this._steps.some((s) => s.type !== 'deterministic' && s.type !== 'worktree');
226
+ if (hasAgentSteps && this._agents.length === 0) {
227
+ throw new Error('Workflow must have at least one agent when using agent steps');
201
228
  }
202
229
  if (this._steps.length === 0) {
203
230
  throw new Error('Workflow must have at least one step');