The status page's walkthrough (M6.6b) #181

Open
dev-agent wants to merge 1 commits from walkthrough into main
pull from: walkthrough
31 changed files with 2550 additions and 15 deletions
Showing only changes of commit a8e2023183 - Show all commits

No files matched your search

+1
View File
@@ -30,6 +30,7 @@ COPY nisse-core nisse-core
COPY nisse-dev nisse-dev
COPY nisse-ops nisse-ops
COPY nisse-review nisse-review
COPY nisse-ui nisse-ui
# nisse-dev and what it needs, installed (not editable): the source tree
# isn't part of the image.
+5 -3
View File
@@ -40,7 +40,7 @@ Only `nisse-dev` has real code; `nisse-core` and `nisse-ops` are placeholders.
| **D3–D4** Workspace, git | partial | Idempotent publishing; no fresh workspace (#62), `git add -A` (#80) |
| **D5** Review | partial | PR conversation comments only (#69), watch stall (#70) |
| **D7–D8** Deployment, projects | partial | Bring-up runs `nisse-dev`'s and `nisse-review`'s images under systemd on this box; project entries in config (M1.4); egress not enforced |
| **O**, **X** | partial | `nisse-dev` has its image (M1.5), `nisse-review` its own (M7.6, acceptance after merge); nothing runs on the agent host |
| **O**, **X** | partial | `nisse-dev` has its image (M1.5), `nisse-review` its own (M7.6, acceptance after merge); nothing runs on the agent host; the status page's walkthrough runs on this box (M6.6b) |
| `specs/` | done for now | `loop.qnt` (the architecture, #91); `orchestrator.qnt` went with `control.py` (M2.8) |
---
@@ -585,9 +585,11 @@ bring-up, end-to-end and benchmark runs; only d waits for M6.1.
- **a. The call record** (L7) in `nisse-core`: written by the shared model client
and `Driver.run`, with each message's provenance from the per-role view and
fitting; wired in each agent's build; deleted at start. Each system prompt
says the calls are recorded, while they are.
says the calls are recorded, while they are. **Done** (#179).
- **b. The walkthrough:** an item's steps, each run's turns, and each request read
against the log it was made from; runs going in circles flagged.
against the log it was made from; runs going in circles flagged. **Done** (this
PR): the `nisse-ui` package, on 127.0.0.1 behind an SSH tunnel until d. The
loop rules are `nisse_core.loop.circles`, for M7.8 to reuse.
- **c. The overview:** each agent's active, queued, parked and blocked items.
- **d. Deployment:** its image, the read-only mounts and a Caddy route behind
Authelia (with #166).
+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]
+40
View File
@@ -0,0 +1,40 @@
# nisse-ui
The status page (architecture X6): a read-only view of what the agents did
and how, for studying context, compaction and memory in practice.
It reads each agent's state store and the call record beside it (L7), and
never writes to either. It runs no model and holds no credentials.
uv run nisse-ui --source dev=/var/lib/nisse-dev/state.db \
--source review=/var/lib/nisse-review/state.db \
--runs /path/to/kept-runs
- `--source NAME=STATE_DB`: an agent's state store. Its call record is
`<stem>.calls.db` beside it.
- `--runs DIR`: runs the benchmark kept (`scripts/benchmark.py run
--keep-runs DIR`). Each folder holding a `state.db` is a source.
It listens on 127.0.0.1:8765, because it shows everything the models saw,
including what other people wrote. Until it sits behind Caddy and Authelia
(M6.6d), reach it through an SSH tunnel:
ssh -L 8765:localhost:8765 <this VM>
then open http://localhost:8765/.
**What it shows:** for each work item, its steps (what people and the
orchestrator said, and each run), each run's turns, and for each turn:
- the request exactly as sent, message by message, beside the log events
each came from and what rendering did to them;
- what changed since the turn before, and where prompt reuse had to stop,
next to the server's own count;
- the reply, with its reasoning;
- the raw JSON.
Runs going in circles are flagged (`nisse_core.loop.circles`).
**Safety:** everything is escaped, and the page sends a Content Security
Policy that runs no script at all. Files are opened through
`nisse_core.state.readonly`, which never creates or locks anything.
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "nisse-ui"
version = "0.1.0"
description = "Status page: a read-only view of what the agents did and how (X6)"
requires-python = ">=3.12"
dependencies = ["nisse-core", "jinja2>=3.1"]
[project.scripts]
nisse-ui = "nisse_ui.main:main"
[tool.uv.sources]
nisse-core = { workspace = true }
[build-system]
requires = ["uv_build>=0.12,<0.13"]
build-backend = "uv_build"
+1
View File
@@ -0,0 +1 @@
"""nisse-ui: the operator's read-only view of the agents (architecture X6)."""
+230
View File
@@ -0,0 +1,230 @@
"""The page's routes, as a plain function from a path to a response, so the
tests drive it without a socket (X6).
/ the sources: agents and kept runs
/s/<source>/ a source's work items
/s/<source>/item/<id> an item's walkthrough ("-": the agent-level log)
/s/<source>/call/<n> one turn, read against its log
/s/<source>/event/<n> one log event in full
"""
import json
import sqlite3
from dataclasses import dataclass, field
from importlib import resources
from typing import Any
from urllib.parse import quote, unquote, urlsplit
from jinja2 import Environment, PackageLoader, StrictUndefined
from nisse_core.models import calls
from nisse_ui import walk
from nisse_ui.sources import Source, StateReader, UnknownSchema, call_record
AGENT_LEVEL = "-" # the item id that stands for the agent-level log
# Everything shown is untrusted text (L11): escaped by the templates, and
# the browser runs no script at all, loads nothing from elsewhere, and
# sends no referrer.
HEADERS = {
"Content-Security-Policy": (
"default-src 'none'; style-src 'self'; img-src 'self'; base-uri 'none'; "
"form-action 'none'; frame-ancestors 'none'"
),
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Cache-Control": "no-store",
}
@dataclass(frozen=True)
class Response:
status: int
body: bytes
content_type: str = "text/html; charset=utf-8"
headers: dict[str, str] = field(default_factory=lambda: dict(HEADERS))
def _when(ts: Any) -> str:
return str(ts)[:19].replace("T", " ") if ts else "—"
def _num(n: Any) -> str:
return f"{n:,}" if isinstance(n, int) and not isinstance(n, bool) else "—"
def _pretty(text: Any) -> str:
"""JSON text indented, anything else as it is."""
if not isinstance(text, str):
return json.dumps(text, indent=2, ensure_ascii=False)
try:
return json.dumps(json.loads(text), indent=2, ensure_ascii=False)
except ValueError:
return text
def _clip(text: Any, limit: int = 300) -> str:
text = str(text or "")
return (
text
if len(text) <= limit
else text[:limit] + f" … [{len(text) - limit:,} more]"
)
def summary(kind: str, payload: dict[str, Any]) -> str:
"""One event as a line of text: what it said or did."""
match kind:
case "user_message" | "assistant_text" | "fact":
return str(payload.get("text", ""))
case "tool_call":
args = json.dumps(payload.get("arguments", {}), ensure_ascii=False)
return f"{payload.get('tool', '?')}({args})"
case "tool_result":
mark = "ok" if payload.get("success") else "FAILED"
return f"{mark}: {payload.get('result', '')}"
case "plan_submitted":
return str(payload.get("plan", ""))
case "gate_opened":
return f"{payload.get('gate', '')}: {payload.get('reason', '')}"
case "delegation_started":
return f"({payload.get('model', '')}) {payload.get('brief', '')}"
case "delegation_completed":
return f"[{payload.get('exit_status', '')}] {payload.get('handback', '')}"
case "compaction":
first, last = (payload.get("replaces") or ["?", "?"])[:2]
return f"replaces {first}–{last}: {payload.get('summary', '')}"
case "action_verdict":
return (
f"{payload.get('tool', '')}: {payload.get('effect', '')} → "
f"{payload.get('decision', '')} ({payload.get('reason', '')})"
)
case _:
return json.dumps(payload, ensure_ascii=False)
def _reply_line(reply: walk.Reply) -> str:
calls_made = "; ".join(f"{tool}({args})" for tool, args in reply.calls)
text = " ".join(reply.text.split())
return " · ".join(p for p in (text, calls_made) if p) or "(no reply)"
class App:
def __init__(self, sources: list[Source]) -> None:
self.sources = list(sources)
self.by_name = {s.name: s for s in sources}
env = Environment(
loader=PackageLoader("nisse_ui", "templates"),
autoescape=True, # always: every page shows untrusted text
undefined=StrictUndefined,
trim_blocks=True,
lstrip_blocks=True,
)
env.filters.update(
u=lambda s: quote(str(s), safe=""),
when=_when,
num=_num,
pretty=_pretty,
clip=_clip,
reply_line=_reply_line,
)
env.globals.update(summary=summary, AGENT_LEVEL=AGENT_LEVEL)
self.env = env
def page(self, template: str, status: int = 200, **values: Any) -> Response:
html = self.env.get_template(template).render(**values)
return Response(status, html.encode())
def problem(self, status: int, title: str, detail: str) -> Response:
return self.page("problem.html", status, title=title, detail=detail)
def handle(self, raw_path: str) -> Response:
path = urlsplit(raw_path).path
parts = [unquote(p) for p in path.split("/")[1:]]
if parts in ([], [""]):
return self.home()
if parts == ["static", "style.css"]:
css = resources.files("nisse_ui").joinpath("static/style.css").read_bytes()
return Response(200, css, "text/css; charset=utf-8")
if len(parts) >= 2 and parts[0] == "s":
source = self.by_name.get(parts[1])
if source is None:
return self.problem(404, "No such source", parts[1])
rest = parts[2:]
try:
if rest in ([], [""]):
return self.items(source)
if len(rest) == 2 and rest[0] == "item":
item = None if rest[1] == AGENT_LEVEL else rest[1]
return self.item(source, item)
if (
len(rest) == 2
and rest[0] in ("call", "event")
and rest[1].isdigit()
):
if rest[0] == "call":
return self.call(source, int(rest[1]))
return self.event(source, int(rest[1]))
except UnknownSchema as exc:
return self.problem(
409, "A state store this page doesn't know", str(exc)
)
except calls.UnknownLayout as exc:
return self.problem(
409, "A call record this page doesn't know", str(exc)
)
except sqlite3.Error as exc:
return self.problem(503, "Couldn't read the store", str(exc))
return self.problem(404, "Not found", path)
# --- pages ---------------------------------------------------------------------
def home(self) -> Response:
rows = []
for source in self.sources:
row: dict[str, Any] = {"source": source, "count": None, "note": ""}
try:
with StateReader(source.state_db) as state:
row["count"] = state.count_items()
row["schema"] = state.schema_version
except (UnknownSchema, sqlite3.Error) as exc:
row["note"] = str(exc)
row["record"] = source.calls_db.is_file()
rows.append(row)
return self.page("sources.html", rows=rows)
def items(self, source: Source) -> Response:
with StateReader(source.state_db) as state:
return self.page(
"items.html",
source=source,
items=state.items(),
active=state.active_item(),
)
def item(self, source: Source, item_id: str | None) -> Response:
with StateReader(source.state_db) as state:
if item_id is not None and state.item(item_id) is None:
return self.problem(404, "No such work item", item_id)
try:
with call_record(source) as reader:
view = walk.walkthrough(state, reader, item_id)
except calls.UnknownLayout as exc:
view = walk.walkthrough(state, None, item_id, record_note=str(exc))
return self.page("item.html", source=source, view=view)
def call(self, source: Source, call_id: int) -> Response:
with StateReader(source.state_db) as state, call_record(source) as reader:
if reader is None:
return self.problem(404, "No call record", str(source.calls_db))
view = walk.turn(state, reader, call_id)
if view is None:
return self.problem(404, "No such call", str(call_id))
return self.page("call.html", source=source, view=view)
def event(self, source: Source, event_id: int) -> Response:
with StateReader(source.state_db) as state:
found = state.event(event_id)
if found is None:
return self.problem(404, "No such event", str(event_id))
return self.page("event.html", source=source, event=found)
+70
View File
@@ -0,0 +1,70 @@
"""Serve the status page (X6), read-only, on this machine only.
nisse-ui --source dev=/var/lib/nisse-dev/state.db --runs bench-kept/
Each source is an agent's state store; its call record is found beside it.
``--runs`` names folders of runs the benchmark kept (``--keep-runs``). It
listens on 127.0.0.1: until it sits behind Authelia (M6.6d), reach it
through an SSH tunnel, since it shows everything the models saw.
"""
import argparse
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from nisse_ui.app import App
from nisse_ui.sources import discover
def _source(text: str) -> tuple[str, Path]:
name, sep, path = text.partition("=")
if not sep or not name or not path:
raise argparse.ArgumentTypeError("expected NAME=STATE_DB")
return name, Path(path)
def handler_for(app: App) -> type[BaseHTTPRequestHandler]:
class Handler(BaseHTTPRequestHandler):
server_version = "nisse-ui"
sys_version = ""
def do_GET(self) -> None:
response = app.handle(self.path)
self.send_response(response.status)
self.send_header("Content-Type", response.content_type)
self.send_header("Content-Length", str(len(response.body)))
for key, value in response.headers.items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(response.body)
def log_message(self, format: str, *args: object) -> None:
sys.stderr.write(f"nisse-ui {self.address_string()} {format % args}\n")
return Handler
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--source", action="append", type=_source, default=[], metavar="NAME=STATE_DB"
)
parser.add_argument("--runs", action="append", type=Path, default=[], metavar="DIR")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args(argv)
app = App(discover(args.source, args.runs))
server = ThreadingHTTPServer((args.host, args.port), handler_for(app))
print(f"nisse-ui on http://{args.host}:{args.port}/", file=sys.stderr)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
return 0
if __name__ == "__main__":
sys.exit(main())
+229
View File
@@ -0,0 +1,229 @@
"""Where the page reads from (X6): each agent's state store and the call
record beside it, and the runs the benchmark kept. Everything is opened
read-only (SQLite ``mode=ro``), one short connection per page, so the agent
stays the only writer of its state (L8) and nothing here can lock it."""
import contextlib
import json
import re
import sqlite3
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Self
from nisse_core.models import calls
from nisse_core.state import readonly
# The state store schema versions this page knows: the shared store's, and
# each agent's that adds tables of its own. The page reads only the shared
# tables, but says so for any version it doesn't know rather than guessing.
KNOWN_SCHEMAS = {4: "nisse-core (nisse-review)", 5: "nisse-dev"}
class UnknownSchema(RuntimeError):
pass
@dataclass(frozen=True)
class Source:
name: str # what the page calls it, safe in a URL
state_db: Path
kind: str # "agent" | "run" (a kept benchmark run)
@property
def calls_db(self) -> Path:
return calls.path_for(self.state_db)
def safe_name(text: str) -> str:
return re.sub(r"[^A-Za-z0-9._-]+", "-", text).strip("-") or "source"
def discover(agents: list[tuple[str, Path]], runs: list[Path]) -> list[Source]:
"""The agents named on the command line, then each run kept under the
runs folders (a folder holding a ``state.db``), newest first."""
found = [Source(safe_name(name), path, "agent") for name, path in agents]
for folder in runs:
kept = sorted(
(p for p in folder.glob("*/state.db") if p.is_file()),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
found += [
Source(safe_name(f"{folder.name}.{p.parent.name}"), p, "run") for p in kept
]
names: set[str] = set()
unique = []
for source in found:
name, n = source.name, 2
while name in names:
name, n = f"{source.name}-{n}", n + 1
names.add(name)
unique.append(Source(name, source.state_db, source.kind))
return unique
@dataclass(frozen=True)
class Item:
id: str
title: str
status: str
outcome: str | None
position: str
scope: str | None
opened_at: str
updated_at: str
closed_at: str | None
data: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class Event:
id: int
ts: str
type: str
payload: dict[str, Any]
work_item: str | None
actor: str
def _loads(text: str | None) -> Any:
"""Item data and payloads, read tolerantly: whatever doesn't parse is kept
as text, so a field from a newer agent never breaks the page."""
if text is None:
return None
try:
return json.loads(text)
except (TypeError, ValueError):
return {"unreadable": text}
def connect(path: Path) -> sqlite3.Connection:
return readonly.connect(path)
class StateReader:
"""A state store, read-only. Use as a context manager: it closes at the
end of the page that opened it."""
def __init__(self, path: Path) -> None:
self.path = path
self._conn = connect(path)
try:
row = self._conn.execute(
"SELECT value FROM meta WHERE key = 'schema_version'"
).fetchone()
except sqlite3.Error as exc:
self._conn.close()
raise UnknownSchema(f"not a state store: {exc}") from exc
self.schema_version = int(row["value"]) if row else 0
if self.schema_version not in KNOWN_SCHEMAS:
self._conn.close()
known = ", ".join(str(v) for v in sorted(KNOWN_SCHEMAS))
raise UnknownSchema(
f"state store schema {self.schema_version}, not known to this "
f"page (it knows {known})"
)
def __enter__(self) -> Self:
return self
def __exit__(self, *exc: object) -> None:
self.close()
def close(self) -> None:
self._conn.close()
def active_item(self) -> str | None:
row = self._conn.execute(
"SELECT active_item FROM agent WHERE id = 1"
).fetchone()
return row["active_item"] if row else None
def items(self) -> list[Item]:
rows = self._conn.execute(
"SELECT * FROM work_items ORDER BY opened_at DESC, id"
).fetchall()
return [self._item(r) for r in rows]
def item(self, item_id: str) -> Item | None:
row = self._conn.execute(
"SELECT * FROM work_items WHERE id = ?", (item_id,)
).fetchone()
return self._item(row) if row else None
def _item(self, row: sqlite3.Row) -> Item:
data = _loads(row["data"])
return Item(
id=row["id"],
title=row["title"],
status=row["status"],
outcome=row["outcome"],
position=row["position"],
scope=row["scope"],
opened_at=row["opened_at"],
updated_at=row["updated_at"],
closed_at=row["closed_at"],
data=data if isinstance(data, dict) else {"unreadable": row["data"]},
)
def events(self, work_item: str | None) -> list[Event]:
"""One item's log, or the agent-level log (None)."""
if work_item is None:
rows = self._conn.execute(
"SELECT * FROM canonical_events WHERE work_item IS NULL ORDER BY id"
)
else:
rows = self._conn.execute(
"SELECT * FROM canonical_events WHERE work_item = ? ORDER BY id",
(work_item,),
)
return [self._event(r) for r in rows]
def event(self, event_id: int) -> Event | None:
row = self._conn.execute(
"SELECT * FROM canonical_events WHERE id = ?", (event_id,)
).fetchone()
return self._event(row) if row else None
def events_by_id(self, ids: list[int]) -> dict[int, Event]:
found: dict[int, Event] = {}
wanted = sorted({i for i in ids if i > 0})
for start in range(0, len(wanted), 500):
chunk = wanted[start : start + 500]
marks = ",".join("?" * len(chunk))
for row in self._conn.execute(
f"SELECT * FROM canonical_events WHERE id IN ({marks})", chunk
):
found[row["id"]] = self._event(row)
return found
def count_items(self) -> int:
return self._conn.execute("SELECT COUNT(*) FROM work_items").fetchone()[0]
def _event(self, row: sqlite3.Row) -> Event:
payload = _loads(row["payload"])
return Event(
id=row["id"],
ts=row["ts"],
type=row["type"],
payload=payload if isinstance(payload, dict) else {"value": payload},
work_item=row["work_item"],
actor=row["actor"],
)
@contextlib.contextmanager
def call_record(source: Source) -> Iterator[calls.CallReader | None]:
"""The source's call record, or None when there is none (an agent
started before M6.6a, or recording turned off). An unknown layout raises
``calls.UnknownLayout`` for the page to report."""
if not source.calls_db.is_file():
yield None
return
reader = calls.CallReader(source.calls_db)
try:
yield reader
finally:
reader.close()
+77
View File
@@ -0,0 +1,77 @@
/* nisse-ui: plain, readable, no external assets (X6). */
:root {
--bg: #fbfbfa; --fg: #1d1d1b; --muted: #6b6b66; --line: #deded8;
--panel: #f2f2ee; --accent: #2f5d8a; --bad: #a3322a; --warn: #8a5a00;
--new: #e3f0e0; --change: #fbe6c8; --flag: #f6d9d6;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161615; --fg: #e6e6e1; --muted: #9a9a93; --line: #34342f;
--panel: #20201e; --accent: #8fb8e0; --bad: #ec8a80; --warn: #e2b462;
--new: #233523; --change: #3d2f17; --flag: #432522;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
main { max-width: 1100px; margin: 0 auto; padding: 0 16px 64px; }
a { color: var(--accent); }
h1 { font-size: 1.5rem; margin: 1.2rem 0 .4rem; }
h2 { font-size: 1.15rem; margin: 1.8rem 0 .6rem; border-bottom: 1px solid var(--line); }
h3 { font-size: 1rem; margin: 1.2rem 0 .4rem; }
.top { border-bottom: 1px solid var(--line); background: var(--panel); }
.crumbs { max-width: 1100px; margin: 0 auto; padding: 8px 16px; }
.muted, .meta { color: var(--muted); }
.meta { font-size: .9rem; }
.bad { color: var(--bad); }
.path { font-family: var(--mono); font-size: .85rem; word-break: break-all; }
pre, code { font-family: var(--mono); font-size: .85rem; }
pre { background: var(--panel); border: 1px solid var(--line); border-radius: 4px;
padding: 8px 10px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; margin: .4rem 0; }
table { border-collapse: collapse; width: 100%; margin: .6rem 0; font-size: .92rem; }
th, td { text-align: left; vertical-align: top; padding: 4px 8px; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 600; }
tr.active td { background: var(--new); }
table.facts th { width: 14%; white-space: nowrap; }
table.log td.text { font-family: var(--mono); font-size: .8rem; word-break: break-word; }
details { margin: .4rem 0; }
summary { cursor: pointer; }
.tag { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px;
background: var(--panel); border: 1px solid var(--line); margin-right: 2px; }
.tag.warn { color: var(--warn); }
.flag { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px;
background: var(--flag); color: var(--bad); margin-left: 4px; }
.badge { display: inline-block; font-size: .78rem; padding: 0 6px; border-radius: 3px; }
.badge.new { background: var(--new); }
.badge.change { background: var(--change); }
.note { background: var(--panel); border-left: 3px solid var(--accent); padding: 6px 10px; }
.circle { background: var(--flag); color: var(--bad); padding: 4px 10px; border-radius: 4px; }
.timeline > * { margin: .5rem 0; }
.step { border-left: 3px solid var(--line); padding: 2px 10px; }
.step .text { white-space: pre-wrap; word-break: break-word; }
.step-user_message { border-left-color: var(--accent); }
.step-gate_opened, .step-fact { border-left-color: var(--warn); }
.run { border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; background: var(--bg); }
.run .run { margin: .4rem 0 .4rem 12px; }
.run > summary { padding: 4px 0; }
.turn { padding: 4px 0 4px 8px; border-top: 1px dashed var(--line); }
.turn.flagged { background: var(--flag); }
.turn-no { font-weight: 600; margin-right: 6px; }
.turn .reply { font-family: var(--mono); font-size: .82rem; color: var(--muted); word-break: break-word; }
.told { font-size: .82rem; color: var(--warn); }
.pager { display: flex; justify-content: space-between; margin-top: 1rem; }
.msg { border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; margin: .6rem 0; }
.msg.first-change { border-color: var(--warn); border-width: 2px; }
.msg-head { font-size: .88rem; }
.msg-head .role { font-weight: 700; text-transform: uppercase; letter-spacing: .03em; margin-right: 4px; }
.role-user { border-left: 4px solid var(--accent); }
.role-assistant { border-left: 4px solid #6a8f4e; }
.role-tool { border-left: 4px solid var(--muted); }
.role-system { border-left: 4px solid var(--warn); }
ul.from { margin: .3rem 0; padding-left: 1.2rem; font-size: .85rem; }
ul.from .text { font-family: var(--mono); font-size: .8rem; color: var(--muted); }
.toolcall { margin: .3rem 0; }
@media (max-width: 640px) {
table.facts th, table.facts td { display: block; width: auto; border: 0; }
}
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Agents{% endblock %} · nisse-ui</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="top">
<nav class="crumbs"><a href="/">nisse-ui</a>{% block crumbs %}{% endblock %}</nav>
</header>
<main>
{% block main %}{% endblock %}
</main>
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% from "macros.html" import src_link, item_href, event_link, flags %}
{% set c = view.call %}
{% block title %}Turn {{ view.number }}{% endblock %}
{% block crumbs %} › {{ src_link(source) }} › <a href="{{ item_href(source, c.work_item) }}">{{ c.work_item or "agent" }}</a> › {% if view.run %}{{ view.run.kind }} {{ view.run.role }} › {% endif %}turn {{ view.number }}{% endblock %}
{% block main %}
<nav class="pager">
{% if view.previous %}<a href="/s/{{ source.name|u }}/call/{{ view.previous }}">← turn {{ view.number - 1 }}</a>{% else %}<span class="muted">first turn</span>{% endif %}
{% if view.following %}<a href="/s/{{ source.name|u }}/call/{{ view.following }}">turn {{ view.number + 1 }} →</a>{% else %}<span class="muted">last turn</span>{% endif %}
</nav>
<h1>Turn {{ view.number }}{% if c.purpose %}: {{ c.purpose }}{% endif %} {{ flags(view.flags) }}</h1>
<table class="facts">
<tr><th>Role</th><td>{{ c.role or "—" }}</td><th>Time</th><td>{{ c.at|when }}, {{ "%.2f"|format(c.seconds) }}s</td></tr>
<tr><th>Model asked</th><td>{{ c.model }}</td><th>Answered by</th><td>{{ c.answered_by or "—" }}</td></tr>
<tr><th>Tokens</th><td>{{ c.tokens_in|num }} in · {{ c.tokens_out|num }} out</td><th>Prompt reuse (server)</th><td>{{ view.reused or "not reported" }}</td></tr>
<tr><th>Estimate</th><td>{{ c.estimate|num }} tokens (factor {{ c.factor }})</td><th>Route</th><td>{{ c.route or "—" }}, HTTP {{ c.status or "—" }}</td></tr>
<tr><th>Outcome</th><td{% if c.outcome != "answered" %} class="bad"{% endif %}>{{ c.outcome }}</td><th>Fitting</th><td>{% if c.trim %}{{ c.trim|pretty }}{% else %}nothing cut{% endif %}</td></tr>
</table>
{% if c.error %}<pre class="bad">{{ c.error }}</pre>{% endif %}
<h2>The reply</h2>
{% if view.reply.reasoning %}
<details open class="reasoning"><summary>Reasoning ({{ view.reply.reasoning|length|num }} characters)</summary><pre class="text">{{ view.reply.reasoning }}</pre></details>
{% endif %}
{% if view.reply.text %}<pre class="text">{{ view.reply.text }}</pre>{% endif %}
{% for tool, args in view.reply.calls %}
<div class="toolcall"><span class="tag">tool call</span> <b>{{ tool }}</b><pre>{{ args|pretty }}</pre></div>
{% endfor %}
{% if not view.reply.text and not view.reply.calls and not view.reply.reasoning %}<p class="muted">No reply recorded.</p>{% endif %}
{% if view.reply.finish %}<p class="meta">Finish reason: {{ view.reply.finish }}</p>{% endif %}
<h2>The request</h2>
{% if view.settings %}
<table class="facts">{% for k, v in view.settings.items() %}<tr><th>{{ k }}</th><td><code>{{ v|pretty }}</code></td></tr>{% endfor %}</table>
{% endif %}
{% if view.tools %}
<details><summary>{{ view.tools|length }} tools offered{% if view.tools_changed %} <span class="flag">changed since the turn before</span>{% endif %}</summary>
<p>{{ view.tools|join(", ") }}</p></details>
{% endif %}
{% if view.compared %}
<p class="note">Messages marked <span class="badge new">new</span> weren't in the turn before. <span class="badge change">first change</span> marks where this request first differs from the last one: prompt reuse can't go past it. The server reused {{ view.reused or "an unreported number of" }} tokens.</p>
{% endif %}
{% if view.match_note %}<p class="note">{{ view.match_note }}</p>{% endif %}
{% for m in view.messages %}
<section class="msg role-{{ m.role }}{% if m.first_change %} first-change{% endif %}" id="m{{ m.index }}">
<div class="msg-head">
<span class="role">{{ m.role }}</span>{% if m.index >= 0 %} <span class="muted">#{{ m.index }}</span>{% endif %}
{% if m.new %}<span class="badge new">new</span>{% endif %}
{% if m.first_change %}<span class="badge change">first change</span>{% endif %}
{% if m.tool_call_id %}<span class="muted">result of {{ m.tool_call_id }}</span>{% endif %}
<span class="muted">{{ m.text|length|num }} characters</span>
</div>
{% if m.role == "system" %}
<details><summary>System prompt</summary><pre class="text">{{ m.text }}</pre></details>
{% elif m.text %}<pre class="text">{{ m.text }}</pre>{% endif %}
{% for id, tool, args in m.tool_calls %}
<div class="toolcall"><span class="tag">tool call</span> <b>{{ tool }}</b> <span class="muted">{{ id }}</span><pre>{{ args|pretty }}</pre></div>
{% endfor %}
{% if m.events %}
<ul class="from">
{% for s in m.events %}<li>from {{ event_link(source, s.id) }} {{ s.type|replace("_", " ") }} by {{ s.actor }}: {% for t in s.tags %}<span class="tag{% if t != 'unchanged' %} warn{% endif %}">{{ t }}</span>{% endfor %}</li>{% endfor %}
</ul>
{% endif %}
</section>
{% endfor %}
{% if view.unmatched %}
<h3>Events sent{% if view.match_note %}, not matched to messages{% else %} but replaced by a compaction{% endif %}</h3>
<ul class="from">
{% for s in view.unmatched %}<li>{{ event_link(source, s.id) }} {{ s.type|replace("_", " ") }} by {{ s.actor }}: {% for t in s.tags %}<span class="tag{% if t != 'unchanged' %} warn{% endif %}">{{ t }}</span>{% endfor %}
<div class="text">{{ summary(s.type, s.payload if s.payload is mapping else {})|clip(300) }}</div></li>{% endfor %}
</ul>
{% endif %}
{% if view.compacted %}
<h3>Replaced by compaction</h3>
<p>{{ view.compacted|length }} events of the log stood behind a compaction in this request:
{% for e, comp in view.compacted %}{{ event_link(source, e) }}{% if comp %} → {{ event_link(source, comp) }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}</p>
{% endif %}
{% if view.dropped %}
<h3>Dropped by fitting</h3>
<ul class="from">
{% for id, e in view.dropped %}<li>{{ event_link(source, id) }}{% if e %} {{ e.type|replace("_", " ") }} by {{ e.actor }}: {{ summary(e.type, e.payload)|clip(200) }}{% else %} <span class="muted">(not in this store)</span>{% endif %}</li>{% endfor %}
</ul>
{% endif %}
<h2>Raw</h2>
<details><summary>Request JSON, exactly as sent ({{ view.raw_request|length|num }} characters)</summary><pre>{{ view.raw_request or "(not recorded)" }}</pre></details>
<details><summary>Response JSON, as received ({{ view.raw_response|length|num }} characters)</summary><pre>{{ view.raw_response or "(not recorded)" }}</pre></details>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% from "macros.html" import src_link, item_href %}
{% block title %}Event {{ event.id }}{% endblock %}
{% block crumbs %} › {{ src_link(source) }} › <a href="{{ item_href(source, event.work_item) }}">{{ event.work_item or "agent" }}</a> › event {{ event.id }}{% endblock %}
{% block main %}
<h1>Event #{{ event.id }}: {{ event.type|replace("_", " ") }}</h1>
<p class="meta">{{ event.ts|when }} · by {{ event.actor }} · work item {{ event.work_item or "(agent-level)" }}</p>
<pre class="text">{{ summary(event.type, event.payload) }}</pre>
<details><summary>Payload</summary><pre>{{ event.payload|pretty }}</pre></details>
{% endblock %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% from "macros.html" import src_link, run_node, turn_row, step, event_link %}
{% set title = view.item.title if view.item else "Agent-level log" %}
{% block title %}{{ title }}{% endblock %}
{% block crumbs %} › {{ src_link(source) }} › {{ view.item.id if view.item else "agent" }}{% endblock %}
{% block main %}
<h1>{{ title }}</h1>
{% if view.item %}
<p class="meta">{{ view.item.id }} · {{ view.item.status }}{% if view.item.outcome %} ({{ view.item.outcome }}){% endif %}
· position {{ view.item.position }} · opened {{ view.item.opened_at|when }}{% if view.item.closed_at %} · closed {{ view.item.closed_at|when }}{% endif %}</p>
<details><summary>Item data</summary><pre>{{ view.item.data|pretty }}</pre></details>
{% endif %}
{% if view.record_note %}<p class="note">{{ view.record_note }}</p>
{% elif view.before_record %}<p class="note">Recording began {{ view.started_at|when }}: steps before then have no requests.</p>{% endif %}
<h2>Steps</h2>
<div class="timeline">
{% for kind, obj in view.timeline %}
{% if kind == "event" %}{{ step(source, obj) }}
{% elif kind == "run" %}{{ run_node(source, obj) }}
{% else %}{{ turn_row(source, obj) }}{% endif %}
{% else %}
<p class="muted">Nothing recorded yet.</p>
{% endfor %}
</div>
<h2>The log</h2>
<details{% if view.record_note %} open{% endif %}>
<summary>All {{ view.log|length }} events, as stored</summary>
<table class="log">
{% for e in view.log %}
<tr><td>{{ event_link(source, e.id) }}</td><td>{{ e.ts|when }}</td><td>{{ e.actor }}</td><td>{{ e.type }}</td><td class="text">{{ summary(e.type, e.payload)|clip(200) }}</td></tr>
{% endfor %}
</table>
</details>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% from "macros.html" import item_href %}
{% block title %}{{ source.name }}{% endblock %}
{% block crumbs %} › {{ source.name }}{% endblock %}
{% block main %}
<h1>{{ source.name }}</h1>
<p class="muted path">{{ source.state_db }}</p>
<p><a href="{{ item_href(source, none) }}">Agent-level log</a>: what happened outside any work item.</p>
<table>
<thead><tr><th>Work item</th><th>Title</th><th>Status</th><th>Position</th><th>Opened</th><th>Updated</th></tr></thead>
<tbody>
{% for item in items %}
<tr{% if item.id == active %} class="active"{% endif %}>
<td><a href="{{ item_href(source, item.id) }}">{{ item.id }}</a>{% if item.id == active %} <span class="tag">active</span>{% endif %}</td>
<td>{{ item.title }}</td>
<td>{{ item.status }}{% if item.outcome %} ({{ item.outcome }}){% endif %}</td>
<td>{{ item.position }}</td>
<td>{{ item.opened_at|when }}</td>
<td>{{ item.updated_at|when }}</td>
</tr>
{% else %}
<tr><td colspan="6" class="muted">No work items.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,48 @@
{% macro src_link(source) %}<a href="/s/{{ source.name|u }}/">{{ source.name }}</a>{% endmacro %}
{% macro item_href(source, item_id) %}/s/{{ source.name|u }}/item/{{ (item_id if item_id is not none else AGENT_LEVEL)|u }}{% endmacro %}
{% macro event_link(source, id) %}{% if id > 0 %}<a href="/s/{{ source.name|u }}/event/{{ id }}">#{{ id }}</a>{% else %}<span class="muted">(no id)</span>{% endif %}{% endmacro %}
{% macro flags(names) %}{% for f in names %}<span class="flag">{{ f }}</span>{% endfor %}{% endmacro %}
{% macro turn_row(source, t) %}
<div class="turn{% if t.flags %} flagged{% endif %}">
<a class="turn-no" href="/s/{{ source.name|u }}/call/{{ t.call.id }}">turn {{ t.number }}</a>
<span class="meta">
{% if t.call.purpose %}<span class="tag">{{ t.call.purpose }}</span>{% endif %}
{{ t.call.role or "" }} · {{ t.call.model }}{% if t.call.answered_by and t.call.answered_by != t.call.model %} → {{ t.call.answered_by }}{% endif %}
· {{ "%.1f"|format(t.call.seconds) }}s · {{ t.call.tokens_in|num }} in / {{ t.call.tokens_out|num }} out
{% if t.reused %} · reused {{ t.reused }}{% endif %}
{% if t.call.outcome != "answered" %} · <span class="bad">{{ t.call.outcome }}</span>{% endif %}
</span>
{{ flags(t.flags) }}
{% for told in t.told %}<div class="told">orchestrator: {{ told|clip(160) }}</div>{% endfor %}
<div class="reply">{{ t.reply|reply_line|clip(240) }}</div>
</div>
{% endmacro %}
{% macro run_node(source, node) %}
<details class="run" open>
<summary>
<span class="tag">{{ node.run.kind }}</span> {{ node.run.role }}
· {{ node.run.outcome or "running" }}
· {{ node.run.started_at|when }} → {{ node.run.ended_at|when }}
· {{ node.turns|length }} turns
{% if node.run.note %}<span class="muted"> · {{ node.run.note|clip(160) }}</span>{% endif %}
</summary>
{% for c in node.circles %}
<p class="circle">Going in circles: {{ c.kind }} at turns {% for i in c.turns %}{{ i + 1 }}{% if not loop.last %}, {% endif %}{% endfor %}: {{ c.detail }}</p>
{% endfor %}
{% for kind, obj in node.entries %}
{% if kind == "turn" %}{{ turn_row(source, obj) }}{% elif kind == "event" %}{{ step(source, obj) }}{% else %}{{ run_node(source, obj) }}{% endif %}
{% endfor %}
</details>
{% endmacro %}
{% macro step(source, e) %}
<div class="step step-{{ e.type }}">
<span class="meta">{{ event_link(source, e.id) }} {{ e.ts|when }} · {{ e.actor }} · <span class="tag">{{ e.type|replace("_", " ") }}</span></span>
<div class="text">{{ summary(e.type, e.payload)|clip(600) }}</div>
</div>
{% endmacro %}
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block main %}
<h1>{{ title }}</h1>
<p class="note">{{ detail }}</p>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% from "macros.html" import src_link %}
{% block title %}Sources{% endblock %}
{% block main %}
<h1>Agents and recorded runs</h1>
{% if not rows %}<p class="muted">No sources. Start with <code>--source NAME=STATE_DB</code> or <code>--runs DIR</code>.</p>{% endif %}
<table>
<thead><tr><th>Source</th><th>Kind</th><th>Work items</th><th>Call record</th><th>State store</th></tr></thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ src_link(r.source) }}</td>
<td>{{ r.source.kind }}</td>
<td>{% if r.count is not none %}{{ r.count }}{% else %}<span class="bad">{{ r.note }}</span>{% endif %}</td>
<td>{{ "yes" if r.record else "none" }}</td>
<td class="path">{{ r.source.state_db }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+543
View File
@@ -0,0 +1,543 @@
"""What the pages show, built from the two stores (X6): an item's
walkthrough (its steps, runs and turns) and one turn read against the log it
was made from. Pure reading: nothing here writes."""
import json
from dataclasses import dataclass, field
from typing import Any
from nisse_core.log.canonical import StoredEvent, from_payload
from nisse_core.loop.circles import Circle, Turn, find_circles
from nisse_core.models import calls
from nisse_core.models.render import OpenAIChatRenderer
from nisse_ui.sources import Event, Item, StateReader
# The log events shown as steps between runs: what people and the
# orchestrator said and decided. A model's own turns are shown as turns.
STEP_TYPES = {
"user_message",
"fact",
"gate_opened",
"plan_submitted",
"delegation_started",
"delegation_completed",
"compaction",
}
_TURN_TYPES = {"assistant_text", "tool_call", "tool_result", "action_verdict"}
# --- a model's reply ------------------------------------------------------------
@dataclass(frozen=True)
class Reply:
text: str = ""
reasoning: str = ""
calls: tuple[tuple[str, str], ...] = () # (tool, arguments as sent)
finish: str | None = None
def _text(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for part in content:
if isinstance(part, dict) and isinstance(part.get("text"), str):
parts.append(part["text"])
else:
parts.append(json.dumps(part, ensure_ascii=False))
return "\n".join(parts)
return json.dumps(content, ensure_ascii=False)
def _arguments(value: Any) -> str:
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def reply_of(response: Any) -> Reply:
"""The reply in a raw response, from either route: the OpenAI-shaped chat
route or Anthropic's messages route. Anything else is no reply."""
if not isinstance(response, dict):
return Reply()
choices = response.get("choices")
if isinstance(choices, list) and choices and isinstance(choices[0], dict):
message = choices[0].get("message") or {}
reasoning = message.get("reasoning_content") or message.get("reasoning") or ""
tool_calls = []
for call in message.get("tool_calls") or []:
function = (call.get("function") or {}) if isinstance(call, dict) else {}
tool_calls.append(
(str(function.get("name", "?")), _arguments(function.get("arguments")))
)
return Reply(
text=_text(message.get("content")),
reasoning=_text(reasoning),
calls=tuple(tool_calls),
finish=choices[0].get("finish_reason"),
)
blocks = response.get("content")
if isinstance(blocks, list):
texts, thinking, tool_calls = [], [], []
for block in blocks:
if not isinstance(block, dict):
continue
match block.get("type"):
case "text":
texts.append(str(block.get("text", "")))
case "thinking":
thinking.append(str(block.get("thinking", "")))
case "tool_use":
tool_calls.append(
(str(block.get("name", "?")), _arguments(block.get("input")))
)
return Reply(
text="\n".join(texts),
reasoning="\n".join(thinking),
calls=tuple(tool_calls),
finish=response.get("stop_reason"),
)
return Reply()
# --- an item's walkthrough --------------------------------------------------------
@dataclass
class TurnRow:
"""One call, as a line of the walkthrough."""
call: dict[str, Any]
number: int # within its run, from 1
reply: Reply
told: tuple[str, ...] # corrections given since the turn before
flags: list[str] = field(default_factory=list)
@property
def reused(self) -> str | None:
return reuse(self.call)
@dataclass
class RunNode:
run: dict[str, Any]
entries: list[tuple[str, Any]] = field(default_factory=list) # turns, runs
turns: list[TurnRow] = field(default_factory=list)
circles: list[Circle] = field(default_factory=list)
@dataclass
class Walkthrough:
item: Item | None # None: the agent-level log
timeline: list[
tuple[str, Any]
] # ("event", Event) | ("run", RunNode) | ("turn", TurnRow)
log: list[Event]
record_note: str | None # why there are no requests, when there aren't
started_at: str | None
before_record: bool # some of this work happened before recording began
def reuse(call: dict[str, Any]) -> str | None:
"""Prompt reuse as the server counted it: cached tokens of the prompt."""
cached, fresh = call.get("cache_n"), call.get("prompt_n")
if isinstance(cached, int) and isinstance(fresh, int):
return f"{cached:,} of {cached + fresh:,}"
return None
def _corrections(
reader: calls.CallReader, call: dict[str, Any], before: set[int]
) -> tuple[str, ...]:
"""The orchestrator's statements first sent in this call: a correction
is appended to the log each time it's given, so it is new each time."""
told = []
for event_id, actor, kind, digest in call["sent"]:
if kind == "fact" and actor == "orchestrator" and event_id not in before:
try:
told.append(str(reader.blob(digest).get("text", "")))
except (KeyError, AttributeError):
continue
return tuple(told)
def _response(reader: calls.CallReader, call: dict[str, Any]) -> Any:
try:
return reader.response(call)
except KeyError:
return None
def walkthrough(
state: StateReader,
reader: calls.CallReader | None,
item_id: str | None,
*,
record_note: str | None = None,
) -> Walkthrough:
item = state.item(item_id) if item_id is not None else None
log = state.events(item_id)
steps = [e for e in log if e.type in STEP_TYPES or e.type not in _TURN_TYPES]
timeline: list[tuple[str, str, Any]] = []
started_at = None
if reader is None:
record_note = record_note or "No call record: requests weren't recorded."
else:
started_at = reader.started_at
nodes = {
r["id"]: RunNode(r) for r in reader.runs() if r["work_item"] == item_id
}
seen: dict[str | None, set[int]] = {}
numbers: dict[str | None, int] = {}
loose: list[TurnRow] = []
for call in reader.calls_for(item_id):
run = call["run"] if call["run"] in nodes else None
before = seen.setdefault(run, set())
numbers[run] = numbers.get(run, 0) + 1
row = TurnRow(
call=call,
number=numbers[run],
reply=reply_of(_response(reader, call)),
told=_corrections(reader, call, before),
)
before.update(entry[0] for entry in call["sent"])
if run is None:
loose.append(row)
else:
nodes[run].turns.append(row)
# A step taken while a run was going on (a correction, a fact) is
# shown inside the innermost run it happened during.
inside: dict[str, list[tuple[str, str, Any]]] = {}
for e in steps:
during = [n for n in nodes.values() if _during(n.run, e.ts)]
if during:
innermost = max(during, key=lambda n: n.run["started_at"])
inside.setdefault(innermost.run["id"], []).append(("event", e.ts, e))
else:
timeline.append(("event", e.ts, e))
steps = []
for node in nodes.values():
node.circles = find_circles(
[Turn(t.reply.text, t.reply.calls, t.told) for t in node.turns]
)
for circle in node.circles:
for i in circle.turns:
node.turns[i].flags.append(circle.kind)
children = [n for n in nodes.values() if n.run["parent"] == node.run["id"]]
entries = [("turn", t.call["at"], t) for t in node.turns]
entries += [("run", c.run["started_at"], c) for c in children]
entries += inside.get(node.run["id"], [])
node.entries = [(kind, obj) for kind, _, obj in sorted(entries, key=_when)]
for node in nodes.values():
if node.run["parent"] not in nodes:
timeline.append(("run", node.run["started_at"], node))
timeline += [("turn", t.call["at"], t) for t in loose]
timeline += [("event", e.ts, e) for e in steps]
ordered = [(kind, obj) for kind, _, obj in sorted(timeline, key=_when)]
before_record = reader is None or (
started_at is not None and bool(log) and log[0].ts < started_at
)
return Walkthrough(item, ordered, log, record_note, started_at, before_record)
def _during(run: dict[str, Any], ts: str) -> bool:
ended = run.get("ended_at")
return run["started_at"] <= ts and (ended is None or ts <= ended)
def _when(entry: tuple[str, str, Any]) -> str:
return entry[1] or ""
# --- one turn, read against its log -----------------------------------------------
@dataclass
class SentEvent:
id: int
actor: str
type: str
payload: Any
tags: list[str]
log: Event | None
@dataclass
class Message:
index: int
role: str
text: str
tool_calls: list[tuple[str, str, str]] # (id, tool, arguments)
tool_call_id: str | None
events: list[SentEvent] = field(default_factory=list)
new: bool = False # not in the turn before
first_change: bool = False # where this request first differs from the last
@dataclass
class TurnView:
call: dict[str, Any]
number: int
run: dict[str, Any] | None
previous: int | None
following: int | None
request: dict[str, Any] | None
response: Any
reply: Reply
messages: list[Message]
unmatched: list[SentEvent] # sent events not matched to a message
match_note: str | None
dropped: list[tuple[int, Event | None]]
compacted: list[tuple[int, int | None]]
tools: list[str]
tools_changed: bool
settings: dict[str, Any] # the request's other fields: tool_choice, limits
compared: bool # whether there was a turn before to compare with
flags: list[str]
raw_request: str
raw_response: str
@property
def reused(self) -> str | None:
return reuse(self.call)
def _fields(call: dict[str, Any] | None) -> dict[str, Any]:
if not call or not call.get("request"):
return {}
try:
return dict(json.loads(call["request"])["fields"])
except (ValueError, KeyError, TypeError):
return {}
def _tags(
entry: list[Any], payload: Any, log: Event | None, call: dict[str, Any]
) -> list[str]:
event_id, _, kind, _ = entry
tags: list[str] = []
cut = call["shortened"].get(str(event_id))
if event_id == 0:
tags.append("made for this call, not in the log")
elif kind == "compaction" and isinstance(payload, dict):
first, last = (payload.get("replaces") or [None, None])[:2]
tags.append(f"compaction of events {first}–{last}")
elif log is None:
tags.append("not in this state store")
elif log.payload == payload and log.type == kind:
tags.append("unchanged")
else:
# A role's view says another's work as a labelled statement (L6):
# a tool step narrated, a fact or someone's message labelled.
source = payload.get("source", "") if isinstance(payload, dict) else ""
viewed = kind == "user_message" and str(source).startswith("view:")
was = f"{log.type.replace('_', ' ')} by {log.actor}"
if viewed and log.type in ("tool_call", "tool_result"):
tags.append(f"narrated from {was}")
elif viewed:
tags.append(
"labelled for this role"
+ ("" if log.type == "user_message" else f" (a {was})")
)
elif log.type != kind:
tags.append(f"rewritten from {was}")
elif cut is None:
tags.append("changed for this call")
if cut is not None:
tags.append(f"shortened by {int(cut):,} characters")
return tags
def turn(state: StateReader, reader: calls.CallReader, call_id: int) -> TurnView | None:
call = reader.call(call_id)
if call is None:
return None
run = next((r for r in reader.runs() if r["id"] == call["run"]), None)
siblings = (
reader.calls(run=call["run"])
if call["run"]
else [c for c in reader.calls_for(call["work_item"]) if not c["run"]]
)
at = next(i for i, c in enumerate(siblings) if c["id"] == call_id)
before = siblings[at - 1] if at > 0 else None
after = siblings[at + 1] if at + 1 < len(siblings) else None
try:
request = reader.request(call)
except KeyError:
request = None
response = _response(reader, call)
reply = reply_of(response)
logged = state.events_by_id([entry[0] for entry in call["sent"]])
dropped = state.events_by_id(call["dropped"])
sent: list[SentEvent] = []
for entry in call["sent"]:
try:
payload = reader.blob(entry[3])
except KeyError:
payload = None
log = logged.get(entry[0])
sent.append(
SentEvent(
entry[0],
entry[1],
entry[2],
payload,
_tags(entry, payload, log, call),
log,
)
)
raw_messages = (request or {}).get("messages")
messages: list[Message] = []
if isinstance(raw_messages, list):
for i, m in enumerate(raw_messages):
m = m if isinstance(m, dict) else {"role": "?", "content": m}
calls_made = []
for c in m.get("tool_calls") or []:
function = (c.get("function") or {}) if isinstance(c, dict) else {}
calls_made.append(
(
str(c.get("id", "")) if isinstance(c, dict) else "",
str(function.get("name", "?")),
_arguments(function.get("arguments")),
)
)
if isinstance(m.get("content"), list): # Anthropic content blocks
for block in m["content"]:
if isinstance(block, dict) and block.get("type") == "tool_use":
calls_made.append(
(
str(block.get("id", "")),
str(block.get("name", "?")),
_arguments(block.get("input")),
)
)
messages.append(
Message(
index=i,
role=str(m.get("role", "?")),
text=_text(m.get("content")),
tool_calls=calls_made,
tool_call_id=m.get("tool_call_id"),
)
)
unmatched, note = _match(call, request, sent, messages)
mine, theirs = _fields(call), _fields(before)
compared = bool(theirs)
if compared:
old = theirs.get("messages") or []
new = mine.get("messages") or []
old_set = set(old)
for message, digest in zip(messages, new, strict=False):
message.new = digest not in old_set
change = next(
(i for i, (a, b) in enumerate(zip(old, new, strict=False)) if a != b),
min(len(old), len(new)),
)
if change < len(messages):
messages[change].first_change = True
tools_raw = (request or {}).get("tools") or []
tools = []
for t in tools_raw if isinstance(tools_raw, list) else []:
if isinstance(t, dict):
tools.append(
str((t.get("function") or {}).get("name") or t.get("name", "?"))
)
settings = {
k: v
for k, v in (request or {}).items()
if k not in ("messages", "tools", "system")
}
if isinstance(request, dict) and "system" in request:
messages.insert(
0,
Message(
index=-1,
role="system",
text=_text(request["system"]),
tool_calls=[],
tool_call_id=None,
),
)
flags = []
if run is not None:
node_turns = [
Turn(r.text, r.calls, ())
for r in (reply_of(_response(reader, c)) for c in siblings)
]
flags = sorted({c.kind for c in find_circles(node_turns) if at in c.turns})
return TurnView(
call=call,
number=at + 1,
run=run,
previous=before["id"] if before else None,
following=after["id"] if after else None,
request=request,
response=response,
reply=reply,
messages=messages,
unmatched=unmatched,
match_note=note,
dropped=[(i, dropped.get(i)) for i in call["dropped"]],
compacted=[(int(k), v) for k, v in call["compacted"].items()],
tools=tools,
tools_changed=compared and mine.get("tools") != theirs.get("tools"),
settings=settings,
compared=compared,
flags=flags,
raw_request=json.dumps(request, indent=2, ensure_ascii=False)
if request is not None
else "",
raw_response=json.dumps(response, indent=2, ensure_ascii=False)
if response is not None
else "",
)
def _match(
call: dict[str, Any],
request: dict[str, Any] | None,
sent: list[SentEvent],
messages: list[Message],
) -> tuple[list[SentEvent], str | None]:
"""Each sent event beside the message it went into, found with the same
renderer that built the request; when that can't be done exactly, the
events are listed apart, with why."""
if not sent:
return [], None
if request is None:
return sent, "The request wasn't recorded (the call failed before it)."
if call.get("route") != "chat":
return sent, "Messages-route requests are shown beside their events, unmatched."
try:
events = [
StoredEvent(id=s.id, ts="", event=from_payload(s.type, s.payload))
for s in sent
]
except (ValueError, TypeError):
return sent, "Some events didn't read as this version's event types."
first = messages[0] if messages else None
system = first.text if first is not None and first.role == "system" else None
renderer = OpenAIChatRenderer()
expected = renderer.render(events, None, "m", system)["messages"]
if len(expected) != len(messages):
return sent, (
f"Rendering the sent events gives {len(expected)} messages, the request "
f"has {len(messages)}; shown apart."
)
unmatched = []
for s, index in zip(sent, renderer.message_indexes(events, system), strict=True):
if index is None:
unmatched.append(s)
else:
messages[index].events.append(s)
return unmatched, None
+102
View File
@@ -0,0 +1,102 @@
"""The page and the agents are released together (X6): these fail in the PR
that would break the page, not in the operator's browser."""
import threading
from pathlib import Path
import httpx
import pytest
from ui_fixtures import recorded, store_at
from nisse_core.models import calls
from nisse_core.state.store import StateStore as CoreStore
from nisse_dev.state import StateStore as DevStore
from nisse_review.state import StateStore as ReviewStore
from nisse_ui import main as ui_main
from nisse_ui.app import App
from nisse_ui.sources import KNOWN_SCHEMAS, discover
@pytest.mark.parametrize("store", [CoreStore, DevStore, ReviewStore])
def test_every_agents_schema_is_one_the_page_knows(store: type[CoreStore]) -> None:
# An agent that moves its schema on must teach the page too.
assert store.SCHEMA_VERSION in KNOWN_SCHEMAS
@pytest.mark.parametrize("store_class", [DevStore, ReviewStore])
def test_each_agents_own_store_opens(
tmp_path: Path, store_class: type[CoreStore]
) -> None:
store = store_at(tmp_path, store_class)
store.open_work_item("i1", title="Agent item", position="NEW", cause="t")
store.close()
app = App(discover([("a", tmp_path / "state.db")], []))
assert app.handle("/s/a/").status == 200
assert "Agent item" in app.handle("/s/a/item/i1").body.decode()
def test_runs_the_benchmark_kept_are_sources(tmp_path: Path) -> None:
# As bench --keep-runs leaves them: <dir>/<task>-<id>/state.db + record.
for name in ("fix-bug-1a2b3c4d", "trace-bug-5e6f7a8b"):
_, store = recorded(tmp_path / "kept" / name)
store.close()
(tmp_path / "kept" / "not-a-run").mkdir()
sources = discover([], [tmp_path / "kept"])
assert sorted(s.name for s in sources) == [
"kept.fix-bug-1a2b3c4d",
"kept.trace-bug-5e6f7a8b",
]
assert all(s.kind == "run" and s.calls_db.is_file() for s in sources)
assert sources[0].calls_db == calls.path_for(sources[0].state_db)
def test_source_names_are_safe_and_unique(tmp_path: Path) -> None:
sources = discover(
[("dev agent/1", tmp_path / "a"), ("dev agent/1", tmp_path / "b")], []
)
assert [s.name for s in sources] == ["dev-agent-1", "dev-agent-1-2"]
def test_the_server_listens_on_this_machine_and_sends_the_headers(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a")
store.close()
server = ui_main.ThreadingHTTPServer(
("127.0.0.1", 0), ui_main.handler_for(App([source]))
)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
port = server.server_address[1]
response = httpx.get(f"http://127.0.0.1:{port}/s/agent/item/w1")
assert response.status_code == 200
assert "Fix the parser" in response.text
assert "default-src 'none'" in response.headers["content-security-policy"]
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["server"].startswith("nisse-ui")
finally:
server.shutdown()
server.server_close()
def test_the_command_line_defaults_to_this_machine_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert ui_main._source("dev=/var/lib/nisse-dev/state.db") == (
"dev",
Path("/var/lib/nisse-dev/state.db"),
)
with pytest.raises(Exception, match="NAME=STATE_DB"):
ui_main._source("no-equals")
class Bound(Exception):
pass
def server(address: tuple[str, int], handler: object) -> None:
raise Bound(address)
monkeypatch.setattr(ui_main, "ThreadingHTTPServer", server)
with pytest.raises(Bound) as bound:
ui_main.main([])
assert bound.value.args[0] == ("127.0.0.1", 8765)
+316
View File
@@ -0,0 +1,316 @@
"""The walkthrough (X6, M6.6b): an item's steps, its runs' turns, and each
turn's request read against the log it was made from."""
import hashlib
import sqlite3
from pathlib import Path
import pytest
from ui_fixtures import TOKEN, circling, recorded
from nisse_core.log.canonical import UserMessage
from nisse_core.models import calls
from nisse_ui import walk
from nisse_ui.app import App
from nisse_ui.sources import Source, StateReader, call_record
@pytest.fixture
def agent(tmp_path: Path) -> Source:
source, store = recorded(tmp_path / "agent")
store.close()
return source
def page(app: App, path: str, status: int = 200) -> str:
response = app.handle(path)
assert response.status == status, response.body.decode()[:500]
return response.body.decode()
def view(source: Source, call_id: int) -> walk.TurnView:
with StateReader(source.state_db) as state, call_record(source) as reader:
assert reader is not None
found = walk.turn(state, reader, call_id)
assert found is not None
return found
def steps(source: Source, item: str | None) -> walk.Walkthrough:
with StateReader(source.state_db) as state, call_record(source) as reader:
return walk.walkthrough(state, reader, item)
# --- the way in -------------------------------------------------------------------
def test_the_sources_and_an_agents_items_are_listed(agent: Source) -> None:
app = App([agent])
home = page(app, "/")
assert "/s/agent/" in home and ">1<" in home # one work item
items = page(app, "/s/agent/")
assert "Fix the parser" in items and "/s/agent/item/w1" in items
assert "/s/agent/item/-" in items # the agent-level log
# --- the walkthrough --------------------------------------------------------------
def test_an_items_steps_hold_its_runs_turns_and_what_the_orchestrator_said(
agent: Source,
) -> None:
found = steps(agent, "w1")
kinds = [kind for kind, _ in found.timeline]
assert kinds == ["event", "event", "run"] # the restatement, "go", the run
[run] = [obj for kind, obj in found.timeline if kind == "run"]
assert run.run["outcome"] == "wait"
assert [t.number for t in run.turns] == [1, 2, 3]
# The correction for the prose reply came during the run: it's shown
# inside it, between the turns it fell between.
inner = [kind for kind, _ in run.entries]
assert inner == ["turn", "turn", "event", "turn"]
assert any("prose" in told for told in run.turns[2].told)
assert run.turns[0].reply.calls == (("read_file", '{"path": "parser.py"}'),)
assert run.turns[1].reused == "280 of 340"
html = page(App([agent]), "/s/agent/item/w1")
assert "turn 3" in html and "reused 330 of 350" in html
def test_agent_level_calls_have_their_own_log(agent: Source) -> None:
found = steps(agent, None)
[run] = [obj for kind, obj in found.timeline if kind == "run"]
assert (run.run["kind"], run.run["role"]) == ("classification", "classifier")
assert "harmless" in page(App([agent]), "/s/agent/item/-")
def test_work_from_before_recording_has_its_steps_but_no_requests(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a")
# The agent restarts: a fresh, empty record; the log persists.
calls.CallRecorder(source.calls_db).close()
store.close()
found = steps(source, "w1")
assert found.before_record
# The restatement, "go" and the correction: steps, with no runs to hold them.
assert [kind for kind, _ in found.timeline] == ["event", "event", "event"]
html = page(App([source]), "/s/agent/item/w1")
assert "Recording began" in html
assert "All 8 events" in html # the log is all still there
def test_without_a_call_record_the_log_is_shown(tmp_path: Path) -> None:
source, store = recorded(tmp_path / "a")
store.close()
source.calls_db.unlink()
html = page(App([source]), "/s/agent/item/w1")
assert "No call record" in html and "All 8 events" in html
page(App([source]), "/s/agent/call/1", 404)
def test_a_run_going_in_circles_is_flagged(tmp_path: Path) -> None:
source = circling(tmp_path / "c")
found = steps(source, "pr-7")
[run] = [obj for kind, obj in found.timeline if kind == "run"]
[circle] = run.circles
assert (circle.kind, circle.turns) == ("repeated reply", (0, 1, 2, 3))
assert run.turns[0].flags == ["repeated reply"] and not run.turns[4].flags
assert "Going in circles: repeated reply at turns 1, 2, 3, 4" in page(
App([source]), "/s/circling/item/pr-7"
)
assert view(source, 2).flags == ["repeated reply"]
# --- one turn ---------------------------------------------------------------------
def test_each_message_is_shown_beside_the_log_events_it_came_from(
agent: Source,
) -> None:
turn = view(agent, 2)
assert turn.match_note is None and turn.unmatched == []
roles = [m.role for m in turn.messages]
assert roles == ["system", "user", "assistant", "tool"]
# The restatement and "go" fold into one user message.
assert [s.id for s in turn.messages[1].events] == [1, 2]
assert [s.id for s in turn.messages[2].events] == [3]
assert [s.id for s in turn.messages[3].events] == [4]
assert all(s.tags == ["unchanged"] for m in turn.messages for s in m.events)
assert calls.NOTICE in turn.messages[0].text
def test_what_changed_since_the_turn_before_is_marked(agent: Source) -> None:
first, second = view(agent, 1), view(agent, 2)
assert not first.compared and not any(m.new for m in first.messages)
assert second.compared
assert [m.new for m in second.messages] == [False, False, True, True]
assert [m.first_change for m in second.messages] == [False, False, True, False]
assert not second.tools_changed
assert (second.previous, second.following) == (1, 3)
def test_the_turn_page_shows_the_reply_reasoning_and_raw_json(agent: Source) -> None:
html = page(App([agent]), "/s/agent/call/1")
assert "First, read the parser." in html # the reasoning
assert "Request JSON, exactly as sent" in html
assert "local-backing" in html # the model that answered
assert "reused" not in html or "0 of 300" in html
def test_how_rendering_changed_an_event_is_named() -> None:
call = {"shortened": {"5": 1200}}
logged = walk.Event(5, "t", "tool_result", {"result": "long"}, "w", "driver")
sent = {"result": "lo"}
assert walk._tags([5, "driver", "tool_result", "h"], sent, logged, call) == [
"shortened by 1,200 characters"
]
told = walk.Event(6, "t", "tool_call", {"tool": "x"}, "w", "helper")
narrated = {"text": "x → ok", "source": "view:helper"}
assert walk._tags(
[6, "helper", "user_message", "h"], narrated, told, {"shortened": {}}
) == ["narrated from tool call by helper"]
fact = walk.Event(8, "t", "fact", {"text": "agreed"}, "w", "orchestrator")
as_said = {"text": "[orchestrator] agreed", "source": "view:orchestrator"}
assert walk._tags(
[8, "orchestrator", "user_message", "h"], as_said, fact, {"shortened": {}}
) == ["labelled for this role (a fact by orchestrator)"]
said = walk.Event(
7, "t", "user_message", {"text": "hi", "source": "m"}, "w", "operator"
)
labelled = {"text": "[operator, matrix] hi", "source": "view:operator"}
assert walk._tags(
[7, "operator", "user_message", "h"], labelled, said, {"shortened": {}}
) == ["labelled for this role"]
assert walk._tags(
[0, "harness", "fact", "h"], {"text": "x"}, None, {"shortened": {}}
) == ["made for this call, not in the log"]
assert walk._tags(
[9, "driver", "compaction", "h"], {"replaces": [2, 8]}, None, {"shortened": {}}
) == ["compaction of events 2–8"]
def test_a_reply_reads_from_either_route() -> None:
chat = {
"choices": [
{
"message": {
"content": "ok",
"reasoning_content": "hm",
"tool_calls": [{"function": {"name": "f", "arguments": "{}"}}],
},
"finish_reason": "tool_calls",
}
]
}
assert walk.reply_of(chat) == walk.Reply("ok", "hm", (("f", "{}"),), "tool_calls")
messages = {
"content": [
{"type": "thinking", "thinking": "hm"},
{"type": "text", "text": "ok"},
{"type": "tool_use", "name": "f", "input": {"a": 1}},
],
"stop_reason": "tool_use",
}
assert walk.reply_of(messages) == walk.Reply(
"ok", "hm", (("f", '{"a": 1}'),), "tool_use"
)
assert walk.reply_of("garbage") == walk.Reply()
# --- untrusted text and read-only -------------------------------------------------
def test_everything_shown_is_escaped_and_no_script_may_run(agent: Source) -> None:
app = App([agent])
for path in (
"/s/agent/item/w1",
"/s/agent/call/2",
"/s/agent/call/3",
"/s/agent/event/4",
):
response = app.handle(path)
html = response.body.decode()
assert "<script>alert" not in html
assert "&lt;script&gt;alert" in html
assert "&lt;/pre&gt;&lt;/details&gt;" in html # can't close the page's own
csp = response.headers["Content-Security-Policy"]
assert "default-src 'none'" in csp and "script-src" not in csp
assert TOKEN not in html
def _digests(root: Path) -> dict[str, str]:
"""Each file's content, except SQLite's shared-memory index, which
readers of a live WAL database take part in by design."""
return {
p.name: ""
if p.name.endswith("-shm")
else hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(root.iterdir())
if p.is_file()
}
def test_browsing_reads_only_and_leaves_no_lock_while_the_agent_writes(
tmp_path: Path,
) -> None:
source, store = recorded(tmp_path / "a") # the store stays open, as live
before = _digests(tmp_path / "a")
app = App([source])
for path in (
"/",
"/s/agent/",
"/s/agent/item/w1",
"/s/agent/item/-",
"/s/agent/call/1",
"/s/agent/call/2",
"/s/agent/event/1",
):
page(app, path)
# No file changed, and none was created: the closed call record has no
# -wal or -shm, and opening it made none.
assert _digests(tmp_path / "a") == before
assert not Path(f"{source.calls_db}-wal").exists()
# The agent can still write at once: nothing here holds a lock.
store.transcript("w1", "operator").append(UserMessage(text="more", source="m:2"))
store.close()
assert "more" in page(app, "/s/agent/item/w1")
# --- versions -----------------------------------------------------------------------
def test_a_store_of_an_unknown_version_is_named_not_guessed(agent: Source) -> None:
with sqlite3.connect(agent.state_db) as conn:
conn.execute("UPDATE meta SET value = '99' WHERE key = 'schema_version'")
html = page(App([agent]), "/s/agent/item/w1", 409)
assert "schema 99, not known to this page" in html
assert "schema 99" in page(App([agent]), "/")
def test_a_call_record_of_an_unknown_layout_leaves_the_log_readable(
agent: Source,
) -> None:
with sqlite3.connect(agent.calls_db) as conn:
conn.execute("UPDATE meta SET value = '2' WHERE key = 'layout_version'")
html = page(App([agent]), "/s/agent/item/w1")
assert "call record layout 2" in html and "All 8 events" in html
page(App([agent]), "/s/agent/call/1", 409)
def test_item_data_that_doesnt_parse_still_renders(agent: Source) -> None:
with sqlite3.connect(agent.state_db) as conn:
conn.execute("UPDATE work_items SET data = 'not json', position = 'NEW_STATE'")
html = page(App([agent]), "/s/agent/item/w1")
assert "not json" in html and "NEW_STATE" in html
assert "NEW_STATE" in page(App([agent]), "/s/agent/")
def test_unknown_paths_are_not_found(agent: Source) -> None:
app = App([agent])
page(app, "/s/nobody/", 404)
page(app, "/s/agent/item/nope", 404)
page(app, "/s/agent/call/999", 404)
page(app, "/s/agent/event/999", 404)
page(app, "/s/agent/call/x", 404)
page(app, "/etc/passwd", 404)
assert app.handle("/static/style.css").content_type.startswith("text/css")
+149
View File
@@ -0,0 +1,149 @@
"""Stores written by the agents' own code, for the page to read (X6): a real
state store and a real call record from a driver run against a fake
LiteLLM, so a change in nisse-core that breaks the page fails here."""
import io
import json
from pathlib import Path
from typing import Any
import httpx
from nisse_core.log.canonical import Fact, UserMessage
from nisse_core.loop.driver import Driver
from nisse_core.loop.tools import tool
from nisse_core.models import calls
from nisse_core.models.context import BudgetedClient
from nisse_core.models.llm import LiteLLMClient
from nisse_core.oplog import OpLog
from nisse_core.state.store import StateStore
from nisse_ui.sources import Source
TOKEN = "sk-secret-litellm-token"
HOSTILE = "<script>alert('x')</script></pre></details>"
READ = tool("read_file", "read a file", path="the file")
POST = tool("post_comment", "post a comment", body="the comment")
FINISH = tool("finish", "finish the work", summary="what was done")
def reply(
content: str = "",
*,
call: tuple[str, dict[str, Any]] | None = None,
reasoning: str = "",
timings: dict[str, int] | None = None,
) -> dict[str, Any]:
message: dict[str, Any] = {"role": "assistant", "content": content}
if reasoning:
message["reasoning_content"] = reasoning
if call:
message["tool_calls"] = [
{
"id": f"x{abs(hash(json.dumps(call)))}",
"type": "function",
"function": {"name": call[0], "arguments": json.dumps(call[1])},
}
]
data: dict[str, Any] = {
"model": "local-backing",
"choices": [{"message": message, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 120, "completion_tokens": 7},
}
if timings is not None:
data["timings"] = timings
return data
class Server:
"""A fake LiteLLM answering each request in turn."""
def __init__(self, *answers: dict[str, Any]) -> None:
self.answers = list(answers)
def __call__(self, request: httpx.Request) -> httpx.Response:
data = self.answers.pop(0) if self.answers else reply("ok")
return httpx.Response(200, json=data)
def client(server: Server, recorder: calls.CallRecorder) -> BudgetedClient:
inner = LiteLLMClient(
"https://litellm.test", TOKEN, transport=httpx.MockTransport(server)
)
return BudgetedClient(inner, lambda m: (32768, 1024), call_recorder=recorder)
def store_at(root: Path, store_class: type[StateStore] = StateStore) -> StateStore:
return store_class(root / "state.db", oplog=OpLog("test", io.StringIO()))
def recorded(root: Path) -> tuple[Source, StateStore]:
"""One work item, one driver run of three turns: a tool call whose
result is hostile text, a prose reply the driver corrects, then the
finish. And one agent-level call. The store stays open, as a live
agent's does."""
root.mkdir(parents=True, exist_ok=True)
recorder = calls.CallRecorder(calls.path_for(root / "state.db"))
store = store_at(root)
store.open_work_item("w1", title="Fix the parser", position="RUNNING", cause="t")
store.transcript("w1", "orchestrator").append(
Fact(text="The operator agreed to this restatement: fix the parser.")
)
store.transcript("w1", "operator").append(UserMessage(text="go", source="m:1"))
server = Server(
reply(
call=("read_file", {"path": "parser.py"}),
reasoning="First, read the parser.",
timings={"cache_n": 0, "prompt_n": 300},
),
reply(
"I believe the parser is fine now.",
timings={"cache_n": 280, "prompt_n": 60},
),
reply(
call=("finish", {"summary": "fixed"}),
timings={"cache_n": 330, "prompt_n": 20},
),
)
Driver(client(server, recorder), "local").run(
store.transcript("w1", "driver"),
system="You are the driver.",
tools=[READ, FINISH],
waiting=("finish",),
workspace=None,
handlers={"read_file": lambda args: (f"contents {HOSTILE}", True)},
)
store.transcript(None, "operator").append(UserMessage(text="hello", source="m:9"))
with calls.run(recorder, work_item=None, role="classifier", kind="classification"):
client(Server(reply("harmless")), recorder).complete(
store.transcript(None, "operator").read(), None, "local", system="Classify."
)
recorder.close()
return Source("agent", root / "state.db", "agent"), store
def circling(root: Path) -> Source:
"""A run that posts the same comment over and over, as Flash-Next did
on 2026-10-24, until the finish."""
root.mkdir(parents=True, exist_ok=True)
recorder = calls.CallRecorder(calls.path_for(root / "state.db"))
store = store_at(root)
store.open_work_item("pr-7", title="Review #7", position="RUNNING", cause="t")
store.transcript("pr-7", "operator").append(UserMessage(text="review", source="m"))
answers = [
reply(call=("post_comment", {"body": f"Duplicate, please disregard ({n})."}))
for n in range(4)
]
Driver(
client(Server(*answers, reply(call=("finish", {"summary": "x"}))), recorder),
"local",
).run(
store.transcript("pr-7", "driver"),
system="You review.",
tools=[POST, FINISH],
waiting=("finish",),
workspace=None,
handlers={"post_comment": lambda args: ("posted", True)},
)
recorder.close()
store.close()
return Source("circling", root / "state.db", "agent")
+3 -2
View File
@@ -1,5 +1,5 @@
[tool.uv.workspace]
members = ["nisse-core", "nisse-dev", "nisse-ops", "nisse-review"]
members = ["nisse-core", "nisse-dev", "nisse-ops", "nisse-review", "nisse-ui"]
[dependency-groups]
dev = [
@@ -14,6 +14,7 @@ testpaths = [
"nisse-dev/tests",
"nisse-ops/tests",
"nisse-review/tests",
"nisse-ui/tests",
"tests",
]
# The seed is a test project's own code, pushed to each run's repository.
@@ -33,4 +34,4 @@ extend-exclude = ["tests/integration/seed"] # a test project's own code
extend-select = ["I"]
[tool.ruff.lint.isort]
known-first-party = ["nisse_core", "nisse_dev", "nisse_ops", "nisse_review"]
known-first-party = ["nisse_core", "nisse_dev", "nisse_ops", "nisse_review", "nisse_ui"]
Generated
+141
View File
@@ -8,6 +8,7 @@ members = [
"nisse-dev",
"nisse-ops",
"nisse-review",
"nisse-ui",
]
[manifest.dependency-groups]
@@ -112,6 +113,131 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" },
{ url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" },
{ url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" },
{ url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" },
{ url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" },
{ url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" },
{ url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" },
{ url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" },
{ url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" },
{ url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" },
{ url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" },
{ url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
{ url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" },
{ url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" },
{ url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" },
{ url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" },
{ url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" },
{ url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" },
{ url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" },
{ url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" },
{ url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" },
{ url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" },
{ url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" },
{ url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" },
{ url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" },
{ url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" },
{ url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" },
{ url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" },
{ url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" },
{ url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" },
{ url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" },
{ url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" },
{ url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" },
{ url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" },
{ url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" },
{ url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" },
{ url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" },
{ url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" },
{ url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" },
{ url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" },
{ url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" },
{ url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" },
{ url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" },
{ url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" },
{ url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" },
{ url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" },
{ url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" },
{ url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" },
{ url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" },
{ url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" },
{ url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" },
{ url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" },
{ url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" },
{ url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" },
{ url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" },
{ url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" },
{ url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" },
{ url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" },
{ url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" },
{ url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" },
{ url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" },
{ url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" },
{ url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" },
{ url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" },
{ url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" },
{ url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" },
{ url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" },
{ url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" },
{ url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" },
{ url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" },
{ url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" },
{ url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" },
{ url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" },
{ url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" },
{ url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" },
{ url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" },
{ url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" },
{ url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" },
{ url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" },
{ url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" },
{ url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" },
{ url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" },
{ url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" },
]
[[package]]
name = "nisse-core"
version = "0.1.0"
@@ -166,6 +292,21 @@ dependencies = [
[package.metadata]
requires-dist = [{ name = "nisse-core", editable = "nisse-core" }]
[[package]]
name = "nisse-ui"
version = "0.1.0"
source = { editable = "nisse-ui" }
dependencies = [
{ name = "jinja2" },
{ name = "nisse-core" },
]
[package.metadata]
requires-dist = [
{ name = "jinja2", specifier = ">=3.1" },
{ name = "nisse-core", editable = "nisse-core" },
]
[[package]]
name = "packaging"
version = "26.3"