turnloop 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. {turnloop-0.1.0 → turnloop-0.1.2}/.github/workflows/ci.yml +2 -2
  2. {turnloop-0.1.0 → turnloop-0.1.2}/PKG-INFO +88 -35
  3. {turnloop-0.1.0 → turnloop-0.1.2}/README.md +85 -32
  4. {turnloop-0.1.0 → turnloop-0.1.2}/pyproject.toml +8 -3
  5. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_config.py +256 -201
  6. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_experiments.py +27 -0
  7. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_loop.py +11 -9
  8. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_providers.py +64 -1
  9. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_tools_files.py +27 -0
  10. turnloop-0.1.2/tests/test_websearch.py +160 -0
  11. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/__init__.py +1 -1
  12. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/agent/loop.py +17 -6
  13. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/cli.py +241 -202
  14. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/config.py +16 -4
  15. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/core/messages.py +14 -1
  16. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/diagnostics.py +6 -2
  17. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/runner.py +4 -1
  18. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/mcp/client.py +4 -1
  19. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/permissions/engine.py +3 -3
  20. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/providers/anthropic.py +6 -0
  21. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/providers/base.py +5 -0
  22. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/providers/gemini.py +5 -0
  23. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/providers/openai_compat.py +16 -4
  24. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/sessions/store.py +1 -1
  25. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/base.py +5 -1
  26. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/builtin.py +2 -0
  27. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/read.py +50 -3
  28. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/runner.py +17 -12
  29. turnloop-0.1.2/turnloop/tools/websearch.py +318 -0
  30. {turnloop-0.1.0 → turnloop-0.1.2}/.gitignore +0 -0
  31. {turnloop-0.1.0 → turnloop-0.1.2}/.turnloop/.gitignore +0 -0
  32. {turnloop-0.1.0 → turnloop-0.1.2}/tests/conftest.py +0 -0
  33. {turnloop-0.1.0 → turnloop-0.1.2}/tests/fixtures/anthropic_tool_call.sse +0 -0
  34. {turnloop-0.1.0 → turnloop-0.1.2}/tests/fixtures/glm_tool_call.sse +0 -0
  35. {turnloop-0.1.0 → turnloop-0.1.2}/tests/fixtures/mcp_echo_server.py +0 -0
  36. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_bash.py +0 -0
  37. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_compaction.py +0 -0
  38. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_hooks_mcp_commands.py +0 -0
  39. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_permissions.py +0 -0
  40. {turnloop-0.1.0 → turnloop-0.1.2}/tests/test_tui.py +0 -0
  41. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/__main__.py +0 -0
  42. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/agent/__init__.py +0 -0
  43. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/agent/factory.py +0 -0
  44. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/agent/headless.py +0 -0
  45. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/agent/subagent.py +0 -0
  46. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/agent/system_prompt.py +0 -0
  47. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/commands/__init__.py +0 -0
  48. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/commands/dispatch.py +0 -0
  49. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/commands/loader.py +0 -0
  50. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/context/__init__.py +0 -0
  51. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/context/budget.py +0 -0
  52. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/context/compaction.py +0 -0
  53. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/context/memory.py +0 -0
  54. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/core/__init__.py +0 -0
  55. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/core/events.py +0 -0
  56. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/core/ids.py +0 -0
  57. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/core/tokens.py +0 -0
  58. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/errors.py +0 -0
  59. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/__init__.py +0 -0
  60. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/bakeoff.yaml +0 -0
  61. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
  62. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/context-glm.yaml +0 -0
  63. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/context.yaml +0 -0
  64. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
  65. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/groq-free.yaml +0 -0
  66. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
  67. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/hard-glm.yaml +0 -0
  68. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/loop.yaml +0 -0
  69. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
  70. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/reliability.yaml +0 -0
  71. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
  72. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/configs/smoke.yaml +0 -0
  73. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/graders.py +0 -0
  74. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/report.py +0 -0
  75. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/default.yaml +0 -0
  76. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
  77. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
  78. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
  79. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
  80. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
  81. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
  82. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
  83. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
  84. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
  85. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
  86. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
  87. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
  88. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
  89. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
  90. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
  91. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
  92. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
  93. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
  94. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
  95. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
  96. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
  97. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
  98. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
  99. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
  100. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
  101. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
  102. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
  103. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
  104. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
  105. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
  106. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
  107. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
  108. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
  109. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
  110. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
  111. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
  112. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
  113. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
  114. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
  115. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
  116. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
  117. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/hooks/__init__.py +0 -0
  118. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/hooks/runner.py +0 -0
  119. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/mcp/__init__.py +0 -0
  120. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/mcp/adapter.py +0 -0
  121. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/permissions/__init__.py +0 -0
  122. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/permissions/rules.py +0 -0
  123. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/providers/__init__.py +0 -0
  124. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/providers/mock.py +0 -0
  125. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/providers/pricing.py +0 -0
  126. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/providers/registry.py +0 -0
  127. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/providers/sse.py +0 -0
  128. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/sessions/__init__.py +0 -0
  129. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/sessions/models.py +0 -0
  130. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/__init__.py +0 -0
  131. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/ask.py +0 -0
  132. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/bash.py +0 -0
  133. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/edit.py +0 -0
  134. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/glob.py +0 -0
  135. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/grep.py +0 -0
  136. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/shell.py +0 -0
  137. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/skill.py +0 -0
  138. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/task.py +0 -0
  139. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/textio.py +0 -0
  140. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/todo.py +0 -0
  141. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/webfetch.py +0 -0
  142. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tools/write.py +0 -0
  143. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tui/__init__.py +0 -0
  144. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tui/app.py +0 -0
  145. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tui/app.tcss +0 -0
  146. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tui/bridge.py +0 -0
  147. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tui/widgets/__init__.py +0 -0
  148. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tui/widgets/permission.py +0 -0
  149. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tui/widgets/status.py +0 -0
  150. {turnloop-0.1.0 → turnloop-0.1.2}/turnloop/tui/widgets/transcript.py +0 -0
