runledger 0.2.1__tar.gz → 0.2.2__tar.gz

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 (55) hide show
  1. {runledger-0.2.1 → runledger-0.2.2}/PKG-INFO +53 -17
  2. runledger-0.2.1/src/runledger.egg-info/PKG-INFO → runledger-0.2.2/README.md +48 -40
  3. {runledger-0.2.1 → runledger-0.2.2}/pyproject.toml +7 -1
  4. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/__init__.py +1 -1
  5. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cli.py +207 -118
  6. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/subprocess.py +64 -8
  7. runledger-0.2.1/README.md → runledger-0.2.2/src/runledger.egg-info/PKG-INFO +76 -16
  8. {runledger-0.2.1 → runledger-0.2.2}/LICENSE +0 -0
  9. {runledger-0.2.1 → runledger-0.2.2}/setup.cfg +0 -0
  10. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/__main__.py +0 -0
  11. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/__init__.py +0 -0
  12. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/junit.py +0 -0
  13. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/report.py +0 -0
  14. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/run_log.py +0 -0
  15. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/summary.py +0 -0
  16. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/__init__.py +0 -0
  17. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/base.py +0 -0
  18. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/engine.py +0 -0
  19. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/json_schema.py +0 -0
  20. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/required_fields.py +0 -0
  21. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/tool_contract.py +0 -0
  22. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/baseline/__init__.py +0 -0
  23. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/baseline/io.py +0 -0
  24. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/baseline/models.py +0 -0
  25. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/__init__.py +0 -0
  26. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/loader.py +0 -0
  27. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/match.py +0 -0
  28. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/models.py +0 -0
  29. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/writer.py +0 -0
  30. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/config/__init__.py +0 -0
  31. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/config/loader.py +0 -0
  32. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/config/models.py +0 -0
  33. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/protocol/__init__.py +0 -0
  34. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/protocol/jsonl.py +0 -0
  35. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/protocol/messages.py +0 -0
  36. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/regression/__init__.py +0 -0
  37. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/regression/engine.py +0 -0
  38. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/reporting/__init__.py +0 -0
  39. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/reporting/failures.py +0 -0
  40. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/__init__.py +0 -0
  41. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/budgets.py +0 -0
  42. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/engine.py +0 -0
  43. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/models.py +0 -0
  44. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/tools/__init__.py +0 -0
  45. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/tools/builtin.py +0 -0
  46. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/tools/registry.py +0 -0
  47. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/util/__init__.py +0 -0
  48. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/util/canonical_json.py +0 -0
  49. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/util/normalize.py +0 -0
  50. {runledger-0.2.1 → runledger-0.2.2}/src/runledger/util/redaction.py +0 -0
  51. {runledger-0.2.1 → runledger-0.2.2}/src/runledger.egg-info/SOURCES.txt +0 -0
  52. {runledger-0.2.1 → runledger-0.2.2}/src/runledger.egg-info/dependency_links.txt +0 -0
  53. {runledger-0.2.1 → runledger-0.2.2}/src/runledger.egg-info/entry_points.txt +0 -0
  54. {runledger-0.2.1 → runledger-0.2.2}/src/runledger.egg-info/requires.txt +0 -0
  55. {runledger-0.2.1 → runledger-0.2.2}/src/runledger.egg-info/top_level.txt +0 -0
@@ -1,9 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runledger
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: CI for tool-using agents: deterministic eval suites with record/replay, hard assertions, budgets, and PR regression gates.
5
5
  Author: RunLedger
6
6
  License-Expression: MIT
7
+ Project-URL: Homepage, https://runledger.io
8
+ Project-URL: Repository, https://github.com/runledger/Runledger
9
+ Project-URL: Documentation, https://github.com/runledger/Runledger/tree/main/docs
10
+ Project-URL: Changelog, https://github.com/runledger/Runledger/blob/main/CHANGELOG.md
7
11
  Keywords: llm,agents,evaluation,ci,github-actions,replay,testing,junit,jsonschema
8
12
  Requires-Python: >=3.9
9
13
  Description-Content-Type: text/markdown
@@ -30,25 +34,57 @@ Dynamic: license-file
30
34
 
31
35
  Website: https://runledger.io
32
36
 
33
- **Deterministic CI regression checks for tool-using agents.** Record tool calls once, replay in CI, and fail PRs on mismatches, contract violations, or budget regressions.
37
+ **Deterministic CI regression checks for tool-using agents.** Record tool calls once, replay them in CI, and fail the run when a schema, tool-order, or tool-call budget gate fails.
34
38
 
35
- - Demo repo: https://github.com/runledger/runledger-demo (main is green; regression branches fail for real reasons)
39
+ - Demo: https://github.com/ZackMitchell910/runledger-demo/pull/1 (intentionally left open to show RunLedger failing a bad agent change)
36
40
  - If this is useful, please star the repo (it helps a lot).
37
41
 
38
- RunLedger is a **CI harness**, not an "eval metrics framework":
39
- - **DeepEval-style tools** help you *score* behavior.
40
- - **RunLedger** helps you *ship safely* by making agent tests deterministic and merge-gated.
42
+ ## Five-minute quickstart
41
43
 
42
- ---
43
-
44
- ## Try it now (60 seconds)
44
+ From an empty directory, with Python 3.9+:
45
45
 
46
46
  ```bash
47
- pipx install runledger
47
+ python3 -m pip install runledger
48
48
  runledger init
49
49
  runledger run ./evals/demo --mode replay --baseline baselines/demo.json
50
50
  ```
51
51
 
