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/write.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Write — full-file replacement, guarded by read-before-write."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
8
|
+
from turnloop.tools.textio import detect_newline, has_bom, read_text, write_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WriteArgs(BaseModel):
|
|
12
|
+
file_path: str = Field(description="Absolute or project-relative path to write.")
|
|
13
|
+
content: str = Field(description="The complete new contents of the file.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WriteTool(Tool):
|
|
17
|
+
name = "Write"
|
|
18
|
+
Args = WriteArgs
|
|
19
|
+
read_only = False
|
|
20
|
+
parallel_safe = False
|
|
21
|
+
|
|
22
|
+
descriptions = {
|
|
23
|
+
"terse": """
|
|
24
|
+
Overwrite a file with `content`. Read it first if it exists. Prefer Edit for
|
|
25
|
+
changes to existing files.
|
|
26
|
+
""",
|
|
27
|
+
"normal": """
|
|
28
|
+
Write a complete file, creating it or overwriting it.
|
|
29
|
+
|
|
30
|
+
- If the file exists you must Read it first, and it must not have changed on disk
|
|
31
|
+
since. This prevents silently destroying content you never saw.
|
|
32
|
+
- Prefer Edit for modifying an existing file. Write replaces everything, which
|
|
33
|
+
loses anything you did not reproduce exactly.
|
|
34
|
+
- Parent directories are created as needed.
|
|
35
|
+
- The file's existing newline style is preserved when overwriting.
|
|
36
|
+
""",
|
|
37
|
+
"verbose": """
|
|
38
|
+
Write a complete file, creating it if absent or overwriting it if present.
|
|
39
|
+
|
|
40
|
+
Arguments:
|
|
41
|
+
- `file_path`: absolute, or relative to the working directory.
|
|
42
|
+
- `content`: the entire new contents. Not a patch, not a fragment.
|
|
43
|
+
|
|
44
|
+
Constraints:
|
|
45
|
+
- Overwriting requires a prior Read of that file in this session, and the file
|
|
46
|
+
must be unchanged on disk since that Read. If it changed, Read it again first.
|
|
47
|
+
This is what stops a full-file write from discarding content you never saw.
|
|
48
|
+
- Prefer Edit when modifying an existing file. Write is appropriate for new
|
|
49
|
+
files, or when the change is a genuine rewrite.
|
|
50
|
+
- Parent directories are created automatically.
|
|
51
|
+
- Newline style (LF vs CRLF) is preserved from the existing file, so writing to a
|
|
52
|
+
CRLF repository does not produce a diff on every line.
|
|
53
|
+
- Do not use Write to create documentation, summaries, or notes unless the user
|
|
54
|
+
asked for a file. Answer in the conversation instead.
|
|
55
|
+
""",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async def run(self, args: WriteArgs, ctx: ToolContext) -> ToolOutput:
|
|
59
|
+
path = ctx.resolve(args.file_path)
|
|
60
|
+
|
|
61
|
+
if ctx.readonly:
|
|
62
|
+
return ToolOutput.error("plan mode is read-only; Write is not available")
|
|
63
|
+
if not ctx.within_allowed(path):
|
|
64
|
+
return ToolOutput.error(f"{path} is outside the project root ({ctx.root}).")
|
|
65
|
+
if path.is_dir():
|
|
66
|
+
return ToolOutput.error(f"{path} is a directory.")
|
|
67
|
+
|
|
68
|
+
existed = path.exists()
|
|
69
|
+
if existed:
|
|
70
|
+
if not ctx.files.has_read(path):
|
|
71
|
+
return ToolOutput.error(
|
|
72
|
+
f"{path} exists but has not been read in this session. "
|
|
73
|
+
"Read it first so the overwrite does not discard content."
|
|
74
|
+
)
|
|
75
|
+
if ctx.files.is_stale(path):
|
|
76
|
+
return ToolOutput.error(
|
|
77
|
+
f"{path} changed on disk since you read it. Read it again, then retry."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
newline = detect_newline(path) if existed else None
|
|
81
|
+
keep_bom = has_bom(path) if existed else False
|
|
82
|
+
previous_lines = len(read_text(path).splitlines()) if existed else 0
|
|
83
|
+
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
write_text(path, args.content, newline=newline, bom=keep_bom)
|
|
86
|
+
ctx.files.record_write(path, args.content)
|
|
87
|
+
|
|
88
|
+
new_lines = len(args.content.splitlines())
|
|
89
|
+
verb = "Updated" if existed else "Created"
|
|
90
|
+
return ToolOutput(
|
|
91
|
+
content=f"{verb} {path} ({new_lines} lines).",
|
|
92
|
+
display=f"{verb} {path.name} ({previous_lines} → {new_lines} lines)",
|
|
93
|
+
metrics={"created": not existed, "lines": new_lines, "bytes": len(args.content)},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def summary(self, args: WriteArgs) -> str: # type: ignore[override]
|
|
97
|
+
return f"Write {args.file_path}"
|
turnloop/tui/__init__.py
ADDED
|
File without changes
|
turnloop/tui/app.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""The Textual application.
|
|
2
|
+
|
|
3
|
+
Concurrency, stated once:
|
|
4
|
+
|
|
5
|
+
* `_agent_worker` owns the agent loop. It never touches a widget.
|
|
6
|
+
* `_pump_worker` drains the event stream and calls widget methods. It never awaits
|
|
7
|
+
the model.
|
|
8
|
+
* `_permission_worker` drains permission requests, pushes a modal, and sends the
|
|
9
|
+
answer back through the request's one-shot reply stream — which is what the
|
|
10
|
+
agent's task is parked on.
|
|
11
|
+
|
|
12
|
+
All three are Textual asyncio workers on the same event loop, so there are no
|
|
13
|
+
thread-safety questions, and cancelling the agent worker propagates straight into
|
|
14
|
+
the in-flight HTTP stream and any child process.
|
|
15
|
+
|
|
16
|
+
Typing during a turn is allowed. A message submitted mid-turn is queued and
|
|
17
|
+
delivered as a steering turn once the current one finishes, because the useful
|
|
18
|
+
moment to redirect an agent is exactly while it is going the wrong way.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import anyio
|
|
26
|
+
from textual import work
|
|
27
|
+
from textual.app import App, ComposeResult
|
|
28
|
+
from textual.binding import Binding
|
|
29
|
+
from textual.containers import Vertical
|
|
30
|
+
from textual.widgets import Footer, Input
|
|
31
|
+
from textual.worker import Worker
|
|
32
|
+
|
|
33
|
+
from turnloop.agent.factory import create_agent
|
|
34
|
+
from turnloop.config import Settings
|
|
35
|
+
from turnloop.core.events import (
|
|
36
|
+
CompactionHappened,
|
|
37
|
+
ProviderStatus,
|
|
38
|
+
StatusUpdate,
|
|
39
|
+
TextDelta,
|
|
40
|
+
ThinkingDelta,
|
|
41
|
+
ToolFinished,
|
|
42
|
+
ToolProgress,
|
|
43
|
+
ToolStarted,
|
|
44
|
+
TurnFinished,
|
|
45
|
+
UIEvent,
|
|
46
|
+
)
|
|
47
|
+
from turnloop.tui.bridge import PendingPermission, StreamChannel
|
|
48
|
+
from turnloop.tui.widgets.permission import ChoiceModal, PermissionModal
|
|
49
|
+
from turnloop.tui.widgets.status import BootPanel, StatusBar
|
|
50
|
+
from turnloop.tui.widgets.transcript import Transcript
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TurnloopApp(App):
|
|
54
|
+
CSS_PATH = "app.tcss"
|
|
55
|
+
TITLE = "turnloop"
|
|
56
|
+
|
|
57
|
+
BINDINGS = [
|
|
58
|
+
Binding("ctrl+c", "interrupt", "Interrupt", priority=True),
|
|
59
|
+
Binding("ctrl+d", "quit", "Quit", priority=True),
|
|
60
|
+
# priority=True is required: the focused Input claims these keys otherwise,
|
|
61
|
+
# and the prompt always has focus.
|
|
62
|
+
Binding("ctrl+p", "cycle_mode", "Permission mode", priority=True),
|
|
63
|
+
Binding("ctrl+l", "clear", "Clear view", priority=True),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
def __init__(self, settings: Settings, cwd: Path, resume: str | None = None):
|
|
67
|
+
super().__init__()
|
|
68
|
+
self.settings = settings
|
|
69
|
+
self.cwd = cwd
|
|
70
|
+
self.resume_id = resume
|
|
71
|
+
self.exit_code = 0
|
|
72
|
+
|
|
73
|
+
self.channel, self._events, self._permissions = StreamChannel.create()
|
|
74
|
+
self.agent = create_agent(settings, cwd, self.channel, resume=resume)
|
|
75
|
+
self._queued: list[str] = []
|
|
76
|
+
self._busy = False
|
|
77
|
+
self._agent_worker: Worker | None = None
|
|
78
|
+
|
|
79
|
+
# --- layout -----------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def compose(self) -> ComposeResult:
|
|
82
|
+
with Vertical():
|
|
83
|
+
yield Transcript(id="transcript")
|
|
84
|
+
yield BootPanel(id="boot")
|
|
85
|
+
yield StatusBar(id="status")
|
|
86
|
+
yield Input(placeholder="Ask, or /help", id="prompt")
|
|
87
|
+
yield Footer()
|
|
88
|
+
|
|
89
|
+
def on_mount(self) -> None:
|
|
90
|
+
self.query_one("#boot", BootPanel).display = False
|
|
91
|
+
status = self.query_one(StatusBar)
|
|
92
|
+
status.provider = self.agent.provider.name
|
|
93
|
+
status.model = self.agent.provider.model
|
|
94
|
+
status.mode = self.settings.permission_mode
|
|
95
|
+
status.cost_per_hour = self.agent.provider.caps.cost_per_hour
|
|
96
|
+
status.context_max = self.agent.provider.caps.max_context
|
|
97
|
+
|
|
98
|
+
self._pump_worker()
|
|
99
|
+
self._permission_worker()
|
|
100
|
+
self._startup_worker()
|
|
101
|
+
|
|
102
|
+
transcript = self.query_one(Transcript)
|
|
103
|
+
banner = (
|
|
104
|
+
f"turnloop · {self.agent.provider.name}:{self.agent.provider.model} · "
|
|
105
|
+
f"{self.cwd}"
|
|
106
|
+
)
|
|
107
|
+
self.call_later(transcript.add_note, banner)
|
|
108
|
+
if self.resume_id and self.agent.session.messages:
|
|
109
|
+
self.call_later(
|
|
110
|
+
transcript.add_note,
|
|
111
|
+
f"resumed {self.agent.session.session_id} "
|
|
112
|
+
f"({len(self.agent.session.messages)} messages)",
|
|
113
|
+
)
|
|
114
|
+
if self.agent.provider.caps.cost_per_hour:
|
|
115
|
+
self.call_later(
|
|
116
|
+
transcript.add_note,
|
|
117
|
+
f"this provider bills ${self.agent.provider.caps.cost_per_hour:.2f}/hour of "
|
|
118
|
+
"wall clock; the first request may cold-boot for ~29 minutes",
|
|
119
|
+
)
|
|
120
|
+
self.query_one(Input).focus()
|
|
121
|
+
|
|
122
|
+
# --- input ------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
125
|
+
text = event.value.strip()
|
|
126
|
+
event.input.value = ""
|
|
127
|
+
if not text:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if text.startswith("/"):
|
|
131
|
+
await self._handle_command(text)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
transcript = self.query_one(Transcript)
|
|
135
|
+
await transcript.add_user(text)
|
|
136
|
+
|
|
137
|
+
if self._busy:
|
|
138
|
+
# Steering: delivered after the current turn's tool batch completes.
|
|
139
|
+
self._queued.append(text)
|
|
140
|
+
await transcript.add_note("queued — will be sent when the current turn finishes")
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
self._start_turn(text)
|
|
144
|
+
|
|
145
|
+
def _start_turn(self, prompt: str) -> None:
|
|
146
|
+
self._busy = True
|
|
147
|
+
self._agent_worker = self.run_worker(
|
|
148
|
+
self._run_turn(prompt), exclusive=False, thread=False, name="agent"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def _run_turn(self, prompt: str) -> None:
|
|
152
|
+
transcript = self.query_one(Transcript)
|
|
153
|
+
try:
|
|
154
|
+
await self.agent.loop.run_turn(prompt)
|
|
155
|
+
except anyio.get_cancelled_exc_class():
|
|
156
|
+
await transcript.add_note("interrupted")
|
|
157
|
+
raise
|
|
158
|
+
except Exception as exc: # noqa: BLE001 - a failed turn must not kill the app
|
|
159
|
+
await transcript.add_error(f"error: {exc}")
|
|
160
|
+
finally:
|
|
161
|
+
self._busy = False
|
|
162
|
+
await transcript.end_message()
|
|
163
|
+
|
|
164
|
+
if self._queued:
|
|
165
|
+
self._start_turn(self._queued.pop(0))
|
|
166
|
+
|
|
167
|
+
# --- workers ----------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
@work(exclusive=False, thread=False, name="pump")
|
|
170
|
+
async def _pump_worker(self) -> None:
|
|
171
|
+
transcript = self.query_one(Transcript)
|
|
172
|
+
status = self.query_one(StatusBar)
|
|
173
|
+
boot = self.query_one("#boot", BootPanel)
|
|
174
|
+
|
|
175
|
+
async with self._events:
|
|
176
|
+
async for event in self._events:
|
|
177
|
+
await self._render(event, transcript, status, boot)
|
|
178
|
+
|
|
179
|
+
async def _render(self, event: UIEvent, transcript: Transcript, status: StatusBar,
|
|
180
|
+
boot: BootPanel) -> None:
|
|
181
|
+
if isinstance(event, TextDelta):
|
|
182
|
+
transcript.append_delta(event.text)
|
|
183
|
+
elif isinstance(event, ThinkingDelta):
|
|
184
|
+
transcript.append_thinking(event.text)
|
|
185
|
+
elif isinstance(event, ToolStarted):
|
|
186
|
+
await transcript.add_tool_start(event.tool_use_id, event.summary, event.subagent_id)
|
|
187
|
+
elif isinstance(event, ToolProgress):
|
|
188
|
+
transcript.tool_progress(event.tool_use_id, event.text)
|
|
189
|
+
elif isinstance(event, ToolFinished):
|
|
190
|
+
transcript.update_tool(
|
|
191
|
+
event.tool_use_id, event.name, event.is_error, event.display, event.duration_s
|
|
192
|
+
)
|
|
193
|
+
elif isinstance(event, ProviderStatus):
|
|
194
|
+
if event.phase == "cold_boot":
|
|
195
|
+
boot.show(event.text, event.elapsed_s)
|
|
196
|
+
else:
|
|
197
|
+
boot.hide()
|
|
198
|
+
if event.phase == "retrying":
|
|
199
|
+
await transcript.add_note(event.text)
|
|
200
|
+
elif isinstance(event, StatusUpdate):
|
|
201
|
+
boot.hide()
|
|
202
|
+
status.context_tokens = event.context_tokens
|
|
203
|
+
status.context_max = max(1, event.context_max)
|
|
204
|
+
status.cost_usd = event.cost_usd
|
|
205
|
+
status.tokens_in = event.usage.input_tokens
|
|
206
|
+
status.tokens_out = event.usage.output_tokens
|
|
207
|
+
if event.gpu_seconds is not None:
|
|
208
|
+
status.gpu_seconds = event.gpu_seconds
|
|
209
|
+
elif isinstance(event, CompactionHappened):
|
|
210
|
+
await transcript.add_note(
|
|
211
|
+
f"context compacted ({event.tier}): "
|
|
212
|
+
f"{event.tokens_before:,} → {event.tokens_after:,} tokens"
|
|
213
|
+
)
|
|
214
|
+
elif isinstance(event, TurnFinished):
|
|
215
|
+
await transcript.end_message()
|
|
216
|
+
|
|
217
|
+
@work(exclusive=False, thread=False, name="startup")
|
|
218
|
+
async def _startup_worker(self) -> None:
|
|
219
|
+
"""Connect MCP servers after the UI is up.
|
|
220
|
+
|
|
221
|
+
Doing this in __init__ would mean a slow or hanging MCP server delays the
|
|
222
|
+
first frame, which reads as a broken application.
|
|
223
|
+
"""
|
|
224
|
+
results = await self.agent.start()
|
|
225
|
+
if not results or self.agent.mcp is None:
|
|
226
|
+
return
|
|
227
|
+
transcript = self.query_one(Transcript)
|
|
228
|
+
for name, ok in sorted(results.items()):
|
|
229
|
+
client = self.agent.mcp.clients[name]
|
|
230
|
+
detail = f"{len(client.tools)} tool(s)" if ok else f"unavailable ({client.error})"
|
|
231
|
+
await transcript.add_note(f"mcp {name}: {detail}")
|
|
232
|
+
|
|
233
|
+
@work(exclusive=False, thread=False, name="permissions")
|
|
234
|
+
async def _permission_worker(self) -> None:
|
|
235
|
+
async with self._permissions:
|
|
236
|
+
async for pending in self._permissions:
|
|
237
|
+
await self._resolve_permission(pending)
|
|
238
|
+
|
|
239
|
+
async def _resolve_permission(self, pending: PendingPermission) -> None:
|
|
240
|
+
request = pending.request
|
|
241
|
+
modal = (
|
|
242
|
+
ChoiceModal(request)
|
|
243
|
+
if request.tool_name == "AskUserQuestion"
|
|
244
|
+
else PermissionModal(request)
|
|
245
|
+
)
|
|
246
|
+
decision = await self.push_screen_wait(modal)
|
|
247
|
+
if decision.approved and decision.rule and decision.scope.value == "project":
|
|
248
|
+
self._persist_rule(decision.rule)
|
|
249
|
+
await pending.answer(decision)
|
|
250
|
+
|
|
251
|
+
def _persist_rule(self, rule: str) -> None:
|
|
252
|
+
"""Append an allow rule to settings.local.json.
|
|
253
|
+
|
|
254
|
+
Local rather than shared: a permission the user granted on their machine is
|
|
255
|
+
not automatically one their teammates want committed.
|
|
256
|
+
"""
|
|
257
|
+
import json
|
|
258
|
+
|
|
259
|
+
path = self.settings.project_root / ".turnloop" / "settings.local.json"
|
|
260
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
261
|
+
try:
|
|
262
|
+
data = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
|
263
|
+
except json.JSONDecodeError:
|
|
264
|
+
data = {}
|
|
265
|
+
permissions = data.setdefault("permissions", {})
|
|
266
|
+
allow = permissions.setdefault("allow", [])
|
|
267
|
+
if rule not in allow:
|
|
268
|
+
allow.append(rule)
|
|
269
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
270
|
+
|
|
271
|
+
# --- commands ---------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
async def _handle_command(self, text: str) -> None:
|
|
274
|
+
from turnloop.commands.dispatch import CommandOutcome, dispatch_command
|
|
275
|
+
|
|
276
|
+
transcript = self.query_one(Transcript)
|
|
277
|
+
outcome: CommandOutcome = await dispatch_command(text, self.agent, self.settings, self.cwd)
|
|
278
|
+
|
|
279
|
+
if outcome.message:
|
|
280
|
+
await transcript.add_note(outcome.message)
|
|
281
|
+
if outcome.quit:
|
|
282
|
+
self.exit()
|
|
283
|
+
return
|
|
284
|
+
if outcome.clear:
|
|
285
|
+
await transcript.remove_children()
|
|
286
|
+
await transcript.add_note("view cleared")
|
|
287
|
+
if outcome.mode:
|
|
288
|
+
self.settings.permission_mode = outcome.mode
|
|
289
|
+
self.agent.permissions.mode = outcome.mode
|
|
290
|
+
self.query_one(StatusBar).mode = outcome.mode
|
|
291
|
+
if outcome.prompt:
|
|
292
|
+
await transcript.add_user(outcome.prompt if outcome.echo else text)
|
|
293
|
+
if self._busy:
|
|
294
|
+
self._queued.append(outcome.prompt)
|
|
295
|
+
else:
|
|
296
|
+
self._start_turn(outcome.prompt)
|
|
297
|
+
|
|
298
|
+
# --- actions ----------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
def action_interrupt(self) -> None:
|
|
301
|
+
if self._agent_worker is not None and self._busy:
|
|
302
|
+
self._agent_worker.cancel()
|
|
303
|
+
self._busy = False
|
|
304
|
+
else:
|
|
305
|
+
self.exit()
|
|
306
|
+
|
|
307
|
+
def action_cycle_mode(self) -> None:
|
|
308
|
+
order = ["default", "plan", "auto", "bypass"]
|
|
309
|
+
current = order.index(self.settings.permission_mode)
|
|
310
|
+
mode = order[(current + 1) % len(order)]
|
|
311
|
+
self.settings.permission_mode = mode # type: ignore[assignment]
|
|
312
|
+
self.agent.permissions.mode = mode # type: ignore[assignment]
|
|
313
|
+
self.query_one(StatusBar).mode = mode
|
|
314
|
+
|
|
315
|
+
async def action_clear(self) -> None:
|
|
316
|
+
await self.query_one(Transcript).remove_children()
|
|
317
|
+
|
|
318
|
+
# --- teardown ---------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
async def on_unmount(self) -> None:
|
|
321
|
+
await self.agent.aclose()
|
|
322
|
+
provider = self.agent.provider
|
|
323
|
+
if provider.caps.cost_per_hour and provider.first_request_at:
|
|
324
|
+
gpu = provider.gpu_seconds() or 0
|
|
325
|
+
print(
|
|
326
|
+
f"\nThe self-hosted endpoint is still running "
|
|
327
|
+
f"({gpu / 60:.1f} min ≈ ${gpu / 3600 * provider.caps.cost_per_hour:.2f} so far).\n"
|
|
328
|
+
f"It scales down after 10 idle minutes, or stop it now:\n"
|
|
329
|
+
f" modal app stop glm-5-2-serve"
|
|
330
|
+
)
|
turnloop/tui/app.tcss
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Screen {
|
|
2
|
+
layers: base modal;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
#transcript {
|
|
6
|
+
height: 1fr;
|
|
7
|
+
padding: 0 1;
|
|
8
|
+
scrollbar-size-vertical: 1;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.msg {
|
|
12
|
+
margin-bottom: 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.msg.user {
|
|
16
|
+
color: $text;
|
|
17
|
+
background: $boost;
|
|
18
|
+
padding: 0 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.msg.assistant {
|
|
22
|
+
color: $text;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.msg.thinking {
|
|
26
|
+
color: $text-muted;
|
|
27
|
+
padding-left: 2;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.msg.thinking.collapsed {
|
|
31
|
+
display: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.msg.tool {
|
|
35
|
+
padding-left: 1;
|
|
36
|
+
margin-bottom: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.msg.note {
|
|
40
|
+
color: $text-muted;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.msg.error {
|
|
44
|
+
color: $error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#boot {
|
|
48
|
+
display: none;
|
|
49
|
+
height: auto;
|
|
50
|
+
padding: 1 2;
|
|
51
|
+
border: round $warning;
|
|
52
|
+
margin: 1 2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#status {
|
|
56
|
+
height: 1;
|
|
57
|
+
background: $panel;
|
|
58
|
+
color: $text-muted;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#prompt {
|
|
62
|
+
border: none;
|
|
63
|
+
border-top: solid $primary;
|
|
64
|
+
height: 3;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#permission-box {
|
|
68
|
+
layer: modal;
|
|
69
|
+
width: 80%;
|
|
70
|
+
max-width: 100;
|
|
71
|
+
height: auto;
|
|
72
|
+
max-height: 80%;
|
|
73
|
+
padding: 1 2;
|
|
74
|
+
border: round $warning;
|
|
75
|
+
background: $surface;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#permission-body {
|
|
79
|
+
height: auto;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#permission-keys {
|
|
83
|
+
height: auto;
|
|
84
|
+
}
|
turnloop/tui/bridge.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""The boundary between the agent loop and whatever is displaying it.
|
|
2
|
+
|
|
3
|
+
One rule, and the entire concurrency design follows from it:
|
|
4
|
+
|
|
5
|
+
**The agent loop never touches a widget, and the UI never awaits the model.**
|
|
6
|
+
|
|
7
|
+
Communication is one memory object stream outbound (events) and one one-shot
|
|
8
|
+
stream per permission request inbound (the answer). The loop's task parks on
|
|
9
|
+
`ask()` while the UI keeps repainting, handling keystrokes and streaming the tool
|
|
10
|
+
output that is already on screen.
|
|
11
|
+
|
|
12
|
+
Three implementations share this interface: the Textual app, the headless printer,
|
|
13
|
+
and a null channel for tests and experiments.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
import anyio
|
|
22
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
23
|
+
|
|
24
|
+
from turnloop.core.events import UIEvent
|
|
25
|
+
from turnloop.permissions.engine import PermissionDecision, PermissionRequest, Scope
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class PendingPermission:
|
|
30
|
+
request: PermissionRequest
|
|
31
|
+
reply_send: MemoryObjectSendStream[PermissionDecision]
|
|
32
|
+
reply_recv: MemoryObjectReceiveStream[PermissionDecision]
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def create(cls, request: PermissionRequest) -> PendingPermission:
|
|
36
|
+
send, recv = anyio.create_memory_object_stream[PermissionDecision](1)
|
|
37
|
+
return cls(request=request, reply_send=send, reply_recv=recv)
|
|
38
|
+
|
|
39
|
+
async def answer(self, decision: PermissionDecision) -> None:
|
|
40
|
+
await self.reply_send.send(decision)
|
|
41
|
+
|
|
42
|
+
async def wait(self) -> PermissionDecision:
|
|
43
|
+
return await self.reply_recv.receive()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UIChannel(ABC):
|
|
47
|
+
"""What the agent loop is given instead of a display."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def send(self, event: UIEvent) -> None: ...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
async def ask(self, request: PermissionRequest) -> PermissionDecision: ...
|
|
54
|
+
|
|
55
|
+
def for_subagent(self, subagent_id: str) -> UIChannel:
|
|
56
|
+
"""A channel that tags everything with a subagent id.
|
|
57
|
+
|
|
58
|
+
Subagent output has to be visually separable — a fan-out of three children
|
|
59
|
+
interleaving into one transcript is unreadable — and its permission
|
|
60
|
+
prompts must still reach the one modal the parent owns.
|
|
61
|
+
"""
|
|
62
|
+
return TaggedChannel(self, subagent_id)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class TaggedChannel(UIChannel):
|
|
67
|
+
inner: UIChannel
|
|
68
|
+
subagent_id: str
|
|
69
|
+
|
|
70
|
+
async def send(self, event: UIEvent) -> None:
|
|
71
|
+
if hasattr(event, "subagent_id") and getattr(event, "subagent_id", None) is None:
|
|
72
|
+
try:
|
|
73
|
+
object.__setattr__(event, "subagent_id", self.subagent_id)
|
|
74
|
+
except AttributeError: # slots without the field
|
|
75
|
+
pass
|
|
76
|
+
await self.inner.send(event)
|
|
77
|
+
|
|
78
|
+
async def ask(self, request: PermissionRequest) -> PermissionDecision:
|
|
79
|
+
request.subagent_id = self.subagent_id
|
|
80
|
+
return await self.inner.ask(request)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class StreamChannel(UIChannel):
|
|
85
|
+
"""Pushes events into a memory stream. Used by the Textual app.
|
|
86
|
+
|
|
87
|
+
The send stream is buffered: a burst of token deltas must not block the agent
|
|
88
|
+
loop waiting for the UI to catch up, and if the buffer does fill, dropping
|
|
89
|
+
display events is better than stalling model consumption.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
events: MemoryObjectSendStream[UIEvent]
|
|
93
|
+
permissions: MemoryObjectSendStream[PendingPermission]
|
|
94
|
+
|
|
95
|
+
async def send(self, event: UIEvent) -> None:
|
|
96
|
+
try:
|
|
97
|
+
self.events.send_nowait(event)
|
|
98
|
+
except anyio.WouldBlock:
|
|
99
|
+
# Buffer full: wait, but only briefly. A permanently stuck UI should
|
|
100
|
+
# not hang the model stream.
|
|
101
|
+
with anyio.move_on_after(1.0):
|
|
102
|
+
await self.events.send(event)
|
|
103
|
+
except anyio.BrokenResourceError:
|
|
104
|
+
pass # UI is gone; the loop is being torn down
|
|
105
|
+
|
|
106
|
+
async def ask(self, request: PermissionRequest) -> PermissionDecision:
|
|
107
|
+
pending = PendingPermission.create(request)
|
|
108
|
+
await self.permissions.send(pending)
|
|
109
|
+
return await pending.wait()
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def create(buffer: int = 4096):
|
|
113
|
+
ev_send, ev_recv = anyio.create_memory_object_stream[UIEvent](buffer)
|
|
114
|
+
pm_send, pm_recv = anyio.create_memory_object_stream[PendingPermission](8)
|
|
115
|
+
return StreamChannel(events=ev_send, permissions=pm_send), ev_recv, pm_recv
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class NullChannel(UIChannel):
|
|
120
|
+
"""Discards output; auto-answers permission prompts.
|
|
121
|
+
|
|
122
|
+
Used by experiments and tests. `auto_approve=False` is the interesting
|
|
123
|
+
setting: it exercises the denial path, which is how the permission-safety
|
|
124
|
+
grader checks that plan mode and deny rules actually hold.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
auto_approve: bool = True
|
|
128
|
+
seen: list[UIEvent] = field(default_factory=list)
|
|
129
|
+
asked: list[PermissionRequest] = field(default_factory=list)
|
|
130
|
+
record: bool = False
|
|
131
|
+
|
|
132
|
+
async def send(self, event: UIEvent) -> None:
|
|
133
|
+
if self.record:
|
|
134
|
+
self.seen.append(event)
|
|
135
|
+
|
|
136
|
+
async def ask(self, request: PermissionRequest) -> PermissionDecision:
|
|
137
|
+
self.asked.append(request)
|
|
138
|
+
return PermissionDecision(
|
|
139
|
+
approved=self.auto_approve,
|
|
140
|
+
scope=Scope.ONCE,
|
|
141
|
+
reason="" if self.auto_approve else "non-interactive session",
|
|
142
|
+
)
|
|
File without changes
|