@@ -16,9 +16,9 @@ jobs:
16
16
  python: ["3.11", "3.13"]
17
17
  runs-on: ${{ matrix.os }}
18
18
  steps:
19
- - uses: actions/checkout@v4
19
+ - uses: actions/checkout@v5
20
20
 
21
- - uses: actions/setup-python@v5
21
+ - uses: actions/setup-python@v6
22
22
  with:
23
23
  python-version: ${{ matrix.python }}
24
24
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: turnloop
3
- Version: 0.1.0
3
+ Version: 0.1.2
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
@@ -20,11 +20,11 @@ Requires-Dist: pydantic>=2.7
20
20
  Requires-Dist: pyyaml>=6.0
21
21
  Requires-Dist: textual>=0.80
22
22
  Provides-Extra: dev
23
- Requires-Dist: mypy>=1.11; extra == 'dev'
23
+ Requires-Dist: mypy<3,>=2.1; extra == 'dev'
24
24
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
25
25
  Requires-Dist: pytest-textual-snapshot>=1.0; extra == 'dev'
26
26
  Requires-Dist: pytest>=8.0; extra == 'dev'
27
- Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Requires-Dist: ruff<0.17,>=0.16.1; extra == 'dev'
28
28
  Description-Content-Type: text/markdown
29
29
 
30
30
  # turnloop
@@ -38,18 +38,18 @@ OpenAI-compatible endpoint, and a **self-hosted GLM-5.2 (744B MoE, W4A16) on 4×
38
38
  that this project treats as a first-class target rather than an afterthought.
39
39
 
40
40
  ```bash
41
- pip install -e .
41
+ pip install turnloop
42
42
 
43
43
  tl # TUI, mock provider, zero cost
44
44
  tl -p "fix the failing test" # headless, one turn, prints and exits
45
45
  tl --provider glm # the self-hosted endpoint
46
46
  tl doctor # diagnose shell, providers, context budget
47
- tl experiment run turnloop/experiments/configs/smoke.yaml
47
+ tl experiment run smoke # a measurement run, offline and free
48
48
  ```
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
- ~18,000 lines of Python, 230 tests, no network in the default test run.
52
+ ~18,000 lines of Python, 249 tests, no network in the default test run.
53
53
 
54
54
  ---
