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