tuieval 0.2.0.dev123__tar.gz → 0.2.0.dev126__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 (82) hide show
  1. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/PKG-INFO +4 -2
  3. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/README.md +3 -1
  4. tuieval-0.2.0.dev126/docs/agent-packs.md +142 -0
  5. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/models.md +1 -0
  6. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/writing-packs.md +2 -0
  7. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/_version.py +2 -2
  8. tuieval-0.2.0.dev126/src/tuieval/agents.py +353 -0
  9. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/client.py +1 -1
  10. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/compare.py +35 -1
  11. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/engine.py +202 -94
  12. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/packs.py +98 -0
  13. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/run_evals.py +7 -2
  14. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/selftest.py +39 -6
  15. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/tui.py +37 -4
  16. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/tests/test_tuieval.py +270 -1
  17. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/.gitignore +0 -0
  18. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/LICENSE +0 -0
  19. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/RELEASING.md +0 -0
  20. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/README.md +0 -0
  21. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/favicon.ico +0 -0
  22. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/readme-header.png +0 -0
  23. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/social-preview.png +0 -0
  24. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-1024.png +0 -0
  25. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-128.png +0 -0
  26. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-16.png +0 -0
  27. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-16.svg +0 -0
  28. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-256.png +0 -0
  29. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-32.png +0 -0
  30. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-48.png +0 -0
  31. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-512.png +0 -0
  32. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-64.png +0 -0
  33. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  34. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/brand/tuieval-icon.svg +0 -0
  35. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/tui-answer-reasoning.png +0 -0
  36. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/tui-run-reasoning.png +0 -0
  37. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/docs/images/tui-setup.png +0 -0
  38. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/pyproject.toml +0 -0
  39. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/__init__.py +0 -0
  40. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/__main__.py +0 -0
  41. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/builtin.toml +0 -0
  42. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/cli.py +0 -0
  43. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/dates.py +0 -0
  44. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/doctor.py +0 -0
  45. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/export.py +0 -0
  46. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/graders/__init__.py +0 -0
  47. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/graders/answer.py +0 -0
  48. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/graders/code.py +0 -0
  49. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/graders/rag.py +0 -0
  50. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/graders/reply.py +0 -0
  51. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/graders/tool_call.py +0 -0
  52. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/insights.py +0 -0
  53. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/logs.py +0 -0
  54. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/machines.py +0 -0
  55. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/onboard.py +0 -0
  56. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/profiles.py +0 -0
  57. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/pta.py +0 -0
  58. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/remove.py +0 -0
  59. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/scaffold.py +0 -0
  60. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/models.toml +0 -0
  61. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  62. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/answer/system.txt +0 -0
  63. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/code/pack.toml +0 -0
  65. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/code/system.txt +0 -0
  66. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  68. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/rag/system.txt +0 -0
  69. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  71. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/reply/system.txt +0 -0
  72. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  74. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  75. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  76. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  77. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/tune.py +0 -0
  78. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/verdict.py +0 -0
  79. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/watch_proxy.py +0 -0
  80. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/workspace.py +0 -0
  81. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/src/tuieval/yamlout.py +0 -0
  82. {tuieval-0.2.0.dev123 → tuieval-0.2.0.dev126}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Fix: a request that runs out of time says `time limit: timed out after N s`, not `connection error`, and `tuieval compare` counts it against the model like the run does (it was left out as an error).
