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/tools/base.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Tool contract.
|
|
2
|
+
|
|
3
|
+
Every tool is a class with a pydantic `Args` model, which means the JSON Schema
|
|
4
|
+
sent to the model and the validation applied to its reply are the same object.
|
|
5
|
+
That is the whole reason a model returning nonsense produces a readable error
|
|
6
|
+
result instead of a traceback.
|
|
7
|
+
|
|
8
|
+
Three fields deserve explanation:
|
|
9
|
+
|
|
10
|
+
* `read_only` gates plan mode. A tool whose safety depends on its arguments
|
|
11
|
+
(Bash) overrides `is_read_only_for()` instead.
|
|
12
|
+
* `parallel_safe` lets the loop batch calls in one task group. Default False:
|
|
13
|
+
concurrency is an optimization, correctness is not.
|
|
14
|
+
* `description(verbosity)` returns one of three hand-written strings. Tool
|
|
15
|
+
descriptions are the single largest fixed cost in the context window, so
|
|
16
|
+
making that cost adjustable is what makes the context-engineering
|
|
17
|
+
ablations measurable rather than hypothetical.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
from abc import ABC, abstractmethod
|
|
24
|
+
from collections.abc import Awaitable, Callable
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Literal
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel
|
|
30
|
+
|
|
31
|
+
from turnloop.config import Settings, Verbosity
|
|
32
|
+
from turnloop.core.events import ToolProgress
|
|
33
|
+
from turnloop.core.messages import ToolSpec
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING: # avoid an import cycle: sessions imports messages, not tools
|
|
36
|
+
from turnloop.permissions.engine import PermissionDecision, PermissionEngine, PermissionRequest
|
|
37
|
+
from turnloop.sessions.models import Session
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class ToolOutput:
|
|
42
|
+
"""What a tool returns.
|
|
43
|
+
|
|
44
|
+
`content` goes to the model; `display` goes to the human. Keeping them apart
|
|
45
|
+
is what lets Edit show a colored diff in the terminal while sending the model
|
|
46
|
+
a two-line confirmation, instead of paying context for ANSI codes.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
content: str
|
|
50
|
+
display: str | None = None
|
|
51
|
+
is_error: bool = False
|
|
52
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def error(cls, message: str, **metrics) -> ToolOutput:
|
|
56
|
+
return cls(content=message, is_error=True, metrics=metrics)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class FileTracker:
|
|
60
|
+
"""Read-before-write bookkeeping.
|
|
61
|
+
|
|
62
|
+
Two failure modes this prevents, both observed with every model family:
|
|
63
|
+
blind-writing a file it never read (destroying content it did not know about),
|
|
64
|
+
and editing against a stale copy after an external change.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self):
|
|
68
|
+
self._seen: dict[Path, tuple[float, str]] = {}
|
|
69
|
+
self._modified: set[Path] = set()
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _key(path: Path) -> Path:
|
|
73
|
+
return path.resolve()
|
|
74
|
+
|
|
75
|
+
def record_read(self, path: Path, content: str) -> None:
|
|
76
|
+
p = self._key(path)
|
|
77
|
+
mtime = p.stat().st_mtime if p.exists() else 0.0
|
|
78
|
+
self._seen[p] = (mtime, hashlib.sha256(content.encode("utf-8", "replace")).hexdigest())
|
|
79
|
+
|
|
80
|
+
def record_write(self, path: Path, content: str) -> None:
|
|
81
|
+
self.record_read(path, content)
|
|
82
|
+
self._modified.add(self._key(path))
|
|
83
|
+
|
|
84
|
+
def has_read(self, path: Path) -> bool:
|
|
85
|
+
return self._key(path) in self._seen
|
|
86
|
+
|
|
87
|
+
def is_stale(self, path: Path) -> bool:
|
|
88
|
+
"""True if the file changed on disk since we last read it."""
|
|
89
|
+
p = self._key(path)
|
|
90
|
+
entry = self._seen.get(p)
|
|
91
|
+
if entry is None or not p.exists():
|
|
92
|
+
return False
|
|
93
|
+
return abs(p.stat().st_mtime - entry[0]) > 1e-6
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def modified(self) -> list[Path]:
|
|
97
|
+
return sorted(self._modified)
|
|
98
|
+
|
|
99
|
+
def manifest(self) -> list[tuple[str, bool]]:
|
|
100
|
+
"""(path, was_modified) for every file touched — survives compaction."""
|
|
101
|
+
return [(str(p), p in self._modified) for p in sorted(self._seen)]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class ToolContext:
|
|
106
|
+
"""Everything a tool is allowed to reach.
|
|
107
|
+
|
|
108
|
+
Passing this explicitly rather than letting tools import globals is what
|
|
109
|
+
makes subagent isolation and experiment sandboxing possible: a child gets a
|
|
110
|
+
different context, and cannot see the parent's.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
cwd: Path
|
|
114
|
+
settings: Settings
|
|
115
|
+
session: Session
|
|
116
|
+
permissions: PermissionEngine
|
|
117
|
+
emit: Callable[[ToolProgress], Awaitable[None]]
|
|
118
|
+
ask: Callable[[PermissionRequest], Awaitable[PermissionDecision]]
|
|
119
|
+
files: FileTracker = field(default_factory=FileTracker)
|
|
120
|
+
depth: int = 0
|
|
121
|
+
readonly: bool = False
|
|
122
|
+
tool_use_id: str = ""
|
|
123
|
+
subagent_id: str | None = None
|
|
124
|
+
# Set by the Bash tool; persists across calls within one session.
|
|
125
|
+
shell_cwd: Path | None = None
|
|
126
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def root(self) -> Path:
|
|
130
|
+
return self.settings.project_root
|
|
131
|
+
|
|
132
|
+
def resolve(self, raw: str) -> Path:
|
|
133
|
+
"""Resolve a model-supplied path against the working directory."""
|
|
134
|
+
p = Path(raw).expanduser()
|
|
135
|
+
if not p.is_absolute():
|
|
136
|
+
p = self.cwd / p
|
|
137
|
+
return p.resolve()
|
|
138
|
+
|
|
139
|
+
def allowed_directories(self) -> list[Path]:
|
|
140
|
+
dirs = [self.root.resolve(), self.cwd.resolve()]
|
|
141
|
+
dirs.extend(
|
|
142
|
+
Path(d).expanduser().resolve()
|
|
143
|
+
for d in self.settings.permissions.additional_directories
|
|
144
|
+
)
|
|
145
|
+
return dirs
|
|
146
|
+
|
|
147
|
+
def within_allowed(self, path: Path) -> bool:
|
|
148
|
+
target = path.resolve()
|
|
149
|
+
return any(target == d or d in target.parents for d in self.allowed_directories())
|
|
150
|
+
|
|
151
|
+
async def progress(self, text: str) -> None:
|
|
152
|
+
await self.emit(
|
|
153
|
+
ToolProgress(tool_use_id=self.tool_use_id, text=text, subagent_id=self.subagent_id)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def child(self, **overrides) -> ToolContext:
|
|
157
|
+
from dataclasses import replace
|
|
158
|
+
|
|
159
|
+
return replace(self, **overrides)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Tool(ABC):
|
|
163
|
+
name: ClassVar[str] = ""
|
|
164
|
+
Args: ClassVar[type[BaseModel]]
|
|
165
|
+
read_only: ClassVar[bool] = False
|
|
166
|
+
parallel_safe: ClassVar[bool] = False
|
|
167
|
+
# Tools whose result is usually large enough to be worth micro-compacting.
|
|
168
|
+
bulky: ClassVar[bool] = False
|
|
169
|
+
timeout_s: ClassVar[float | None] = 120.0
|
|
170
|
+
|
|
171
|
+
# --- presentation -----------------------------------------------------
|
|
172
|
+
|
|
173
|
+
descriptions: ClassVar[dict[Verbosity, str]] = {}
|
|
174
|
+
|
|
175
|
+
def description(self, verbosity: Verbosity = "normal") -> str:
|
|
176
|
+
if verbosity in self.descriptions:
|
|
177
|
+
return self.descriptions[verbosity].strip()
|
|
178
|
+
for fallback in ("normal", "terse", "verbose"):
|
|
179
|
+
if text := self.descriptions.get(fallback):
|
|
180
|
+
return text.strip()
|
|
181
|
+
return (self.__doc__ or self.name).strip()
|
|
182
|
+
|
|
183
|
+
def spec(self, verbosity: Verbosity = "normal") -> ToolSpec:
|
|
184
|
+
return ToolSpec(
|
|
185
|
+
name=self.name,
|
|
186
|
+
description=self.description(verbosity),
|
|
187
|
+
input_schema=self.Args.model_json_schema(),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# --- permissions ------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def permission_target(self, args: BaseModel) -> str:
|
|
193
|
+
"""The string permission rules match against.
|
|
194
|
+
|
|
195
|
+
Defaults to the first path-ish field, then the tool name. Bash overrides
|
|
196
|
+
this with the command itself.
|
|
197
|
+
"""
|
|
198
|
+
for field_name in ("file_path", "path", "pattern", "url", "command"):
|
|
199
|
+
value = getattr(args, field_name, None)
|
|
200
|
+
if isinstance(value, str) and value:
|
|
201
|
+
return value
|
|
202
|
+
return self.name
|
|
203
|
+
|
|
204
|
+
def is_read_only_for(self, args: BaseModel) -> bool:
|
|
205
|
+
"""Per-argument read-only classification. Overridden by Bash."""
|
|
206
|
+
return self.read_only
|
|
207
|
+
|
|
208
|
+
def match_target(self, target: str, pattern: str, root: Path | None = None,
|
|
209
|
+
cwd: Path | None = None) -> bool:
|
|
210
|
+
"""How a rule pattern is tested against `permission_target`.
|
|
211
|
+
|
|
212
|
+
Path-based by default; Bash overrides with command-segment matching.
|
|
213
|
+
"""
|
|
214
|
+
from turnloop.permissions.rules import match_path_pattern
|
|
215
|
+
|
|
216
|
+
return match_path_pattern(target, pattern, root, cwd)
|
|
217
|
+
|
|
218
|
+
# --- rendering --------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def summary(self, args: BaseModel) -> str:
|
|
221
|
+
"""One line shown in the transcript while the tool runs."""
|
|
222
|
+
target = self.permission_target(args)
|
|
223
|
+
return f"{self.name}({target})" if target != self.name else self.name
|
|
224
|
+
|
|
225
|
+
# --- execution --------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
@abstractmethod
|
|
228
|
+
async def run(self, args: Any, ctx: ToolContext) -> ToolOutput:
|
|
229
|
+
raise NotImplementedError
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
ToolKind = Literal["builtin", "mcp"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ToolRegistry:
|
|
236
|
+
"""The set of tools visible to one agent.
|
|
237
|
+
|
|
238
|
+
Subagents get a filtered copy rather than a flag on a shared instance, so
|
|
239
|
+
"a subagent cannot spawn subagents" is enforced by absence, not by a check
|
|
240
|
+
that could be forgotten.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(self, tools: list[Tool] | None = None):
|
|
244
|
+
self._tools: dict[str, Tool] = {}
|
|
245
|
+
for tool in tools or []:
|
|
246
|
+
self.add(tool)
|
|
247
|
+
|
|
248
|
+
def add(self, tool: Tool) -> None:
|
|
249
|
+
self._tools[tool.name] = tool
|
|
250
|
+
|
|
251
|
+
def get(self, name: str) -> Tool | None:
|
|
252
|
+
"""Look up a tool, tolerating the ways models misspell the name.
|
|
253
|
+
|
|
254
|
+
Case first: models emit `glob` and `bash` for `Glob` and `Bash` routinely.
|
|
255
|
+
Then separators, because `todo_write` and `TodoWrite` are the same intent.
|
|
256
|
+
Resolving these is strictly better than an error result — the model asked
|
|
257
|
+
for something unambiguous, and refusing it costs a turn to relearn casing.
|
|
258
|
+
"""
|
|
259
|
+
if tool := self._tools.get(name):
|
|
260
|
+
return tool
|
|
261
|
+
folded = name.lower().replace("_", "").replace("-", "")
|
|
262
|
+
for candidate, tool in self._tools.items():
|
|
263
|
+
if candidate.lower().replace("_", "") == folded:
|
|
264
|
+
return tool
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def names(self) -> list[str]:
|
|
268
|
+
return sorted(self._tools)
|
|
269
|
+
|
|
270
|
+
def __iter__(self):
|
|
271
|
+
return iter(self._tools.values())
|
|
272
|
+
|
|
273
|
+
def __len__(self) -> int:
|
|
274
|
+
return len(self._tools)
|
|
275
|
+
|
|
276
|
+
def specs(self, verbosity: Verbosity = "normal") -> list[ToolSpec]:
|
|
277
|
+
return [t.spec(verbosity) for t in self._tools.values()]
|
|
278
|
+
|
|
279
|
+
def without(self, *names: str) -> ToolRegistry:
|
|
280
|
+
return ToolRegistry([t for n, t in self._tools.items() if n not in names])
|
|
281
|
+
|
|
282
|
+
def only(self, *names: str) -> ToolRegistry:
|
|
283
|
+
return ToolRegistry([t for n, t in self._tools.items() if n in names])
|
|
284
|
+
|
|
285
|
+
def read_only_subset(self) -> ToolRegistry:
|
|
286
|
+
return ToolRegistry([t for t in self._tools.values() if t.read_only])
|