55
55
 
@@ -94,16 +94,25 @@ something.
94
94
 
95
95
  ## Install
96
96
 
97
- Requires Python ≥ 3.11.
97
+ Requires Python ≥ 3.11. Four dependencies, no compiler, no GPU needed to run it.
98
+
99
+ ```bash
100
+ pip install turnloop
101
+ ```
102
+
103
+ That is the whole install. It works offline against the `mock` provider immediately —
104
+ no API key required to try it.
105
+
106
+ From source, for development:
98
107
 
99
108
  ```bash
100
109
  git clone https://github.com/Pranesh-2005/turnloop
101
110
  cd turnloop
102
- pip install -e . # editable: source edits take effect immediately
103
- pip install -e ".[dev]" # plus pytest, ruff, mypy
111
+ pip install -e ".[dev]" # editable, plus pytest, ruff, mypy
104
112
  ```
105
113
 
106
- Installs two console scripts, `turnloop` and `tl`. They are the same entry point.
114
+ Either way you get two console scripts, `turnloop` and `tl`. They are the same entry
115
+ point.
107
116
 
108
117
  Verify:
109
118
 
@@ -203,18 +212,36 @@ Custom commands live in `.turnloop/commands/*.md`.
203
212
  - **`TURNLOOP.md`** at the project root for standing instructions (`CLAUDE.md` and
204
213
  `AGENTS.md` are also read)
205
214
  - **Text files of any kind** via the `Read` tool — source, JSON, YAML, CSV, Markdown
215
+ - **Images** — PNG, JPEG, GIF and WEBP via `Read`, detected by magic bytes rather
216
+ than extension, on providers whose `Capabilities.supports_vision` is true
217
+ (Anthropic, OpenAI, Gemini). On a text-only provider `Read` refuses the image and
218
+ names the provider, instead of sending a payload the model cannot parse — which
219
+ matters here, because GLM-5.2 W4A16 as deployed is text-only.
206
220
 
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.
221
+ **Not supported:** binary files other than those image formats refused by a
222
+ null-byte sniff, which covers PDFs, `.docx` and archives. Anything convertible on
223
+ the command line comes back in scope, because `Bash` exists: `pdftotext spec.pdf -`
224
+ and the agent will reach for it itself.
212
225
 
213
226
  ### Tools
214
227
 
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.
228
+ Eleven registered: `Read` `Write` `Edit` `Glob` `Grep` `Bash` `TodoWrite` `Task`
229
+ `WebFetch` `WebSearch` `AskUserQuestion`, plus `Skill` when the project has skills.
230
+ Schemas are generated from pydantic models, so the declared schema and the
231
+ validation are the same object and cannot drift.
232
+
233
+ `WebSearch` and `WebFetch` are the pair: search finds URLs, fetch reads one. Both
234
+ are `read_only` and `parallel_safe`, so they work in plan mode and run concurrently
235
+ with `Read`/`Grep`. **Search needs no API key** — the default DuckDuckGo backend
236
+ works on a fresh install with an empty config. Brave and Tavily are opt-in upgrades:
237
+
238
+ ```json
239
+ { "search": { "backend": "brave", "api_key_env": "BRAVE_API_KEY" } }
240
+ ```
241
+
242
+ A keyed backend only errors if you actually select one, so the zero-config path
243
+ never breaks. Results are budgeted for a small window — 10 results maximum,
244
+ 200-character snippets, 4k characters total, roughly 1k tokens against GLM's 65k.
218
245
 
219
246
  ---
220
247
 
@@ -225,8 +252,8 @@ turnloop/
225
252
  core/ messages, events, token estimation, ids
226
253
  providers/ anthropic · openai_compat (GLM/Groq/NVIDIA/vLLM) · gemini · mock
227
254
  + 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
255
+ tools/ Read Edit Write Bash Glob Grep TodoWrite Task WebFetch WebSearch
256
+ Ask Skill + runner, shell scanner, text io
230
257
  permissions/ rule grammar, matcher, engine
231
258
  agent/ loop, system prompt, subagents, factory, headless
232
259
  context/ budget, three-tier compaction, memory files
