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
@@ -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…"
@@ -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")
@@ -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
+ )