readyagentsdev 0.8.2__py3-none-any.whl

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 (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
readyagents/cli.py ADDED
@@ -0,0 +1,1050 @@
1
+ """Typer CLI for ReadyAgents Core."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, NoReturn
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.markup import escape
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+
16
+ from readyagents import __version__
17
+ from readyagents.config import DEFAULT_MCP_TOKEN_ENV
18
+ from readyagents.errors import ApprovalRequired, MCPError, ReadyAgentsError
19
+ from readyagents.logging import configure_logging
20
+ from readyagents.packs.loader import collect_pack_specs, discover_packs, load_local_packs
21
+ from readyagents.scaffold import TEMPLATES, create_project
22
+ from readyagents.testing.eval import load_eval_suite, run_eval
23
+ from readyagents.workflow.runner import (
24
+ load_workflow,
25
+ replay_run,
26
+ resume_run,
27
+ run_workflow_file,
28
+ )
29
+ from readyagents.workflow.state import (
30
+ RunState,
31
+ build_decisions,
32
+ delete_run,
33
+ gc_runs,
34
+ list_runs,
35
+ load_decision_file,
36
+ load_run,
37
+ parse_input_pairs,
38
+ )
39
+
40
+ app = typer.Typer(
41
+ name="readyagents",
42
+ help="ReadyAgents Core — Agent Workflow engine + MCP Toolkit (BYOK).",
43
+ no_args_is_help=True,
44
+ add_completion=False,
45
+ )
46
+ mcp_app = typer.Typer(help="Run ReadyAgents as an MCP server.", no_args_is_help=True)
47
+ runs_app = typer.Typer(help="Inspect persisted workflow runs.", no_args_is_help=True)
48
+ app.add_typer(mcp_app, name="mcp")
49
+ app.add_typer(runs_app, name="runs")
50
+
51
+ console = Console()
52
+ err_console = Console(stderr=True)
53
+
54
+ _WORKFLOW_ARG = typer.Argument(
55
+ ...,
56
+ help="Workflow YAML or JSON file.",
57
+ )
58
+ _PACK_HELP = (
59
+ "Local pack .py to load (repeatable). Confined to the workspace. Env: READYAGENTS_PACK."
60
+ )
61
+
62
+
63
+ def _version_flag(value: bool) -> None:
64
+ if value:
65
+ console.print(__version__)
66
+ raise typer.Exit()
67
+
68
+
69
+ @app.callback()
70
+ def _root(
71
+ version: bool | None = typer.Option(
72
+ None,
73
+ "--version",
74
+ callback=_version_flag,
75
+ is_eager=True,
76
+ help="Print version and exit.",
77
+ ),
78
+ log_level: str = typer.Option(
79
+ "INFO",
80
+ "--log-level",
81
+ help="DEBUG, INFO, WARNING, or ERROR.",
82
+ envvar="READYAGENTS_LOG_LEVEL",
83
+ ),
84
+ log_format: str = typer.Option(
85
+ "text",
86
+ "--log-format",
87
+ help="text or json (machine-parseable events with run/node).",
88
+ envvar="READYAGENTS_LOG_FORMAT",
89
+ ),
90
+ ) -> None:
91
+ configure_logging(log_level, fmt=log_format)
92
+
93
+
94
+ @app.command()
95
+ def version() -> None:
96
+ """Print the ReadyAgents version."""
97
+ console.print(__version__)
98
+
99
+
100
+ @app.command("init")
101
+ def init_cmd(
102
+ dest: Path = typer.Option(Path(".env"), "--dest", help="Path to write the env file."),
103
+ ) -> None:
104
+ """Create a local `.env` from `.env.example` if it does not exist."""
105
+ example = Path(".env.example")
106
+ if dest.exists():
107
+ console.print(f"[yellow]{dest} already exists[/yellow] — left unchanged.")
108
+ _print_next_steps()
109
+ return
110
+ if example.is_file():
111
+ dest.write_text(example.read_text(encoding="utf-8"), encoding="utf-8")
112
+ else:
113
+ dest.write_text(_ENV_TEMPLATE, encoding="utf-8")
114
+ console.print(f"[green]Wrote {dest}[/green] — then the keyless smoke:")
115
+ _print_next_steps()
116
+
117
+
118
+ def _print_next_steps() -> None:
119
+ console.print(
120
+ Panel(
121
+ "[bold]Next steps[/bold]\n"
122
+ "1. Smoke test (no keys): "
123
+ "[cyan]readyagents run examples/calc_pipeline.yaml[/cyan]\n"
124
+ "2. Scaffold: [cyan]readyagents new my-flow[/cyan]\n"
125
+ "3. Edit `.env` and set OPENAI_API_KEY and/or ANTHROPIC_API_KEY\n"
126
+ "4. With keys: [cyan]readyagents run examples/research_brief.yaml "
127
+ "--input topic=your-topic[/cyan]\n"
128
+ "See docs/getting-started.md",
129
+ title="ReadyAgents",
130
+ )
131
+ )
132
+
133
+
134
+ @app.command("new")
135
+ def new_cmd(
136
+ name: str = typer.Argument("starter", help="Project / workflow name."),
137
+ dest: Path | None = typer.Option(
138
+ None,
139
+ "--dest",
140
+ help="Directory to write (defaults to ./<name>).",
141
+ ),
142
+ template: str = typer.Option(
143
+ "pipeline",
144
+ "--template",
145
+ "-t",
146
+ help=f"Starter kind: {', '.join(TEMPLATES)}.",
147
+ ),
148
+ ) -> None:
149
+ """Write a starter workflow, README, and `.env.example`."""
150
+ target = dest if dest is not None else Path(name)
151
+ try:
152
+ written = create_project(target, name=name, template=template)
153
+ except ReadyAgentsError as exc:
154
+ _fail(exc)
155
+ return
156
+ console.print(f"[green]Created {target.resolve()}[/green] template={template}")
157
+ for path in written:
158
+ console.print(f" {path.name}")
159
+ wf = target / "workflow.yaml"
160
+ if template in {"basic", "pipeline", "foreach"}:
161
+ console.print(f"Run: [cyan]readyagents run {wf}[/cyan]")
162
+ elif template == "agent-tools":
163
+ console.print(f"Run: [cyan]readyagents run {wf} --dry-run[/cyan]")
164
+ elif template == "research":
165
+ console.print(f"Run: [cyan]readyagents run {wf} --approve publish[/cyan]")
166
+ else:
167
+ console.print(f"Run: [cyan]readyagents run {wf} --approve gate[/cyan]")
168
+
169
+
170
+ @app.command()
171
+ def validate(
172
+ path: Path = _WORKFLOW_ARG,
173
+ as_json: bool = typer.Option(
174
+ False,
175
+ "--json",
176
+ help="Print the workflow summary as JSON on stdout (no tables).",
177
+ ),
178
+ ) -> None:
179
+ """Schema-validate a workflow file without executing it."""
180
+ try:
181
+ workflow = load_workflow(path)
182
+ except ReadyAgentsError as exc:
183
+ if as_json:
184
+ _print_json(
185
+ _json_envelope(
186
+ "validate",
187
+ ok=False,
188
+ error=type(exc).__name__,
189
+ message=str(exc),
190
+ )
191
+ )
192
+ raise typer.Exit(code=1) from exc
193
+ _fail(exc)
194
+ nodes = [
195
+ {
196
+ "id": node.id,
197
+ "type": str(node.type),
198
+ "next": node.next,
199
+ "then": node.then,
200
+ "else": node.else_,
201
+ }
202
+ for node in workflow.nodes
203
+ ]
204
+ if as_json:
205
+ _print_json(
206
+ _json_envelope(
207
+ "validate",
208
+ ok=True,
209
+ name=workflow.name,
210
+ start=workflow.start,
211
+ node_count=len(workflow.nodes),
212
+ nodes=nodes,
213
+ )
214
+ )
215
+ return
216
+ table = Table(title=f"Valid: {workflow.name}")
217
+ table.add_column("Node")
218
+ table.add_column("Type")
219
+ table.add_column("Next")
220
+ for node in workflow.nodes:
221
+ table.add_row(node.id, str(node.type), _node_routing(node))
222
+ console.print(table)
223
+ console.print(f"[green]OK[/green] — {len(workflow.nodes)} node(s), start={workflow.start}")
224
+
225
+
226
+ @app.command("eval")
227
+ def eval_cmd(
228
+ path: Path = typer.Argument(
229
+ ...,
230
+ help="Eval suite YAML or JSON file.",
231
+ ),
232
+ as_json: bool = typer.Option(
233
+ False,
234
+ "--json",
235
+ help="Print the eval report as JSON on stdout (no tables).",
236
+ ),
237
+ ) -> None:
238
+ """Score fixture workflows from a suite file (no network, no API keys)."""
239
+ try:
240
+ cases = load_eval_suite(path)
241
+ report = run_eval(cases)
242
+ except ReadyAgentsError as extra:
243
+ if as_json:
244
+ _print_json(
245
+ _json_envelope(
246
+ "eval",
247
+ ok=False,
248
+ error=type(extra).__name__,
249
+ message=str(extra),
250
+ )
251
+ )
252
+ raise typer.Exit(code=1) from extra
253
+ _fail(extra)
254
+ if as_json:
255
+ _print_json(
256
+ _json_envelope(
257
+ "eval",
258
+ ok=report.ok,
259
+ passed=report.passed,
260
+ failed=report.failed,
261
+ results=[
262
+ {"name": row.name, "passed": row.passed, "reason": row.reason}
263
+ for row in report.results
264
+ ],
265
+ )
266
+ )
267
+ else:
268
+ for row in report.results:
269
+ if row.passed:
270
+ console.print(f"[green]PASS[/green] {escape(row.name)}")
271
+ else:
272
+ console.print(f"[red]FAIL[/red] {escape(row.name)}: {escape(row.reason)}")
273
+ console.print(f"passed={report.passed} failed={report.failed}")
274
+ if not report.ok:
275
+ raise typer.Exit(code=1)
276
+
277
+
278
+ @app.command()
279
+ def run(
280
+ path: Path = _WORKFLOW_ARG,
281
+ inputs: list[str] = typer.Option(
282
+ [],
283
+ "--input",
284
+ "-i",
285
+ help="Input as KEY=VALUE (repeatable).",
286
+ ),
287
+ dry_run: bool = typer.Option(
288
+ False,
289
+ "--dry-run",
290
+ help="Walk the graph without calling an LLM, http_get, or write_file.",
291
+ ),
292
+ no_persist: bool = typer.Option(False, "--no-persist", help="Do not write a run record."),
293
+ approve: list[str] = typer.Option(
294
+ [],
295
+ "--approve",
296
+ help="Approve an approval node by id (repeatable).",
297
+ ),
298
+ reject: list[str] = typer.Option(
299
+ [],
300
+ "--reject",
301
+ help="Reject an approval node by id (repeatable).",
302
+ ),
303
+ resume: str | None = typer.Option(
304
+ None,
305
+ "--resume",
306
+ help="Resume this run id instead of starting a new run.",
307
+ ),
308
+ as_json: bool = typer.Option(
309
+ False,
310
+ "--json",
311
+ help="Print the run record as JSON on stdout (no tables).",
312
+ ),
313
+ log_level: str | None = typer.Option(
314
+ None,
315
+ "--log-level",
316
+ help="DEBUG, INFO, WARNING, or ERROR (same as the root flag).",
317
+ ),
318
+ log_format: str | None = typer.Option(
319
+ None,
320
+ "--log-format",
321
+ help="text or json (same as the root flag).",
322
+ ),
323
+ decision_file: Path | None = typer.Option(
324
+ None,
325
+ "--decision-file",
326
+ help="JSON file injecting approval decisions (not only --approve flags).",
327
+ ),
328
+ actor: str | None = typer.Option(
329
+ None,
330
+ "--actor",
331
+ help="Actor id for RBAC hooks (env: READYAGENTS_ACTOR).",
332
+ envvar="READYAGENTS_ACTOR",
333
+ ),
334
+ no_cache: bool = typer.Option(
335
+ False,
336
+ "--no-cache",
337
+ help="Skip the local LLM response cache for this run.",
338
+ ),
339
+ pack: list[str] = typer.Option([], "--pack", help=_PACK_HELP),
340
+ ) -> None:
341
+ """Execute a workflow."""
342
+ if log_level or log_format:
343
+ configure_logging(log_level or "INFO", **({"fmt": log_format} if log_format else {}))
344
+ persist = not no_persist
345
+ try:
346
+ parsed = parse_input_pairs(inputs)
347
+ decisions = build_decisions(approve, reject)
348
+ extra_packs = _load_extra_packs(pack)
349
+ if resume:
350
+ state = resume_run(
351
+ resume,
352
+ path=path,
353
+ inputs=parsed or None,
354
+ dry_run=dry_run,
355
+ persist=persist,
356
+ extra_packs=extra_packs,
357
+ decisions=decisions,
358
+ decision_file=decision_file,
359
+ actor=actor,
360
+ no_cache=no_cache,
361
+ )
362
+ else:
363
+ state = run_workflow_file(
364
+ path,
365
+ inputs=parsed,
366
+ dry_run=dry_run,
367
+ persist=persist,
368
+ extra_packs=extra_packs,
369
+ decisions=decisions,
370
+ decision_file=decision_file,
371
+ actor=actor,
372
+ no_cache=no_cache,
373
+ )
374
+ except KeyboardInterrupt:
375
+ if as_json:
376
+ _print_json(_json_envelope("run", ok=False, error="cancelled", status="cancelled"))
377
+ else:
378
+ err_console.print("[yellow]cancelled[/yellow]")
379
+ raise typer.Exit(code=1) from None
380
+ except ReadyAgentsError as extra:
381
+ _emit_run_exception(extra, as_json=as_json, persist=persist, command="run")
382
+ _emit_run(state, as_json=as_json, command="run")
383
+
384
+
385
+ @app.command("resume")
386
+ def resume_cmd(
387
+ run_id: str = typer.Argument(..., help="Run id (or unique prefix)."),
388
+ workflow: Path | None = typer.Option(
389
+ None,
390
+ "--workflow",
391
+ help="Workflow file (defaults to the path stored on the run).",
392
+ ),
393
+ inputs: list[str] = typer.Option(
394
+ [],
395
+ "--input",
396
+ "-i",
397
+ help="Override stored inputs as KEY=VALUE (repeatable).",
398
+ ),
399
+ dry_run: bool = typer.Option(False, "--dry-run"),
400
+ no_persist: bool = typer.Option(False, "--no-persist"),
401
+ approve: list[str] = typer.Option([], "--approve"),
402
+ reject: list[str] = typer.Option([], "--reject"),
403
+ as_json: bool = typer.Option(
404
+ False,
405
+ "--json",
406
+ help="Print the run record as JSON on stdout (no tables).",
407
+ ),
408
+ decision_file: Path | None = typer.Option(
409
+ None,
410
+ "--decision-file",
411
+ help="JSON file injecting approval decisions.",
412
+ ),
413
+ actor: str | None = typer.Option(
414
+ None,
415
+ "--actor",
416
+ envvar="READYAGENTS_ACTOR",
417
+ ),
418
+ no_cache: bool = typer.Option(False, "--no-cache"),
419
+ pack: list[str] = typer.Option([], "--pack", help=_PACK_HELP),
420
+ ) -> None:
421
+ """Resume a paused or failed run from the last successful node."""
422
+ persist = not no_persist
423
+ try:
424
+ parsed = parse_input_pairs(inputs)
425
+ state = resume_run(
426
+ run_id,
427
+ path=workflow,
428
+ inputs=parsed or None,
429
+ dry_run=dry_run,
430
+ persist=persist,
431
+ extra_packs=_load_extra_packs(pack),
432
+ decisions=build_decisions(approve, reject),
433
+ decision_file=decision_file,
434
+ actor=actor,
435
+ no_cache=no_cache,
436
+ )
437
+ except KeyboardInterrupt:
438
+ if as_json:
439
+ _print_json(_json_envelope("resume", ok=False, error="cancelled", status="cancelled"))
440
+ else:
441
+ err_console.print("[yellow]cancelled[/yellow]")
442
+ raise typer.Exit(code=1) from None
443
+ except ReadyAgentsError as extra:
444
+ _emit_run_exception(extra, as_json=as_json, persist=persist, command="resume")
445
+ _emit_run(state, as_json=as_json, command="resume")
446
+
447
+
448
+ @app.command("decide")
449
+ def decide_cmd(
450
+ run_id: str = typer.Argument(..., help="Paused run id (or unique prefix)."),
451
+ decision_file: Path | None = typer.Option(
452
+ None,
453
+ "--file",
454
+ "--decision-file",
455
+ help='JSON payload: {"node": "approve"} or {"node_id", "decision"}.',
456
+ ),
457
+ node: str | None = typer.Option(None, "--node", help="Approval node id."),
458
+ decision: str | None = typer.Option(
459
+ None,
460
+ "--decision",
461
+ help="approve or reject (requires --node).",
462
+ ),
463
+ actor: str | None = typer.Option(None, "--actor", envvar="READYAGENTS_ACTOR"),
464
+ as_json: bool = typer.Option(False, "--json"),
465
+ no_persist: bool = typer.Option(False, "--no-persist"),
466
+ pack: list[str] = typer.Option([], "--pack", help=_PACK_HELP),
467
+ ) -> None:
468
+ """Inject an external approval decision into a paused run, then resume.
469
+
470
+ This is the core side of a webhook/pack: no always-on HTTP listener.
471
+ A pack can receive the webhook and call this (or write --file).
472
+ """
473
+ from readyagents.errors import ConfigError
474
+
475
+ persist = not no_persist
476
+ try:
477
+ decisions: dict[str, str] = {}
478
+ if decision_file is not None:
479
+ decisions.update(load_decision_file(decision_file))
480
+ if node:
481
+ if not decision:
482
+ raise ConfigError("--decision is required with --node")
483
+ decisions[node] = decision.strip().lower()
484
+ if not decisions:
485
+ raise ConfigError("Pass --file or --node plus --decision")
486
+ state = resume_run(
487
+ run_id,
488
+ persist=persist,
489
+ extra_packs=_load_extra_packs(pack),
490
+ decisions=decisions,
491
+ actor=actor,
492
+ )
493
+ except ReadyAgentsError as exc:
494
+ _emit_run_exception(exc, as_json=as_json, persist=persist, command="decide")
495
+ _emit_run(state, as_json=as_json, command="decide")
496
+
497
+
498
+ @app.command("packs")
499
+ def packs_cmd(
500
+ as_json: bool = typer.Option(False, "--json", help="Print JSON instead of a table."),
501
+ pack: list[str] = typer.Option([], "--pack", help=_PACK_HELP),
502
+ ) -> None:
503
+ """List installed ReadyAgents packs (entry point group readyagents.packs)."""
504
+ try:
505
+ found = list(discover_packs())
506
+ found.extend(_load_extra_packs(pack))
507
+ except ReadyAgentsError as exc:
508
+ if as_json:
509
+ _print_json(
510
+ _json_envelope(
511
+ "packs",
512
+ ok=False,
513
+ error=type(exc).__name__,
514
+ message=str(exc),
515
+ )
516
+ )
517
+ raise typer.Exit(code=1) from exc
518
+ _fail(exc)
519
+ return
520
+ if as_json:
521
+ _print_json(
522
+ _json_envelope(
523
+ "packs",
524
+ ok=True,
525
+ packs=[{"name": pack.name, "version": pack.version} for pack in found],
526
+ )
527
+ )
528
+ return
529
+ if not found:
530
+ console.print("No packs installed. Core runs without any packs.")
531
+ console.print("readyagents packs --pack examples/packs/connector_pack.py")
532
+ return
533
+ table = Table(title="Installed packs")
534
+ table.add_column("Name")
535
+ table.add_column("Version")
536
+ for pack in found:
537
+ table.add_row(pack.name, pack.version)
538
+ console.print(table)
539
+
540
+
541
+ @runs_app.command("list")
542
+ def runs_list(
543
+ as_json: bool = typer.Option(False, "--json", help="Print JSON instead of a table."),
544
+ status: str | None = typer.Option(
545
+ None, "--status", help="Filter: running, paused, failed, succeeded."
546
+ ),
547
+ workflow: str | None = typer.Option(None, "--workflow", help="Filter by workflow name."),
548
+ limit: int = typer.Option(0, "--limit", help="Max rows (0 = all)."),
549
+ ) -> None:
550
+ """List persisted runs (newest first)."""
551
+ from readyagents.config import get_settings
552
+
553
+ settings = get_settings()
554
+ found = list_runs(
555
+ settings.runs_dir(),
556
+ status=status,
557
+ workflow=workflow,
558
+ limit=limit,
559
+ )
560
+ if as_json:
561
+ payload = [
562
+ {
563
+ "run_id": s.run_id,
564
+ "workflow": s.workflow_name,
565
+ "status": s.status,
566
+ "started_at": s.started_at,
567
+ "pending_node": s.pending_node,
568
+ "nodes": [r.node_id for r in s.results],
569
+ }
570
+ for s in found
571
+ ]
572
+ _print_json(payload)
573
+ return
574
+ if not found:
575
+ console.print(f"No runs in {settings.runs_dir()}")
576
+ return
577
+ console.print(f"Runs in {settings.runs_dir()}")
578
+ for state in found:
579
+ nodes = ",".join(r.node_id for r in state.results) or "-"
580
+ console.print(
581
+ f"run_id: {state.run_id} workflow: {state.workflow_name} "
582
+ f"status: {state.status} started: {state.started_at} nodes: {nodes}"
583
+ )
584
+
585
+
586
+ @runs_app.command("show")
587
+ def runs_show(
588
+ run_id: str = typer.Argument(..., help="Run id (or unique prefix)."),
589
+ as_json: bool = typer.Option(False, "--json", help="Print the stored run record as JSON."),
590
+ ) -> None:
591
+ """Show a run record and its node timeline."""
592
+ _show_run(run_id, as_json=as_json)
593
+
594
+
595
+ @runs_app.command("inspect")
596
+ def runs_inspect(
597
+ run_id: str = typer.Argument(..., help="Run id (or unique prefix)."),
598
+ as_json: bool = typer.Option(False, "--json", help="Print the stored run record as JSON."),
599
+ ) -> None:
600
+ """Alias for `runs show` — inspect stored state and the node timeline."""
601
+ _show_run(run_id, as_json=as_json)
602
+
603
+
604
+ @runs_app.command("report")
605
+ def runs_report(
606
+ run_id: str = typer.Argument(..., help="Run id (or unique prefix)."),
607
+ dest: Path | None = typer.Option(
608
+ None,
609
+ "--out",
610
+ "-o",
611
+ help="HTML file to write (default: <run_id>.html in cwd).",
612
+ ),
613
+ ) -> None:
614
+ """Write a local HTML summary of a persisted run."""
615
+ from readyagents.config import get_settings
616
+ from readyagents.report import write_html_report
617
+
618
+ try:
619
+ state = load_run(get_settings().runs_dir(), run_id)
620
+ path = dest or Path(f"{state.run_id}.html")
621
+ written = write_html_report(state, path)
622
+ except ReadyAgentsError as exc:
623
+ _fail(exc)
624
+ return
625
+ console.print(f"[green]Wrote {written}[/green] open it in a browser.")
626
+
627
+
628
+ @runs_app.command("replay")
629
+ def runs_replay(
630
+ run_id: str = typer.Argument(..., help="Run id (or unique prefix)."),
631
+ dry_run: bool = typer.Option(False, "--dry-run"),
632
+ no_persist: bool = typer.Option(False, "--no-persist"),
633
+ approve: list[str] = typer.Option([], "--approve"),
634
+ reject: list[str] = typer.Option([], "--reject"),
635
+ as_json: bool = typer.Option(
636
+ False,
637
+ "--json",
638
+ help="Print the run record as JSON on stdout (no tables).",
639
+ ),
640
+ decision_file: Path | None = typer.Option(None, "--decision-file"),
641
+ actor: str | None = typer.Option(None, "--actor", envvar="READYAGENTS_ACTOR"),
642
+ no_cache: bool = typer.Option(False, "--no-cache"),
643
+ pack: list[str] = typer.Option([], "--pack", help=_PACK_HELP),
644
+ ) -> None:
645
+ """Start a new run using the stored workflow path and inputs."""
646
+ persist = not no_persist
647
+ try:
648
+ state = replay_run(
649
+ run_id,
650
+ dry_run=dry_run,
651
+ persist=persist,
652
+ extra_packs=_load_extra_packs(pack),
653
+ decisions=build_decisions(approve, reject),
654
+ decision_file=decision_file,
655
+ actor=actor,
656
+ no_cache=no_cache,
657
+ )
658
+ except ReadyAgentsError as exc:
659
+ _emit_run_exception(exc, as_json=as_json, persist=persist, command="replay")
660
+ _emit_run(state, as_json=as_json, command="replay")
661
+
662
+
663
+ @runs_app.command("delete")
664
+ def runs_delete(
665
+ run_id: str = typer.Argument(..., help="Run id (or unique prefix)."),
666
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not prompt."),
667
+ ) -> None:
668
+ """Delete one persisted run JSON file."""
669
+ from readyagents.config import get_settings
670
+
671
+ settings = get_settings()
672
+ try:
673
+ state = load_run(settings.runs_dir(), run_id)
674
+ if not yes:
675
+ console.print(f"Delete run {state.run_id} ({state.status})? Pass --yes to confirm.")
676
+ raise typer.Exit(code=1)
677
+ path = delete_run(settings.runs_dir(), run_id)
678
+ except ReadyAgentsError as extra:
679
+ _fail(extra)
680
+ return
681
+ console.print(f"[green]Deleted[/green] {path}")
682
+
683
+
684
+ @runs_app.command("gc")
685
+ def runs_gc_cmd(
686
+ status: list[str] = typer.Option(
687
+ ["succeeded", "failed", "cancelled"],
688
+ "--status",
689
+ help="Statuses to delete (repeatable).",
690
+ ),
691
+ keep: int = typer.Option(0, "--keep", help="Keep this many newest matching runs."),
692
+ include_paused: bool = typer.Option(
693
+ False,
694
+ "--include-paused",
695
+ help="Also delete paused runs (off by default).",
696
+ ),
697
+ yes: bool = typer.Option(False, "--yes", "-y", help="Do not prompt."),
698
+ ) -> None:
699
+ """Delete old succeeded/failed/cancelled runs. Paused runs are kept unless forced."""
700
+ from readyagents.config import get_settings
701
+
702
+ settings = get_settings()
703
+ if not yes:
704
+ console.print("Pass --yes to garbage-collect matching run files.")
705
+ raise typer.Exit(code=1)
706
+ try:
707
+ deleted = gc_runs(
708
+ settings.runs_dir(),
709
+ statuses=status,
710
+ include_paused=include_paused,
711
+ keep=keep,
712
+ )
713
+ except ReadyAgentsError as extra:
714
+ _fail(extra)
715
+ return
716
+ console.print(f"[green]Deleted {len(deleted)} run(s)[/green]")
717
+ for rid in deleted:
718
+ console.print(f" {rid}")
719
+
720
+
721
+ @mcp_app.command("serve")
722
+ def mcp_serve(
723
+ ctx: typer.Context,
724
+ transport: str = typer.Option(
725
+ "stdio",
726
+ "--transport",
727
+ help="stdio (default) or streamable-http.",
728
+ ),
729
+ host: str = typer.Option(
730
+ "127.0.0.1",
731
+ "--host",
732
+ help="Bind host. HTTP only. v0.9 rejects non-loopback binds.",
733
+ envvar="READYAGENTS_MCP_HTTP_HOST",
734
+ ),
735
+ port: int = typer.Option(
736
+ 8765,
737
+ "--port",
738
+ min=1,
739
+ max=65535,
740
+ help="Bind port. HTTP only.",
741
+ envvar="READYAGENTS_MCP_HTTP_PORT",
742
+ ),
743
+ auth: str = typer.Option(
744
+ "token",
745
+ "--auth",
746
+ help="token (default) or none. none is loopback-only and warns.",
747
+ ),
748
+ token_env: str = typer.Option(
749
+ DEFAULT_MCP_TOKEN_ENV,
750
+ "--token-env",
751
+ help="Env var holding the bearer token. HTTP only.",
752
+ ),
753
+ max_concurrent_runs: int = typer.Option(
754
+ 4,
755
+ "--max-concurrent-runs",
756
+ help="In-process executor cap. HTTP only.",
757
+ envvar="READYAGENTS_MCP_MAX_CONCURRENT_RUNS",
758
+ min=1,
759
+ ),
760
+ max_pending_runs: int = typer.Option(
761
+ 32,
762
+ "--max-pending-runs",
763
+ help="Pending-run queue cap. HTTP only.",
764
+ envvar="READYAGENTS_MCP_MAX_PENDING_RUNS",
765
+ min=1,
766
+ ),
767
+ ) -> None:
768
+ """Expose builtin tools (and run_workflow) over MCP stdio or Streamable HTTP."""
769
+ mode = (transport or "stdio").strip().lower()
770
+ http_flags = (
771
+ "--host",
772
+ "--port",
773
+ "--auth",
774
+ "--token-env",
775
+ "--max-concurrent-runs",
776
+ "--max-pending-runs",
777
+ )
778
+ http_params = (
779
+ "host",
780
+ "port",
781
+ "auth",
782
+ "token_env",
783
+ "max_concurrent_runs",
784
+ "max_pending_runs",
785
+ )
786
+ if mode == "stdio":
787
+ from_argv = any(
788
+ arg == flag or arg.startswith(f"{flag}=") for arg in sys.argv for flag in http_flags
789
+ )
790
+ from_cli = any(
791
+ getattr(ctx.get_parameter_source(name), "name", None) == "COMMANDLINE"
792
+ for name in http_params
793
+ )
794
+ passed = from_argv or from_cli
795
+ if passed:
796
+ _fail(
797
+ MCPError(
798
+ "HTTP flags (--host, --port, --auth, --token-env, "
799
+ "--max-concurrent-runs, --max-pending-runs) are only valid "
800
+ "with --transport streamable-http"
801
+ )
802
+ )
803
+ try:
804
+ from readyagents.mcp.server import serve_stdio
805
+
806
+ serve_stdio()
807
+ except ReadyAgentsError as exc:
808
+ _fail(exc)
809
+ return
810
+ if mode != "streamable-http":
811
+ _fail(MCPError(f"Unknown --transport '{transport}'. Use stdio or streamable-http."))
812
+ try:
813
+ from readyagents.mcp.http import serve_streamable_http
814
+
815
+ serve_streamable_http(
816
+ host=host,
817
+ port=port,
818
+ auth_mode=auth,
819
+ token_env=token_env,
820
+ max_concurrent_runs=max_concurrent_runs,
821
+ max_pending_runs=max_pending_runs,
822
+ )
823
+ except ReadyAgentsError as exc:
824
+ _fail(exc)
825
+
826
+
827
+ def _show_run(run_id: str, *, as_json: bool = False) -> None:
828
+ from readyagents.config import get_settings
829
+
830
+ try:
831
+ state = load_run(get_settings().runs_dir(), run_id)
832
+ except ReadyAgentsError as exc:
833
+ if as_json:
834
+ _print_json(
835
+ _json_envelope(
836
+ "runs show",
837
+ ok=False,
838
+ error=type(exc).__name__,
839
+ message=str(exc),
840
+ run_id=run_id,
841
+ )
842
+ )
843
+ raise typer.Exit(code=1) from exc
844
+ _fail(exc)
845
+ return
846
+ if as_json:
847
+ _print_json(_json_envelope("runs show", ok=True, **state.to_record()))
848
+ return
849
+ console.print(f"run_id: {state.run_id}")
850
+ console.print(f"workflow: {state.workflow_name}")
851
+ console.print(f"status: {state.status}")
852
+ if state.pending_node:
853
+ console.print(f"pending_node: {state.pending_node}")
854
+ if state.pending:
855
+ prompt = state.pending.get("prompt")
856
+ if prompt:
857
+ console.print(Panel(escape(str(prompt)), title="Pending prompt"))
858
+ resume_hint = state.pending.get("resume")
859
+ if resume_hint:
860
+ console.print(f"Resume: [cyan]{escape(str(resume_hint))}[/cyan]")
861
+ _print_usage(state)
862
+ _print_run(state)
863
+ if state.output_keys:
864
+ console.print(Panel(escape(_preview(state.output_keys, limit=2000)), title="Outputs"))
865
+ if state.inputs:
866
+ console.print(Panel(escape(_preview(state.inputs, limit=2000)), title="Inputs"))
867
+
868
+
869
+ def _load_extra_packs(pack_flags: list[str]) -> list[Any]:
870
+ """Load --pack / READYAGENTS_PACK modules confined to the workspace."""
871
+ from readyagents.config import get_settings
872
+
873
+ specs = collect_pack_specs(pack_flags)
874
+ if not specs:
875
+ return []
876
+ return load_local_packs(specs, root=get_settings().workspace_path())
877
+
878
+
879
+ def _print_json(payload: object) -> None:
880
+ """Write JSON to stdout without Rich markup (values may contain `[...]`)."""
881
+ typer.echo(json.dumps(payload, indent=2, ensure_ascii=False))
882
+
883
+
884
+ def _json_envelope(command: str, *, ok: bool, **fields: Any) -> dict[str, Any]:
885
+ """Additive JSON envelope: existing keys stay, ok/command always win."""
886
+ payload = dict(fields)
887
+ payload["ok"] = ok
888
+ payload["command"] = command
889
+ return payload
890
+
891
+
892
+ def _node_routing(node: Any) -> str:
893
+ """Compact then/else/next for the validate table (else was previously dropped)."""
894
+ bits: list[str] = []
895
+ if node.then:
896
+ bits.append(f"then:{node.then}")
897
+ if node.else_:
898
+ bits.append(f"else:{node.else_}")
899
+ if node.next:
900
+ bits.append(node.next if not bits else f"next:{node.next}")
901
+ return " ".join(bits)
902
+
903
+
904
+ def _state_from_exc(exc: BaseException) -> RunState | None:
905
+ state = getattr(exc, "state", None)
906
+ return state if isinstance(state, RunState) else None
907
+
908
+
909
+ def _emit_run(state: RunState, *, as_json: bool, command: str = "run") -> None:
910
+ if as_json:
911
+ _print_json(
912
+ _json_envelope(
913
+ command,
914
+ ok=state.status == "succeeded",
915
+ **state.to_record(),
916
+ )
917
+ )
918
+ else:
919
+ _print_run(state)
920
+ if state.status == "succeeded":
921
+ console.print("[green]succeeded[/green]")
922
+ console.print(f"run_id: {state.run_id}")
923
+ _print_usage(state)
924
+ if state.output_keys:
925
+ console.print(
926
+ Panel(escape(_preview(state.output_keys, limit=2000)), title="Outputs")
927
+ )
928
+ else:
929
+ console.print(f"[red]{state.status}[/red]")
930
+ console.print(f"run_id: {state.run_id}")
931
+ if state.status != "succeeded":
932
+ raise typer.Exit(code=1)
933
+
934
+
935
+ def _emit_run_exception(
936
+ exc: ReadyAgentsError, *, as_json: bool, persist: bool, command: str = "run"
937
+ ) -> NoReturn:
938
+ """Print a paused or failed run (JSON or tables) and exit. Never returns."""
939
+ if isinstance(exc, ApprovalRequired):
940
+ state = _state_from_exc(exc)
941
+ if as_json:
942
+ payload: dict[str, Any] = {
943
+ "error": type(exc).__name__,
944
+ "message": str(exc),
945
+ "run_id": exc.run_id,
946
+ "node_id": exc.node_id,
947
+ "prompt": exc.prompt,
948
+ "status": "paused",
949
+ }
950
+ if state is not None:
951
+ payload["run"] = state.to_record()
952
+ _print_json(_json_envelope(command, ok=False, **payload))
953
+ else:
954
+ _print_paused(exc)
955
+ raise typer.Exit(code=2) from exc
956
+
957
+ state = _state_from_exc(exc)
958
+ run_id = getattr(exc, "run_id", None) or (state.run_id if state is not None else None)
959
+ if as_json:
960
+ payload = {
961
+ "error": type(exc).__name__,
962
+ "message": str(exc),
963
+ "run_id": run_id,
964
+ "status": state.status if state is not None else "failed",
965
+ }
966
+ if state is not None:
967
+ payload["run"] = state.to_record()
968
+ _print_json(_json_envelope(command, ok=False, **payload))
969
+ raise typer.Exit(code=1) from exc
970
+
971
+ if state is not None:
972
+ _print_run(state)
973
+ err_console.print(f"[red]{type(exc).__name__}:[/red] {exc}")
974
+ console.print(f"run_id: {state.run_id} status: {state.status}")
975
+ if persist:
976
+ cmd = f"readyagents resume {state.run_id}"
977
+ pending = state.pending_node
978
+ if pending:
979
+ console.print(
980
+ f"Resume: [cyan]{cmd}[/cyan] (retry node [bold]{escape(pending)}[/bold])"
981
+ )
982
+ else:
983
+ console.print(f"Resume: [cyan]{cmd}[/cyan]")
984
+ raise typer.Exit(code=1) from exc
985
+
986
+ _fail(exc)
987
+
988
+
989
+ def _print_usage(state: RunState) -> None:
990
+ if not state.usage:
991
+ return
992
+ parts = [f"{k}={v}" for k, v in state.usage.items()]
993
+ micros = state.usage.get("cost_micros")
994
+ if micros:
995
+ parts.append(f"cost_usd={micros / 1_000_000:.6f}")
996
+ console.print("usage: " + " ".join(parts))
997
+
998
+
999
+ def _print_run(state: RunState) -> None:
1000
+ table = Table(title=f"Run {state.run_id} — {state.status}")
1001
+ table.add_column("Node")
1002
+ table.add_column("Type")
1003
+ table.add_column("Status")
1004
+ table.add_column("Output", overflow="fold")
1005
+ for result in state.results:
1006
+ preview = result.error or _preview(result.output)
1007
+ if result.tool_rounds:
1008
+ names = ",".join(str(row.get("name") or "?") for row in result.tool_rounds)
1009
+ preview = f"{preview} [tools:{names}]"
1010
+ table.add_row(result.node_id, result.type, result.status, escape(preview))
1011
+ console.print(table)
1012
+
1013
+
1014
+ def _print_paused(exc: ApprovalRequired) -> None:
1015
+ if exc.state is not None and isinstance(exc.state, RunState):
1016
+ _print_run(exc.state)
1017
+ err_console.print(f"[yellow]{type(exc).__name__}:[/yellow] {exc}")
1018
+ if exc.prompt:
1019
+ console.print(Panel(escape(exc.prompt), title=f"Approval: {exc.node_id}"))
1020
+ console.print(f"Resume: [cyan]readyagents resume {exc.run_id} --approve {exc.node_id}[/cyan]")
1021
+
1022
+
1023
+ def _preview(value: object, limit: int = 160) -> str:
1024
+ text = value if isinstance(value, str) else repr(value)
1025
+ text = text.replace("\n", " ")
1026
+ if len(text) > limit:
1027
+ return text[: limit - 1] + "…"
1028
+ return text
1029
+
1030
+
1031
+ def _fail(exc: BaseException) -> NoReturn:
1032
+ err_console.print(f"[red]{type(exc).__name__}:[/red] {exc}")
1033
+ raise typer.Exit(code=1) from exc
1034
+
1035
+
1036
+ _ENV_TEMPLATE = """# ReadyAgents BYOK — fill in your keys. Never commit real keys.
1037
+
1038
+ READYAGENTS_DEFAULT_MODEL=openai:gpt-4o-mini
1039
+ OPENAI_API_KEY=
1040
+ ANTHROPIC_API_KEY=
1041
+ # READYAGENTS_ALLOW_HTTP=0
1042
+ """
1043
+
1044
+
1045
+ def main() -> None:
1046
+ app()
1047
+
1048
+
1049
+ if __name__ == "__main__":
1050
+ main()