The status page's walkthrough (M6.6b)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m41s
CI / e2e (pull_request) Successful in 4m25s

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:
dongarandClaude Opus 5.5 committed 2026-10-25 03:36:50 +00:00
1 parent c1f3d9dafb
commit a8e2023183
31 files changed
+2550 -15

No files matched your search

+111
View File
@@ -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
]
+32 -6
View File
@@ -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)
+22 -4
View File
@@ -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
+18
View File
@@ -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"]
+73
View File
@@ -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) == []
+43
View File
@@ -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()
+44
View File
@@ -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]