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.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. 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.