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,263 @@
1
+ """System prompt assembly.
2
+
3
+ Returned as an ordered list of segments rather than one string, for two reasons.
4
+ Anthropic cache breakpoints attach to segment boundaries, so the stable parts have
5
+ to be separable from the volatile ones; and the ablation experiments need to swap
6
+ one segment without rewriting the rest.
7
+
8
+ Ordering is stable-to-volatile: identity, then behavior, then tool guidance, then
9
+ environment, then memory. Putting the environment first would invalidate the cache
10
+ on every session, which is the most expensive possible mistake here.
11
+
12
+ `variant` selects a whole behavioral segment. The variants are not decoration —
13
+ "minimal" versus "default" is a direct test of how much of a coding agent's
14
+ competence comes from its prompt versus its model, which is one of the more
15
+ interesting things this project can measure.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import platform
21
+ import subprocess
22
+ import sys
23
+ from datetime import date
24
+ from pathlib import Path
25
+
26
+ from turnloop.config import PermissionMode, Settings
27
+ from turnloop.context.memory import discover_memory, render_memory
28
+ from turnloop.providers.base import Capabilities
29
+ from turnloop.tools.base import ToolRegistry
30
+
31
+ IDENTITY = """\
32
+ You are turnloop, a coding agent that works directly in a user's codebase from \
33
+ their terminal. You read and modify real files, run real commands, and the effects \
34
+ are immediate and visible to the user.
35
+ """
36
+
37
+ BEHAVIOR_DEFAULT = """\
38
+ How to work:
39
+
40
+ - Act on the request that was made. Do not widen the scope, and do not narrow it \
41
+ either: if the task has four parts, finish all four.
42
+ - Read before you write. Never edit a file you have not read in this session.
43
+ - Prefer the dedicated tools over shell equivalents: Read over `cat`, Glob over \
44
+ `find`, Grep over `grep`. They are cheaper and they do not need approval.
45
+ - Match the surrounding code. Its conventions, naming and structure are the spec \
46
+ for anything you add, regardless of your own preferences.
47
+ - Verify your work. If there are tests, run them. If there is a build, run it. \
48
+ Report what the output actually said, including failures — never claim something \
49
+ passes without having seen it pass.
50
+ - When several independent tool calls are needed, request them together rather \
51
+ than one per turn.
52
+ - Make routine judgment calls yourself. Ask the user only when two readings of \
53
+ the request would lead to materially different work.
54
+ - Be concise in what you say. The user is reading a terminal, not a report. Skip \
55
+ preambles, skip summaries of what you are about to do, and do not restate code \
56
+ you just wrote.
57
+ """
58
+
59
+ BEHAVIOR_MINIMAL = """\
60
+ Use the available tools to complete the user's request. Read files before editing \
61
+ them. Verify your changes.
62
+ """
63
+
64
+ BEHAVIOR_CAREFUL = """\
65
+ How to work:
66
+
67
+ - Before acting, state your plan in one or two sentences.
68
+ - Read every file you intend to change, in full, before changing it.
69
+ - Make one change at a time and verify it before starting the next.
70
+ - After each edit, re-read the changed region to confirm it is what you intended.
71
+ - Run the tests after every change, not once at the end.
72
+ - If a command fails, stop and diagnose the cause before trying anything else. Do \
73
+ not attempt the same thing twice.
74
+ """
75
+
76
+ BEHAVIOR_VARIANTS = {
77
+ "default": BEHAVIOR_DEFAULT,
78
+ "minimal": BEHAVIOR_MINIMAL,
79
+ "careful": BEHAVIOR_CAREFUL,
80
+ }
81
+
82
+ TOOL_GUIDANCE = """\
83
+ Tool use:
84
+
85
+ - A tool result marked as an error is information, not a dead end. Read it, fix \
86
+ the cause, and continue. The same call repeated unchanged will fail the same way.
87
+ - If a tool reports invalid arguments, the schema is in the tool definition. Fix \
88
+ the argument shape rather than rephrasing the same call.
89
+ - Task delegates work to a subagent with its own context. Use it for searches that \
90
+ would flood this conversation with output you do not need to keep, and for \
91
+ independent pieces of work that can run in parallel.
92
+ - TodoWrite is for multi-step work. Keep exactly one item in progress, and mark \
93
+ items complete as you finish them rather than in a batch at the end.
94
+ """
95
+
96
+ PLAN_MODE = """\
97
+ You are in plan mode. Every tool that modifies anything is unavailable: no Write, \
98
+ no Edit, no state-changing shell command. Read, search and investigate as much as \
99
+ you need, then present a plan for the user to approve. Do not describe the plan as \
100
+ already done.
101
+ """
102
+
103
+ CONCISENESS = """\
104
+ Response style: terminal output, not prose. No preamble ("I'll help you with \
105
+ that"), no postamble ("Let me know if you need anything else"), no restating the \
106
+ user's request. Answer in as few words as carry the information. Reference code as \
107
+ `path/to/file.py:42` — the user's terminal makes that clickable.
108
+ """
109
+
110
+ THINK_FIRST = """\
111
+ Before each tool call, briefly reason about what you expect it to show and how that \
112
+ advances the task. State the reasoning in one short line, then make the call.
113
+ """
114
+
115
+
116
+ def build_system(
117
+ settings: Settings,
118
+ cwd: Path,
119
+ registry: ToolRegistry,
120
+ caps: Capabilities,
121
+ mode: PermissionMode = "default",
122
+ variant: str | None = None,
123
+ subagent_prompt: str | None = None,
124
+ ) -> list[str]:
125
+ """Assemble the system prompt segments, stable first."""
126
+ variant = variant or settings.system_prompt_variant
127
+ segments: list[str] = [IDENTITY.strip()]
128
+
129
+ if subagent_prompt:
130
+ # A subagent's brief replaces the general behavior block: it has one job,
131
+ # and the full guidance would dilute it while costing context.
132
+ segments.append(subagent_prompt.strip())
133
+ else:
134
+ segments.append(BEHAVIOR_VARIANTS.get(variant, BEHAVIOR_DEFAULT).strip())
135
+ segments.append(CONCISENESS.strip())
136
+
137
+ if len(registry) > 1 and variant != "minimal":
138
+ segments.append(TOOL_GUIDANCE.strip())
139
+
140
+ # Models with no reasoning mechanism benefit from being told to reason in
141
+ # text; models with one are hurt by it, because it duplicates the thinking.
142
+ if not caps.native_thinking and not caps.supports_reasoning_field and variant != "minimal":
143
+ segments.append(THINK_FIRST.strip())
144
+
145
+ if mode == "plan":
146
+ segments.append(PLAN_MODE.strip())
147
+
148
+ segments.append(environment_segment(cwd, settings, caps))
149
+
150
+ if settings.include_memory:
151
+ memory = render_memory(discover_memory(cwd, settings.project_root))
152
+ if memory:
153
+ segments.append(
154
+ "The user's project instructions follow. Treat them as direct "
155
+ "instructions from the user; they override your defaults.\n\n" + memory
156
+ )
157
+
158
+ return [s for s in segments if s.strip()]
159
+
160
+
161
+ def environment_segment(cwd: Path, settings: Settings, caps: Capabilities) -> str:
162
+ """Facts about the machine the agent is running on.
163
+
164
+ Volatile, so it goes last. The shell line is load-bearing on Windows: a model
165
+ that assumes POSIX when PowerShell is in use will write commands that fail for
166
+ reasons it cannot see.
167
+ """
168
+ from turnloop.tools.shell import resolve_shell
169
+
170
+ try:
171
+ spec = resolve_shell(settings.bash.shell)
172
+ shell_desc = f"{spec.kind} at {spec.exe} ({'POSIX' if spec.posix else 'PowerShell'} syntax)"
173
+ except Exception: # noqa: BLE001
174
+ shell_desc = "none detected — Bash will fail"
175
+
176
+ lines = [
177
+ "Environment:",
178
+ f"- Working directory: {cwd}",
179
+ f"- Project root: {settings.project_root}",
180
+ f"- Platform: {sys.platform} ({platform.system()} {platform.release()})",
181
+ f"- Shell: {shell_desc}",
182
+ f"- Today: {date.today().isoformat()}",
183
+ f"- Context window: {caps.max_context:,} tokens",
184
+ ]
185
+
186
+ if caps.max_context <= 100_000:
187
+ lines.append(
188
+ f"- This window is small ({caps.max_context:,} tokens). Read files in "
189
+ "targeted ranges rather than whole, and prefer Grep in "
190
+ "files_with_matches mode over dumping content. Older tool output is "
191
+ "compacted automatically, so do not rely on being able to re-read it "
192
+ "later in the conversation."
193
+ )
194
+
195
+ if git := _git_context(cwd, settings.project_root):
196
+ lines.append(git)
197
+
198
+ return "\n".join(lines)
199
+
200
+
201
+ def _git_context(cwd: Path, project_root: Path) -> str:
202
+ """Current branch and dirty-file count, when the repository *is* this project.
203
+
204
+ The `toplevel` check is not cosmetic. A project can sit inside an unrelated
205
+ ancestor repository — on the author's machine the drive root `F:\\` is itself a
206
+ git repo — and then `git status` scans the entire drive: tens of seconds,
207
+ blocking, for a dirty count that describes something the agent is not working
208
+ on. So: report the branch, skip the count, say why.
209
+ """
210
+ try:
211
+ toplevel = subprocess.run(
212
+ ["git", "rev-parse", "--show-toplevel"],
213
+ cwd=cwd, capture_output=True, text=True, timeout=5,
214
+ )
215
+ if toplevel.returncode != 0:
216
+ return ""
217
+ branch = subprocess.run(
218
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
219
+ cwd=cwd, capture_output=True, text=True, timeout=5,
220
+ )
221
+ name = branch.stdout.strip() or "(detached)"
222
+
223
+ repo_root = Path(toplevel.stdout.strip())
224
+ if repo_root.resolve() != project_root.resolve():
225
+ return f"- Git: branch {name} (repository root is {repo_root}, not this project)"
226
+
227
+ status = subprocess.run(
228
+ ["git", "status", "--porcelain"],
229
+ cwd=cwd, capture_output=True, text=True, timeout=10,
230
+ )
231
+ dirty = len([line for line in status.stdout.splitlines() if line.strip()])
232
+ return f"- Git: branch {name}, {dirty} uncommitted change(s)"
233
+ except (OSError, subprocess.SubprocessError):
234
+ return ""
235
+
236
+
237
+ SUBAGENT_PROMPTS = {
238
+ "general": """\
239
+ You are a subagent handling one delegated task. You have your own context; the \
240
+ parent agent cannot see your intermediate steps, only your final message.
241
+
242
+ Do the work, then reply with the answer itself — findings, file paths with line \
243
+ numbers, what you changed. Do not narrate your process, and do not ask questions: \
244
+ nobody is available to answer them. If the task turns out to be impossible, say \
245
+ what blocked you and what you tried.
246
+ """,
247
+ "explore": """\
248
+ You are a read-only search subagent. Locate what was asked for and report it.
249
+
250
+ Report file paths with line numbers and a one-line description of each hit. Quote \
251
+ only the lines that matter. Do not review the code, do not propose changes, and do \
252
+ not read whole files when a targeted range answers the question. Your value is \
253
+ that the parent gets the conclusion without paying for the search.
254
+ """,
255
+ "verify": """\
256
+ You are a verification subagent. Determine whether the stated work is actually \
257
+ complete and correct.
258
+
259
+ Run the tests. Run the build. Read the changed files. Report what you observed, \
260
+ quoting real command output — not what should have happened. If something is \
261
+ broken, say precisely what and where. Do not fix anything.
262
+ """,
263
+ }
turnloop/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ """Command-line entry point.
2
+
3
+ Subcommand-free by default: `turnloop` opens the TUI, `turnloop -p "..."` runs
4
+ one headless turn. Explicit subcommands exist for things that are not a
5
+ conversation (`doctor`, `config`, `sessions`, `experiment`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from turnloop import __version__
15
+ from turnloop.config import Settings, load_settings
16
+ from turnloop.errors import ConfigError, TurnloopError
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ # Shared flags live on a parent parser so they work on either side of a
21
+ # subcommand: `turnloop --provider glm config` and `turnloop config
22
+ # --provider glm` both do what the user obviously meant.
23
+ # SUPPRESS is required, not cosmetic: a subparser built with `parents=`
24
+ # re-applies its defaults over the namespace, so a plain default=None would
25
+ # make `turnloop --provider glm config` silently forget the flag.
26
+ common = argparse.ArgumentParser(add_help=False)
27
+ common.add_argument("--provider", default=argparse.SUPPRESS,
28
+ help="configured provider name (default: mock)")
29
+ common.add_argument("--model", default=argparse.SUPPRESS,
30
+ help="override the selected provider's model")
31
+ common.add_argument("--permission-mode", choices=["default", "plan", "auto", "bypass"],
32
+ default=argparse.SUPPRESS, help="how tool calls are gated")
33
+ common.add_argument("--cwd", type=Path, default=argparse.SUPPRESS,
34
+ help="working directory")
35
+ common.add_argument("--max-iterations", type=int, default=argparse.SUPPRESS,
36
+ help="tool-loop safety cap")
37
+
38
+ p = argparse.ArgumentParser(
39
+ prog="turnloop",
40
+ description="A local-first agentic coding harness.",
41
+ parents=[common],
42
+ )
43
+ p.add_argument("--version", action="version", version=f"turnloop {__version__}")
44
+ p.add_argument("-p", "--print", dest="prompt", metavar="PROMPT",
45
+ help="run a single headless turn and print the result")
46
+ p.add_argument("--resume", nargs="?", const="__last__", metavar="SESSION_ID",
47
+ help="resume the last session, or a specific one")
48
+ p.add_argument("--json", action="store_true", help="headless output as JSONL events")
49
+
50
+ sub = p.add_subparsers(dest="command")
51
+
52
+ sub.add_parser("doctor", parents=[common],
53
+ help="diagnose shell, providers and configuration")
54
+
55
+ cfg = sub.add_parser("config", parents=[common], help="show effective configuration")
56
+ cfg.add_argument("--raw", action="store_true", help="dump full JSON")
57
+
58
+ sess = sub.add_parser("sessions", parents=[common], help="list recorded sessions")
59
+ sess.add_argument("-n", type=int, default=20, help="how many to show")
60
+
61
+ exp = sub.add_parser("experiment", parents=[common], help="run measurement experiments")
62
+ exp.add_argument("action", choices=["run", "report"])
63
+ exp.add_argument("config", nargs="?", help="path to an experiment config YAML")
64
+
65
+ return p
66
+
67
+
68
+ def _cli_overrides(args: argparse.Namespace) -> dict:
69
+ out: dict = {}
70
+ for flag, key in (
71
+ ("provider", "provider"),
72
+ ("model", "_model"),
73
+ ("permission_mode", "permission_mode"),
74
+ ("max_iterations", "max_iterations"),
75
+ ):
76
+ value = getattr(args, flag, None)
77
+ if value:
78
+ out[key] = value
79
+ return out
80
+
81
+
82
+ def main(argv: list[str] | None = None) -> int:
83
+ parser = build_parser()
84
+ args = parser.parse_args(argv)
85
+ cwd = (getattr(args, "cwd", None) or Path.cwd()).resolve()
86
+
87
+ try:
88
+ settings = load_settings(cwd, _cli_overrides(args))
89
+ except ConfigError as exc:
90
+ print(f"configuration error: {exc}", file=sys.stderr)
91
+ return 2
92
+
93
+ try:
94
+ if args.command == "doctor":
95
+ return _cmd_doctor(settings, cwd)
96
+ if args.command == "config":
97
+ return _cmd_config(settings, raw=args.raw)
98
+ if args.command == "sessions":
99
+ return _cmd_sessions(settings, args.n)
100
+ if args.command == "experiment":
101
+ return _cmd_experiment(settings, args)
102
+ if args.prompt:
103
+ return _cmd_headless(settings, cwd, args)
104
+ return _cmd_tui(settings, cwd, args)
105
+ except TurnloopError as exc:
106
+ print(f"error: {exc}", file=sys.stderr)
107
+ return 1
108
+ except KeyboardInterrupt:
109
+ print("\ninterrupted", file=sys.stderr)
110
+ return 130
111
+
112
+
113
+ # --------------------------------------------------------------------------
114
+ # subcommands
115
+ # --------------------------------------------------------------------------
116
+
117
+
118
+ def _cmd_config(settings: Settings, raw: bool) -> int:
119
+ if raw:
120
+ print(settings.model_dump_json(indent=2))
121
+ return 0
122
+ cfg = settings.provider_config()
123
+ print(f"project root {settings.project_root}")
124
+ print(f"config layers {', '.join(settings.sources)}")
125
+ print(f"provider {settings.provider} ({cfg.kind}) model={cfg.model}")
126
+ print(f"context window {cfg.caps.max_context:,} tokens")
127
+ print(f"permission mode {settings.permission_mode}")
128
+ print(f"tool verbosity {settings.verbosity_for()}")
129
+ if cfg.caps.cost_per_hour:
130
+ print(f"billing ${cfg.caps.cost_per_hour:.2f}/hour (wall clock, self-hosted)")
131
+ else:
132
+ print(
133
+ f"billing ${cfg.caps.price_in_per_mtok:.2f}/Mtok in, "
134
+ f"${cfg.caps.price_out_per_mtok:.2f}/Mtok out"
135
+ )
136
+ print(f"providers {', '.join(sorted(settings.providers))}")
137
+ return 0
138
+
139
+
140
+ def _cmd_doctor(settings: Settings, cwd: Path) -> int:
141
+ from turnloop.diagnostics import run_doctor
142
+
143
+ return run_doctor(settings, cwd)
144
+
145
+
146
+ def _cmd_sessions(settings: Settings, limit: int) -> int:
147
+ from turnloop.sessions.store import SessionStore
148
+
149
+ rows = SessionStore.list_sessions(settings.project_root)[:limit]
150
+ if not rows:
151
+ print("no sessions recorded yet")
152
+ return 0
153
+ for info in rows:
154
+ print(
155
+ f"{info.session_id} {info.started_at:%Y-%m-%d %H:%M} "
156
+ f"{info.messages:>4} msgs {info.provider:<10} {info.summary[:60]}"
157
+ )
158
+ return 0
159
+
160
+
161
+ def _cmd_headless(settings: Settings, cwd: Path, args: argparse.Namespace) -> int:
162
+ import anyio
163
+
164
+ from turnloop.agent.headless import run_headless
165
+
166
+ return anyio.run(
167
+ run_headless, settings, cwd, args.prompt, args.json, args.resume
168
+ )
169
+
170
+
171
+ def _cmd_tui(settings: Settings, cwd: Path, args: argparse.Namespace) -> int:
172
+ from turnloop.tui.app import TurnloopApp
173
+
174
+ app = TurnloopApp(settings=settings, cwd=cwd, resume=args.resume)
175
+ app.run()
176
+ return app.exit_code or 0
177
+
178
+
179
+ def _cmd_experiment(settings: Settings, args: argparse.Namespace) -> int:
180
+ import anyio
181
+
182
+ from turnloop.experiments.report import render_report
183
+ from turnloop.experiments.runner import run_from_config
184
+
185
+ if args.action == "report":
186
+ if not args.config:
187
+ print("experiment report: pass a run directory", file=sys.stderr)
188
+ return 2
189
+ print(render_report(Path(args.config)))
190
+ return 0
191
+
192
+ if not args.config:
193
+ print("experiment run: pass an experiment config YAML", file=sys.stderr)
194
+ return 2
195
+ out = anyio.run(run_from_config, Path(args.config), settings)
196
+ print(f"\nartifacts: {out}")
197
+ print(render_report(out))
198
+ return 0
199
+
200
+
201
+ if __name__ == "__main__": # pragma: no cover
202
+ raise SystemExit(main())
File without changes