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.
- readyagents/__init__.py +38 -0
- readyagents/__main__.py +4 -0
- readyagents/audit.py +67 -0
- readyagents/cli.py +1050 -0
- readyagents/config.py +264 -0
- readyagents/errors.py +129 -0
- readyagents/llm/__init__.py +11 -0
- readyagents/llm/anthropic_provider.py +72 -0
- readyagents/llm/base.py +57 -0
- readyagents/llm/cache.py +86 -0
- readyagents/llm/openai_compat.py +12 -0
- readyagents/llm/openai_provider.py +70 -0
- readyagents/llm/registry.py +112 -0
- readyagents/llm/resilience.py +179 -0
- readyagents/llm/tool_calls.py +286 -0
- readyagents/logging.py +162 -0
- readyagents/mcp/__init__.py +43 -0
- readyagents/mcp/builtin.py +674 -0
- readyagents/mcp/client.py +253 -0
- readyagents/mcp/http.py +585 -0
- readyagents/mcp/run_api.py +1077 -0
- readyagents/mcp/server.py +246 -0
- readyagents/notify.py +63 -0
- readyagents/packs/__init__.py +26 -0
- readyagents/packs/loader.py +157 -0
- readyagents/packs/protocol.py +55 -0
- readyagents/policy.py +127 -0
- readyagents/py.typed +1 -0
- readyagents/report.py +88 -0
- readyagents/scaffold.py +410 -0
- readyagents/secrets.py +120 -0
- readyagents/testing/__init__.py +17 -0
- readyagents/testing/eval.py +219 -0
- readyagents/testing/helpers.py +128 -0
- readyagents/testing/recorded.py +68 -0
- readyagents/tools/__init__.py +67 -0
- readyagents/workflow/__init__.py +3 -0
- readyagents/workflow/cancellation.py +88 -0
- readyagents/workflow/conditions.py +279 -0
- readyagents/workflow/engine.py +354 -0
- readyagents/workflow/nodes.py +944 -0
- readyagents/workflow/runner.py +375 -0
- readyagents/workflow/schema.py +287 -0
- readyagents/workflow/state.py +472 -0
- readyagents/workflow/structured.py +103 -0
- readyagents/workflow/templates.py +125 -0
- readyagentsdev-0.8.2.dist-info/METADATA +215 -0
- readyagentsdev-0.8.2.dist-info/RECORD +51 -0
- readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
- readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
- 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()
|