turnloop 0.1.1__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.1 → turnloop-0.1.2}/.github/workflows/ci.yml +2 -2
  2. {turnloop-0.1.1 → turnloop-0.1.2}/PKG-INFO +65 -26
  3. {turnloop-0.1.1 → turnloop-0.1.2}/README.md +62 -23
  4. {turnloop-0.1.1 → turnloop-0.1.2}/pyproject.toml +8 -3
  5. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_config.py +256 -201
  6. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_loop.py +11 -9
  7. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_providers.py +64 -1
  8. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_tools_files.py +27 -0
  9. turnloop-0.1.2/tests/test_websearch.py +160 -0
  10. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/__init__.py +1 -1
  11. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/agent/loop.py +17 -6
  12. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/cli.py +241 -220
  13. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/config.py +16 -4
  14. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/core/messages.py +14 -1
  15. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/diagnostics.py +6 -2
  16. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/runner.py +4 -1
  17. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/permissions/engine.py +3 -3
  18. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/providers/anthropic.py +6 -0
  19. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/providers/base.py +5 -0
  20. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/providers/gemini.py +5 -0
  21. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/providers/openai_compat.py +16 -4
  22. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/sessions/store.py +1 -1
  23. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/base.py +5 -1
  24. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/builtin.py +2 -0
  25. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/read.py +50 -3
  26. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/runner.py +17 -12
  27. turnloop-0.1.2/turnloop/tools/websearch.py +318 -0
  28. {turnloop-0.1.1 → turnloop-0.1.2}/.gitignore +0 -0
  29. {turnloop-0.1.1 → turnloop-0.1.2}/.turnloop/.gitignore +0 -0
  30. {turnloop-0.1.1 → turnloop-0.1.2}/tests/conftest.py +0 -0
  31. {turnloop-0.1.1 → turnloop-0.1.2}/tests/fixtures/anthropic_tool_call.sse +0 -0
  32. {turnloop-0.1.1 → turnloop-0.1.2}/tests/fixtures/glm_tool_call.sse +0 -0
  33. {turnloop-0.1.1 → turnloop-0.1.2}/tests/fixtures/mcp_echo_server.py +0 -0
  34. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_bash.py +0 -0
  35. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_compaction.py +0 -0
  36. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_experiments.py +0 -0
  37. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_hooks_mcp_commands.py +0 -0
  38. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_permissions.py +0 -0
  39. {turnloop-0.1.1 → turnloop-0.1.2}/tests/test_tui.py +0 -0
  40. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/__main__.py +0 -0
  41. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/agent/__init__.py +0 -0
  42. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/agent/factory.py +0 -0
  43. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/agent/headless.py +0 -0
  44. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/agent/subagent.py +0 -0
  45. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/agent/system_prompt.py +0 -0
  46. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/commands/__init__.py +0 -0
  47. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/commands/dispatch.py +0 -0
  48. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/commands/loader.py +0 -0
  49. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/context/__init__.py +0 -0
  50. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/context/budget.py +0 -0
  51. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/context/compaction.py +0 -0
  52. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/context/memory.py +0 -0
  53. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/core/__init__.py +0 -0
  54. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/core/events.py +0 -0
  55. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/core/ids.py +0 -0
  56. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/core/tokens.py +0 -0
  57. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/errors.py +0 -0
  58. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/__init__.py +0 -0
  59. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/bakeoff.yaml +0 -0
  60. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/ceiling-check.yaml +0 -0
  61. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/context-glm.yaml +0 -0
  62. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/context.yaml +0 -0
  63. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/glm-selfhosted.yaml +0 -0
  64. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/groq-free.yaml +0 -0
  65. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/hard-calibration.yaml +0 -0
  66. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/hard-glm.yaml +0 -0
  67. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/loop.yaml +0 -0
  68. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/nvidia-smoke.yaml +0 -0
  69. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/reliability.yaml +0 -0
  70. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/rewrite-calibration.yaml +0 -0
  71. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/configs/smoke.yaml +0 -0
  72. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/graders.py +0 -0
  73. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/report.py +0 -0
  74. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/default.yaml +0 -0
  75. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_1.py +0 -0
  76. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_2.py +0 -0
  77. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_3.py +0 -0
  78. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_4.py +0 -0
  79. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_5.py +0 -0
  80. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_6.py +0 -0
  81. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_7.py +0 -0
  82. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/bulky/module_8.py +0 -0
  83. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/circular_import/models.py +0 -0
  84. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/circular_import/services.py +0 -0
  85. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +0 -0
  86. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cli_flag/app.py +0 -0
  87. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +0 -0
  88. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +0 -0
  89. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +0 -0
  90. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +0 -0
  91. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/empty/.keep +0 -0
  92. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/failing_test/calc.py +0 -0
  93. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/failing_test/test_calc.py +0 -0
  94. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/generate_bulky.py +0 -0
  95. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/grep_edit/ingest.py +0 -0
  96. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/grep_edit/load.py +0 -0
  97. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/grep_edit/transform.py +0 -0
  98. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +0 -0
  99. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +0 -0
  100. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +0 -0
  101. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +0 -0
  102. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +0 -0
  103. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/needle/inventory.py +0 -0
  104. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/needle/pricing.py +0 -0
  105. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/needle/shipping.py +0 -0
  106. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +0 -0
  107. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/regression_trap/validators.py +0 -0
  108. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/rename/billing.py +0 -0
  109. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/rename/report.py +0 -0
  110. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/rename/test_billing.py +0 -0
  111. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/spec/parser.py +0 -0
  112. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/spec/test_parser.py +0 -0
  113. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +0 -0
  114. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +0 -0
  115. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/experiments/suite/fixtures/util/util.py +0 -0
  116. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/hooks/__init__.py +0 -0
  117. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/hooks/runner.py +0 -0
  118. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/mcp/__init__.py +0 -0
  119. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/mcp/adapter.py +0 -0
  120. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/mcp/client.py +0 -0
  121. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/permissions/__init__.py +0 -0
  122. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/permissions/rules.py +0 -0
  123. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/providers/__init__.py +0 -0
  124. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/providers/mock.py +0 -0
  125. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/providers/pricing.py +0 -0
  126. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/providers/registry.py +0 -0
  127. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/providers/sse.py +0 -0
  128. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/sessions/__init__.py +0 -0
  129. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/sessions/models.py +0 -0
  130. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/__init__.py +0 -0
  131. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/ask.py +0 -0
  132. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/bash.py +0 -0
  133. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/edit.py +0 -0
  134. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/glob.py +0 -0
  135. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/grep.py +0 -0
  136. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/shell.py +0 -0
  137. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/skill.py +0 -0
  138. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/task.py +0 -0
  139. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/textio.py +0 -0
  140. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/todo.py +0 -0
  141. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/webfetch.py +0 -0
  142. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tools/write.py +0 -0
  143. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tui/__init__.py +0 -0
  144. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tui/app.py +0 -0
  145. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tui/app.tcss +0 -0
  146. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tui/bridge.py +0 -0
  147. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tui/widgets/__init__.py +0 -0
  148. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tui/widgets/permission.py +0 -0
  149. {turnloop-0.1.1 → turnloop-0.1.2}/turnloop/tui/widgets/status.py +0 -0
  150. {turnloop-0.1.1 → 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.1
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
@@ -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
- ~18,000 lines of Python, 233 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
 
@@ -212,18 +212,36 @@ Custom commands live in `.turnloop/commands/*.md`.
212
212
  - **`TURNLOOP.md`** at the project root for standing instructions (`CLAUDE.md` and
213
213
  `AGENTS.md` are also read)
214
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.
215
220
 
216
- **Not supported:** images (there is no image content block in the message model —
217
- only `TextBlock`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`) and binary
218
- files (refused by a null-byte sniff, which covers PDFs, `.docx`, archives and
219
- images-on-disk). Anything convertible on the command line comes back in scope,
220
- 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.
221
225
 
222
226
  ### Tools
223
227
 
224
- Ten registered: `Read` `Write` `Edit` `Glob` `Grep` `Bash` `TodoWrite` `Task`
225
- `WebFetch` `AskUserQuestion`. Schemas are generated from pydantic models, so the
226
- 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.
227
245
 
228
246
  ---
229
247
 
@@ -234,8 +252,8 @@ turnloop/
234
252
  core/ messages, events, token estimation, ids
235
253
  providers/ anthropic · openai_compat (GLM/Groq/NVIDIA/vLLM) · gemini · mock
236
254
  + sse parser, pricing/capability presets, registry
237
- tools/ Read Edit Write Bash Glob Grep TodoWrite Task WebFetch Ask Skill
238
- + 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
239
257
  permissions/ rule grammar, matcher, engine
240
258
  agent/ loop, system prompt, subagents, factory, headless
241
259
  context/ budget, three-tier compaction, memory files
@@ -373,7 +391,7 @@ All nine ship preconfigured. `doctor` shows which have keys present.
373
391
  | `groq` | openai_compat | `openai/gpt-oss-120b` | `GROQ_API_KEY` | free tier: 8k TPM |
374
392
  | `anthropic` | anthropic | `claude-sonnet-4-5` | `ANTHROPIC_API_KEY` | |
375
393
  | `openai` | openai_compat | `gpt-4.1` | `OPENAI_API_KEY` | |
376
- | `gemini` | gemini | `gemini-2.5-pro` | `GEMINI_API_KEY` | |
394
+ | `gemini` | gemini | `gemini-flash-latest` | `GEMINI_API_KEY` | alias, not a pin — see below |
377
395
  | `blaxel` | openai_compat | `gpt-4o-mini` | `BL_API_KEY` | sandbox; ignores the model field |
378
396
  | `ollama` | openai_compat | `qwen2.5-coder:14b` | none | local |
379
397
 
@@ -402,6 +420,15 @@ smoke tests, not for experiments.
402
420
  NVIDIA model would silently inherit the self-hosted 65k window and $18.16/hr cost
403
421
  basis. A regression test guards it.
404
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
+
405
432
  ---
406
433
 
407
434
  ## Adding a provider
@@ -835,7 +862,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
835
862
  ## Testing
836
863
 
837
864
  ```bash
838
- pytest # 233 tests, no network
865
+ pytest # 249 tests, no network
839
866
  ruff check turnloop
840
867
  mypy turnloop
841
868
  ```
@@ -844,13 +871,14 @@ mypy turnloop
844
871
  |---|---|---|
845
872
  | `test_permissions.py` | 48 | rule grammar, compound-command splitting, mode enforcement |
846
873
  | `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
847
- | `test_providers.py` | 27 | adapters against recorded `.sse` fixtures |
848
- | `test_tools_files.py` | 25 | Read/Write/Edit/Glob/Grep, encodings, newline preservation |
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 |
849
876
  | `test_loop.py` | 20 | streaming, truncation, iteration cap |
850
877
  | `test_hooks_mcp_commands.py` | 20 | lifecycle hooks, MCP client, slash commands |
851
878
  | `test_config.py` | 19 | layering, env overrides, capability presets |
852
879
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
853
880
  | `test_bash.py` | 14 | shell selection, process-tree kill |
881
+ | `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
854
882
  | `test_tui.py` | 8 | Textual snapshots |
855
883
 
856
884
  `conftest.py` monkeypatches httpx's transport to raise unless a test is marked
@@ -889,7 +917,8 @@ sessions wrongly.
889
917
 
890
918
  API keys come from the environment or a `.env` at the project root. A real environment
891
919
  variable always wins over the file, and `doctor` reports which key *names* were loaded
892
- — 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.
893
922
 
894
923
  Project instructions go in `TURNLOOP.md` (`CLAUDE.md` and `AGENTS.md` are also read).
895
924
  Slash commands live in `.turnloop/commands/*.md`, skills in
@@ -907,13 +936,23 @@ just because it is on disk.
907
936
 
908
937
  Stated rather than buried:
909
938
 
910
- - **No image support.** No image content block exists in the message model. Adding one
911
- is small (three adapters, a `supports_vision` flag, `Read` returning it instead of
912
- refusing) but useless on the primary target GLM-5.2 W4A16 as deployed is text-only.
913
- - **No binary file support.** Null-byte sniff refuses them. Convert via `Bash`.
914
- - **The Anthropic, Gemini and OpenAI adapters are fixture-tested but have never sent a
915
- real request.** They are coded against the documented wire format and pass against
916
- 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.
917
956
  - **`subtle_spec_edge` currently passes for nobody** (0/5 on gpt-4o-mini). The rewrite
918
957
  overshot — runs die on collection errors and leave `NotImplementedError`, which is
919
958
  failure-to-produce-working-code, not falling for the decoy. It needs another pass.
@@ -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
- ~18,000 lines of Python, 233 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
 
@@ -183,18 +183,36 @@ Custom commands live in `.turnloop/commands/*.md`.
183
183
  - **`TURNLOOP.md`** at the project root for standing instructions (`CLAUDE.md` and
184
184
  `AGENTS.md` are also read)
185
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.
186
191
 
187
- **Not supported:** images (there is no image content block in the message model —
188
- only `TextBlock`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`) and binary
189
- files (refused by a null-byte sniff, which covers PDFs, `.docx`, archives and
190
- images-on-disk). Anything convertible on the command line comes back in scope,
191
- 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.
192
196
 
193
197
  ### Tools
194
198
 
195
- Ten registered: `Read` `Write` `Edit` `Glob` `Grep` `Bash` `TodoWrite` `Task`
196
- `WebFetch` `AskUserQuestion`. Schemas are generated from pydantic models, so the
197
- 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.
198
216
 
199
217
  ---
200
218
 
@@ -205,8 +223,8 @@ turnloop/
205
223
  core/ messages, events, token estimation, ids
206
224
  providers/ anthropic · openai_compat (GLM/Groq/NVIDIA/vLLM) · gemini · mock
207
225
  + sse parser, pricing/capability presets, registry
208
- tools/ Read Edit Write Bash Glob Grep TodoWrite Task WebFetch Ask Skill
209
- + 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
210
228
  permissions/ rule grammar, matcher, engine
211
229
  agent/ loop, system prompt, subagents, factory, headless
212
230
  context/ budget, three-tier compaction, memory files
@@ -344,7 +362,7 @@ All nine ship preconfigured. `doctor` shows which have keys present.
344
362
  | `groq` | openai_compat | `openai/gpt-oss-120b` | `GROQ_API_KEY` | free tier: 8k TPM |
345
363
  | `anthropic` | anthropic | `claude-sonnet-4-5` | `ANTHROPIC_API_KEY` | |
346
364
  | `openai` | openai_compat | `gpt-4.1` | `OPENAI_API_KEY` | |
347
- | `gemini` | gemini | `gemini-2.5-pro` | `GEMINI_API_KEY` | |
365
+ | `gemini` | gemini | `gemini-flash-latest` | `GEMINI_API_KEY` | alias, not a pin — see below |
348
366
  | `blaxel` | openai_compat | `gpt-4o-mini` | `BL_API_KEY` | sandbox; ignores the model field |
349
367
  | `ollama` | openai_compat | `qwen2.5-coder:14b` | none | local |
350
368
 
@@ -373,6 +391,15 @@ smoke tests, not for experiments.
373
391
  NVIDIA model would silently inherit the self-hosted 65k window and $18.16/hr cost
374
392
  basis. A regression test guards it.
375
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
+
376
403
  ---
377
404
 
378
405
  ## Adding a provider
@@ -806,7 +833,7 @@ one-line edit in a CRLF checkout does not produce a whole-file diff.
806
833
  ## Testing
807
834
 
808
835
  ```bash
809
- pytest # 233 tests, no network
836
+ pytest # 249 tests, no network
810
837
  ruff check turnloop
811
838
  mypy turnloop
812
839
  ```
@@ -815,13 +842,14 @@ mypy turnloop
815
842
  |---|---|---|
816
843
  | `test_permissions.py` | 48 | rule grammar, compound-command splitting, mode enforcement |
817
844
  | `test_experiments.py` | 36 | runner, graders, report, suite invariants, config resolution |
818
- | `test_providers.py` | 27 | adapters against recorded `.sse` fixtures |
819
- | `test_tools_files.py` | 25 | Read/Write/Edit/Glob/Grep, encodings, newline preservation |
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 |
820
847
  | `test_loop.py` | 20 | streaming, truncation, iteration cap |
821
848
  | `test_hooks_mcp_commands.py` | 20 | lifecycle hooks, MCP client, slash commands |
822
849
  | `test_config.py` | 19 | layering, env overrides, capability presets |
823
850
  | `test_compaction.py` | 16 | three tiers, tool_use/tool_result invariant |
824
851
  | `test_bash.py` | 14 | shell selection, process-tree kill |
852
+ | `test_websearch.py` | 9 | HTML parsing, backend selection, truncation, failure modes |
825
853
  | `test_tui.py` | 8 | Textual snapshots |
826
854
 
827
855
  `conftest.py` monkeypatches httpx's transport to raise unless a test is marked
@@ -860,7 +888,8 @@ sessions wrongly.
860
888
 
861
889
  API keys come from the environment or a `.env` at the project root. A real environment
862
890
  variable always wins over the file, and `doctor` reports which key *names* were loaded
863
- — 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.
864
893
 
865
894
  Project instructions go in `TURNLOOP.md` (`CLAUDE.md` and `AGENTS.md` are also read).
866
895
  Slash commands live in `.turnloop/commands/*.md`, skills in
@@ -878,13 +907,23 @@ just because it is on disk.
878
907
 
879
908
  Stated rather than buried:
880
909
 
881
- - **No image support.** No image content block exists in the message model. Adding one
882
- is small (three adapters, a `supports_vision` flag, `Read` returning it instead of
883
- refusing) but useless on the primary target GLM-5.2 W4A16 as deployed is text-only.
884
- - **No binary file support.** Null-byte sniff refuses them. Convert via `Bash`.
885
- - **The Anthropic, Gemini and OpenAI adapters are fixture-tested but have never sent a
886
- real request.** They are coded against the documented wire format and pass against
887
- 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.
888
927
  - **`subtle_spec_edge` currently passes for nobody** (0/5 on gpt-4o-mini). The rewrite
889
928
  overshot — runs die on collection errors and leave `NotImplementedError`, which is
890
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.1"
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]