turnloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
turnloop/config.py ADDED
@@ -0,0 +1,425 @@
1
+ """Layered configuration.
2
+
3
+ Precedence, later wins:
4
+ packaged defaults
5
+ ~/.turnloop/settings.json
6
+ <project>/.turnloop/settings.json
7
+ <project>/.turnloop/settings.local.json (gitignored, personal)
8
+ TURNLOOP_* environment variables
9
+ CLI flags
10
+
11
+ Dicts merge recursively; lists replace wholesale. Replacing lists is the right
12
+ call for permission rules — a project that declares its allowlist means *that*
13
+ list, not that list plus whatever the user had globally.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ from pathlib import Path
21
+ from typing import Any, Literal
22
+
23
+ from pydantic import BaseModel, Field
24
+
25
+ from turnloop.errors import ConfigError
26
+ from turnloop.providers.base import Capabilities
27
+ from turnloop.providers.pricing import preset_for
28
+
29
+ PermissionMode = Literal["default", "plan", "auto", "bypass"]
30
+ Verbosity = Literal["terse", "normal", "verbose"]
31
+
32
+ GLM_BASE_URL = "https://praneshmadhan646--glm-5-2-serve-serve.modal.run"
33
+ NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
34
+ BLAXEL_BASE_URL = "https://run.blaxel.ai/pranesh/models/sandbox-openai/v1"
35
+
36
+
37
+ class ProviderConfig(BaseModel):
38
+ kind: Literal["anthropic", "openai_compat", "gemini", "mock"] = "mock"
39
+ model: str = "mock-1"
40
+ base_url: str | None = None
41
+ api_key_env: str | None = None
42
+ api_key: str = ""
43
+ headers: dict[str, str] = Field(default_factory=dict)
44
+ caps: Capabilities = Field(default_factory=Capabilities)
45
+
46
+ # None means "no read timeout", which is required for a cold-booting
47
+ # self-hosted endpoint: time-to-first-token can exceed ten minutes.
48
+ timeout_s: float | None = None
49
+ health_url: str | None = None
50
+ cold_boot_budget_s: float | None = None
51
+
52
+ glm_reasoning: bool = False # read reasoning_content from deltas
53
+ extra_body: dict[str, Any] = Field(default_factory=dict)
54
+
55
+ mock_mode: Literal["scripted", "replay", "chaos", "echo"] = "echo"
56
+ mock_seed: int = 0
57
+ mock_replay_path: str | None = None
58
+
59
+ # Tool descriptions cost context. On a 65k window the terse variants buy
60
+ # back a couple of thousand tokens, so tight endpoints default to terse.
61
+ tool_verbosity: Verbosity | None = None
62
+
63
+
64
+ class PermissionConfig(BaseModel):
65
+ allow: list[str] = Field(default_factory=list)
66
+ deny: list[str] = Field(default_factory=list)
67
+ ask: list[str] = Field(default_factory=list)
68
+ # Absolute paths outside the project that file tools may still touch.
69
+ additional_directories: list[str] = Field(default_factory=list)
70
+
71
+
72
+ class BashConfig(BaseModel):
73
+ shell: str | None = None # explicit path wins over autodetection
74
+ default_timeout_ms: int = 120_000
75
+ max_timeout_ms: int = 600_000
76
+ max_output_chars: int = 30_000
77
+ max_output_lines: int = 2_000
78
+
79
+
80
+ class CompactionConfig(BaseModel):
81
+ enabled: bool = True
82
+ thinking_drop_pressure: float = 0.60
83
+ full_pressure: float = 0.75
84
+ reserve_output_tokens: int = 4_096
85
+ max_tool_result_tokens: int = 2_000
86
+ keep_recent_turns: int = 3
87
+ # Tight windows need aggressive micro-compaction, applied when
88
+ # caps.max_context <= small_context_threshold.
89
+ small_context_threshold: int = 100_000
90
+ small_max_tool_result_tokens: int = 800
91
+
92
+
93
+ class HookSpec(BaseModel):
94
+ type: Literal["command"] = "command"
95
+ command: str
96
+ timeout: int = 60
97
+
98
+
99
+ class HookMatcher(BaseModel):
100
+ matcher: str = "*" # tool name glob; ignored by non-tool events
101
+ hooks: list[HookSpec] = Field(default_factory=list)
102
+
103
+
104
+ class MCPServerConfig(BaseModel):
105
+ transport: Literal["stdio", "sse"] = "stdio"
106
+ command: str | None = None
107
+ args: list[str] = Field(default_factory=list)
108
+ env: dict[str, str] = Field(default_factory=dict)
109
+ url: str | None = None
110
+ headers: dict[str, str] = Field(default_factory=dict)
111
+ enabled: bool = True
112
+ timeout_s: float = 30.0
113
+
114
+
115
+ class Settings(BaseModel):
116
+ provider: str = "mock"
117
+ providers: dict[str, ProviderConfig] = Field(default_factory=dict)
118
+
119
+ permission_mode: PermissionMode = "default"
120
+ permissions: PermissionConfig = Field(default_factory=PermissionConfig)
121
+
122
+ bash: BashConfig = Field(default_factory=BashConfig)
123
+ compaction: CompactionConfig = Field(default_factory=CompactionConfig)
124
+
125
+ hooks: dict[str, list[HookMatcher]] = Field(default_factory=dict)
126
+ mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
127
+
128
+ max_iterations: int = 40
129
+ max_thinking_tokens: int = 8_000
130
+ system_prompt_variant: str = "default"
131
+ tool_verbosity: Verbosity = "normal"
132
+ include_memory: bool = True
133
+
134
+ # Populated by load_settings, not by any file.
135
+ project_root: Path = Field(default_factory=Path.cwd)
136
+ sources: list[str] = Field(default_factory=list)
137
+
138
+ def provider_config(self, name: str | None = None) -> ProviderConfig:
139
+ key = name or self.provider
140
+ if key not in self.providers:
141
+ raise ConfigError(
142
+ f"unknown provider '{key}'. Configured: {', '.join(sorted(self.providers)) or 'none'}"
143
+ )
144
+ return self.providers[key]
145
+
146
+ def verbosity_for(self, provider_name: str | None = None) -> Verbosity:
147
+ cfg = self.provider_config(provider_name)
148
+ return cfg.tool_verbosity or self.tool_verbosity
149
+
150
+
151
+ # --------------------------------------------------------------------------
152
+ # packaged defaults
153
+ # --------------------------------------------------------------------------
154
+
155
+
156
+ def default_providers() -> dict[str, ProviderConfig]:
157
+ """Providers that work with no configuration file at all.
158
+
159
+ `glm` is shipped preconfigured because it is this project's reference
160
+ self-hosted target, and every one of its non-obvious settings (no auth,
161
+ unbounded read timeout, health gate, 65k window, terse tools) is a fact
162
+ about that deployment rather than a preference.
163
+ """
164
+ return {
165
+ "mock": ProviderConfig(
166
+ kind="mock",
167
+ model="mock-1",
168
+ mock_mode="echo",
169
+ caps=Capabilities(max_context=32_000, max_output=4_096),
170
+ ),
171
+ "glm": ProviderConfig(
172
+ kind="openai_compat",
173
+ model="glm-5.2",
174
+ base_url=f"{GLM_BASE_URL}/v1",
175
+ health_url=f"{GLM_BASE_URL}/health",
176
+ api_key="", # vLLM on Modal is unauthenticated
177
+ timeout_s=None,
178
+ cold_boot_budget_s=55 * 60,
179
+ glm_reasoning=True,
180
+ caps=preset_for("glm-5.2"),
181
+ tool_verbosity="terse",
182
+ ),
183
+ "nvidia": ProviderConfig(
184
+ kind="openai_compat",
185
+ model="z-ai/glm-5.2",
186
+ base_url=NVIDIA_BASE_URL,
187
+ api_key_env="NVIDIA_API_KEY",
188
+ timeout_s=180.0,
189
+ glm_reasoning=True,
190
+ # Thinking is off by default on this endpoint; this chat template
191
+ # kwarg is what turns it on. reasoning_effort is silently ignored.
192
+ extra_body={"chat_template_kwargs": {"thinking": {"type": "enabled"}}},
193
+ caps=preset_for("z-ai/glm-5.2"),
194
+ ),
195
+ "blaxel": ProviderConfig(
196
+ kind="openai_compat",
197
+ # The sandbox ignores the model field entirely and always serves
198
+ # gpt-4o-mini-2024-07-18; this name is set for readability only.
199
+ model="gpt-4o-mini",
200
+ base_url=BLAXEL_BASE_URL,
201
+ api_key_env="BL_API_KEY",
202
+ caps=preset_for("gpt-4o-mini"),
203
+ ),
204
+ "anthropic": ProviderConfig(
205
+ kind="anthropic",
206
+ model="claude-sonnet-4-5",
207
+ api_key_env="ANTHROPIC_API_KEY",
208
+ caps=preset_for("claude-sonnet-4-5"),
209
+ ),
210
+ "openai": ProviderConfig(
211
+ kind="openai_compat",
212
+ model="gpt-4.1",
213
+ base_url="https://api.openai.com/v1",
214
+ api_key_env="OPENAI_API_KEY",
215
+ caps=preset_for("gpt-4.1"),
216
+ ),
217
+ "groq": ProviderConfig(
218
+ kind="openai_compat",
219
+ model="openai/gpt-oss-120b",
220
+ base_url="https://api.groq.com/openai/v1",
221
+ api_key_env="GROQ_API_KEY",
222
+ glm_reasoning=True, # Groq surfaces reasoning the same way
223
+ caps=preset_for("openai/gpt-oss-120b"),
224
+ # 7k of usable window cannot afford 3.3k of tool descriptions.
225
+ tool_verbosity="terse",
226
+ ),
227
+ "gemini": ProviderConfig(
228
+ kind="gemini",
229
+ model="gemini-2.5-pro",
230
+ api_key_env="GEMINI_API_KEY",
231
+ caps=preset_for("gemini-2.5-pro"),
232
+ ),
233
+ "ollama": ProviderConfig(
234
+ kind="openai_compat",
235
+ model="qwen2.5-coder:14b",
236
+ base_url="http://localhost:11434/v1",
237
+ api_key="ollama",
238
+ caps=Capabilities(max_context=32_768, max_output=4_096, max_concurrent_requests=1),
239
+ ),
240
+ }
241
+
242
+
243
+ DEFAULT_DENY = [
244
+ # These are not a security boundary — a determined model can always find
245
+ # another spelling. They exist to stop the single most common catastrophic
246
+ # accident, which is a model "cleaning up" a repository.
247
+ "Bash(rm -rf /*)",
248
+ "Bash(rm -rf ~*)",
249
+ "Bash(git push --force*)",
250
+ "Bash(git reset --hard*)",
251
+ "Bash(shutdown*)",
252
+ "Bash(:(){*)",
253
+ "Read(**/.env)",
254
+ "Read(**/.env.*)",
255
+ "Read(**/id_rsa*)",
256
+ "Read(**/*.pem)",
257
+ ]
258
+
259
+
260
+ def default_settings() -> Settings:
261
+ return Settings(
262
+ provider="mock",
263
+ providers=default_providers(),
264
+ permissions=PermissionConfig(deny=list(DEFAULT_DENY)),
265
+ )
266
+
267
+
268
+ # --------------------------------------------------------------------------
269
+ # loading
270
+ # --------------------------------------------------------------------------
271
+
272
+
273
+ _PROJECT_MARKERS = (
274
+ "pyproject.toml",
275
+ "package.json",
276
+ "go.mod",
277
+ "Cargo.toml",
278
+ "pom.xml",
279
+ ".git",
280
+ )
281
+
282
+
283
+ def find_project_root(start: Path) -> Path:
284
+ """Nearest ancestor that looks like a project root.
285
+
286
+ Two-pass on purpose. `.turnloop` is an explicit declaration and always wins.
287
+ Otherwise the *nearest* build-manifest-or-repo marker wins, with `.git` last
288
+ in the tuple — a drive root can itself be a git repository (F:\\ is one on
289
+ the author's machine), and keying only on `.git` would make every project's
290
+ root the entire drive, which then scopes permissions and sessions wrongly.
291
+ """
292
+ start = start.resolve()
293
+ for candidate in (start, *start.parents):
294
+ if (candidate / ".turnloop").is_dir():
295
+ return candidate
296
+ for candidate in (start, *start.parents):
297
+ for marker in _PROJECT_MARKERS:
298
+ if (candidate / marker).exists():
299
+ return candidate
300
+ return start
301
+
302
+
303
+ def _read_json(path: Path) -> dict:
304
+ if not path.is_file():
305
+ return {}
306
+ try:
307
+ data = json.loads(path.read_text(encoding="utf-8"))
308
+ except json.JSONDecodeError as exc:
309
+ raise ConfigError(f"{path}: invalid JSON — {exc}") from exc
310
+ if not isinstance(data, dict):
311
+ raise ConfigError(f"{path}: expected a JSON object at the top level")
312
+ return data
313
+
314
+
315
+ def deep_merge(base: dict, overlay: dict) -> dict:
316
+ out = dict(base)
317
+ for key, value in overlay.items():
318
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
319
+ out[key] = deep_merge(out[key], value)
320
+ else:
321
+ out[key] = value
322
+ return out
323
+
324
+
325
+ def load_dotenv(root: Path) -> list[str]:
326
+ """Load `<root>/.env` into the environment. Returns the names it set.
327
+
328
+ A real environment variable always wins, so `GROQ_API_KEY=x turnloop` beats a
329
+ stale value in the file. Returns names, never values — nothing in this project
330
+ logs a credential.
331
+
332
+ Eight lines instead of python-dotenv: the format we need is `KEY=value` with
333
+ optional `export`, comments, and optional surrounding quotes.
334
+ """
335
+ path = root / ".env"
336
+ if not path.is_file():
337
+ return []
338
+ loaded: list[str] = []
339
+ for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
340
+ line = raw.strip().removeprefix("export ").strip()
341
+ if not line or line.startswith("#") or "=" not in line:
342
+ continue
343
+ key, _, value = line.partition("=")
344
+ key = key.strip()
345
+ if not key or key in os.environ:
346
+ continue
347
+ os.environ[key] = value.strip().strip("'\"")
348
+ loaded.append(key)
349
+ return loaded
350
+
351
+
352
+ _ENV_MAP = {
353
+ "TURNLOOP_PROVIDER": ("provider", str),
354
+ "TURNLOOP_MODEL": ("_model", str),
355
+ "TURNLOOP_PERMISSION_MODE": ("permission_mode", str),
356
+ "TURNLOOP_MAX_ITERATIONS": ("max_iterations", int),
357
+ "TURNLOOP_TOOL_VERBOSITY": ("tool_verbosity", str),
358
+ "TURNLOOP_SYSTEM_VARIANT": ("system_prompt_variant", str),
359
+ }
360
+
361
+
362
+ def env_overrides() -> dict:
363
+ out: dict = {}
364
+ for var, (key, cast) in _ENV_MAP.items():
365
+ raw = os.environ.get(var)
366
+ if raw is None or raw == "":
367
+ continue
368
+ try:
369
+ out[key] = cast(raw)
370
+ except ValueError as exc:
371
+ raise ConfigError(f"{var}={raw!r} is not a valid {cast.__name__}") from exc
372
+ return out
373
+
374
+
375
+ def load_settings(cwd: Path | str | None = None, overrides: dict | None = None) -> Settings:
376
+ cwd = Path(cwd or Path.cwd())
377
+ root = find_project_root(cwd)
378
+ dotenv_keys = load_dotenv(root)
379
+
380
+ layers: list[tuple[str, dict]] = [("defaults", default_settings().model_dump(mode="json"))]
381
+ for label, path in (
382
+ ("user", Path.home() / ".turnloop" / "settings.json"),
383
+ ("project", root / ".turnloop" / "settings.json"),
384
+ ("local", root / ".turnloop" / "settings.local.json"),
385
+ ):
386
+ data = _read_json(path)
387
+ if data:
388
+ layers.append((f"{label}:{path}", data))
389
+
390
+ if env := env_overrides():
391
+ layers.append(("env", env))
392
+ if overrides:
393
+ layers.append(("cli", {k: v for k, v in overrides.items() if v is not None}))
394
+
395
+ merged: dict = {}
396
+ for _, data in layers:
397
+ merged = deep_merge(merged, data)
398
+
399
+ # A bare model override applies to whichever provider is selected, and
400
+ # pulls fresh capability defaults with it. Without this, `--model
401
+ # claude-opus-4-5` would keep sonnet's pricing and window.
402
+ model = merged.pop("_model", None)
403
+ merged.pop("project_root", None)
404
+ merged.pop("sources", None)
405
+
406
+ settings = Settings.model_validate(merged)
407
+ settings.project_root = root
408
+ settings.sources = [label for label, _ in layers]
409
+ if dotenv_keys:
410
+ settings.sources.append(f".env ({', '.join(dotenv_keys)})")
411
+
412
+ if model:
413
+ cfg = settings.provider_config()
414
+ cfg.model = model
415
+ cfg.caps = preset_for(model)
416
+
417
+ for name, cfg in settings.providers.items():
418
+ if cfg.kind != "mock" and not cfg.base_url and cfg.kind == "openai_compat":
419
+ raise ConfigError(f"provider '{name}': openai_compat requires base_url")
420
+
421
+ return settings
422
+
423
+
424
+ def settings_dir(root: Path) -> Path:
425
+ return root / ".turnloop"
File without changes
@@ -0,0 +1,74 @@
1
+ """Context accounting.
2
+
3
+ Worth doing the arithmetic explicitly once, for GLM-5.2:
4
+
5
+ 65,536 total window
6
+ -4,096 reserved for output
7
+ -1,500 system prompt
8
+ -2,500 twelve tool schemas
9
+ =57,440 available for conversation history
10
+
11
+ A single 3,000-line file read is roughly 30k tokens — over half of that. One
12
+ unbounded Grep in content mode can be more. This is why micro-compaction is a
13
+ requirement rather than a nicety on this endpoint, and why the same harness on a
14
+ 200k model would never notice.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ from turnloop.core.messages import Message, ToolSpec
22
+ from turnloop.core.tokens import history_tokens, rough_tokens, tool_spec_tokens
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class Budget:
27
+ max_context: int
28
+ reserve_output: int
29
+ system_tokens: int = 0
30
+ tools_tokens: int = 0
31
+
32
+ @property
33
+ def available(self) -> int:
34
+ """Tokens left for conversation history."""
35
+ return max(
36
+ 1_000, self.max_context - self.reserve_output - self.system_tokens - self.tools_tokens
37
+ )
38
+
39
+ def pressure(self, history: int) -> float:
40
+ return history / self.available
41
+
42
+ def remaining(self, history: int) -> int:
43
+ return self.available - history
44
+
45
+ def describe(self, history: int) -> str:
46
+ return (
47
+ f"{history:,}/{self.available:,} tokens "
48
+ f"({self.pressure(history) * 100:.0f}% of usable window)"
49
+ )
50
+
51
+ @classmethod
52
+ def build(cls, max_context: int, reserve_output: int, system: list[str],
53
+ specs: list[ToolSpec]) -> Budget:
54
+ return cls(
55
+ max_context=max_context,
56
+ reserve_output=reserve_output,
57
+ system_tokens=rough_tokens("\n\n".join(system)),
58
+ tools_tokens=tool_spec_tokens(specs),
59
+ )
60
+
61
+
62
+ def measure(messages: list[Message]) -> int:
63
+ return history_tokens(messages)
64
+
65
+
66
+ def output_reserve(configured: int, max_output: int) -> int:
67
+ """How much of the window to keep free for the reply.
68
+
69
+ The smaller of the configured reserve and what the model can actually emit.
70
+ Reserving a flat 4,096 against a 7,000-token effective window (Groq's free-tier
71
+ rate limit) leaves nothing for history, and the clamp is what keeps a tight
72
+ endpoint usable instead of permanently over budget.
73
+ """
74
+ return max(256, min(configured, max_output))