Compare commits

...
3 Commits
Author SHA1 Message Date
dongarandClaude Opus 5.5 a8e2023183 The status page's walkthrough (M6.6b)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m41s
CI / e2e (pull_request) Successful in 4m25s
A new package, nisse-ui: a read-only page for studying how the agents
work. For each work item it shows the steps (what people and the
orchestrator said, and each run), each run's turns, and each turn's
request read against the log it was made from:
- each message beside the events it came from, tagged unchanged,
  labelled, narrated, shortened (by how much) or a compaction, with what
  fitting dropped;
- what is new since the turn before, and where the request first
  differs (where prompt reuse had to stop), next to the server's count;
- the reply with its reasoning, and the raw JSON.

Runs going in circles are flagged by nisse_core.loop.circles (repeated
near-identical replies, one correction given again and again, repeated
apologies), a pure function M7.8 can reuse in Driver.run.

In nisse-core:
- render.py: message_indexes(), from the same code render() uses, so
  the page's matching can't drift from what was sent.
- calls.py: the record notes when recording began; the reader gains
  calls_for(item) and call(id).
- state/readonly.py: opens an agent's SQLite file without creating its
  -wal/-shm files (mode=ro alone does, once the writer has closed).

The page reads only through read-only connections, escapes everything,
and sends a Content Security Policy that allows no script. It listens on
127.0.0.1; until M6.6d puts it behind Authelia, reach it over an SSH
tunnel. Jinja2 is its one new dependency, for autoescaping.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-25 03:36:50 +00:00
dev-agent c1f3d9dafb Merge pull request 'The call record: every model call, for the operator's study (M6.6a)' (#179) from call-record into main
CI / checks (push) Successful in 3m21s
CI / e2e (push) Skipped
2026-10-24 13:10:31 +00:00
dongarandClaude Opus 5.5 dd060566b2 The call record: every model call, for the operator's study (M6.6a)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m54s
CI / e2e (pull_request) Successful in 4m20s
nisse-core records each model call of every agent into a file beside its
state store (<state>.calls.db), for the status page (L7, X6):

- the request body exactly as sent (never headers), the raw response with
  its reasoning, the model asked and the one that answered, time, tokens
  and llama-server's own prompt reuse (timings.cache_n);
- its work item, role, run and purpose: Driver.run and nisse-dev's
  delegation loop open runs, nested; compaction, expert consults, the
  classifier and the go-ahead reader label their calls;
- provenance, worked out in BudgetedClient by comparing the events at
  each stage: each event sent with its log id and content as sent, which
  a compaction replaced, which fitting shortened (by how much) and
  dropped.

Content is stored once by hash, compressed. It is diagnostic only: the
agent never reads it, a failed write is logged once and recording stops
while the call goes on, and the file is deleted when the agent starts,
so its layout never migrates; CallReader refuses a layout it doesn't
know. While recording, the system prompt ends with one sentence saying
so. `call_record: false` in either agent's YAML turns it off.