52
+ `runledger init` writes:
53
+
54
+ - `evals/demo/` — replay suite, cassette, JSON Schema, and a small agent that calls `search_docs`
55
+ - `baselines/demo.json` — baseline from that passing run
56
+ - `.github/workflows/runledger.yml` — pull-request workflow that runs the same command
57
+
58
+ A second `runledger init` leaves those files unchanged. `--force` regenerates them.
59
+
60
+ The demo exits non-zero unless all three of these hold:
61
+
62
+ - **Schema.** The final output matches `evals/demo/schema.json` (`category` and `reply`).
63
+ - **Tool order.** The agent calls `search_docs` (`call_order` in `evals/demo/cases/t1.yaml`).
64
+ - **Tool-call budget.** The run stays within `max_tool_calls: 1`.
65
+
66
+ A passing run prints `PASS` and writes `runledger_out/demo/<run_id>/` (`summary.json`, `junit.xml`, `report.html`, `run.jsonl`). Commit the init files to run that gate on pull requests.
67
+
68
+ The same check as a GitHub Action. `suite.yaml` already points at `baselines/demo.json`, so the action compares against that baseline:
69
+
70
+ ```yaml
71
+ - id: evals
72
+ uses: runledger/Runledger@v0.2
73
+ with:
74
+ path: ./evals/demo
75
+ mode: replay
76
+ ```
77
+
78
+ This repo's `action.yml` also accepts a `baseline` input and sets `result` (`pass` or `fail`), `exit-code`, and `artifacts-path`. Those fields ship when a release containing this file is tagged. The in-repo self-test calls the action with `uses: ./`.
79
+
80
+ Page copy for runledger.io lives in `docs/site-quickstart.md`.
81
+
82
+ ---
83
+
84
+ RunLedger is a **CI harness**, not an "eval metrics framework":
85
+ - **DeepEval-style tools** help you *score* behavior.
86
+ - **RunLedger** helps you *ship safely* by making agent tests deterministic and merge-gated.
87
+
52
88
  ## Integrations (merged)
53
89
 
54
90
  - hoangsonww/Agentic-AI-Pipeline - https://github.com/hoangsonww/Agentic-AI-Pipeline/pull/24
@@ -73,7 +109,7 @@ RunLedger stops regressions by shifting from "vibes-based" evaluation to determi
73
109
 
74
110
  - **Record & replay:** record tool outputs once; replay them in CI for instant, stable tests.
75
111
  - **Strict contracts:** enforce tool schemas, calling order, and allowlists.
76
- - **Budget gating:** fail PRs automatically if wall time, tool-call count, or tool errors exceed defined limits.
112
+ - **Budget gating:** fail the run when tool-call count exceeds `max_tool_calls`. Wall time and tool-error caps are separate gates, configured with `max_wall_ms` and `max_tool_errors`.
77
113
 
78
114
  ### RunLedger vs evaluation frameworks
79
115
 
@@ -352,18 +388,18 @@ jobs:
352
388
  - uses: actions/checkout@v4
353
389
 
354
390
  - name: Run deterministic evals (replay)
391
+ id: evals
355
392
  uses: runledger/Runledger@v0.2
356
393
  with:
357
394
  path: ./evals/demo
358
395
  mode: replay
359
396
 
