Package: 2.0 - watchdog and audit modules
Browse files- tiny_computer_agent/__init__.py +32 -21
- tiny_computer_agent/__main__.py +45 -1
- tiny_computer_agent/audit.py +202 -0
- tiny_computer_agent/gates.py +34 -0
- tiny_computer_agent/loop.py +38 -5
- tiny_computer_agent/watchdog.py +147 -0
tiny_computer_agent/__init__.py
CHANGED
|
@@ -36,6 +36,13 @@ from tiny_computer_agent.accessibility import (
|
|
| 36 |
UiaReader,
|
| 37 |
accessibility_available,
|
| 38 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
from tiny_computer_agent.act import (
|
| 40 |
ActionResult,
|
| 41 |
DryRunExecutor,
|
|
@@ -118,6 +125,7 @@ from tiny_computer_agent.gates import (
|
|
| 118 |
DenyAll,
|
| 119 |
Gate,
|
| 120 |
InteractiveGate,
|
|
|
|
| 121 |
mutating,
|
| 122 |
)
|
| 123 |
from tiny_computer_agent.runner import RunReport, Task, TaskOutcome, TaskRunner
|
|
@@ -131,6 +139,7 @@ from tiny_computer_agent.skills import (
|
|
| 131 |
record_skill,
|
| 132 |
slug,
|
| 133 |
)
|
|
|
|
| 134 |
from tiny_computer_agent.trace import (
|
| 135 |
Scenario,
|
| 136 |
Scoreboard,
|
|
@@ -152,29 +161,31 @@ __all__ = [
|
|
| 152 |
"ACTION_KINDS", "ACTION_MENU", "AccessibleNode", "Action",
|
| 153 |
"ActionError", "ActionResult", "AgentLoop", "AllOf",
|
| 154 |
"AllowAll", "AllowOnlyLabels", "AnyOf", "Approval",
|
| 155 |
-
"AtspiDetector", "AtspiReader", "
|
| 156 |
-
"
|
| 157 |
-
"
|
| 158 |
-
"
|
| 159 |
-
"
|
| 160 |
-
"
|
| 161 |
-
"
|
| 162 |
-
"
|
| 163 |
-
"
|
| 164 |
-
"
|
| 165 |
-
"
|
| 166 |
-
"
|
| 167 |
-
"
|
| 168 |
-
"
|
| 169 |
-
"
|
| 170 |
-
"
|
|
|
|
| 171 |
"__version__", "accessibility_available", "benchmark", "benchmark_traces",
|
| 172 |
"build_agent", "detector_available", "diff", "display_available",
|
| 173 |
"doctor", "element_key", "extract_json_object", "extract_json_objects",
|
| 174 |
-
"fake_desktop", "load_traces", "make_detector",
|
| 175 |
-
"model_cached", "model_dependencies", "mutating",
|
| 176 |
-
"
|
| 177 |
-
"
|
| 178 |
-
"
|
|
|
|
| 179 |
"write_fake_desktop",
|
| 180 |
]
|
|
|
|
| 36 |
UiaReader,
|
| 37 |
accessibility_available,
|
| 38 |
)
|
| 39 |
+
from tiny_computer_agent.audit import (
|
| 40 |
+
AuditEntry,
|
| 41 |
+
AuditLog,
|
| 42 |
+
observation_digest,
|
| 43 |
+
read_audit,
|
| 44 |
+
verify_audit,
|
| 45 |
+
)
|
| 46 |
from tiny_computer_agent.act import (
|
| 47 |
ActionResult,
|
| 48 |
DryRunExecutor,
|
|
|
|
| 125 |
DenyAll,
|
| 126 |
Gate,
|
| 127 |
InteractiveGate,
|
| 128 |
+
gate_from_spec,
|
| 129 |
mutating,
|
| 130 |
)
|
| 131 |
from tiny_computer_agent.runner import RunReport, Task, TaskOutcome, TaskRunner
|
|
|
|
| 139 |
record_skill,
|
| 140 |
slug,
|
| 141 |
)
|
| 142 |
+
from tiny_computer_agent.watchdog import Watchdog, WatchdogTimeout
|
| 143 |
from tiny_computer_agent.trace import (
|
| 144 |
Scenario,
|
| 145 |
Scoreboard,
|
|
|
|
| 161 |
"ACTION_KINDS", "ACTION_MENU", "AccessibleNode", "Action",
|
| 162 |
"ActionError", "ActionResult", "AgentLoop", "AllOf",
|
| 163 |
"AllowAll", "AllowOnlyLabels", "AnyOf", "Approval",
|
| 164 |
+
"AtspiDetector", "AtspiReader", "AuditEntry", "AuditLog",
|
| 165 |
+
"AutoDetector", "BRAIN_NAMES", "Brain", "BrainError",
|
| 166 |
+
"ChangeVerifier", "Check", "DEFAULT_KILL_SWITCH", "DEFAULT_SKILLS_PATH",
|
| 167 |
+
"DEFAULT_TEXT_MODEL", "DEFAULT_VISION_MODEL", "DecisionMenu", "Delta",
|
| 168 |
+
"DenyAll", "Detector", "DetectorUnavailable", "DryRunExecutor",
|
| 169 |
+
"Element", "Episode", "Executor", "FakeScreen",
|
| 170 |
+
"Gate", "GoalUnreachable", "GrabScreen", "GridDetector",
|
| 171 |
+
"INTERACTIVE_ROLES", "ImageScreen", "IndexedScreen", "InputUnavailable",
|
| 172 |
+
"InteractiveGate", "MODEL_BRAINS", "MenuItem", "MockBrain",
|
| 173 |
+
"ModelBrain", "Observation", "OcrDetector", "PlannerBrain",
|
| 174 |
+
"Policy", "PolicyViolation", "PyAutoGuiExecutor", "READ_ONLY_KINDS",
|
| 175 |
+
"RunReport", "Scenario", "Scoreboard", "ScreenSource",
|
| 176 |
+
"ScreenUnavailable", "Skill", "SkillBrain", "SkillLibrary",
|
| 177 |
+
"SkillStep", "Step", "TEXT_ROLES", "Task",
|
| 178 |
+
"TaskOutcome", "TaskRunner", "TextPlannerBrain", "Trace",
|
| 179 |
+
"TraceStep", "TreeReader", "UiaDetector", "UiaReader",
|
| 180 |
+
"VLMBrain", "Verdict", "Watchdog", "WatchdogTimeout",
|
| 181 |
"__version__", "accessibility_available", "benchmark", "benchmark_traces",
|
| 182 |
"build_agent", "detector_available", "diff", "display_available",
|
| 183 |
"doctor", "element_key", "extract_json_object", "extract_json_objects",
|
| 184 |
+
"fake_desktop", "gate_from_spec", "load_traces", "make_detector",
|
| 185 |
+
"make_model_brain", "model_cached", "model_dependencies", "mutating",
|
| 186 |
+
"normalise_label", "observation_digest", "parse_action", "parse_decision",
|
| 187 |
+
"pick_dtype", "read_audit", "record_skill", "resolve_device",
|
| 188 |
+
"resolve_point", "run_checks", "run_scenario", "score",
|
| 189 |
+
"score_replay", "score_trace", "slug", "verify_audit",
|
| 190 |
"write_fake_desktop",
|
| 191 |
]
|
tiny_computer_agent/__main__.py
CHANGED
|
@@ -42,6 +42,8 @@ from tiny_computer_agent.safety import Policy
|
|
| 42 |
from tiny_computer_agent.runner import Task, TaskRunner
|
| 43 |
from tiny_computer_agent.skills import DEFAULT_SKILLS_PATH, SkillBrain, SkillLibrary, record_skill
|
| 44 |
from tiny_computer_agent.trace import Trace, score_trace
|
|
|
|
|
|
|
| 45 |
|
| 46 |
ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
|
| 47 |
EXAMPLE_IMAGE = "example"
|
|
@@ -103,6 +105,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 103 |
parser.add_argument("--trace", default=None, metavar="PATH",
|
| 104 |
help="write a portable trace of the run (frames, no pixels) so it "
|
| 105 |
"can be replayed and scored offline")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
parser.add_argument("--expected", default=None, metavar="LABEL",
|
| 107 |
help="the element label that is the right answer for this goal; "
|
| 108 |
"recorded in the trace so the run can be scored")
|
|
@@ -141,6 +150,23 @@ def _probe_live(args: argparse.Namespace) -> Check:
|
|
| 141 |
fix="run this on the desktop session itself, not a headless shell")
|
| 142 |
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
def _build_screen(args: argparse.Namespace):
|
| 145 |
"""(screen, description). Raises ScreenUnavailable / OSError on failure."""
|
| 146 |
detector_name = "grid" if args.no_ocr else args.detector
|
|
@@ -345,6 +371,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 345 |
args = build_parser().parse_args(argv)
|
| 346 |
detector_name = "grid" if args.no_ocr else args.detector
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
if args.doctor:
|
| 349 |
checks = run_checks(brain=args.brain, detector=detector_name,
|
| 350 |
real_input=args.real_input, kill_switch_path=args.kill_switch)
|
|
@@ -410,6 +447,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 410 |
print(f"goal: {args.goal}")
|
| 411 |
print(f"mode: {mode} | screen: {description} | brain: {args.brain} | "
|
| 412 |
f"kill switch: create {args.kill_switch} to stop")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
if args.image is not None or args.live:
|
| 414 |
_, index_detail = detector_available(detector_name)
|
| 415 |
print(f"index: {detector_name} - {index_detail}")
|
|
@@ -445,7 +486,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 445 |
|
| 446 |
try:
|
| 447 |
episode = AgentLoop(screen=screen, brain=brain, executor=executor,
|
| 448 |
-
policy=policy, on_step=on_step, verify=verify
|
|
|
|
| 449 |
except ScreenUnavailable as exc:
|
| 450 |
print(f"cannot see the screen: {exc}", file=sys.stderr)
|
| 451 |
print("run `python -m tiny_computer_agent --doctor --live` for details.", file=sys.stderr)
|
|
@@ -453,6 +495,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 453 |
|
| 454 |
print()
|
| 455 |
print(episode.render())
|
|
|
|
|
|
|
| 456 |
|
| 457 |
if args.trace:
|
| 458 |
trace = Trace.record(episode, expected=args.expected, brain=args.brain,
|
|
|
|
| 42 |
from tiny_computer_agent.runner import Task, TaskRunner
|
| 43 |
from tiny_computer_agent.skills import DEFAULT_SKILLS_PATH, SkillBrain, SkillLibrary, record_skill
|
| 44 |
from tiny_computer_agent.trace import Trace, score_trace
|
| 45 |
+
from tiny_computer_agent.audit import AuditLog, verify_audit
|
| 46 |
+
from tiny_computer_agent.gates import Gate, gate_from_spec
|
| 47 |
|
| 48 |
ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
|
| 49 |
EXAMPLE_IMAGE = "example"
|
|
|
|
| 105 |
parser.add_argument("--trace", default=None, metavar="PATH",
|
| 106 |
help="write a portable trace of the run (frames, no pixels) so it "
|
| 107 |
"can be replayed and scored offline")
|
| 108 |
+
parser.add_argument("--gate", default=None,
|
| 109 |
+
help="the operator's yes, before each action: read-only | interactive "
|
| 110 |
+
"| labels:Save,Cancel | allow-all")
|
| 111 |
+
parser.add_argument("--audit", default=None, metavar="PATH",
|
| 112 |
+
help="append a hash-chained record of this run to a JSONL file")
|
| 113 |
+
parser.add_argument("--verify-audit", default=None, metavar="PATH",
|
| 114 |
+
help="check an audit log's chain and exit (0 intact, 1 tampered)")
|
| 115 |
parser.add_argument("--expected", default=None, metavar="LABEL",
|
| 116 |
help="the element label that is the right answer for this goal; "
|
| 117 |
"recorded in the trace so the run can be scored")
|
|
|
|
| 150 |
fix="run this on the desktop session itself, not a headless shell")
|
| 151 |
|
| 152 |
|
| 153 |
+
def _gate_from_spec_arg(args: argparse.Namespace) -> tuple[Gate | None, str | None]:
|
| 154 |
+
"""The operator's gate, from the one-line spelling on the command line."""
|
| 155 |
+
spec = getattr(args, "gate", None)
|
| 156 |
+
if not spec:
|
| 157 |
+
return None, None
|
| 158 |
+
try:
|
| 159 |
+
return gate_from_spec(spec), None
|
| 160 |
+
except ValueError as exc:
|
| 161 |
+
return None, f"bad --gate: {exc}"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _audit_from_arg(args: argparse.Namespace) -> AuditLog | None:
|
| 165 |
+
"""Append-only evidence for this run, if the operator asked for it."""
|
| 166 |
+
path = getattr(args, "audit", None)
|
| 167 |
+
return AuditLog(path) if path else None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
def _build_screen(args: argparse.Namespace):
|
| 171 |
"""(screen, description). Raises ScreenUnavailable / OSError on failure."""
|
| 172 |
detector_name = "grid" if args.no_ocr else args.detector
|
|
|
|
| 371 |
args = build_parser().parse_args(argv)
|
| 372 |
detector_name = "grid" if args.no_ocr else args.detector
|
| 373 |
|
| 374 |
+
if args.verify_audit:
|
| 375 |
+
intact, detail = verify_audit(args.verify_audit)
|
| 376 |
+
print(f"{'ok ' if intact else 'FAIL'} audit chain: {detail}")
|
| 377 |
+
return 0 if intact else 1
|
| 378 |
+
|
| 379 |
+
gate, gate_error = _gate_from_spec_arg(args)
|
| 380 |
+
if gate_error is not None:
|
| 381 |
+
print(gate_error, file=sys.stderr)
|
| 382 |
+
return 2
|
| 383 |
+
audit = _audit_from_arg(args)
|
| 384 |
+
|
| 385 |
if args.doctor:
|
| 386 |
checks = run_checks(brain=args.brain, detector=detector_name,
|
| 387 |
real_input=args.real_input, kill_switch_path=args.kill_switch)
|
|
|
|
| 447 |
print(f"goal: {args.goal}")
|
| 448 |
print(f"mode: {mode} | screen: {description} | brain: {args.brain} | "
|
| 449 |
f"kill switch: create {args.kill_switch} to stop")
|
| 450 |
+
if gate is not None:
|
| 451 |
+
print(f"gate: {args.gate} - every action needs the operator's yes")
|
| 452 |
+
if audit is not None:
|
| 453 |
+
print(f"audit: recording this run to {audit.path}")
|
| 454 |
if args.image is not None or args.live:
|
| 455 |
_, index_detail = detector_available(detector_name)
|
| 456 |
print(f"index: {detector_name} - {index_detail}")
|
|
|
|
| 486 |
|
| 487 |
try:
|
| 488 |
episode = AgentLoop(screen=screen, brain=brain, executor=executor,
|
| 489 |
+
policy=policy, on_step=on_step, verify=verify,
|
| 490 |
+
gate=gate, audit=audit).run(args.goal)
|
| 491 |
except ScreenUnavailable as exc:
|
| 492 |
print(f"cannot see the screen: {exc}", file=sys.stderr)
|
| 493 |
print("run `python -m tiny_computer_agent --doctor --live` for details.", file=sys.stderr)
|
|
|
|
| 495 |
|
| 496 |
print()
|
| 497 |
print(episode.render())
|
| 498 |
+
if audit is not None:
|
| 499 |
+
print(audit.render())
|
| 500 |
|
| 501 |
if args.trace:
|
| 502 |
trace = Trace.record(episode, expected=args.expected, brain=args.brain,
|
tiny_computer_agent/audit.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A run's record, chained so that editing it is louder than deleting it.
|
| 2 |
+
|
| 3 |
+
The harness already refuses to take a brain's word for success, and a trace already keeps a
|
| 4 |
+
run's decisions so they can be replayed and scored. What neither does is make the *record*
|
| 5 |
+
checkable. A JSON file can be edited by whoever would prefer a better result, and nothing
|
| 6 |
+
notices; a directory of screenshots can lose the one frame that proves a failure, and nothing
|
| 7 |
+
notices either.
|
| 8 |
+
|
| 9 |
+
An audit log is an append-only JSONL whose every line carries the digest of the line before
|
| 10 |
+
it. Change one entry and its own digest stops matching; delete one and the next entry's `prev`
|
| 11 |
+
points at an entry that is not there; reorder them and the indices give it away.
|
| 12 |
+
:func:`verify_audit` says which entry is at fault, by number, because "the log is fine" is
|
| 13 |
+
only useful if a reader can check it.
|
| 14 |
+
|
| 15 |
+
log = AuditLog(Path("runs/today.jsonl"))
|
| 16 |
+
loop = AgentLoop(screen=..., brain=..., audit=log) # one entry per step
|
| 17 |
+
loop.run("click 'Save' to save the document")
|
| 18 |
+
|
| 19 |
+
ok, detail = verify_audit("runs/today.jsonl") # (True, "3 entries, chain intact")
|
| 20 |
+
|
| 21 |
+
What this is not: a signature. A hash chain catches edits, not an author who simply writes a
|
| 22 |
+
whole new chain. It is evidence against accident and casual tampering, and it says so.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import hashlib
|
| 28 |
+
import json
|
| 29 |
+
from dataclasses import dataclass, field
|
| 30 |
+
from datetime import datetime, timezone
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Any, Iterable
|
| 33 |
+
|
| 34 |
+
from tiny_computer_agent.observe import Observation
|
| 35 |
+
|
| 36 |
+
__all__ = ["AuditEntry", "AuditLog", "observation_digest", "read_audit", "verify_audit"]
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _canonical(payload: dict[str, Any]) -> str:
|
| 40 |
+
"""One stable spelling per entry, so a digest means the same thing twice."""
|
| 41 |
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def observation_digest(observation: Observation) -> str:
|
| 45 |
+
"""A digest of what was on screen: the element index, not the pixels.
|
| 46 |
+
|
| 47 |
+
Two frames with the same labels in the same places hash the same, which is the point -
|
| 48 |
+
a step is tied to the screen it was decided on without storing a screenshot.
|
| 49 |
+
"""
|
| 50 |
+
index = [[element.label, element.kind, element.x, element.y, element.width, element.height]
|
| 51 |
+
for element in observation.elements]
|
| 52 |
+
return hashlib.sha256(_canonical({"index": index, "text": observation.text[:200]})
|
| 53 |
+
.encode("utf-8")).hexdigest()[:16]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass(frozen=True)
|
| 57 |
+
class AuditEntry:
|
| 58 |
+
"""One line of the log: a step, or the episode that closed it."""
|
| 59 |
+
|
| 60 |
+
kind: str # "step" | "episode"
|
| 61 |
+
index: int
|
| 62 |
+
at: str
|
| 63 |
+
goal: str
|
| 64 |
+
screen: str
|
| 65 |
+
action: str
|
| 66 |
+
outcome: str
|
| 67 |
+
ok: bool
|
| 68 |
+
prev: str
|
| 69 |
+
digest: str = ""
|
| 70 |
+
|
| 71 |
+
def payload(self) -> dict[str, Any]:
|
| 72 |
+
"""Everything the digest covers - which is everything except the digest."""
|
| 73 |
+
return {"kind": self.kind, "index": self.index, "at": self.at, "goal": self.goal,
|
| 74 |
+
"screen": self.screen, "action": self.action, "outcome": self.outcome,
|
| 75 |
+
"ok": self.ok, "prev": self.prev}
|
| 76 |
+
|
| 77 |
+
def compute_digest(self) -> str:
|
| 78 |
+
return hashlib.sha256(_canonical(self.payload()).encode("utf-8")).hexdigest()
|
| 79 |
+
|
| 80 |
+
def to_dict(self) -> dict[str, Any]:
|
| 81 |
+
return {**self.payload(), "digest": self.digest}
|
| 82 |
+
|
| 83 |
+
@classmethod
|
| 84 |
+
def from_dict(cls, raw: dict[str, Any]) -> "AuditEntry":
|
| 85 |
+
return cls(kind=raw.get("kind", "step"), index=raw.get("index", -1),
|
| 86 |
+
at=raw.get("at", ""), goal=raw.get("goal", ""), screen=raw.get("screen", ""),
|
| 87 |
+
action=raw.get("action", ""), outcome=raw.get("outcome", ""),
|
| 88 |
+
ok=bool(raw.get("ok", False)), prev=raw.get("prev", ""),
|
| 89 |
+
digest=raw.get("digest", ""))
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class AuditLog:
|
| 94 |
+
"""An append-only, hash-chained record of what an episode did."""
|
| 95 |
+
|
| 96 |
+
path: Path | str
|
| 97 |
+
_last: str = field(default="", init=False, repr=False)
|
| 98 |
+
_count: int = field(default=0, init=False, repr=False)
|
| 99 |
+
|
| 100 |
+
def __post_init__(self) -> None:
|
| 101 |
+
self.path = Path(self.path)
|
| 102 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 103 |
+
# resuming a log keeps its chain: pick up the last digest rather than starting over
|
| 104 |
+
if self.path.exists():
|
| 105 |
+
existing = read_audit(self.path)
|
| 106 |
+
self._count = len(existing)
|
| 107 |
+
if existing:
|
| 108 |
+
self._last = existing[-1].digest
|
| 109 |
+
|
| 110 |
+
# -- writing ----------------------------------------------------------- #
|
| 111 |
+
|
| 112 |
+
def _write(self, kind: str, *, goal: str, screen: str = "", action: str = "",
|
| 113 |
+
outcome: str = "", ok: bool = False) -> AuditEntry:
|
| 114 |
+
entry = AuditEntry(kind=kind, index=self._count,
|
| 115 |
+
at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 116 |
+
goal=goal, screen=screen, action=action, outcome=outcome,
|
| 117 |
+
ok=ok, prev=self._last)
|
| 118 |
+
entry = AuditEntry(**{**entry.payload(), "digest": entry.compute_digest()})
|
| 119 |
+
with open(self.path, "a", encoding="utf-8") as handle:
|
| 120 |
+
handle.write(_canonical(entry.to_dict()) + "\n")
|
| 121 |
+
self._last, self._count = entry.digest, self._count + 1
|
| 122 |
+
return entry
|
| 123 |
+
|
| 124 |
+
def append(self, step: Any, goal: str = "") -> AuditEntry:
|
| 125 |
+
"""Record one step. Accepts the loop's :class:`~tiny_computer_agent.loop.Step`."""
|
| 126 |
+
result = getattr(step, "result", None)
|
| 127 |
+
outcome = (getattr(step, "error", None) or (result.summary() if result else "")) or ""
|
| 128 |
+
return self._write("step", goal=goal or "",
|
| 129 |
+
screen=observation_digest(step.observation),
|
| 130 |
+
action=step.action.describe() if step.action else "",
|
| 131 |
+
outcome=str(outcome), ok=bool(step.ok))
|
| 132 |
+
|
| 133 |
+
def close(self, episode: Any) -> AuditEntry:
|
| 134 |
+
"""Record how the episode ended - verified, failed, refused, or out of budget."""
|
| 135 |
+
return self._write("episode", goal=getattr(episode, "goal", ""),
|
| 136 |
+
outcome=f"{'success' if episode.success else 'failure'}: "
|
| 137 |
+
f"{episode.stop_reason}",
|
| 138 |
+
ok=bool(episode.success))
|
| 139 |
+
|
| 140 |
+
# -- reading ----------------------------------------------------------- #
|
| 141 |
+
|
| 142 |
+
def entries(self) -> list[AuditEntry]:
|
| 143 |
+
return read_audit(self.path)
|
| 144 |
+
|
| 145 |
+
def verify(self) -> tuple[bool, str]:
|
| 146 |
+
return verify_audit(self.path)
|
| 147 |
+
|
| 148 |
+
def render(self) -> str:
|
| 149 |
+
entries = self.entries()
|
| 150 |
+
steps = [e for e in entries if e.kind == "step"]
|
| 151 |
+
ok, detail = self.verify()
|
| 152 |
+
counts: dict[str, int] = {}
|
| 153 |
+
for entry in steps:
|
| 154 |
+
label = "ok" if entry.ok else "failed"
|
| 155 |
+
counts[label] = counts.get(label, 0) + 1
|
| 156 |
+
tally = ", ".join(f"{n} {label}" for label, n in sorted(counts.items())) or "no steps"
|
| 157 |
+
return (f"audit: {len(entries)} entries ({len(steps)} steps, {tally}) | "
|
| 158 |
+
f"chain {'intact' if ok else 'BROKEN'}: {detail}")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def read_audit(path: Path | str) -> list[AuditEntry]:
|
| 162 |
+
"""Every entry in the log, in order. A malformed line is an entry that fails to load."""
|
| 163 |
+
path = Path(path)
|
| 164 |
+
if not path.exists():
|
| 165 |
+
return []
|
| 166 |
+
entries: list[AuditEntry] = []
|
| 167 |
+
with open(path, encoding="utf-8") as handle:
|
| 168 |
+
for line in handle:
|
| 169 |
+
if line.strip():
|
| 170 |
+
entries.append(AuditEntry.from_dict(json.loads(line)))
|
| 171 |
+
return entries
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def verify_audit(path: Path | str) -> tuple[bool, str]:
|
| 175 |
+
"""Check the chain. Returns ``(ok, detail)``, naming the first entry at fault.
|
| 176 |
+
|
| 177 |
+
Deliberately reports *which* entry broke and *how* - a digest that does not match, a
|
| 178 |
+
`prev` pointing at nothing, or an index out of order - because those three failures mean
|
| 179 |
+
different things to whoever has to decide whether to trust the run.
|
| 180 |
+
"""
|
| 181 |
+
path = Path(path)
|
| 182 |
+
if not path.exists():
|
| 183 |
+
return False, f"no audit log at {path}"
|
| 184 |
+
|
| 185 |
+
through = ""
|
| 186 |
+
count = 0
|
| 187 |
+
for number, line in enumerate((l for l in path.read_text(encoding="utf-8").splitlines()
|
| 188 |
+
if l.strip()), start=1):
|
| 189 |
+
entry = AuditEntry.from_dict(json.loads(line))
|
| 190 |
+
if entry.index != number - 1:
|
| 191 |
+
return False, (f"entry {number} is out of order (index {entry.index} where "
|
| 192 |
+
f"{number - 1} was expected): lines were removed or reordered")
|
| 193 |
+
if entry.prev != through:
|
| 194 |
+
return False, (f"entry {number} does not follow entry {number - 1}: its link "
|
| 195 |
+
f"points at an entry that is not there")
|
| 196 |
+
if entry.compute_digest() != entry.digest:
|
| 197 |
+
return False, f"entry {number} was edited after it was written"
|
| 198 |
+
through = entry.digest
|
| 199 |
+
count += 1
|
| 200 |
+
if count == 0:
|
| 201 |
+
return False, f"{path} is empty"
|
| 202 |
+
return True, f"{count} entries, chain intact"
|
tiny_computer_agent/gates.py
CHANGED
|
@@ -161,3 +161,37 @@ class AnyOf:
|
|
| 161 |
if approval.approved:
|
| 162 |
return approval
|
| 163 |
return Approval(False, self.reason)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
if approval.approved:
|
| 162 |
return approval
|
| 163 |
return Approval(False, self.reason)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
GATE_SPECS = ("allow-all", "read-only", "interactive", "labels:")
|
| 167 |
+
"""The spellings :func:`gate_from_spec` accepts, in the order its error lists them."""
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def gate_from_spec(spec: str) -> Gate:
|
| 171 |
+
"""Build a gate from a one-line spelling, the one the command line takes.
|
| 172 |
+
|
| 173 |
+
A gate is the operator's yes, and an operator should not have to write Python to give
|
| 174 |
+
one::
|
| 175 |
+
|
| 176 |
+
allow-all everything policy already allowed
|
| 177 |
+
read-only nothing that changes the machine, whatever policy says
|
| 178 |
+
interactive ask me, once per action, and remember "always"/"never"
|
| 179 |
+
labels:Save,Cancel only these labels, and nothing else
|
| 180 |
+
|
| 181 |
+
An unknown spec raises ``ValueError`` naming the accepted spellings, because a typo in
|
| 182 |
+
a safety switch must never quietly mean "allow everything".
|
| 183 |
+
"""
|
| 184 |
+
wanted = (spec or "").strip().lower()
|
| 185 |
+
if wanted in ("allow-all", "allow", "none"):
|
| 186 |
+
return AllowAll()
|
| 187 |
+
if wanted in ("read-only", "readonly"):
|
| 188 |
+
return DenyAll(reason="read-only gate: nothing may change the machine")
|
| 189 |
+
if wanted in ("interactive", "ask"):
|
| 190 |
+
return InteractiveGate()
|
| 191 |
+
if wanted.startswith("labels:"):
|
| 192 |
+
labels = [part.strip() for part in spec.split(":", 1)[1].split(",") if part.strip()]
|
| 193 |
+
if not labels:
|
| 194 |
+
raise ValueError("labels: needs at least one label, e.g. labels:Save,Cancel")
|
| 195 |
+
return AllowOnlyLabels(tuple(labels))
|
| 196 |
+
accepted = " | ".join(GATE_SPECS)
|
| 197 |
+
raise ValueError(f"unknown gate {spec!r}: expected one of {accepted}")
|
tiny_computer_agent/loop.py
CHANGED
|
@@ -7,10 +7,12 @@ from typing import Any, Callable
|
|
| 7 |
|
| 8 |
from tiny_computer_agent.act import ActionResult, DryRunExecutor, Executor, PyAutoGuiExecutor
|
| 9 |
from tiny_computer_agent.actions import Action
|
|
|
|
| 10 |
from tiny_computer_agent.brain import Brain, GoalUnreachable
|
| 11 |
from tiny_computer_agent.observe import Observation, ScreenSource
|
| 12 |
from tiny_computer_agent.gates import Approval, Gate
|
| 13 |
from tiny_computer_agent.safety import Policy, PolicyViolation
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
@dataclass(frozen=True)
|
|
@@ -86,7 +88,9 @@ class AgentLoop:
|
|
| 86 |
on_step: Callable[[Step], None] | None = None,
|
| 87 |
max_repeats: int = 1,
|
| 88 |
verify: Callable[[Observation], bool] | None = None,
|
| 89 |
-
gate: Gate | None = None
|
|
|
|
|
|
|
| 90 |
"""``verify`` runs after every action: the harness, not the model, decides
|
| 91 |
whether the goal was reached. A brain saying "done" is a claim; a verifier
|
| 92 |
looking at the screen is evidence."""
|
|
@@ -99,6 +103,9 @@ class AgentLoop:
|
|
| 99 |
self.on_step = on_step
|
| 100 |
self.verify = verify
|
| 101 |
self.gate = gate
|
|
|
|
|
|
|
|
|
|
| 102 |
self.max_repeats = max_repeats
|
| 103 |
# the stepwise state: an episode in progress, and the guards around it
|
| 104 |
self.episode: Episode | None = None
|
|
@@ -127,8 +134,12 @@ class AgentLoop:
|
|
| 127 |
self._repeats = 0
|
| 128 |
self._done = False
|
| 129 |
self.paused = False
|
|
|
|
| 130 |
try:
|
| 131 |
-
self.observation = self.screen.capture
|
|
|
|
|
|
|
|
|
|
| 132 |
except Exception as exc:
|
| 133 |
self.observation = None
|
| 134 |
self._finish(f"capture failed: {type(exc).__name__}: {exc}")
|
|
@@ -171,7 +182,10 @@ class AgentLoop:
|
|
| 171 |
index, self._turns = self._turns, self._turns + 1
|
| 172 |
|
| 173 |
try:
|
| 174 |
-
action = self.brain.decide
|
|
|
|
|
|
|
|
|
|
| 175 |
except GoalUnreachable as exc:
|
| 176 |
return self._record_step(Step(index, observation, error=f"gave up: {exc}"),
|
| 177 |
f"brain gave up: {exc}")
|
|
@@ -214,7 +228,11 @@ class AgentLoop:
|
|
| 214 |
f"screen ({action.kind} {action.label or action.text or ''})".strip())
|
| 215 |
|
| 216 |
try:
|
| 217 |
-
result = self.executor.execute
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
except Exception as exc:
|
| 219 |
return self._record_step(
|
| 220 |
Step(index, observation, action=action,
|
|
@@ -228,7 +246,11 @@ class AgentLoop:
|
|
| 228 |
return step
|
| 229 |
|
| 230 |
try:
|
| 231 |
-
self.observation = self.screen.capture
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
except Exception as exc:
|
| 233 |
self._append(Step(index + 1, observation,
|
| 234 |
error=f"capture failed: {type(exc).__name__}: {exc}"))
|
|
@@ -261,6 +283,8 @@ class AgentLoop:
|
|
| 261 |
|
| 262 |
def _append(self, step: Step) -> None:
|
| 263 |
self.episode.steps.append(step)
|
|
|
|
|
|
|
| 264 |
if self.on_step is not None:
|
| 265 |
self.on_step(step)
|
| 266 |
|
|
@@ -272,6 +296,15 @@ class AgentLoop:
|
|
| 272 |
def _finish(self, stop_reason: str) -> None:
|
| 273 |
self.episode.stop_reason = stop_reason
|
| 274 |
self._done = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
def _verified(self, observation: Observation) -> bool:
|
| 277 |
"""A broken verifier must never fake a success."""
|
|
|
|
| 7 |
|
| 8 |
from tiny_computer_agent.act import ActionResult, DryRunExecutor, Executor, PyAutoGuiExecutor
|
| 9 |
from tiny_computer_agent.actions import Action
|
| 10 |
+
from tiny_computer_agent.audit import AuditLog
|
| 11 |
from tiny_computer_agent.brain import Brain, GoalUnreachable
|
| 12 |
from tiny_computer_agent.observe import Observation, ScreenSource
|
| 13 |
from tiny_computer_agent.gates import Approval, Gate
|
| 14 |
from tiny_computer_agent.safety import Policy, PolicyViolation
|
| 15 |
+
from tiny_computer_agent.watchdog import Watchdog, WatchdogTimeout
|
| 16 |
|
| 17 |
|
| 18 |
@dataclass(frozen=True)
|
|
|
|
| 88 |
on_step: Callable[[Step], None] | None = None,
|
| 89 |
max_repeats: int = 1,
|
| 90 |
verify: Callable[[Observation], bool] | None = None,
|
| 91 |
+
gate: Gate | None = None,
|
| 92 |
+
watchdog: Watchdog | None = None,
|
| 93 |
+
audit: AuditLog | None = None) -> None:
|
| 94 |
"""``verify`` runs after every action: the harness, not the model, decides
|
| 95 |
whether the goal was reached. A brain saying "done" is a claim; a verifier
|
| 96 |
looking at the screen is evidence."""
|
|
|
|
| 103 |
self.on_step = on_step
|
| 104 |
self.verify = verify
|
| 105 |
self.gate = gate
|
| 106 |
+
self.watchdog = watchdog
|
| 107 |
+
self.audit = audit
|
| 108 |
+
self._audit_closed = False
|
| 109 |
self.max_repeats = max_repeats
|
| 110 |
# the stepwise state: an episode in progress, and the guards around it
|
| 111 |
self.episode: Episode | None = None
|
|
|
|
| 134 |
self._repeats = 0
|
| 135 |
self._done = False
|
| 136 |
self.paused = False
|
| 137 |
+
self._audit_closed = False
|
| 138 |
try:
|
| 139 |
+
self.observation = self._guard("capture", self.screen.capture)
|
| 140 |
+
except WatchdogTimeout as exc:
|
| 141 |
+
self.observation = None
|
| 142 |
+
self._finish(f"watchdog stopped the episode: {exc}")
|
| 143 |
except Exception as exc:
|
| 144 |
self.observation = None
|
| 145 |
self._finish(f"capture failed: {type(exc).__name__}: {exc}")
|
|
|
|
| 182 |
index, self._turns = self._turns, self._turns + 1
|
| 183 |
|
| 184 |
try:
|
| 185 |
+
action = self._guard("decide", self.brain.decide, episode.goal, observation)
|
| 186 |
+
except WatchdogTimeout as exc:
|
| 187 |
+
return self._record_step(Step(index, observation, error=f"watchdog: {exc}"),
|
| 188 |
+
f"watchdog stopped the episode: {exc}")
|
| 189 |
except GoalUnreachable as exc:
|
| 190 |
return self._record_step(Step(index, observation, error=f"gave up: {exc}"),
|
| 191 |
f"brain gave up: {exc}")
|
|
|
|
| 228 |
f"screen ({action.kind} {action.label or action.text or ''})".strip())
|
| 229 |
|
| 230 |
try:
|
| 231 |
+
result = self._guard("execute", self.executor.execute, action, observation)
|
| 232 |
+
except WatchdogTimeout as exc:
|
| 233 |
+
return self._record_step(
|
| 234 |
+
Step(index, observation, action=action, error=f"watchdog: {exc}"),
|
| 235 |
+
f"watchdog stopped the episode: {exc}")
|
| 236 |
except Exception as exc:
|
| 237 |
return self._record_step(
|
| 238 |
Step(index, observation, action=action,
|
|
|
|
| 246 |
return step
|
| 247 |
|
| 248 |
try:
|
| 249 |
+
self.observation = self._guard("capture", self.screen.capture)
|
| 250 |
+
except WatchdogTimeout as exc: # verify: what did that change?
|
| 251 |
+
self._append(Step(index + 1, observation, error=f"watchdog: {exc}"))
|
| 252 |
+
self._finish(f"watchdog stopped the episode: {exc}")
|
| 253 |
+
return step
|
| 254 |
except Exception as exc:
|
| 255 |
self._append(Step(index + 1, observation,
|
| 256 |
error=f"capture failed: {type(exc).__name__}: {exc}"))
|
|
|
|
| 283 |
|
| 284 |
def _append(self, step: Step) -> None:
|
| 285 |
self.episode.steps.append(step)
|
| 286 |
+
if self.audit is not None:
|
| 287 |
+
self.audit.append(step, self.episode.goal)
|
| 288 |
if self.on_step is not None:
|
| 289 |
self.on_step(step)
|
| 290 |
|
|
|
|
| 296 |
def _finish(self, stop_reason: str) -> None:
|
| 297 |
self.episode.stop_reason = stop_reason
|
| 298 |
self._done = True
|
| 299 |
+
if self.audit is not None and not self._audit_closed: # one closing entry, ever
|
| 300 |
+
self._audit_closed = True
|
| 301 |
+
self.audit.close(self.episode)
|
| 302 |
+
|
| 303 |
+
def _guard(self, where: str, call: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
| 304 |
+
"""One guarded call: with a watchdog, a hung call becomes a named failure."""
|
| 305 |
+
if self.watchdog is None:
|
| 306 |
+
return call(*args, **kwargs)
|
| 307 |
+
return self.watchdog.call(where, call, *args, **kwargs)
|
| 308 |
|
| 309 |
def _verified(self, observation: Observation) -> bool:
|
| 310 |
"""A broken verifier must never fake a success."""
|
tiny_computer_agent/watchdog.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Refuse to wait forever.
|
| 2 |
+
|
| 3 |
+
The loop already stops a *repeated* action against an unchanged screen, and a step budget
|
| 4 |
+
bounds how many turns an episode gets. Neither of them covers the other failure: one call
|
| 5 |
+
that never returns. A model that is loading weights, an OCR pass over a 4K screen, an
|
| 6 |
+
executor waiting on a window manager - the harness sits inside `brain.decide()` or
|
| 7 |
+
`executor.execute()` and there is no step, no reason and no evidence, because nothing has
|
| 8 |
+
finished. A console in that state looks exactly like a console that is thinking.
|
| 9 |
+
|
| 10 |
+
A watchdog puts a deadline on each call and on the episode as a whole, and turns a missed
|
| 11 |
+
deadline into the same kind of object as every other failure in this harness: a recorded
|
| 12 |
+
step with a reason, never a traceback and never a hang.
|
| 13 |
+
|
| 14 |
+
loop = AgentLoop(screen=..., brain=...,
|
| 15 |
+
watchdog=Watchdog(step_timeout=30, wall_clock=600))
|
| 16 |
+
|
| 17 |
+
Honest limits, because this is not a scheduler. Python cannot interrupt a thread, so a call
|
| 18 |
+
that has missed its deadline is **abandoned, not killed**: it keeps running in a daemon
|
| 19 |
+
thread until it returns or the process exits, and the watchdog records its name in
|
| 20 |
+
`orphans`. What is guaranteed is that the *loop* stops waiting, says which call it stopped
|
| 21 |
+
waiting for, and gives control back to the caller.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import threading
|
| 27 |
+
import time
|
| 28 |
+
from dataclasses import dataclass, field
|
| 29 |
+
from typing import Any, Callable
|
| 30 |
+
|
| 31 |
+
__all__ = ["DEFAULT_STEP_TIMEOUT", "Watchdog", "WatchdogTimeout"]
|
| 32 |
+
|
| 33 |
+
DEFAULT_STEP_TIMEOUT = 60.0
|
| 34 |
+
"""Seconds one guarded call may take. Generous, because a model's first decision loads."""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class WatchdogTimeout(RuntimeError):
|
| 38 |
+
"""A guarded call missed its deadline.
|
| 39 |
+
|
| 40 |
+
Carries the two facts a reader needs - *where* it hung and *how long* it was given -
|
| 41 |
+
so the step line says something more useful than "failed".
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(self, where: str, seconds: float, *, abandoned: bool = True) -> None:
|
| 45 |
+
self.where = where
|
| 46 |
+
self.seconds = seconds
|
| 47 |
+
self.abandoned = abandoned
|
| 48 |
+
how = "abandoned after" if abandoned else "refused: the wall clock left"
|
| 49 |
+
super().__init__(f"{where} {how} {seconds:g}s")
|
| 50 |
+
|
| 51 |
+
def __str__(self) -> str: # keep the step line short
|
| 52 |
+
return (f"{self.where} did not finish within {self.seconds:g}s"
|
| 53 |
+
if self.abandoned else
|
| 54 |
+
f"{self.where} was not attempted: the episode's wall clock is spent")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class Watchdog:
|
| 59 |
+
"""A deadline on every call the loop cannot otherwise escape.
|
| 60 |
+
|
| 61 |
+
``step_timeout=None`` disables per-call deadlines entirely and runs calls inline, with
|
| 62 |
+
no thread and no overhead - which is the honest default for a harness driving a local
|
| 63 |
+
desktop, where the process *is* the thing being watched. ``wall_clock`` bounds the
|
| 64 |
+
whole episode instead, and is checked before each call rather than during one.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
step_timeout: float | None = DEFAULT_STEP_TIMEOUT
|
| 68 |
+
wall_clock: float | None = None
|
| 69 |
+
clock: Callable[[], float] = time.monotonic
|
| 70 |
+
threads_started: int = field(default=0, init=False)
|
| 71 |
+
orphans: list[str] = field(default_factory=list, init=False)
|
| 72 |
+
_started_at: float = field(default=0.0, init=False, repr=False)
|
| 73 |
+
_ticking: bool = field(default=False, init=False, repr=False)
|
| 74 |
+
|
| 75 |
+
# -- the clock for the episode as a whole ------------------------------- #
|
| 76 |
+
|
| 77 |
+
def start(self) -> "Watchdog":
|
| 78 |
+
"""Begin timing this episode. Idempotent, so a re-run cannot double-charge it."""
|
| 79 |
+
if not self._ticking:
|
| 80 |
+
self._started_at = self.clock()
|
| 81 |
+
self._ticking = True
|
| 82 |
+
return self
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def elapsed(self) -> float:
|
| 86 |
+
return self.clock() - self._started_at if self._ticking else 0.0
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def remaining(self) -> float | None:
|
| 90 |
+
"""Seconds left on the wall clock, or ``None`` when there is no wall clock."""
|
| 91 |
+
if self.wall_clock is None:
|
| 92 |
+
return None
|
| 93 |
+
return max(0.0, self.wall_clock - self.elapsed)
|
| 94 |
+
|
| 95 |
+
@property
|
| 96 |
+
def expired(self) -> bool:
|
| 97 |
+
return self.wall_clock is not None and self.elapsed >= self.wall_clock
|
| 98 |
+
|
| 99 |
+
# -- the one method the loop calls ------------------------------------- #
|
| 100 |
+
|
| 101 |
+
def call(self, where: str, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
| 102 |
+
"""Call ``fn`` under the deadline, or raise :class:`WatchdogTimeout`.
|
| 103 |
+
|
| 104 |
+
The wall clock is checked *before* the call, so a game over is never reported as a
|
| 105 |
+
timeout of whatever happened to be next. Per-call deadlines run on a daemon thread;
|
| 106 |
+
with none configured the call is made inline.
|
| 107 |
+
"""
|
| 108 |
+
if self.expired:
|
| 109 |
+
raise WatchdogTimeout(where, self.wall_clock or 0.0, abandoned=False)
|
| 110 |
+
if self.step_timeout is None:
|
| 111 |
+
return fn(*args, **kwargs)
|
| 112 |
+
|
| 113 |
+
self.threads_started += 1
|
| 114 |
+
outcome: dict[str, Any] = {}
|
| 115 |
+
|
| 116 |
+
def run() -> None:
|
| 117 |
+
try:
|
| 118 |
+
outcome["value"] = fn(*args, **kwargs)
|
| 119 |
+
except BaseException as exc: # carried back to the caller intact
|
| 120 |
+
outcome["error"] = exc
|
| 121 |
+
|
| 122 |
+
worker = threading.Thread(target=run, name=f"watchdog-{where}", daemon=True)
|
| 123 |
+
worker.start()
|
| 124 |
+
worker.join(self.step_timeout)
|
| 125 |
+
if worker.is_alive():
|
| 126 |
+
self.orphans.append(where)
|
| 127 |
+
raise WatchdogTimeout(where, self.step_timeout)
|
| 128 |
+
if "error" in outcome:
|
| 129 |
+
raise outcome["error"]
|
| 130 |
+
return outcome.get("value")
|
| 131 |
+
|
| 132 |
+
# -- reporting --------------------------------------------------------- #
|
| 133 |
+
|
| 134 |
+
def render(self) -> str:
|
| 135 |
+
limits = []
|
| 136 |
+
if self.step_timeout is None:
|
| 137 |
+
limits.append("per-call deadline: none")
|
| 138 |
+
else:
|
| 139 |
+
limits.append(f"per call <= {self.step_timeout:g}s")
|
| 140 |
+
limits.append("wall clock: none" if self.wall_clock is None
|
| 141 |
+
else f"wall clock <= {self.wall_clock:g}s")
|
| 142 |
+
if self._ticking:
|
| 143 |
+
limits.append(f"{self.elapsed:.1f}s used")
|
| 144 |
+
line = "watchdog: " + ", ".join(limits)
|
| 145 |
+
if self.orphans:
|
| 146 |
+
line += f" | abandoned: {', '.join(self.orphans)}"
|
| 147 |
+
return line
|