hkblockchaintechnology's picture
Package: 2.0 - watchdog and audit modules
42ee5cf verified
Raw History Blame Contribute Delete
7.44 kB
"""The operator's yes, as a first-class part of the loop.
A :class:`~tiny_computer_agent.safety.Policy` answers "may the harness do this at all?".
A gate answers the other question: "does the person watching want it done *now*?" Both end
in the same place - a refusal that is recorded as a step and stops the episode, never a
traceback and never a silent click.
loop = AgentLoop(screen=..., brain=..., gate=InteractiveGate(), policy=Policy(dry_run=False))
loop = AgentLoop(..., gate=AllowOnlyLabels(["Save", "Cancel"])) # click nothing else
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Protocol, Sequence
from .actions import Action
from .observe import Observation, normalise_label
READ_ONLY_KINDS = ("wait", "done")
"""Verbs that cannot change the machine, and so never need an operator's consent."""
def mutating(action: Action) -> bool:
"""True when this action would change something on the machine."""
return action.kind not in READ_ONLY_KINDS
@dataclass(frozen=True)
class Approval:
"""A gate's answer, with the reason it will be recorded under."""
approved: bool
reason: str = ""
def line(self) -> str:
return f"{'approved' if self.approved else 'refused'}: {self.reason}"
class Gate(Protocol):
"""Ask before acting. Return an :class:`Approval`; never raise to say no."""
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
...
@dataclass
class AllowAll:
"""No gate: everything policy already allowed is approved. The default when none is given."""
reason: str = "no gate configured"
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
return Approval(True, self.reason)
@dataclass
class DenyAll:
"""Refuse every action that would change anything - a one-line kill switch for a live loop."""
reason: str = "the operator said no"
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
if not mutating(action):
return Approval(True, "read-only")
return Approval(False, self.reason)
@dataclass
class AllowOnlyLabels:
"""A whitelist of labels: the loop may click these and nothing else.
This is the gate for "watch it work, but it may only touch Save and Cancel" - the
coordinates still come from the index, so a renamed button is refused rather than
clicked at.
"""
labels: Sequence[str] = ()
reason: str = "not in the operator's whitelist"
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
if not mutating(action):
return Approval(True, "read-only")
if action.label and self.matches(action.label):
return Approval(True, f"{action.label!r} is whitelisted")
return Approval(False, self.reason)
def matches(self, label: str) -> bool:
target = normalise_label(label)
for allowed in self.labels:
wanted = normalise_label(allowed)
if target and (target == wanted or wanted in target or target in wanted):
return True
return False
@dataclass
class InteractiveGate:
"""Ask a person, once per action, and remember what they said.
``always`` and ``never`` persist for the rest of the run, so a supervised session does not
turn into a keyboard exercise. ``reader``/``writer`` are injectable, so the gate is testable
and a console can route the question through its own widgets.
"""
prompt: str = "run this action?"
reader: Callable[[str], str] = input
writer: Callable[[str], None] = print
_always: bool = field(default=False, init=False, repr=False)
_never: bool = field(default=False, init=False, repr=False)
asked: int = field(default=0, init=False)
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
if not mutating(action):
return Approval(True, "read-only")
if self._never:
return Approval(False, "the operator said never")
if self._always:
return Approval(True, "the operator said always")
self.asked += 1
question = f"{self.prompt} {action.describe()} [y]es / [n]o / [a]lways / [v]never: "
try:
answer = (self.reader(question) or "").strip().lower()
except (EOFError, KeyboardInterrupt):
return Approval(False, "the operator was not there to answer")
if answer.startswith("a"):
self._always = True
return Approval(True, "the operator said always")
if answer.startswith("v"):
self._never = True
return Approval(False, "the operator said never")
if answer.startswith("y"):
return Approval(True, "the operator said yes")
return Approval(False, "the operator said no")
@dataclass
class AllOf:
"""Every gate must approve - the strictest reading of a set of rules."""
gates: Sequence[Gate] = ()
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
for gate in self.gates:
approval = gate.approve(action, observation)
if not approval.approved:
return approval
return Approval(True, "every gate approved")
@dataclass
class AnyOf:
"""Any gate may approve - the loosest, for "my colleague or my rulebook"."""
gates: Sequence[Gate] = ()
reason: str = "no gate approved"
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
for gate in self.gates:
approval = gate.approve(action, observation)
if approval.approved:
return approval
return Approval(False, self.reason)
GATE_SPECS = ("allow-all", "read-only", "interactive", "labels:")
"""The spellings :func:`gate_from_spec` accepts, in the order its error lists them."""
def gate_from_spec(spec: str) -> Gate:
"""Build a gate from a one-line spelling, the one the command line takes.
A gate is the operator's yes, and an operator should not have to write Python to give
one::
allow-all everything policy already allowed
read-only nothing that changes the machine, whatever policy says
interactive ask me, once per action, and remember "always"/"never"
labels:Save,Cancel only these labels, and nothing else
An unknown spec raises ``ValueError`` naming the accepted spellings, because a typo in
a safety switch must never quietly mean "allow everything".
"""
wanted = (spec or "").strip().lower()
if wanted in ("allow-all", "allow", "none"):
return AllowAll()
if wanted in ("read-only", "readonly"):
return DenyAll(reason="read-only gate: nothing may change the machine")
if wanted in ("interactive", "ask"):
return InteractiveGate()
if wanted.startswith("labels:"):
labels = [part.strip() for part in spec.split(":", 1)[1].split(",") if part.strip()]
if not labels:
raise ValueError("labels: needs at least one label, e.g. labels:Save,Cancel")
return AllowOnlyLabels(tuple(labels))
accepted = " | ".join(GATE_SPECS)
raise ValueError(f"unknown gate {spec!r}: expected one of {accepted}")