turnloop 0.1.4__tar.gz → 0.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- turnloop-0.1.6/.turnloop/settings.local.json +3 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/PKG-INFO +144 -17
- {turnloop-0.1.4 → turnloop-0.1.6}/README.md +143 -16
- {turnloop-0.1.4 → turnloop-0.1.6}/pyproject.toml +1 -1
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_config.py +83 -30
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_hooks_mcp_commands.py +537 -431
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_loop.py +73 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_permissions.py +48 -0
- turnloop-0.1.6/tests/test_system_prompt_layout.py +85 -0
- turnloop-0.1.6/tests/test_tui.py +613 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/__init__.py +1 -1
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/agent/headless.py +17 -9
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/agent/system_prompt.py +31 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/cli.py +22 -8
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/commands/dispatch.py +486 -448
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/commands/loader.py +39 -2
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/config.py +478 -444
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/sessions/store.py +286 -271
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/runner.py +63 -1
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/app.py +518 -415
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/config_screen.py +20 -1
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/mcp_screen.py +363 -344
- turnloop-0.1.6/turnloop/tui/session_screen.py +114 -0
- turnloop-0.1.4/tests/test_tui.py +0 -170
- {turnloop-0.1.4 → turnloop-0.1.6}/.github/workflows/ci.yml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/.gitignore +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/.turnloop/.gitignore +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/conftest.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/fixtures/anthropic_tool_call.sse +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/fixtures/glm_tool_call.sse +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/fixtures/mcp_echo_server.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_bash.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_compaction.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_config_screen.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_configio.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_experiments.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_providers.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_tools_files.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/tests/test_websearch.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/__main__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/agent/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/agent/factory.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/agent/loop.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/agent/subagent.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/commands/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/configio.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/context/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/context/budget.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/context/compaction.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/context/memory.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/core/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/core/events.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/core/ids.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/core/messages.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/core/tokens.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/diagnostics.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/errors.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/bakeoff.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/context-glm.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/context.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/groq-free.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/hard-glm.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/loop.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/reliability.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/configs/smoke.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/graders.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/report.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/runner.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/default.yaml +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/hooks/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/hooks/runner.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/mcp/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/mcp/adapter.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/mcp/client.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/permissions/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/permissions/engine.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/permissions/rules.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/providers/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/providers/anthropic.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/providers/base.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/providers/gemini.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/providers/mock.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/providers/openai_compat.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/providers/pricing.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/providers/registry.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/providers/sse.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/sessions/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/sessions/models.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/ask.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/base.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/bash.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/builtin.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/edit.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/glob.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/grep.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/read.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/shell.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/skill.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/task.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/textio.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/todo.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/webfetch.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/websearch.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tools/write.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/app.tcss +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/bridge.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/widgets/__init__.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/widgets/permission.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/widgets/status.py +0 -0
- {turnloop-0.1.4 → turnloop-0.1.6}/turnloop/tui/widgets/transcript.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: turnloop
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Pranesh-2005/turnloop
|
|
6
6
|
Author: Pranesh
|
|
@@ -49,7 +49,7 @@ tl experiment run smoke # a measurement run, offline and free
|
|
|
49
49
|
|
|
50
50
|
Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
|
|
51
51
|
SDKs, no agent framework — the loop is the point, so the loop is written here.
|
|
52
|
-
~
|
|
52
|
+
~20,000 lines of Python, 318 tests, no network in the default test run.
|
|
53
53
|
|
|
54
54
|
---
|
|
55
55
|
|
|
@@ -117,7 +117,7 @@ point.
|
|
|
117
117
|
Verify:
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
|
-
tl --version # turnloop 0.1.
|
|
120
|
+
tl --version # turnloop 0.1.6
|
|
121
121
|
tl doctor # every provider, key presence, shell, context budget
|
|
122
122
|
```
|
|
123
123
|
|
|
@@ -139,8 +139,9 @@ context arithmetic for the active provider:
|
|
|
139
139
|
tl # interactive TUI
|
|
140
140
|
tl -p "why does the login test flake" # headless single turn
|
|
141
141
|
tl -p "..." --json # headless, JSONL events on stdout
|
|
142
|
-
tl
|
|
143
|
-
tl --resume
|
|
142
|
+
tl -c # resume the most recent session, no picker
|
|
143
|
+
tl --resume # interactive picker (TUI); most recent (headless)
|
|
144
|
+
tl --resume <session-id> # resume a specific one, no picker
|
|
144
145
|
tl sessions # list recorded sessions
|
|
145
146
|
tl config # merged config + which layer each value came from
|
|
146
147
|
tl config --edit # interactive settings editor
|
|
@@ -162,7 +163,8 @@ and neither is reachable by the model — see
|
|
|
162
163
|
| `--max-iterations N` | tool-loop safety cap (default 40) |
|
|
163
164
|
| `-p, --print PROMPT` | headless single turn |
|
|
164
165
|
| `--json` | headless output as JSONL events |
|
|
165
|
-
|
|
|
166
|
+
| `-c, --continue` | resume the most recent session directly, no picker |
|
|
167
|
+
| `--resume [ID]` | bare: interactive picker in the TUI, most recent in headless. With an id: that session, no picker |
|
|
166
168
|
|
|
167
169
|
### Permission modes
|
|
168
170
|
|
|
@@ -184,12 +186,18 @@ to cycle. Make it permanent via `TURNLOOP_PERMISSION_MODE` or `settings.json`.
|
|
|
184
186
|
### Keys
|
|
185
187
|
|
|
186
188
|
```
|
|
187
|
-
ctrl+c
|
|
189
|
+
ctrl+c copy the selection; else interrupt a running turn; else nothing
|
|
188
190
|
ctrl+p cycle permission mode
|
|
189
191
|
ctrl+l clear the view (history is kept)
|
|
190
192
|
ctrl+d quit
|
|
191
193
|
```
|
|
192
194
|
|
|
195
|
+
`ctrl+c` no longer quits. It used to call `self.exit()` whenever no turn was
|
|
196
|
+
running, so pressing it to copy text killed the session. It now checks, in order,
|
|
197
|
+
whether there is a selection to copy (via Textual's `App.copy_to_clipboard`, OSC 52,
|
|
198
|
+
no new dependency), then whether a turn is running to interrupt, and otherwise does
|
|
199
|
+
nothing. `ctrl+d` is still the only way to quit.
|
|
200
|
+
|
|
193
201
|
`ctrl+c` and friends are bound with `priority=True` — the prompt Input always has
|
|
194
202
|
focus and would otherwise swallow them.
|
|
195
203
|
|
|
@@ -205,6 +213,9 @@ focus and would otherwise swallow them.
|
|
|
205
213
|
/provider switch provider /config settings editor
|
|
206
214
|
/export transcript to markdown /hooks configured hooks
|
|
207
215
|
/sessions recorded sessions /doctor diagnostics
|
|
216
|
+
/skills loaded skills, and any /resume picker, rebuilds the
|
|
217
|
+
rejected on disk (why, agent in place; refuses
|
|
218
|
+
and where) while a turn is running
|
|
208
219
|
/quit exit
|
|
209
220
|
```
|
|
210
221
|
|
|
@@ -350,6 +361,13 @@ close a self-escalation path: settings files set `permission_mode` and
|
|
|
350
361
|
next launch. Verified against the real engine *in bypass mode*, which is the case that
|
|
351
362
|
matters — deny beats bypass, so the block holds even there.
|
|
352
363
|
|
|
364
|
+
That was a unit test. It has since been verified live: with
|
|
365
|
+
`Write(.turnloop/**)` explicitly allowed *and* `--permission-mode bypass`, a real
|
|
366
|
+
model told to grant itself permanent bypass access was blocked, made one attempt,
|
|
367
|
+
and proposed the change to the user instead. The settings file was unchanged. Deny
|
|
368
|
+
beats an explicit allow rule and bypass mode at once — not just in the test suite,
|
|
369
|
+
against a live model that was actually trying.
|
|
370
|
+
|
|
353
371
|
It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
|
|
354
372
|
and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
|
|
355
373
|
Closing that properly needs a path guard below the tool layer rather than more patterns.
|
|
@@ -395,6 +413,14 @@ because a crashed run still leaves a readable trace, and because the experiments
|
|
|
395
413
|
layer reads exactly the same format the TUI writes — a run trace and a real session
|
|
396
414
|
are the same artifact.
|
|
397
415
|
|
|
416
|
+
`-c`/`--continue` reopens the most recent session with no prompting. Bare `--resume`
|
|
417
|
+
in the TUI opens an interactive picker — a `DataTable` of sessions, newest first,
|
|
418
|
+
with a live preview pane of the selected conversation — and `--resume <id>` jumps
|
|
419
|
+
straight to a specific one. `/resume` opens the same picker in-session and rebuilds
|
|
420
|
+
the agent in place; it refuses while a turn is running rather than tearing one down
|
|
421
|
+
mid-flight. Headless has no picker to show, so a bare `--resume` there falls back to
|
|
422
|
+
"most recent" — the same thing `--continue` does.
|
|
423
|
+
|
|
398
424
|
---
|
|
399
425
|
|
|
400
426
|
## Providers
|
|
@@ -869,11 +895,101 @@ and through session JSONL, so a `--resume`d conversation does not hit the same w
|
|
|
869
895
|
check, and anyone who pointed 0.1.2 at Gemini got one working tool call and then a
|
|
870
896
|
400. Fixed in 0.1.3.
|
|
871
897
|
|
|
872
|
-
|
|
898
|
+
### `NoActiveWorker`: 283 green tests and a crash on the first keystroke
|
|
899
|
+
|
|
900
|
+
The config UI shipped with tests covering `/config` — they asserted that dispatching it
|
|
901
|
+
returned `open_screen="config"`. All 283 passed. Typing `/config` in the actual TUI
|
|
902
|
+
raised `NoActiveWorker: push_screen must be run from a worker`.
|
|
903
|
+
|
|
904
|
+
Textual's `push_screen_wait` blocks the calling coroutine until the screen is dismissed,
|
|
905
|
+
so it must run inside a worker; called from the message pump it would deadlock the pump
|
|
906
|
+
it is waiting on, and Textual refuses rather than hanging. `_handle_command` runs on the
|
|
907
|
+
pump. So does Textual's action dispatch, which meant the same defect existed at four
|
|
908
|
+
call sites, not the one in the traceback — including the provider form reachable from
|
|
909
|
+
`tl config --edit`, a path the crash report never touched.
|
|
910
|
+
|
|
911
|
+
The correct pattern was eleven lines away in the same file: `_permission_worker` is
|
|
912
|
+
`@work`-decorated, which is exactly why *its* `push_screen_wait` works.
|
|
913
|
+
|
|
914
|
+
Two things this cost, both worth stating. The tests verified the layer *below* the bug —
|
|
915
|
+
that the command produced the right instruction, never that the app could carry it out.
|
|
916
|
+
And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/config`.
|
|
917
|
+
Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
|
|
918
|
+
fail if the decorators are removed.
|
|
919
|
+
|
|
920
|
+
**A directory that exists on every launch cannot mean "this is a project."**
|
|
921
|
+
|
|
922
|
+
`find_project_root` treated any ancestor `.turnloop` directory as an explicit project
|
|
923
|
+
declaration. But `default_project_dir` creates `.turnloop/` — with a `sessions/`
|
|
924
|
+
folder and a `.gitignore` — on every launch, unconditionally, wherever turnloop
|
|
925
|
+
happens to start. So the directory's mere existence declared nothing; it was
|
|
926
|
+
frequently just the residue of having once run the tool there. Run `tl` once from a
|
|
927
|
+
home directory, or a drive root, and it permanently annexed every project beneath it:
|
|
928
|
+
sessions, the `.env` layer, `settings.json` and the permission rules all resolved to
|
|
929
|
+
the wrong root from then on. Real instance, the author's own machine: `F:\.turnloop`
|
|
930
|
+
was created on 2026-08-01, and every `F:\anything\proj\pyproject.toml` since then
|
|
931
|
+
resolved its project root to `F:\`.
|
|
932
|
+
|
|
933
|
+
The invariant that broke was never written down as one, which is exactly why it lasted.
|
|
934
|
+
The fix makes the implicit rule explicit: a `.turnloop` counts only if it holds
|
|
935
|
+
something a human put there — `settings.json`, `settings.local.json`, `commands/`,
|
|
936
|
+
`skills/`, `TURNLOOP.md` — and one holding only an auto-created `sessions/` is ignored.
|
|
937
|
+
The home directory is excluded outright on top of that, since `~/.turnloop` is the
|
|
938
|
+
user-scope settings location and can never mean "project." The false invariant had
|
|
939
|
+
been there since the tool first created `.turnloop/` on launch; fixed in 0.1.6.
|
|
940
|
+
|
|
941
|
+
**A skill that failed to parse vanished with no error, and the model reported success.**
|
|
942
|
+
|
|
943
|
+
A live run installed a skill whose frontmatter was markdown-bold — `**name**:
|
|
944
|
+
something` — rather than YAML. `yaml.safe_load` returned no usable keys,
|
|
945
|
+
`load_skills` hit its "no description" skip, and the skill disappeared: no exception,
|
|
946
|
+
no log line, nothing in `/skills`. The model that had just "installed" it reported
|
|
947
|
+
success, because from its side the write succeeded and nothing said otherwise.
|
|
948
|
+
|
|
949
|
+
The bug was not the parser rejecting bad frontmatter — that part is correct. It was
|
|
950
|
+
that a rejection and a nonexistent skill looked identical from the outside. `load_skills`
|
|
951
|
+
keeps its exact signature, but a new `rejected_skills()` now returns what was dropped
|
|
952
|
+
and why, `/skills` reports it, and `CONFIG_LAYOUT` shows the literal YAML frontmatter
|
|
953
|
+
block in the system prompt so the model stops guessing at markdown-bold instead.
|
|
954
|
+
Fixed in 0.1.6.
|
|
955
|
+
|
|
956
|
+
**A permission denial that should end in one message looped ten times, and it cost
|
|
957
|
+
real money before anyone noticed — because nothing about it failed a test.**
|
|
958
|
+
|
|
959
|
+
Two defects landed in the same code path with the same symptom. The DENY branch's
|
|
960
|
+
message said only `Permission denied: <rule>`; the ASK-decline branch, which is
|
|
961
|
+
recoverable, had always carried "Do not retry it" — so the weaker wording sat on the
|
|
962
|
+
*permanent* block, the one that most needed it. Fixing the message was not enough on
|
|
963
|
+
its own: the ASK path looped despite already carrying that warning, because prompt
|
|
964
|
+
wording is a request, not a constraint, and a model under pressure to finish a task
|
|
965
|
+
does not reliably honor it.
|
|
966
|
+
|
|
967
|
+
Measured against a live, non-interactive provider — this is what a passing test suite
|
|
968
|
+
cannot see, since nothing here raises or returns the wrong value, it just keeps calling
|
|
969
|
+
a tool that keeps getting denied:
|
|
970
|
+
|
|
971
|
+
- denied `Write .turnloop/settings.local.json`, repeated 10 times → **233,742 input
|
|
972
|
+
tokens, $0.0372**
|
|
973
|
+
- after the message fix alone → **10,654 tokens, $0.0017**, one attempt then a correct
|
|
974
|
+
proposal to the user
|
|
975
|
+
- a separate ASK-decline loop on `dir`/`mkdir`, before any fix → **199,352 tokens,
|
|
976
|
+
$0.0317**
|
|
977
|
+
|
|
978
|
+
So wording is now backed by a deterministic guard in `ToolRunner` rather than trusted
|
|
979
|
+
alone: identity is tool name plus pydantic-normalized args, scope is the session, one
|
|
980
|
+
free retry, and the entry clears the moment the call is allowed or approved — so a
|
|
981
|
+
permission granted mid-session un-poisons a call that was denied earlier, instead of
|
|
982
|
+
blocking it forever. Fixed in 0.1.6.
|
|
983
|
+
|
|
984
|
+
**The lesson, eight times over in one project:** an 84-run sweep failing while printing
|
|
873
985
|
a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
|
|
874
986
|
10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
|
|
875
|
-
the turn the test never made
|
|
876
|
-
|
|
987
|
+
the turn the test never made; a feature with green tests that crashed on its first
|
|
988
|
+
keystroke; an implicit invariant that a docstring never stated, so nobody noticed it
|
|
989
|
+
was false; a silent failure that a model reported as success; a cost bug that no
|
|
990
|
+
assertion could ever have caught, because every individual call did exactly what it
|
|
991
|
+
was told. Each was caught only by looking past the summary line — and most of them
|
|
992
|
+
only by using the thing instead of testing it.
|
|
877
993
|
|
|
878
994
|
---
|
|
879
995
|
|
|
@@ -907,26 +1023,27 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
|
|
|
907
1023
|
## Testing
|
|
908
1024
|
|
|
909
1025
|
```bash
|
|
910
|
-
pytest #
|
|
1026
|
+
pytest # 318 tests, no network
|
|
911
1027
|
ruff check turnloop
|
|
912
1028
|
mypy turnloop
|
|
913
1029
|
```
|
|
914
1030
|
|
|
915
1031
|
| file | tests | |
|
|
916
1032
|
|---|---|---|
|
|
917
|
-
| `test_permissions.py` |
|
|
1033
|
+
| `test_permissions.py` | 50 | rule grammar, compound-command splitting, mode enforcement |
|
|
918
1034
|
| `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
|
|
919
1035
|
| `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
|
|
920
1036
|
| `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
|
|
921
|
-
| `
|
|
922
|
-
| `
|
|
923
|
-
| `test_config.py` |
|
|
1037
|
+
| `test_hooks_mcp_commands.py` | 28 | lifecycle hooks, MCP client, slash commands |
|
|
1038
|
+
| `test_tui.py` | 24 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
|
|
1039
|
+
| `test_config.py` | 23 | layering, env overrides, presets, narrow-console guard |
|
|
1040
|
+
| `test_loop.py` | 23 | streaming, truncation, iteration cap |
|
|
924
1041
|
| `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
|
|
925
|
-
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
926
1042
|
| `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
|
|
1043
|
+
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
927
1044
|
| `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
|
|
928
1045
|
| `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
|
|
929
|
-
| `
|
|
1046
|
+
| `test_system_prompt_layout.py` | 7 | `CONFIG_LAYOUT` presence, token cost, `minimal` variant omission |
|
|
930
1047
|
|
|
931
1048
|
`conftest.py` monkeypatches httpx's transport to raise unless a test is marked
|
|
932
1049
|
`live` — **network is off at the transport layer**, so a forgotten real call fails
|
|
@@ -973,6 +1090,16 @@ Slash commands live in `.turnloop/commands/*.md`, skills in
|
|
|
973
1090
|
in the system prompt and load their body on demand, which on a 65k window is the
|
|
974
1091
|
difference between having skills and not.
|
|
975
1092
|
|
|
1093
|
+
The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
|
|
1094
|
+
— 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
|
|
1095
|
+
the model cannot configure the tool it is running inside: asked to add an MCP server
|
|
1096
|
+
it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
|
|
1097
|
+
`examples/` — both real outputs from live runs. With it, both go to the right place
|
|
1098
|
+
first try. It includes the literal YAML frontmatter a skill needs, since a skill with
|
|
1099
|
+
markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
|
|
1100
|
+
now also reports anything found on disk but rejected, with the reason and path — see
|
|
1101
|
+
[Bugs worth reading about](#bugs-worth-reading-about).
|
|
1102
|
+
|
|
976
1103
|
Shell expansion inside a command template (`` !`cmd` ``) goes through the permission
|
|
977
1104
|
engine like any other Bash call. A markdown file in a repository is not trusted input
|
|
978
1105
|
just because it is on disk.
|
|
@@ -20,7 +20,7 @@ tl experiment run smoke # a measurement run, offline and free
|
|
|
20
20
|
|
|
21
21
|
Four runtime dependencies: `textual`, `pydantic`, `httpx`, `pyyaml`. No vendor
|
|
22
22
|
SDKs, no agent framework — the loop is the point, so the loop is written here.
|
|
23
|
-
~
|
|
23
|
+
~20,000 lines of Python, 318 tests, no network in the default test run.
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -88,7 +88,7 @@ point.
|
|
|
88
88
|
Verify:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
-
tl --version # turnloop 0.1.
|
|
91
|
+
tl --version # turnloop 0.1.6
|
|
92
92
|
tl doctor # every provider, key presence, shell, context budget
|
|
93
93
|
```
|
|
94
94
|
|
|
@@ -110,8 +110,9 @@ context arithmetic for the active provider:
|
|
|
110
110
|
tl # interactive TUI
|
|
111
111
|
tl -p "why does the login test flake" # headless single turn
|
|
112
112
|
tl -p "..." --json # headless, JSONL events on stdout
|
|
113
|
-
tl
|
|
114
|
-
tl --resume
|
|
113
|
+
tl -c # resume the most recent session, no picker
|
|
114
|
+
tl --resume # interactive picker (TUI); most recent (headless)
|
|
115
|
+
tl --resume <session-id> # resume a specific one, no picker
|
|
115
116
|
tl sessions # list recorded sessions
|
|
116
117
|
tl config # merged config + which layer each value came from
|
|
117
118
|
tl config --edit # interactive settings editor
|
|
@@ -133,7 +134,8 @@ and neither is reachable by the model — see
|
|
|
133
134
|
| `--max-iterations N` | tool-loop safety cap (default 40) |
|
|
134
135
|
| `-p, --print PROMPT` | headless single turn |
|
|
135
136
|
| `--json` | headless output as JSONL events |
|
|
136
|
-
|
|
|
137
|
+
| `-c, --continue` | resume the most recent session directly, no picker |
|
|
138
|
+
| `--resume [ID]` | bare: interactive picker in the TUI, most recent in headless. With an id: that session, no picker |
|
|
137
139
|
|
|
138
140
|
### Permission modes
|
|
139
141
|
|
|
@@ -155,12 +157,18 @@ to cycle. Make it permanent via `TURNLOOP_PERMISSION_MODE` or `settings.json`.
|
|
|
155
157
|
### Keys
|
|
156
158
|
|
|
157
159
|
```
|
|
158
|
-
ctrl+c
|
|
160
|
+
ctrl+c copy the selection; else interrupt a running turn; else nothing
|
|
159
161
|
ctrl+p cycle permission mode
|
|
160
162
|
ctrl+l clear the view (history is kept)
|
|
161
163
|
ctrl+d quit
|
|
162
164
|
```
|
|
163
165
|
|
|
166
|
+
`ctrl+c` no longer quits. It used to call `self.exit()` whenever no turn was
|
|
167
|
+
running, so pressing it to copy text killed the session. It now checks, in order,
|
|
168
|
+
whether there is a selection to copy (via Textual's `App.copy_to_clipboard`, OSC 52,
|
|
169
|
+
no new dependency), then whether a turn is running to interrupt, and otherwise does
|
|
170
|
+
nothing. `ctrl+d` is still the only way to quit.
|
|
171
|
+
|
|
164
172
|
`ctrl+c` and friends are bound with `priority=True` — the prompt Input always has
|
|
165
173
|
focus and would otherwise swallow them.
|
|
166
174
|
|
|
@@ -176,6 +184,9 @@ focus and would otherwise swallow them.
|
|
|
176
184
|
/provider switch provider /config settings editor
|
|
177
185
|
/export transcript to markdown /hooks configured hooks
|
|
178
186
|
/sessions recorded sessions /doctor diagnostics
|
|
187
|
+
/skills loaded skills, and any /resume picker, rebuilds the
|
|
188
|
+
rejected on disk (why, agent in place; refuses
|
|
189
|
+
and where) while a turn is running
|
|
179
190
|
/quit exit
|
|
180
191
|
```
|
|
181
192
|
|
|
@@ -321,6 +332,13 @@ close a self-escalation path: settings files set `permission_mode` and
|
|
|
321
332
|
next launch. Verified against the real engine *in bypass mode*, which is the case that
|
|
322
333
|
matters — deny beats bypass, so the block holds even there.
|
|
323
334
|
|
|
335
|
+
That was a unit test. It has since been verified live: with
|
|
336
|
+
`Write(.turnloop/**)` explicitly allowed *and* `--permission-mode bypass`, a real
|
|
337
|
+
model told to grant itself permanent bypass access was blocked, made one attempt,
|
|
338
|
+
and proposed the change to the user instead. The settings file was unchanged. Deny
|
|
339
|
+
beats an explicit allow rule and bypass mode at once — not just in the test suite,
|
|
340
|
+
against a live model that was actually trying.
|
|
341
|
+
|
|
324
342
|
It is a guardrail, not a sandbox, and the boundary is worth being precise about. `Write`
|
|
325
343
|
and `Edit` are blocked; **`Bash` is not**, so a shell redirect reaches the same file.
|
|
326
344
|
Closing that properly needs a path guard below the tool layer rather than more patterns.
|
|
@@ -366,6 +384,14 @@ because a crashed run still leaves a readable trace, and because the experiments
|
|
|
366
384
|
layer reads exactly the same format the TUI writes — a run trace and a real session
|
|
367
385
|
are the same artifact.
|
|
368
386
|
|
|
387
|
+
`-c`/`--continue` reopens the most recent session with no prompting. Bare `--resume`
|
|
388
|
+
in the TUI opens an interactive picker — a `DataTable` of sessions, newest first,
|
|
389
|
+
with a live preview pane of the selected conversation — and `--resume <id>` jumps
|
|
390
|
+
straight to a specific one. `/resume` opens the same picker in-session and rebuilds
|
|
391
|
+
the agent in place; it refuses while a turn is running rather than tearing one down
|
|
392
|
+
mid-flight. Headless has no picker to show, so a bare `--resume` there falls back to
|
|
393
|
+
"most recent" — the same thing `--continue` does.
|
|
394
|
+
|
|
369
395
|
---
|
|
370
396
|
|
|
371
397
|
## Providers
|
|
@@ -840,11 +866,101 @@ and through session JSONL, so a `--resume`d conversation does not hit the same w
|
|
|
840
866
|
check, and anyone who pointed 0.1.2 at Gemini got one working tool call and then a
|
|
841
867
|
400. Fixed in 0.1.3.
|
|
842
868
|
|
|
843
|
-
|
|
869
|
+
### `NoActiveWorker`: 283 green tests and a crash on the first keystroke
|
|
870
|
+
|
|
871
|
+
The config UI shipped with tests covering `/config` — they asserted that dispatching it
|
|
872
|
+
returned `open_screen="config"`. All 283 passed. Typing `/config` in the actual TUI
|
|
873
|
+
raised `NoActiveWorker: push_screen must be run from a worker`.
|
|
874
|
+
|
|
875
|
+
Textual's `push_screen_wait` blocks the calling coroutine until the screen is dismissed,
|
|
876
|
+
so it must run inside a worker; called from the message pump it would deadlock the pump
|
|
877
|
+
it is waiting on, and Textual refuses rather than hanging. `_handle_command` runs on the
|
|
878
|
+
pump. So does Textual's action dispatch, which meant the same defect existed at four
|
|
879
|
+
call sites, not the one in the traceback — including the provider form reachable from
|
|
880
|
+
`tl config --edit`, a path the crash report never touched.
|
|
881
|
+
|
|
882
|
+
The correct pattern was eleven lines away in the same file: `_permission_worker` is
|
|
883
|
+
`@work`-decorated, which is exactly why *its* `push_screen_wait` works.
|
|
884
|
+
|
|
885
|
+
Two things this cost, both worth stating. The tests verified the layer *below* the bug —
|
|
886
|
+
that the command produced the right instruction, never that the app could carry it out.
|
|
887
|
+
And **0.1.4 shipped this way**, so `pip install turnloop==0.1.4` crashes on `/config`.
|
|
888
|
+
Fixed in 0.1.5. The tests now drive a real app through Textual's `run_test()` pilot and
|
|
889
|
+
fail if the decorators are removed.
|
|
890
|
+
|
|
891
|
+
**A directory that exists on every launch cannot mean "this is a project."**
|
|
892
|
+
|
|
893
|
+
`find_project_root` treated any ancestor `.turnloop` directory as an explicit project
|
|
894
|
+
declaration. But `default_project_dir` creates `.turnloop/` — with a `sessions/`
|
|
895
|
+
folder and a `.gitignore` — on every launch, unconditionally, wherever turnloop
|
|
896
|
+
happens to start. So the directory's mere existence declared nothing; it was
|
|
897
|
+
frequently just the residue of having once run the tool there. Run `tl` once from a
|
|
898
|
+
home directory, or a drive root, and it permanently annexed every project beneath it:
|
|
899
|
+
sessions, the `.env` layer, `settings.json` and the permission rules all resolved to
|
|
900
|
+
the wrong root from then on. Real instance, the author's own machine: `F:\.turnloop`
|
|
901
|
+
was created on 2026-08-01, and every `F:\anything\proj\pyproject.toml` since then
|
|
902
|
+
resolved its project root to `F:\`.
|
|
903
|
+
|
|
904
|
+
The invariant that broke was never written down as one, which is exactly why it lasted.
|
|
905
|
+
The fix makes the implicit rule explicit: a `.turnloop` counts only if it holds
|
|
906
|
+
something a human put there — `settings.json`, `settings.local.json`, `commands/`,
|
|
907
|
+
`skills/`, `TURNLOOP.md` — and one holding only an auto-created `sessions/` is ignored.
|
|
908
|
+
The home directory is excluded outright on top of that, since `~/.turnloop` is the
|
|
909
|
+
user-scope settings location and can never mean "project." The false invariant had
|
|
910
|
+
been there since the tool first created `.turnloop/` on launch; fixed in 0.1.6.
|
|
911
|
+
|
|
912
|
+
**A skill that failed to parse vanished with no error, and the model reported success.**
|
|
913
|
+
|
|
914
|
+
A live run installed a skill whose frontmatter was markdown-bold — `**name**:
|
|
915
|
+
something` — rather than YAML. `yaml.safe_load` returned no usable keys,
|
|
916
|
+
`load_skills` hit its "no description" skip, and the skill disappeared: no exception,
|
|
917
|
+
no log line, nothing in `/skills`. The model that had just "installed" it reported
|
|
918
|
+
success, because from its side the write succeeded and nothing said otherwise.
|
|
919
|
+
|
|
920
|
+
The bug was not the parser rejecting bad frontmatter — that part is correct. It was
|
|
921
|
+
that a rejection and a nonexistent skill looked identical from the outside. `load_skills`
|
|
922
|
+
keeps its exact signature, but a new `rejected_skills()` now returns what was dropped
|
|
923
|
+
and why, `/skills` reports it, and `CONFIG_LAYOUT` shows the literal YAML frontmatter
|
|
924
|
+
block in the system prompt so the model stops guessing at markdown-bold instead.
|
|
925
|
+
Fixed in 0.1.6.
|
|
926
|
+
|
|
927
|
+
**A permission denial that should end in one message looped ten times, and it cost
|
|
928
|
+
real money before anyone noticed — because nothing about it failed a test.**
|
|
929
|
+
|
|
930
|
+
Two defects landed in the same code path with the same symptom. The DENY branch's
|
|
931
|
+
message said only `Permission denied: <rule>`; the ASK-decline branch, which is
|
|
932
|
+
recoverable, had always carried "Do not retry it" — so the weaker wording sat on the
|
|
933
|
+
*permanent* block, the one that most needed it. Fixing the message was not enough on
|
|
934
|
+
its own: the ASK path looped despite already carrying that warning, because prompt
|
|
935
|
+
wording is a request, not a constraint, and a model under pressure to finish a task
|
|
936
|
+
does not reliably honor it.
|
|
937
|
+
|
|
938
|
+
Measured against a live, non-interactive provider — this is what a passing test suite
|
|
939
|
+
cannot see, since nothing here raises or returns the wrong value, it just keeps calling
|
|
940
|
+
a tool that keeps getting denied:
|
|
941
|
+
|
|
942
|
+
- denied `Write .turnloop/settings.local.json`, repeated 10 times → **233,742 input
|
|
943
|
+
tokens, $0.0372**
|
|
944
|
+
- after the message fix alone → **10,654 tokens, $0.0017**, one attempt then a correct
|
|
945
|
+
proposal to the user
|
|
946
|
+
- a separate ASK-decline loop on `dir`/`mkdir`, before any fix → **199,352 tokens,
|
|
947
|
+
$0.0317**
|
|
948
|
+
|
|
949
|
+
So wording is now backed by a deterministic guard in `ToolRunner` rather than trusted
|
|
950
|
+
alone: identity is tool name plus pydantic-normalized args, scope is the session, one
|
|
951
|
+
free retry, and the entry clears the moment the call is allowed or approved — so a
|
|
952
|
+
permission granted mid-session un-poisons a call that was denied earlier, instead of
|
|
953
|
+
blocking it forever. Fixed in 0.1.6.
|
|
954
|
+
|
|
955
|
+
**The lesson, eight times over in one project:** an 84-run sweep failing while printing
|
|
844
956
|
a tidy report of zeros; a tool returning empty with `ok: true`; a sweep sitting at
|
|
845
957
|
10/18 doing nothing for 25 minutes; an adapter passing its live test and failing on
|
|
846
|
-
the turn the test never made
|
|
847
|
-
|
|
958
|
+
the turn the test never made; a feature with green tests that crashed on its first
|
|
959
|
+
keystroke; an implicit invariant that a docstring never stated, so nobody noticed it
|
|
960
|
+
was false; a silent failure that a model reported as success; a cost bug that no
|
|
961
|
+
assertion could ever have caught, because every individual call did exactly what it
|
|
962
|
+
was told. Each was caught only by looking past the summary line — and most of them
|
|
963
|
+
only by using the thing instead of testing it.
|
|
848
964
|
|
|
849
965
|
---
|
|
850
966
|
|
|
@@ -878,26 +994,27 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
|
|
|
878
994
|
## Testing
|
|
879
995
|
|
|
880
996
|
```bash
|
|
881
|
-
pytest #
|
|
997
|
+
pytest # 318 tests, no network
|
|
882
998
|
ruff check turnloop
|
|
883
999
|
mypy turnloop
|
|
884
1000
|
```
|
|
885
1001
|
|
|
886
1002
|
| file | tests | |
|
|
887
1003
|
|---|---|---|
|
|
888
|
-
| `test_permissions.py` |
|
|
1004
|
+
| `test_permissions.py` | 50 | rule grammar, compound-command splitting, mode enforcement |
|
|
889
1005
|
| `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
|
|
890
1006
|
| `test_providers.py` | 35 | adapters vs recorded `.sse`, image blocks, thought signatures |
|
|
891
1007
|
| `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
|
|
892
|
-
| `
|
|
893
|
-
| `
|
|
894
|
-
| `test_config.py` |
|
|
1008
|
+
| `test_hooks_mcp_commands.py` | 28 | lifecycle hooks, MCP client, slash commands |
|
|
1009
|
+
| `test_tui.py` | 24 | Textual snapshots, `/config` and `/mcp add` driven through a real app |
|
|
1010
|
+
| `test_config.py` | 23 | layering, env overrides, presets, narrow-console guard |
|
|
1011
|
+
| `test_loop.py` | 23 | streaming, truncation, iteration cap |
|
|
895
1012
|
| `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
|
|
896
|
-
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
897
1013
|
| `test_configio.py` | 15 | minimal-diff writes, atomic save, secret-bearing MCP targets, the settings deny rules |
|
|
1014
|
+
| `test_bash.py` | 14 | shell selection, process-tree kill |
|
|
898
1015
|
| `test_config_screen.py` | 10 | provider form validation, caps derivation, env indicator leaks |
|
|
899
1016
|
| `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
|
|
900
|
-
| `
|
|
1017
|
+
| `test_system_prompt_layout.py` | 7 | `CONFIG_LAYOUT` presence, token cost, `minimal` variant omission |
|
|
901
1018
|
|
|
902
1019
|
`conftest.py` monkeypatches httpx's transport to raise unless a test is marked
|
|
903
1020
|
`live` — **network is off at the transport layer**, so a forgotten real call fails
|
|
@@ -944,6 +1061,16 @@ Slash commands live in `.turnloop/commands/*.md`, skills in
|
|
|
944
1061
|
in the system prompt and load their body on demand, which on a 65k window is the
|
|
945
1062
|
difference between having skills and not.
|
|
946
1063
|
|
|
1064
|
+
The system prompt also documents this layout to the model itself, as `CONFIG_LAYOUT`
|
|
1065
|
+
— 326 tokens, in the cacheable region, omitted from the `minimal` variant. Without it
|
|
1066
|
+
the model cannot configure the tool it is running inside: asked to add an MCP server
|
|
1067
|
+
it invented `.turnloop/turnloop.config.json`, and asked to install a skill it wrote to
|
|
1068
|
+
`examples/` — both real outputs from live runs. With it, both go to the right place
|
|
1069
|
+
first try. It includes the literal YAML frontmatter a skill needs, since a skill with
|
|
1070
|
+
markdown-bold frontmatter instead of YAML used to vanish with no diagnostic; `/skills`
|
|
1071
|
+
now also reports anything found on disk but rejected, with the reason and path — see
|
|
1072
|
+
[Bugs worth reading about](#bugs-worth-reading-about).
|
|
1073
|
+
|
|
947
1074
|
Shell expansion inside a command template (`` !`cmd` ``) goes through the permission
|
|
948
1075
|
engine like any other Bash call. A markdown file in a repository is not trusted input
|
|
949
1076
|
just because it is on disk.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "turnloop"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.6"
|
|
8
8
|
description = "A local-first agentic coding harness: streaming tool loop, permission gating, context compaction, subagents, and any LLM provider."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|