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/bash.py ADDED
@@ -0,0 +1,423 @@
1
+ """Bash — run a shell command.
2
+
3
+ The dangerous tool, so the interesting parts are the classifier and the output
4
+ contract rather than the execution.
5
+
6
+ `classify()` answers "could this write anything?" and is deliberately pessimistic:
7
+ anything it cannot parse confidently is treated as mutating. That single choice is
8
+ what makes plan mode trustworthy — a read-only mode that guesses optimistically is
9
+ worse than no read-only mode, because people rely on it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Literal
18
+
19
+ import anyio
20
+ from pydantic import BaseModel, Field
21
+
22
+ from turnloop.permissions.rules import (
23
+ match_command_pattern,
24
+ normalize_command,
25
+ split_shell_command,
26
+ )
27
+ from turnloop.tools.base import Tool, ToolContext, ToolOutput
28
+ from turnloop.tools.shell import (
29
+ child_env,
30
+ decode_output,
31
+ kill_tree,
32
+ resolve_shell,
33
+ spawn_kwargs,
34
+ )
35
+
36
+ Classification = Literal["read_only", "mutating", "unknown"]
37
+
38
+ # First tokens that cannot modify anything on their own.
39
+ READ_ONLY_COMMANDS = {
40
+ "ls", "dir", "pwd", "cat", "type", "head", "tail", "wc", "nl", "less", "more",
41
+ "grep", "egrep", "fgrep", "rg", "ag", "ack", "find", "fd", "locate",
42
+ "which", "where", "whereis", "command", "file", "stat", "du", "df",
43
+ "tree", "echo", "printf", "date", "env", "printenv", "hostname", "whoami", "id",
44
+ "uname", "basename", "dirname", "realpath", "readlink", "sort", "uniq", "cut",
45
+ "tr", "diff", "cmp", "md5sum", "sha256sum", "seq", "true", "false", "test",
46
+ "jq", "yq", "column", "sleep", "ps", "top", "free", "uptime", "curl", "wget",
47
+ }
48
+
49
+ # Subcommand allowlists for commands that are read-only only in some modes.
50
+ READ_ONLY_SUBCOMMANDS: dict[str, set[str]] = {
51
+ "git": {
52
+ "status", "log", "diff", "show", "branch", "remote", "rev-parse", "ls-files",
53
+ "ls-tree", "blame", "describe", "shortlog", "tag", "reflog", "cat-file",
54
+ "config", "whatchanged", "grep", "bisect", "count-objects", "verify-pack",
55
+ },
56
+ "npm": {"ls", "list", "view", "outdated", "why", "ping", "root", "prefix", "config"},
57
+ "pnpm": {"ls", "list", "outdated", "why", "root"},
58
+ "yarn": {"list", "why", "info"},
59
+ "pip": {"list", "show", "freeze", "check", "config"},
60
+ "poetry": {"show", "check", "env"},
61
+ "uv": {"pip", "tree"},
62
+ "cargo": {"tree", "metadata", "search"},
63
+ "docker": {"ps", "images", "logs", "inspect", "version", "info", "top", "port", "stats"},
64
+ "kubectl": {"get", "describe", "logs", "explain", "version", "top", "api-resources"},
65
+ "go": {"list", "version", "env", "doc"},
66
+ "gh": {"pr", "issue", "run", "repo", "api", "auth"}, # narrowed below
67
+ "terraform": {"show", "output", "version", "validate", "providers"},
68
+ "modal": {"app", "profile", "volume", "token"}, # `app list`, not `app stop`
69
+ }
70
+
71
+ # Subcommand pairs that are NOT read-only despite a read-only-looking parent.
72
+ MUTATING_PAIRS = {
73
+ ("git", "config"): {"--global", "--system", "--unset", "--add", "--replace-all"},
74
+ ("gh", "pr"): {"create", "merge", "close", "edit", "review", "comment", "ready"},
75
+ ("gh", "issue"): {"create", "close", "edit", "comment", "reopen", "delete"},
76
+ ("gh", "run"): {"cancel", "rerun", "delete"},
77
+ ("gh", "repo"): {"create", "delete", "fork", "clone", "edit", "archive"},
78
+ ("gh", "api"): {"-X", "--method"},
79
+ ("gh", "auth"): {"login", "logout", "refresh", "setup-git"},
80
+ ("npm", "config"): {"set", "delete"},
81
+ ("pip", "config"): {"set", "unset", "edit"},
82
+ ("modal", "app"): {"stop", "deploy", "rollback"},
83
+ ("modal", "volume"): {"put", "rm", "delete", "create"},
84
+ ("modal", "token"): {"new", "set"},
85
+ ("docker", "logs"): set(),
86
+ ("kubectl", "get"): set(),
87
+ ("uv", "pip"): {"install", "uninstall", "sync"},
88
+ ("cargo", "tree"): set(),
89
+ }
90
+
91
+
92
+ class BashArgs(BaseModel):
93
+ command: str = Field(description="The shell command to run.")
94
+ description: str = Field(
95
+ default="", description="5-10 word description of what this does, shown to the user."
96
+ )
97
+ timeout_ms: int | None = Field(default=None, description="Timeout in ms (default 120000).")
98
+ run_in_background: bool = Field(
99
+ default=False, description="Return immediately and keep the process running."
100
+ )
101
+
102
+
103
+ class BashTool(Tool):
104
+ name = "Bash"
105
+ Args = BashArgs
106
+ read_only = False # refined per-command by is_read_only_for
107
+ parallel_safe = False
108
+ bulky = True
109
+ timeout_s = None # the tool manages its own timeout
110
+
111
+ descriptions = {
112
+ "terse": """
113
+ Run a shell command. Use absolute paths (`cd` does not persist).
114
+ Prefer Read/Glob/Grep over cat/find/grep. Quote paths with spaces.
115
+ """,
116
+ "normal": """
117
+ Run a shell command.
118
+
119
+ - Prefer the dedicated tools where one fits: Read over `cat`, Glob over `find`,
120
+ Grep over `grep`. They are faster, produce cleaner output, and are permitted
121
+ without a prompt.
122
+ - The working directory persists between calls, but a bare `cd` inside a command
123
+ does not change it. Use absolute paths.
124
+ - Quote paths containing spaces.
125
+ - Output is truncated in the middle if very long; the exit code is always
126
+ reported when non-zero.
127
+ - Do not use interactive commands (`git rebase -i`, editors, prompts) — nothing
128
+ can answer them.
129
+ - `run_in_background: true` returns immediately for long-running processes.
130
+ """,
131
+ "verbose": """
132
+ Run a shell command.
133
+
134
+ Arguments:
135
+ - `command`: the command line to execute.
136
+ - `description`: a short phrase shown to the user while it runs.
137
+ - `timeout_ms`: default 120000, maximum 600000.
138
+ - `run_in_background`: return a handle immediately instead of waiting.
139
+
140
+ Tool selection:
141
+ - Use Read, Glob and Grep instead of `cat`, `find` and `grep`. They return
142
+ structured output, they are cheaper in context, and they do not need a
143
+ permission prompt.
144
+ - Use Bash for builds, tests, git operations, package managers and scripts.
145
+
146
+ Execution model:
147
+ - The command runs through a POSIX shell where one is available (Git Bash on
148
+ Windows), otherwise PowerShell. Write POSIX shell.
149
+ - The working directory persists across calls within a session. A bare `cd` does
150
+ not change it — the shell exits after every command. Use absolute paths, or
151
+ chain `cd /path && cmd` within a single command.
152
+ - Compound commands are permitted only when every segment is permitted, so
153
+ `allowed && not-allowed` will prompt.
154
+ - On timeout the entire process tree is killed, not just the shell.
155
+
156
+ Output contract:
157
+ - stdout and stderr are interleaved as they arrive.
158
+ - Very long output is truncated in the middle, keeping the beginning and the end,
159
+ with an explicit marker. Beginnings carry the command echo, ends carry the
160
+ error — the middle is what you can afford to lose.
161
+ - A non-zero exit code is always appended as `[exit code: N]`.
162
+
163
+ Do not run interactive commands: no `-i` flags, no editors, no `git rebase -i`,
164
+ nothing that prompts. There is no terminal attached and the call will hang until
165
+ it times out.
166
+ """,
167
+ }
168
+
169
+ # --- permission surface ------------------------------------------------
170
+
171
+ def permission_target(self, args: BashArgs) -> str: # type: ignore[override]
172
+ return normalize_command(args.command)
173
+
174
+ def match_target(self, target: str, pattern: str, root: Path | None = None,
175
+ cwd: Path | None = None) -> bool:
176
+ return match_command_pattern(target, pattern)
177
+
178
+ def is_read_only_for(self, args: BashArgs) -> bool: # type: ignore[override]
179
+ return classify(args.command) == "read_only"
180
+
181
+ def summary(self, args: BashArgs) -> str: # type: ignore[override]
182
+ desc = args.description.strip()
183
+ cmd = normalize_command(args.command)
184
+ shown = cmd if len(cmd) <= 70 else cmd[:70] + "…"
185
+ return f"{shown}" + (f" — {desc}" if desc else "")
186
+
187
+ # --- execution ---------------------------------------------------------
188
+
189
+ async def run(self, args: BashArgs, ctx: ToolContext) -> ToolOutput:
190
+ cfg = ctx.settings.bash
191
+
192
+ if ctx.readonly and classify(args.command) != "read_only":
193
+ return ToolOutput.error(
194
+ "plan mode is read-only, and this command is not classified as read-only. "
195
+ "Describe the command you would run instead."
196
+ )
197
+
198
+ try:
199
+ spec = resolve_shell(cfg.shell)
200
+ except Exception as exc: # noqa: BLE001 - surfaced to the model as an error result
201
+ return ToolOutput.error(str(exc))
202
+
203
+ timeout_ms = min(args.timeout_ms or cfg.default_timeout_ms, cfg.max_timeout_ms)
204
+ cwd = ctx.shell_cwd or ctx.cwd
205
+ argv = spec.argv(args.command)
206
+ started = time.monotonic()
207
+
208
+ if args.run_in_background:
209
+ return await _start_background(argv, cwd, ctx, args.command)
210
+
211
+ collected: list[str] = []
212
+ total_chars = 0
213
+ timed_out = False
214
+ exit_code: int | None = None
215
+
216
+ try:
217
+ process = await anyio.open_process(
218
+ argv,
219
+ cwd=str(cwd),
220
+ env=child_env(),
221
+ stdout=subprocess.PIPE,
222
+ # Interleaved, because a model reading output needs the error next
223
+ # to the line that caused it, not in a separate block.
224
+ stderr=subprocess.STDOUT,
225
+ **spawn_kwargs(),
226
+ )
227
+ except OSError as exc:
228
+ return ToolOutput.error(f"failed to start shell: {exc}")
229
+
230
+ async def pump() -> None:
231
+ nonlocal total_chars
232
+ assert process.stdout is not None
233
+ buffer = b""
234
+ async for chunk in process.stdout:
235
+ buffer += chunk
236
+ while b"\n" in buffer:
237
+ line, buffer = buffer.split(b"\n", 1)
238
+ text = decode_output(line).rstrip("\r")
239
+ total_chars += len(text) + 1
240
+ collected.append(text)
241
+ await ctx.progress(text)
242
+ if buffer:
243
+ text = decode_output(buffer)
244
+ total_chars += len(text)
245
+ collected.append(text)
246
+ await ctx.progress(text)
247
+
248
+ try:
249
+ with anyio.fail_after(timeout_ms / 1000):
250
+ async with anyio.create_task_group() as tg:
251
+ tg.start_soon(pump)
252
+ exit_code = await process.wait()
253
+ except TimeoutError:
254
+ timed_out = True
255
+ kill_tree(process.pid)
256
+ with anyio.move_on_after(3):
257
+ exit_code = await process.wait()
258
+ finally:
259
+ with anyio.CancelScope(shield=True):
260
+ try:
261
+ await process.aclose()
262
+ except Exception: # noqa: BLE001
263
+ pass
264
+
265
+ elapsed = time.monotonic() - started
266
+ body = truncate_output(
267
+ "\n".join(collected), cfg.max_output_chars, cfg.max_output_lines
268
+ )
269
+
270
+ if timed_out:
271
+ body += (
272
+ f"\n\n[timed out after {timeout_ms / 1000:.0f}s; the process tree was killed]"
273
+ )
274
+ elif exit_code:
275
+ body += f"\n\n[exit code: {exit_code}]"
276
+
277
+ if not body.strip():
278
+ body = "(no output)"
279
+
280
+ return ToolOutput(
281
+ content=body,
282
+ display=None,
283
+ is_error=timed_out or bool(exit_code),
284
+ metrics={
285
+ "exit_code": exit_code,
286
+ "timed_out": timed_out,
287
+ "elapsed_s": round(elapsed, 3),
288
+ "output_chars": total_chars,
289
+ "shell": spec.kind,
290
+ "classification": classify(args.command),
291
+ },
292
+ )
293
+
294
+
295
+ async def _start_background(argv: list[str], cwd: Path, ctx: ToolContext,
296
+ command: str) -> ToolOutput:
297
+ """Launch detached and register for later inspection/cleanup."""
298
+ try:
299
+ # Popen rather than anyio.open_process: a background process must outlive
300
+ # this call's task scope, and an anyio process handle is bound to it.
301
+ process = subprocess.Popen( # noqa: ASYNC220
302
+ argv,
303
+ cwd=str(cwd),
304
+ env=child_env(),
305
+ stdout=subprocess.PIPE,
306
+ stderr=subprocess.STDOUT,
307
+ **spawn_kwargs(),
308
+ )
309
+ except OSError as exc:
310
+ return ToolOutput.error(f"failed to start background process: {exc}")
311
+
312
+ handles = ctx.extras.setdefault("background", {})
313
+ handles[str(process.pid)] = {"process": process, "command": command}
314
+ return ToolOutput(
315
+ content=(
316
+ f"Started in the background with pid {process.pid}.\n"
317
+ "It will be terminated when the session ends."
318
+ ),
319
+ display=f"background pid {process.pid}: {normalize_command(command)[:60]}",
320
+ metrics={"pid": process.pid, "background": True},
321
+ )
322
+
323
+
324
+ # --------------------------------------------------------------------------
325
+ # classification
326
+ # --------------------------------------------------------------------------
327
+
328
+
329
+ def classify(command: str) -> Classification:
330
+ """Can this command modify anything?
331
+
332
+ Pessimistic by design. `unknown` is treated exactly like `mutating` by every
333
+ caller; it exists as a separate value only so diagnostics can distinguish
334
+ "definitely writes" from "could not parse".
335
+ """
336
+ analysis = split_shell_command(command)
337
+
338
+ if analysis.unbalanced_quotes:
339
+ return "unknown"
340
+ if analysis.has_substitution:
341
+ # $(...) can run anything, including a write, and its content is not
342
+ # analyzed here.
343
+ return "unknown"
344
+ if analysis.has_redirect:
345
+ return "unknown"
346
+ if not analysis.segments:
347
+ return "unknown"
348
+
349
+ verdicts = [_classify_segment(seg) for seg in analysis.segments]
350
+ if any(v == "mutating" for v in verdicts):
351
+ return "mutating"
352
+ if any(v == "unknown" for v in verdicts):
353
+ return "unknown"
354
+ return "read_only"
355
+
356
+
357
+ def _classify_segment(segment: str) -> Classification:
358
+ tokens = normalize_command(segment).split()
359
+ if not tokens:
360
+ return "unknown"
361
+
362
+ # Strip leading environment assignments: FOO=bar cmd ...
363
+ while tokens and "=" in tokens[0] and not tokens[0].startswith("-"):
364
+ tokens = tokens[1:]
365
+ if not tokens:
366
+ return "unknown"
367
+
368
+ head = Path(tokens[0]).name.lower()
369
+ head = head[:-4] if head.endswith(".exe") else head
370
+ rest = tokens[1:]
371
+
372
+ if head in ("sudo", "doas", "runas"):
373
+ return "mutating"
374
+
375
+ if head in READ_ONLY_SUBCOMMANDS:
376
+ sub = next((t for t in rest if not t.startswith("-")), None)
377
+ if sub is None:
378
+ return "read_only" # bare `git`, `docker` etc. just print help
379
+ if sub not in READ_ONLY_SUBCOMMANDS[head]:
380
+ return "mutating"
381
+ forbidden = MUTATING_PAIRS.get((head, sub))
382
+ if forbidden and any(tok in forbidden for tok in rest):
383
+ return "mutating"
384
+ return "read_only"
385
+
386
+ if head in READ_ONLY_COMMANDS:
387
+ # `find -delete`/`-exec` and `curl -o` write despite a read-only head.
388
+ if head in ("find", "fd") and any(
389
+ t in ("-delete", "-exec", "-execdir", "-ok", "-fprint") for t in rest
390
+ ):
391
+ return "mutating"
392
+ if head in ("curl", "wget") and any(
393
+ t in ("-o", "-O", "--output", "--output-document", "--remote-name") for t in rest
394
+ ):
395
+ return "mutating"
396
+ return "read_only"
397
+
398
+ return "mutating"
399
+
400
+
401
+ def truncate_output(text: str, max_chars: int, max_lines: int) -> str:
402
+ """Keep the head and the tail, drop the middle.
403
+
404
+ Command output is informative at both ends and repetitive in between: the head
405
+ has the invocation and early errors, the tail has the failure and the summary.
406
+ A head-only truncation throws away the part that says what went wrong.
407
+ """
408
+ lines = text.splitlines()
409
+
410
+ if len(lines) > max_lines:
411
+ head_n = int(max_lines * 0.6)
412
+ tail_n = max_lines - head_n
413
+ omitted = len(lines) - max_lines
414
+ lines = [*lines[:head_n], f"... [{omitted} lines truncated] ...", *lines[-tail_n:]]
415
+ text = "\n".join(lines)
416
+
417
+ if len(text) > max_chars:
418
+ head_n = int(max_chars * 0.6)
419
+ tail_n = max_chars - head_n
420
+ omitted = len(text) - max_chars
421
+ text = text[:head_n] + f"\n... [{omitted} characters truncated] ...\n" + text[-tail_n:]
422
+
423
+ return text
@@ -0,0 +1,63 @@
1
+ """Assembling the tool registry for a session.
2
+
3
+ Order matters slightly: tools appear in the model's tool list in the order added,
4
+ and the first few get marginally more attention. Read/Edit/Bash first is not
5
+ superstition, it is the order of what actually gets used.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from turnloop.config import Settings
13
+ from turnloop.tools.ask import AskUserQuestionTool
14
+ from turnloop.tools.base import ToolRegistry
15
+ from turnloop.tools.bash import BashTool
16
+ from turnloop.tools.edit import EditTool
17
+ from turnloop.tools.glob import GlobTool
18
+ from turnloop.tools.grep import GrepTool
19
+ from turnloop.tools.read import ReadTool
20
+ from turnloop.tools.skill import SkillTool
21
+ from turnloop.tools.task import TaskTool
22
+ from turnloop.tools.todo import TodoWriteTool
23
+ from turnloop.tools.webfetch import WebFetchTool
24
+ from turnloop.tools.write import WriteTool
25
+
26
+
27
+ def build_registry(settings: Settings, cwd: Path | None = None, *,
28
+ include_task: bool = True, include_ask: bool = True,
29
+ include_web: bool = True) -> ToolRegistry:
30
+ registry = ToolRegistry(
31
+ [
32
+ ReadTool(),
33
+ EditTool(),
34
+ WriteTool(),
35
+ BashTool(),
36
+ GlobTool(),
37
+ GrepTool(),
38
+ TodoWriteTool(),
39
+ ]
40
+ )
41
+
42
+ if include_task:
43
+ registry.add(TaskTool())
44
+ if include_ask:
45
+ registry.add(AskUserQuestionTool())
46
+ if include_web:
47
+ registry.add(WebFetchTool())
48
+
49
+ from turnloop.commands.loader import load_skills
50
+
51
+ skills = load_skills(settings.project_root)
52
+ if skills:
53
+ registry.add(SkillTool(skills))
54
+
55
+ return registry
56
+
57
+
58
+ def skills_segment(registry: ToolRegistry) -> str:
59
+ """The available-skills list for the system prompt, if any skills exist."""
60
+ tool = registry.get("Skill")
61
+ if tool is None:
62
+ return ""
63
+ return tool.advertise() # type: ignore[attr-defined]