turnloop 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: turnloop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Pranesh-2005/turnloop
|
|
6
|
+
Author: Pranesh
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: agent,cli,coding-agent,harness,llm,tool-use
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: anyio>=4.2
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: pydantic>=2.7
|
|
20
|
+
Requires-Dist: pyyaml>=6.0
|
|
21
|
+
Requires-Dist: textual>=0.80
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-textual-snapshot>=1.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# turnloop
|
|
31
|
+
|
|
32
|
+
A local-first agentic coding harness. Streaming tool loop, permission gating,
|
|
33
|
+
context compaction, subagents, MCP, hooks — and an experiments layer that measures
|
|
34
|
+
whether any of it actually helps.
|
|
35
|
+
|
|
36
|
+
Runs against Anthropic, OpenAI, Gemini, Groq, NVIDIA NIM, Ollama, any
|
|
37
|
+
OpenAI-compatible endpoint, and a **self-hosted GLM-5.2 (744B MoE, W4A16) on 4×H200**
|
|
38
|
+
that this project treats as a first-class target rather than an afterthought.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install -e .
|
|
42
|
+
|
|
43
|
+
tl # TUI, mock provider, zero cost
|
|
44
|
+
tl -p "fix the failing test" # headless, one turn, prints and exits
|
|
45
|
+
tl --provider glm # the self-hosted endpoint
|
|
46
|
+
tl doctor # diagnose shell, providers, context budget
|
|
47
|
+
tl experiment run turnloop/experiments/configs/smoke.yaml
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
|
|
51
|
+
SDKs, no agent framework — the loop is the point, so the loop is written here.
|
|
52
|
+
~18,000 lines of Python, 230 tests, no network in the default test run.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Table of contents
|
|
57
|
+
|
|
58
|
+
- [Why it exists](#why-it-exists)
|
|
59
|
+
- [Install](#install)
|
|
60
|
+
- [Using it](#using-it)
|
|
61
|
+
- [Architecture](#architecture)
|
|
62
|
+
- [Providers](#providers)
|
|
63
|
+
- [Adding a provider](#adding-a-provider)
|
|
64
|
+
- [The self-hosted GLM-5.2 target](#the-self-hosted-glm-52-target)
|
|
65
|
+
- [Experiments](#experiments)
|
|
66
|
+
- [Results](#results)
|
|
67
|
+
- [What the measurements changed](#what-the-measurements-changed)
|
|
68
|
+
- [Designing tasks that measure something](#designing-tasks-that-measure-something)
|
|
69
|
+
- [Bugs worth reading about](#bugs-worth-reading-about)
|
|
70
|
+
- [Windows notes](#windows-notes)
|
|
71
|
+
- [Testing](#testing)
|
|
72
|
+
- [Configuration reference](#configuration-reference)
|
|
73
|
+
- [Limitations](#limitations)
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Why it exists
|
|
78
|
+
|
|
79
|
+
Agentic coding tools are mostly judged by anecdote. This one is built so its own
|
|
80
|
+
design decisions can be measured: swap the compaction strategy, the tool-description
|
|
81
|
+
verbosity, the system prompt or the whole model, run the same fifteen tasks, and read
|
|
82
|
+
the pass rate against the token cost.
|
|
83
|
+
|
|
84
|
+
That measurement layer is not decoration. It found that two of this project's own
|
|
85
|
+
assumptions were wrong — see [Results](#results) — and one of the bugs it surfaced had
|
|
86
|
+
been silently corrupting every experiment for the life of the repository.
|
|
87
|
+
|
|
88
|
+
It is also a clean-room implementation. Nothing here is derived from any leaked or
|
|
89
|
+
proprietary source; the architecture is designed from first principles and the
|
|
90
|
+
comments say *why* each decision went the way it did, including the ones that cost
|
|
91
|
+
something.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Install
|
|
96
|
+
|
|
97
|
+
Requires Python ≥ 3.11.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
git clone https://github.com/Pranesh-2005/turnloop
|
|
101
|
+
cd turnloop
|
|
102
|
+
pip install -e . # editable: source edits take effect immediately
|
|
103
|
+
pip install -e ".[dev]" # plus pytest, ruff, mypy
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Installs two console scripts, `turnloop` and `tl`. They are the same entry point.
|
|
107
|
+
|
|
108
|
+
Verify:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
tl --version # turnloop 0.1.0
|
|
112
|
+
tl doctor # every provider, key presence, shell, context budget
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`doctor` is the first thing to run on a new machine. It reports which API key
|
|
116
|
+
*names* were found (never their values), which shell it will use, and the exact
|
|
117
|
+
context arithmetic for the active provider:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
+ context budget 32,000 window − 4,096 output − 835 system − 3,365 tools = 23,704 for history
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Using it
|
|
126
|
+
|
|
127
|
+
### Launch modes
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
tl # interactive TUI
|
|
131
|
+
tl -p "why does the login test flake" # headless single turn
|
|
132
|
+
tl -p "..." --json # headless, JSONL events on stdout
|
|
133
|
+
tl --resume # resume the most recent session
|
|
134
|
+
tl --resume <session-id> # resume a specific one
|
|
135
|
+
tl sessions # list recorded sessions
|
|
136
|
+
tl config # merged config + which layer each value came from
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Flags
|
|
140
|
+
|
|
141
|
+
| flag | meaning |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `--provider NAME` | which configured provider to use (default `mock`) |
|
|
144
|
+
| `--model NAME` | override that provider's model |
|
|
145
|
+
| `--permission-mode MODE` | `default` · `plan` · `auto` · `bypass` |
|
|
146
|
+
| `--cwd PATH` | working directory the agent operates in |
|
|
147
|
+
| `--max-iterations N` | tool-loop safety cap (default 40) |
|
|
148
|
+
| `-p, --print PROMPT` | headless single turn |
|
|
149
|
+
| `--json` | headless output as JSONL events |
|
|
150
|
+
| `--resume [ID]` | resume last, or a named session |
|
|
151
|
+
|
|
152
|
+
### Permission modes
|
|
153
|
+
|
|
154
|
+
| mode | behavior |
|
|
155
|
+
|---|---|
|
|
156
|
+
| `default` | every write and shell call asks. The manual mode. |
|
|
157
|
+
| `plan` | read-only, enforced three independent ways |
|
|
158
|
+
| `auto` | no prompts inside `--cwd`; still asks for anything outside it |
|
|
159
|
+
| `bypass` | no gate — deny rules still apply |
|
|
160
|
+
|
|
161
|
+
Set at launch, or switch mid-session with `/default`, `/plan`, `/auto`, or **Ctrl+P**
|
|
162
|
+
to cycle. Make it permanent via `TURNLOOP_PERMISSION_MODE` or `settings.json`.
|
|
163
|
+
|
|
164
|
+
> **Running inside VS Code's terminal?** VS Code claims `Ctrl+P` for Quick Open before
|
|
165
|
+
> turnloop ever sees the key. Use the slash commands, or add
|
|
166
|
+
> `"terminal.integrated.commandsToSkipShell": ["-workbench.action.quickOpen"]` to your
|
|
167
|
+
> VS Code settings to pass it through.
|
|
168
|
+
|
|
169
|
+
### Keys
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
ctrl+c interrupt the running turn (again to exit)
|
|
173
|
+
ctrl+p cycle permission mode
|
|
174
|
+
ctrl+l clear the view (history is kept)
|
|
175
|
+
ctrl+d quit
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`ctrl+c` and friends are bound with `priority=True` — the prompt Input always has
|
|
179
|
+
focus and would otherwise swallow them.
|
|
180
|
+
|
|
181
|
+
### Slash commands
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
/help list commands /permissions show rules and mode
|
|
185
|
+
/clear clear view, keep history /plan /auto /default switch mode
|
|
186
|
+
/compact summarize conversation now /memory discovered memory files
|
|
187
|
+
/cost token and cost accounting /tools available tools
|
|
188
|
+
/context what is filling the window /mcp MCP server status
|
|
189
|
+
/model show or change model /hooks configured hooks
|
|
190
|
+
/provider switch provider /sessions recorded sessions
|
|
191
|
+
/export transcript to markdown /doctor diagnostics
|
|
192
|
+
/quit exit
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Custom commands live in `.turnloop/commands/*.md`.
|
|
196
|
+
|
|
197
|
+
### What you can feed it
|
|
198
|
+
|
|
199
|
+
- **Prose** — "the checkout total is wrong for orders over $500, find out why"
|
|
200
|
+
- **`@path/to/file`** — inlines that file into the prompt
|
|
201
|
+
- **Shell output** — `` !`git diff` `` inside a command template, routed through the
|
|
202
|
+
permission engine like any other Bash call
|
|
203
|
+
- **`TURNLOOP.md`** at the project root for standing instructions (`CLAUDE.md` and
|
|
204
|
+
`AGENTS.md` are also read)
|
|
205
|
+
- **Text files of any kind** via the `Read` tool — source, JSON, YAML, CSV, Markdown
|
|
206
|
+
|
|
207
|
+
**Not supported:** images (there is no image content block in the message model —
|
|
208
|
+
only `TextBlock`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`) and binary
|
|
209
|
+
files (refused by a null-byte sniff, which covers PDFs, `.docx`, archives and
|
|
210
|
+
images-on-disk). Anything convertible on the command line comes back in scope,
|
|
211
|
+
because `Bash` exists: `pdftotext spec.pdf -` and the agent will reach for it itself.
|
|
212
|
+
|
|
213
|
+
### Tools
|
|
214
|
+
|
|
215
|
+
Ten registered: `Read` `Write` `Edit` `Glob` `Grep` `Bash` `TodoWrite` `Task`
|
|
216
|
+
`WebFetch` `AskUserQuestion`. Schemas are generated from pydantic models, so the
|
|
217
|
+
declared schema and the validation are the same object and cannot drift.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Architecture
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
turnloop/
|
|
225
|
+
core/ messages, events, token estimation, ids
|
|
226
|
+
providers/ anthropic · openai_compat (GLM/Groq/NVIDIA/vLLM) · gemini · mock
|
|
227
|
+
+ sse parser, pricing/capability presets, registry
|
|
228
|
+
tools/ Read Edit Write Bash Glob Grep TodoWrite Task WebFetch Ask Skill
|
|
229
|
+
+ runner, shell scanner, text io
|
|
230
|
+
permissions/ rule grammar, matcher, engine
|
|
231
|
+
agent/ loop, system prompt, subagents, factory, headless
|
|
232
|
+
context/ budget, three-tier compaction, memory files
|
|
233
|
+
sessions/ append-only JSONL, resume
|
|
234
|
+
tui/ Textual app, streaming transcript, permission modal, status bar
|
|
235
|
+
mcp/ JSON-RPC client (stdio + SSE), schema→pydantic adapter
|
|
236
|
+
hooks/ settings.json lifecycle hooks
|
|
237
|
+
commands/ slash commands and skills
|
|
238
|
+
experiments/ runner, graders, 15-task suite, report renderer
|
|
239
|
+
diagnostics.py the doctor
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### The loop
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
compact if needed → stream a turn → run any requested tools → append results → repeat
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Four decisions worth defending:
|
|
249
|
+
|
|
250
|
+
**Compaction runs before every request, not on a timer.** Pressure is a property of
|
|
251
|
+
what just happened, and one large tool result can cross two thresholds in a single
|
|
252
|
+
turn.
|
|
253
|
+
|
|
254
|
+
**Tools run in parallel only when every tool is `parallel_safe` and none needs a
|
|
255
|
+
permission prompt.** Two concurrent edits to one file, or two modals racing for the
|
|
256
|
+
screen, are both worse than being slow.
|
|
257
|
+
|
|
258
|
+
**A truncated stream never leaves a partial assistant message in history.** An
|
|
259
|
+
assistant turn containing a `tool_use` with no matching `tool_result` is a hard 400
|
|
260
|
+
from every provider, and it would poison every later request in the session — not
|
|
261
|
+
just the one that failed.
|
|
262
|
+
|
|
263
|
+
**The iteration cap injects a message instead of returning silently.** A loop that
|
|
264
|
+
stops at turn 40 with no explanation looks like a crash.
|
|
265
|
+
|
|
266
|
+
### Provider layer
|
|
267
|
+
|
|
268
|
+
One `httpx.AsyncClient` and a 40-line SSE parser serve every backend. That is not
|
|
269
|
+
purism — it solves three concrete problems:
|
|
270
|
+
|
|
271
|
+
- GLM's reasoning arrives as `reasoning_content`, a sibling of `content`. With the
|
|
272
|
+
raw dict there is nothing to fight; through a typed SDK it hides in `model_extra`.
|
|
273
|
+
- We own the timeout. The `openai` SDK's 600-second default **fails** on a genuine
|
|
274
|
+
GLM cold boot, which takes longer than that.
|
|
275
|
+
- Each adapter becomes a pure `dict → StreamEvent` function, tested against recorded
|
|
276
|
+
`.sse` fixtures with no network at all.
|
|
277
|
+
|
|
278
|
+
Capabilities are declared per model (`max_context`, `max_output`,
|
|
279
|
+
`supports_prompt_caching`, `supports_reasoning_field`, `native_thinking`,
|
|
280
|
+
`max_concurrent_requests`, per-token pricing, `cost_per_hour`) and the *loop* decides
|
|
281
|
+
how to degrade — so fallback logic exists once rather than once per adapter.
|
|
282
|
+
|
|
283
|
+
Streaming tool-call shapes differ by vendor and both are handled: NVIDIA NIM sends
|
|
284
|
+
each call **whole in one delta** (id, name, complete arguments JSON); vLLM's `glm47`
|
|
285
|
+
parser and OpenAI **fragment** arguments across deltas, splitting mid-token
|
|
286
|
+
(`{"file_p` / `ath": "README.md"}`). The accumulator is tested against both.
|
|
287
|
+
|
|
288
|
+
### Permissions
|
|
289
|
+
|
|
290
|
+
Rules read `Tool` or `Tool(pattern)`:
|
|
291
|
+
|
|
292
|
+
```json
|
|
293
|
+
{
|
|
294
|
+
"permissions": {
|
|
295
|
+
"allow": ["Bash(git status)", "Bash(npm test)", "Edit(src/**)"],
|
|
296
|
+
"deny": ["Read(**/.env)", "Bash(git push --force*)"],
|
|
297
|
+
"ask": ["Bash(npm publish*)"]
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Precedence is flat and fixed: deny → plan-mode read-only → bypass → allow → ask →
|
|
303
|
+
mode default. No specificity scoring, because a permission system nobody can predict
|
|
304
|
+
is a permission system people disable.
|
|
305
|
+
|
|
306
|
+
The single most important behavior: **a compound shell command is allowed only if
|
|
307
|
+
every segment is allowed.** Without that, `Bash(git *)` grants
|
|
308
|
+
`git status && rm -rf /`. Splitting on top-level `;`/`&&`/`||`/`|` needs a real
|
|
309
|
+
scanner that respects quoting — `shlex` discards operators, so the `rm` comes back
|
|
310
|
+
looking like an argument to `git`. 48 tests cover this file alone.
|
|
311
|
+
|
|
312
|
+
Ten deny rules ship by default, including `Read(**/.env)`.
|
|
313
|
+
|
|
314
|
+
### Context compaction
|
|
315
|
+
|
|
316
|
+
Three escalating tiers, checked before every request:
|
|
317
|
+
|
|
318
|
+
| tier | cost | trigger | what it does |
|
|
319
|
+
|---|---|---|---|
|
|
320
|
+
| micro | free | always | old oversized tool results → head+tail; superseded file reads collapse |
|
|
321
|
+
| thinking drop | free | 60% pressure | reasoning removed outside the last two turns |
|
|
322
|
+
| summarize | one model call | 75% pressure | prefix → structured summary + verbatim todos + file manifest |
|
|
323
|
+
|
|
324
|
+
Tiers 1 and 2 typically reclaim 30–50% of a tool-heavy session, which is why they run
|
|
325
|
+
first: a summarization call that could have been avoided costs money, latency and
|
|
326
|
+
fidelity.
|
|
327
|
+
|
|
328
|
+
The invariant, with its own property test over 200 random histories: **a `tool_use`
|
|
329
|
+
block and its `tool_result` are never separated.** An orphan is a hard 400 from every
|
|
330
|
+
provider, and it happens exactly when context is already tight.
|
|
331
|
+
|
|
332
|
+
Token estimation self-calibrates. `len/3.6` seeded, then corrected by EWMA against
|
|
333
|
+
each response's reported `input_tokens`. No `tiktoken`: a flat 4-chars-per-token guess
|
|
334
|
+
is ~20% off on GLM's tokenizer, and 20% of 65k is 13k tokens of headroom either
|
|
335
|
+
wasted or blown through.
|
|
336
|
+
|
|
337
|
+
### Subagents
|
|
338
|
+
|
|
339
|
+
Isolation is structural, not disciplinary. A child gets a fresh session, a filtered
|
|
340
|
+
registry (no `Task`, no `AskUserQuestion`), `depth=1`, and **the same permission
|
|
341
|
+
engine** — permissions belong to the user, so delegation must not be a privilege
|
|
342
|
+
escalation path. Only the child's final message returns to the parent; its
|
|
343
|
+
intermediate tool calls never enter parent context, which is the entire economic
|
|
344
|
+
argument for delegating.
|
|
345
|
+
|
|
346
|
+
### Sessions
|
|
347
|
+
|
|
348
|
+
Append-only JSONL, one file per session. Resume replays it. Append-only matters
|
|
349
|
+
because a crashed run still leaves a readable trace, and because the experiments
|
|
350
|
+
layer reads exactly the same format the TUI writes — a run trace and a real session
|
|
351
|
+
are the same artifact.
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Providers
|
|
356
|
+
|
|
357
|
+
All nine ship preconfigured. `doctor` shows which have keys present.
|
|
358
|
+
|
|
359
|
+
| name | kind | model | auth | notes |
|
|
360
|
+
|---|---|---|---|---|
|
|
361
|
+
| `mock` | mock | `mock-1` | none | default. scripted / replay / chaos modes |
|
|
362
|
+
| `glm` | openai_compat | `glm-5.2` | **none** | self-hosted vLLM on Modal, 65k, $18.16/hr |
|
|
363
|
+
| `nvidia` | openai_compat | `z-ai/glm-5.2` | `NVIDIA_API_KEY` | hosted GLM-5.2, ≥200k context |
|
|
364
|
+
| `groq` | openai_compat | `openai/gpt-oss-120b` | `GROQ_API_KEY` | free tier: 8k TPM |
|
|
365
|
+
| `anthropic` | anthropic | `claude-sonnet-4-5` | `ANTHROPIC_API_KEY` | |
|
|
366
|
+
| `openai` | openai_compat | `gpt-4.1` | `OPENAI_API_KEY` | |
|
|
367
|
+
| `gemini` | gemini | `gemini-2.5-pro` | `GEMINI_API_KEY` | |
|
|
368
|
+
| `blaxel` | openai_compat | `gpt-4o-mini` | `BL_API_KEY` | sandbox; ignores the model field |
|
|
369
|
+
| `ollama` | openai_compat | `qwen2.5-coder:14b` | none | local |
|
|
370
|
+
|
|
371
|
+
Three provider facts that are configuration rather than capability, each learned from
|
|
372
|
+
a live failure:
|
|
373
|
+
|
|
374
|
+
**Groq's free tier counts the *requested* `max_tokens` against an 8,000 TPM quota.**
|
|
375
|
+
A 4k prompt asking for the model's real 32k output is a 35k request → hard `413` with
|
|
376
|
+
`code: rate_limit_exceeded` before a single token generates. The preset therefore sets
|
|
377
|
+
`max_context: 7000`, `max_output: 1500` and terse tool descriptions, leaving ~2,400
|
|
378
|
+
tokens for history. Raise both on a paid tier. A 413 carrying `rate_limit` is
|
|
379
|
+
classified retryable, not fatal. Groq also validates tool names server-side: a model
|
|
380
|
+
emitting `glob` instead of `Glob` gets a 400 rather than a recoverable tool result.
|
|
381
|
+
|
|
382
|
+
**NVIDIA NIM serves GLM-5.2 at ≥200,000 tokens of context** — a 200,010-token prompt
|
|
383
|
+
was accepted and answered. Thinking is *off* by default there; `reasoning_effort` is
|
|
384
|
+
silently ignored and `{"chat_template_kwargs": {"thinking": {"type": "enabled"}}}` is
|
|
385
|
+
what turns it on. Its free tier is unusable for sweeps: an 84-run experiment died
|
|
386
|
+
84/84 on HTTP 429, and the quota is **token**-based (~156k input tokens per window),
|
|
387
|
+
not request-based. The endpoint exposes no `Retry-After` and no `x-ratelimit-*`
|
|
388
|
+
headers, so backoff has nothing to key on. `max_concurrent_requests: 1`. Fine for
|
|
389
|
+
smoke tests, not for experiments.
|
|
390
|
+
|
|
391
|
+
**Capability presets fall back to substring matching, and that is a live trap.**
|
|
392
|
+
`"glm-5.2" in "z-ai/glm-5.2"` is `True`, so without an explicit `PRESETS` key the
|
|
393
|
+
NVIDIA model would silently inherit the self-hosted 65k window and $18.16/hr cost
|
|
394
|
+
basis. A regression test guards it.
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## Adding a provider
|
|
399
|
+
|
|
400
|
+
### If it speaks OpenAI chat-completions: config only, no code
|
|
401
|
+
|
|
402
|
+
OpenRouter, Together, Fireworks, DeepSeek, Mistral, xAI, Groq, LM Studio, vLLM,
|
|
403
|
+
llama.cpp, LocalAI — all of these are a `settings.json` entry. Drop it in
|
|
404
|
+
`~/.turnloop/settings.json` to have it everywhere, `<project>/.turnloop/settings.json`
|
|
405
|
+
to share it with the repo, or `.turnloop/settings.local.json` to keep it out of git.
|
|
406
|
+
|
|
407
|
+
```json
|
|
408
|
+
{
|
|
409
|
+
"providers": {
|
|
410
|
+
"openrouter": {
|
|
411
|
+
"kind": "openai_compat",
|
|
412
|
+
"model": "anthropic/claude-sonnet-4.5",
|
|
413
|
+
"base_url": "https://openrouter.ai/api/v1",
|
|
414
|
+
"api_key_env": "OPENROUTER_API_KEY",
|
|
415
|
+
"headers": {
|
|
416
|
+
"HTTP-Referer": "https://github.com/you/yourproject",
|
|
417
|
+
"X-Title": "turnloop"
|
|
418
|
+
},
|
|
419
|
+
"caps": {
|
|
420
|
+
"max_context": 200000,
|
|
421
|
+
"max_output": 32000,
|
|
422
|
+
"price_in_per_mtok": 3.0,
|
|
423
|
+
"price_out_per_mtok": 15.0,
|
|
424
|
+
"max_concurrent_requests": 8
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
```bash
|
|
432
|
+
export OPENROUTER_API_KEY=... # or put it in .env at the project root
|
|
433
|
+
tl --provider openrouter
|
|
434
|
+
tl doctor # confirms it registered and the key is present
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
`providers` is a dict and config layers merge recursively, so a new key is added
|
|
438
|
+
without restating the nine that ship.
|
|
439
|
+
|
|
440
|
+
### The fields
|
|
441
|
+
|
|
442
|
+
| field | when you need it |
|
|
443
|
+
|---|---|
|
|
444
|
+
| `kind` | `openai_compat` · `anthropic` · `gemini` · `mock` |
|
|
445
|
+
| `model` | exactly the id the endpoint expects |
|
|
446
|
+
| `base_url` | required for anything but the built-in defaults |
|
|
447
|
+
| `api_key_env` | the *name* of the env var. Never put a key in a committed file |
|
|
448
|
+
| `headers` | vendor attribution, org routing, custom auth schemes |
|
|
449
|
+
| `timeout_s` | `null` means no read timeout — only for endpoints that cold-boot |
|
|
450
|
+
| `health_url` / `cold_boot_budget_s` | preflight polling for a scale-to-zero endpoint |
|
|
451
|
+
| `extra_body` | merged into the request JSON: reasoning toggles, routing preferences |
|
|
452
|
+
| `glm_reasoning` | read `reasoning_content` as a sibling of `content` |
|
|
453
|
+
| `tool_verbosity` | `terse` on a tight window — measured −20% input tokens |
|
|
454
|
+
| `caps.max_context` | drives compaction thresholds. Get this one right |
|
|
455
|
+
| `caps.max_concurrent_requests` | builds a process-wide `CapacityLimiter` so subagent fan-out cannot queue behind itself |
|
|
456
|
+
| `caps.price_*_per_mtok` | cost column only. Wrong numbers skew reporting, never behavior |
|
|
457
|
+
|
|
458
|
+
`extra_body` is the escape hatch for provider-specific knobs. OpenRouter routing:
|
|
459
|
+
|
|
460
|
+
```json
|
|
461
|
+
"extra_body": { "provider": { "order": ["Anthropic"], "allow_fallbacks": false } }
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
...and it is exactly how thinking is enabled on NVIDIA NIM, where
|
|
465
|
+
`reasoning_effort` is silently ignored:
|
|
466
|
+
|
|
467
|
+
```json
|
|
468
|
+
"extra_body": { "chat_template_kwargs": { "thinking": { "type": "enabled" } } }
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
> **Declare `caps` explicitly.** Omitting it falls back to `preset_for(model)`, which
|
|
472
|
+
> does substring matching — `"glm-5.2" in "z-ai/glm-5.2"` is `True`. That is how a
|
|
473
|
+
> hosted endpoint once silently inherited a 65k window and an $18.16/hour cost basis
|
|
474
|
+
> from an unrelated self-hosted deployment. If your model id contains a known model's
|
|
475
|
+
> name, spell out `caps`.
|
|
476
|
+
|
|
477
|
+
### If it speaks something else: one adapter, ~150 lines
|
|
478
|
+
|
|
479
|
+
`kind` is a closed set, so a genuinely different wire format — Bedrock's SigV4
|
|
480
|
+
signing, Vertex's auth, Cohere's schema — needs code. It is a small, well-fenced job:
|
|
481
|
+
|
|
482
|
+
1. Add `providers/yourvendor.py` with a `feed(chunk: dict) -> list[StreamEvent]`
|
|
483
|
+
accumulator, a `headers()` method, and request-body shaping.
|
|
484
|
+
2. Register it in `providers/registry.py` and add the literal to `ProviderConfig.kind`.
|
|
485
|
+
3. Record a `.sse` fixture and test against it — **no network**.
|
|
486
|
+
|
|
487
|
+
The adapter stays a pure `dict → StreamEvent` function; everything else (retries,
|
|
488
|
+
timeouts, cold-boot classification, capacity limiting, cost accounting) already lives
|
|
489
|
+
in the base class and the loop. Two things the fixture should cover, because both are
|
|
490
|
+
easy to get wrong and neither shows up until production: reasoning arriving alongside
|
|
491
|
+
content, and tool-call arguments **fragmented mid-token** across deltas
|
|
492
|
+
(`{"file_p` / `ath": "README.md"}`) — NVIDIA sends each call whole in one delta,
|
|
493
|
+
vLLM and OpenAI split them.
|
|
494
|
+
|
|
495
|
+
---
|
|
496
|
+
|
|
497
|
+
## The self-hosted GLM-5.2 target
|
|
498
|
+
|
|
499
|
+
Every non-obvious setting is a fact about that deployment rather than a preference:
|
|
500
|
+
|
|
501
|
+
| | |
|
|
502
|
+
|---|---|
|
|
503
|
+
| endpoint | stock `vllm serve` 0.25.1, OpenAI-compatible, **unauthenticated** |
|
|
504
|
+
| model id | `glm-5.2`, `--tool-call-parser glm47`, `--reasoning-parser glm45` |
|
|
505
|
+
| weights | `/models/GLM-5.2-W4A16`, 744B MoE quantized |
|
|
506
|
+
| context | **65,536 tokens**, prompt + completion combined |
|
|
507
|
+
| concurrency | `--max-num-seqs 16`, `max_containers=1` |
|
|
508
|
+
| cost | **$18.16/hour** of wall clock, 4×H200 |
|
|
509
|
+
| cold boot | ~29 min cold, **12m34s measured** with a warm compile cache |
|
|
510
|
+
| scaledown | `scaledown_window=600` — 10 idle minutes, then the container is released |
|
|
511
|
+
|
|
512
|
+
Deploying it:
|
|
513
|
+
|
|
514
|
+
```bash
|
|
515
|
+
cd zai-glm5.2-modal
|
|
516
|
+
modal deploy serve.py # registers the web function in ~6s
|
|
517
|
+
tl --provider glm -p "hello" # first request triggers the boot; poll is narrated
|
|
518
|
+
modal app stop glm-5-2-serve -y # ALWAYS. an idle container is ~$3/hour of nothing
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Three consequences the harness handles explicitly:
|
|
522
|
+
|
|
523
|
+
**The window is small.** After output reserve, system prompt and ten tool schemas,
|
|
524
|
+
~58k remains for history — and a 3,000-line file read is ~30k of it. Micro-compaction
|
|
525
|
+
is a requirement here, not an optimization. Tool descriptions default to `terse` for
|
|
526
|
+
this provider, which buys back ~1,100 tokens; whether that costs pass rate was one of
|
|
527
|
+
the shipped experiments, and the answer is [below](#results).
|
|
528
|
+
|
|
529
|
+
**Cold boot dominates.** `/health` is polled with progress narrated in the UI, the read
|
|
530
|
+
timeout is unbounded while connect stays at 10s (short connect *is* the cold-boot
|
|
531
|
+
signal), and errors are classified into `cold_boot` / `retryable` / `fatal` — so a 502
|
|
532
|
+
from Modal's edge waits patiently while a 400 from a malformed schema fails
|
|
533
|
+
immediately instead of retrying for 55 minutes.
|
|
534
|
+
|
|
535
|
+
A hard-won detail: **`modal app stop` deregisters the app, it does not scale it to
|
|
536
|
+
zero.** A stopped deployment and a cold-booting one are *indistinguishable at the
|
|
537
|
+
transport level* — both accept TCP, then read-timeout. The timeout message therefore
|
|
538
|
+
says so explicitly and tells you to check the deployment, because otherwise you sit
|
|
539
|
+
watching a progress counter tick toward a 55-minute budget against a URL that will
|
|
540
|
+
never answer. Ask how that was discovered.
|
|
541
|
+
|
|
542
|
+
**It bills by the hour.** The status bar shows GPU uptime and running cost in amber,
|
|
543
|
+
and exiting prints `modal app stop glm-5-2-serve`. Note that in-app cost accounting
|
|
544
|
+
covers wall clock *across requests* and excludes boot time — the Modal invoice counts
|
|
545
|
+
the 12 minutes of loading weights, so the two numbers do not match by design.
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
## Experiments
|
|
550
|
+
|
|
551
|
+
```bash
|
|
552
|
+
tl experiment run turnloop/experiments/configs/smoke.yaml # free, offline
|
|
553
|
+
tl experiment run turnloop/experiments/configs/bakeoff.yaml # real models
|
|
554
|
+
tl experiment report .turnloop/experiments/<run-dir>
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
Thirteen configs ship, in four families, one runner:
|
|
558
|
+
|
|
559
|
+
- **`bakeoff.yaml`** — same tasks across GLM-5.2, Claude, GPT, Gemini, Groq.
|
|
560
|
+
- **`context.yaml` / `context-glm.yaml`** — ablate tool verbosity, compaction strategy
|
|
561
|
+
and system prompt variant on one model, so the model is not the variable. The
|
|
562
|
+
`minimal-prompt` arm measures how much of a coding agent's competence comes from the
|
|
563
|
+
harness rather than the weights.
|
|
564
|
+
- **`loop.yaml`** — single agent vs subagent fan-out vs plan-then-execute.
|
|
565
|
+
- **`reliability.yaml`** — malformed-args and schema-violation rates, and the number
|
|
566
|
+
that actually separates models: the **recovery rate** after a rejected call.
|
|
567
|
+
|
|
568
|
+
Plus calibration configs (`hard-calibration`, `rewrite-calibration`, `ceiling-check`)
|
|
569
|
+
used to establish whether a task discriminates at all before spending GPU on it.
|
|
570
|
+
|
|
571
|
+
### The suite
|
|
572
|
+
|
|
573
|
+
Fifteen offline fixture-based tasks, all decided by **programmatic graders** — no LLM
|
|
574
|
+
judge, which would introduce the very variable being measured.
|
|
575
|
+
|
|
576
|
+
| task | fixture | grader | what it probes |
|
|
577
|
+
|---|---|---|---|
|
|
578
|
+
| `fix_failing_test` | `failing_test` | `pytest_passes` | read → edit → verify |
|
|
579
|
+
| `find_the_bug` | `needle` | `answer_contains` | search, read-only |
|
|
580
|
+
| `multi_file_rename` | `rename` | `command_exits_zero` | multi-file consistency |
|
|
581
|
+
| `implement_from_spec` | `spec` | `pytest_passes` | build from a written spec |
|
|
582
|
+
| `add_cli_flag` | `cli_flag` | `command_exits_zero` | feature addition |
|
|
583
|
+
| `grep_then_edit` | `grep_edit` | `command_exits_zero` | search-driven edit |
|
|
584
|
+
| `structural_function` | `util` | `ast_has_function` | structural, not textual, grading |
|
|
585
|
+
| `refuse_outside_root` | — | `no_writes_outside` | **permission safety** |
|
|
586
|
+
| `ambiguous_request` | — | `asked_a_question` | did it clarify, or guess? |
|
|
587
|
+
| `forces_compaction` | `bulky` | `answer_contains` | 133k tokens of fixture |
|
|
588
|
+
| `regression_trap` | `regression_trap` | `pytest_passes` | fix without breaking a sibling |
|
|
589
|
+
| `cross_file_contract` | `cross_file_contract` | `pytest_passes` | encode/decode contract |
|
|
590
|
+
| `subtle_spec_edge` | `subtle_spec_edge` | `pytest_passes` | ordering edge case |
|
|
591
|
+
| `circular_import` | `circular_import` | `pytest_passes` | import cycle |
|
|
592
|
+
| `misleading_traceback` | `misleading_traceback` | `pytest_passes` | symptom ≠ cause |
|
|
593
|
+
|
|
594
|
+
A test asserts no task in the suite is graded by `always_pass` — a task that always
|
|
595
|
+
passes measures nothing, and it will happily report a turn that died on a provider
|
|
596
|
+
error as a success.
|
|
597
|
+
|
|
598
|
+
### Mechanics that keep runs honest
|
|
599
|
+
|
|
600
|
+
Each run writes `config.json`, one full session trace per `(arm, task, repeat)`, and
|
|
601
|
+
`results.jsonl`. The report shows **medians and n**, never a single run: agent
|
|
602
|
+
behavior is heavy-tailed and one 20-turn flail makes a mean meaningless.
|
|
603
|
+
|
|
604
|
+
- Execution is grouped **by provider**, so a scale-to-zero endpoint boots once instead
|
|
605
|
+
of once per arm.
|
|
606
|
+
- Every repeat gets a **fresh fixture copy**, or repeat 2 grades the agent against
|
|
607
|
+
repeat 1's output.
|
|
608
|
+
- **Provider abort threshold.** If the first 3 runs for a provider all end in a
|
|
609
|
+
transport `error`, the rest of that provider's runs are recorded `skipped` rather
|
|
610
|
+
than attempted. This exists because an 84-run sweep once failed 84/84 on HTTP 429
|
|
611
|
+
and exited 0 with a tidy report full of zeros. The predicate is `row.error is not
|
|
612
|
+
None`, so a *grader* failure never trips it — the chaos arm is supposed to fail.
|
|
613
|
+
- **Cost basis is recorded per row** as `tokens` or `wall_clock` and marked with `†`
|
|
614
|
+
in the report. A per-hour GPU and a per-token API are not comparable, and a report
|
|
615
|
+
that puts them in one column will show "+26% tokens, −23% cost" and quietly mean
|
|
616
|
+
nothing.
|
|
617
|
+
- **A 15-minute cold-boot budget** for experiment runs, with preflight progress printed
|
|
618
|
+
every 30s — because a silent preflight against a dead endpoint looks exactly like a
|
|
619
|
+
slow one.
|
|
620
|
+
|
|
621
|
+
### The mock provider is load-bearing
|
|
622
|
+
|
|
623
|
+
Three modes: `scripted` (golden transcripts), `replay` (re-runs a recorded real trace
|
|
624
|
+
at zero cost), and `chaos` — which emits malformed JSON, truncated arguments and
|
|
625
|
+
unknown tool names on purpose. Chaos is how "tool dispatch never raises" gets proven
|
|
626
|
+
rather than asserted:
|
|
627
|
+
|
|
628
|
+
```
|
|
629
|
+
| arm | tool calls | error rate | malformed args | schema violations |
|
|
630
|
+
| mock-chaos | 18 | 100.0% | 3 | 15 |
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
18 deliberately broken calls, every one turned into an error result the model can
|
|
634
|
+
read, no exception surfaced.
|
|
635
|
+
|
|
636
|
+
---
|
|
637
|
+
|
|
638
|
+
## Results
|
|
639
|
+
|
|
640
|
+
All numbers below are from runs in this repository, reproducible from the shipped
|
|
641
|
+
configs. Roughly $20 of GPU time.
|
|
642
|
+
|
|
643
|
+
### Context engineering ablation — 84 runs, 7 arms, GLM-5.2
|
|
644
|
+
|
|
645
|
+
Same model throughout, so the harness is the only variable.
|
|
646
|
+
|
|
647
|
+
All seven arms, 12 runs each, pass rate 100% across the board:
|
|
648
|
+
|
|
649
|
+
| arm | tok in | vs baseline | tok out | tool calls | error rate | median wall |
|
|
650
|
+
|---|---|---|---|---|---|---|
|
|
651
|
+
| baseline | 33,941 | — | 950 | 98 | 4.1% | 33.5s |
|
|
652
|
+
| **`terse-tools`** | **27,104** | **−20%** | 840 | 98 | **0.0%** | **26.1s** |
|
|
653
|
+
| `minimal-prompt` | 29,711 | −12% | 795 | 92 | 5.4% | 30.2s |
|
|
654
|
+
| `careful-prompt` | 35,047 | +3% | 1,128 | 103 | 3.9% | 37.2s |
|
|
655
|
+
| `no-compaction` | 35,028 | +3% | 1,074 | 101 | 5.0% | 34.0s |
|
|
656
|
+
| `micro-only-compaction` | 35,072 | +3% | 1,099 | 104 | 4.8% | 32.9s |
|
|
657
|
+
| `verbose-tools` | 42,680 | +26% | 949 | 95 | 2.1% | 26.5s |
|
|
658
|
+
|
|
659
|
+
**`terse-tools` dominates `verbose-tools` on both axes** — 36% fewer input tokens
|
|
660
|
+
(27,104 vs 42,680) *and* a lower error rate (0.0% vs 2.1%), at the same pass rate and
|
|
661
|
+
the same 98 tool calls. Longer tool descriptions are not buying accuracy here.
|
|
662
|
+
|
|
663
|
+
Against baseline the picture is more interesting than "verbose is bad": verbose-tools
|
|
664
|
+
*did* cut the error rate 2.0pp. It just cost 26% more input to do it, while terse-tools
|
|
665
|
+
cut it 4.1pp while *saving* 20%. Length is not the useful variable; wording is.
|
|
666
|
+
|
|
667
|
+
**`minimal-prompt` buys tokens with reliability.** −12% input tokens and −12% tool
|
|
668
|
+
calls at an identical pass rate, but the error rate rises to 5.4%. That is the
|
|
669
|
+
interesting result: the system prompt is not making the model *smarter*, it is making
|
|
670
|
+
the model's tool calls *better formed*. Competence comes from the weights; protocol
|
|
671
|
+
compliance comes from the harness.
|
|
672
|
+
|
|
673
|
+
**Compaction does what it claims.** On `forces_compaction`, the `no-compaction` arm
|
|
674
|
+
used **272,938** median input tokens against baseline's **196,104** — a 39% penalty
|
|
675
|
+
for turning it off.
|
|
676
|
+
|
|
677
|
+
> **Caveat, stated plainly:** the error-rate deltas rest on small counts (4 errors in
|
|
678
|
+
> 98 calls versus 0). The ordering is suggestive, not established. The token deltas are
|
|
679
|
+
> solid.
|
|
680
|
+
|
|
681
|
+
### Model comparison — 18 runs, 3 hard tasks
|
|
682
|
+
|
|
683
|
+
| | GLM-5.2 (self-hosted) | gpt-4o-mini |
|
|
684
|
+
|---|---|---|
|
|
685
|
+
| pass rate | **100%** (9/9) | 11% (1/9) |
|
|
686
|
+
| tool-call error rate | **2.8%** | 23.3% |
|
|
687
|
+
| **recovery after a failed call** | **100%** | **0%** |
|
|
688
|
+
| median output tokens | **1,521** | 16,430 |
|
|
689
|
+
| median wall clock | **35.2s** | 157.8s |
|
|
690
|
+
| median input tokens | 20,174 | 19,883 |
|
|
691
|
+
|
|
692
|
+
The recovery column is the one that matters and the one nobody reports. Both models
|
|
693
|
+
make mistakes; only one reads the error and adapts. gpt-4o-mini never repairs a failed
|
|
694
|
+
tool call — it repeats it, which is also why its output token count is 10× higher for
|
|
695
|
+
a worse result.
|
|
696
|
+
|
|
697
|
+
Per task: `circular_import` 3/3 vs 0/3 · `cross_file_contract` 3/3 vs 0/3 ·
|
|
698
|
+
`regression_trap` 3/3 vs 1/3.
|
|
699
|
+
|
|
700
|
+
### The result that cost the most to learn
|
|
701
|
+
|
|
702
|
+
**Pass rate is the wrong outcome metric for GLM-5.2 on this suite.** It scored 100% on
|
|
703
|
+
all four original tasks across 84 runs, then 9/9 on five purpose-built "hard" tasks
|
|
704
|
+
with decoy solutions. Every context-engineering ablation therefore ties at the ceiling
|
|
705
|
+
and measures nothing about correctness.
|
|
706
|
+
|
|
707
|
+
Writing harder tasks did not fix it. GLM-5.2 solves realistic multi-file refactors,
|
|
708
|
+
circular-import traps and cross-file contract bugs in ~30 seconds and 5–6 tool calls.
|
|
709
|
+
The falsification cost ~$7 of GPU and the plan was mine.
|
|
710
|
+
|
|
711
|
+
So the suite demotes pass rate to a **gate** — it must be 100%, and the report prints
|
|
712
|
+
a note when every arm hits it — and compares on efficiency and reliability, which have
|
|
713
|
+
real headroom.
|
|
714
|
+
|
|
715
|
+
---
|
|
716
|
+
|
|
717
|
+
## What the measurements changed
|
|
718
|
+
|
|
719
|
+
Concrete changes to this codebase that exist because a run said so:
|
|
720
|
+
|
|
721
|
+
1. **Tool verbosity defaults to `terse`** on constrained providers. Measured −20%
|
|
722
|
+
input tokens at no cost to pass rate.
|
|
723
|
+
2. **Pass rate became a gate, not a metric.** The report emits
|
|
724
|
+
`_pass_rate_gate_note` when every arm hits 100%, so a tie at the ceiling is
|
|
725
|
+
labelled rather than read as a finding.
|
|
726
|
+
3. **`cost_basis` was added to every result row** after a report showed +26% tokens
|
|
727
|
+
and −23% cost in adjacent columns.
|
|
728
|
+
4. **`PROVIDER_ABORT_THRESHOLD = 3`** after 84 runs failed and reported success.
|
|
729
|
+
5. **The efficiency tables exist at all** — tokens in/out, tool calls, error rate,
|
|
730
|
+
recovery, median wall — because the outcome table had stopped discriminating.
|
|
731
|
+
|
|
732
|
+
---
|
|
733
|
+
|
|
734
|
+
## Designing tasks that measure something
|
|
735
|
+
|
|
736
|
+
Two principles, both learned by getting them wrong.
|
|
737
|
+
|
|
738
|
+
**A discriminating task needs a decoy.** There must be a plausible wrong fix that
|
|
739
|
+
makes the stated symptom disappear while still failing the grader. Without one, the
|
|
740
|
+
task measures reading comprehension. Validation is therefore three-state, not two:
|
|
741
|
+
|
|
742
|
+
```
|
|
743
|
+
initial state → FAILS
|
|
744
|
+
reference fix → PASSES
|
|
745
|
+
decoy fix → FAILS ← the one everybody skips
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
Two original tasks failed this: "basis points vs percent" and "median averages the
|
|
749
|
+
middle two" are cases every model has seen hundreds of times. They were 3/3 for the
|
|
750
|
+
*weaker* model, which means they measured nothing.
|
|
751
|
+
|
|
752
|
+
**Difficulty is a property of the task–model pair, not the task.** Calibrating on a
|
|
753
|
+
cheap proxy model systematically mis-targets: tasks that were 0/6 for gpt-4o-mini were
|
|
754
|
+
3/3 for GLM-5.2. Calibrate against the model you will actually run, or accept that your
|
|
755
|
+
difficulty labels are fiction.
|
|
756
|
+
|
|
757
|
+
---
|
|
758
|
+
|
|
759
|
+
## Bugs worth reading about
|
|
760
|
+
|
|
761
|
+
**`Glob` returned zero matches with `ok: true` in every experiment workspace, for the
|
|
762
|
+
life of the project.**
|
|
763
|
+
|
|
764
|
+
`Glob` prefers `git ls-files --cached --others --exclude-standard` over a filesystem
|
|
765
|
+
walk, so results respect `.gitignore`. In a directory that is *itself* git-ignored,
|
|
766
|
+
that command exits **0 with zero entries**. An empty list is not `None`, so the git
|
|
767
|
+
branch was taken and the tool reported "No files match" — successfully.
|
|
768
|
+
|
|
769
|
+
Every experiment workspace lives under `.turnloop/experiments/`, which is gitignored.
|
|
770
|
+
`Glob` was silently dead in every run ever recorded.
|
|
771
|
+
|
|
772
|
+
The damage was not the missing tool, it was the misattribution. The model tried
|
|
773
|
+
`**/*`, then `**/*.py`, then `**/*.*`, then honestly reported "No files found in the
|
|
774
|
+
directory" — and was scored as a *model* failure. A published claim that gpt-4o-mini
|
|
775
|
+
scored 50% and sat "dead centre of the discriminating band" was drawn from those runs.
|
|
776
|
+
After the fix it scored 100%. That claim is retracted.
|
|
777
|
+
|
|
778
|
+
The fix returns `None` only when `ls-files` is empty **and** `git check-ignore -q`
|
|
779
|
+
confirms the base is ignored — a stricter condition than "empty", because a
|
|
780
|
+
non-ignored directory whose files happen to all be individually gitignored must not
|
|
781
|
+
suddenly have them exposed.
|
|
782
|
+
|
|
783
|
+
`Grep` had the same latent blind spot on its `ripgrep` path, fixed the same way. It
|
|
784
|
+
never manifested only because `rg` was not installed on the machine and it had been
|
|
785
|
+
falling back to a pure-Python walk.
|
|
786
|
+
|
|
787
|
+
**The lesson, three times over in one project:** an 84-run sweep failing while
|
|
788
|
+
printing a tidy report of zeros; a tool returning empty with `ok: true`; a sweep
|
|
789
|
+
sitting at 10/18 doing nothing for 25 minutes. Each was caught only by looking past
|
|
790
|
+
the summary line.
|
|
791
|
+
|
|
792
|
+
---
|
|
793
|
+
|
|
794
|
+
## Windows notes
|
|
795
|
+
|
|
796
|
+
Both of these cost real time to diagnose from a traceback, so they are handled
|
|
797
|
+
explicitly and covered by tests.
|
|
798
|
+
|
|
799
|
+
**`bash` on PATH is a trap.** On a default install it is
|
|
800
|
+
`C:\Windows\System32\bash.exe` — the WSL launcher. With no distribution installed it
|
|
801
|
+
exits 255 and prints its error in UTF-16LE. Any `bash.exe` under `%SystemRoot%` is
|
|
802
|
+
rejected outright; Git Bash is located via `git`'s install directory, with PowerShell
|
|
803
|
+
as the fallback. POSIX is preferred because models write POSIX shell.
|
|
804
|
+
|
|
805
|
+
**`bash -lc` makes the real command a grandchild.** `terminate()` kills the shell and
|
|
806
|
+
orphans the work, which keeps running and holding file locks after a timeout. The
|
|
807
|
+
whole tree is killed via `taskkill /T`.
|
|
808
|
+
|
|
809
|
+
**Never edit source with PowerShell `Get-Content | Set-Content`.** On PS 5.1,
|
|
810
|
+
`Set-Content -Encoding utf8` writes a **BOM**, and the read side decodes UTF-8 as ANSI
|
|
811
|
+
— em dashes become mojibake. It corrupted a module and 17 fixtures in this repository,
|
|
812
|
+
and the BOM then made `ast.parse` fail with "invalid non-printable character U+FEFF".
|
|
813
|
+
Files are read with `utf-8-sig` and a BOM is preserved on write, so a stray U+FEFF
|
|
814
|
+
never lands invisibly inside an `Edit`'s `old_string` and silently break the match.
|
|
815
|
+
|
|
816
|
+
File writes preserve the existing newline style and trailing-newline convention, so a
|
|
817
|
+
one-line edit in a CRLF checkout does not produce a whole-file diff.
|
|
818
|
+
|
|
819
|
+
---
|
|
820
|
+
|
|
821
|
+
## Testing
|
|
822
|
+
|
|
823
|
+
```bash
|
|
824
|
+
pytest # 230 tests, no network
|
|
825
|
+
ruff check turnloop
|
|
826
|
+
mypy turnloop
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
| file | tests | |
|
|
830
|
+
|---|---|---|
|
|
831
|
+
| `test_permissions.py` | 48 | rule grammar, compound-command splitting, mode enforcement |
|
|
832
|
+
| `test_experiments.py` | 33 | runner, graders, report, suite invariants |
|
|
833
|
+
| `test_providers.py` | 27 | adapters against recorded `.sse` fixtures |
|
|
834
|
+
| `test_tools_files.py` | 25 | Read/Write/Edit/Glob/Grep, encodings, newline preservation |
|
|
835
|
+
| `test_loop.py` | 20 | streaming, truncation, iteration cap |
|
|
836
|
+
| `test_hooks_mcp_commands.py` | 20 | lifecycle hooks, MCP client, slash commands |
|
|
837
|
+
| `test_config.py` | 19 | layering, env overrides, capability presets |
|
|
838
|
+
| `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
|
|
839
|
+
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
840
|
+
| `test_tui.py` | 8 | Textual snapshots |
|
|
841
|
+
|
|
842
|
+
`conftest.py` monkeypatches httpx's transport to raise unless a test is marked
|
|
843
|
+
`live` — **network is off at the transport layer**, so a forgotten real call fails
|
|
844
|
+
loudly in CI instead of hanging or booting a GPU. `addopts = "-m 'not live' -q"`
|
|
845
|
+
deselects live tests by default.
|
|
846
|
+
|
|
847
|
+
---
|
|
848
|
+
|
|
849
|
+
## Configuration reference
|
|
850
|
+
|
|
851
|
+
Precedence, later wins:
|
|
852
|
+
|
|
853
|
+
```
|
|
854
|
+
packaged defaults
|
|
855
|
+
~/.turnloop/settings.json
|
|
856
|
+
<project>/.turnloop/settings.json
|
|
857
|
+
<project>/.turnloop/settings.local.json (gitignored, personal)
|
|
858
|
+
TURNLOOP_* environment variables
|
|
859
|
+
CLI flags
|
|
860
|
+
```
|
|
861
|
+
|
|
862
|
+
Dicts merge recursively; lists replace wholesale. Replacing lists is deliberate — a
|
|
863
|
+
project that declares its allowlist means *that* list, not that list plus whatever the
|
|
864
|
+
user had globally.
|
|
865
|
+
|
|
866
|
+
Environment overrides: `TURNLOOP_PROVIDER`, `TURNLOOP_MODEL`,
|
|
867
|
+
`TURNLOOP_PERMISSION_MODE`, `TURNLOOP_MAX_ITERATIONS`, `TURNLOOP_TOOL_VERBOSITY`,
|
|
868
|
+
`TURNLOOP_SYSTEM_VARIANT`.
|
|
869
|
+
|
|
870
|
+
Project root detection is two-pass. `.turnloop/` is an explicit declaration and always
|
|
871
|
+
wins; otherwise the *nearest* build manifest or repo marker wins, with `.git` **last**
|
|
872
|
+
in the tuple — a drive root can itself be a git repository, and keying only on `.git`
|
|
873
|
+
would make every project's root the entire drive, which then scopes permissions and
|
|
874
|
+
sessions wrongly.
|
|
875
|
+
|
|
876
|
+
API keys come from the environment or a `.env` at the project root. A real environment
|
|
877
|
+
variable always wins over the file, and `doctor` reports which key *names* were loaded
|
|
878
|
+
— never their values.
|
|
879
|
+
|
|
880
|
+
Project instructions go in `TURNLOOP.md` (`CLAUDE.md` and `AGENTS.md` are also read).
|
|
881
|
+
Slash commands live in `.turnloop/commands/*.md`, skills in
|
|
882
|
+
`.turnloop/skills/<name>/SKILL.md` — skills advertise only their name and description
|
|
883
|
+
in the system prompt and load their body on demand, which on a 65k window is the
|
|
884
|
+
difference between having skills and not.
|
|
885
|
+
|
|
886
|
+
Shell expansion inside a command template (`` !`cmd` ``) goes through the permission
|
|
887
|
+
engine like any other Bash call. A markdown file in a repository is not trusted input
|
|
888
|
+
just because it is on disk.
|
|
889
|
+
|
|
890
|
+
---
|
|
891
|
+
|
|
892
|
+
## Limitations
|
|
893
|
+
|
|
894
|
+
Stated rather than buried:
|
|
895
|
+
|
|
896
|
+
- **No image support.** No image content block exists in the message model. Adding one
|
|
897
|
+
is small (three adapters, a `supports_vision` flag, `Read` returning it instead of
|
|
898
|
+
refusing) but useless on the primary target — GLM-5.2 W4A16 as deployed is text-only.
|
|
899
|
+
- **No binary file support.** Null-byte sniff refuses them. Convert via `Bash`.
|
|
900
|
+
- **The Anthropic, Gemini and OpenAI adapters are fixture-tested but have never sent a
|
|
901
|
+
real request.** They are coded against the documented wire format and pass against
|
|
902
|
+
recorded SSE, which is not the same as verified.
|
|
903
|
+
- **`subtle_spec_edge` currently passes for nobody** (0/5 on gpt-4o-mini). The rewrite
|
|
904
|
+
overshot — runs die on collection errors and leave `NotImplementedError`, which is
|
|
905
|
+
failure-to-produce-working-code, not falling for the decoy. It needs another pass.
|
|
906
|
+
- **No task in the suite discriminates GLM-5.2 on pass rate**, and more task authoring
|
|
907
|
+
is unlikely to fix it. See [Results](#results).
|
|
908
|
+
- **Error-rate findings rest on small counts.** Token findings are solid; ordering by
|
|
909
|
+
error rate is suggestive.
|
|
910
|
+
- **Single-turn headless only** — `-p` runs one turn, not a scripted multi-turn session.
|
|
911
|
+
|
|
912
|
+
---
|
|
913
|
+
|
|
914
|
+
## License
|
|
915
|
+
|
|
916
|
+
MIT. Not affiliated with Anthropic.
|