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/runner.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Tool dispatch — the only place `Tool.run()` is called.
|
|
2
|
+
|
|
3
|
+
Everything that must happen for every tool call happens here exactly once:
|
|
4
|
+
schema validation, hooks, permission checks, timeout, metrics, and error
|
|
5
|
+
containment. A test asserts that no other module calls `.run(` on a tool, because
|
|
6
|
+
the moment there are two dispatch paths the permission system has a hole in it.
|
|
7
|
+
|
|
8
|
+
The invariant that matters most: **dispatch never raises.** Every failure becomes
|
|
9
|
+
an error `tool_result` the model can read and react to. A tool that crashes must
|
|
10
|
+
cost one turn, not the whole session — and the error text it produces is also the
|
|
11
|
+
raw signal the tool-calling-reliability experiment measures.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
import anyio
|
|
20
|
+
from pydantic import ValidationError
|
|
21
|
+
|
|
22
|
+
from turnloop.core.events import ToolFinished, ToolStarted
|
|
23
|
+
from turnloop.core.messages import ToolResultBlock, ToolUseBlock
|
|
24
|
+
from turnloop.permissions.engine import (
|
|
25
|
+
PermissionDecision,
|
|
26
|
+
PermissionEngine,
|
|
27
|
+
PermissionRequest,
|
|
28
|
+
Scope,
|
|
29
|
+
Verdict,
|
|
30
|
+
)
|
|
31
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput, ToolRegistry
|
|
32
|
+
|
|
33
|
+
# Classified error kinds, recorded per call. These names are the schema the
|
|
34
|
+
# reliability report groups by, so they are stable.
|
|
35
|
+
ERROR_UNKNOWN_TOOL = "unknown_tool"
|
|
36
|
+
ERROR_BAD_JSON = "malformed_args"
|
|
37
|
+
ERROR_SCHEMA = "schema_violation"
|
|
38
|
+
ERROR_DENIED = "permission_denied"
|
|
39
|
+
ERROR_TIMEOUT = "timeout"
|
|
40
|
+
ERROR_CRASH = "tool_crash"
|
|
41
|
+
ERROR_TOOL = "tool_error"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class ToolCallRecord:
|
|
46
|
+
tool: str
|
|
47
|
+
ok: bool
|
|
48
|
+
error_kind: str | None
|
|
49
|
+
duration_s: float
|
|
50
|
+
metrics: dict = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ToolRunner:
|
|
54
|
+
def __init__(self, registry: ToolRegistry, permissions: PermissionEngine,
|
|
55
|
+
hooks=None, ui=None):
|
|
56
|
+
self.registry = registry
|
|
57
|
+
self.permissions = permissions
|
|
58
|
+
self.hooks = hooks
|
|
59
|
+
self.ui = ui
|
|
60
|
+
self.records: list[ToolCallRecord] = []
|
|
61
|
+
|
|
62
|
+
async def dispatch(self, block: ToolUseBlock, ctx: ToolContext) -> ToolResultBlock:
|
|
63
|
+
started = time.monotonic()
|
|
64
|
+
ctx = ctx.child(tool_use_id=block.id)
|
|
65
|
+
|
|
66
|
+
tool = self.registry.get(block.name)
|
|
67
|
+
if tool is None:
|
|
68
|
+
return await self._fail(
|
|
69
|
+
block, ctx, ERROR_UNKNOWN_TOOL,
|
|
70
|
+
f"No tool named {block.name!r}. Available tools: "
|
|
71
|
+
f"{', '.join(self.registry.names())}.",
|
|
72
|
+
started,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# A parse failure upstream leaves this marker rather than raising, so the
|
|
76
|
+
# model gets told its JSON was broken instead of the turn dying.
|
|
77
|
+
if "_parse_error" in block.args:
|
|
78
|
+
return await self._fail(
|
|
79
|
+
block, ctx, ERROR_BAD_JSON,
|
|
80
|
+
f"Your tool arguments were not valid JSON ({block.args['_parse_error']}). "
|
|
81
|
+
f"Call {block.name} again with well-formed JSON.",
|
|
82
|
+
started, tool=tool,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
args = tool.Args.model_validate(block.args)
|
|
87
|
+
except ValidationError as exc:
|
|
88
|
+
return await self._fail(
|
|
89
|
+
block, ctx, ERROR_SCHEMA,
|
|
90
|
+
f"Invalid arguments for {block.name}:\n{_format_validation(exc)}",
|
|
91
|
+
started, tool=tool,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
await self._emit(ToolStarted(block.id, tool.name, tool.summary(args), ctx.subagent_id))
|
|
95
|
+
|
|
96
|
+
# --- hooks may block before anything happens ------------------------
|
|
97
|
+
if self.hooks is not None:
|
|
98
|
+
outcome = await self.hooks.run_pre_tool(tool.name, args, ctx)
|
|
99
|
+
if outcome is not None and outcome.blocked:
|
|
100
|
+
return await self._fail(
|
|
101
|
+
block, ctx, ERROR_DENIED,
|
|
102
|
+
f"Blocked by a PreToolUse hook: {outcome.reason}",
|
|
103
|
+
started, tool=tool,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# --- permissions ----------------------------------------------------
|
|
107
|
+
decision = self.permissions.check(tool, args)
|
|
108
|
+
if decision.verdict is Verdict.DENY:
|
|
109
|
+
self._log_permission(ctx, tool.name, decision.verdict.value, decision.reason)
|
|
110
|
+
return await self._fail(
|
|
111
|
+
block, ctx, ERROR_DENIED,
|
|
112
|
+
f"Permission denied: {decision.reason}", started, tool=tool,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if decision.verdict is Verdict.ASK:
|
|
116
|
+
answer = await self._ask(tool, args, ctx, decision.reason)
|
|
117
|
+
self._log_permission(
|
|
118
|
+
ctx, tool.name, "approved" if answer.approved else "rejected", answer.reason
|
|
119
|
+
)
|
|
120
|
+
if not answer.approved:
|
|
121
|
+
return await self._fail(
|
|
122
|
+
block, ctx, ERROR_DENIED,
|
|
123
|
+
"The user declined this call."
|
|
124
|
+
+ (f" They said: {answer.reason}" if answer.reason else "")
|
|
125
|
+
+ " Do not retry it; ask what they would prefer.",
|
|
126
|
+
started, tool=tool,
|
|
127
|
+
)
|
|
128
|
+
if answer.scope is not Scope.ONCE and answer.rule:
|
|
129
|
+
self.permissions.grant(answer.rule, answer.scope)
|
|
130
|
+
else:
|
|
131
|
+
self._log_permission(ctx, tool.name, "allowed", decision.reason)
|
|
132
|
+
|
|
133
|
+
# --- run ------------------------------------------------------------
|
|
134
|
+
timeout = tool.timeout_s
|
|
135
|
+
try:
|
|
136
|
+
if timeout:
|
|
137
|
+
with anyio.fail_after(timeout):
|
|
138
|
+
output = await tool.run(args, ctx)
|
|
139
|
+
else:
|
|
140
|
+
output = await tool.run(args, ctx)
|
|
141
|
+
except TimeoutError:
|
|
142
|
+
return await self._fail(
|
|
143
|
+
block, ctx, ERROR_TIMEOUT,
|
|
144
|
+
f"{tool.name} exceeded its {timeout:.0f}s timeout and was cancelled.",
|
|
145
|
+
started, tool=tool,
|
|
146
|
+
)
|
|
147
|
+
except anyio.get_cancelled_exc_class():
|
|
148
|
+
# A user interrupt must propagate — it is not a tool failure.
|
|
149
|
+
raise
|
|
150
|
+
except Exception as exc: # noqa: BLE001 - containment is the point
|
|
151
|
+
return await self._fail(
|
|
152
|
+
block, ctx, ERROR_CRASH,
|
|
153
|
+
f"{tool.name} raised {type(exc).__name__}: {exc}",
|
|
154
|
+
started, tool=tool,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
duration = time.monotonic() - started
|
|
158
|
+
if self.hooks is not None:
|
|
159
|
+
await self.hooks.run_post_tool(tool.name, args, output, ctx)
|
|
160
|
+
|
|
161
|
+
self._record(tool.name, not output.is_error,
|
|
162
|
+
ERROR_TOOL if output.is_error else None, duration, output.metrics)
|
|
163
|
+
self._log_metrics(ctx, tool.name, output, duration)
|
|
164
|
+
await self._emit(
|
|
165
|
+
ToolFinished(block.id, tool.name, output.is_error, output.display, duration,
|
|
166
|
+
ctx.subagent_id)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return ToolResultBlock(
|
|
170
|
+
tool_use_id=block.id,
|
|
171
|
+
content=output.content,
|
|
172
|
+
is_error=output.is_error,
|
|
173
|
+
display=output.display,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# --- helpers -----------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
async def _ask(self, tool: Tool, args, ctx: ToolContext, reason: str) -> PermissionDecision:
|
|
179
|
+
request = PermissionRequest(
|
|
180
|
+
tool_name=tool.name,
|
|
181
|
+
target=tool.permission_target(args),
|
|
182
|
+
summary=tool.summary(args),
|
|
183
|
+
args_preview=_preview(args),
|
|
184
|
+
is_read_only=tool.is_read_only_for(args),
|
|
185
|
+
suggested_rule=self.permissions.suggested_rule(tool, args),
|
|
186
|
+
reason=reason,
|
|
187
|
+
subagent_id=ctx.subagent_id,
|
|
188
|
+
)
|
|
189
|
+
return await ctx.ask(request)
|
|
190
|
+
|
|
191
|
+
async def _fail(self, block: ToolUseBlock, ctx: ToolContext, kind: str, message: str,
|
|
192
|
+
started: float, tool: Tool | None = None) -> ToolResultBlock:
|
|
193
|
+
duration = time.monotonic() - started
|
|
194
|
+
name = tool.name if tool else block.name
|
|
195
|
+
self._record(name, False, kind, duration, {})
|
|
196
|
+
if ctx.session is not None and getattr(ctx.session, "store", None) is not None:
|
|
197
|
+
ctx.session.store.write_tool_metrics( # type: ignore[attr-defined]
|
|
198
|
+
name, {"ok": False, "error_kind": kind, "duration_s": round(duration, 4)}
|
|
199
|
+
)
|
|
200
|
+
await self._emit(ToolFinished(block.id, name, True, None, duration, ctx.subagent_id))
|
|
201
|
+
return ToolResultBlock(
|
|
202
|
+
tool_use_id=block.id, content=message, is_error=True, display=message
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _record(self, name: str, ok: bool, kind: str | None, duration: float,
|
|
206
|
+
metrics: dict) -> None:
|
|
207
|
+
self.records.append(
|
|
208
|
+
ToolCallRecord(tool=name, ok=ok, error_kind=kind, duration_s=duration,
|
|
209
|
+
metrics=dict(metrics))
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _log_metrics(self, ctx: ToolContext, name: str, output: ToolOutput,
|
|
213
|
+
duration: float) -> None:
|
|
214
|
+
store = getattr(ctx.session, "store", None)
|
|
215
|
+
if store is None:
|
|
216
|
+
return
|
|
217
|
+
store.write_tool_metrics(
|
|
218
|
+
name,
|
|
219
|
+
{
|
|
220
|
+
"ok": not output.is_error,
|
|
221
|
+
"error_kind": ERROR_TOOL if output.is_error else None,
|
|
222
|
+
"duration_s": round(duration, 4),
|
|
223
|
+
**output.metrics,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def _log_permission(self, ctx: ToolContext, tool: str, verdict: str, reason: str) -> None:
|
|
228
|
+
store = getattr(ctx.session, "store", None)
|
|
229
|
+
if store is None:
|
|
230
|
+
return
|
|
231
|
+
store.write_record("permission", {"tool": tool, "verdict": verdict, "reason": reason})
|
|
232
|
+
|
|
233
|
+
async def _emit(self, event) -> None:
|
|
234
|
+
if self.ui is not None:
|
|
235
|
+
await self.ui.send(event)
|
|
236
|
+
|
|
237
|
+
# --- reporting ---------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def error_counts(self) -> dict[str, int]:
|
|
240
|
+
counts: dict[str, int] = {}
|
|
241
|
+
for record in self.records:
|
|
242
|
+
if record.error_kind:
|
|
243
|
+
counts[record.error_kind] = counts.get(record.error_kind, 0) + 1
|
|
244
|
+
return counts
|
|
245
|
+
|
|
246
|
+
def recovery_rate(self) -> float | None:
|
|
247
|
+
"""Fraction of failed calls followed by a success on the same tool.
|
|
248
|
+
|
|
249
|
+
The single most informative reliability number: models differ far more in
|
|
250
|
+
whether they recover from a bad call than in how often they make one.
|
|
251
|
+
"""
|
|
252
|
+
failures = [i for i, r in enumerate(self.records) if r.error_kind]
|
|
253
|
+
if not failures:
|
|
254
|
+
return None
|
|
255
|
+
recovered = 0
|
|
256
|
+
for i in failures:
|
|
257
|
+
tool = self.records[i].tool
|
|
258
|
+
if any(r.tool == tool and r.ok for r in self.records[i + 1 : i + 6]):
|
|
259
|
+
recovered += 1
|
|
260
|
+
return recovered / len(failures)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _format_validation(exc: ValidationError, limit: int = 6) -> str:
|
|
264
|
+
"""Render pydantic errors so a model can act on them.
|
|
265
|
+
|
|
266
|
+
Verbatim pydantic text is genuinely good at this — it names the field, the
|
|
267
|
+
expected type and what arrived — so it is passed through rather than
|
|
268
|
+
paraphrased, and it is also exactly what the reliability experiment counts.
|
|
269
|
+
"""
|
|
270
|
+
lines = []
|
|
271
|
+
for err in exc.errors()[:limit]:
|
|
272
|
+
loc = ".".join(str(p) for p in err["loc"]) or "(root)"
|
|
273
|
+
lines.append(f"- {loc}: {err['msg']}")
|
|
274
|
+
if len(exc.errors()) > limit:
|
|
275
|
+
lines.append(f"- ... and {len(exc.errors()) - limit} more")
|
|
276
|
+
return "\n".join(lines)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _preview(args, limit: int = 600) -> str:
|
|
280
|
+
try:
|
|
281
|
+
text = args.model_dump_json(indent=2)
|
|
282
|
+
except Exception: # noqa: BLE001
|
|
283
|
+
text = str(args)
|
|
284
|
+
return text if len(text) <= limit else text[:limit] + "\n…"
|
turnloop/tools/shell.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Shell resolution and process execution.
|
|
2
|
+
|
|
3
|
+
The reason this file exists rather than a one-line `subprocess.run(["bash", ...])`:
|
|
4
|
+
|
|
5
|
+
On a default Windows install, `bash` on PATH is `C:\\Windows\\System32\\bash.exe`
|
|
6
|
+
— the WSL launcher. With no distribution installed it exits 255 and prints its
|
|
7
|
+
error in UTF-16LE, so a naive shell tool fails on its first call with a garbled
|
|
8
|
+
message and no obvious cause. Real Git Bash lives under the Git installation.
|
|
9
|
+
Any `bash.exe` beneath %SystemRoot% is therefore rejected outright.
|
|
10
|
+
|
|
11
|
+
Second trap, also Windows-specific: `bash -lc "<command>"` makes the actual
|
|
12
|
+
command a *grandchild* of the process we spawn. Calling `terminate()` on the
|
|
13
|
+
parent kills the shell and orphans the real work, which then keeps running and
|
|
14
|
+
holding file locks after a timeout. Killing the whole tree via `taskkill /T` is
|
|
15
|
+
the only reliable answer.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
from turnloop.errors import ShellNotFound
|
|
29
|
+
|
|
30
|
+
ShellKind = Literal["gitbash", "bash", "pwsh", "powershell", "sh"]
|
|
31
|
+
|
|
32
|
+
IS_WINDOWS = sys.platform == "win32"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class ShellSpec:
|
|
37
|
+
kind: ShellKind
|
|
38
|
+
exe: Path
|
|
39
|
+
posix: bool
|
|
40
|
+
|
|
41
|
+
def argv(self, command: str) -> list[str]:
|
|
42
|
+
if self.posix:
|
|
43
|
+
# -l loads the login profile so PATH matches an interactive shell.
|
|
44
|
+
return [str(self.exe), "-lc", command]
|
|
45
|
+
return [
|
|
46
|
+
str(self.exe),
|
|
47
|
+
"-NoProfile",
|
|
48
|
+
"-NonInteractive",
|
|
49
|
+
"-ExecutionPolicy",
|
|
50
|
+
"Bypass",
|
|
51
|
+
"-Command",
|
|
52
|
+
command,
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def label(self) -> str:
|
|
57
|
+
return f"{self.kind} ({self.exe})"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_wsl_stub(path: Path) -> bool:
|
|
61
|
+
"""True for the WSL launcher masquerading as bash."""
|
|
62
|
+
system_root = Path(os.environ.get("SystemRoot", r"C:\Windows")).resolve()
|
|
63
|
+
try:
|
|
64
|
+
resolved = path.resolve()
|
|
65
|
+
except OSError:
|
|
66
|
+
return False
|
|
67
|
+
return resolved == system_root or system_root in resolved.parents
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _git_bash_candidates() -> list[Path]:
|
|
71
|
+
candidates: list[Path] = []
|
|
72
|
+
git = shutil.which("git")
|
|
73
|
+
if git:
|
|
74
|
+
# <install>/cmd/git.exe -> <install>/bin/bash.exe
|
|
75
|
+
install = Path(git).resolve().parent.parent
|
|
76
|
+
candidates += [install / "bin" / "bash.exe", install / "usr" / "bin" / "bash.exe"]
|
|
77
|
+
for drive in ("C:", "D:", "E:", "F:"):
|
|
78
|
+
candidates += [
|
|
79
|
+
Path(f"{drive}/Program Files/Git/bin/bash.exe"),
|
|
80
|
+
Path(f"{drive}/Program Files (x86)/Git/bin/bash.exe"),
|
|
81
|
+
Path(f"{drive}/Git/bin/bash.exe"),
|
|
82
|
+
]
|
|
83
|
+
return candidates
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_shell(explicit: str | None = None) -> ShellSpec:
|
|
87
|
+
"""Find a usable shell, preferring POSIX so model-written commands work.
|
|
88
|
+
|
|
89
|
+
Models overwhelmingly emit POSIX shell. Handing them PowerShell means `ls
|
|
90
|
+
-la`, `cat`, `&&` chains and `2>/dev/null` all fail, so Git Bash is preferred
|
|
91
|
+
on Windows even though PowerShell is the native shell.
|
|
92
|
+
"""
|
|
93
|
+
if explicit:
|
|
94
|
+
exe = Path(explicit)
|
|
95
|
+
if not exe.exists():
|
|
96
|
+
found = shutil.which(explicit)
|
|
97
|
+
if not found:
|
|
98
|
+
raise ShellNotFound(f"configured shell not found: {explicit}")
|
|
99
|
+
exe = Path(found)
|
|
100
|
+
if IS_WINDOWS and exe.name.lower() == "bash.exe" and _is_wsl_stub(exe):
|
|
101
|
+
raise ShellNotFound(
|
|
102
|
+
f"{exe} is the WSL launcher, not a real bash. Point bash.shell at "
|
|
103
|
+
r"Git Bash, e.g. C:\Program Files\Git\bin\bash.exe"
|
|
104
|
+
)
|
|
105
|
+
posix = exe.name.lower() in ("bash.exe", "bash", "sh", "sh.exe", "zsh", "dash")
|
|
106
|
+
kind: ShellKind = "bash" if posix else ("pwsh" if "pwsh" in exe.name.lower() else "powershell")
|
|
107
|
+
return ShellSpec(kind=kind, exe=exe, posix=posix)
|
|
108
|
+
|
|
109
|
+
if not IS_WINDOWS:
|
|
110
|
+
for name in ("bash", "sh"):
|
|
111
|
+
if found := shutil.which(name):
|
|
112
|
+
return ShellSpec(kind="bash" if name == "bash" else "sh",
|
|
113
|
+
exe=Path(found), posix=True)
|
|
114
|
+
raise ShellNotFound("no POSIX shell found on PATH")
|
|
115
|
+
|
|
116
|
+
for candidate in _git_bash_candidates():
|
|
117
|
+
if candidate.is_file() and not _is_wsl_stub(candidate):
|
|
118
|
+
return ShellSpec(kind="gitbash", exe=candidate, posix=True)
|
|
119
|
+
|
|
120
|
+
# A bash on PATH is acceptable only if it is not the System32 stub.
|
|
121
|
+
if found := shutil.which("bash"):
|
|
122
|
+
path = Path(found)
|
|
123
|
+
if not _is_wsl_stub(path):
|
|
124
|
+
return ShellSpec(kind="bash", exe=path, posix=True)
|
|
125
|
+
|
|
126
|
+
for name in ("pwsh", "powershell"):
|
|
127
|
+
if found := shutil.which(name):
|
|
128
|
+
ps_kind: ShellKind = "pwsh" if name == "pwsh" else "powershell"
|
|
129
|
+
return ShellSpec(kind=ps_kind, exe=Path(found), posix=False)
|
|
130
|
+
|
|
131
|
+
raise ShellNotFound(
|
|
132
|
+
"no usable shell found. Install Git for Windows (provides Git Bash) or "
|
|
133
|
+
"ensure powershell.exe is on PATH."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def describe_shell_environment() -> list[tuple[str, str]]:
|
|
138
|
+
"""Diagnostics for `turnloop doctor`."""
|
|
139
|
+
rows: list[tuple[str, str]] = []
|
|
140
|
+
try:
|
|
141
|
+
spec = resolve_shell()
|
|
142
|
+
rows.append(("shell", spec.label))
|
|
143
|
+
rows.append(("shell dialect", "POSIX" if spec.posix else "PowerShell"))
|
|
144
|
+
except ShellNotFound as exc:
|
|
145
|
+
rows.append(("shell", f"NOT FOUND — {exc}"))
|
|
146
|
+
|
|
147
|
+
if IS_WINDOWS:
|
|
148
|
+
on_path = shutil.which("bash")
|
|
149
|
+
if on_path and _is_wsl_stub(Path(on_path)):
|
|
150
|
+
rows.append(
|
|
151
|
+
("bash on PATH", f"{on_path} — WSL launcher, ignored (it is not a real bash)")
|
|
152
|
+
)
|
|
153
|
+
elif on_path:
|
|
154
|
+
rows.append(("bash on PATH", on_path))
|
|
155
|
+
else:
|
|
156
|
+
rows.append(("bash on PATH", "not present"))
|
|
157
|
+
return rows
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --------------------------------------------------------------------------
|
|
161
|
+
# process control
|
|
162
|
+
# --------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def spawn_kwargs() -> dict:
|
|
166
|
+
"""Platform flags needed to kill a whole process tree later."""
|
|
167
|
+
if IS_WINDOWS:
|
|
168
|
+
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
169
|
+
return {"start_new_session": True}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def kill_tree(pid: int) -> None:
|
|
173
|
+
"""Kill a process and everything it spawned.
|
|
174
|
+
|
|
175
|
+
Required because `bash -lc` puts the real command one level down; terminating
|
|
176
|
+
only the shell leaves the command running and holding locks.
|
|
177
|
+
"""
|
|
178
|
+
if IS_WINDOWS:
|
|
179
|
+
subprocess.run(
|
|
180
|
+
["taskkill", "/T", "/F", "/PID", str(pid)],
|
|
181
|
+
capture_output=True,
|
|
182
|
+
check=False,
|
|
183
|
+
)
|
|
184
|
+
return
|
|
185
|
+
import signal
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
# POSIX-only attributes; this branch never runs on Windows.
|
|
189
|
+
os.killpg(os.getpgid(pid), signal.SIGKILL) # type: ignore[attr-defined]
|
|
190
|
+
except (ProcessLookupError, PermissionError):
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def child_env(extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
195
|
+
"""Environment for spawned commands.
|
|
196
|
+
|
|
197
|
+
Forcing UTF-8 on the child avoids decoding surprises from Python subprocesses
|
|
198
|
+
and from PowerShell, which otherwise emits UTF-16LE in some configurations.
|
|
199
|
+
"""
|
|
200
|
+
env = dict(os.environ)
|
|
201
|
+
env.update(
|
|
202
|
+
{
|
|
203
|
+
"PYTHONIOENCODING": "utf-8",
|
|
204
|
+
"PYTHONUNBUFFERED": "1",
|
|
205
|
+
"TERM": env.get("TERM", "dumb"),
|
|
206
|
+
"NO_COLOR": "1", # ANSI escapes in tool output are wasted context
|
|
207
|
+
"GIT_PAGER": "cat",
|
|
208
|
+
"PAGER": "cat",
|
|
209
|
+
"TURNLOOP": "1", # let scripts and hooks detect the harness
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
env.update(extra or {})
|
|
213
|
+
return env
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def decode_output(raw: bytes) -> str:
|
|
217
|
+
"""Decode child output, tolerating PowerShell's UTF-16."""
|
|
218
|
+
if raw.startswith(b"\xff\xfe"):
|
|
219
|
+
return raw.decode("utf-16-le", errors="replace").lstrip("\ufeff")
|
|
220
|
+
if raw.startswith(b"\xef\xbb\xbf"):
|
|
221
|
+
return raw.decode("utf-8-sig", errors="replace")
|
|
222
|
+
return raw.decode("utf-8", errors="replace")
|
turnloop/tools/skill.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Skill — load a set of instructions on demand.
|
|
2
|
+
|
|
3
|
+
Progressive disclosure, and on a 65k window it is the difference between having
|
|
4
|
+
skills and not. The system prompt carries one line per skill; the body enters
|
|
5
|
+
context only when the model decides the skill applies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
from turnloop.commands.loader import Skill as SkillDef
|
|
13
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SkillArgs(BaseModel):
|
|
17
|
+
skill: str = Field(description="Exact skill name from the available-skills list.")
|
|
18
|
+
args: str = Field(default="", description="Optional arguments to pass through.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SkillTool(Tool):
|
|
22
|
+
name = "Skill"
|
|
23
|
+
Args = SkillArgs
|
|
24
|
+
read_only = True
|
|
25
|
+
parallel_safe = False
|
|
26
|
+
|
|
27
|
+
def __init__(self, skills: dict[str, SkillDef] | None = None):
|
|
28
|
+
self.skills = skills or {}
|
|
29
|
+
|
|
30
|
+
descriptions = {
|
|
31
|
+
"terse": """
|
|
32
|
+
Load a skill's full instructions by exact name. Call it when a listed skill covers
|
|
33
|
+
the task, then follow what it returns.
|
|
34
|
+
""",
|
|
35
|
+
"normal": """
|
|
36
|
+
Load the full instructions for a skill.
|
|
37
|
+
|
|
38
|
+
- `skill` must be an exact name from the available-skills list in your system
|
|
39
|
+
prompt. Do not guess names.
|
|
40
|
+
- The returned instructions apply to the current task; follow them in place of
|
|
41
|
+
your default approach.
|
|
42
|
+
- Call this before starting work the skill covers, not after.
|
|
43
|
+
""",
|
|
44
|
+
"verbose": """
|
|
45
|
+
Load the full instructions for a skill.
|
|
46
|
+
|
|
47
|
+
Arguments:
|
|
48
|
+
- `skill`: an exact name from the available-skills list in your system prompt.
|
|
49
|
+
Guessing a name simply fails.
|
|
50
|
+
- `args`: optional arguments, passed through to the skill.
|
|
51
|
+
|
|
52
|
+
Why this is a tool rather than part of your prompt: skill bodies are long. Keeping
|
|
53
|
+
them all loaded would consume a large fraction of the context window on
|
|
54
|
+
instructions that are mostly irrelevant to any given task. You are shown each
|
|
55
|
+
skill's name and description, and you fetch the body when it is the one you need.
|
|
56
|
+
|
|
57
|
+
Once loaded, treat the instructions as authoritative for this task — they encode
|
|
58
|
+
how this project wants the work done, which takes precedence over your defaults.
|
|
59
|
+
""",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async def run(self, args: SkillArgs, ctx: ToolContext) -> ToolOutput:
|
|
63
|
+
skill = self.skills.get(args.skill)
|
|
64
|
+
if skill is None:
|
|
65
|
+
available = ", ".join(sorted(self.skills)) or "none configured"
|
|
66
|
+
return ToolOutput.error(
|
|
67
|
+
f"No skill named {args.skill!r}. Available: {available}."
|
|
68
|
+
)
|
|
69
|
+
body = skill.body.strip()
|
|
70
|
+
if args.args:
|
|
71
|
+
body += f"\n\nArguments supplied: {args.args}"
|
|
72
|
+
return ToolOutput(
|
|
73
|
+
content=f"--- skill: {skill.name} ({skill.path}) ---\n{body}",
|
|
74
|
+
display=f"Loaded skill {skill.name}",
|
|
75
|
+
metrics={"skill": skill.name, "body_chars": len(body)},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def summary(self, args: SkillArgs) -> str: # type: ignore[override]
|
|
79
|
+
return f"Skill {args.skill}"
|
|
80
|
+
|
|
81
|
+
def advertise(self) -> str:
|
|
82
|
+
"""The system-prompt segment listing what is available."""
|
|
83
|
+
if not self.skills:
|
|
84
|
+
return ""
|
|
85
|
+
lines = [skill.advertise() for skill in sorted(self.skills.values(), key=lambda s: s.name)]
|
|
86
|
+
return (
|
|
87
|
+
"Available skills (load one with the Skill tool when it applies):\n"
|
|
88
|
+
+ "\n".join(lines)
|
|
89
|
+
)
|