File size: 8,967 Bytes
42ee5cf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | """A run's record, chained so that editing it is louder than deleting it.
The harness already refuses to take a brain's word for success, and a trace already keeps a
run's decisions so they can be replayed and scored. What neither does is make the *record*
checkable. A JSON file can be edited by whoever would prefer a better result, and nothing
notices; a directory of screenshots can lose the one frame that proves a failure, and nothing
notices either.
An audit log is an append-only JSONL whose every line carries the digest of the line before
it. Change one entry and its own digest stops matching; delete one and the next entry's `prev`
points at an entry that is not there; reorder them and the indices give it away.
:func:`verify_audit` says which entry is at fault, by number, because "the log is fine" is
only useful if a reader can check it.
log = AuditLog(Path("runs/today.jsonl"))
loop = AgentLoop(screen=..., brain=..., audit=log) # one entry per step
loop.run("click 'Save' to save the document")
ok, detail = verify_audit("runs/today.jsonl") # (True, "3 entries, chain intact")
What this is not: a signature. A hash chain catches edits, not an author who simply writes a
whole new chain. It is evidence against accident and casual tampering, and it says so.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from tiny_computer_agent.observe import Observation
__all__ = ["AuditEntry", "AuditLog", "observation_digest", "read_audit", "verify_audit"]
def _canonical(payload: dict[str, Any]) -> str:
"""One stable spelling per entry, so a digest means the same thing twice."""
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def observation_digest(observation: Observation) -> str:
"""A digest of what was on screen: the element index, not the pixels.
Two frames with the same labels in the same places hash the same, which is the point -
a step is tied to the screen it was decided on without storing a screenshot.
"""
index = [[element.label, element.kind, element.x, element.y, element.width, element.height]
for element in observation.elements]
return hashlib.sha256(_canonical({"index": index, "text": observation.text[:200]})
.encode("utf-8")).hexdigest()[:16]
@dataclass(frozen=True)
class AuditEntry:
"""One line of the log: a step, or the episode that closed it."""
kind: str # "step" | "episode"
index: int
at: str
goal: str
screen: str
action: str
outcome: str
ok: bool
prev: str
digest: str = ""
def payload(self) -> dict[str, Any]:
"""Everything the digest covers - which is everything except the digest."""
return {"kind": self.kind, "index": self.index, "at": self.at, "goal": self.goal,
"screen": self.screen, "action": self.action, "outcome": self.outcome,
"ok": self.ok, "prev": self.prev}
def compute_digest(self) -> str:
return hashlib.sha256(_canonical(self.payload()).encode("utf-8")).hexdigest()
def to_dict(self) -> dict[str, Any]:
return {**self.payload(), "digest": self.digest}
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "AuditEntry":
return cls(kind=raw.get("kind", "step"), index=raw.get("index", -1),
at=raw.get("at", ""), goal=raw.get("goal", ""), screen=raw.get("screen", ""),
action=raw.get("action", ""), outcome=raw.get("outcome", ""),
ok=bool(raw.get("ok", False)), prev=raw.get("prev", ""),
digest=raw.get("digest", ""))
@dataclass
class AuditLog:
"""An append-only, hash-chained record of what an episode did."""
path: Path | str
_last: str = field(default="", init=False, repr=False)
_count: int = field(default=0, init=False, repr=False)
def __post_init__(self) -> None:
self.path = Path(self.path)
self.path.parent.mkdir(parents=True, exist_ok=True)
# resuming a log keeps its chain: pick up the last digest rather than starting over
if self.path.exists():
existing = read_audit(self.path)
self._count = len(existing)
if existing:
self._last = existing[-1].digest
# -- writing ----------------------------------------------------------- #
def _write(self, kind: str, *, goal: str, screen: str = "", action: str = "",
outcome: str = "", ok: bool = False) -> AuditEntry:
entry = AuditEntry(kind=kind, index=self._count,
at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
goal=goal, screen=screen, action=action, outcome=outcome,
ok=ok, prev=self._last)
entry = AuditEntry(**{**entry.payload(), "digest": entry.compute_digest()})
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(_canonical(entry.to_dict()) + "\n")
self._last, self._count = entry.digest, self._count + 1
return entry
def append(self, step: Any, goal: str = "") -> AuditEntry:
"""Record one step. Accepts the loop's :class:`~tiny_computer_agent.loop.Step`."""
result = getattr(step, "result", None)
outcome = (getattr(step, "error", None) or (result.summary() if result else "")) or ""
return self._write("step", goal=goal or "",
screen=observation_digest(step.observation),
action=step.action.describe() if step.action else "",
outcome=str(outcome), ok=bool(step.ok))
def close(self, episode: Any) -> AuditEntry:
"""Record how the episode ended - verified, failed, refused, or out of budget."""
return self._write("episode", goal=getattr(episode, "goal", ""),
outcome=f"{'success' if episode.success else 'failure'}: "
f"{episode.stop_reason}",
ok=bool(episode.success))
# -- reading ----------------------------------------------------------- #
def entries(self) -> list[AuditEntry]:
return read_audit(self.path)
def verify(self) -> tuple[bool, str]:
return verify_audit(self.path)
def render(self) -> str:
entries = self.entries()
steps = [e for e in entries if e.kind == "step"]
ok, detail = self.verify()
counts: dict[str, int] = {}
for entry in steps:
label = "ok" if entry.ok else "failed"
counts[label] = counts.get(label, 0) + 1
tally = ", ".join(f"{n} {label}" for label, n in sorted(counts.items())) or "no steps"
return (f"audit: {len(entries)} entries ({len(steps)} steps, {tally}) | "
f"chain {'intact' if ok else 'BROKEN'}: {detail}")
def read_audit(path: Path | str) -> list[AuditEntry]:
"""Every entry in the log, in order. A malformed line is an entry that fails to load."""
path = Path(path)
if not path.exists():
return []
entries: list[AuditEntry] = []
with open(path, encoding="utf-8") as handle:
for line in handle:
if line.strip():
entries.append(AuditEntry.from_dict(json.loads(line)))
return entries
def verify_audit(path: Path | str) -> tuple[bool, str]:
"""Check the chain. Returns ``(ok, detail)``, naming the first entry at fault.
Deliberately reports *which* entry broke and *how* - a digest that does not match, a
`prev` pointing at nothing, or an index out of order - because those three failures mean
different things to whoever has to decide whether to trust the run.
"""
path = Path(path)
if not path.exists():
return False, f"no audit log at {path}"
through = ""
count = 0
for number, line in enumerate((l for l in path.read_text(encoding="utf-8").splitlines()
if l.strip()), start=1):
entry = AuditEntry.from_dict(json.loads(line))
if entry.index != number - 1:
return False, (f"entry {number} is out of order (index {entry.index} where "
f"{number - 1} was expected): lines were removed or reordered")
if entry.prev != through:
return False, (f"entry {number} does not follow entry {number - 1}: its link "
f"points at an entry that is not there")
if entry.compute_digest() != entry.digest:
return False, f"entry {number} was edited after it was written"
through = entry.digest
count += 1
if count == 0:
return False, f"{path} is empty"
return True, f"{count} entries, chain intact"
|