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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- 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))
|