Files
hermes-agent/tests/e2e/core/compaction/_helpers.py
teknium1 23366b44f3 test(e2e): close the core-suite review findings (narrow xfails, surfaced handler errors, no-retry CI)
Independent review of #120171 found checks that could not fail. Each is now
proven red by a mutation that the old version reported as XFAIL or pass.

- chaos/test_tui_gateway_turn_liveness: the orphaned-tool xfail used
  raises=AssertionError and RpcError subclasses it, so a gateway crash counted
  as the expected failure. Every invariant is now asserted normally; only the
  known leftovers (surviving tool tree and the tool_call it leaves without a
  result, both fixed by #120306) raise ToolOutlivedGateway, the only exception
  the xfail accepts. The DB check used to sit behind the orphan assert and
  never ran; running it exposed the dangling tool_call half of the same bug.
- history/test_prefix_stability: surface_switch's strict xfail tripped at the
  first prefix break, before usage and integrity. Messages/system prompt,
  usage and integrity are asserted first; the tools-array drift is checked
  last and raises ToolsArrayDrift, the only exception the xfail accepts.
- history/test_transcript_ledger: scripted steer/interrupt callables run on
  the fake provider's handler thread, where an assert only dropped the
  connection. Script records those failures and the test re-raises them after
  every turn; steer must land and the interrupted turn must report
  interrupted=True within 30 s.
- fakes/fake_llm_provider: Hang drops the connection at its deadline instead
  of leaving a kept-alive client waiting past it.
- parity: the API server port was picked, released, then bound by the child.
  Readiness now requires our child's pid from authenticated /health/detailed
  and retries on a fresh port when the child reports it in use. The fixture
  guard refused any HERMES_HOME under ~/.hermes, failing all parity tests
  whenever TMPDIR is Hermes's scratch dir; it now refuses only the live root
  or a real profile.
- chaos/_gateway_harness: the gateway stays in pytest's process group, so
  the runner's kill of a timed-out file reaches it.
- sqlite: a DELETE-mode open can fail with SQLITE_BUSY reported as "vtable
  constructor failed: messages_fts"; the delete arm's busy tolerance keys on
  the result code. A failed episode's roles are stopped so the shared chamber
  and rig no longer fail every later episode.
- chaos, compaction, parity homes: updates.check=false (history already had
  it). The passive update check made a GitHub round-trip from every test
  surface, and on a blobless clone whose objects lag upstream its
  `git merge-base --is-ancestor <upstream tip> HEAD` starts a lazy fetch that
  the 5 s timeout orphans; the orphan scans then failed on git processes.
- chaos/test_agent_turn_liveness: a PROBE failure now carries the provider
  call counts and the agent's stale-kill log, so a cross-turn breaker trip
  can be told apart from a slow probe.
- tests.yml e2e: HERMES_TEST_FILE_RETRIES=0 so a race detector's red is never
  retried into green; own uv cache entry (cache-suffix: e2e).
2026-09-23 14:54:36 -07:00

651 lines
29 KiB
Python

"""Lane-private harness for the compaction property / liveness suites (class C4).
Everything real except the LLM: a real ``AIAgent`` with a real ``SessionDB`` on disk, real tools
(``read_file`` on seeded files), real compaction, all talking to the recording loopback provider in
``tests/fakes/fake_llm_provider.py``. The provider is shared per module; each scenario installs its own
main-turn and summarizer responders on the ``Dispatch`` object.
The invariants live here so every test file asserts the SAME properties:
* ``assert_wire_request_ok`` — every request the model sees: system first, first user (job/task
anchor) present, tool_call/tool_result pairs matched, no user->user.
* ``assert_persisted_prefix`` — what state.db holds at request time is exactly the history prefix
the request carries; the rest is only the in-flight turn (persisted == sent).
* ``assert_db_recoverable`` — no user message is lost: each is live (``active=1``) or archived
as summarized history (``compacted=1``); a superseded duplicate (``active=0, compacted=0``) always
has an identical live/archived copy.
* ``assert_db_matches_history`` — the live rows equal the in-memory history the host keeps.
"""
from __future__ import annotations
import base64
import json
import random
import re
import sqlite3
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Iterable
from tests.fakes.fake_llm_provider import MODEL_ID, Error, FakeLLMServer, Hang, Text, ToolCall
# Every compaction knob is tiny so a handful of turns crosses the trigger. The window must stay at the
# 64K agent floor; ``threshold_tokens`` (an absolute cap) pulls the trigger far below the ratio floor.
CONTEXT_LENGTH = 64_000
THRESHOLD_TOKENS = 12_000
# Seconds the "slow" summarizer hangs; the summary timeout is pinned far below it.
SLOW_HANG_SECONDS = 60.0
SLOW_SUMMARY_TIMEOUT_SECONDS = 3.0
# Seeded session shape shared by the automatic suites.
SEEDS = (11, 23, 37)
N_TURNS = 10
# Legitimate summarizer traffic per turn: max_attempts (3) x (primary + one main-model retry) plus the
# stall fallback route. A runaway compaction loop (the 877-call class) blows far past this.
MAX_SUMMARY_CALLS_PER_TURN = 8
# Upper bound on one turn's wall time. The slowest legitimate turn is a slow-summarizer turn that waits
# one pinned timeout per attempt; 10x headroom over that keeps the bound load-proof.
TURN_WALL_BOUND_SECONDS = 120.0
MARKER_RE = re.compile(r"\bU\d+x\d+x[0-9a-f]{6}\b")
GOOD_SUMMARY_TOKEN = "SUMMARY-OK"
# Bodies of the bad summaries; none may ever reach the model or state.db as context.
REFUSAL_TEXT = "I'm sorry, but I can't help with summarizing this conversation."
TRUNCATED_TEXT = "## Goal\nThe user is work"
_PNG_1PX = base64.b64encode(bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154"
"78da63f8cfc0f01f0005000201ffa3a8b2b10000000049454e44ae426082")).decode()
def estimate_prompt_tokens(body: dict[str, Any]) -> int:
"""What the fake provider reports as ``usage.prompt_tokens``: chars/4 of the whole request."""
return len(json.dumps(body.get("messages", []))) // 4 + len(json.dumps(body.get("tools", []))) // 4
# ---------------------------------------------------------------------------------------------------
# Provider dispatch
# ---------------------------------------------------------------------------------------------------
class Dispatch:
"""Per-scenario responders behind one module-scoped provider."""
def __init__(self) -> None:
self.main: Callable[[dict[str, Any]], Any] = lambda _rec: Text("ok")
self.aux: Callable[[dict[str, Any]], Any] = lambda _rec: Text(good_summary(0))
def start_provider() -> tuple[FakeLLMServer, Dispatch]:
dispatch = Dispatch()
server = FakeLLMServer(
lambda rec: dispatch.main(rec), aux=lambda rec: dispatch.aux(rec),
prompt_tokens_fn=estimate_prompt_tokens)
server.start()
return server, dispatch
def good_summary(n: int) -> str:
return (
f"## Goal\nKeep helping with the seeded task ({GOOD_SUMMARY_TOKEN}-{n}).\n"
"## Progress\n### Done\n- Read several files and answered.\n"
"## Next Steps\n- Continue with the latest request.\n")
def summary_responder(mode: str, calls: list) -> Callable[[dict[str, Any]], Any]:
"""Summarizer behaviours from the C4 harness spec."""
def respond(rec: dict[str, Any]) -> Any:
calls.append(rec)
n = len(calls)
if mode == "good":
return Text(good_summary(n), chunk_chars=64)
if mode == "empty":
return Text("")
if mode == "refusal":
return Text(REFUSAL_TEXT)
if mode == "http500":
return Error(500, "summarizer upstream exploded")
if mode == "truncated":
return Text(TRUNCATED_TEXT, finish_reason="length")
if mode == "slow":
return Hang(SLOW_HANG_SECONDS)
raise AssertionError(f"unknown summarizer mode {mode!r}")
return respond
def compaction_config(*, extra: str = "", slow: bool = False, threshold_tokens: int = THRESHOLD_TOKENS) -> str:
cfg = (
"compression:\n"
f" threshold_tokens: {threshold_tokens}\n"
" protect_last_n: 4\n"
+ extra
+ "auxiliary:\n"
" title_generation:\n"
" enabled: false\n"
)
if slow:
cfg += f" compression:\n timeout: {SLOW_SUMMARY_TIMEOUT_SECONDS}\n"
return cfg
def write_home(hermes_home: Path, base_url: str, extra_config: str) -> None:
hermes_home.mkdir(parents=True, exist_ok=True)
(hermes_home / "config.yaml").write_text(
"model:\n"
" provider: custom\n"
f" base_url: {base_url}\n"
f" default: {MODEL_ID}\n"
f" context_length: {CONTEXT_LENGTH}\n"
"agent:\n"
" api_max_retries: 1\n"
"updates:\n"
" check: false\n" # offline: no GitHub round-trip or git lazy fetch from a test surface
+ extra_config,
encoding="utf-8",
)
(hermes_home / ".env").write_text("OPENAI_API_KEY=sk-fake-e2e\n", encoding="utf-8")
# ---------------------------------------------------------------------------------------------------
# Seeded transcripts
# ---------------------------------------------------------------------------------------------------
@dataclass
class TurnSpec:
marker: str
user: Any # str, or a list of content parts (image turns)
steps: list[Any]
kind: str
def _write_file(workdir: Path, name: str, rng: random.Random, chars: int) -> Path:
line_no = 0
lines = []
total = 0
while total < chars:
line = f"{name}:{line_no} " + " ".join(rng.choice(("alpha", "bravo", "delta", "omega", "sigma")) for _ in range(14))
lines.append(line)
total += len(line) + 1
line_no += 1
path = workdir / name
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
def generate_transcript(seed: int, n_turns: int, workdir: Path, *, kinds: Iterable[str] | None = None) -> list[TurnSpec]:
"""A seeded random session: tool-pair heavy (incl. parallel calls), huge single turns, image parts,
long answers, and turns whose single tool result alone exceeds the compaction threshold."""
rng = random.Random(seed)
workdir.mkdir(parents=True, exist_ok=True)
weights = {"tools": 5, "parallel": 3, "huge_user": 1, "image": 1, "chat": 2, "tail_bomb": 1}
if kinds is not None:
weights = {k: weights[k] for k in kinds}
names, ws = zip(*weights.items())
specs: list[TurnSpec] = []
fileno = 0
def new_file(chars: int) -> str:
nonlocal fileno
fileno += 1
return str(_write_file(workdir, f"s{seed}f{fileno}.txt", rng, chars))
for i in range(n_turns):
marker = f"U{seed}x{i}x{rng.getrandbits(24):06x}"
# Turn 0 is always a plain tool turn: its user message is the session's task anchor.
kind = "tools" if i == 0 else rng.choices(names, ws)[0]
steps: list[Any] = []
user: Any = f"{marker} please read the next file and report"
if kind == "tools":
for _ in range(rng.randint(1, 3)):
steps.append(ToolCall("read_file", {"path": new_file(rng.randint(600, 9000))}))
elif kind == "parallel":
for _ in range(rng.randint(1, 2)):
extra = [("read_file", {"path": new_file(rng.randint(400, 6000))}) for _ in range(rng.randint(1, 3))]
steps.append(ToolCall("read_file", {"path": new_file(rng.randint(400, 6000))}, parallel=extra))
elif kind == "huge_user":
filler = " ".join(f"w{rng.randint(0, 99999)}" for _ in range(rng.randint(2500, 6000)))
user = f"{marker} here is a large paste to keep in mind: {filler}"
elif kind == "image":
user = [
{"type": "text", "text": f"{marker} what is in this picture?"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{_PNG_1PX}"}},
]
elif kind == "tail_bomb":
steps.append(ToolCall("read_file", {"path": new_file(int(THRESHOLD_TOKENS * 4 * 1.3))}))
answer_len = rng.randint(4000, 12000) if kind == "chat" else rng.randint(20, 200)
answer = f"answer for {marker}: " + " ".join(f"t{rng.randint(0, 9999)}" for _ in range(answer_len // 6))
steps.append(Text(answer, chunk_chars=256))
specs.append(TurnSpec(marker=marker, user=user, steps=steps, kind=kind))
return specs
def job_turn(seed: int, workdir: Path, rounds: int) -> TurnSpec:
"""A cron-shaped run: ONE user message (the job prompt) followed by many tool rounds, so compaction
has to fire mid-turn with the job prompt as the only real ask."""
rng = random.Random(seed)
workdir.mkdir(parents=True, exist_ok=True)
marker = f"U{seed}x0x{rng.getrandbits(24):06x}"
steps: list[Any] = []
for k in range(rounds):
path = _write_file(workdir, f"job{seed}r{k}.txt", rng, rng.randint(3000, 9000))
steps.append(ToolCall("read_file", {"path": str(path)}))
steps.append(Text(f"job report for {marker}: done", chunk_chars=64))
return TurnSpec(marker=marker, user=f"{marker} daily job: read every report file and summarize", steps=steps, kind="job")
# ---------------------------------------------------------------------------------------------------
# Normalisation + invariants
# ---------------------------------------------------------------------------------------------------
def flatten(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
out = []
for part in content:
if isinstance(part, dict):
out.append(str(part.get("text") or ""))
else:
out.append(str(part))
return "\n".join(out)
return str(content)
def _squash(text: str) -> str:
return " ".join(text.split())
def _calls(tool_calls: Any) -> tuple:
if isinstance(tool_calls, str):
try:
tool_calls = json.loads(tool_calls)
except ValueError:
tool_calls = []
out = []
for tc in tool_calls or []:
fn = tc.get("function") or {}
args = fn.get("arguments")
try:
args = json.dumps(json.loads(args), sort_keys=True) if isinstance(args, str) else json.dumps(args, sort_keys=True)
except ValueError:
pass
out.append((tc.get("id"), fn.get("name"), args))
return tuple(out)
def norm(msg: dict[str, Any]) -> tuple:
"""Identity of a message that must survive persistence and request building unchanged.
A user message is identified by the seeded markers it carries (image turns are rendered to text
differently for the wire and for storage by design); everything else by its full content."""
role = msg.get("role")
text = flatten(msg.get("content"))
if role == "user":
marks = tuple(sorted(set(MARKER_RE.findall(text))))
return ("user", marks if marks else _squash(text))
if role == "assistant":
return ("assistant", _squash(text), _calls(msg.get("tool_calls")))
if role == "tool":
# Tool output may be shrunk on the wire by designed lossy steps (old-result pruning, oversized-result
# spill to disk); the pairing, not the bytes, is the invariant here.
return ("tool", msg.get("tool_call_id"))
return (role, _squash(text))
def merge_users(items: list[tuple]) -> list[tuple]:
"""Alternation repair merges back-to-back same-role rows (e.g. after an aborted turn) on the wire; the
merged row carries exactly the same user messages, so compare with the same merge applied."""
out: list[tuple] = []
for item in items:
if (item[0] == "user" and out and out[-1][0] == "user"
and isinstance(item[1], tuple) and isinstance(out[-1][1], tuple)):
out[-1] = ("user", tuple(sorted(set(out[-1][1]) | set(item[1]))))
continue
# Same repair for assistant->assistant (a split summary row followed by the next assistant).
if item[0] == "assistant" and out and out[-1][0] == "assistant" and not out[-1][2]:
out[-1] = ("assistant", _squash(f"{out[-1][1]} {item[1]}"), item[2])
continue
out.append(item)
return out
def norm_list(msgs: Iterable[dict[str, Any]]) -> list[tuple]:
return merge_users([norm(m) for m in msgs if m.get("role") != "system"])
def db_rows(db_path: Path, session_id: str) -> list[dict[str, Any]]:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=30)
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT id, role, content, tool_call_id, tool_calls, active, compacted FROM messages "
"WHERE session_id = ? ORDER BY id", (session_id,)).fetchall()
out = []
for r in rows:
d = dict(r)
content = d["content"]
if isinstance(content, str) and content.startswith("\x00json:"):
d["content"] = flatten(json.loads(content[len("\x00json:"):]))
out.append(d)
return out
finally:
conn.close()
def active_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [r for r in rows if r["active"]]
def assert_tool_pairs(msgs: list[dict[str, Any]], where: str, *, allow_trailing_pending: bool = False) -> None:
pending: dict[str, int] = {}
seen_calls: set[str] = set()
seen_results: set[str] = set()
for i, m in enumerate(msgs):
role = m.get("role")
if role == "assistant":
assert not pending, f"{where}: tool call(s) {sorted(pending)} unanswered before assistant #{i}"
for call_id, _name, _args in _calls(m.get("tool_calls")):
assert call_id not in seen_calls, f"{where}: tool_call id {call_id} issued twice (#{i})"
seen_calls.add(call_id)
pending[call_id] = i
elif role == "tool":
cid = m.get("tool_call_id")
assert cid in pending, f"{where}: orphan tool result {cid!r} at #{i} (no open assistant tool_call)"
assert cid not in seen_results, f"{where}: tool result {cid} duplicated (#{i})"
seen_results.add(cid)
del pending[cid]
elif role == "user":
assert not pending, f"{where}: tool call(s) {sorted(pending)} unanswered before user #{i}"
if not allow_trailing_pending:
assert not pending, f"{where}: tool call(s) {sorted(pending)} never answered"
def assert_wire_request_ok(body: dict[str, Any], turn_marker: str, base_system: str | None, where: str) -> None:
msgs = body["messages"]
assert msgs and msgs[0]["role"] == "system", f"{where}: system prompt missing from the request"
assert all(m["role"] != "system" for m in msgs[1:]), f"{where}: a second system message in the request"
if base_system is not None:
# The system anchor is built once per session; compaction may only append its note.
assert msgs[0]["content"].startswith(base_system), f"{where}: the session system prompt was rewritten"
# The in-flight ask (for a cron run: the job prompt) must reach the model AFTER any summary; a summary
# that swallows it leaves the model told to "respond to the message below" with nothing below (#100818).
text = "\n".join(flatten(m.get("content")) for m in msgs[1:] if m["role"] in ("user", "assistant", "tool"))
ask_at = max((i for i, m in enumerate(msgs) if m["role"] == "user" and turn_marker in flatten(m.get("content"))),
default=-1)
assert ask_at > 0, f"{where}: the in-flight user message {turn_marker} is missing from the request"
if GOOD_SUMMARY_TOKEN in text:
assert text.rfind(turn_marker) > text.rfind(GOOD_SUMMARY_TOKEN), (
f"{where}: the in-flight user message {turn_marker} only survives inside/before the summary")
assert_tool_pairs(msgs, where)
for a, b in zip(msgs, msgs[1:]):
assert not (a["role"] == "user" and b["role"] == "user"), f"{where}: two consecutive user messages"
assert msgs[-1]["role"] in ("user", "tool"), f"{where}: request ends with {msgs[-1]['role']}"
def assert_persisted_prefix(snapshot: list[tuple], body: dict[str, Any], turn_marker: str, first_new_call: int, where: str) -> None:
"""The durable rows at request time are exactly the request's history prefix; whatever follows is
only the in-flight turn (its own user message and the tool calls it issued)."""
sent = norm_list(body["messages"])
expected = list(snapshot)
if not any(it[0] == "user" and isinstance(it[1], tuple) and turn_marker in it[1] for it in expected):
expected = merge_users(expected + [("user", (turn_marker,))])
assert sent[: len(expected)] == expected, (
f"{where}: request history diverges from state.db (+ the in-flight user message) at the time it "
f"was sent\n first mismatch {_diff_at(sent, expected)}\n sent={_brief(sent)}\n db ={_brief(expected)}")
for item in sent[len(expected):]:
assert item[0] != "user", f"{where}: unpersisted user message {item[1]} in the request after the in-flight turn"
if item[0] == "assistant":
for call_id, _n, _a in item[2]:
assert int(call_id.rsplit("_", 1)[1]) >= first_new_call, (
f"{where}: unpersisted tool call {call_id} from an earlier turn in the request")
def assert_db_recoverable(rows: list[dict[str, Any]], sent_markers: Iterable[str], where: str) -> None:
live_or_archived = [r for r in rows if r["active"] or r["compacted"]]
marks_kept: set[str] = set()
for r in live_or_archived:
if r["role"] == "user":
marks_kept.update(MARKER_RE.findall(r["content"] or ""))
lost = [m for m in sent_markers if m not in marks_kept]
assert not lost, f"{where}: user message(s) {lost} are neither live nor archived in state.db (data loss)"
# A carried row may be re-inserted with the summary merged into its content, so the live copy only
# has to contain the original bytes (same role, same tool linkage).
keep: dict[tuple, list[str]] = {}
for r in live_or_archived:
keep.setdefault(_row_key(r), []).append(r["content"] or "")
# Tool outputs are exempt: pruning old tool results to stubs is a designed, lossy compaction step.
for r in rows:
if not r["active"] and not r["compacted"] and r["role"] in ("user", "assistant"):
copies = keep.get(_row_key(r), [])
if r["role"] == "user" and MARKER_RE.search(r["content"] or ""):
# Multimodal user rows are stored as a placeholder by the turn flush but as the full parts
# list when carried; identity is the seeded marker.
marks = set(MARKER_RE.findall(r["content"]))
covered = any(marks <= set(MARKER_RE.findall(c)) for c in copies)
else:
covered = any((r["content"] or "") in c for c in copies)
assert covered, (
f"{where}: row {r['id']} ({r['role']}) was retired as a carried duplicate but no live or "
f"archived copy exists: {str(r['content'])[:80]!r}; same-linkage copies: "
f"{[c[:200] for c in copies]}")
def assert_db_matches_history(rows: list[dict[str, Any]], history: list[dict[str, Any]], where: str) -> None:
live = norm_list(_row_msg(r) for r in active_rows(rows))
mem = norm_list(history)
assert live == mem, (
f"{where}: live state.db rows differ from the host's in-memory history at #{_first_mismatch(live, mem)}\n"
f" db ={_brief(live)}\n mem={_brief(mem)}")
assert_tool_pairs([_row_msg(r) for r in active_rows(rows)], f"{where} (state.db live rows)")
def _row_msg(r: dict[str, Any]) -> dict[str, Any]:
return {"role": r["role"], "content": r["content"], "tool_calls": r["tool_calls"], "tool_call_id": r["tool_call_id"]}
def _row_key(r: dict[str, Any]) -> tuple:
return (r["role"], r["tool_call_id"], json.dumps(_calls(r["tool_calls"])))
def _diff_at(a: list, b: list) -> str:
i = _first_mismatch(a, b)
x = a[i] if i < len(a) else None
y = b[i] if i < len(b) else None
return f"#{i}: {str(x)[:160]!r} ... {str(x)[-160:]!r}\n vs {str(y)[:160]!r} ... {str(y)[-160:]!r}"
def _first_mismatch(a: list, b: list) -> int:
for i, (x, y) in enumerate(zip(a, b)):
if x != y:
return i
return min(len(a), len(b))
def _brief(items: list[tuple]) -> list[str]:
out = []
for it in items:
if it[0] == "user":
out.append(f"U{it[1] if isinstance(it[1], tuple) else '(' + it[1][:30] + ')'}")
elif it[0] == "assistant":
out.append(f"A[{','.join(c[0] for c in it[2])}]{it[1][:24]!r}")
else:
out.append(f"{it[0][0].upper()}:{it[1]}")
return out
# ---------------------------------------------------------------------------------------------------
# Scenario driver
# ---------------------------------------------------------------------------------------------------
@dataclass
class TurnRecord:
spec: TurnSpec
result: dict[str, Any]
elapsed: float
main_requests: list[dict[str, Any]]
summary_calls: int
statuses: list[tuple]
aborted: bool = False
@dataclass
class Scenario:
"""One session: its own HERMES_HOME, state.db and agent against the shared provider."""
server: FakeLLMServer
dispatch: Dispatch
home: Path
mode: str = "good"
session_id: str = "compaction-e2e"
platform: str = "cli"
window: int | None = None # provider-enforced context window (tokens); None = unlimited
history: list[dict[str, Any]] = field(default_factory=list)
sent_markers: list[str] = field(default_factory=list)
summary_calls: list = field(default_factory=list)
statuses: list[tuple] = field(default_factory=list)
turns: list[TurnRecord] = field(default_factory=list)
base_system: str | None = None
def __post_init__(self) -> None:
self.db_path = self.home / "state.db"
self._queue: list[Any] = []
self._qlock = threading.Lock()
self._snapshots: list[tuple[dict[str, Any], list[tuple]]] = []
self.overflow_rejections = 0
self.db = None
self.agent = None
# provider side ------------------------------------------------------------------------------
def install(self) -> None:
self.dispatch.main = self._main
self.dispatch.aux = summary_responder(self.mode, self.summary_calls)
def _main(self, rec: dict[str, Any]) -> Any:
body = rec["body"]
snap = norm_list(_row_msg(r) for r in active_rows(db_rows(self.db_path, self.session_id))) if self.db_path.exists() else []
self._snapshots.append((body, snap))
if self.window is not None and estimate_prompt_tokens(body) > self.window:
self.overflow_rejections += 1
return Error(400, f"This model's maximum context length is {self.window} tokens. However, your "
f"messages resulted in {estimate_prompt_tokens(body)} tokens. (context_length_exceeded)")
with self._qlock:
if self._queue:
return self._queue.pop(0)
return Text("(unscripted extra request)")
# agent side ---------------------------------------------------------------------------------
def build_agent(self) -> Any:
from hermes_state import SessionDB
from run_agent import AIAgent
if self.db is None:
self.db = SessionDB(db_path=self.db_path)
self.agent = AIAgent(
provider="custom", base_url=self.server.base_url, api_key="sk-fake-e2e", model=MODEL_ID,
session_db=self.db, session_id=self.session_id, quiet_mode=True, platform=self.platform,
enabled_toolsets=["file"], skip_context_files=True, skip_memory=True,
status_callback=lambda *a, **_k: self.statuses.append(tuple(a)),
)
return self.agent
def resume_from_db(self) -> None:
"""Gateway/Desktop shape: a fresh agent whose history is whatever state.db hands back."""
self.close_agent()
self.build_agent()
self.history = self.db.get_messages_as_conversation(self.session_id)
def close_agent(self) -> None:
if self.agent is not None:
close = getattr(self.agent, "close", None)
if callable(close):
close()
self.agent = None
def close(self) -> None:
self.close_agent()
if self.db is not None:
self.db.close()
self.db = None
def run_turn(self, spec: TurnSpec, *, allow_abort: bool = False) -> TurnRecord:
if self.agent is None:
self.build_agent()
self.install()
with self._qlock:
self._queue = list(spec.steps)
first_req = len(self._snapshots)
summary_before = len(self.summary_calls)
status_before = len(self.statuses)
with self.server._lock:
first_new_call = self.server._tool_seq + 1
started = time.monotonic()
result = self.agent.run_conversation(spec.user, conversation_history=self.history)
elapsed = time.monotonic() - started
self.sent_markers.append(spec.marker)
where = f"turn {len(self.turns)} ({spec.kind}, mode={self.mode})"
# (1) liveness: bounded wall time, every scripted step consumed exactly once.
assert elapsed < TURN_WALL_BOUND_SECONDS, f"{where}: turn took {elapsed:.1f}s"
with self._qlock:
unconsumed = len(self._queue)
self._queue = []
if unconsumed:
# Stopping early is only acceptable as an explicit, user-visible failure.
assert allow_abort, (
f"{where}: turn ended with {unconsumed} scripted step(s) unconsumed: "
f"{ {k: result.get(k) for k in ('failed', 'error', 'turn_exit_reason', 'final_response')} }")
assert result.get("error") or result.get("failed"), (
f"{where}: turn stopped early without an explicit error: "
f"{ {k: result.get(k) for k in ('failed', 'error', 'turn_exit_reason', 'final_response')} }")
reqs = self._snapshots[first_req:]
if self.base_system is None and reqs:
self.base_system = reqs[0][0]["messages"][0]["content"]
# (2)+(4) every request: well-formed, anchored, pairs matched, persisted prefix + in-flight turn.
for k, (body, snap) in enumerate(reqs):
w = f"{where} request {k}"
assert_wire_request_ok(body, spec.marker, self.base_system, w)
assert_persisted_prefix(snap, body, spec.marker, first_new_call, w)
rows = db_rows(self.db_path, self.session_id)
assert_db_recoverable(rows, self.sent_markers, where)
self.history = result["messages"]
assert_db_matches_history(rows, self.history, where)
rec = TurnRecord(spec, result, elapsed, [b for b, _ in reqs], len(self.summary_calls) - summary_before,
self.statuses[status_before:], aborted=bool(unconsumed))
self.turns.append(rec)
return rec
# derived facts ----------------------------------------------------------------------------------
def committed_compactions(self) -> int:
return sum(1 for r in db_rows(self.db_path, self.session_id) if r["compacted"])
def summary_rows(self) -> list[dict[str, Any]]:
return [r for r in db_rows(self.db_path, self.session_id) if r["active"] and GOOD_SUMMARY_TOKEN in (r["content"] or "")]
def drive(sc: Scenario, seed: int, workdir: Path, n_turns: int = N_TURNS, *, resume_every: int = 4,
kinds: Iterable[str] | None = None) -> list[TurnSpec]:
"""Run a seeded session through ``sc``; every ``resume_every`` turns the host is replaced by a fresh
agent resumed from state.db (the gateway/Desktop shape). Every turn asserts the shared invariants."""
specs = generate_transcript(seed, n_turns, workdir, kinds=kinds)
for i, spec in enumerate(specs):
if i and resume_every and i % resume_every == 0:
sc.resume_from_db()
rec = sc.run_turn(spec)
assert rec.summary_calls <= MAX_SUMMARY_CALLS_PER_TURN, (
f"turn {i}: {rec.summary_calls} summarizer calls in one turn (runaway compaction)")
return specs
def warned(sc: Scenario) -> bool:
"""The user was told something about context/compaction (status ``warn`` / ``error`` lines)."""
return any(s and s[0] in ("warn", "error") for s in sc.statuses)