The status page's walkthrough (M6.6b)
A new package, nisse-ui: a read-only page for studying how the agents work. For each work item it shows the steps (what people and the orchestrator said, and each run), each run's turns, and each turn's request read against the log it was made from: - each message beside the events it came from, tagged unchanged, labelled, narrated, shortened (by how much) or a compaction, with what fitting dropped; - what is new since the turn before, and where the request first differs (where prompt reuse had to stop), next to the server's count; - the reply with its reasoning, and the raw JSON. Runs going in circles are flagged by nisse_core.loop.circles (repeated near-identical replies, one correction given again and again, repeated apologies), a pure function M7.8 can reuse in Driver.run. In nisse-core: - render.py: message_indexes(), from the same code render() uses, so the page's matching can't drift from what was sent. - calls.py: the record notes when recording began; the reader gains calls_for(item) and call(id). - state/readonly.py: opens an agent's SQLite file without creating its -wal/-shm files (mode=ro alone does, once the writer has closed). The page reads only through read-only connections, escapes everything, and sends a Content Security Policy that allows no script. It listens on 127.0.0.1; until M6.6d puts it behind Authelia, reach it over an SSH tunnel. Jinja2 is its one new dependency, for autoescaping. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
c1f3d9dafb
commit
a8e2023183
31 files changed
+2550
-15
No files matched your search
@@ -0,0 +1,111 @@
|
||||
"""Runs going in circles (L2, X6): turns that repeat near-identically, one
|
||||
correction given again and again, or apologies over and over.
|
||||
|
||||
A pure function over a run's turns, so the status page can flag a loop now
|
||||
and ``Driver.run`` can end one later (M7.8) from the same rules.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
SIMILAR = 0.9 # how alike two replies are to count as the same
|
||||
REPEATS = 3 # how many times before it's a circle
|
||||
_COMPARED = 2000 # characters of each reply compared
|
||||
|
||||
_APOLOGY = re.compile(
|
||||
r"\b(sorry|apologi[sz]e|apologies|my mistake|my apologies|please disregard|"
|
||||
r"disregard (this|that|the previous|my))\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Turn:
|
||||
"""What a circle is judged on: the model's reply (its text and tool
|
||||
calls) and what the harness told it before this turn."""
|
||||
|
||||
text: str = ""
|
||||
calls: tuple[tuple[str, str], ...] = () # (tool, arguments as sent)
|
||||
told: tuple[str, ...] = () # corrections given since the turn before
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Circle:
|
||||
kind: str # "repeated reply" | "repeated correction" | "repeated apology"
|
||||
turns: tuple[int, ...] # indexes into the turns given
|
||||
detail: str
|
||||
|
||||
|
||||
def find_circles(
|
||||
turns: Sequence[Turn], *, similar: float = SIMILAR, repeats: int = REPEATS
|
||||
) -> list[Circle]:
|
||||
"""Every circle in a run, in the order they started."""
|
||||
found = _repeated_replies(turns, similar, repeats)
|
||||
found += _repeated_corrections(turns, repeats)
|
||||
apologies = tuple(i for i, t in enumerate(turns) if _APOLOGY.search(t.text))
|
||||
if len(apologies) >= repeats:
|
||||
found.append(
|
||||
Circle(
|
||||
"repeated apology",
|
||||
apologies,
|
||||
f"the model apologised in {len(apologies)} turns",
|
||||
)
|
||||
)
|
||||
return sorted(found, key=lambda c: c.turns[0])
|
||||
|
||||
|
||||
def _signature(turn: Turn) -> str:
|
||||
calls = "\n".join(f"{tool}({args})" for tool, args in turn.calls)
|
||||
return f"{turn.text.strip()}\n{calls}"[:_COMPARED]
|
||||
|
||||
|
||||
def _alike(a: str, b: str, similar: float) -> bool:
|
||||
if a == b:
|
||||
return True
|
||||
matcher = SequenceMatcher(None, a, b, autojunk=False)
|
||||
return matcher.quick_ratio() >= similar and matcher.ratio() >= similar
|
||||
|
||||
|
||||
def _repeated_replies(
|
||||
turns: Sequence[Turn], similar: float, repeats: int
|
||||
) -> list[Circle]:
|
||||
"""Stretches of consecutive turns each alike the one before."""
|
||||
found: list[Circle] = []
|
||||
signatures = [_signature(t) for t in turns]
|
||||
start = 0
|
||||
for i in range(1, len(turns) + 1):
|
||||
if (
|
||||
i < len(turns)
|
||||
and signatures[i].strip()
|
||||
and _alike(signatures[i - 1], signatures[i], similar)
|
||||
):
|
||||
continue
|
||||
if i - start >= repeats:
|
||||
found.append(
|
||||
Circle(
|
||||
"repeated reply",
|
||||
tuple(range(start, i)),
|
||||
f"{i - start} near-identical replies in a row",
|
||||
)
|
||||
)
|
||||
start = i
|
||||
return found
|
||||
|
||||
|
||||
def _repeated_corrections(turns: Sequence[Turn], repeats: int) -> list[Circle]:
|
||||
"""One correction given in ``repeats`` or more turns, in a row or not."""
|
||||
given: dict[str, list[int]] = {}
|
||||
for i, turn in enumerate(turns):
|
||||
for text in dict.fromkeys(turn.told):
|
||||
given.setdefault(text, []).append(i)
|
||||
return [
|
||||
Circle(
|
||||
"repeated correction",
|
||||
tuple(at),
|
||||
f"told {len(at)} times: {text[:120]}",
|
||||
)
|
||||
for text, at in given.items()
|
||||
if len(at) >= repeats
|
||||
]
|
||||
@@ -29,7 +29,7 @@ import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
import zlib
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime
|
||||
@@ -38,6 +38,7 @@ from typing import Any
|
||||
|
||||
from nisse_core.log.canonical import StoredEvent, event_type, to_payload
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state import readonly
|
||||
|
||||
LAYOUT_VERSION = 1
|
||||
|
||||
@@ -241,8 +242,9 @@ class CallRecorder:
|
||||
for statement in _SCHEMA:
|
||||
conn.execute(statement)
|
||||
conn.execute(
|
||||
"INSERT INTO meta (key, value) VALUES ('layout_version', ?)",
|
||||
(str(LAYOUT_VERSION),),
|
||||
"INSERT INTO meta (key, value) VALUES ('layout_version', ?), "
|
||||
"('started_at', ?)",
|
||||
(str(LAYOUT_VERSION), utc_now()),
|
||||
)
|
||||
conn.execute("COMMIT")
|
||||
self._conn = conn
|
||||
@@ -427,11 +429,10 @@ class UnknownLayout(RuntimeError):
|
||||
|
||||
|
||||
class CallReader:
|
||||
"""Opens a call record read-only (``mode=ro``) and never writes to it."""
|
||||
"""Opens a call record read-only and never writes to it (``readonly``)."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self._conn = sqlite3.connect(f"file:{Path(path)}?mode=ro", uri=True)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._conn = readonly.connect(path)
|
||||
row = self._conn.execute(
|
||||
"SELECT value FROM meta WHERE key = 'layout_version'"
|
||||
).fetchone()
|
||||
@@ -442,6 +443,11 @@ class CallReader:
|
||||
f"call record layout {self.layout_version}; this reader knows "
|
||||
f"{LAYOUT_VERSION}"
|
||||
)
|
||||
row = self._conn.execute(
|
||||
"SELECT value FROM meta WHERE key = 'started_at'"
|
||||
).fetchone()
|
||||
# When recording began: work before it has its log but no requests.
|
||||
self.started_at: str | None = row["value"] if row else None
|
||||
|
||||
def blob(self, digest: str) -> Any:
|
||||
row = self._conn.execute(
|
||||
@@ -464,6 +470,26 @@ class CallReader:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM calls WHERE run = ? ORDER BY id", (run,)
|
||||
)
|
||||
return self._decoded(rows)
|
||||
|
||||
def calls_for(self, work_item: str | None) -> list[dict[str, Any]]:
|
||||
"""One work item's calls, or the agent-level ones (None)."""
|
||||
if work_item is None:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM calls WHERE work_item IS NULL ORDER BY id"
|
||||
)
|
||||
else:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM calls WHERE work_item = ? ORDER BY id", (work_item,)
|
||||
)
|
||||
return self._decoded(rows)
|
||||
|
||||
def call(self, call_id: int) -> dict[str, Any] | None:
|
||||
rows = self._conn.execute("SELECT * FROM calls WHERE id = ?", (call_id,))
|
||||
found = self._decoded(rows)
|
||||
return found[0] if found else None
|
||||
|
||||
def _decoded(self, rows: Iterable[sqlite3.Row]) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for row in rows:
|
||||
call = dict(row)
|
||||
|
||||
@@ -88,13 +88,34 @@ class OpenAIChatRenderer:
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
messages, _ = self._messages(events, system)
|
||||
body: dict[str, Any] = {"model": model, "messages": messages}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
return body
|
||||
|
||||
def message_indexes(
|
||||
self, events: list[StoredEvent], system: str | None = None
|
||||
) -> list[int | None]:
|
||||
"""For each event, the index of the request message ``render`` puts it
|
||||
in, or None where a compaction stands in for it: so a request can be
|
||||
read against the log it was made from (X6). Several events share an
|
||||
index where one turn's text and tool calls fold into one message."""
|
||||
_, where = self._messages(events, system)
|
||||
return [where.get(id(stored)) for stored in events]
|
||||
|
||||
def _messages(
|
||||
self, events: list[StoredEvent], system: str | None
|
||||
) -> tuple[list[dict[str, Any]], dict[int, int]]:
|
||||
messages: list[dict[str, Any]] = []
|
||||
where: dict[int, int] = {} # id() of an event given -> its message
|
||||
if system is not None:
|
||||
messages.append({"role": "system", "content": system})
|
||||
kept = effective_events(events)
|
||||
answered = {e.event.call_id for e in kept if isinstance(e.event, ToolResult)}
|
||||
for stored in kept:
|
||||
_append_merged(messages, self._message_for(stored.event))
|
||||
where[id(stored)] = len(messages) - 1
|
||||
# Providers reject a tool call with no result following it. One
|
||||
# can legitimately be missing: a crash between the call and its
|
||||
# result (L8), or a turn-ending gate tool. Render a placeholder so
|
||||
@@ -109,10 +130,7 @@ class OpenAIChatRenderer:
|
||||
"content": "[no result recorded]",
|
||||
}
|
||||
)
|
||||
body: dict[str, Any] = {"model": model, "messages": messages}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
return body
|
||||
return messages, where
|
||||
|
||||
def _message_for(self, event: object) -> dict[str, Any]:
|
||||
match event:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Opening an agent's SQLite file without writing to it (X6), for readers
|
||||
beside the agent: the status page, the call record's reader.
|
||||
|
||||
``mode=ro`` alone still creates the ``-wal`` and ``-shm`` files of a
|
||||
database whose writer has closed, which writes to the agent's volume and
|
||||
fails on a read-only mount. With no ``-wal`` file there is no writer, so the
|
||||
file is opened immutable instead: nothing is created, nothing is locked.
|
||||
With one, a writer is (or was) there, and SQLite's own read-only WAL
|
||||
reading applies.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def connect(path: str | Path, *, timeout: float = 5.0) -> sqlite3.Connection:
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
raise sqlite3.OperationalError(f"unable to open database file: {path}")
|
||||
mode = "ro" if Path(f"{path}-wal").exists() else "ro&immutable=1"
|
||||
conn = sqlite3.connect(
|
||||
f"file:{quote(str(path.resolve()))}?mode={mode}", uri=True, timeout=timeout
|
||||
)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
@@ -374,3 +374,21 @@ def test_the_reader_never_writes(recorder: calls.CallRecorder) -> None:
|
||||
reader = read(recorder)
|
||||
with pytest.raises(sqlite3.OperationalError, match="readonly"):
|
||||
reader._conn.execute("DELETE FROM calls")
|
||||
|
||||
|
||||
def test_the_page_reads_one_items_calls_and_when_recording_began(
|
||||
recorder: calls.CallRecorder,
|
||||
) -> None:
|
||||
client = budgeted(Server(), recorder)
|
||||
with calls.run(recorder, work_item="w", role="driver"):
|
||||
client.complete(stored([UserMessage(text="go", source="t")]), None, "local")
|
||||
with calls.run(recorder, work_item=None, role="classifier"):
|
||||
client.complete(stored([UserMessage(text="hi", source="t")]), None, "local")
|
||||
reader = read(recorder)
|
||||
[mine] = reader.calls_for("w")
|
||||
[agent_level] = reader.calls_for(None)
|
||||
assert (mine["work_item"], agent_level["work_item"]) == ("w", None)
|
||||
assert reader.call(agent_level["id"])["role"] == "classifier"
|
||||
assert reader.call(999) is None
|
||||
assert reader.started_at is not None
|
||||
assert reader.started_at <= mine["at"]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Runs going in circles (L2, X6), judged from the turns alone."""
|
||||
|
||||
from nisse_core.loop.circles import Turn, find_circles
|
||||
|
||||
NUDGE = "Your last reply was prose. Every reply must be a tool call."
|
||||
|
||||
|
||||
def comment(n: int) -> Turn:
|
||||
return Turn(
|
||||
text="",
|
||||
calls=(("post_comment", f'{{"body": "Duplicate, please disregard. ({n})"}}'),),
|
||||
)
|
||||
|
||||
|
||||
def test_near_identical_replies_in_a_row_are_a_circle() -> None:
|
||||
# The Flash-Next review loop of 2026-10-24: the same comment posted over
|
||||
# and over, differing only in a counter.
|
||||
turns = [Turn(calls=(("read_file", '{"path": "a.py"}'),))]
|
||||
turns += [comment(n) for n in range(6)]
|
||||
turns += [Turn(calls=(("finish", "{}"),))]
|
||||
[circle] = find_circles(turns)
|
||||
assert circle.kind == "repeated reply"
|
||||
assert circle.turns == (1, 2, 3, 4, 5, 6)
|
||||
|
||||
|
||||
def test_work_that_moves_on_is_no_circle() -> None:
|
||||
turns = [
|
||||
Turn(calls=(("read_file", '{"path": "a.py"}'),)),
|
||||
Turn(calls=(("run_command", '{"command": "pytest -q"}'),)),
|
||||
Turn(calls=(("edit_file", '{"path": "a.py", "old": "x", "new": "y"}'),)),
|
||||
Turn(calls=(("run_command", '{"command": "pytest -q"}'),)),
|
||||
Turn(text="Done.", calls=(("finish", "{}"),)),
|
||||
]
|
||||
assert find_circles(turns) == []
|
||||
|
||||
|
||||
def test_two_alike_is_not_yet_a_circle() -> None:
|
||||
assert find_circles([comment(1), comment(2), Turn(text="done")]) == []
|
||||
|
||||
|
||||
def test_one_correction_given_again_and_again_is_a_circle() -> None:
|
||||
# Invented tools met with the prose reminder turn after turn, the replies
|
||||
# themselves all different.
|
||||
replies = [
|
||||
"I'll run the linter first: <tool_call>lint_fix</tool_call>",
|
||||
"<call>read_file src/review.py</call>",
|
||||
"Installing the dependencies with install(requirements.txt) now.",
|
||||
"Let me use the skill tool to summarise the diff for the review.",
|
||||
]
|
||||
turns = [Turn(text=r, told=(NUDGE,) if i else ()) for i, r in enumerate(replies)]
|
||||
[circle] = find_circles(turns)
|
||||
assert circle.kind == "repeated correction"
|
||||
assert circle.turns == (1, 2, 3)
|
||||
assert NUDGE[:40] in circle.detail
|
||||
|
||||
|
||||
def test_apologies_over_and_over_are_a_circle() -> None:
|
||||
turns = [
|
||||
Turn(text="Sorry, I made an error reading the file."),
|
||||
Turn(calls=(("read_file", '{"path": "b.py"}'),)),
|
||||
Turn(text="My apologies, that was the wrong path."),
|
||||
Turn(calls=(("list_files", "{}"),)),
|
||||
Turn(text="Please disregard my previous comment."),
|
||||
]
|
||||
[circle] = find_circles(turns)
|
||||
assert circle.kind == "repeated apology"
|
||||
assert circle.turns == (0, 2, 4)
|
||||
|
||||
|
||||
def test_the_limits_are_tuning() -> None:
|
||||
turns = [comment(1), comment(2)]
|
||||
assert [c.kind for c in find_circles(turns, repeats=2)] == ["repeated reply"]
|
||||
assert find_circles([comment(1), comment(22), comment(333)], similar=1.0) == []
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Readers beside an agent open its files without writing to them (X6)."""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nisse_core.state import readonly
|
||||
|
||||
|
||||
def wal_db(path: Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(path, isolation_level=None)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("CREATE TABLE t (v TEXT)")
|
||||
conn.execute("INSERT INTO t VALUES ('one')")
|
||||
return conn
|
||||
|
||||
|
||||
def test_a_closed_database_is_read_without_creating_any_file(tmp_path: Path) -> None:
|
||||
wal_db(tmp_path / "a.db").close()
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == ["a.db"]
|
||||
conn = readonly.connect(tmp_path / "a.db")
|
||||
assert conn.execute("SELECT v FROM t").fetchall()[0]["v"] == "one"
|
||||
conn.close()
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == ["a.db"]
|
||||
|
||||
|
||||
def test_a_live_database_is_read_as_its_writer_commits(tmp_path: Path) -> None:
|
||||
writer = wal_db(tmp_path / "a.db")
|
||||
conn = readonly.connect(tmp_path / "a.db")
|
||||
writer.execute("INSERT INTO t VALUES ('two')")
|
||||
assert [r["v"] for r in conn.execute("SELECT v FROM t")] == ["one", "two"]
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
conn.execute("INSERT INTO t VALUES ('three')")
|
||||
conn.close()
|
||||
writer.execute("INSERT INTO t VALUES ('four')") # no lock left behind
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_a_missing_file_is_an_error_not_a_new_database(tmp_path: Path) -> None:
|
||||
with pytest.raises(sqlite3.OperationalError):
|
||||
readonly.connect(tmp_path / "none.db")
|
||||
assert not (tmp_path / "none.db").exists()
|
||||
@@ -163,3 +163,47 @@ def test_roles_never_repeat_for_strict_templates() -> None:
|
||||
roles = _roles(OpenAIChatRenderer().render(events, tools=None, model="q"))
|
||||
for first, second in pairwise(roles):
|
||||
assert not (first == second and first in ("user", "assistant")), roles
|
||||
|
||||
|
||||
def test_each_event_maps_to_the_message_render_puts_it_in() -> None:
|
||||
# A turn's text and tool call fold into one message; a call with no
|
||||
# result gets a placeholder that came from no event; the system prompt
|
||||
# is message 0.
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="go", source="m:1"),
|
||||
AssistantText(text="Looking.", model="m"),
|
||||
ToolCall(call_id="c1", tool="read_file", arguments={"path": "a"}),
|
||||
ToolResult(call_id="c1", result="text", success=True),
|
||||
ToolCall(call_id="c2", tool="finish", arguments={}),
|
||||
UserMessage(text="and then?", source="m:2"),
|
||||
]
|
||||
)
|
||||
renderer = OpenAIChatRenderer()
|
||||
body = renderer.render(events, tools=None, model="m", system="sys")
|
||||
indexes = renderer.message_indexes(events, system="sys")
|
||||
assert indexes == [1, 2, 2, 3, 4, 6]
|
||||
assert body["messages"][5]["content"] == "[no result recorded]"
|
||||
for event, index in zip(events, indexes, strict=True):
|
||||
message = body["messages"][index]
|
||||
if isinstance(event.event, ToolCall):
|
||||
ids = [c["id"] for c in message["tool_calls"]]
|
||||
assert event.event.call_id in ids
|
||||
elif isinstance(event.event, (UserMessage, AssistantText)):
|
||||
assert event.event.text in message["content"]
|
||||
assert renderer.message_indexes(events) == [i - 1 for i in indexes]
|
||||
|
||||
|
||||
def test_events_a_compaction_replaced_map_to_no_message() -> None:
|
||||
events = stored(
|
||||
[
|
||||
UserMessage(text="one", source="m:1"),
|
||||
AssistantText(text="two", model="m"),
|
||||
UserMessage(text="three", source="m:3"),
|
||||
Compaction(summary="one and two", model="m", replaces=[1, 2]),
|
||||
]
|
||||
)
|
||||
# The summary takes the place of what it replaced, and the user message
|
||||
# after it folds into the same user message.
|
||||
indexes = OpenAIChatRenderer().message_indexes(events)
|
||||
assert indexes == [None, None, 0, 0]
|
||||
Reference in new issue
Block a user