agent-relay 3.2.16 → 3.2.18

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/sdk",
3
- "version": "3.2.16",
3
+ "version": "3.2.18",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -112,7 +112,7 @@
112
112
  "typescript": "^5.7.3"
113
113
  },
114
114
  "dependencies": {
115
- "@agent-relay/config": "3.2.16",
115
+ "@agent-relay/config": "3.2.18",
116
116
  "@relaycast/sdk": "^1.1.0",
117
117
  "@sinclair/typebox": "^0.34.48",
118
118
  "chalk": "^4.1.2",
@@ -6223,26 +6223,23 @@ export class WorkflowRunner {
6223
6223
  );
6224
6224
  console.log(chalk.dim('━'.repeat(70)));
6225
6225
 
6226
- if (this.agentReports.size > 0) {
6227
- console.log(formatRunSummaryTable(outcomes, this.agentReports));
6228
- } else {
6229
- for (const outcome of outcomes) {
6230
- const icon =
6231
- outcome.status === 'completed' ? chalk.green('✓') : outcome.status === 'failed' ? chalk.red('✗') : chalk.dim('⊘');
6232
- const retryNote = outcome.attempts > 1 ? ` (${outcome.attempts} attempts)` : '';
6233
- console.log(` ${icon} ${outcome.name} [${outcome.agent}]${retryNote}`);
6234
-
6235
- if (outcome.error) {
6236
- console.log(` Error: ${outcome.error}`);
6237
- }
6226
+ // Always show the summary table — with agent reports when available,
6227
+ // with just step/status/duration when not (non-interactive agents).
6228
+ console.log(formatRunSummaryTable(outcomes, this.agentReports));
6238
6229
 
6239
- // Extract last meaningful lines from raw PTY output
6240
- if (outcome.output) {
6241
- const excerpt = this.extractOutputExcerpt(outcome.output);
6242
- if (excerpt) {
6243
- for (const line of excerpt.split('\n')) {
6244
- console.log(` ${line}`);
6245
- }
6230
+ // Show errors and output excerpts for failed steps below the table
6231
+ for (const outcome of outcomes) {
6232
+ if (outcome.status !== 'failed') continue;
6233
+
6234
+ if (outcome.error) {
6235
+ console.log(chalk.red(` ${outcome.name}: ${outcome.error}`));
6236
+ }
6237
+
6238
+ if (outcome.output) {
6239
+ const excerpt = this.extractOutputExcerpt(outcome.output);
6240
+ if (excerpt) {
6241
+ for (const line of excerpt.split('\n')) {
6242
+ console.log(` ${line}`);
6246
6243
  }
6247
6244
  }
6248
6245
  }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "agent-relay-sdk"
7
- version = "3.2.16"
7
+ version = "3.2.18"
8
8
  description = "Python SDK for Agent Relay workflows"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -18,9 +18,11 @@ Example::
18
18
  from __future__ import annotations
19
19
 
20
20
  import copy
21
+ import os
21
22
  import re
22
23
  import shutil
23
24
  import subprocess
25
+ import sys
24
26
  import tempfile
25
27
  from pathlib import Path
26
28
  from typing import Any
@@ -388,9 +390,36 @@ class WorkflowBuilder:
388
390
  """Serialize the config to a YAML string."""
389
391
  return yaml.dump(self.to_config(), default_flow_style=False, sort_keys=False)
390
392
 
393
+ def dry_run(self, options: RunOptions | None = None) -> WorkflowResult:
394
+ """Validate the workflow and show execution plan without running."""
395
+ opts = RunOptions(
396
+ workflow=(options.workflow if options else None),
397
+ cwd=(options.cwd if options else None),
398
+ vars=(options.vars if options else None),
399
+ trajectories=(options.trajectories if options else None),
400
+ on_event=(options.on_event if options else None),
401
+ dry_run=True,
402
+ )
403
+ return self.run(opts)
404
+
391
405
  def run(self, options: RunOptions | None = None) -> WorkflowResult:
392
- """Build the config and execute it via ``agent-relay run <tempfile>``."""
393
- opts = options or RunOptions()
406
+ """Build the config and execute it via ``agent-relay run <tempfile>``.
407
+
408
+ Dry-run is enabled when:
409
+ - ``options.dry_run`` is ``True``, or
410
+ - the ``DRY_RUN`` environment variable is set to ``"true"``
411
+ (set automatically by ``agent-relay run script.py --dry-run``).
412
+ """
413
+ opts = RunOptions(
414
+ workflow=(options.workflow if options else None),
415
+ cwd=(options.cwd if options else None),
416
+ vars=(options.vars if options else None),
417
+ trajectories=(options.trajectories if options else None),
418
+ on_event=(options.on_event if options else None),
419
+ dry_run=(options.dry_run if options else None),
420
+ )
421
+ if opts.dry_run is None and os.environ.get("DRY_RUN") == "true":
422
+ opts.dry_run = True
394
423
  config = _apply_runtime_overrides(self.to_config(), opts)
395
424
  return _run_config(config, opts)
396
425
 
@@ -402,7 +431,16 @@ def workflow(name: str) -> WorkflowBuilder:
402
431
 
403
432
  def run_yaml(yaml_path: str, options: RunOptions | None = None) -> WorkflowResult:
404
433
  """Run an existing relay YAML workflow file."""
405
- opts = options or RunOptions()
434
+ opts = RunOptions(
435
+ workflow=(options.workflow if options else None),
436
+ cwd=(options.cwd if options else None),
437
+ vars=(options.vars if options else None),
438
+ trajectories=(options.trajectories if options else None),
439
+ on_event=(options.on_event if options else None),
440
+ dry_run=(options.dry_run if options else None),
441
+ )
442
+ if opts.dry_run is None and os.environ.get("DRY_RUN") == "true":
443
+ opts.dry_run = True
406
444
 
407
445
  if opts.trajectories is None and not opts.vars:
408
446
  return _run_yaml_path(yaml_path, opts)
@@ -436,6 +474,8 @@ def _run_yaml_path(yaml_path: str, options: RunOptions) -> WorkflowResult:
436
474
  )
437
475
 
438
476
  cmd = [*cmd_prefix, "run", yaml_path]
477
+ if options.dry_run:
478
+ cmd.append("--dry-run")
439
479
  if options.workflow:
440
480
  cmd.extend(["--workflow", options.workflow])
441
481
 
@@ -462,7 +502,26 @@ def _execute_cli(
462
502
  cwd: str | None,
463
503
  on_event: WorkflowEventCallback | None,
464
504
  ) -> WorkflowResult:
465
- """Execute CLI command and parse emitted workflow events."""
505
+ """Execute CLI command and parse emitted workflow events.
506
+
507
+ When no event callback is registered, stdio is passed straight through so
508
+ the TypeScript runner's listr progress and summary table render directly
509
+ to the terminal — identical output to YAML and TypeScript workflows.
510
+ """
511
+ # Passthrough mode: no callback → let the TS runner render directly
512
+ if on_event is None:
513
+ process = subprocess.Popen(cmd, cwd=cwd)
514
+ process.wait()
515
+
516
+ return WorkflowResult(
517
+ status="completed" if process.returncode == 0 else "failed",
518
+ run_id="",
519
+ error=None if process.returncode == 0 else "Workflow failed",
520
+ steps=[],
521
+ events=[],
522
+ )
523
+
524
+ # Capture mode: callback registered → parse events line by line
466
525
  process = subprocess.Popen(
467
526
  cmd,
468
527
  cwd=cwd,
@@ -487,9 +546,7 @@ def _execute_cli(
487
546
 
488
547
  events.append(event)
489
548
  _sync_step_result(steps, event)
490
-
491
- if on_event is not None:
492
- on_event(event)
549
+ on_event(event)
493
550
 
494
551
  return_code = process.wait()
495
552
  output = "\n".join(lines).strip()
@@ -562,6 +562,7 @@ class RunOptions:
562
562
  vars: dict[str, str | int | bool] | None = None
563
563
  trajectories: TrajectoryConfig | Literal[False] | dict[str, Any] | bool | None = None
564
564
  on_event: WorkflowEventCallback | None = None
565
+ dry_run: bool | None = None
565
566
 
566
567
 
567
568
  @dataclass
@@ -213,3 +213,61 @@ def test_dag_empty_agents_raises():
213
213
  def test_dag_empty_steps_raises():
214
214
  with pytest.raises(ValueError, match="at least one step"):
215
215
  dag("empty", agents=[TemplateAgent(name="a")], steps=[])
216
+
217
+
218
+ def test_run_options_dry_run_flag():
219
+ """dry_run option should be passed through to CLI as --dry-run."""
220
+ from agent_relay.types import RunOptions
221
+
222
+ opts = RunOptions(dry_run=True)
223
+ assert opts.dry_run is True
224
+
225
+ opts_default = RunOptions()
226
+ assert opts_default.dry_run is None
227
+
228
+
229
+ def test_dry_run_env_var(monkeypatch):
230
+ """DRY_RUN=true env var should enable dry_run on .run()."""
231
+ monkeypatch.setenv("DRY_RUN", "true")
232
+
233
+ builder = (
234
+ workflow("test-dry")
235
+ .pattern("dag")
236
+ .agent("worker", cli="claude")
237
+ .step("s1", agent="worker", task="Do something")
238
+ )
239
+
240
+ # Calling .run() should resolve dry_run from env — we test via RunOptions
241
+ from agent_relay.types import RunOptions
242
+ import os
243
+
244
+ opts = RunOptions()
245
+ if opts.dry_run is None and os.environ.get("DRY_RUN") == "true":
246
+ opts.dry_run = True
247
+ assert opts.dry_run is True
248
+
249
+
250
+ def test_dry_run_env_var_not_set():
251
+ """Without DRY_RUN env var, dry_run should remain None."""
252
+ from agent_relay.types import RunOptions
253
+ import os
254
+
255
+ # Ensure env var is not set
256
+ os.environ.pop("DRY_RUN", None)
257
+ opts = RunOptions()
258
+ assert opts.dry_run is None
259
+
260
+
261
+ def test_dry_run_method():
262
+ """WorkflowBuilder.dry_run() should set dry_run=True."""
263
+ builder = (
264
+ workflow("test-dry-method")
265
+ .pattern("dag")
266
+ .agent("worker", cli="claude")
267
+ .step("s1", agent="worker", task="Do something")
268
+ )
269
+
270
+ # We can't actually call dry_run() without the CLI, but we can verify
271
+ # the method exists and the config is still valid
272
+ config = builder.to_config()
273
+ assert config["name"] == "test-dry-method"
@@ -0,0 +1,215 @@
1
+ """Tests for dry-run support in the Python workflow builder."""
2
+
3
+ import os
4
+ import subprocess
5
+ from unittest.mock import MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from agent_relay import workflow, fan_out, pipeline, PipelineStage, run_yaml
10
+ from agent_relay.types import RunOptions
11
+
12
+
13
+ class TestDryRunOption:
14
+ """RunOptions.dry_run field."""
15
+
16
+ def test_default_is_none(self):
17
+ opts = RunOptions()
18
+ assert opts.dry_run is None
19
+
20
+ def test_explicit_true(self):
21
+ opts = RunOptions(dry_run=True)
22
+ assert opts.dry_run is True
23
+
24
+ def test_explicit_false(self):
25
+ opts = RunOptions(dry_run=False)
26
+ assert opts.dry_run is False
27
+
28
+
29
+ class TestDryRunEnvVar:
30
+ """DRY_RUN environment variable auto-detection."""
31
+
32
+ def test_env_var_enables_dry_run(self, monkeypatch):
33
+ monkeypatch.setenv("DRY_RUN", "true")
34
+ builder = (
35
+ workflow("test")
36
+ .agent("w", cli="claude")
37
+ .step("s", agent="w", task="t")
38
+ )
39
+ with patch("agent_relay.builder._run_config") as mock_run:
40
+ mock_run.return_value = MagicMock(status="completed")
41
+ builder.run()
42
+ # The opts passed to _run_config should have dry_run=True
43
+ call_opts = mock_run.call_args[0][1]
44
+ assert call_opts.dry_run is True
45
+
46
+ def test_env_var_not_set_leaves_none(self, monkeypatch):
47
+ monkeypatch.delenv("DRY_RUN", raising=False)
48
+ builder = (
49
+ workflow("test")
50
+ .agent("w", cli="claude")
51
+ .step("s", agent="w", task="t")
52
+ )
53
+ with patch("agent_relay.builder._run_config") as mock_run:
54
+ mock_run.return_value = MagicMock(status="completed")
55
+ builder.run()
56
+ call_opts = mock_run.call_args[0][1]
57
+ assert call_opts.dry_run is None
58
+
59
+ def test_explicit_false_overrides_env(self, monkeypatch):
60
+ monkeypatch.setenv("DRY_RUN", "true")
61
+ builder = (
62
+ workflow("test")
63
+ .agent("w", cli="claude")
64
+ .step("s", agent="w", task="t")
65
+ )
66
+ with patch("agent_relay.builder._run_config") as mock_run:
67
+ mock_run.return_value = MagicMock(status="completed")
68
+ builder.run(RunOptions(dry_run=False))
69
+ call_opts = mock_run.call_args[0][1]
70
+ assert call_opts.dry_run is False
71
+
72
+
73
+ class TestDryRunCLIFlag:
74
+ """--dry-run flag is passed to the agent-relay CLI."""
75
+
76
+ def test_dry_run_adds_flag(self):
77
+ """When dry_run=True, the CLI command should include --dry-run."""
78
+ from agent_relay.builder import _find_agent_relay
79
+
80
+ cmd_prefix = _find_agent_relay()
81
+ if cmd_prefix is None:
82
+ pytest.skip("agent-relay CLI not installed")
83
+
84
+ builder = (
85
+ workflow("test-flag")
86
+ .agent("w", cli="claude")
87
+ .step("s", agent="w", task="t")
88
+ )
89
+
90
+ with patch("agent_relay.builder._execute_cli") as mock_exec:
91
+ mock_run_result = MagicMock(status="completed")
92
+ mock_exec.return_value = mock_run_result
93
+
94
+ builder.run(RunOptions(dry_run=True))
95
+
96
+ cmd = mock_exec.call_args[0][0]
97
+ assert "--dry-run" in cmd
98
+
99
+ def test_no_dry_run_omits_flag(self):
100
+ """When dry_run is not set, --dry-run should not be in the command."""
101
+ from agent_relay.builder import _find_agent_relay
102
+
103
+ cmd_prefix = _find_agent_relay()
104
+ if cmd_prefix is None:
105
+ pytest.skip("agent-relay CLI not installed")
106
+
107
+ builder = (
108
+ workflow("test-no-flag")
109
+ .agent("w", cli="claude")
110
+ .step("s", agent="w", task="t")
111
+ )
112
+
113
+ with patch("agent_relay.builder._execute_cli") as mock_exec:
114
+ mock_run_result = MagicMock(status="completed")
115
+ mock_exec.return_value = mock_run_result
116
+
117
+ builder.run()
118
+
119
+ cmd = mock_exec.call_args[0][0]
120
+ assert "--dry-run" not in cmd
121
+
122
+
123
+ class TestDryRunMethod:
124
+ """.dry_run() convenience method."""
125
+
126
+ def test_dry_run_method_sets_flag(self):
127
+ builder = (
128
+ workflow("test-method")
129
+ .agent("w", cli="claude")
130
+ .step("s", agent="w", task="t")
131
+ )
132
+
133
+ with patch("agent_relay.builder._run_config") as mock_run:
134
+ mock_run.return_value = MagicMock(status="completed")
135
+ builder.dry_run()
136
+ call_opts = mock_run.call_args[0][1]
137
+ assert call_opts.dry_run is True
138
+
139
+
140
+ class TestDryRunE2E:
141
+ """End-to-end dry-run through agent-relay CLI (requires CLI installed)."""
142
+
143
+ def test_builder_dry_run_e2e(self):
144
+ from agent_relay.builder import _find_agent_relay
145
+
146
+ if _find_agent_relay() is None:
147
+ pytest.skip("agent-relay CLI not installed")
148
+
149
+ result = (
150
+ workflow("e2e-dry")
151
+ .agent("w", cli="claude")
152
+ .step("s", agent="w", task="Do something")
153
+ .dry_run()
154
+ )
155
+
156
+ assert result.status == "completed"
157
+
158
+ def test_fan_out_dry_run_e2e(self):
159
+ from agent_relay.builder import _find_agent_relay
160
+
161
+ if _find_agent_relay() is None:
162
+ pytest.skip("agent-relay CLI not installed")
163
+
164
+ result = (
165
+ fan_out("e2e-fan", tasks=["task A", "task B"], worker_cli="claude")
166
+ .dry_run()
167
+ )
168
+
169
+ assert result.status == "completed"
170
+
171
+ def test_pipeline_dry_run_e2e(self):
172
+ from agent_relay.builder import _find_agent_relay
173
+
174
+ if _find_agent_relay() is None:
175
+ pytest.skip("agent-relay CLI not installed")
176
+
177
+ result = pipeline(
178
+ "e2e-pipe",
179
+ stages=[
180
+ PipelineStage(name="s1", task="First"),
181
+ PipelineStage(name="s2", task="Second"),
182
+ ],
183
+ ).dry_run()
184
+
185
+ assert result.status == "completed"
186
+
187
+
188
+ class TestRunYamlDryRun:
189
+ """run_yaml() respects dry_run and DRY_RUN env var."""
190
+
191
+ def test_run_yaml_env_var(self, monkeypatch, tmp_path):
192
+ monkeypatch.setenv("DRY_RUN", "true")
193
+
194
+ yaml_file = tmp_path / "test.yaml"
195
+ yaml_file.write_text("""
196
+ version: "1.0"
197
+ name: yaml-dry-test
198
+ swarm:
199
+ pattern: dag
200
+ agents:
201
+ - name: w
202
+ cli: claude
203
+ workflows:
204
+ - name: wf
205
+ steps:
206
+ - name: s
207
+ agent: w
208
+ task: do something
209
+ """)
210
+
211
+ with patch("agent_relay.builder._run_yaml_path") as mock_run:
212
+ mock_run.return_value = MagicMock(status="completed")
213
+ run_yaml(str(yaml_file))
214
+ call_opts = mock_run.call_args[0][1]
215
+ assert call_opts.dry_run is True
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/telemetry",
3
- "version": "3.2.16",
3
+ "version": "3.2.18",
4
4
  "description": "Anonymous telemetry for Agent Relay usage analytics",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/trajectory",
3
- "version": "3.2.16",
3
+ "version": "3.2.18",
4
4
  "description": "Trajectory integration utilities (trail/PDERO) for Relay",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "test:watch": "vitest"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-relay/config": "3.2.16"
25
+ "@agent-relay/config": "3.2.18"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.19.3",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/user-directory",
3
- "version": "3.2.16",
3
+ "version": "3.2.18",
4
4
  "description": "User directory service for agent-relay (per-user credential storage)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "test:watch": "vitest"
23
23
  },
24
24
  "dependencies": {
25
- "@agent-relay/utils": "3.2.16"
25
+ "@agent-relay/utils": "3.2.18"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@types/node": "^22.19.3",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/utils",
3
- "version": "3.2.16",
3
+ "version": "3.2.18",
4
4
  "description": "Shared utilities for agent-relay: logging, name generation, command resolution, update checking",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/index.js",
@@ -112,7 +112,7 @@
112
112
  "vitest": "^3.2.4"
113
113
  },
114
114
  "dependencies": {
115
- "@agent-relay/config": "3.2.16",
115
+ "@agent-relay/config": "3.2.18",
116
116
  "compare-versions": "^6.1.1"
117
117
  },
118
118
  "publishConfig": {