@@ -364,7 +391,7 @@ All nine ship preconfigured. `doctor` shows which have keys present.
364
391
  | `groq` | openai_compat | `openai/gpt-oss-120b` | `GROQ_API_KEY` | free tier: 8k TPM |
365
392
  | `anthropic` | anthropic | `claude-sonnet-4-5` | `ANTHROPIC_API_KEY` | |
366
393
  | `openai` | openai_compat | `gpt-4.1` | `OPENAI_API_KEY` | |
367
- | `gemini` | gemini | `gemini-2.5-pro` | `GEMINI_API_KEY` | |
394
+ | `gemini` | gemini | `gemini-flash-latest` | `GEMINI_API_KEY` | alias, not a pin — see below |
368
395
  | `blaxel` | openai_compat | `gpt-4o-mini` | `BL_API_KEY` | sandbox; ignores the model field |
369
396
  | `ollama` | openai_compat | `qwen2.5-coder:14b` | none | local |
370
397
 
@@ -393,6 +420,15 @@ smoke tests, not for experiments.
393
420
  NVIDIA model would silently inherit the self-hosted 65k window and $18.16/hr cost
394
421
  basis. A regression test guards it.
395
422
 
423
+ **Gemini's default model is an alias because pinned ones rot in two different ways.**
424
+ 0.1.1 shipped `gemini-2.5-pro`, whose free-tier quota is literally zero: a brand-new
425
+ key gets `429 ... generate_content_free_tier_input_token_count, limit: 0` on its first
426
+ request, which reads as a broken adapter rather than a model that was never free.
427
+ Pinning `gemini-2.5-flash` instead fails the other way — `404 no longer available to
428
+ new users`. `gemini-flash-latest` follows Google's own pointer. Worth knowing when
429
+ reading a 429 from any provider: the status code says "slow down", the body says
430
+ whether slowing down will ever help.
431
+
396
432
  ---
397
433
 
398
434
  ## Adding a provider
@@ -549,11 +585,16 @@ the 12 minutes of loading weights, so the two numbers do not match by design.
549
585
  ## Experiments
550
586
 
551
587
  ```bash
552
- tl experiment run turnloop/experiments/configs/smoke.yaml # free, offline
553
- tl experiment run turnloop/experiments/configs/bakeoff.yaml # real models
588
+ tl experiment run smoke # free, offline — a shipped config, by name
589
+ tl experiment run bakeoff # real models, real money
590
+ tl experiment run ./my-config.yaml # or your own, by path
554
591
  tl experiment report .turnloop/experiments/<run-dir>
555
592
  ```
556
593
 
594
+ A bare name resolves against the configs bundled in the package, so the shipped
595
+ experiments are runnable straight from a `pip install`. A local file always wins, so
596
+ your own `smoke.yaml` is never shadowed by ours.
597
+
557
598
  Thirteen configs ship, in four families, one runner:
558
599
 
559
600
  - **`bakeoff.yaml`** — same tasks across GLM-5.2, Claude, GPT, Gemini, Groq.
@@ -821,7 +862,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
821
862
  ## Testing
822
863
 
823
864
  ```bash
824
- pytest # 230 tests, no network
865
+ pytest # 249 tests, no network
825
866
  ruff check turnloop
826
867
  mypy turnloop
827
868
  ```
@@ -829,14 +870,15 @@ mypy turnloop
829
870
  | file | tests | |
830
871
  |---|---|---|
831
872
  | `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 |
873
+ | `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
874
+ | `test_providers.py` | 31 | adapters against recorded `.sse` fixtures, image block encoding |
875
+ | `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
835
876
  | `test_loop.py` | 20 | streaming, truncation, iteration cap |
836
877
  | `test_hooks_mcp_commands.py` | 20 | lifecycle hooks, MCP client, slash commands |
837
878
  | `test_config.py` | 19 | layering, env overrides, capability presets |
838
879
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
839
880
  | `test_bash.py` | 14 | shell selection, process-tree kill |
881
+ | `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
840
882
  | `test_tui.py` | 8 | Textual snapshots |
841
883
 
842
884
  `conftest.py` monkeypatches httpx's transport to raise unless a test is marked
@@ -875,7 +917,8 @@ sessions wrongly.
875
917
 