The benchmark records each run beside that run's own state store, and
`--keep-runs DIR` keeps both for the page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
2026-10-24 06:28:53 +00:00
51 changed files with 3890 additions and 57 deletions

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
]
+14 -1
View File
@@ -15,6 +15,7 @@ from typing import Any, Literal, Protocol
from nisse_core.log.canonical import AssistantText, Fact, ToolCall, ToolResult
from nisse_core.loop.tools import names
from nisse_core.models import calls
from nisse_core.models.compact import Compactor
from nisse_core.models.llm import LLMClient, LLMError
from nisse_core.state.cancel import Cancel, skip_calls
@@ -93,7 +94,19 @@ class Driver:
self._failure_limit = failure_limit
self._cancel = cancel or Cancel()
def run(
def run(self, transcript: Transcript, **options: Any) -> RunOutcome:
"""One run, recorded as one (L7): its calls are its turns. See
``_run`` for the options."""
with calls.run(
calls.recorder_of(self._client),
work_item=transcript.work_item,
role=transcript.actor,
) as recorded:
outcome = self._run(transcript, **options)
recorded.end(outcome.kind, outcome.note)
return outcome
def _run(
self,
transcript: Transcript,
*,
+14 -12
View File
@@ -32,6 +32,7 @@ from nisse_core.log.canonical import (
StoredEvent,
UserMessage,
)
from nisse_core.models import calls
from nisse_core.models.llm import LLMClient, LLMError, ModelRefused, RoleMap
from nisse_core.state.store import Transcript
@@ -156,18 +157,19 @@ class ExpertDesk:
# consult neither depends on nor leaks repo context. It cannot
# ride in the system prompt alone — the Messages API requires
# at least one non-system message.
produced = self._client.complete(
[
StoredEvent(
id=0,
ts="",
event=UserMessage(text=question, source="ask_expert"),
)
],
tools=None,
model=model,
system=system,
)
with calls.purpose("consult", role="expert"):
produced = self._client.complete(
[
StoredEvent(
id=0,
ts="",
event=UserMessage(text=question, source="ask_expert"),
)
],
tools=None,
model=model,
system=system,
)
except ModelRefused as exc:
# Not retried: the same question is declined again.
return None, f"{exc}. Ask it differently, or work from the repository"
+519
View File
@@ -0,0 +1,519 @@
"""The call record (architecture L7, X6): every model call an agent makes,
kept for the operator to study, never for the agent to read.
Each call is recorded as it was sent and answered: the request body exactly
(never its headers, so no credential), the raw response with its reasoning,
the model asked and the one that answered, time, tokens and the server's own
count of prompt reuse. Beside that, where the request came from: its work
item, role and run, and for each event sent, its log id and its content as
sent, with what compaction replaced, what fitting shortened or dropped. The
status page reads a request against the state store it was made from.
It is diagnostic only. Nothing in the agent reads it, a write that fails is
logged once and recording stops while the call goes ahead, and the file is
deleted when the agent starts, so its layout never migrates: it carries a
version, and a reader that doesn't know it says so.
Content is stored once by hash and compressed, so a conversation that grows
by a turn a call costs about one copy of itself.
Runs and purposes reach the recorder through a context variable: the driver
opens a run around its turns (``run``), and a call made for something else
(a compaction, a consult) says so (``purpose``).
"""
import contextlib
import hashlib
import json
import sqlite3
import threading
import uuid
import zlib
from collections.abc import Iterable, Iterator, Mapping, Sequence
from contextvars import ContextVar
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from pathlib import Path
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
# Said to the model, at the end of its system prompt, while its calls are
# recorded (L7): it is the operator's record, and the agent should know.
NOTICE = "Your model calls are recorded for the operator's study."
_SCHEMA = (
"CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
"CREATE TABLE blobs (hash TEXT PRIMARY KEY, data BLOB NOT NULL)",
"""
CREATE TABLE runs (
id TEXT PRIMARY KEY,
parent TEXT,
work_item TEXT,
role TEXT NOT NULL,
kind TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
outcome TEXT,
note TEXT
)
""",
"""
CREATE TABLE calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
seconds REAL NOT NULL,
run TEXT,
work_item TEXT,
role TEXT,
purpose TEXT,
model TEXT NOT NULL,
answered_by TEXT,
outcome TEXT NOT NULL,
error TEXT,
tokens_in INTEGER,
tokens_out INTEGER,
tokens_cached INTEGER,
cache_n INTEGER,
prompt_n INTEGER,
estimate INTEGER,
factor REAL,
route TEXT,
status INTEGER,
request TEXT,
response TEXT,
sent TEXT NOT NULL,
compacted TEXT NOT NULL,
shortened TEXT NOT NULL,
dropped TEXT NOT NULL,
trim TEXT
)
""",
"CREATE INDEX calls_by_run ON calls (run, id)",
"CREATE INDEX calls_by_item ON calls (work_item, id)",
)
def utc_now() -> str:
return datetime.now(UTC).isoformat()
def path_for(state_db: Path) -> Path:
"""Where an agent's call record lives: beside its state store, named
after it, so two agents sharing a directory keep two records."""
return state_db.with_name(f"{state_db.stem}.calls.db")
def recorder_for(
state_db: Path, enabled: bool, oplog: OpLog | None = None
) -> "CallRecorder | None":
"""An agent's recorder, as its configuration has it (L7): made, and so
the old record deleted, when the agent starts."""
return CallRecorder(path_for(state_db), oplog=oplog) if enabled else None
def with_notice(system: str | None) -> str:
return f"{system}\n\n{NOTICE}" if system else NOTICE
# Where a call is made from, for its record.
@dataclass(frozen=True)
class Scope:
run: str | None = None
parent: str | None = None
work_item: str | None = None
role: str | None = None
purpose: str | None = None
_OUTSIDE = Scope() # no run, no purpose: frozen, so safe to share
_scope: ContextVar[Scope] = ContextVar("nisse_call_scope", default=_OUTSIDE)
def current() -> Scope:
return _scope.get()
class RunHandle:
"""Says how a run ended, for its record."""
def __init__(self) -> None:
self.outcome = ""
self.note = ""
def end(self, outcome: str, note: str = "") -> None:
self.outcome, self.note = outcome, note
def recorder_of(client: Any) -> "CallRecorder | None":
"""The recorder a client records into, through any wrappers (they pass
attributes on); None if it records nothing."""
found = getattr(client, "call_recorder", None)
return found if isinstance(found, CallRecorder) else None
@contextlib.contextmanager
def run(
recorder: "CallRecorder | None",
*,
work_item: str | None,
role: str,
kind: str = "run",
) -> Iterator[RunHandle]:
"""A run: the calls made inside it are its turns. Nested in any run
already open (a delegation inside a driver run)."""
outer = _scope.get()
run_id = uuid.uuid4().hex[:16]
handle = RunHandle()
if recorder is not None:
recorder.run_started(run_id, outer.run, work_item, role, kind)
token = _scope.set(
Scope(run=run_id, parent=outer.run, work_item=work_item, role=role)
)
try:
yield handle
except BaseException as exc:
handle.end("error", f"{type(exc).__name__}: {exc}"[:500])
raise
finally:
_scope.reset(token)
if recorder is not None:
recorder.run_ended(run_id, handle.outcome, handle.note)
@contextlib.contextmanager
def purpose(name: str, *, role: str | None = None) -> Iterator[None]:
"""Calls made inside are for ``name`` (a compaction, a consult), by
``role`` when it isn't the run's."""
outer = _scope.get()
token = _scope.set(replace(outer, purpose=name, role=role or outer.role))
try:
yield
finally:
_scope.reset(token)
# What a call did to the events it was given (L6), for its record.
@dataclass(frozen=True)
class Provenance:
sent: Sequence[StoredEvent] # what went out, in order, as sent
compacted: Mapping[int, int | None] # event id -> the compaction that replaced it
shortened: Mapping[int, int] # event id -> characters fitting cut
dropped: Sequence[int] # event ids fitting left out
def _encode(value: Any) -> tuple[str, bytes]:
text = json.dumps(value, ensure_ascii=False, sort_keys=False)
digest = hashlib.sha256(text.encode()).hexdigest()[:32]
return digest, zlib.compress(text.encode(), 6)
class CallRecorder:
"""Writes the call record. Never raises into the call it records: the
first failure is logged and recording stops."""
def __init__(self, path: str | Path, *, oplog: OpLog | None = None) -> None:
self.path = Path(path)
self._oplog = oplog
self._lock = threading.Lock()
self._conn: sqlite3.Connection | None = None
self.active = False
try:
# Deleted at start (L7): records live as long as the process, so
# the layout never needs migrating.
for suffix in ("", "-wal", "-shm"):
Path(f"{self.path}{suffix}").unlink(missing_ok=True)
conn = sqlite3.connect(
self.path, isolation_level=None, check_same_thread=False
)
# WAL so the status page can read while the agent writes; no
# fsync per write, since a record lost in a power cut costs nothing.
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("BEGIN")
for statement in _SCHEMA:
conn.execute(statement)
conn.execute(
"INSERT INTO meta (key, value) VALUES ('layout_version', ?), "
"('started_at', ?)",
(str(LAYOUT_VERSION), utc_now()),
)
conn.execute("COMMIT")
self._conn = conn
self.active = True
except (OSError, sqlite3.Error) as exc:
self._failed(exc)
def _failed(self, exc: BaseException) -> None:
self.active = False
if self._oplog is not None:
self._oplog.emit(
"calls.record_failed",
cause="recording stopped; the calls go on",
path=str(self.path),
error=f"{type(exc).__name__}: {exc}"[:300],
)
def _write(self, write: Any) -> None:
if not self.active or self._conn is None:
return
with self._lock:
try:
self._conn.execute("BEGIN")
write(self._conn)
self._conn.execute("COMMIT")
except Exception as exc: # noqa: BLE001 -- never into the call
with contextlib.suppress(sqlite3.Error):
self._conn.execute("ROLLBACK")
self._failed(exc)
def _blob(self, conn: sqlite3.Connection, value: Any) -> str:
digest, data = _encode(value)
conn.execute(
"INSERT OR IGNORE INTO blobs (hash, data) VALUES (?, ?)", (digest, data)
)
return digest
def run_started(
self,
run_id: str,
parent: str | None,
work_item: str | None,
role: str,
kind: str,
) -> None:
self._write(
lambda conn: conn.execute(
"INSERT INTO runs (id, parent, work_item, role, kind, started_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(run_id, parent, work_item, role, kind, utc_now()),
)
)
def run_ended(self, run_id: str, outcome: str, note: str) -> None:
self._write(
lambda conn: conn.execute(
"UPDATE runs SET ended_at = ?, outcome = ?, note = ? WHERE id = ?",
(utc_now(), outcome, note, run_id),
)
)
def record(
self,
*,
at: str,
seconds: float,
model: str,
answered_by: str | None,
error: str | None,
usage: Mapping[str, int],
exchange: Mapping[str, Any] | None,
provenance: Provenance,
estimate: int | None,
factor: float,
trim: Mapping[str, Any] | None,
) -> None:
scope = _scope.get()
def write(conn: sqlite3.Connection) -> None:
request = response = route = status = None
timings: Mapping[str, Any] = {}
if exchange is not None:
route = exchange.get("route")
status = exchange.get("status")
body = exchange.get("request")
if isinstance(body, Mapping):
request = json.dumps(_split(conn, self, body))
if "response" in exchange:
raw = exchange["response"]
response = self._blob(conn, raw)
if isinstance(raw, Mapping) and isinstance(
raw.get("timings"), Mapping
):
timings = raw["timings"]
sent = [
[
s.id,
s.actor,
event_type(s.event),
self._blob(conn, to_payload(s.event)),
]
for s in provenance.sent
]
conn.execute(
"""
INSERT INTO calls (
at, seconds, run, work_item, role, purpose, model, answered_by,
outcome, error, tokens_in, tokens_out, tokens_cached, cache_n,
prompt_n, estimate, factor, route, status, request,
response, sent, compacted, shortened, dropped, trim
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?, ?)
""",
(
at,
round(seconds, 3),
scope.run,
scope.work_item,
scope.role,
scope.purpose,
model,
answered_by,
"failed" if error is not None else "answered",
error,
usage.get("input"),
usage.get("output"),
usage.get("cached"),
_int(timings.get("cache_n")),
_int(timings.get("prompt_n")),
estimate,
factor,
route,
status,
request,
response,
json.dumps(sent),
json.dumps({str(k): v for k, v in provenance.compacted.items()}),
json.dumps({str(k): v for k, v in provenance.shortened.items()}),
json.dumps(list(provenance.dropped)),
json.dumps(dict(trim)) if trim is not None else None,
),
)
self._write(write)
def close(self) -> None:
with self._lock:
self.active = False
if self._conn is not None:
with contextlib.suppress(sqlite3.Error):
self._conn.close()
self._conn = None
def _int(value: Any) -> int | None:
return (
int(value)
if isinstance(value, int | float) and not isinstance(value, bool)
else None
)
def _split(
conn: sqlite3.Connection, recorder: CallRecorder, body: Mapping[str, Any]
) -> dict[str, Any]:
"""A request body as hashes: each top-level field once, and the messages
one by one, since each call repeats the conversation before it."""
fields: list[list[Any]] = []
for key, value in body.items():
if key == "messages" and isinstance(value, list):
fields.append([key, [recorder._blob(conn, m) for m in value]])
else:
fields.append([key, recorder._blob(conn, value)])
return {"fields": fields}
# Reading it back: the status page's side, and the tests'.
class UnknownLayout(RuntimeError):
pass
class CallReader:
"""Opens a call record read-only and never writes to it (``readonly``)."""
def __init__(self, path: str | Path) -> None:
self._conn = readonly.connect(path)
row = self._conn.execute(
"SELECT value FROM meta WHERE key = 'layout_version'"
).fetchone()
self.layout_version = int(row["value"]) if row else 0
if self.layout_version != LAYOUT_VERSION:
self._conn.close()
raise UnknownLayout(
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(
"SELECT data FROM blobs WHERE hash = ?", (digest,)
).fetchone()
if row is None:
raise KeyError(digest)
return json.loads(zlib.decompress(row["data"]).decode())
def runs(self) -> list[dict[str, Any]]:
return [
dict(r)
for r in self._conn.execute("SELECT * FROM runs ORDER BY started_at")
]
def calls(self, *, run: str | None = None) -> list[dict[str, Any]]:
if run is None:
rows = self._conn.execute("SELECT * FROM calls ORDER BY id")
else:
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)
for key in ("sent", "compacted", "shortened", "dropped"):
call[key] = json.loads(call[key])
call["trim"] = json.loads(call["trim"]) if call["trim"] else None
out.append(call)
return out
def request(self, call: Mapping[str, Any]) -> dict[str, Any] | None:
"""The request body exactly as sent, rebuilt."""
if not call.get("request"):
return None
body: dict[str, Any] = {}
for key, value in json.loads(call["request"])["fields"]:
body[key] = (
[self.blob(h) for h in value]
if isinstance(value, list)
else self.blob(value)
)
return body
def response(self, call: Mapping[str, Any]) -> Any:
return self.blob(call["response"]) if call.get("response") else None
def close(self) -> None:
self._conn.close()
+5 -3
View File
@@ -39,6 +39,7 @@ from nisse_core.log.canonical import (
ToolCall,
UserMessage,
)
from nisse_core.models import calls
from nisse_core.models.context import _MODEL_SIDE, _turn_starts
from nisse_core.models.llm import LLMError
from nisse_core.models.render import effective_events
@@ -207,9 +208,10 @@ class Compactor:
ask = StoredEvent(
id=0, ts="", event=UserMessage(text=NOTES_REQUEST, source="compaction")
)
produced = self._client.complete(
[*events, ask], tools, model, system=system, require_tool_call=False
)
with calls.purpose("compaction"):
produced = self._client.complete(
[*events, ask], tools, model, system=system, require_tool_call=False
)
return "\n".join(
e.text.strip() for e in produced if isinstance(e, AssistantText)
).strip()
+163 -4
View File
@@ -39,6 +39,7 @@ from typing import Any
from nisse_core.log.canonical import (
AssistantText,
CanonicalEvent,
Compaction,
DelegationStarted,
PlanSubmitted,
StoredEvent,
@@ -46,6 +47,7 @@ from nisse_core.log.canonical import (
ToolResult,
UserMessage,
)
from nisse_core.models import calls
from nisse_core.models.llm import DEFAULT_MAX_OUTPUT_TOKENS, LLMError
from nisse_core.models.render import effective_events
from nisse_core.oplog import OpLog
@@ -264,8 +266,12 @@ class BudgetedClient:
safety_margin: float = 0.1,
enforces_tool_choice: Callable[[str], bool] = lambda model: False,
oplog: OpLog | None = None,
call_recorder: calls.CallRecorder | None = None,
) -> None:
self._inner = inner
# Every call is recorded here for the operator's study (L7), while it
# is active; the benchmark swaps it per run.
self.call_recorder = call_recorder
self._oplog = oplog
self._limits_for = limits_for
# Whether a model's server holds it to the offered tools when asked
@@ -359,10 +365,36 @@ class BudgetedClient:
"""``require_tool_call``: None asks for a tool call whenever tools
are offered (and the model's server enforces it); False lets the
model answer in prose, as compaction's notes do."""
recorder = self.call_recorder
if recorder is not None and not recorder.active:
recorder = None
if recorder is not None:
# The agent is told its calls are recorded, while they are (L7).
system = calls.with_notice(system)
budget = self.budget_for(model)
factor = self._factors.get(model, 1.0)
events = effective_events(list(events))
fitted, trim = fit_report(events, system, tools, budget, factor)
given = list(events)
events = effective_events(given)
at = calls.utc_now()
started = time.monotonic()
try:
fitted, trim = fit_report(events, system, tools, budget, factor)
except ContextOverflow as exc:
if recorder is not None:
self._record(
recorder,
at,
started,
model,
given,
events,
events,
None,
factor,
exchange=None,
error=f"ContextOverflow: {exc}",
)
raise
self.last_trim = trim
if trim is not None:
# Counted so the benchmark can report how often a model's window
@@ -386,6 +418,7 @@ class BudgetedClient:
)
if require_tool_call is None:
require_tool_call = bool(tools) and self._enforces_tool_choice(model)
before = getattr(self._inner, "last_exchange", None)
started = time.monotonic()
try:
produced = self._inner.complete(
@@ -399,13 +432,139 @@ class BudgetedClient:
# ask_human, a delegation with hand_back.
require_tool_call=require_tool_call,
)
except Exception as e:
self._log_call(model, started, error=type(e).__name__)
except BaseException as e:
if isinstance(e, Exception):
self._log_call(model, started, error=type(e).__name__)
if recorder is not None:
self._record(
recorder,
at,
started,
model,
given,
events,
fitted,
trim,
factor,
exchange=self._exchange(before),
error=f"{type(e).__name__}: {e}"[:1000],
tools=tools,
system=system,
)
raise
self._log_call(model, started)
if recorder is not None:
self._record(
recorder,
at,
started,
model,
given,
events,
fitted,
trim,
factor,
exchange=self._exchange(before),
error=None,
tools=tools,
system=system,
)
reported = int(self.last_usage.get("input", 0))
if reported > 0:
estimated = estimate_tokens(fitted, system, tools)
observed = reported / max(estimated, 1)
self._factors[model] = max(factor, observed)
return produced
def _exchange(self, before: Any) -> dict[str, Any] | None:
"""This call's request and response, if the client set them anew."""
exchange = getattr(self._inner, "last_exchange", None)
return (
exchange if isinstance(exchange, dict) and exchange is not before else None
)
def _record(
self,
recorder: calls.CallRecorder,
at: str,
started: float,
model: str,
given: list[StoredEvent],
effective: list[StoredEvent],
fitted: list[StoredEvent],
trim: Trim | None,
factor: float,
*,
exchange: dict[str, Any] | None,
error: str | None,
tools: list[dict[str, Any]] | None = None,
system: str | None = None,
) -> None:
answered = None if error is not None else self.last_model
recorder.record(
at=at,
seconds=time.monotonic() - started,
model=model,
answered_by=answered if answered and answered != model else None,
error=error,
usage={} if error is not None else dict(self.last_usage),
exchange=exchange,
provenance=provenance(given, effective, fitted),
estimate=(
estimate_tokens(fitted, system, tools, factor)
if error is None or exchange is not None
else None
),
factor=factor,
trim=None
if trim is None
else {
"step": trim.step,
"tokens_before": trim.tokens_before,
"tokens_after": trim.tokens_after,
"limit": trim.limit,
"results_cut": trim.results_cut,
"events_dropped": trim.events_dropped,
},
)
def provenance(
given: Sequence[StoredEvent],
effective: Sequence[StoredEvent],
fitted: Sequence[StoredEvent],
) -> calls.Provenance:
"""What compaction and fitting did to the events a call was given (L6),
for its record: which a compaction replaced, which fitting shortened and
by how much, and which it dropped. Events the harness made up for one
call (id 0) are in what was sent, and in nothing else."""
compactions = [e for e in effective if isinstance(e.event, Compaction)]
kept = {e.id for e in effective}
compacted: dict[int, int | None] = {}
for stored in given:
if stored.id == 0 or stored.id in kept or isinstance(stored.event, Compaction):
continue
compacted[stored.id] = next(
(
c.id
for c in compactions
if c.event.replaces[0] <= stored.id <= c.event.replaces[1]
),
None,
)
by_id = {e.id: e for e in effective if e.id != 0}
sent_ids = {e.id for e in fitted}
shortened: dict[int, int] = {}
for stored in fitted:
original = by_id.get(stored.id)
if (
original is not None
and isinstance(stored.event, ToolResult)
and isinstance(original.event, ToolResult)
and stored.event.result != original.event.result
):
shortened[stored.id] = len(original.event.result) - len(stored.event.result)
dropped = [i for i in by_id if i not in sent_ids]
return calls.Provenance(
sent=list(fitted), compacted=compacted, shortened=shortened, dropped=dropped
)
+22
View File
@@ -188,6 +188,10 @@ class LiteLLMClient:
# Set even when the call then fails: time spent waiting out a rate
# limit is the proxy's, not the model's (the benchmark subtracts it).
self.last_rate_limit_wait_s: float = 0.0
# The last call's request body and what came back, for the call
# record (L7): a new dict each call, set before it is sent. Never the
# headers, which carry the token.
self.last_exchange: dict[str, Any] | None = None
def complete(
self,
@@ -208,10 +212,12 @@ class LiteLLMClient:
# nothing to check against. A caller that knows the model's limits
# (the context budget) passes a tighter one.
body["max_tokens"] = max_tokens or self._max_tokens
self.last_exchange = exchange = {"route": "chat", "request": body}
self.last_rate_limit_wait_s = 0.0 # not the last call's, if this one raises
response, self.last_rate_limit_wait_s = _post_with_rate_limit_retry(
self._http, "/chat/completions", body, self._sleep
)
_keep_response(exchange, response)
if response.status_code != 200:
_raise_for_upstream(response)
raise LLMError(
@@ -321,6 +327,10 @@ class AnthropicPassthroughClient:
# Set even when the call then fails: time spent waiting out a rate
# limit is the proxy's, not the model's (the benchmark subtracts it).
self.last_rate_limit_wait_s: float = 0.0
# The last call's request body and what came back, for the call
# record (L7): a new dict each call, set before it is sent. Never the
# headers, which carry the token.
self.last_exchange: dict[str, Any] | None = None
def complete(
self,
@@ -341,10 +351,12 @@ class AnthropicPassthroughClient:
)
if require_tool_call and tools:
body["tool_choice"] = {"type": "any"} # Anthropic's name for it
self.last_exchange = exchange = {"route": "messages", "request": body}
self.last_rate_limit_wait_s = 0.0 # not the last call's, if this one raises
response, self.last_rate_limit_wait_s = _post_with_rate_limit_retry(
self._http, "/v1/messages", body, self._sleep
)
_keep_response(exchange, response)
if response.status_code != 200:
_raise_for_upstream(response)
raise LLMError(
@@ -444,6 +456,16 @@ def _post_with_rate_limit_retry(
# Model families we have a native renderer adapter for. L9: delegations take
# the vendor's passthrough route because a normalising middlebox is lossy
# exactly where a long-running delegation lives.
def _keep_response(exchange: dict[str, Any], response: httpx.Response) -> None:
"""What came back, for the call record: the JSON as the server sent it
(reasoning included), or its text when it isn't JSON."""
exchange["status"] = response.status_code
try:
exchange["response"] = response.json()
except ValueError:
exchange["response"] = {"text": response.text[:20000]}
def _response_cost(response: httpx.Response) -> float:
try:
return float(response.headers.get("x-litellm-response-cost") or 0.0)
+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
+394
View File
@@ -0,0 +1,394 @@
"""The call record (architecture L7): every model call, as sent and answered,
with where its request came from, for the operator's study."""
import io
import json
import sqlite3
import zlib
from pathlib import Path
from typing import Any
import httpx
import pytest
from nisse_core.log.canonical import (
AssistantText,
CanonicalEvent,
Compaction,
StoredEvent,
ToolCall,
ToolResult,
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, ContextOverflow
from nisse_core.models.llm import LiteLLMClient, LLMError
from nisse_core.oplog import OpLog
from nisse_core.state.store import StateStore
TOKEN = "sk-secret-litellm-token"
FINISH = tool("finish", "finish the work", summary="what was done")
def stored(events: list[CanonicalEvent], start: int = 1) -> list[StoredEvent]:
return [
StoredEvent(id=start + i, ts="t", event=e, work_item="w", actor="driver")
for i, e in enumerate(events)
]
def reply(
content: str = "",
*,
tool_call: str | 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 tool_call:
message["tool_calls"] = [
{
"id": "x",
"type": "function",
"function": {"name": tool_call, "arguments": '{"summary": "done"}'},
}
]
data: dict[str, Any] = {
"model": "local",
"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: answers each request in turn and keeps what it got."""
def __init__(self, *answers: tuple[int, dict[str, Any]]) -> None:
self.answers = list(answers)
self.bodies: list[dict[str, Any]] = []
def __call__(self, request: httpx.Request) -> httpx.Response:
self.bodies.append(json.loads(request.content))
status, data = self.answers.pop(0) if self.answers else (200, reply("ok"))
return httpx.Response(status, json=data)
def budgeted(
server: Server, recorder: calls.CallRecorder | None, window: int = 32768
) -> BudgetedClient:
inner = LiteLLMClient(
"https://litellm.test", TOKEN, transport=httpx.MockTransport(server)
)
return BudgetedClient(inner, lambda m: (window, 1024), call_recorder=recorder)
@pytest.fixture
def recorder(tmp_path: Path) -> calls.CallRecorder:
return calls.CallRecorder(tmp_path / "state.calls.db")
def read(recorder: calls.CallRecorder) -> calls.CallReader:
return calls.CallReader(recorder.path)
# --- what each call was --------------------------------------------------------
def test_a_call_is_recorded_exactly_as_sent_with_its_reasoning(
recorder: calls.CallRecorder,
) -> None:
server = Server(
(
200,
reply(
"Finishing.",
tool_call="finish",
reasoning="The tests pass, so the work is done.",
timings={"cache_n": 90, "prompt_n": 30},
),
)
)
client = budgeted(server, recorder)
client.complete(
stored([UserMessage(text="go", source="t")]), [FINISH], "local", system="sys"
)
reader = read(recorder)
[call] = reader.calls()
# The request rebuilt from the record is the body the server got.
assert reader.request(call) == server.bodies[0]
response = reader.response(call)
assert (
response["choices"][0]["message"]["reasoning_content"]
== "The tests pass, so the work is done."
)
assert call["outcome"] == "answered"
assert (call["tokens_in"], call["tokens_out"]) == (120, 7)
assert (call["cache_n"], call["prompt_n"]) == (90, 30)
assert call["route"] == "chat" and call["status"] == 200
def test_the_token_is_nowhere_in_the_record(recorder: calls.CallRecorder) -> None:
client = budgeted(Server(), recorder)
client.complete(stored([UserMessage(text="go", source="t")]), None, "local")
recorder.close()
raw = recorder.path.read_bytes()
conn = sqlite3.connect(recorder.path)
blobs = b"".join(
zlib.decompress(d) for (d,) in conn.execute("SELECT data FROM blobs")
)
rows = json.dumps(conn.execute("SELECT * FROM calls").fetchall()).encode()
conn.close()
assert TOKEN.encode() not in raw + blobs + rows
def test_a_failed_call_is_recorded_with_its_error(recorder: calls.CallRecorder) -> None:
server = Server((400, {"error": {"message": "bad request: no such model"}}))
client = budgeted(server, recorder)
with pytest.raises(LLMError):
client.complete(stored([UserMessage(text="go", source="t")]), None, "local")
reader = read(recorder)
[call] = reader.calls()
assert call["outcome"] == "failed"
assert "LLMError" in call["error"]
assert call["status"] == 400
assert reader.request(call) == server.bodies[0]
assert reader.response(call) == {"error": {"message": "bad request: no such model"}}
def test_a_request_too_big_to_send_is_recorded_without_one(
recorder: calls.CallRecorder,
) -> None:
server = Server()
client = budgeted(server, recorder, window=4096)
with pytest.raises(ContextOverflow):
client.complete(
stored([UserMessage(text="go", source="t")]),
None,
"local",
system="s" * 20_000,
)
[call] = read(recorder).calls()
assert server.bodies == []
assert call["outcome"] == "failed" and "ContextOverflow" in call["error"]
assert call["request"] is None
def test_a_growing_conversation_is_stored_once(recorder: calls.CallRecorder) -> None:
client = budgeted(Server(), recorder)
events: list[CanonicalEvent] = [
UserMessage(text="message 0 " + "x" * 2000, source="t")
]
for n in range(1, 6):
client.complete(stored(events), None, "local")
events.append(AssistantText(text=f"reply {n} " + "y" * 2000, model="local"))
events.append(UserMessage(text=f"message {n} " + "z" * 2000, source="t"))
conn = sqlite3.connect(recorder.path)
stored_chars = sum(
len(zlib.decompress(d)) for (d,) in conn.execute("SELECT data FROM blobs")
)
conn.close()
# Five calls resend 1 + 3 + 5 + 7 + 9 messages of about 2 kB; stored
# once each, that's 9 messages (as sent, and as events), not 25.
assert stored_chars < 2 * 9 * 2100 + 20_000
# --- where each request came from ---------------------------------------------
def test_compaction_shortening_and_dropping_are_recorded(
recorder: calls.CallRecorder,
) -> None:
long = "line\n" * 2000
events = stored(
[
UserMessage(text="start", source="t"), # 1
AssistantText(text="old thinking", model="m"), # 2, compacted
AssistantText(text="more old thinking", model="m"), # 3, compacted
Compaction(summary="notes so far", model="m", replaces=[2, 3]), # 4
]
)
for n in range(8): # turns 5.. : a command and its long output each
events += stored(
[
ToolCall(
call_id=f"c{n}", tool="run_command", arguments={"command": "make"}
),
ToolResult(call_id=f"c{n}", result=long, success=True),
],
start=len(events) + 1,
)
client = BudgetedClient(
LiteLLMClient(
"https://litellm.test", TOKEN, transport=httpx.MockTransport(Server())
),
lambda m: (12_000, 1024),
keep_recent_turns=2,
call_recorder=recorder,
)
client.complete(events, None, "local")
[call] = read(recorder).calls()
assert call["compacted"] == {"2": 4, "3": 4}
assert call["shortened"], "older results were cut to fit"
assert all(cut > 0 for cut in call["shortened"].values())
sent_ids = [entry[0] for entry in call["sent"]]
assert 4 in sent_ids and 2 not in sent_ids
assert set(map(int, call["shortened"])) <= set(sent_ids)
assert not set(call["dropped"]) & set(sent_ids)
def test_runs_nest_and_label_their_calls(recorder: calls.CallRecorder) -> None:
client = budgeted(Server(), recorder)
events = stored([UserMessage(text="go", source="t")])
with calls.run(recorder, work_item="w", role="driver") as outer:
client.complete(events, None, "local")
with calls.purpose("consult", role="expert"):
client.complete(events, None, "local")
with calls.run(
recorder, work_item="w", role="helper", kind="delegation"
) as inner:
client.complete(events, None, "local")
inner.end("handback", "done")
outer.end("wait", "")
reader = read(recorder)
runs = {r["kind"]: r for r in reader.runs()}
assert runs["delegation"]["parent"] == runs["run"]["id"]
assert runs["run"]["outcome"] == "wait" and runs["run"]["ended_at"]
assert runs["delegation"]["outcome"] == "handback"
turn, consult, helped = reader.calls()
assert (turn["run"], turn["role"], turn["purpose"]) == (
runs["run"]["id"],
"driver",
None,
)
assert (consult["role"], consult["purpose"]) == ("expert", "consult")
assert (helped["run"], helped["role"]) == (runs["delegation"]["id"], "helper")
assert {c["work_item"] for c in (turn, consult, helped)} == {"w"}
def test_a_driver_run_is_recorded_with_how_it_ended(
tmp_path: Path, recorder: calls.CallRecorder
) -> None:
store = StateStore(tmp_path / "state.db", oplog=OpLog("t", io.StringIO()))
store.open_work_item("w", title="t", position="RUNNING", cause="test")
store.transcript("w", "operator").append(UserMessage(text="go", source="t"))
client = budgeted(Server((200, reply(tool_call="finish"))), recorder)
outcome = Driver(client, "local").run(
store.transcript("w", "driver"),
system="sys",
tools=[FINISH],
waiting=("finish",),
workspace=None,
)
reader = read(recorder)
[run] = reader.runs()
assert outcome.kind == "wait"
assert (run["work_item"], run["role"], run["outcome"]) == ("w", "driver", "wait")
[call] = reader.calls(run=run["id"])
# Each event sent is the log's, by id, so the page can read it against
# the state store.
assert [entry[:3] for entry in call["sent"]] == [[1, "operator", "user_message"]]
# --- the agent is told, and nothing depends on the record ----------------------
def test_the_agent_is_told_exactly_while_it_is_recorded(tmp_path: Path) -> None:
on, off = Server(), Server()
budgeted(on, calls.CallRecorder(tmp_path / "a.calls.db")).complete(
stored([UserMessage(text="go", source="t")]), None, "local", system="sys"
)
budgeted(off, None).complete(
stored([UserMessage(text="go", source="t")]), None, "local", system="sys"
)
assert on.bodies[0]["messages"][0]["content"] == f"sys\n\n{calls.NOTICE}"
assert off.bodies[0]["messages"][0]["content"] == "sys"
def test_a_recorder_that_fails_never_fails_the_call(tmp_path: Path) -> None:
out = io.StringIO()
recorder = calls.CallRecorder(tmp_path / "state.calls.db", oplog=OpLog("t", out))
recorder._conn.close() # the disk went away, say
client = budgeted(Server(), recorder)
first = client.complete(stored([UserMessage(text="go", source="t")]), None, "local")
second = client.complete(
stored([UserMessage(text="go", source="t")]), None, "local"
)
assert [e.text for e in first + second] == ["ok", "ok"] # type: ignore[union-attr]
assert not recorder.active
logged = [json.loads(line) for line in out.getvalue().splitlines()]
assert [e["event"] for e in logged].count("calls.record_failed") == 1
def test_the_record_is_new_each_start(tmp_path: Path) -> None:
path = tmp_path / "state.calls.db"
path.write_bytes(b"an older layout, or not a database at all")
Path(f"{path}-wal").write_bytes(b"stale")
recorder = calls.CallRecorder(path)
budgeted(Server(), recorder).complete(
stored([UserMessage(text="go", source="t")]), None, "local"
)
assert recorder.active
assert len(read(recorder).calls()) == 1
again = calls.CallRecorder(path)
assert again.active and read(again).calls() == []
def test_off_means_no_file(tmp_path: Path) -> None:
assert calls.recorder_for(tmp_path / "state.db", False) is None
assert not list(tmp_path.iterdir())
recorder = calls.recorder_for(tmp_path / "state.db", True)
assert recorder is not None and recorder.path == tmp_path / "state.calls.db"
def test_a_reader_refuses_a_layout_it_does_not_know(
recorder: calls.CallRecorder,
) -> None:
recorder._conn.execute("UPDATE meta SET value = '99' WHERE key = 'layout_version'")
with pytest.raises(calls.UnknownLayout, match="layout 99"):
read(recorder)
def test_the_reader_never_writes(recorder: calls.CallRecorder) -> None:
budgeted(Server(), recorder).complete(
stored([UserMessage(text="go", source="t")]), None, "local"
)
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]
+36 -2
View File
@@ -1,11 +1,13 @@
"""Running one task once with one driver model, and recording it."""
import contextlib
import io
import shlex
import subprocess
import tempfile
import time
import uuid
from collections.abc import Iterator
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path
@@ -21,6 +23,7 @@ from nisse_core.log.canonical import (
UserMessage,
)
from nisse_core.loop.driver import Driver, RunOutcome
from nisse_core.models import calls
from nisse_core.models.catalog import ModelFacts
from nisse_core.models.compact import Compactor
from nisse_core.oplog import OpLog
@@ -184,6 +187,37 @@ def _count(events: list[StoredEvent]) -> tuple[int, int]:
return failed, invented
@contextlib.contextmanager
def _run_dir(keep: Path | None, workdir: Path | None, name: str) -> Iterator[Path]:
"""Where a run's state store and call record go: kept under ``keep``
for the status page (X6), or a temporary directory."""
if keep is not None:
root = keep / f"{name}-{uuid.uuid4().hex[:8]}"
root.mkdir(parents=True)
yield root
return
with tempfile.TemporaryDirectory(dir=workdir) as tmp:
yield Path(tmp)
@contextlib.contextmanager
def _recording(client: Any, root: Path) -> Iterator[None]:
"""The run's calls recorded beside its own state store (L7), as the
agent records its own, so the run measures what the agent would do."""
recorder = calls.CallRecorder(calls.path_for(root / "state.db"))
had = hasattr(client, "call_recorder")
previous = getattr(client, "call_recorder", None)
client.call_recorder = recorder
try:
yield
finally:
if had:
client.call_recorder = previous
else:
del client.call_recorder
recorder.close()
def run_task(
task: Task,
facts: ModelFacts,
@@ -194,6 +228,7 @@ def run_task(
note: str = "",
commit: str = "",
workdir: Path | None = None,
keep: Path | None = None,
) -> RunRecord:
"""Run one task once: seed a workspace, hand the driver an agreed item
whose restatement is the task's plan, then judge the result with the
@@ -202,8 +237,7 @@ def run_task(
It is the loop's own driver, system prompt and tools, so what is measured
is what the agent would do, without the harness around it: the milestone
is started but no branch is cut, and finishing it ends the run."""
with tempfile.TemporaryDirectory(dir=workdir) as tmp:
root = Path(tmp)
with _run_dir(keep, workdir, task.name) as root, _recording(client, root):
# The workspace is on the build VM, like the agent's own (L13).
workspace = Workspace(
host,
+7
View File
@@ -63,6 +63,8 @@ OPTIONAL_KEYS = (
"roles.classifier",
"llm.max_output_tokens",
"llm.request_timeout_s",
# Record every model call for the operator's study (L7); on unless false.
"call_record",
)
# Settings the old loop had (until M2.8), and what to do instead.
RETIRED_KEYS = {
@@ -444,6 +446,7 @@ class AgentConfig:
profiles: Mapping[str, Profile] = field(default_factory=dict)
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
call_record: bool = True # every model call recorded for the operator (L7)
tuning: Tuning = field(default_factory=Tuning)
vault: VaultConfig | None = None
config_file: Path | None = None
@@ -521,6 +524,9 @@ class AgentConfig:
errors.append("unset or empty — " + ", ".join(missing))
max_output, request_timeout = llm_numbers(values, errors)
record = values.get("call_record", True)
if not isinstance(record, bool):
errors.append("call_record: true or false")
if errors:
raise ConfigError("cannot start: " + "; ".join(errors))
@@ -548,6 +554,7 @@ class AgentConfig:
profiles=profiles,
max_output_tokens=max_output,
request_timeout_s=request_timeout,
call_record=record,
tuning=tuning,
vault=vault,
config_file=config_file,
+16
View File
@@ -38,6 +38,7 @@ from nisse_core.log.canonical import (
ToolCall,
ToolResult,
)
from nisse_core.models import calls
from nisse_core.models.llm import LLMClient, LLMError, UpstreamUnavailable
from nisse_core.state.cancel import Cancel, skip_calls
from nisse_core.state.reconcile import Reconciler
@@ -333,6 +334,21 @@ class Delegation:
def _loop(
self, transcript: Transcript, brief: str, branch: str, started_at: float
) -> DelegationResult:
"""The delegation's turns, recorded as a run of its own, inside the
driver's (L7)."""
with calls.run(
calls.recorder_of(self._client),
work_item=transcript.work_item,
role=transcript.actor,
kind="delegation",
) as recorded:
result = self._turns(transcript, brief, branch, started_at)
recorded.end(result.status, result.note)
return result
def _turns(
self, transcript: Transcript, brief: str, branch: str, started_at: float
) -> DelegationResult:
system = (
f"{SYSTEM_PROMPT.format(reader=self._reader)}\n\n"
+5
View File
@@ -14,6 +14,7 @@ from nisse_core.gitea import GiteaClient
from nisse_core.knowledge.memory import Memory
from nisse_core.knowledge.vault import Vault
from nisse_core.loop.driver import Driver
from nisse_core.models import calls
from nisse_core.models.catalog import ModelCatalog, select_client
from nisse_core.models.compact import Compactor
from nisse_core.models.context import BudgetedClient
@@ -126,6 +127,9 @@ def make_llm_clients(
assert facts.max_input_tokens is not None # require_limits checked
return facts.max_input_tokens, facts.max_output_tokens
# Both routes record into one file (L7).
recorder = calls.recorder_for(config.state_db, config.call_record, oplog)
def budgeted(client: object) -> BudgetedClient:
# Every request, on either route, is fitted to its model's window.
return BudgetedClient(
@@ -138,6 +142,7 @@ def make_llm_clients(
catalog.facts(m).supports_tool_choice is True
),
oplog=oplog, # a cut that fitting has to make is logged
call_recorder=recorder,
)
normalized = budgeted(
+15 -13
View File
@@ -18,6 +18,7 @@ from typing import Any
from nisse_core.authority.actions import CONTAINED, DANGEROUS, Action, Verdict
from nisse_core.log.canonical import StoredEvent, ToolCall, UserMessage
from nisse_core.models.calls import purpose
from nisse_core.models.llm import LLMClient, LLMError
SAFE = "safe"
@@ -120,19 +121,20 @@ class ActionClassifier:
shown = f"{model} ({resolved})" if resolved and resolved != model else model
prompt = self.input_for(action, scope, workspace)
try:
produced = self._client.complete(
[
StoredEvent(
id=0,
ts="",
event=UserMessage(text=prompt, source="action classifier"),
actor="orchestrator",
)
],
tools=[VERDICT],
model=model,
system=SYSTEM,
)
with purpose("classification", role="classifier"):
produced = self._client.complete(
[
StoredEvent(
id=0,
ts="",
event=UserMessage(text=prompt, source="action classifier"),
actor="orchestrator",
)
],
tools=[VERDICT],
model=model,
system=SYSTEM,
)
except LLMError as exc:
return _closed(f"the classifier's call failed: {exc}"), shown
except Exception as exc: # noqa: BLE001 -- fails closed, whatever it was
+15 -13
View File
@@ -11,6 +11,7 @@ The driver's model reads for now; the classifier role takes over in M2.6.
from typing import Any
from nisse_core.log.canonical import StoredEvent, ToolCall, UserMessage
from nisse_core.models.calls import purpose
from nisse_core.models.llm import LLMClient, LLMError
@@ -65,19 +66,20 @@ class GoAheadReader:
return False
prompt = f"The proposal:\n\n{proposal}\n\nThe operator's reply:\n\n{reply}"
try:
produced = self._client.complete(
[
StoredEvent(
id=0,
ts="",
event=UserMessage(text=prompt, source="go-ahead reader"),
actor="orchestrator",
)
],
tools=[VERDICT],
model=model or self._model,
system=SYSTEM,
)
with purpose("go-ahead reading", role="classifier"):
produced = self._client.complete(
[
StoredEvent(
id=0,
ts="",
event=UserMessage(text=prompt, source="go-ahead reader"),
actor="orchestrator",
)
],
tools=[VERDICT],
model=model or self._model,
system=SYSTEM,
)
except LLMError:
return False # unread: the driver reads it as an answer instead
return any(_yes(event) for event in produced)
+23
View File
@@ -467,3 +467,26 @@ def test_runs_from_before_the_driver_read_as_its_defaults() -> None:
old["tuning"]["executor"] = old["tuning"].pop("driver")
[row] = summarise([old], current)
assert row.tuning == ""
def test_a_kept_run_leaves_its_state_and_call_record(tmp_path: Path) -> None:
# As the agent records itself (L7), so the status page can open a
# benchmark run (X6); each run gets its own record, and the client is
# left as it was.
from nisse_core.models.calls import CallReader
[task] = load_tasks(["write-function"])
client = Scripted(list(SOLVE_SLUGIFY))
record = run_task(task, FACTS, client, Tuning(), host=HOST, keep=tmp_path / "runs")
[kept] = list((tmp_path / "runs").iterdir())
assert kept.name.startswith("write-function-")
assert (kept / "state.db").exists()
[run] = CallReader(kept / "state.calls.db").runs()
assert (run["work_item"], run["role"], run["outcome"]) == (
"bench",
"driver",
"wait",
)
assert record.success
assert not hasattr(client, "call_recorder")
+25
View File
@@ -573,3 +573,28 @@ def test_the_vault_is_read() -> None:
def test_a_bad_vault_is_refused(vault: dict[str, Any], said: str) -> None:
with pytest.raises(ConfigError, match=re.escape(said)):
build(vault=vault)
def test_the_call_record_is_on_unless_turned_off() -> None:
assert build().call_record is True
assert build(call_record=False).call_record is False
with pytest.raises(ConfigError, match="call_record"):
build(call_record="no")
def test_both_routes_record_into_one_file_beside_the_state_store(
tmp_path: Path,
) -> None:
from nisse_core.models.catalog import ModelCatalog
from nisse_dev.loop.build import make_llm_clients
normalized, passthrough = make_llm_clients(
build(state_db=str(tmp_path / "state.db")), ModelCatalog({})
)
assert normalized.call_recorder is passthrough.call_recorder
assert normalized.call_recorder.path == tmp_path / "state.calls.db"
off, _ = make_llm_clients(
build(state_db=str(tmp_path / "other.db"), call_record=False), ModelCatalog({})
)
assert off.call_recorder is None
assert not (tmp_path / "other.calls.db").exists()
+22
View File
@@ -702,3 +702,25 @@ def test_a_bad_request_is_not_given_the_benefit_of_the_doubt(
result = build(BadRequestClient([]), store, ws, repo).run(brief="b", branch="d")
assert result.status == "failed"
assert not result.interrupted
def test_a_delegation_is_recorded_as_a_run_of_its_own(
store: StateStore, ws: Workspace, repo: GitRepo
) -> None:
# Its calls are the helper's turns (L7), inside the run that delegated.
from nisse_core.models import calls
seen: list[calls.Scope] = []
class Watching(ScriptedClient):
def complete(self, *args: Any, **kwargs: Any) -> list[CanonicalEvent]:
seen.append(calls.current())
return super().complete(*args, **kwargs)
client = Watching([[hand_back(succeeded=False, changed="nothing")]])
with calls.run(None, work_item=ITEM, role="driver"):
outer = calls.current().run
build(client, store, ws, repo).run(brief="look into it", branch="d1")
[scope] = seen
assert (scope.role, scope.work_item, scope.parent) == ("helper", ITEM, outer)
assert scope.run not in (None, outer)
+24
View File
@@ -346,3 +346,27 @@ def test_the_export_script_reads_a_store(tmp_path: Path) -> None:
lines = [json.loads(line) for line in run.stdout.splitlines()]
assert [line["verdict"] for line in lines] == ["needs_discussion", "safe"]
assert lines[0]["input"]["action"]["arguments"] == {"command": "x"}
def test_the_classifiers_calls_are_recorded_as_its_own() -> None:
# In the call record (L7), a verdict is the classifier's call, not a
# turn of the run that asked for it.
from nisse_core.models import calls
seen: list[calls.Scope] = []
class Watching(Judge):
def complete(self, events, tools, model, system=None) -> list[Any]:
seen.append(calls.current())
return super().complete(events, tools, model, system)
classifier = ActionClassifier(Watching(verdict("contained")), "local") # type: ignore[arg-type]
with calls.run(None, work_item="w", role="driver"):
classifier.judge(Action("run_command", {"command": "make"}), SCOPE, "/w")
[scope] = seen
assert (scope.role, scope.purpose, scope.work_item) == (
"classifier",
"classification",
"w",
)
assert scope.run is not None
+16
View File
@@ -316,3 +316,19 @@ def test_a_restatement_that_asks_is_sent_back(tmp_path: Path) -> None:
if hasattr(e.event, "result")
]
assert any("a restatement asks no questions" in r for r in results)
def test_the_go_ahead_readers_calls_are_recorded_as_the_classifiers() -> None:
from nisse_core.models import calls
seen: list[calls.Scope] = []
class Watching(_Model):
def complete(self, *args: Any, **kwargs: Any) -> Any:
seen.append(calls.current())
return super().complete(*args, **kwargs)
yes = [ToolCall(call_id="v", tool="verdict", arguments={"answer": "yes"})]
GoAheadReader(Watching(yes), "m").agrees("add count_words", "ok") # type: ignore[arg-type]
[scope] = seen
assert (scope.role, scope.purpose) == ("classifier", "go-ahead reading")
+2
View File
@@ -9,6 +9,7 @@ from nisse_core.knowledge.memory import Memory
from nisse_core.knowledge.vault import Vault
from nisse_core.loop.driver import Driver
from nisse_core.loop.expert import ExpertDesk
from nisse_core.models import calls
from nisse_core.models.catalog import ModelCatalog
from nisse_core.models.compact import Compactor
from nisse_core.models.context import BudgetedClient
@@ -94,6 +95,7 @@ def llm_client(config: ReviewConfig, oplog: OpLog | None = None) -> BudgetedClie
safety_margin=tuning.safety_margin,
enforces_tool_choice=lambda m: catalog.facts(m).supports_tool_choice is True,
oplog=oplog, # a cut that fitting has to make is logged
call_recorder=calls.recorder_for(config.state_db, config.call_record, oplog),
)
+7
View File
@@ -47,6 +47,8 @@ OPTIONAL_KEYS = (
# Patterns of the repositories it reviews ("dev/nisse", "dev/*", "*/*"),
# public ones included; unset, every one it was given (R1, ``access``).
"repositories",
# Record every model call for the operator's study (L7); on unless false.
"call_record",
)
@@ -108,6 +110,7 @@ class ReviewConfig:
gitea_operators: frozenset[str] # whose comments are the operator's (R4)
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
call_record: bool = True # every model call recorded for the operator (L7)
tuning: Tuning = field(default_factory=Tuning)
vault: VaultConfig | None = None
repositories: frozenset[str] | None = None # None: all it can read
@@ -158,6 +161,9 @@ class ReviewConfig:
owner, slash, name = pattern.partition("/")
if not (owner and slash and name) or "/" in name:
errors.append(f"repositories: {pattern!r} is not owner/name")
record = values.get("call_record", True)
if not isinstance(record, bool):
errors.append("call_record: true or false")
if errors:
raise ConfigError("cannot start: " + "; ".join(errors))
reviewer = str(values["roles.reviewer"])
@@ -173,6 +179,7 @@ class ReviewConfig:
gitea_operators=people(values["gitea.operators"]),
max_output_tokens=max_output,
request_timeout_s=timeout,
call_record=record,
tuning=tuning,
vault=vault,
repositories=patterns,
+40
View File
@@ -645,3 +645,43 @@ def test_code_git_cannot_give_fails_the_run(tmp_path: Path) -> None:
w["scheduler"].round()
assert (item(w).position, pr_of(item(w)).attempts) == (READY, 1)
assert w["gitea"].posted == []
def test_the_review_agent_records_its_calls_unless_told_not_to(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
from nisse_core.config import ConfigError
from nisse_core.models.catalog import ModelCatalog
from nisse_review import build as review_build
from nisse_review.config import ReviewConfig
tree = {
"state_db": str(tmp_path / "s.db"),
"llm": {"base_url": "http://llm", "token": "k"},
"roles": {"reviewer": "local", "expert": "frontier"},
"gitea": {"url": "https://g", "token": "t", "operators": ["don"]},
}
limits = {"max_input_tokens": 32768, "max_output_tokens": 4096}
catalog = ModelCatalog.from_payload(
{
"data": [
{
"model_name": m,
"litellm_params": {"model": f"x/{m}"},
"model_info": limits,
}
for m in ("local", "frontier")
]
}
)
monkeypatch.setattr(review_build.ModelCatalog, "discover", lambda *a, **k: catalog)
on = review_build.llm_client(ReviewConfig.from_tree(tree, {}))
assert on.call_recorder is not None
assert on.call_recorder.path == tmp_path / "s.calls.db"
off = review_build.llm_client(
ReviewConfig.from_tree({**tree, "call_record": False}, {})
)
assert off.call_recorder is None
with pytest.raises(ConfigError, match="call_record"):
ReviewConfig.from_tree({**tree, "call_record": "yes"}, {})
+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"]
+6
View File
@@ -66,6 +66,7 @@ def run(args: argparse.Namespace) -> int:
host=host,
note=args.note,
commit=commit,
keep=args.keep_runs,
)
append(out, record)
verdict = "pass" if record.success else f"FAIL ({record.outcome})"
@@ -113,6 +114,11 @@ def main() -> int:
"--note", default="", help='how the model was served, e.g. "vLLM 0.29, AWQ"'
)
run_p.add_argument("--config", help="agent config (default: NISSE_CONFIG)")
run_p.add_argument(
"--keep-runs",
type=Path,
help="keep each run's state store and call record here, for the status page",
)
run_p.set_defaults(handler=run)
report_p = sub.add_parser("report", help="compare stored results")
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"