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/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])