6
+ - Agent packs (`kind = "agent"`): each test is a small project with a task, worked on by a command-line agent you define in `[agents]`, using the model tuieval serves, and graded by the task's visible and hidden tests. Results show the agent's stats, diff and transcript; `tuieval compare` adds an Agent tasks table; `tuieval selftest` checks each task's reference and wrong fixes. See docs/agent-packs.md.
5
7
  - Answers that used more tokens than the `max_tokens` sent (a provider that didn't stop them) are marked over budget: `(n over budget)` in the PTA index, an **over budget** column, `over` in Per question and a note in the answer view. `[defaults] over_budget = "fail"` fails them instead. Works on stored results.
6
8
  - Fix: Results → Per question opened the answers of the model one column to the right of the cell picked, and crashed on the last model's column.
7
9
  - Fix: `v` on the run screen switches to the last model still running when the stream shows one that has finished.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev123
3
+ Version: 0.2.0.dev126
4
4
  Summary: Evaluate local and frontier LLMs on your own questions: accuracy, speed, tokens and PASS/FAIL verdicts, in the terminal.
5
5
  Project-URL: Homepage, https://github.com/ashe-wb/tuieval
6
6
  Project-URL: Issues, https://github.com/ashe-wb/tuieval/issues
@@ -111,6 +111,8 @@ packs/support-bot/
111
111
 
112
112
  Built-in graders: `answer` (a number or word on an `ANSWER:` line, or a correct refusal when the data isn't there), `rag` (grounded answers with citations), `reply` (free-form replies against rules), `tool_call` (the right tool with the right arguments, or rightly none) and `code` (Python run against hidden tests). Your own grader is one Python file in the workspace's `graders/` folder.
113
113
 
114
+ **Agent packs** test models inside a coding agent: each test is a small project with a task. An agent you choose works on it, using the model tuieval serves, and the project's tests grade the result. See [docs/agent-packs.md](docs/agent-packs.md).
115
+
114
116
  `tuieval new-pack <name> --grader <grader>` creates a pack with working examples for any grader. **See [docs/writing-packs.md](docs/writing-packs.md)** for every field, gates, critical failures, difficulty labels, custom graders and how to write tests that separate good models from weak ones.
115
117
 
116
118
  ## The workspace
@@ -186,7 +188,7 @@ tuieval help
186
188
 
187
189
  ## Safety
188
190
 
189
- ⚠️ The `code` grader executes model-written code on your machine. Run code packs inside a container or VM with no credentials in the environment.
191
+ ⚠️ The `code` grader executes model-written code on your machine, and an agent pack's agent runs commands as you. Run code and agent packs inside a container or VM with no credentials in the environment.
190
192
 
191
193
  ## Development
192
194
 
@@ -85,6 +85,8 @@ packs/support-bot/
85
85
 
86
86
  Built-in graders: `answer` (a number or word on an `ANSWER:` line, or a correct refusal when the data isn't there), `rag` (grounded answers with citations), `reply` (free-form replies against rules), `tool_call` (the right tool with the right arguments, or rightly none) and `code` (Python run against hidden tests). Your own grader is one Python file in the workspace's `graders/` folder.
87
87
 
88
+ **Agent packs** test models inside a coding agent: each test is a small project with a task. An agent you choose works on it, using the model tuieval serves, and the project's tests grade the result. See [docs/agent-packs.md](docs/agent-packs.md).
89
+
88
90
  `tuieval new-pack <name> --grader <grader>` creates a pack with working examples for any grader. **See [docs/writing-packs.md](docs/writing-packs.md)** for every field, gates, critical failures, difficulty labels, custom graders and how to write tests that separate good models from weak ones.
89
91
 
90
92
  ## The workspace
@@ -160,7 +162,7 @@ tuieval help
160
162
 
161
163
  ## Safety
162
164
 
163
- ⚠️ The `code` grader executes model-written code on your machine. Run code packs inside a container or VM with no credentials in the environment.
165
+ ⚠️ The `code` grader executes model-written code on your machine, and an agent pack's agent runs commands as you. Run code and agent packs inside a container or VM with no credentials in the environment.
164
166
 
165
167
  ## Development
166
168
 
@@ -0,0 +1,142 @@
1
+ # Agent packs
2
+
3
+ An ordinary pack sends one prompt and grades one answer. That ranks models fairly, but it can't show how a model does inside a coding agent: reading files, editing them, running the tests, reacting to failures and deciding when it's done. An **agent pack** tests that.
4
+
5
+ Each test is a small project with a task. tuieval serves the model as for any other pack, hands a scratch copy of the project to a command-line agent you choose, and grades what the agent leaves behind with tests. Results show up beside your other packs.
6
+
7
+ ## How a task runs
8
+
9
+ 1. **Copy the project.** The task's `repo/` is copied to a scratch folder and committed to git, so the agent's changes can be shown as a diff.
10
+ 2. **Run the agent.** Its command runs in that folder. Every line it prints shows live in the run screen.
11
+ 3. **Stop it at the time limit.** The agent is stopped at the task's time limit (`minutes`). It keeps to the turn limit (`max_turns`) itself.
12
+ 4. **Grade the folder** with pytest. Two sets of tests run:
13
+ - the task's **original** visible tests. If the agent edits or deletes them, it gains nothing, and the edit is recorded;
14
+ - its **hidden** tests, which the agent never sees.
15
+
16
+ The task passes when every test passes. Its score is the share of hidden tests passed, so a near miss is visible.
17
+
18
+ Server failures never count against the model. If the model's server stops, or the agent reports that the server failed, the task is asked again. If the server keeps failing, the pack stops, like any pack whose server keeps failing.
19
+
20
+ ## The pack
21
+
22
+ ```
23
+ packs/agentic-coding/
24
+ pack.toml
25
+ tasks/
26
+ fix-fees/
27
+ task.md the instruction the agent gets
28
+ task.toml optional: description, category, difficulty, minutes, max_turns, visible_tests
29
+ repo/ the project the agent works on, with its visible tests (tests/ by default)
30
+ hidden/ tests the agent never sees, copied in only for grading
31
+ solution/ optional: a reference fix, copied over repo/ (a .delete file lists files to remove)
32
+ wrong/<name>/ optional: plausible wrong fixes that must fail
33
+ ```
34
+
35
+ ```toml
36
+ # pack.toml
37
+ label = "Agentic coding"
38
+ group = "Coding"
39
+ kind = "agent" # task folders run by an agent
40
+ agent = "my-agent" # optional: which [agents.<name>]; default: [defaults] agent
41
+ python = "agentic/.venv/bin/python" # optional: the Python the tasks run with (relative to the workspace)
42
+ minutes = 15 # optional: default time limit per task
43
+ max_turns = 40 # optional: default turn limit per task
44
+ min_context = 32768 # optional: context a model needs for this pack (default 32768)
45
+
46
+ [certify]
47
+ repeat = 1
48
+ [gate]
49
+ min_accuracy = 0.0
50
+ ```
51
+
52
+ ```toml
53
+ # tasks/fix-fees/task.toml
54
+ description = "Fees counted twice"
55
+ category = "bug fix"
56
+ difficulty = "medium"
57
+ minutes = 15
58
+ max_turns = 40
59
+ ```
60
+
61
+ - **Python:** the pack's Python must have pytest installed, plus whatever the tasks import. Without a `python` setting, tuieval's own interpreter is used (`pipx inject tuieval pytest` adds pytest to a pipx install).
62
+ - **Fingerprint:** it covers each task's `task.md`, its limits and every file under `repo/` and `hidden/`. Editing any of them reruns that pack. The description, category and difficulty are labels only.
63
+ - **Selftest:** `tuieval selftest <pack>` checks three things for every task: the untouched `repo/` fails, `solution/` passes, and every `wrong/<name>/` fails. A task that passes untouched, or a wrong fix that passes, can't tell good agents from bad ones.
64
+
65
+ ## The agent
66
+
67
+ Define the agent in `models.toml`. Its command is any program; `{root}` is your workspace.
68
+
69
+ ```toml
70
+ [agents.my-agent]
71
+ cmd = ["python3", "{root}/agents/my_agent.py"]
72
+
73
+ [defaults]
74
+ agent = "my-agent"
75
+ ```
76
+
77
+ The agent's name and command are part of an agent pack's settings, so changing the command reruns the pack. The agent's version isn't: each answer records it, and `tuieval compare` marks a pack that ran on more than one version as MIXED.
78
+
79
+ Most agents need a small **adapter**: a script that reads what tuieval provides, starts the agent with it, and writes the stats file. pi, Aider, Codex CLI, OpenCode and similar agents can all be wrapped this way. The adapter gets everything from its environment:
80
+
81
+ | Variable | Contents |
82
+ |---|---|
83
+ | `TUIEVAL_BASE_URL` | OpenAI-compatible URL of the model (ends in `/v1`) |
84
+ | `TUIEVAL_MODEL_ID` | the model name to send |
85
+ | `TUIEVAL_API_KEY` | the API key for hosted models; empty for local ones |
86
+ | `TUIEVAL_PROMPT_FILE` | the task's instruction |
87
+ | `TUIEVAL_SAMPLING` | the model's sampling settings as JSON (temperature, top_p, max_tokens, reasoning_effort, …) |
88
+ | `TUIEVAL_CONTEXT` | the model's context size in tokens, if known |
89
+ | `TUIEVAL_MAX_TURNS` | the task's turn limit |
90
+ | `TUIEVAL_TASK` | the task's id |
91
+ | `TUIEVAL_STATS_FILE` | where to write the stats (below) |
92
+
93
+ - **Working folder:** the scratch copy of the task.
94
+ - **PATH:** starts with the pack's Python, so `python` and `pytest` work.
95
+ - **Exit code:** ignored. Only the tests decide.
96
+
97
+ **Stats file.** It's optional; without it, tuieval records only the time taken. Any of these keys may be written:
98
+
99
+ ```json
100
+ {"agent": "my-agent 1.4.2", "turns": 14, "input_tokens": 182340, "output_tokens": 9120,
101
+ "tool_calls": 22, "tool_errors": 1, "ran_tests": true, "stop": "done",
102
+ "final_message": "Fixed: the ledger subtracted the fee twice."}
103
+ ```
104
+
105
+ - **`tool_errors`:** count failed edits and malformed tool calls, not commands that exit nonzero.
106
+ - **`stop`:** `done`, `turn limit` or `error`.
107
+ - **`server_error`:** if the model's server failed rather than the model, write this key with the error text. The task isn't counted and is asked again.
108
+
109
+ A minimal adapter:
110
+
111
+ ```python
112
+ #!/usr/bin/env python3
113
+ import json, os, subprocess
114
+
115
+ prompt = open(os.environ["TUIEVAL_PROMPT_FILE"]).read()
116
+ proc = subprocess.run(["my-agent", "--base-url", os.environ["TUIEVAL_BASE_URL"],
117
+ "--model", os.environ["TUIEVAL_MODEL_ID"], "--max-turns", os.environ["TUIEVAL_MAX_TURNS"],
118
+ "--print", prompt], capture_output=True, text=True)
119
+ print(proc.stdout) # shows in the run screen and the transcript
120
+ with open(os.environ["TUIEVAL_STATS_FILE"], "w") as f:
121
+ json.dump({"agent": "my-agent", "stop": "done" if proc.returncode == 0 else "error",
122
+ "final_message": proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else ""}, f)
123
+ ```
124
+
125
+ For a live view in the run screen, print a line per turn and tool call as they happen rather than at the end.
126
+
127
+ ## Results
128
+
129
+ Agent tasks appear wherever other answers do: Per question, Failures, the scorecard and the PTA index.
130
+
131
+ - **Answer view:**
132
+ - the agent's stats: version, turns, tokens, tool errors, and whether it ran the tests;
133
+ - its final message and its changes as a diff;
134
+ - the full transcript, with the test output;
135
+ - under **What's checked**, the visible and hidden tests.
136
+ - **`tuieval compare`:** adds an **Agent tasks** table per model and pack. It shows tasks passed, median minutes and turns, output tokens, tool errors, how often the tests were run, limits hit, tests edited, and the agent version.
137
+
138
+ `tuieval regrade` leaves agent results alone: they're graded when they run, so retest the pack instead.
139
+
140
+ ## Safety
141
+
142
+ ⚠️ The agent runs commands as you, with your permissions. It works in a scratch folder, but nothing stops it from reaching outside that folder. Run agent packs in a container or VM with no credentials in the environment. The hidden tests and reference fixes live in your pack folder, so an agent that searched your disk could find them. The transcript would show it.
@@ -210,6 +210,7 @@ request_timeout_ms = 1800000 # per request; long thinking runs need it
210
210
  stall_timeout_ms = 300000 # no tokens this long once answering began: the server stalled (retried, not counted)
211
211
  over_budget = "flag" # answers past the max_tokens sent (a provider that didn't stop them): "flag" or "fail"
212
212
  # reference = "<label>" # the model Results → Insights compares against by default
213
+ # agent = "<name>" # the [agents.<name>] that runs agent packs (see docs/agent-packs.md)
213
214
  ready_timeout_s = 1200 # how long a model may take to load before giving up
214
215
  model_dirs = ["~/models"] # where "Scan for models" looks for new GGUFs
215
216
 
@@ -15,6 +15,8 @@ Two shortcuts to a first set of real questions:
15
15
  - **From a spreadsheet:** `tuieval new-pack quiz --from questions.csv` makes one test per row from a `question` and an `answer` column (optional: `id`, `category`, `difficulty`, `tolerance`, and `wrong` with known-wrong answers separated by `|`). Numbers are checked with the tolerance, words case-insensitively, and `NOT_AVAILABLE` means the right answer is "the question doesn't say".
16
16
  - **Drafted by a model:** `tuieval new-pack support-bot --grader reply --about "refund questions for a shoe store"` also writes `DRAFT-PROMPT.md`. Paste it into the strongest model you have, save the YAML it writes as `tests.yaml`, and run `tuieval selftest`: it catches drafted tests whose expected answer is wrong or whose grader can't tell a mistake from the right answer. Read the questions anyway; you know your domain better than the model.
17
17
 
18
+ An **agent pack** (`kind = "agent"`) has task folders instead of questions: a coding agent works on each one, and tests grade the result. See [agent-packs.md](agent-packs.md).
19
+
18
20
  To try a different set of questions, copy a pack (`cp -r packs/support-bot packs/support-bot-v2`), edit the copy, and pick whichever you want in the TUI.
19
21
 
20
22
  ## Fingerprints
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0.dev123'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev123')
21
+ __version__ = version = '0.2.0.dev126'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev126')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,353 @@
1
+ """Agent packs: coding tasks a command-line agent works on, graded by tests.
2
+
3
+ models.toml says how to start each agent (any program; {root} is the workspace):
4
+
5
+ [agents.my-agent]
6
+ cmd = ["python3", "{root}/agents/my_agent.py"]
7
+
8
+ [defaults]
9
+ agent = "my-agent" # for agent packs that don't name one (pack.toml agent = "...")
10
+
11
+ tuieval serves the model as for any pack, then for each task:
12
+
13
+ 1. copies the task's repo/ to a scratch folder, a git repository, so the agent's changes show as a diff
14
+ 2. runs the agent's command there; every line it prints shows live in the run screen
15
+ 3. stops it at the task's time limit (task.toml minutes); the agent keeps to max_turns itself
16
+ 4. grades the folder with pytest: the task's original visible tests (editing or deleting them gains
17
+ the agent nothing, and is recorded) plus its hidden tests, which the agent never sees
18
+
19
+ The agent learns the rest from its environment:
20
+
21
+ TUIEVAL_BASE_URL OpenAI-compatible URL of the model (…/v1)
22
+ TUIEVAL_MODEL_ID the model name to send
23
+ TUIEVAL_API_KEY the API key for hosted models ("" for local ones)
24
+ TUIEVAL_PROMPT_FILE the task's instruction
25
+ TUIEVAL_SAMPLING the model's sampling settings as JSON (temperature, max_tokens, reasoning_effort, …)
26
+ TUIEVAL_CONTEXT the model's context size (tokens), if known
27
+ TUIEVAL_MAX_TURNS the task's turn limit
28
+ TUIEVAL_STATS_FILE where it may write its stats as JSON when it finishes (all optional):
29
+ agent ("name version"), turns, input_tokens, output_tokens, tool_calls,
30
+ tool_errors (failed edits, bad tool calls; not commands that exit nonzero),
31
+ ran_tests, stop ("done", "turn limit" or "error"), final_message, and
32
+ server_error: the model's server failed, not the model (the task isn't counted
33
+ and is asked again)
34
+
35
+ PATH starts with the pack's Python (pack.toml python, else tuieval's), so `python` runs the tasks'
36
+ code and tests. The agent's exit code decides nothing; the tests do.
37
+
38
+ The agent runs as you, with your permissions, in the scratch folder: there is no sandbox.
39
+ """
40
+ import filecmp
41
+ import json
42
+ import os
43
+ import queue
44
+ import re
45
+ import shutil
46
+ import signal
47
+ import subprocess
48
+ import sys
49
+ import tempfile
50
+ import threading
51
+ import time
52
+ import xml.etree.ElementTree as ET
53
+
54
+ PROMPT_TAIL = "\n\nYou're working in a project in the current directory. Run Python with `python`.\n"
55
+ GRADE_TIMEOUT_S = 300
56
+ DIFF_KEEP = 20_000 # characters of the diff kept in the result (the whole diff is in the log)
57
+ HIDDEN_RE = re.compile(r"\.?_hidden\b") # junit names of the hidden tests (a collection error starts with ".")
58
+ STATS_KEYS = ("agent", "turns", "input_tokens", "output_tokens", "tool_calls", "tool_errors", "ran_tests", "stop",
59
+ "final_message", "server_error")
60
+
61
+
62
+ class AgentError(Exception):
63
+ pass
64
+
65
+
66
+ class ServerGone(Exception):
67
+ """The model's server stopped while the agent worked: not the agent's fault."""
68
+
69
+
70
+ def agent_for(cfg, pack):
71
+ """(name, [agents.<name>] table or None) for an agent pack."""
72
+ name = pack.agent or cfg.get("defaults", {}).get("agent", "")
73
+ return name, cfg.get("agents", {}).get(name) if name else None
74
+
75
+
76
+ def identity(cfg, pack):
77
+ """What a result's settings record about the agent: its name and command (a different command
78
+ makes earlier results outdated; a new version of the same agent doesn't, it's shown per answer)."""
79
+ name, a = agent_for(cfg, pack)
80
+ return {"name": name, "cmd": list((a or {}).get("cmd", []))}
81
+
82
+
83
+ def python_for(pack, root):
84
+ """The Python an agent pack's tasks and tests run with."""
85
+ if not pack.python:
86
+ return sys.executable
87
+ p = os.path.expanduser(pack.python)
88
+ return p if os.path.isabs(p) else os.path.join(root, p)
89
+
90
+
91
+ _PYTEST = {}
92
+
93
+
94
+ def problem(cfg, pack, root):
95
+ """Why an agent pack can't run here (a note for a skipped job), or None."""
96
+ name, a = agent_for(cfg, pack)
97
+ if not name:
98
+ return 'no agent: add [agents.<name>] to models.toml and [defaults] agent = "<name>"'
99
+ if not a or not a.get("cmd"):
100
+ return f"agent {name!r} isn't defined: add [agents.{name}] with a cmd to models.toml"
101
+ py = python_for(pack, root)
102
+ if py not in _PYTEST:
103
+ try:
104
+ _PYTEST[py] = subprocess.run([py, "-c", "import pytest"], capture_output=True, timeout=60).returncode == 0
105
+ except (OSError, subprocess.TimeoutExpired):
106
+ _PYTEST[py] = False
107
+ if not _PYTEST[py]:
108
+ return f"pytest isn't installed in {py} (pack.toml python), which grades the tasks"
109
+ return None
110
+
111
+
112
+ def env_for(python, extra):
113
+ """The environment an agent and its tests run in: the pack's Python first on PATH."""
114
+ env = {k: v for k, v in os.environ.items() if not k.startswith(("PYTHON", "VIRTUAL_ENV", "CONDA_PREFIX"))}
115
+ env["PATH"] = os.path.dirname(python) + os.pathsep + env.get("PATH", "/usr/bin:/bin")
116
+ venv = os.path.dirname(os.path.dirname(python))
117
+ if os.path.isfile(os.path.join(venv, "pyvenv.cfg")):
118
+ env["VIRTUAL_ENV"] = venv
119
+ env["MPLBACKEND"] = "Agg"
120
+ env.update(extra)
121
+ return env
122
+
123
+
124
+ def _git(work, *args):
125
+ try:
126
+ return subprocess.run(["git", "-c", "user.name=tuieval", "-c", "user.email=tuieval@localhost",
127
+ "-c", "commit.gpgsign=false", *args], cwd=work, capture_output=True, text=True,
128
+ timeout=60)
129
+ except (OSError, subprocess.TimeoutExpired):
130
+ return None
131
+
132
+
133
+ def prepare(test, parent):
134
+ """Copy a task's repo/ into parent/work as a fresh git repository; returns the folder."""
135
+ work = os.path.join(parent, "work")
136
+ shutil.copytree(os.path.join(test["task_dir"], "repo"), work,
137
+ ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache", ".git"))
138
+ if _git(work, "init", "-q") is not None:
139
+ with open(os.path.join(work, ".git", "info", "exclude"), "a") as f:
140
+ f.write("__pycache__/\n.pytest_cache/\n")
141
+ _git(work, "add", "-A")
142
+ _git(work, "commit", "-qm", "task")
143
+ return work
144
+
145
+
146
+ def diff(work):
147
+ """The agent's changes as a patch (empty without git)."""
148
+ if not os.path.isdir(os.path.join(work, ".git")):
149
+ return ""
150
+ _git(work, "add", "-A")
151
+ out = _git(work, "diff", "--cached", "--stat", "-p")
152
+ return out.stdout if out and out.returncode == 0 else ""
153
+
154
+
155
+ def _kill(proc):
156
+ for sig in (signal.SIGTERM, signal.SIGKILL):
157
+ try:
158
+ os.killpg(proc.pid, sig)
159
+ except (ProcessLookupError, PermissionError):
160
+ return
161
+ try:
162
+ proc.wait(timeout=5)
163
+ return
164
+ except subprocess.TimeoutExpired:
165
+ continue
166
+
167
+
168
+ def run(cmd, work, env, limit_s, on_line, check, alive, log):
169
+ """Run the agent in work until it exits or limit_s passes. Every output line goes to on_line and
170
+ the log file. check() may raise (a cancel): the agent is stopped first. Returns (stop, seconds,
171
+ exit code); stop is "exited" or "time limit". Raises ServerGone when alive() turns False."""
172
+ t0 = time.monotonic()
173
+ proc = subprocess.Popen(cmd, cwd=work, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
174
+ stderr=subprocess.STDOUT, start_new_session=True)
175
+ lines = queue.Queue()
176
+
177
+ def read():
178
+ for raw in iter(proc.stdout.readline, b""):
179
+ lines.put(raw.decode("utf-8", "replace"))
180
+ lines.put(None)
181
+ threading.Thread(target=read, daemon=True).start()
182
+ stop = "exited"
183
+ try:
184
+ while True:
185
+ check()
186
+ if not alive():
187
+ raise ServerGone("the model's server stopped while the agent worked")
188
+ if time.monotonic() - t0 > limit_s:
189
+ stop = "time limit"
190
+ break
191
+ try:
192
+ line = lines.get(timeout=0.5)
193
+ except queue.Empty:
194
+ continue
195
+ if line is None:
196
+ break
197
+ log.write(line)
198
+ on_line(line)
199
+ finally:
200
+ if proc.poll() is None:
201
+ _kill(proc)
202
+ else: # exited: its own children may still run
203
+ try:
204
+ os.killpg(proc.pid, signal.SIGKILL)
205
+ except (ProcessLookupError, PermissionError):
206
+ pass
207
+ return stop, round(time.monotonic() - t0, 1), proc.wait()
208
+
209
+
210
+ def read_stats(path):
211
+ """The stats the agent wrote (only the known keys), or {}."""
212
+ try:
213
+ with open(path) as f:
214
+ data = json.load(f)
215
+ except (OSError, ValueError):
216
+ return {}
217
+ return {k: data[k] for k in STATS_KEYS if k in data} if isinstance(data, dict) else {}
218
+
219
+
220
+ def tampered(test, work):
221
+ """Visible test files the agent changed or removed."""
222
+ src = os.path.join(test["task_dir"], "repo", test["visible_tests"])
223
+ out = []
224
+ for dirpath, _, files in os.walk(src):
225
+ for fn in sorted(files):
226
+ if fn.endswith(".py"):
227
+ a = os.path.join(dirpath, fn)
228
+ rel = os.path.relpath(a, os.path.join(test["task_dir"], "repo"))
229
+ b = os.path.join(work, rel)
230
+ if not os.path.isfile(b) or not filecmp.cmp(a, b, shallow=False):
231
+ out.append(rel)
232
+ return sorted(out)
233
+
234
+
235
+ def _cases(xml_path):
236
+ """{test name: "pass" | "fail" | "skip"} from a pytest junit file."""
237
+ try:
238
+ root = ET.parse(xml_path).getroot()
239
+ except (OSError, ET.ParseError):
240
+ return {}
241
+ out = {}
242
+ for case in root.iter("testcase"):
243
+ name = f'{case.get("classname")}.{case.get("name")}'
244
+ bad = case.find("failure") is not None or case.find("error") is not None
245
+ out[name] = "skip" if case.find("skipped") is not None else "fail" if bad else "pass"
246
+ return out
247
+
248
+
249
+ def grade(test, work, python):
250
+ """Run the task's original visible tests and its hidden tests on a copy of work.
251
+ Returns {pass, score, visible, hidden, failed, output}; score is the share of hidden tests passed."""
252
+ with tempfile.TemporaryDirectory(prefix="tuieval-grade-") as tmp:
253
+ g = os.path.join(tmp, "work")
254
+ shutil.copytree(work, g, ignore=shutil.ignore_patterns(".git", "__pycache__", ".pytest_cache"))
255
+ vis_src = os.path.join(test["task_dir"], "repo", test["visible_tests"])
256
+ vis = os.path.join(g, test["visible_tests"])
257
+ has_visible = os.path.isdir(vis_src)
258
+ if os.path.isdir(vis):
259
+ shutil.rmtree(vis)
260
+ if has_visible:
261
+ shutil.copytree(vis_src, vis)
262
+ shutil.copytree(os.path.join(test["task_dir"], "hidden"), os.path.join(g, "_hidden"))
263
+ xml = os.path.join(tmp, "junit.xml")
264
+ cmd = [python, "-m", "pytest", "-q", "-p", "no:cacheprovider", "-o", "addopts=",
265
+ "--continue-on-collection-errors", f"--junitxml={xml}",
266
+ *([test["visible_tests"]] if has_visible else []), "_hidden"]
267
+ try:
268
+ p = subprocess.run(cmd, cwd=g, env=env_for(python, {}), capture_output=True, text=True,
269
+ timeout=GRADE_TIMEOUT_S)
270
+ except subprocess.TimeoutExpired:
271
+ return {"pass": False, "score": 0.0, "visible": "0/0", "hidden": "0/0", "failed": [],
272
+ "output": f"the tests ran over {GRADE_TIMEOUT_S} s"}
273
+ cases = _cases(xml)
274
+ hidden = {k: v for k, v in cases.items() if HIDDEN_RE.match(k)}
275
+ visible = {k: v for k, v in cases.items() if not HIDDEN_RE.match(k)}
276
+ failed = [k for k, v in cases.items() if v == "fail"]
277
+ passed_hidden = sum(v == "pass" for v in hidden.values())
278
+
279
+ def frac(c):
280
+ return f'{sum(v == "pass" for v in c.values())}/{len(c)}'
281
+ # a module that no longer imports shows as a collection error: a nonzero exit, never a pass
282
+ ok = bool(hidden) and not failed and p.returncode == 0
283
+ return {"pass": ok, "score": round(passed_hidden / len(hidden), 4) if hidden else 0.0,
284
+ "visible": frac(visible), "hidden": frac(hidden), "failed": failed,
285
+ "output": (p.stdout[-6000:] + p.stderr[-2000:]).strip()}
286
+
287
+
288
+ def reason(g, stop, edited, exit_code, stats):
289
+ """One line about a graded task, for the answer's reason."""
290
+ parts = [f"hidden tests {g['hidden']}, visible {g['visible']}"]
291
+ if g["failed"]:
292
+ names = [re.sub(r"^\.?(_hidden|tests)\.", "", f) for f in g["failed"]]
293
+ parts.append("failed: " + ", ".join(names[:6]) + (f" and {len(names) - 6} more" if len(names) > 6 else ""))
294
+ elif not g["pass"] and g["output"]:
295
+ parts.append(g["output"].splitlines()[-1][:200])
296
+ if stop == "time limit":
297
+ parts.append("stopped at the time limit")
298
+ elif stats.get("stop") == "turn limit":
299
+ parts.append("stopped at the turn limit")
300
+ elif stats.get("stop") == "error":
301
+ parts.append("the agent stopped with an error")
302
+ elif exit_code not in (0, None) and not stats:
303
+ parts.append(f"the agent exited with code {exit_code}")
304
+ if edited:
305
+ parts.append("edited the tests: " + ", ".join(edited))
306
+ return "; ".join(parts)
307
+
308
+
309
+ def check_task(test, python):
310
+ """Selftest errors for one task: the untouched repo must fail, solution/ must pass, each
311
+ wrong/<name>/ must fail. (A task without solution/ is only checked to fail untouched.)"""
312
+ errors = []
313
+ with tempfile.TemporaryDirectory(prefix="tuieval-selftest-") as tmp:
314
+ def variant(name, overlay_dir=None):
315
+ work = os.path.join(tmp, name)
316
+ shutil.copytree(os.path.join(test["task_dir"], "repo"), work,
317
+ ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache", ".git"))
318
+ if overlay_dir:
319
+ overlay(overlay_dir, work)
320
+ return grade(test, work, python)
321
+ if variant("base")["pass"]:
322
+ errors.append(f"{test['id']}: the untouched repo already passes its tests")
323
+ sol = os.path.join(test["task_dir"], "solution")
324
+ if os.path.isdir(sol):
325
+ g = variant("solution", sol)
326
+ if not g["pass"]:
327
+ errors.append(f"{test['id']}: solution/ fails: {g['failed'][:4] or g['output'][-300:]}")
328
+ wrong = os.path.join(test["task_dir"], "wrong")
329
+ for w in sorted(os.listdir(wrong)) if os.path.isdir(wrong) else []:
330
+ if os.path.isdir(os.path.join(wrong, w)) and variant(f"wrong-{w}", os.path.join(wrong, w))["pass"]:
331
+ errors.append(f"{test['id']}: wrong/{w} passes, so the tests can't catch that mistake")
332
+ return errors
333
+
334
+
335
+ def overlay(src, dst):
336
+ """Copy src's files over dst; a .delete file in src lists paths (one per line) to remove from dst."""
337
+ for dirpath, dirnames, files in os.walk(src):
338
+ dirnames[:] = [d for d in dirnames if d != "__pycache__"]
339
+ for fn in files:
340
+ if fn == ".delete":
341
+ continue
342
+ rel = os.path.relpath(os.path.join(dirpath, fn), src)
343
+ os.makedirs(os.path.dirname(os.path.join(dst, rel)), exist_ok=True)
344
+ shutil.copy2(os.path.join(dirpath, fn), os.path.join(dst, rel))
345
+ listing = os.path.join(src, ".delete")
346
+ if os.path.isfile(listing):
347
+ with open(listing) as f:
348
+ for line in f.read().split():
349
+ target = os.path.join(dst, line)
350
+ if os.path.isdir(target):
351
+ shutil.rmtree(target)
352
+ elif os.path.exists(target):
353
+ os.remove(target)
@@ -153,7 +153,7 @@ def stream_chat(base_url, body, on_delta=None, timeout=1800, stream=None, header
153
153
  for raw in r:
154
154
  now = time.time()
155
155
  if now - start > timeout:
156
- out["error"] = f"connection error: timed out after {timeout:.0f} s in all"
156
+ out["error"] = f"time limit: timed out after {timeout:.0f} s in all"
157
157
  break
158
158
  if stall_s and out["ttft_s"] is not None and now - last > stall_s:
159
159
  out["error"] = _stalled(stall_s)
@@ -49,7 +49,8 @@ def _load_native(data):
49
49
  "model": m["label"], "suite": pack["name"], "test": f"{pack['name']}: {r['test']}",
50
50
  "name": f"{pack['name']}: {r['description']}",
51
51
  "ok": bool(r["pass"]), "score": float(r["score"]), "reason": r["reason"],
52
- "error": r["reason"].startswith(("server returned", "connection error")),
52
+ # a timeout is the model too slow (client.is_server_error), not an error; older results called it one
53
+ "error": r["reason"].startswith(("server returned", "connection error")) and "timed out" not in r["reason"],
53
54
  "truncated": r.get("finish") == "length", "output": r.get("answer") or "",
54
55
  # tokens past the max_tokens sent, which the provider didn't stop (client.mark_over_budget)
55
56
  "over_budget": r.get("over_budget"),
@@ -63,6 +64,7 @@ def _load_native(data):
63
64
  "machine": r.get("machine") or run.get("machine"), "prompt_tokens": r.get("prompt_tokens"),
64
65
  # other models served at the same time (parallel_models): its speed was shared with them
65
66
  "alongside": r.get("ran_alongside") or [],
67
+ "agent": r.get("agent"), # an agent pack's task: the agent's stats (see agents.py)
66
68
  })
67
69
  return rows, info
68
70
 
@@ -573,6 +575,34 @@ def items(rows):
573
575
  return out
574
576
 
575
577
 
578
+ def agent_table(rows):
579
+ """(header, table) summing up agent-pack tasks per model and pack, or None without any. A pack run
580
+ by more than one agent version is marked, since a new version can change how models do."""
581
+ groups = collections.defaultdict(list)
582
+ for r in rows:
583
+ if r.get("agent"):
584
+ groups[(r["model"], r["suite"])].append(r)
585
+ if not groups:
586
+ return None
587
+ header = ["model", "pack", "passed", "med min", "med turns", "out tokens", "tool errors", "ran tests",
588
+ "limits hit", "tests edited", "agent"]
589
+ table = []
590
+ for (model, suite), rs in sorted(groups.items()):
591
+ a = [r["agent"] for r in rs]
592
+ turns = [x["turns"] for x in a if x.get("turns") is not None]
593
+ out_tokens = sum(x.get("output_tokens") or 0 for x in a)
594
+ versions = sorted({x.get("version") or "?" for x in a})
595
+ table.append([model, suite, f"{sum(r['ok'] for r in rs)}/{len(rs)}",
596
+ f"{median([r['latency'] / 60000 for r in rs]):.1f}", f"{median(turns):.0f}" if turns else "-",
597
+ f"{out_tokens:,}" if out_tokens else "-",
598
+ str(sum(x.get("tool_errors") or 0 for x in a)),
599
+ f"{sum(bool(x.get('ran_tests')) for x in a)}/{len(a)}",
600
+ str(sum(x.get("stop") in ("time limit", "turn limit") for x in a)),
601
+ str(sum(bool(x.get("tests_edited")) for x in a)),
602
+ " + ".join(versions) + (" (MIXED)" if len(versions) > 1 else "")])
603
+ return header, table
604
+
605
+
576
606
  def print_table(header, table):
577
607
  widths = [max(len(str(x)) for x in col) for col in zip(header, *table)]
578
608
  fmt = " ".join("{:<%d}" % w for w in widths)
@@ -629,6 +659,10 @@ def main(argv):
629
659
  if any(r.get("difficulty") in LEVELS for r in rows):
630
660
  print("\nPass rate by difficulty:")
631
661
  print_table(header, table)
662
+ agents_summary = agent_table(rows)
663
+ if agents_summary:
664
+ print("\nAgent tasks (agent packs; MIXED = answers from more than one agent version):")
665
+ print_table(*agents_summary)
632
666
  machine = argv[argv.index("--machine") + 1] if "--machine" in argv else None
633
667
  if "--speed" in flags or machine:
634
668
  speeds = {}