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,131 @@
1
+ """The approval modal.
2
+
3
+ Answerable with one key. The options are deliberately asymmetric: approving once
4
+ is `y`, approving a *narrow* rule for the session is `a`, and there is no
5
+ "always allow everything" — a blanket grant made in a hurry is how a permission
6
+ system stops meaning anything.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from rich.text import Text
12
+ from textual.app import ComposeResult
13
+ from textual.containers import Vertical
14
+ from textual.screen import ModalScreen
15
+ from textual.widgets import Static
16
+
17
+ from turnloop.permissions.engine import PermissionDecision, PermissionRequest, Scope
18
+
19
+
20
+ class PermissionModal(ModalScreen[PermissionDecision]):
21
+ BINDINGS = [
22
+ ("y", "approve_once", "Allow once"),
23
+ ("enter", "approve_once", "Allow once"),
24
+ ("a", "approve_session", "Allow for session"),
25
+ ("s", "approve_project", "Save to settings"),
26
+ ("n", "reject", "Decline"),
27
+ ("escape", "reject", "Decline"),
28
+ ]
29
+
30
+ def __init__(self, request: PermissionRequest):
31
+ super().__init__()
32
+ self.request = request
33
+
34
+ def compose(self) -> ComposeResult:
35
+ with Vertical(id="permission-box"):
36
+ yield Static(self._body(), id="permission-body")
37
+ yield Static(self._keys(), id="permission-keys")
38
+
39
+ def _body(self) -> Text:
40
+ req = self.request
41
+ text = Text()
42
+ text.append("Permission required\n\n", style="bold yellow")
43
+ text.append(f"{req.tool_name} ", style="bold")
44
+ text.append(f"{req.summary}\n\n", style="cyan")
45
+ if req.reason:
46
+ text.append(f"{req.reason}\n\n", style="dim")
47
+ preview = req.args_preview.strip()
48
+ if preview and preview != req.summary:
49
+ text.append(preview[:800] + "\n", style="dim")
50
+ return text
51
+
52
+ def _keys(self) -> Text:
53
+ text = Text()
54
+ text.append("\n[y] ", style="bold green")
55
+ text.append("allow once ")
56
+ if self.request.suggested_rule:
57
+ text.append("[a] ", style="bold green")
58
+ text.append(f"allow {self.request.suggested_rule} this session ")
59
+ text.append("[s] ", style="bold")
60
+ text.append("save that rule ")
61
+ text.append("[n] ", style="bold red")
62
+ text.append("decline")
63
+ return text
64
+
65
+ # --- actions ----------------------------------------------------------
66
+
67
+ def action_approve_once(self) -> None:
68
+ self.dismiss(PermissionDecision(approved=True, scope=Scope.ONCE))
69
+
70
+ def action_approve_session(self) -> None:
71
+ self.dismiss(
72
+ PermissionDecision(
73
+ approved=True, scope=Scope.SESSION, rule=self.request.suggested_rule
74
+ )
75
+ )
76
+
77
+ def action_approve_project(self) -> None:
78
+ self.dismiss(
79
+ PermissionDecision(
80
+ approved=True, scope=Scope.PROJECT, rule=self.request.suggested_rule
81
+ )
82
+ )
83
+
84
+ def action_reject(self) -> None:
85
+ self.dismiss(
86
+ PermissionDecision(approved=False, reason="the user declined this call")
87
+ )
88
+
89
+
90
+ class ChoiceModal(ModalScreen[PermissionDecision]):
91
+ """AskUserQuestion's presentation: numbered options, one keystroke each."""
92
+
93
+ BINDINGS = [("escape", "dismiss_none", "Skip")]
94
+
95
+ def __init__(self, request: PermissionRequest):
96
+ super().__init__()
97
+ self.request = request
98
+ self.options = _parse_options(request.args_preview)
99
+ for index, label in enumerate(self.options[:9], start=1):
100
+ self._bind_option(str(index), label)
101
+
102
+ def _bind_option(self, key: str, label: str) -> None:
103
+ self._bindings.bind(key, f"choose('{label}')", label)
104
+
105
+ def compose(self) -> ComposeResult:
106
+ with Vertical(id="permission-box"):
107
+ yield Static(self._body(), id="permission-body")
108
+
109
+ def _body(self) -> Text:
110
+ text = Text()
111
+ text.append(f"{self.request.summary}\n\n", style="bold")
112
+ for index, label in enumerate(self.options[:9], start=1):
113
+ text.append(f" [{index}] ", style="bold green")
114
+ text.append(f"{label}\n")
115
+ text.append("\n[esc] skip", style="dim")
116
+ return text
117
+
118
+ def action_choose(self, label: str) -> None:
119
+ self.dismiss(PermissionDecision(approved=True, reason=label))
120
+
121
+ def action_dismiss_none(self) -> None:
122
+ self.dismiss(PermissionDecision(approved=False, reason=""))
123
+
124
+
125
+ def _parse_options(preview: str) -> list[str]:
126
+ options: list[str] = []
127
+ for line in preview.splitlines():
128
+ stripped = line.strip()
129
+ if stripped[:1].isdigit() and "." in stripped[:3]:
130
+ options.append(stripped.split(".", 1)[1].strip())
131
+ return options
@@ -0,0 +1,110 @@
1
+ """The status bar.
2
+
3
+ Computes nothing. Everything it shows arrives in a StatusUpdate, so a slow status
4
+ line can never slow the agent loop down.
5
+
6
+ The GPU segment is the part that earns its space: a self-hosted endpoint bills by
7
+ wall clock and only scales down after ten idle minutes, so an open session quietly
8
+ costs $18.16/hour. Showing the running total, in amber, is the difference between
9
+ noticing and finding out from a bill.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from rich.text import Text
15
+ from textual.reactive import reactive
16
+ from textual.widgets import Static
17
+
18
+
19
+ class StatusBar(Static):
20
+ provider = reactive("")
21
+ model = reactive("")
22
+ mode = reactive("default")
23
+ context_tokens = reactive(0)
24
+ context_max = reactive(1)
25
+ cost_usd = reactive(0.0)
26
+ tokens_in = reactive(0)
27
+ tokens_out = reactive(0)
28
+ gpu_seconds = reactive(0.0)
29
+ cost_per_hour = reactive(0.0)
30
+ note = reactive("")
31
+
32
+ def render(self) -> Text:
33
+ line = Text()
34
+ line.append(f" {self.provider}", style="bold")
35
+ if self.model:
36
+ line.append(f":{self.model}", style="dim")
37
+ line.append(" ")
38
+
39
+ pressure = self.context_tokens / max(1, self.context_max)
40
+ style = "green" if pressure < 0.5 else ("yellow" if pressure < 0.8 else "red")
41
+ line.append(
42
+ f"ctx {_k(self.context_tokens)}/{_k(self.context_max)} ({pressure * 100:.0f}%)",
43
+ style=style,
44
+ )
45
+
46
+ line.append(" ")
47
+ line.append(f"{_k(self.tokens_in)} in / {_k(self.tokens_out)} out", style="dim")
48
+
49
+ if self.cost_per_hour and self.gpu_seconds:
50
+ gpu_cost = self.gpu_seconds / 3600 * self.cost_per_hour
51
+ line.append(" ")
52
+ line.append(
53
+ f"GPU {_clock(self.gpu_seconds)} ≈ ${gpu_cost:.2f} (idle-stop 10m)",
54
+ style="bold yellow",
55
+ )
56
+ elif self.cost_usd:
57
+ line.append(" ")
58
+ line.append(f"${self.cost_usd:.4f}", style="dim")
59
+
60
+ if self.mode != "default":
61
+ line.append(" ")
62
+ line.append(
63
+ f"[{self.mode}]",
64
+ style="bold magenta" if self.mode == "plan" else "bold red",
65
+ )
66
+
67
+ if self.note:
68
+ line.append(" ")
69
+ line.append(self.note, style="italic dim")
70
+
71
+ return line
72
+
73
+
74
+ def _k(n: int) -> str:
75
+ if n >= 1_000_000:
76
+ return f"{n / 1_000_000:.1f}M"
77
+ if n >= 1_000:
78
+ return f"{n / 1_000:.1f}k"
79
+ return str(n)
80
+
81
+
82
+ def _clock(seconds: float) -> str:
83
+ total = int(seconds)
84
+ return f"{total // 3600:02d}:{total % 3600 // 60:02d}:{total % 60:02d}"
85
+
86
+
87
+ class BootPanel(Static):
88
+ """Shown while a self-hosted endpoint cold-boots.
89
+
90
+ Twenty-nine minutes of silence is indistinguishable from a hang, and the
91
+ natural reaction is to kill it — which throws away the boot and starts another
92
+ one. So the wait is narrated with elapsed time and an expectation.
93
+ """
94
+
95
+ def show(self, text: str, elapsed: float) -> None:
96
+ body = Text()
97
+ body.append("Waiting for the model endpoint\n\n", style="bold")
98
+ body.append(text + "\n", style="yellow")
99
+ body.append(f"\nelapsed {_clock(elapsed)}\n", style="dim")
100
+ body.append(
101
+ "\nThis endpoint is a 744B model on 4xH200 that scales to zero when idle.\n"
102
+ "A cold boot loads 388GB of weights and JIT-compiles attention kernels:\n"
103
+ "roughly 29 minutes cold, 13 with a warm compile cache. It is not stuck.\n",
104
+ style="dim",
105
+ )
106
+ self.update(body)
107
+ self.display = True
108
+
109
+ def hide(self) -> None:
110
+ self.display = False
@@ -0,0 +1,146 @@
1
+ """The conversation view.
2
+
3
+ The one performance decision that matters: **token deltas are buffered and
4
+ flushed on a timer, not applied on arrival.** A model streaming 60 tokens per
5
+ second would otherwise trigger 60 full repaints per second, and the UI ends up
6
+ stuttering worse than the model streams. Buffering at 50 ms makes streaming look
7
+ smooth and costs nothing perceptible.
8
+
9
+ Assistant text is a single Static that gets updated in place, which is what makes
10
+ text appear to grow rather than scrolling line by line.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from rich.markdown import Markdown
16
+ from rich.text import Text
17
+ from textual.containers import VerticalScroll
18
+ from textual.widgets import Static
19
+
20
+ FLUSH_INTERVAL = 0.05
21
+
22
+
23
+ class Transcript(VerticalScroll):
24
+ """Append-only conversation log with an in-place streaming block."""
25
+
26
+ def __init__(self, **kwargs):
27
+ super().__init__(**kwargs)
28
+ self._buffer: list[str] = []
29
+ self._live: Static | None = None
30
+ self._live_text = ""
31
+ self._thinking: Static | None = None
32
+ self._thinking_text = ""
33
+ self._flush_timer = None
34
+ self._tool_blocks: dict[str, Static] = {}
35
+
36
+ def on_mount(self) -> None:
37
+ self._flush_timer = self.set_interval(FLUSH_INTERVAL, self._flush, pause=False)
38
+
39
+ # --- user / system lines ----------------------------------------------
40
+
41
+ async def add_user(self, text: str) -> None:
42
+ await self._close_live()
43
+ await self.mount(Static(Text(f"› {text}", style="bold"), classes="msg user"))
44
+ self.scroll_end(animate=False)
45
+
46
+ async def add_note(self, text: str, classes: str = "note") -> None:
47
+ await self._close_live()
48
+ await self.mount(Static(Text(text, style="dim"), classes=f"msg {classes}"))
49
+ self.scroll_end(animate=False)
50
+
51
+ async def add_error(self, text: str) -> None:
52
+ await self._close_live()
53
+ await self.mount(Static(Text(text, style="bold red"), classes="msg error"))
54
+ self.scroll_end(animate=False)
55
+
56
+ # --- streaming --------------------------------------------------------
57
+
58
+ def append_delta(self, text: str) -> None:
59
+ """Called once per token. Must stay cheap — it only appends to a list."""
60
+ self._buffer.append(text)
61
+
62
+ def append_thinking(self, text: str) -> None:
63
+ self._thinking_text += text
64
+
65
+ def _flush(self) -> None:
66
+ if self._thinking_text and self._thinking is None:
67
+ self._thinking = Static(classes="msg thinking")
68
+ self.mount(self._thinking)
69
+ if self._thinking is not None and self._thinking_text:
70
+ self._thinking.update(Text(_collapse(self._thinking_text), style="italic dim"))
71
+
72
+ if not self._buffer:
73
+ return
74
+ chunk = "".join(self._buffer)
75
+ self._buffer.clear()
76
+ self._live_text += chunk
77
+
78
+ if self._live is None:
79
+ self._live = Static(classes="msg assistant")
80
+ self.mount(self._live)
81
+ # Plain Text while streaming: partial markdown (an unclosed code fence) is
82
+ # rendered wrongly and flickers as the fence completes.
83
+ self._live.update(Text(self._live_text))
84
+ self.scroll_end(animate=False)
85
+
86
+ async def _close_live(self) -> None:
87
+ self._flush()
88
+ if self._live is not None:
89
+ # Re-render as markdown now that the text is complete.
90
+ self._live.update(_render_final(self._live_text))
91
+ self._live = None
92
+ self._live_text = ""
93
+ if self._thinking is not None:
94
+ self._thinking.add_class("collapsed")
95
+ self._thinking = None
96
+ self._thinking_text = ""
97
+
98
+ async def end_message(self) -> None:
99
+ await self._close_live()
100
+
101
+ # --- tools ------------------------------------------------------------
102
+
103
+ async def add_tool_start(self, tool_use_id: str, summary: str,
104
+ subagent_id: str | None = None) -> None:
105
+ await self._close_live()
106
+ prefix = " └ " if subagent_id else ""
107
+ block = Static(Text(f"{prefix}⏵ {summary}", style="cyan"), classes="msg tool running")
108
+ self._tool_blocks[tool_use_id] = block
109
+ await self.mount(block)
110
+ self.scroll_end(animate=False)
111
+
112
+ def update_tool(self, tool_use_id: str, name: str, is_error: bool,
113
+ display: str | None, duration: float) -> None:
114
+ block = self._tool_blocks.pop(tool_use_id, None)
115
+ mark = "✗" if is_error else "✓"
116
+ style = "red" if is_error else "green"
117
+ detail = (display or "").strip().splitlines()
118
+ head = detail[0] if detail else name
119
+ text = Text(f"{mark} {head}", style=style)
120
+ if duration > 1:
121
+ text.append(f" ({duration:.1f}s)", style="dim")
122
+ if block is not None:
123
+ block.remove_class("running")
124
+ block.update(text)
125
+ else:
126
+ self.mount(Static(text, classes="msg tool"))
127
+ self.scroll_end(animate=False)
128
+
129
+ def tool_progress(self, tool_use_id: str, text: str) -> None:
130
+ block = self._tool_blocks.get(tool_use_id)
131
+ if block is None:
132
+ return
133
+ # Only the latest line is shown: a build's full output belongs in the
134
+ # transcript's tool result, not scrolling live through the UI.
135
+ block.update(Text(f"⏵ {text[:160]}", style="cyan dim"))
136
+
137
+
138
+ def _collapse(text: str, limit: int = 400) -> str:
139
+ flat = " ".join(text.split())
140
+ return flat if len(flat) <= limit else "…" + flat[-limit:]
141
+
142
+
143
+ def _render_final(text: str):
144
+ if any(marker in text for marker in ("```", "\n- ", "\n* ", "\n#", "**")):
145
+ return Markdown(text)
146
+ return Text(text)