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