876
918
  API keys come from the environment or a `.env` at the project root. A real environment
877
919
  variable always wins over the file, and `doctor` reports which key *names* were loaded
878
- — never their values.
920
+ — never their values. This applies to `search.api_key_env` exactly as it does to a
921
+ provider's: settings files name the variable, never the secret.
879
922
 
880
923
  Project instructions go in `TURNLOOP.md` (`CLAUDE.md` and `AGENTS.md` are also read).
881
924
  Slash commands live in `.turnloop/commands/*.md`, skills in
@@ -893,13 +936,23 @@ just because it is on disk.
893
936
 
894
937
  Stated rather than buried:
895
938
 
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.
939
+ - **Image support is verified on two of three encoders.** The `openai_compat` data-URL
940
+ path and Gemini's `inline_data` path both round-trip a real image live; Anthropic's
941
+ is still fixture-only. None of it can be exercised on the primary target, since
942
+ GLM-5.2 W4A16 as deployed is text-only.
943
+ - **No binary file support** beyond those image formats. Null-byte sniff refuses the
944
+ rest. Convert via `Bash`.
945
+ - **`WebSearch`'s default backend parses HTML, not an API.** DuckDuckGo will rate-limit
946
+ and will eventually change its markup; both are handled as distinct, actionable
947
+ errors rather than a crash, but a keyed backend is the durable choice for anything
948
+ that matters. That trade is deliberate: the default has to work with no key at all.
949
+ - **The Anthropic adapter is fixture-tested but has never sent a real request.** It is
950
+ coded against the documented wire format and passes against recorded SSE, which is not
951
+ the same as verified. It is now the only one: the Gemini adapter is verified live
952
+ (text, tool calls and images on `gemini-flash-latest`), and OpenAI needs no separate
953
+ verification because `openai` is `kind="openai_compat"` — the same adapter Groq,
954
+ NVIDIA, Ollama, Blaxel and the self-hosted GLM endpoint all run through, making it
955
+ the most exercised code path in the repo.
903
956
  - **`subtle_spec_edge` currently passes for nobody** (0/5 on gpt-4o-mini). The rewrite
904
957
  overshot — runs die on collection errors and leave `NotImplementedError`, which is
905
958
  failure-to-produce-working-code, not falling for the decoy. It needs another pass.
@@ -9,18 +9,18 @@ OpenAI-compatible endpoint, and a **self-hosted GLM-5.2 (744B MoE, W4A16) on 4×
9
9
  that this project treats as a first-class target rather than an afterthought.
10
10
 
11
11
  ```bash
12
- pip install -e .
12
+ pip install turnloop
13
13
 
14
14
  tl # TUI, mock provider, zero cost
15
15
  tl -p "fix the failing test" # headless, one turn, prints and exits
16
16
  tl --provider glm # the self-hosted endpoint
17
17
  tl doctor # diagnose shell, providers, context budget
18
- tl experiment run turnloop/experiments/configs/smoke.yaml
18
+ tl experiment run smoke # a measurement run, offline and free
19
19
  ```
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
- ~18,000 lines of Python, 230 tests, no network in the default test run.
23
+ ~18,000 lines of Python, 249 tests, no network in the default test run.
24
24
 
25
25
  ---
26
26
 
@@ -65,16 +65,25 @@ something.
65
65
 
66
66
  ## Install
67
67
 
68
- Requires Python ≥ 3.11.
68
+ Requires Python ≥ 3.11. Four dependencies, no compiler, no GPU needed to run it.
69
+
70
+ ```bash
71
+ pip install turnloop
72
+ ```
73
+
74
+ That is the whole install. It works offline against the `mock` provider immediately —
75
+ no API key required to try it.
76
+
77
+ From source, for development:
69
78
 
70
79
  ```bash
71
80
  git clone https://github.com/Pranesh-2005/turnloop
72
81
  cd turnloop
73
- pip install -e . # editable: source edits take effect immediately
74
- pip install -e ".[dev]" # plus pytest, ruff, mypy
82
+ pip install -e ".[dev]" # editable, plus pytest, ruff, mypy
75
83
  ```
76
84
 
