quality-loop 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.
- quality_loop/OPERATOR.md +103 -0
- quality_loop/__init__.py +15 -0
- quality_loop/__main__.py +48 -0
- quality_loop/adapter.py +88 -0
- quality_loop/audit.py +144 -0
- quality_loop/mcp.py +161 -0
- quality_loop/operator.py +186 -0
- quality_loop/pages.py +84 -0
- quality_loop/report.py +170 -0
- quality_loop/room.py +105 -0
- quality_loop/runner.py +160 -0
- quality_loop/scenario.py +160 -0
- quality_loop/screens/__init__.py +192 -0
- quality_loop/screens/_mac.py +152 -0
- quality_loop/screens/_windows.py +110 -0
- quality_loop/session.py +301 -0
- quality_loop/standin.py +193 -0
- quality_loop/transcript.py +130 -0
- quality_loop-0.1.0.dist-info/METADATA +74 -0
- quality_loop-0.1.0.dist-info/RECORD +23 -0
- quality_loop-0.1.0.dist-info/WHEEL +5 -0
- quality_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
- quality_loop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Screens on Windows: Edge for pages, Windows Terminal for scripts.
|
|
2
|
+
|
|
3
|
+
A window is recognised the same way as on macOS, by its title: the top-level
|
|
4
|
+
windows that appear after the launch are enumerated, and the one whose title
|
|
5
|
+
contains the declared one is the screen. Its handle is recorded, and closing
|
|
6
|
+
posts WM_CLOSE to that handle and nothing else.
|
|
7
|
+
|
|
8
|
+
Every ctypes prototype is declared explicitly: on 64-bit Windows the default
|
|
9
|
+
c_int restype truncates handles.
|
|
10
|
+
|
|
11
|
+
Not yet confirmed on a Windows machine: that Windows Terminal keeps the title
|
|
12
|
+
with --suppressApplicationTitle, that Edge's window title contains the page
|
|
13
|
+
title, and that --new-window gives a window rather than a tab.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from quality_loop.screens import ScreenError
|
|
22
|
+
|
|
23
|
+
WM_CLOSE = 0x0010
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _user32():
|
|
27
|
+
import ctypes
|
|
28
|
+
from ctypes import wintypes
|
|
29
|
+
|
|
30
|
+
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
|
31
|
+
enum_proc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
32
|
+
user32.EnumWindows.argtypes = [enum_proc, wintypes.LPARAM]
|
|
33
|
+
user32.EnumWindows.restype = wintypes.BOOL
|
|
34
|
+
user32.GetWindowTextLengthW.argtypes = [wintypes.HWND]
|
|
35
|
+
user32.GetWindowTextLengthW.restype = ctypes.c_int
|
|
36
|
+
user32.GetWindowTextW.argtypes = [wintypes.HWND, wintypes.LPWSTR, ctypes.c_int]
|
|
37
|
+
user32.GetWindowTextW.restype = ctypes.c_int
|
|
38
|
+
user32.IsWindowVisible.argtypes = [wintypes.HWND]
|
|
39
|
+
user32.IsWindowVisible.restype = wintypes.BOOL
|
|
40
|
+
user32.IsWindow.argtypes = [wintypes.HWND]
|
|
41
|
+
user32.IsWindow.restype = wintypes.BOOL
|
|
42
|
+
user32.PostMessageW.argtypes = [wintypes.HWND, wintypes.UINT,
|
|
43
|
+
wintypes.WPARAM, wintypes.LPARAM]
|
|
44
|
+
user32.PostMessageW.restype = wintypes.BOOL
|
|
45
|
+
return ctypes, user32, enum_proc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def windows() -> dict[int, str]:
|
|
49
|
+
"""``{handle: title}`` of the visible top-level windows."""
|
|
50
|
+
ctypes, user32, enum_proc = _user32()
|
|
51
|
+
found: dict[int, str] = {}
|
|
52
|
+
|
|
53
|
+
def each(hwnd, _lparam):
|
|
54
|
+
if user32.IsWindowVisible(hwnd):
|
|
55
|
+
length = user32.GetWindowTextLengthW(hwnd)
|
|
56
|
+
buffer = ctypes.create_unicode_buffer(length + 1)
|
|
57
|
+
user32.GetWindowTextW(hwnd, buffer, length + 1)
|
|
58
|
+
found[int(hwnd)] = buffer.value
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
user32.EnumWindows(enum_proc(each), 0)
|
|
62
|
+
return found
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def page_command(url: str) -> list[str]:
|
|
66
|
+
return ["cmd", "/c", "start", "", "msedge", "--new-window", url]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
#: The shell a terminal screen runs in: PowerShell without the operator's
|
|
70
|
+
#: profile, and kept open once the script ends. Given the script directly,
|
|
71
|
+
#: Windows Terminal closes the tab when it exits, and the room has nothing to read.
|
|
72
|
+
SHELL = ["powershell.exe", "-NoLogo", "-NoProfile", "-NoExit", "-Command"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _powershell_quote(value: str) -> str:
|
|
76
|
+
return "'" + value.replace("'", "''") + "'"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def terminal_command(command: list[str], title: str) -> list[str]:
|
|
80
|
+
run = "& " + " ".join(_powershell_quote(part) for part in command)
|
|
81
|
+
return ["wt.exe", "-w", "new", "--title", title, "--suppressApplicationTitle",
|
|
82
|
+
*SHELL, run]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _launch_and_find(launch: list[str], title: str, patience: float,
|
|
86
|
+
listing=windows, run=subprocess.Popen) -> int:
|
|
87
|
+
before = set(listing())
|
|
88
|
+
run(launch, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
89
|
+
stderr=subprocess.DEVNULL)
|
|
90
|
+
deadline = time.monotonic() + patience
|
|
91
|
+
while time.monotonic() < deadline:
|
|
92
|
+
for handle, shown in listing().items():
|
|
93
|
+
if handle not in before and title in shown:
|
|
94
|
+
return handle
|
|
95
|
+
time.sleep(0.5)
|
|
96
|
+
raise ScreenError(f"no new window titled {title!r} appeared within {patience:.0f}s")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def open_page(url: str, title: str, patience: float) -> int:
|
|
100
|
+
return _launch_and_find(page_command(url), title, patience)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def open_terminal(command: list[str], title: str, patience: float) -> int:
|
|
104
|
+
return _launch_and_find(terminal_command(command, title), title, patience)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def close(kind: str, handle: int) -> None:
|
|
108
|
+
_ctypes, user32, _proc = _user32()
|
|
109
|
+
if user32.IsWindow(handle):
|
|
110
|
+
user32.PostMessageW(handle, WM_CLOSE, 0, 0)
|
quality_loop/session.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Starting a room's session, and the flags that make it a clean one.
|
|
2
|
+
|
|
3
|
+
The room is driven non-interactively on purpose. "Answer nothing it asks" is a
|
|
4
|
+
discipline, and a discipline fails at the first plausible-sounding question; a
|
|
5
|
+
`-p` session has nobody to ask, so the only questions it can raise are the
|
|
6
|
+
ones it writes into QUESTIONS.md - which is what the exercise produces.
|
|
7
|
+
|
|
8
|
+
Every flag here was paid for by a run:
|
|
9
|
+
|
|
10
|
+
- `--restricted`: a session in a room inside the checkout, asked what project
|
|
11
|
+
it is in and allowed to read nothing, names the product without it and
|
|
12
|
+
answers UNKNOWN with it. It also hides the checkout's own skills, the
|
|
13
|
+
scoring key among them, and ignores the settings files.
|
|
14
|
+
- `--strict-mcp-config` with exactly the servers given: whatever else the
|
|
15
|
+
operator has connected is not part of the product's interface.
|
|
16
|
+
- `--settings` with the permission list, under `--permission-mode dontAsk`:
|
|
17
|
+
a `-p` session with no grant is refused every tool call, and a first run
|
|
18
|
+
reported, accurately and uselessly, that it could not write a file.
|
|
19
|
+
- `--no-session-persistence`: the transcript is kept in the room.
|
|
20
|
+
- `GIT_CEILING_DIRECTORIES` at the room's parent, so `git log` from the room
|
|
21
|
+
answers "not a git repository" rather than reading the checkout's history,
|
|
22
|
+
whose commit titles say what every change to the product was for.
|
|
23
|
+
- stdin closed: a `-p` session with an open one waits three seconds for
|
|
24
|
+
piped input and writes a warning to the top of the transcript.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import dataclasses
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import pathlib
|
|
33
|
+
import shlex
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import tempfile
|
|
37
|
+
import threading
|
|
38
|
+
import time
|
|
39
|
+
from typing import Callable
|
|
40
|
+
|
|
41
|
+
#: The sentence a room is opened with, and the whole of what it is told.
|
|
42
|
+
PROMPT = "Read RULES.md, then do TASK.md."
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def find_claude() -> str:
|
|
46
|
+
"""The `claude` binary, which is routinely not on PATH.
|
|
47
|
+
|
|
48
|
+
It often lives inside the VS Code extension only, so a script that just
|
|
49
|
+
calls "claude" fails at the last step of a run that has already built a
|
|
50
|
+
room. ``CLAUDE_BIN`` overrides the search.
|
|
51
|
+
"""
|
|
52
|
+
if override := os.environ.get("CLAUDE_BIN"):
|
|
53
|
+
return override
|
|
54
|
+
if found := shutil.which("claude"):
|
|
55
|
+
return found
|
|
56
|
+
binary = "claude.exe" if os.name == "nt" else "claude"
|
|
57
|
+
candidates = list(pathlib.Path.home().glob(
|
|
58
|
+
f".vscode/extensions/anthropic.claude-code-*/resources/native-binary/{binary}"))
|
|
59
|
+
local = pathlib.Path.home() / ".claude" / "local" / binary
|
|
60
|
+
if local.exists():
|
|
61
|
+
candidates.append(local)
|
|
62
|
+
if not candidates:
|
|
63
|
+
raise FileNotFoundError(
|
|
64
|
+
"no `claude` binary - put it on PATH or set CLAUDE_BIN=/path/to/claude")
|
|
65
|
+
# Newest install wins; version strings sort wrong (2.1.99 > 2.1.251).
|
|
66
|
+
return str(max(candidates, key=lambda p: p.stat().st_mtime))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclasses.dataclass
|
|
70
|
+
class Session:
|
|
71
|
+
"""How a product's rooms are started.
|
|
72
|
+
|
|
73
|
+
Attributes:
|
|
74
|
+
allow: The permission list - the room's job description. What is
|
|
75
|
+
listed runs; everything else is refused.
|
|
76
|
+
tools: The built-in tools the session has at all (``--tools``), or
|
|
77
|
+
None for `--restricted`'s default, which has no Bash. A product
|
|
78
|
+
whose surface is a CLI grants a narrowed Bash here and in
|
|
79
|
+
``allow``.
|
|
80
|
+
add_dirs: Directories outside the room the file tools may reach.
|
|
81
|
+
mcp_servers: ``{name: server config}`` - exactly these, nothing else.
|
|
82
|
+
env: Extra environment for the session (a PATH shim, a shell).
|
|
83
|
+
max_turns: Turn limit for a non-interactive run.
|
|
84
|
+
model: Model override, or None for the default.
|
|
85
|
+
"""
|
|
86
|
+
allow: list[str]
|
|
87
|
+
tools: list[str] | None = None
|
|
88
|
+
add_dirs: list[pathlib.Path] = dataclasses.field(default_factory=list)
|
|
89
|
+
mcp_servers: dict = dataclasses.field(default_factory=dict)
|
|
90
|
+
env: dict = dataclasses.field(default_factory=dict)
|
|
91
|
+
max_turns: int = 150
|
|
92
|
+
model: str | None = None
|
|
93
|
+
claude: str | None = None
|
|
94
|
+
|
|
95
|
+
def command(self, interactive: bool = False) -> list[str]:
|
|
96
|
+
"""The command line, without ``-p`` when a person will drive it."""
|
|
97
|
+
command = [self.claude or find_claude(), "--restricted"]
|
|
98
|
+
if self.tools is not None:
|
|
99
|
+
command += ["--tools", ",".join(self.tools)]
|
|
100
|
+
for directory in self.add_dirs:
|
|
101
|
+
command += ["--add-dir", str(directory)]
|
|
102
|
+
command += [
|
|
103
|
+
"--mcp-config", json.dumps({"mcpServers": self.mcp_servers}),
|
|
104
|
+
"--strict-mcp-config",
|
|
105
|
+
# --restricted ignores the settings files, so the permissions have
|
|
106
|
+
# to be handed over directly.
|
|
107
|
+
"--settings", json.dumps({"permissions": {"allow": self.allow}}),
|
|
108
|
+
"--permission-mode", "dontAsk",
|
|
109
|
+
]
|
|
110
|
+
if interactive:
|
|
111
|
+
return command
|
|
112
|
+
command += ["-p", PROMPT, "--no-session-persistence",
|
|
113
|
+
"--max-turns", str(self.max_turns),
|
|
114
|
+
"--output-format", "stream-json", "--verbose"]
|
|
115
|
+
if self.model:
|
|
116
|
+
command += ["--model", self.model]
|
|
117
|
+
return command
|
|
118
|
+
|
|
119
|
+
def environment(self, room: pathlib.Path) -> dict:
|
|
120
|
+
"""The operator's environment, with git stopped at the room."""
|
|
121
|
+
return dict(os.environ, **self.env,
|
|
122
|
+
GIT_CEILING_DIRECTORIES=str(pathlib.Path(room).resolve().parent))
|
|
123
|
+
|
|
124
|
+
def printable(self, room: pathlib.Path, shell: str | None = None) -> str:
|
|
125
|
+
"""The same session, for a person to paste - flags and all.
|
|
126
|
+
|
|
127
|
+
A bare `cd <room> && claude` is how a room in the checkout gets opened
|
|
128
|
+
without `--restricted`, which is the failure the whole arrangement
|
|
129
|
+
trades on not happening. ``shell`` is "posix" or "powershell"; the
|
|
130
|
+
default is this OS's.
|
|
131
|
+
"""
|
|
132
|
+
shell = shell or ("powershell" if os.name == "nt" else "posix")
|
|
133
|
+
variables = {**self.env,
|
|
134
|
+
"GIT_CEILING_DIRECTORIES": str(pathlib.Path(room).parent)}
|
|
135
|
+
command = self.command(interactive=True)
|
|
136
|
+
if shell == "powershell":
|
|
137
|
+
quote = _powershell_quote
|
|
138
|
+
lines = [f"Set-Location {quote(str(room))}"]
|
|
139
|
+
lines += [f"$env:{name} = {quote(str(value))}"
|
|
140
|
+
for name, value in variables.items()]
|
|
141
|
+
lines.append("& " + " `\n ".join(_paired(command, quote)))
|
|
142
|
+
return "\n".join(lines)
|
|
143
|
+
quote = shlex.quote
|
|
144
|
+
assignments = [f"{name}={quote(str(value))}" for name, value in variables.items()]
|
|
145
|
+
return (f"cd {quote(str(room))} && \\\n "
|
|
146
|
+
+ " \\\n ".join(assignments + _paired(command, quote)))
|
|
147
|
+
|
|
148
|
+
def run(self, room: pathlib.Path, on_event: Callable[[dict], None] | None = None,
|
|
149
|
+
alive: Callable[[], bool] | None = None, heartbeat: float = 20.0,
|
|
150
|
+
say: Callable[[str], None] = print) -> "Run":
|
|
151
|
+
"""Drive the room to completion, keeping the transcript in it.
|
|
152
|
+
|
|
153
|
+
The transcript is written outside the room and moved in at the end:
|
|
154
|
+
left in the room it is a file the room can read, and one room did -
|
|
155
|
+
grepping its own log to reconstruct the task its prompt referred to.
|
|
156
|
+
|
|
157
|
+
``alive`` is asked on the heartbeat whether the product is still
|
|
158
|
+
answering. Two misses in a row end the run: a room that cannot reach
|
|
159
|
+
the product measures the outage, and one went on for twenty-three
|
|
160
|
+
minutes after its product had died.
|
|
161
|
+
"""
|
|
162
|
+
room = pathlib.Path(room)
|
|
163
|
+
transcript = (pathlib.Path(tempfile.gettempdir())
|
|
164
|
+
/ f"quality-loop-{room.parent.name}-{room.name}.jsonl")
|
|
165
|
+
result = Run(room / "transcript.jsonl")
|
|
166
|
+
clock = _Clock()
|
|
167
|
+
try:
|
|
168
|
+
with subprocess.Popen(self.command(), cwd=room, stdin=subprocess.DEVNULL,
|
|
169
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
170
|
+
text=True, encoding="utf-8", bufsize=1,
|
|
171
|
+
env=self.environment(room)) as session, \
|
|
172
|
+
transcript.open("w", encoding="utf-8") as log:
|
|
173
|
+
beat = threading.Thread(target=_heartbeat, daemon=True,
|
|
174
|
+
args=(session, clock, alive, heartbeat, say, result))
|
|
175
|
+
beat.start()
|
|
176
|
+
try:
|
|
177
|
+
for line in session.stdout:
|
|
178
|
+
log.write(line)
|
|
179
|
+
clock.touch()
|
|
180
|
+
try:
|
|
181
|
+
event = json.loads(line)
|
|
182
|
+
except ValueError:
|
|
183
|
+
continue
|
|
184
|
+
if isinstance(event, dict):
|
|
185
|
+
if on_event:
|
|
186
|
+
on_event(event)
|
|
187
|
+
if text := digest(event, room, clock):
|
|
188
|
+
say(text)
|
|
189
|
+
except BaseException:
|
|
190
|
+
# Ctrl-C or SIGTERM: the session goes with the run, so
|
|
191
|
+
# nothing is still writing into what is being collected.
|
|
192
|
+
session.terminate()
|
|
193
|
+
raise
|
|
194
|
+
result.exit_code = session.wait()
|
|
195
|
+
finally:
|
|
196
|
+
if transcript.exists():
|
|
197
|
+
shutil.move(transcript, result.transcript)
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclasses.dataclass
|
|
202
|
+
class Run:
|
|
203
|
+
"""What driving a room came to."""
|
|
204
|
+
transcript: pathlib.Path
|
|
205
|
+
exit_code: int | None = None
|
|
206
|
+
product_lost: bool = False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _paired(command: list[str], quote) -> list[str]:
|
|
210
|
+
"""Each flag on one line with its value."""
|
|
211
|
+
parts = [quote(part) for part in command]
|
|
212
|
+
lines, index = [], 0
|
|
213
|
+
while index < len(parts):
|
|
214
|
+
flag, value = command[index], parts[index + 1:index + 2]
|
|
215
|
+
if flag.startswith("--") and value and not command[index + 1].startswith("--"):
|
|
216
|
+
lines.append(f"{parts[index]} {value[0]}")
|
|
217
|
+
index += 2
|
|
218
|
+
else:
|
|
219
|
+
lines.append(parts[index])
|
|
220
|
+
index += 1
|
|
221
|
+
return lines
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _powershell_quote(value: str) -> str:
|
|
225
|
+
return "'" + value.replace("'", "''") + "'"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _Clock:
|
|
229
|
+
def __init__(self):
|
|
230
|
+
self.started = self.last = time.monotonic()
|
|
231
|
+
|
|
232
|
+
def touch(self):
|
|
233
|
+
self.last = time.monotonic()
|
|
234
|
+
|
|
235
|
+
def __str__(self):
|
|
236
|
+
seconds = int(time.monotonic() - self.started)
|
|
237
|
+
return f"{seconds // 60:>2}:{seconds % 60:02d}"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _heartbeat(session, clock, alive, every, say, result) -> None:
|
|
241
|
+
"""Say the run is alive while it thinks, and end it when the product is not.
|
|
242
|
+
|
|
243
|
+
A model between tool calls writes nothing, so a long turn and a hung
|
|
244
|
+
process look identical; a line every quiet ``every`` seconds tells them
|
|
245
|
+
apart.
|
|
246
|
+
"""
|
|
247
|
+
missed = 0
|
|
248
|
+
while session.poll() is None:
|
|
249
|
+
time.sleep(every / 4)
|
|
250
|
+
quiet = time.monotonic() - clock.last
|
|
251
|
+
if quiet < every:
|
|
252
|
+
continue
|
|
253
|
+
say(f" [{clock}] … thinking ({int(quiet)}s since the last step)")
|
|
254
|
+
clock.touch()
|
|
255
|
+
if alive is None:
|
|
256
|
+
continue
|
|
257
|
+
missed = 0 if alive() else missed + 1
|
|
258
|
+
if missed >= 2:
|
|
259
|
+
say(f" [{clock}] !! the product stopped answering - ending the run")
|
|
260
|
+
result.product_lost = True
|
|
261
|
+
session.terminate()
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _hint(given: dict, room: pathlib.Path) -> str:
|
|
266
|
+
"""The part of a tool call worth one line: a path's tail, or a pattern."""
|
|
267
|
+
for key in ("file_path", "path", "notebook_path"):
|
|
268
|
+
if value := given.get(key):
|
|
269
|
+
shown = str(value)
|
|
270
|
+
return shown[len(str(room)) + 1:] if shown.startswith(str(room)) else shown
|
|
271
|
+
for key in ("pattern", "command", "app", "action", "title", "role", "text", "name"):
|
|
272
|
+
if value := given.get(key):
|
|
273
|
+
return str(value)[:70]
|
|
274
|
+
return ""
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def digest(event: dict, room: pathlib.Path, clock="") -> str | None:
|
|
278
|
+
"""One line per thing the room did, so a long run is watchable."""
|
|
279
|
+
from quality_loop.transcript import parts
|
|
280
|
+
|
|
281
|
+
lines = []
|
|
282
|
+
kind = event.get("type")
|
|
283
|
+
if kind == "assistant":
|
|
284
|
+
for part in parts(event):
|
|
285
|
+
if part.get("type") == "tool_use":
|
|
286
|
+
given = part.get("input") if isinstance(part.get("input"), dict) else {}
|
|
287
|
+
lines.append(f" [{clock}] · {part.get('name')} {_hint(given, room)}".rstrip())
|
|
288
|
+
elif part.get("type") == "text" and str(part.get("text", "")).strip():
|
|
289
|
+
lines.append(f" [{clock}] {str(part['text']).strip().splitlines()[0][:110]}")
|
|
290
|
+
elif kind == "user":
|
|
291
|
+
# A refusal is the one result worth a line: it is what a wrong
|
|
292
|
+
# permission list or a reach outside the room looks like from here.
|
|
293
|
+
for part in parts(event):
|
|
294
|
+
if part.get("type") == "tool_result" and part.get("is_error"):
|
|
295
|
+
said = str(part.get("content", "")).replace("\n", " ")
|
|
296
|
+
lines.append(f" [{clock}] ! refused - {said[:120]}")
|
|
297
|
+
elif kind == "result":
|
|
298
|
+
lines.append(f"\n turns: {event.get('num_turns', '?')}, "
|
|
299
|
+
f"{(event.get('duration_ms') or 0) / 1000:.0f}s, "
|
|
300
|
+
f"${event.get('total_cost_usd') or 0:.2f}")
|
|
301
|
+
return "\n".join(lines) or None
|
quality_loop/standin.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Standing fixtures in for the operator's own files, and putting them back.
|
|
2
|
+
|
|
3
|
+
A task that edits the product's configuration has to edit a known file, and
|
|
4
|
+
must not edit the operator's. So for the length of a room their files are
|
|
5
|
+
moved into a marker directory, fixtures take their place, and everything is
|
|
6
|
+
put back on the way out - whatever ends the run. An exception, Ctrl-C and
|
|
7
|
+
SIGTERM (turned into KeyboardInterrupt by the runner) all unwind through the
|
|
8
|
+
same ``finally``. A marker left by a run that could not unwind refuses the
|
|
9
|
+
next one until `restore` has put it back.
|
|
10
|
+
|
|
11
|
+
Everything new in the directories the room can write to moves into the room's
|
|
12
|
+
evidence on the way out, not only the files it was asked for: a state file, a
|
|
13
|
+
helper package, a backup. Left behind, the next room finds them, and a product
|
|
14
|
+
that loads every module in its extensions directory shows them to the user.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import dataclasses
|
|
20
|
+
import os
|
|
21
|
+
import pathlib
|
|
22
|
+
import shutil
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StandInError(Exception):
|
|
27
|
+
"""The run cannot start without risking the operator's files."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclasses.dataclass
|
|
31
|
+
class Watch:
|
|
32
|
+
"""A directory the room can write to.
|
|
33
|
+
|
|
34
|
+
Attributes:
|
|
35
|
+
directory: The directory.
|
|
36
|
+
label: Where its new entries go under the evidence directory.
|
|
37
|
+
move: Move new entries into evidence (True), or copy them and leave
|
|
38
|
+
them (False - for the operator who asked to keep what a run made).
|
|
39
|
+
skip: Name prefixes that are never collected: the product's own state
|
|
40
|
+
beside the files, which moving would break (an endpoint file, a
|
|
41
|
+
settings file).
|
|
42
|
+
"""
|
|
43
|
+
directory: pathlib.Path
|
|
44
|
+
label: str
|
|
45
|
+
move: bool = True
|
|
46
|
+
skip: tuple[str, ...] = ()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _entries(directory: pathlib.Path) -> set[str]:
|
|
50
|
+
return {p.name for p in directory.iterdir()} if directory.is_dir() else set()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclasses.dataclass
|
|
54
|
+
class StandIn:
|
|
55
|
+
"""The operator's machine made into the room's starting state, and given back.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
home: The product's data directory, where the stood-in files live.
|
|
59
|
+
marker: Where the operator's originals wait. Its existence at start
|
|
60
|
+
means a run ended without putting them back.
|
|
61
|
+
fixtures: ``{file name in home: the fixture's text}``.
|
|
62
|
+
evidence: The room's evidence directory.
|
|
63
|
+
backups: A glob, in ``home``, of backups the product keeps of those
|
|
64
|
+
files and prunes. Copies wait in the marker, and any the room's
|
|
65
|
+
writes pruned come back.
|
|
66
|
+
watch: Directories whose new entries are collected.
|
|
67
|
+
staged: ``(source, destination)`` input files a task works from. A
|
|
68
|
+
destination that already exists refuses the run rather than being
|
|
69
|
+
overwritten.
|
|
70
|
+
on_change: Called after the fixtures go in and after the originals
|
|
71
|
+
come back - a reload, so the running product follows the file.
|
|
72
|
+
What it returns is reported if it says something went wrong.
|
|
73
|
+
"""
|
|
74
|
+
home: pathlib.Path
|
|
75
|
+
marker: pathlib.Path
|
|
76
|
+
fixtures: dict[str, str]
|
|
77
|
+
evidence: pathlib.Path
|
|
78
|
+
backups: str | None = None
|
|
79
|
+
watch: list[Watch] = dataclasses.field(default_factory=list)
|
|
80
|
+
staged: list[tuple[pathlib.Path, pathlib.Path]] = dataclasses.field(default_factory=list)
|
|
81
|
+
on_change: Callable[[], object] | None = None
|
|
82
|
+
|
|
83
|
+
def __enter__(self) -> "StandIn":
|
|
84
|
+
if self.marker.exists():
|
|
85
|
+
raise StandInError(
|
|
86
|
+
f"{self.marker} exists: a run ended without putting the operator's "
|
|
87
|
+
f"files back. Check that what is in it is theirs, then restore it.")
|
|
88
|
+
for _source, destination in self.staged:
|
|
89
|
+
if destination.exists():
|
|
90
|
+
raise StandInError(
|
|
91
|
+
f"{destination} already exists, and this run puts its own file "
|
|
92
|
+
f"there. Move it out of the way first.")
|
|
93
|
+
self._before = {str(w.directory): _entries(w.directory) for w in self.watch}
|
|
94
|
+
self.marker.mkdir(parents=True)
|
|
95
|
+
self._had = {}
|
|
96
|
+
for name, text in self.fixtures.items():
|
|
97
|
+
own = self.home / name
|
|
98
|
+
self._had[name] = own.exists()
|
|
99
|
+
if own.exists():
|
|
100
|
+
os.replace(own, self.marker / name)
|
|
101
|
+
# LF on every OS: a fixture compared line by line with what the
|
|
102
|
+
# room left must not differ in its line ends alone.
|
|
103
|
+
own.write_text(text, encoding="utf-8", newline="\n")
|
|
104
|
+
if self.backups:
|
|
105
|
+
for backup in self.home.glob(self.backups):
|
|
106
|
+
shutil.copy2(backup, self.marker / backup.name)
|
|
107
|
+
for source, destination in self.staged:
|
|
108
|
+
shutil.copy2(source, destination)
|
|
109
|
+
if self.on_change:
|
|
110
|
+
self.on_change()
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *exc) -> bool:
|
|
114
|
+
try:
|
|
115
|
+
self.collect()
|
|
116
|
+
finally:
|
|
117
|
+
self.give_back()
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
def collect(self) -> None:
|
|
121
|
+
"""Move everything the run made into the evidence."""
|
|
122
|
+
before_dir, after_dir = self.evidence / "before", self.evidence / "after"
|
|
123
|
+
before_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
after_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
for name, text in self.fixtures.items():
|
|
126
|
+
(before_dir / name).write_text(text, encoding="utf-8", newline="\n")
|
|
127
|
+
if (self.home / name).exists():
|
|
128
|
+
shutil.copy2(self.home / name, after_dir / name)
|
|
129
|
+
for watch in self.watch:
|
|
130
|
+
before = self._before.get(str(watch.directory), set())
|
|
131
|
+
for entry in sorted(_entries(watch.directory) - before):
|
|
132
|
+
if watch.directory == self.home and (
|
|
133
|
+
entry in self.fixtures or entry == self.marker.name):
|
|
134
|
+
continue
|
|
135
|
+
if watch.skip and entry.startswith(watch.skip):
|
|
136
|
+
continue
|
|
137
|
+
if entry == "__pycache__":
|
|
138
|
+
shutil.rmtree(watch.directory / entry, ignore_errors=True)
|
|
139
|
+
continue
|
|
140
|
+
source = watch.directory / entry
|
|
141
|
+
target = self.evidence / watch.label / entry
|
|
142
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
if watch.move:
|
|
144
|
+
shutil.move(str(source), target)
|
|
145
|
+
elif source.is_dir():
|
|
146
|
+
shutil.copytree(source, target)
|
|
147
|
+
else:
|
|
148
|
+
shutil.copy2(source, target)
|
|
149
|
+
|
|
150
|
+
def give_back(self) -> None:
|
|
151
|
+
"""Put the operator's files back."""
|
|
152
|
+
for name, had in self._had.items():
|
|
153
|
+
if had:
|
|
154
|
+
os.replace(self.marker / name, self.home / name)
|
|
155
|
+
else:
|
|
156
|
+
(self.home / name).unlink(missing_ok=True)
|
|
157
|
+
_return_backups(self.marker, self.home)
|
|
158
|
+
shutil.rmtree(self.marker, ignore_errors=True)
|
|
159
|
+
if self.on_change:
|
|
160
|
+
self.on_change()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _return_backups(marker: pathlib.Path, home: pathlib.Path) -> None:
|
|
164
|
+
for kept in marker.iterdir() if marker.is_dir() else ():
|
|
165
|
+
if kept.is_file() and not (home / kept.name).exists():
|
|
166
|
+
os.replace(kept, home / kept.name)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def restore(marker: pathlib.Path, home: pathlib.Path, names: list[str],
|
|
170
|
+
on_change: Callable[[], object] | None = None) -> list[str]:
|
|
171
|
+
"""Put back what a run that could not unwind left in ``marker``.
|
|
172
|
+
|
|
173
|
+
A fixture still in place is moved aside to ``<name>.quality-loop-fixture``
|
|
174
|
+
rather than deleted. Returns what it did, one line each.
|
|
175
|
+
"""
|
|
176
|
+
done = []
|
|
177
|
+
if not marker.exists():
|
|
178
|
+
return ["nothing to restore"]
|
|
179
|
+
for name in names:
|
|
180
|
+
saved = marker / name
|
|
181
|
+
if not saved.exists():
|
|
182
|
+
continue
|
|
183
|
+
if (home / name).exists():
|
|
184
|
+
aside = home / f"{name}.quality-loop-fixture"
|
|
185
|
+
os.replace(home / name, aside)
|
|
186
|
+
done.append(f"moved the fixture aside to {aside}")
|
|
187
|
+
os.replace(saved, home / name)
|
|
188
|
+
done.append(f"restored {home / name}")
|
|
189
|
+
_return_backups(marker, home)
|
|
190
|
+
shutil.rmtree(marker)
|
|
191
|
+
if on_change:
|
|
192
|
+
on_change()
|
|
193
|
+
return done
|