360
- - name: Upload eval artifacts
361
- uses: actions/upload-artifact@v4
362
- with:
363
- name: agent-eval-artifacts
364
- path: runledger_out/**
397
+ - name: Show the action result
398
+ run: echo "result=${{ steps.evals.outputs.result }} exit=${{ steps.evals.outputs.exit-code }}"
365
399
  ```
366
400
 
401
+ `runledger init` writes `.github/workflows/runledger.yml`, which installs the CLI and runs the suite with `--baseline`. The action above is the Marketplace entry in `action.yml`. It uploads `runledger_out/**` itself. On a release that contains this `action.yml`, the step also sets `result`, `exit-code`, and `artifacts-path`, and you can pass `baseline:` to override `suite.yaml`.
402
+
367
403
  ---
368
404
 
369
405
  ## Determinism guide (rules of the road)
@@ -386,6 +422,7 @@ jobs:
386
422
 
387
423
  ## Docs
388
424
 
425
+ - `docs/site-quickstart.md` -- five-minute copy for runledger.io
389
426
  - `docs/quickstart.md` -- install + first run
390
427
  - `docs/assertions.md` -- assertions, tool contracts, budgets
391
428
  - `docs/baselines.md` -- baselines, diffing, promotion
@@ -398,7 +435,6 @@ jobs:
398
435
  ## Roadmap
399
436
 
400
437
  * `init` templates for Python (OpenAI SDK), LangGraph/LangChain, and Node/TS
401
- * richer budgets (tokens/cost) via optional `task_metrics`
402
438
  * PR comments bot for regression summaries
403
439
  * plugin system for custom assertions
404
440
  * HTML report trace viewer improvements
@@ -1,27 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: runledger
3
- Version: 0.2.1
4
- Summary: CI for tool-using agents: deterministic eval suites with record/replay, hard assertions, budgets, and PR regression gates.
5
- Author: RunLedger
6
- License-Expression: MIT
7
- Keywords: llm,agents,evaluation,ci,github-actions,replay,testing,junit,jsonschema
8
- Requires-Python: >=3.9
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- Requires-Dist: typer>=0.9.0
12
- Requires-Dist: rich>=13.0.0
13
- Requires-Dist: pydantic>=2.0.0
14
- Requires-Dist: pyyaml>=6.0.0
15
- Requires-Dist: jsonschema>=4.0.0
16
- Requires-Dist: jinja2>=3.1.0
17
- Requires-Dist: eval_type_backport>=0.2.0; python_version < "3.10"
18
- Provides-Extra: dev
19
- Requires-Dist: pytest>=7.0.0; extra == "dev"
20
- Requires-Dist: pytest-timeout>=2.1.0; extra == "dev"
21
- Requires-Dist: ruff>=0.1.0; extra == "dev"
22
- Requires-Dist: mypy>=1.0.0; extra == "dev"
23
- Dynamic: license-file
24
-
25
1
  # RunLedger
26
2
 
27
3
  [![CI](https://github.com/runledger/Runledger/actions/workflows/ci.yml/badge.svg)](https://github.com/runledger/Runledger/actions/workflows/ci.yml)
@@ -30,25 +6,57 @@ Dynamic: license-file
30
6
 
31
7
  Website: https://runledger.io
32
8
 
33
- **Deterministic CI regression checks for tool-using agents.** Record tool calls once, replay in CI, and fail PRs on mismatches, contract violations, or budget regressions.
9
+ **Deterministic CI regression checks for tool-using agents.** Record tool calls once, replay them in CI, and fail the run when a schema, tool-order, or tool-call budget gate fails.
34
10
 
35
- - Demo repo: https://github.com/runledger/runledger-demo (main is green; regression branches fail for real reasons)
11
+ - Demo: https://github.com/ZackMitchell910/runledger-demo/pull/1 (intentionally left open to show RunLedger failing a bad agent change)
36
12
  - If this is useful, please star the repo (it helps a lot).
37
13
 
38
- RunLedger is a **CI harness**, not an "eval metrics framework":
39
- - **DeepEval-style tools** help you *score* behavior.
40
- - **RunLedger** helps you *ship safely* by making agent tests deterministic and merge-gated.
14
+ ## Five-minute quickstart
41
15
 
42
- ---
43
-
44
- ## Try it now (60 seconds)
16
+ From an empty directory, with Python 3.9+:
45
17
 
46
18
  ```bash
47
- pipx install runledger
19
+ python3 -m pip install runledger
48
20
  runledger init
49
21
  runledger run ./evals/demo --mode replay --baseline baselines/demo.json
50
22
  ```
51
23
 
24
+ `runledger init` writes:
25
+
26
+ - `evals/demo/` — replay suite, cassette, JSON Schema, and a small agent that calls `search_docs`
27
+ - `baselines/demo.json` — baseline from that passing run
28
+ - `.github/workflows/runledger.yml` — pull-request workflow that runs the same command
29
+
30
+ A second `runledger init` leaves those files unchanged. `--force` regenerates them.
31
+
32
+ The demo exits non-zero unless all three of these hold:
33
+
34
+ - **Schema.** The final output matches `evals/demo/schema.json` (`category` and `reply`).
35
+ - **Tool order.** The agent calls `search_docs` (`call_order` in `evals/demo/cases/t1.yaml`).
36
+ - **Tool-call budget.** The run stays within `max_tool_calls: 1`.
37
+
38
+ A passing run prints `PASS` and writes `runledger_out/demo/<run_id>/` (`summary.json`, `junit.xml`, `report.html`, `run.jsonl`). Commit the init files to run that gate on pull requests.
39
+
40
+ The same check as a GitHub Action. `suite.yaml` already points at `baselines/demo.json`, so the action compares against that baseline:
41
+
42
+ ```yaml
43
+ - id: evals
44
+ uses: runledger/Runledger@v0.2
45
+ with:
46
+ path: ./evals/demo
47
+ mode: replay
48
+ ```
49
+
50
+ This repo's `action.yml` also accepts a `baseline` input and sets `result` (`pass` or `fail`), `exit-code`, and `artifacts-path`. Those fields ship when a release containing this file is tagged. The in-repo self-test calls the action with `uses: ./`.
51
+
52
+ Page copy for runledger.io lives in `docs/site-quickstart.md`.
53
+
54
+ ---
55
+
56
+ RunLedger is a **CI harness**, not an "eval metrics framework":
57
+ - **DeepEval-style tools** help you *score* behavior.
58
+ - **RunLedger** helps you *ship safely* by making agent tests deterministic and merge-gated.
59
+
52
60
  ## Integrations (merged)
53
61
 
54
62
  - hoangsonww/Agentic-AI-Pipeline - https://github.com/hoangsonww/Agentic-AI-Pipeline/pull/24
@@ -73,7 +81,7 @@ RunLedger stops regressions by shifting from "vibes-based" evaluation to determi
73
81
 
74
82
  - **Record & replay:** record tool outputs once; replay them in CI for instant, stable tests.
75
83
  - **Strict contracts:** enforce tool schemas, calling order, and allowlists.
76
- - **Budget gating:** fail PRs automatically if wall time, tool-call count, or tool errors exceed defined limits.
84
+ - **Budget gating:** fail the run when tool-call count exceeds `max_tool_calls`. Wall time and tool-error caps are separate gates, configured with `max_wall_ms` and `max_tool_errors`.
77
85
 
78
86
  ### RunLedger vs evaluation frameworks
79
87
 
@@ -352,18 +360,18 @@ jobs:
352
360
  - uses: actions/checkout@v4
353
361
 
354
362
  - name: Run deterministic evals (replay)
363
+ id: evals
355
364
  uses: runledger/Runledger@v0.2
356
365
  with:
357
366
  path: ./evals/demo
358
367
  mode: replay
359
368
 
360
- - name: Upload eval artifacts
361
- uses: actions/upload-artifact@v4
362
- with:
363
- name: agent-eval-artifacts
364
- path: runledger_out/**
369
+ - name: Show the action result
370
+ run: echo "result=${{ steps.evals.outputs.result }} exit=${{ steps.evals.outputs.exit-code }}"
365
371
  ```
366
372
 
373
+ `runledger init` writes `.github/workflows/runledger.yml`, which installs the CLI and runs the suite with `--baseline`. The action above is the Marketplace entry in `action.yml`. It uploads `runledger_out/**` itself. On a release that contains this `action.yml`, the step also sets `result`, `exit-code`, and `artifacts-path`, and you can pass `baseline:` to override `suite.yaml`.
374
+
367
375
  ---
368
376
 
369
377
  ## Determinism guide (rules of the road)
@@ -386,6 +394,7 @@ jobs:
386
394
 
387
395
  ## Docs
388
396
 
397
+ - `docs/site-quickstart.md` -- five-minute copy for runledger.io
389
398
  - `docs/quickstart.md` -- install + first run
390
399
  - `docs/assertions.md` -- assertions, tool contracts, budgets
391
400
  - `docs/baselines.md` -- baselines, diffing, promotion
@@ -398,7 +407,6 @@ jobs:
398
407
  ## Roadmap
399
408
 
400
409
  * `init` templates for Python (OpenAI SDK), LangGraph/LangChain, and Node/TS
401
- * richer budgets (tokens/cost) via optional `task_metrics`
402
410
  * PR comments bot for regression summaries
403
411
  * plugin system for custom assertions
404
412
  * HTML report trace viewer improvements
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "runledger"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "CI for tool-using agents: deterministic eval suites with record/replay, hard assertions, budgets, and PR regression gates."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -34,6 +34,12 @@ dependencies = [
34
34
  "eval_type_backport>=0.2.0; python_version < \"3.10\"",
35
35
  ]
36
36
 
37
+ [project.urls]
38
+ Homepage = "https://runledger.io"
39
+ Repository = "https://github.com/runledger/Runledger"
40
+ Documentation = "https://github.com/runledger/Runledger/tree/main/docs"
41
+ Changelog = "https://github.com/runledger/Runledger/blob/main/CHANGELOG.md"
42
+
37
43
  [project.optional-dependencies]
38
44
  dev = [
39
45
  "pytest>=7.0.0",
@@ -1,3 +1,3 @@
1
1
  __all__ = ["__version__"]
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.2"
@@ -4,6 +4,7 @@ from datetime import datetime, timezone
4
4
  import json
5
5
  import os
6
6
  import shutil
7
+ import sys
7
8
  from pathlib import Path
8
9
  from typing import Optional
9
10
 
@@ -35,6 +36,22 @@ baseline_app = typer.Typer(add_completion=False, no_args_is_help=True)
35
36
  app.add_typer(baseline_app, name="baseline")
36
37
 
37
38
 
39
+ def select_python_launcher() -> str:
40
+ """Pick a Python command for suites generated by ``runledger init``.
41
+
42
+ Prefer a portable name on PATH so the suite still runs on other machines.
43
+ ``python3`` comes first because Ubuntu, macOS, and pipx installs often have
44
+ no ``python`` executable. When neither name exists, use the interpreter
45
+ that is running RunLedger (``sys.executable``).
46
+ """
47
+ for name in ("python3", "python"):
48
+ if shutil.which(name):
49
+ return name
50
+ if sys.executable:
51
+ return sys.executable
52
+ return "python3"
53
+
54
+
38
55
  def _display_path(path: Path, *, base_dir: Path) -> str:
39
56
  if not path.is_absolute():
40
57
  return path.as_posix()
@@ -47,6 +64,57 @@ def _display_path(path: Path, *, base_dir: Path) -> str:
47
64
  return path.as_posix()
48
65
 
49
66
 
67
+ def _posix_rel(path: Path, root: Path) -> str:
68
+ text = _display_path(path, base_dir=root)
69
+ if text.startswith(("/", ".")):
70
+ return text
71
+ return f"./{text}"
72
+
73
+
74
+ def render_init_workflow(*, suite_rel: str, baseline_rel: str) -> str:
75
+ """GitHub Actions workflow that replays the suite scaffolded by ``runledger init``."""
76
+ for label, value in (("suite path", suite_rel), ("baseline path", baseline_rel)):
77
+ if any(char in value for char in '\n\r"'):
78
+ raise ValueError(f"Unsafe {label} for workflow: {value!r}")
79
+ lines = [
80
+ "name: runledger",
81
+ "on:",
82
+ " pull_request:",
83
+ "",
84
+ "jobs:",
85
+ " evals:",
86
+ " runs-on: ubuntu-latest",
87
+ " steps:",
88
+ " - uses: actions/checkout@v4",
89
+ " - uses: actions/setup-python@v5",
90
+ " with:",
91
+ ' python-version: "3.11"',
92
+ " - name: Install RunLedger",
93
+ " run: |",
94
+ " python -m pip install --upgrade pip",
95
+ " python -m pip install runledger",
96
+ " - name: Run deterministic evals",
97
+ f' run: runledger run "{suite_rel}" --mode replay --baseline "{baseline_rel}"',
98
+ " - name: Upload artifacts",
99
+ " if: always()",
100
+ " uses: actions/upload-artifact@v4",
101
+ " with:",
102
+ " name: runledger-artifacts",
103
+ " path: runledger_out/**",
104
+ "",
105
+ ]
106
+ return "\n".join(lines)
107
+
108
+
109
+ def _write_unless_present(path: Path, content: str) -> bool:
110
+ """Write ``content`` when ``path`` is missing. Return True if the file was created."""
111
+ if path.exists():
112
+ return False
113
+ path.parent.mkdir(parents=True, exist_ok=True)
114
+ path.write_text(content, encoding="utf-8")
115
+ return True
116
+
117
+
50
118
  def _resolve_summary_path(run_path: Path) -> Path:
51
119
  if run_path.is_dir():
52
120
  summary_path = run_path / "summary.json"
@@ -141,7 +209,11 @@ def init(
141
209
  ),
142
210
  suite: str = typer.Option("demo", help="Suite name to generate"),
143
211
  template: str = typer.Option("support-triage", help="Template name"),
144
- force: bool = typer.Option(False, "--force", help="Overwrite existing suite files"),
212
+ force: bool = typer.Option(
213
+ False,
214
+ "--force",
215
+ help="Overwrite existing init files, including the workflow and baseline",
216
+ ),
145
217
  language: str = typer.Option("python", help="Agent language (python or node)"),
146
218
  ) -> None:
147
219
  """Initialize an example eval suite."""
@@ -167,18 +239,22 @@ def init(
167
239
  agent_command = ["node", "agent/agent.js"]
168
240
  else:
169
241
  agent_filename = "agent.py"
170
- agent_command = ["python", "agent/agent.py"]
242
+ agent_command = [select_python_launcher(), "agent/agent.py"]
171
243
  agent_path = agent_dir / agent_filename
172
244
 
173
- if evals_dir.exists():
174
- if not force:
175
- console.print(f"[red]Target already exists:[/red] {evals_dir}")
176
- raise typer.Exit(code=1)
177
- shutil.rmtree(evals_dir)
178
- if baselines_dir.exists():
179
- baseline_file = baselines_dir / f"{suite}.json"
180
- if baseline_file.exists() and force:
245
+ project_root = base_dir.parent
246
+ baseline_file = baselines_dir / f"{suite}.json"
247
+ workflow_path = project_root / ".github" / "workflows" / "runledger.yml"
248
+ if evals_dir.exists() and not evals_dir.is_dir():
249
+ console.print(f"[red]Target exists and is not a directory:[/red] {evals_dir}")
250
+ raise typer.Exit(code=1)
251
+ if force:
252
+ if evals_dir.exists():
253
+ shutil.rmtree(evals_dir)
254
+ if baseline_file.exists():
181
255
  baseline_file.unlink()
256
+ if workflow_path.exists():
257
+ workflow_path.unlink()
182
258
 
183
259
  agent_dir.mkdir(parents=True, exist_ok=True)
184
260
  cases_dir.mkdir(parents=True, exist_ok=True)
@@ -186,7 +262,7 @@ def init(
186
262
  baselines_dir.mkdir(parents=True, exist_ok=True)
187
263
 
188
264
  if language == "node":
189
- agent_path.write_text(
265
+ agent_text = (
190
266
  "\n".join(
191
267
  [
192
268
  "const readline = require(\"readline\");",
@@ -237,11 +313,10 @@ def init(
237
313
  ");",
238
314
  "",
239
315
  ]
240
- ),
241
- encoding="utf-8",
316
+ )
242
317
  )
243
318
  else:
244
- agent_path.write_text(
319
+ agent_text = (
245
320
  "\n".join(
246
321
  [
247
322
  "import json",
@@ -269,12 +344,10 @@ def init(
269
344
  " raise SystemExit(main())",
270
345
  "",
271
346
  ]
272
- ),
273
- encoding="utf-8",
347
+ )
274
348
  )
275
349
 
276
- schema_path = evals_dir / "schema.json"
277
- schema_path.write_text(
350
+ schema_text = (
278
351
  json.dumps(
279
352
  {
280
353
  "type": "object",
@@ -286,12 +359,9 @@ def init(
286
359
  },
287
360
  indent=2,
288
361
  )
289
- + "\n",
290
- encoding="utf-8",
362
+ + "\n"
291
363
  )
292
-
293
- cassette_path = cassettes_dir / "t1.jsonl"
294
- cassette_path.write_text(
364
+ cassette_text = (
295
365
  json.dumps(
296
366
  {
297
367
  "tool": "search_docs",
@@ -302,115 +372,134 @@ def init(
302
372
  ensure_ascii=False,
303
373
  sort_keys=True,
304
374
  )
305
- + "\n",
306
- encoding="utf-8",
375
+ + "\n"
307
376
  )
308
-
309
- case_path = cases_dir / "t1.yaml"
310
- case_path.write_text(
311
- yaml.safe_dump(
312
- {
313
- "id": "t1",
314
- "description": "triage a login ticket",
315
- "input": {"ticket": "reset password"},
316
- "cassette": "cassettes/t1.jsonl",
317
- "assertions": [
318
- {"type": "required_fields", "fields": ["category", "reply"]},
319
- ],
377
+ case_text = yaml.safe_dump(
378
+ {
379
+ "id": "t1",
380
+ "description": "triage a login ticket",
381
+ "input": {"ticket": "reset password"},
382
+ "cassette": "cassettes/t1.jsonl",
383
+ "assertions": [
384
+ {"type": "required_fields", "fields": ["category", "reply"]},
385
+ {"type": "call_order", "order": ["search_docs"]},
386
+ ],
387
+ },
388
+ sort_keys=False,
389
+ )
390
+ suite_text = yaml.safe_dump(
391
+ {
392
+ "suite_name": suite,
393
+ "agent_command": agent_command,
394
+ "mode": "replay",
395
+ "cases_path": "cases",
396
+ "tool_registry": ["search_docs"],
397
+ "assertions": [
398
+ {"type": "json_schema", "schema_path": "schema.json"},
399
+ ],
400
+ "budgets": {
401
+ "max_wall_ms": 20000,
402
+ "max_tool_calls": 1,
403
+ "max_tool_errors": 0,
320
404
  },
321
- sort_keys=False,
322
- ),
323
- encoding="utf-8",
405
+ "regression": {
406
+ "min_pass_rate": 1.0,
407
+ },
408
+ "baseline_path": f"../../baselines/{suite}.json",
409
+ },
410
+ sort_keys=False,
324
411
  )
325
412
 
413
+ schema_path = evals_dir / "schema.json"
414
+ cassette_path = cassettes_dir / "t1.jsonl"
415
+ case_path = cases_dir / "t1.yaml"
326
416
  suite_path = evals_dir / "suite.yaml"
327
- suite_path.write_text(
328
- yaml.safe_dump(
329
- {
330
- "suite_name": suite,
331
- "agent_command": agent_command,
332
- "mode": "replay",
333
- "cases_path": "cases",
334
- "tool_registry": ["search_docs"],
335
- "assertions": [
336
- {"type": "json_schema", "schema_path": "schema.json"},
337
- ],
338
- "budgets": {
339
- "max_wall_ms": 20000,
340
- "max_tool_calls": 1,
341
- "max_tool_errors": 0,
342
- },
343
- "regression": {
344
- "min_pass_rate": 1.0,
345
- },
346
- "baseline_path": f"../../baselines/{suite}.json",
347
- },
348
- sort_keys=False,
349
- ),
350
- encoding="utf-8",
351
- )
417
+ created: list[Path] = []
418
+ unchanged: list[Path] = []
419
+ for target, content in (
420
+ (agent_path, agent_text),
421
+ (schema_path, schema_text),
422
+ (cassette_path, cassette_text),
423
+ (case_path, case_text),
424
+ (suite_path, suite_text),
425
+ ):
426
+ if _write_unless_present(target, content):
427
+ created.append(target)
428
+ else:
429
+ unchanged.append(target)
352
430
 
353
- try:
354
- suite_config = load_suite(suite_path)
355
- cases = load_cases(evals_dir, suite_config.cases_path)
356
- suite_run = suite_config.model_copy(
357
- update={"agent_command": [agent_command[0], str(agent_path)]}
358
- )
359
- suite_result = run_suite(suite_run, cases)
360
- run_id = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
361
- summary = build_summary(
362
- suite=suite_config,
363
- suite_path=suite_path,
364
- suite_result=suite_result,
365
- run_id=run_id,
366
- generated_at=datetime.now(timezone.utc),
367
- )
368
- baseline = BaselineSummary.model_validate(summary)
369
- write_baseline(baselines_dir / f"{suite}.json", baseline)
370
- except Exception as exc:
371
- console.print(f"[red]Failed to generate baseline:[/red] {exc}")
372
- raise typer.Exit(code=1)
431
+ if baseline_file.exists():
432
+ unchanged.append(baseline_file)
433
+ else:
434
+ try:
435
+ suite_config = load_suite(suite_path)
436
+ cases = load_cases(evals_dir, suite_config.cases_path)
437
+ if suite_path in created:
438
+ suite_run = suite_config.model_copy(
439
+ update={"agent_command": [agent_command[0], str(agent_path)]}
440
+ )
441
+ else:
442
+ suite_run = suite_config
443
+ suite_result = run_suite(suite_run, cases)
444
+ except Exception as exc:
445
+ console.print(f"[red]Failed to generate baseline:[/red] {exc}")
446
+ raise typer.Exit(code=1)
447
+
448
+ if not suite_result.passed:
449
+ console.print("[red]Failed to generate baseline.[/red] The example agent did not pass.")
450
+ for line in format_failure_lines(collect_case_failures(suite_result.cases), []):
451
+ console.print(line, markup=False, highlight=False, soft_wrap=True)
452
+ raise typer.Exit(code=1)
453
+
454
+ try:
455
+ run_id = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
456
+ summary = build_summary(
457
+ suite=suite_config,
458
+ suite_path=suite_path,
459
+ suite_result=suite_result,
460
+ run_id=run_id,
461
+ generated_at=datetime.now(timezone.utc),
462
+ )
463
+ baseline = BaselineSummary.model_validate(summary)
464
+ write_baseline(baseline_file, baseline)
465
+ except Exception as exc:
466
+ console.print(f"[red]Failed to generate baseline:[/red] {exc}")
467
+ raise typer.Exit(code=1)
468
+ created.append(baseline_file)
469
+
470
+ if workflow_path.exists():
471
+ unchanged.append(workflow_path)
472
+ else:
473
+ suite_rel = _posix_rel(evals_dir, project_root)
474
+ baseline_rel = _posix_rel(baseline_file, project_root)
475
+ try:
476
+ workflow_text = render_init_workflow(suite_rel=suite_rel, baseline_rel=baseline_rel)
477
+ except ValueError as exc:
478
+ console.print(f"[red]{exc}[/red]")
479
+ raise typer.Exit(code=1)
480
+ _write_unless_present(workflow_path, workflow_text)
481
+ created.append(workflow_path)
373
482
 
374
483
  cwd = Path.cwd()
375
- evals_dir_display = _display_path(evals_dir, base_dir=cwd)
376
- baseline_display = _display_path(baselines_dir / f"{suite}.json", base_dir=cwd)
377
484
 
378
- console.print(f"Created eval suite at: {evals_dir_display}")
379
- console.print(f"Created baseline at: {baseline_display}")
485
+ def _show(paths: list[Path]) -> None:
486
+ for item in paths:
487
+ console.print(f" {_display_path(item, base_dir=cwd)}")
488
+
489
+ if created:
490
+ console.print("Created:")
491
+ _show(created)
492
+ if unchanged:
493
+ console.print("Unchanged:")
494
+ _show(unchanged)
380
495
  console.print("")
381
496
  console.print("Next steps:")
382
497
  console.print(
383
- f' runledger run "{evals_dir_display}" --mode replay --baseline "{baseline_display}"'
498
+ " runledger run "
499
+ f'"{_posix_rel(evals_dir, cwd)}" --mode replay --baseline "{_posix_rel(baseline_file, cwd)}"'
384
500
  )
385
501
  console.print(f" open runledger_out/{suite}/<run_id>/report.html")
386
- console.print("")
387
- console.print("GitHub Actions snippet:")
388
- console.print(
389
- "\n".join(
390
- [
391
- "name: runledger-evals",
392
- "on:",
393
- " pull_request:",
394
- "",
395
- "jobs:",
396
- " evals:",
397
- " runs-on: ubuntu-latest",
398
- " steps:",
399
- " - uses: actions/checkout@v4",
400
- " - name: Install RunLedger",
401
- " run: |",
402
- " python -m pip install --upgrade pip",
403
- " python -m pip install runledger",
404
- " - name: Run deterministic evals",
405
- f' run: runledger run "{evals_dir_display}" --mode replay --baseline "{baseline_display}"',
406
- " - name: Upload artifacts",
407
- " uses: actions/upload-artifact@v4",
408
- " with:",
409
- " name: runledger-artifacts",
410
- " path: runledger_out/**",
411
- ]
412
- )
413
- )
502
+ console.print(f" commit {_display_path(workflow_path, base_dir=cwd)}")
414
503
 
415
504
 
416
505
  @app.command()
@@ -25,6 +25,57 @@ class AgentProcessError(Exception):
25
25
  return f"{self.message}\nAgent stderr (tail):\n{tail}"
26
26
 
27
27
 
28
+ def _command_basename(program: str) -> str:
29
+ return program.replace("\\", "/").rsplit("/", 1)[-1].lower()
30
+
31
+
32
+ def _agent_start_error(command: Sequence[str], exc: BaseException) -> AgentProcessError:
33
+ """Turn a failed exec into an actionable error for suite.yaml authors."""
34
+ program = command[0] if command else ""
35
+ rendered = " ".join(command) if command else "<empty>"
36
+ example = '["python3", "agent/agent.py"]'
37
+ if isinstance(exc, FileNotFoundError):
38
+ if _command_basename(program) in {"python", "python.exe"}:
39
+ message = (
40
+ f"Agent command not found: {program!r}. "
41
+ "There is no 'python' executable on PATH. "
42
+ "Ubuntu, macOS, and many pipx installs only provide python3. "
43
+ f"Set agent_command in suite.yaml to a Python that exists, for example {example}, "
44
+ "or re-run `runledger init --force` to regenerate the example suite."
45
+ )
46
+ elif program:
47
+ message = (
48
+ f"Agent command not found: {program!r}. "
49
+ "RunLedger could not start the agent because that program is not on PATH "
50
+ "and is not an existing file. "
51
+ "Install it, or set agent_command in suite.yaml to the command that starts "
52
+ f"your agent (for example {example})."
53
+ )
54
+ else:
55
+ message = (
56
+ "Agent command is empty. "
57
+ "Set agent_command in suite.yaml to the program that starts your agent, "
58
+ f"for example {example}."
59
+ )
60
+ elif isinstance(exc, PermissionError):
61
+ message = (
62
+ f"Agent command is not executable: {program!r}. {exc}. "
63
+ "Grant execute permission, or set agent_command in suite.yaml to a program you can run."
64
+ )
65
+ elif isinstance(exc, ValueError):
66
+ message = (
67
+ "Agent command is empty or invalid. "
68
+ "Set agent_command in suite.yaml to the program that starts your agent, "
69
+ f"for example {example}."
70
+ )
71
+ else:
72
+ message = (
73
+ f"Failed to start agent command {rendered!r}: {exc}. "
74
+ "Check that agent_command in suite.yaml points at an installed, runnable program."
75
+ )
76
+ return AgentProcessError(message, [])
77
+
78
+
28
79
  class AgentProcess:
29
80
  def __init__(self, command: Sequence[str], timeout_s: float = 30.0, stderr_tail: int = 200):
30
81
  self._command = list(command)
@@ -47,14 +98,19 @@ class AgentProcess:
47
98
  def start(self) -> None:
48
99
  if self._process is not None:
49
100
  return
50
- self._process = subprocess.Popen(
51
- self._command,
52
- stdin=subprocess.PIPE,
53
- stdout=subprocess.PIPE,
54
- stderr=subprocess.PIPE,
55
- text=True,
56
- bufsize=1,
57
- )
101
+ if not self._command or not self._command[0].strip():
102
+ raise _agent_start_error(self._command, ValueError("empty command"))
103
+ try:
104
+ self._process = subprocess.Popen(
105
+ self._command,
106
+ stdin=subprocess.PIPE,
107
+ stdout=subprocess.PIPE,
108
+ stderr=subprocess.PIPE,
109
+ text=True,
110
+ bufsize=1,
111
+ )
112
+ except OSError as exc:
113
+ raise _agent_start_error(self._command, exc) from exc
58
114
  if self._process.stdout is None or self._process.stdin is None or self._process.stderr is None:
59
115
  raise AgentProcessError("Failed to open subprocess pipes", [])
60
116
  # Background threads prevent blocking reads from stalling the main loop.
@@ -1,3 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: runledger
3
+ Version: 0.2.2
4
+ Summary: CI for tool-using agents: deterministic eval suites with record/replay, hard assertions, budgets, and PR regression gates.
5
+ Author: RunLedger
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://runledger.io
8
+ Project-URL: Repository, https://github.com/runledger/Runledger
9
+ Project-URL: Documentation, https://github.com/runledger/Runledger/tree/main/docs
10
+ Project-URL: Changelog, https://github.com/runledger/Runledger/blob/main/CHANGELOG.md
11
+ Keywords: llm,agents,evaluation,ci,github-actions,replay,testing,junit,jsonschema
12
+ Requires-Python: >=3.9
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: typer>=0.9.0
16
+ Requires-Dist: rich>=13.0.0
17
+ Requires-Dist: pydantic>=2.0.0
18
+ Requires-Dist: pyyaml>=6.0.0
19
+ Requires-Dist: jsonschema>=4.0.0
20
+ Requires-Dist: jinja2>=3.1.0
21
+ Requires-Dist: eval_type_backport>=0.2.0; python_version < "3.10"
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
24
+ Requires-Dist: pytest-timeout>=2.1.0; extra == "dev"
25
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
26
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
27
+ Dynamic: license-file
28
+
1
29
  # RunLedger
2
30
 
3
31
  [![CI](https://github.com/runledger/Runledger/actions/workflows/ci.yml/badge.svg)](https://github.com/runledger/Runledger/actions/workflows/ci.yml)
@@ -6,25 +34,57 @@
6
34
 
7
35
  Website: https://runledger.io
8
36
 
9
- **Deterministic CI regression checks for tool-using agents.** Record tool calls once, replay in CI, and fail PRs on mismatches, contract violations, or budget regressions.
37
+ **Deterministic CI regression checks for tool-using agents.** Record tool calls once, replay them in CI, and fail the run when a schema, tool-order, or tool-call budget gate fails.
10
38
 
11
- - Demo repo: https://github.com/runledger/runledger-demo (main is green; regression branches fail for real reasons)
39
+ - Demo: https://github.com/ZackMitchell910/runledger-demo/pull/1 (intentionally left open to show RunLedger failing a bad agent change)
12
40
  - If this is useful, please star the repo (it helps a lot).
13
41
 
14
- RunLedger is a **CI harness**, not an "eval metrics framework":
15
- - **DeepEval-style tools** help you *score* behavior.
16
- - **RunLedger** helps you *ship safely* by making agent tests deterministic and merge-gated.
42
+ ## Five-minute quickstart
17
43
 
18
- ---
19
-
20
- ## Try it now (60 seconds)
44
+ From an empty directory, with Python 3.9+:
21
45
 
22
46
  ```bash
23
- pipx install runledger
47
+ python3 -m pip install runledger
24
48
  runledger init
25
49
  runledger run ./evals/demo --mode replay --baseline baselines/demo.json
26
50
  ```
27
51
 
52
+ `runledger init` writes:
53
+
54
+ - `evals/demo/` — replay suite, cassette, JSON Schema, and a small agent that calls `search_docs`
55
+ - `baselines/demo.json` — baseline from that passing run
56
+ - `.github/workflows/runledger.yml` — pull-request workflow that runs the same command
57
+
58
+ A second `runledger init` leaves those files unchanged. `--force` regenerates them.
59
+
60
+ The demo exits non-zero unless all three of these hold:
61
+
62
+ - **Schema.** The final output matches `evals/demo/schema.json` (`category` and `reply`).
63
+ - **Tool order.** The agent calls `search_docs` (`call_order` in `evals/demo/cases/t1.yaml`).
64
+ - **Tool-call budget.** The run stays within `max_tool_calls: 1`.
65
+
66
+ A passing run prints `PASS` and writes `runledger_out/demo/<run_id>/` (`summary.json`, `junit.xml`, `report.html`, `run.jsonl`). Commit the init files to run that gate on pull requests.
67
+
68
+ The same check as a GitHub Action. `suite.yaml` already points at `baselines/demo.json`, so the action compares against that baseline:
69
+
70
+ ```yaml
71
+ - id: evals
72
+ uses: runledger/Runledger@v0.2
73
+ with:
74
+ path: ./evals/demo
75
+ mode: replay
76
+ ```
77
+
78
+ This repo's `action.yml` also accepts a `baseline` input and sets `result` (`pass` or `fail`), `exit-code`, and `artifacts-path`. Those fields ship when a release containing this file is tagged. The in-repo self-test calls the action with `uses: ./`.
79
+
80
+ Page copy for runledger.io lives in `docs/site-quickstart.md`.
81
+
82
+ ---
83
+
84
+ RunLedger is a **CI harness**, not an "eval metrics framework":
85
+ - **DeepEval-style tools** help you *score* behavior.
86
+ - **RunLedger** helps you *ship safely* by making agent tests deterministic and merge-gated.
87
+
28
88
  ## Integrations (merged)
29
89
 
30
90
  - hoangsonww/Agentic-AI-Pipeline - https://github.com/hoangsonww/Agentic-AI-Pipeline/pull/24
@@ -49,7 +109,7 @@ RunLedger stops regressions by shifting from "vibes-based" evaluation to determi
49
109
 
50
110
  - **Record & replay:** record tool outputs once; replay them in CI for instant, stable tests.
51
111
  - **Strict contracts:** enforce tool schemas, calling order, and allowlists.
52
- - **Budget gating:** fail PRs automatically if wall time, tool-call count, or tool errors exceed defined limits.
112
+ - **Budget gating:** fail the run when tool-call count exceeds `max_tool_calls`. Wall time and tool-error caps are separate gates, configured with `max_wall_ms` and `max_tool_errors`.
53
113
 
54
114
  ### RunLedger vs evaluation frameworks
55
115
 
@@ -328,18 +388,18 @@ jobs:
328
388
  - uses: actions/checkout@v4
329
389
 
330
390
  - name: Run deterministic evals (replay)
391
+ id: evals
331
392
  uses: runledger/Runledger@v0.2
332
393
  with:
333
394
  path: ./evals/demo
334
395
  mode: replay
335
396
 
336
- - name: Upload eval artifacts
337
- uses: actions/upload-artifact@v4
338
- with:
339
- name: agent-eval-artifacts
340
- path: runledger_out/**
397
+ - name: Show the action result
398
+ run: echo "result=${{ steps.evals.outputs.result }} exit=${{ steps.evals.outputs.exit-code }}"
341
399
  ```
342
400
 
401
+ `runledger init` writes `.github/workflows/runledger.yml`, which installs the CLI and runs the suite with `--baseline`. The action above is the Marketplace entry in `action.yml`. It uploads `runledger_out/**` itself. On a release that contains this `action.yml`, the step also sets `result`, `exit-code`, and `artifacts-path`, and you can pass `baseline:` to override `suite.yaml`.
402
+
343
403
  ---
344
404
 
345
405
  ## Determinism guide (rules of the road)
@@ -362,6 +422,7 @@ jobs:
362
422
 
363
423
  ## Docs
364
424
 
425
+ - `docs/site-quickstart.md` -- five-minute copy for runledger.io
365
426
  - `docs/quickstart.md` -- install + first run
366
427
  - `docs/assertions.md` -- assertions, tool contracts, budgets
367
428
  - `docs/baselines.md` -- baselines, diffing, promotion
@@ -374,7 +435,6 @@ jobs:
374
435
  ## Roadmap
375
436
 
376
437
  * `init` templates for Python (OpenAI SDK), LangGraph/LangChain, and Node/TS
377
- * richer budgets (tokens/cost) via optional `task_metrics`
378
438
  * PR comments bot for regression summaries
379
439
  * plugin system for custom assertions
380
440
  * HTML report trace viewer improvements
File without changes
File without changes