hkblockchaintechnology commited on
Commit
42ee5cf
·
verified ·
1 Parent(s): dc70429

Package: 2.0 - watchdog and audit modules

Browse files
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", "AutoDetector", "BRAIN_NAMES",
156
- "Brain", "BrainError", "ChangeVerifier", "Check",
157
- "DEFAULT_KILL_SWITCH", "DEFAULT_SKILLS_PATH", "DEFAULT_TEXT_MODEL", "DEFAULT_VISION_MODEL",
158
- "DecisionMenu", "Delta", "DenyAll", "Detector",
159
- "DetectorUnavailable", "DryRunExecutor", "Element", "Episode",
160
- "Executor", "FakeScreen", "Gate", "GoalUnreachable",
161
- "GrabScreen", "GridDetector", "INTERACTIVE_ROLES", "ImageScreen",
162
- "IndexedScreen", "InputUnavailable", "InteractiveGate", "MODEL_BRAINS",
163
- "MenuItem", "MockBrain", "ModelBrain", "Observation",
164
- "OcrDetector", "PlannerBrain", "Policy", "PolicyViolation",
165
- "PyAutoGuiExecutor", "READ_ONLY_KINDS", "RunReport", "Scenario",
166
- "Scoreboard", "ScreenSource", "ScreenUnavailable", "Skill",
167
- "SkillBrain", "SkillLibrary", "SkillStep", "Step",
168
- "TEXT_ROLES", "Task", "TaskOutcome", "TaskRunner",
169
- "TextPlannerBrain", "Trace", "TraceStep", "TreeReader",
170
- "UiaDetector", "UiaReader", "VLMBrain", "Verdict",
 
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", "make_model_brain",
175
- "model_cached", "model_dependencies", "mutating", "normalise_label",
176
- "parse_action", "parse_decision", "pick_dtype", "record_skill",
177
- "resolve_device", "resolve_point", "run_checks", "run_scenario",
178
- "score", "score_replay", "score_trace", "slug",
 
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).run(args.goal)
 
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) -> 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(episode.goal, observation)
 
 
 
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(action, observation)
 
 
 
 
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() # verify: what did that change?
 
 
 
 
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