77
- Installs two console scripts, `turnloop` and `tl`. They are the same entry point.
85
+ Either way you get two console scripts, `turnloop` and `tl`. They are the same entry
86
+ point.
78
87
 
79
88
  Verify:
80
89
 
@@ -174,18 +183,36 @@ Custom commands live in `.turnloop/commands/*.md`.
174
183
  - **`TURNLOOP.md`** at the project root for standing instructions (`CLAUDE.md` and
175
184
  `AGENTS.md` are also read)
176
185
  - **Text files of any kind** via the `Read` tool — source, JSON, YAML, CSV, Markdown
186
+ - **Images** — PNG, JPEG, GIF and WEBP via `Read`, detected by magic bytes rather
187
+ than extension, on providers whose `Capabilities.supports_vision` is true
188
+ (Anthropic, OpenAI, Gemini). On a text-only provider `Read` refuses the image and
189
+ names the provider, instead of sending a payload the model cannot parse — which
190
+ matters here, because GLM-5.2 W4A16 as deployed is text-only.
177
191
 
178
- **Not supported:** images (there is no image content block in the message model —
179
- only `TextBlock`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`) and binary
180
- files (refused by a null-byte sniff, which covers PDFs, `.docx`, archives and
181
- images-on-disk). Anything convertible on the command line comes back in scope,
182
- because `Bash` exists: `pdftotext spec.pdf -` and the agent will reach for it itself.
192
+ **Not supported:** binary files other than those image formats refused by a
193
+ null-byte sniff, which covers PDFs, `.docx` and archives. Anything convertible on
194
+ the command line comes back in scope, because `Bash` exists: `pdftotext spec.pdf -`
195
+ and the agent will reach for it itself.
183
196
 
184
197
  ### Tools
185
198
 
186
- Ten registered: `Read` `Write` `Edit` `Glob` `Grep` `Bash` `TodoWrite` `Task`
187
- `WebFetch` `AskUserQuestion`. Schemas are generated from pydantic models, so the
188
- declared schema and the validation are the same object and cannot drift.
199
+ Eleven registered: `Read` `Write` `Edit` `Glob` `Grep` `Bash` `TodoWrite` `Task`
200
+ `WebFetch` `WebSearch` `AskUserQuestion`, plus `Skill` when the project has skills.
201
+ Schemas are generated from pydantic models, so the declared schema and the
202
+ validation are the same object and cannot drift.
203
+
204
+ `WebSearch` and `WebFetch` are the pair: search finds URLs, fetch reads one. Both
205
+ are `read_only` and `parallel_safe`, so they work in plan mode and run concurrently
206
+ with `Read`/`Grep`. **Search needs no API key** — the default DuckDuckGo backend
207
+ works on a fresh install with an empty config. Brave and Tavily are opt-in upgrades:
208
+
209
+ ```json
210
+ { "search": { "backend": "brave", "api_key_env": "BRAVE_API_KEY" } }
211
+ ```
212
+
213
+ A keyed backend only errors if you actually select one, so the zero-config path
214
+ never breaks. Results are budgeted for a small window — 10 results maximum,
215
+ 200-character snippets, 4k characters total, roughly 1k tokens against GLM's 65k.
189
216
 
190
217
  ---
191
218
 
@@ -196,8 +223,8 @@ turnloop/
196
223
  core/ messages, events, token estimation, ids
197
224
  providers/ anthropic · openai_compat (GLM/Groq/NVIDIA/vLLM) · gemini · mock
198
225
  + sse parser, pricing/capability presets, registry
199
- tools/ Read Edit Write Bash Glob Grep TodoWrite Task WebFetch Ask Skill
200
- + runner, shell scanner, text io
226
+ tools/ Read Edit Write Bash Glob Grep TodoWrite Task WebFetch WebSearch
227
+ Ask Skill + runner, shell scanner, text io
201
228
  permissions/ rule grammar, matcher, engine
202
229
  agent/ loop, system prompt, subagents, factory, headless
203
230
  context/ budget, three-tier compaction, memory files
@@ -335,7 +362,7 @@ All nine ship preconfigured. `doctor` shows which have keys present.
335
362
  | `groq` | openai_compat | `openai/gpt-oss-120b` | `GROQ_API_KEY` | free tier: 8k TPM |
336
363
  | `anthropic` | anthropic | `claude-sonnet-4-5` | `ANTHROPIC_API_KEY` | |
337
364
  | `openai` | openai_compat | `gpt-4.1` | `OPENAI_API_KEY` | |
338
- | `gemini` | gemini | `gemini-2.5-pro` | `GEMINI_API_KEY` | |
365
+ | `gemini` | gemini | `gemini-flash-latest` | `GEMINI_API_KEY` | alias, not a pin — see below |
339
366
  | `blaxel` | openai_compat | `gpt-4o-mini` | `BL_API_KEY` | sandbox; ignores the model field |
340
367
  | `ollama` | openai_compat | `qwen2.5-coder:14b` | none | local |
341
368
 
@@ -364,6 +391,15 @@ smoke tests, not for experiments.
364
391
  NVIDIA model would silently inherit the self-hosted 65k window and $18.16/hr cost
365
392
  basis. A regression test guards it.
366
393
 
394
+ **Gemini's default model is an alias because pinned ones rot in two different ways.**
395
+ 0.1.1 shipped `gemini-2.5-pro`, whose free-tier quota is literally zero: a brand-new
396
+ key gets `429 ... generate_content_free_tier_input_token_count, limit: 0` on its first
397
+ request, which reads as a broken adapter rather than a model that was never free.
398
+ Pinning `gemini-2.5-flash` instead fails the other way — `404 no longer available to
399
+ new users`. `gemini-flash-latest` follows Google's own pointer. Worth knowing when
400
+ reading a 429 from any provider: the status code says "slow down", the body says
401
+ whether slowing down will ever help.
402
+
367
403
  ---
368
404
 
369
405
  ## Adding a provider
@@ -520,11 +556,16 @@ the 12 minutes of loading weights, so the two numbers do not match by design.
520
556
  ## Experiments
521
557
 
522
558
  ```bash
523
- tl experiment run turnloop/experiments/configs/smoke.yaml # free, offline
524
- tl experiment run turnloop/experiments/configs/bakeoff.yaml # real models
559
+ tl experiment run smoke # free, offline — a shipped config, by name
560
+ tl experiment run bakeoff # real models, real money
561
+ tl experiment run ./my-config.yaml # or your own, by path
525
562
  tl experiment report .turnloop/experiments/<run-dir>
526
563
  ```
527
564
 
565
+ A bare name resolves against the configs bundled in the package, so the shipped
566
+ experiments are runnable straight from a `pip install`. A local file always wins, so
567
+ your own `smoke.yaml` is never shadowed by ours.
568
+
528
569
  Thirteen configs ship, in four families, one runner:
529
570
 
530
571
  - **`bakeoff.yaml`** — same tasks across GLM-5.2, Claude, GPT, Gemini, Groq.
@@ -792,7 +833,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
792
833
  ## Testing
793
834
 
794
835
  ```bash
795
- pytest # 230 tests, no network
836
+ pytest # 249 tests, no network
796
837
  ruff check turnloop
797
838
  mypy turnloop
798
839
  ```
@@ -800,14 +841,15 @@ mypy turnloop
800
841
  | file | tests | |
801
842
  |---|---|---|
802
843
  | `test_permissions.py` | 48 | rule grammar, compound-command splitting, mode enforcement |
803
- | `test_experiments.py` | 33 | runner, graders, report, suite invariants |
804
- | `test_providers.py` | 27 | adapters against recorded `.sse` fixtures |
805
- | `test_tools_files.py` | 25 | Read/Write/Edit/Glob/Grep, encodings, newline preservation |
844
+ | `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
845
+ | `test_providers.py` | 31 | adapters against recorded `.sse` fixtures, image block encoding |
846
+ | `test_tools_files.py` | 28 | Read/Write/Edit/Glob/Grep, encodings, newlines, image detection |
806
847
  | `test_loop.py` | 20 | streaming, truncation, iteration cap |
807
848
  | `test_hooks_mcp_commands.py` | 20 | lifecycle hooks, MCP client, slash commands |
808
849
  | `test_config.py` | 19 | layering, env overrides, capability presets |
809
850
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
810
851
  | `test_bash.py` | 14 | shell selection, process-tree kill |
852
+ | `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
811
853
  | `test_tui.py` | 8 | Textual snapshots |
812
854
 
813
855
  `conftest.py` monkeypatches httpx's transport to raise unless a test is marked
@@ -846,7 +888,8 @@ sessions wrongly.
846
888
 
847
889
  API keys come from the environment or a `.env` at the project root. A real environment
848
890
  variable always wins over the file, and `doctor` reports which key *names* were loaded
849
- — never their values.
891
+ — never their values. This applies to `search.api_key_env` exactly as it does to a
892
+ provider's: settings files name the variable, never the secret.
850
893
 
851
894
  Project instructions go in `TURNLOOP.md` (`CLAUDE.md` and `AGENTS.md` are also read).
852
895
  Slash commands live in `.turnloop/commands/*.md`, skills in
@@ -864,13 +907,23 @@ just because it is on disk.
864
907
 
865
908
  Stated rather than buried:
866
909
 
867
- - **No image support.** No image content block exists in the message model. Adding one
868
- is small (three adapters, a `supports_vision` flag, `Read` returning it instead of
869
- refusing) but useless on the primary target GLM-5.2 W4A16 as deployed is text-only.
870
- - **No binary file support.** Null-byte sniff refuses them. Convert via `Bash`.
871
- - **The Anthropic, Gemini and OpenAI adapters are fixture-tested but have never sent a
872
- real request.** They are coded against the documented wire format and pass against
873
- recorded SSE, which is not the same as verified.
910
+ - **Image support is verified on two of three encoders.** The `openai_compat` data-URL
911
+ path and Gemini's `inline_data` path both round-trip a real image live; Anthropic's
912
+ is still fixture-only. None of it can be exercised on the primary target, since
913
+ GLM-5.2 W4A16 as deployed is text-only.
914
+ - **No binary file support** beyond those image formats. Null-byte sniff refuses the
915
+ rest. Convert via `Bash`.
916
+ - **`WebSearch`'s default backend parses HTML, not an API.** DuckDuckGo will rate-limit
917
+ and will eventually change its markup; both are handled as distinct, actionable
918
+ errors rather than a crash, but a keyed backend is the durable choice for anything
919
+ that matters. That trade is deliberate: the default has to work with no key at all.
920
+ - **The Anthropic adapter is fixture-tested but has never sent a real request.** It is
921
+ coded against the documented wire format and passes against recorded SSE, which is not
922
+ the same as verified. It is now the only one: the Gemini adapter is verified live
923
+ (text, tool calls and images on `gemini-flash-latest`), and OpenAI needs no separate
924
+ verification because `openai` is `kind="openai_compat"` — the same adapter Groq,
925
+ NVIDIA, Ollama, Blaxel and the self-hosted GLM endpoint all run through, making it
926
+ the most exercised code path in the repo.
874
927
  - **`subtle_spec_edge` currently passes for nobody** (0/5 on gpt-4o-mini). The rewrite
875
928
  overshot — runs die on collection errors and leave `NotImplementedError`, which is
876
929
  failure-to-produce-working-code, not falling for the decoy. It needs another pass.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "turnloop"
7
- version = "0.1.0"
7
+ version = "0.1.2"
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"
@@ -35,8 +35,13 @@ dev = [
35
35
  "pytest>=8.0",
36
36
  "pytest-asyncio>=0.23",
37
37
  "pytest-textual-snapshot>=1.0",
38
- "ruff>=0.6",
39
- "mypy>=1.11",
38
+ # Pinned to a minor range, not floated. Ruff adds lint rules in minor releases,
39
+ # so `>=0.6` means a build that is green today goes red tomorrow with no commit
40
+ # in between — which is how this CI first broke. Bump deliberately.
41
+ "ruff>=0.16.1,<0.17",
42
+ # Must match what is actually run locally, or CI type-checks with a different
43
+ # tool than the author does — which is exactly how this broke.
44
+ "mypy>=2.1,<3",
40
45
  ]
41
46
 
42
47
  [project.scripts]