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.
- {runledger-0.2.1 → runledger-0.2.2}/PKG-INFO +53 -17
- runledger-0.2.1/src/runledger.egg-info/PKG-INFO → runledger-0.2.2/README.md +48 -40
- {runledger-0.2.1 → runledger-0.2.2}/pyproject.toml +7 -1
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/__init__.py +1 -1
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cli.py +207 -118
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/subprocess.py +64 -8
- runledger-0.2.1/README.md → runledger-0.2.2/src/runledger.egg-info/PKG-INFO +76 -16
- {runledger-0.2.1 → runledger-0.2.2}/LICENSE +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/setup.cfg +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/__main__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/junit.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/report.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/run_log.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/artifacts/summary.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/base.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/engine.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/json_schema.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/required_fields.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/assertions/tool_contract.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/baseline/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/baseline/io.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/baseline/models.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/loader.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/match.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/models.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/cassette/writer.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/config/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/config/loader.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/config/models.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/protocol/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/protocol/jsonl.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/protocol/messages.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/regression/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/regression/engine.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/reporting/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/reporting/failures.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/budgets.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/engine.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/runner/models.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/tools/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/tools/builtin.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/tools/registry.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/util/__init__.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/util/canonical_json.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/util/normalize.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger/util/redaction.py +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger.egg-info/SOURCES.txt +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger.egg-info/dependency_links.txt +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger.egg-info/entry_points.txt +0 -0
- {runledger-0.2.1 → runledger-0.2.2}/src/runledger.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
361
|
-
|
|
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
|
[](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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
361
|
-
|
|
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.
|
|
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",
|
|
@@ -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(
|
|
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 = [
|
|
242
|
+
agent_command = [select_python_launcher(), "agent/agent.py"]
|
|
171
243
|
agent_path = agent_dir / agent_filename
|
|
172
244
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
{
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
"
|
|
316
|
-
"
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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.
|
|
51
|
-
self._command,
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
[](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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
337
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|