Publish tiny_computer_agent/ from marimo
Browse files- tiny_computer_agent/__init__.py +63 -0
- tiny_computer_agent/__main__.py +105 -0
- tiny_computer_agent/act.py +148 -0
- tiny_computer_agent/actions.py +126 -0
- tiny_computer_agent/brain.py +174 -0
- tiny_computer_agent/loop.py +152 -0
- tiny_computer_agent/observe.py +141 -0
- tiny_computer_agent/safety.py +93 -0
tiny_computer_agent/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny Computer Agent - an agentic harness that drives a real computer with a
|
| 2 |
+
tiny, fully local model.
|
| 3 |
+
|
| 4 |
+
The harness is deliberately boring: it owns the loop, the closed action
|
| 5 |
+
vocabulary and the safety gates, so the model only ever answers one question -
|
| 6 |
+
"which action, from this menu, comes next?" Nothing here needs a network call
|
| 7 |
+
at run time, and the core is standard-library only.
|
| 8 |
+
|
| 9 |
+
from tiny_computer_agent import (
|
| 10 |
+
AgentLoop, DryRunExecutor, Element, FakeScreen, MockBrain, Policy,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
screen = FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
|
| 14 |
+
loop = AgentLoop(screen=screen, brain=MockBrain([{"kind": "click", "label": "Save"}]))
|
| 15 |
+
episode = loop.run("save the document")
|
| 16 |
+
print(episode.render())
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from tiny_computer_agent.act import (
|
| 20 |
+
ActionResult,
|
| 21 |
+
DryRunExecutor,
|
| 22 |
+
Executor,
|
| 23 |
+
InputUnavailable,
|
| 24 |
+
PyAutoGuiExecutor,
|
| 25 |
+
resolve_point,
|
| 26 |
+
)
|
| 27 |
+
from tiny_computer_agent.actions import (
|
| 28 |
+
ACTION_KINDS,
|
| 29 |
+
ACTION_MENU,
|
| 30 |
+
Action,
|
| 31 |
+
ActionError,
|
| 32 |
+
parse_action,
|
| 33 |
+
)
|
| 34 |
+
from tiny_computer_agent.brain import (
|
| 35 |
+
Brain,
|
| 36 |
+
BrainError,
|
| 37 |
+
GoalUnreachable,
|
| 38 |
+
MockBrain,
|
| 39 |
+
PlannerBrain,
|
| 40 |
+
VLMBrain,
|
| 41 |
+
extract_json_object,
|
| 42 |
+
)
|
| 43 |
+
from tiny_computer_agent.loop import AgentLoop, Episode, Step
|
| 44 |
+
from tiny_computer_agent.observe import (
|
| 45 |
+
Element,
|
| 46 |
+
FakeScreen,
|
| 47 |
+
GrabScreen,
|
| 48 |
+
Observation,
|
| 49 |
+
ScreenSource,
|
| 50 |
+
ScreenUnavailable,
|
| 51 |
+
)
|
| 52 |
+
from tiny_computer_agent.safety import Policy, PolicyViolation
|
| 53 |
+
|
| 54 |
+
__version__ = "0.1.0"
|
| 55 |
+
|
| 56 |
+
__all__ = [
|
| 57 |
+
"ACTION_KINDS", "ACTION_MENU", "Action", "ActionError", "ActionResult",
|
| 58 |
+
"AgentLoop", "Brain", "BrainError", "DryRunExecutor", "Element", "Episode",
|
| 59 |
+
"Executor", "FakeScreen", "GoalUnreachable", "GrabScreen", "InputUnavailable", "MockBrain",
|
| 60 |
+
"Observation", "PlannerBrain", "Policy", "PolicyViolation", "PyAutoGuiExecutor",
|
| 61 |
+
"ScreenSource", "ScreenUnavailable", "Step", "VLMBrain", "extract_json_object",
|
| 62 |
+
"parse_action", "resolve_point", "__version__",
|
| 63 |
+
]
|
tiny_computer_agent/__main__.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Command line entry point.
|
| 2 |
+
|
| 3 |
+
python -m tiny_computer_agent # scripted demo, dry-run
|
| 4 |
+
python -m tiny_computer_agent --live # your real screen, dry-run
|
| 5 |
+
python -m tiny_computer_agent "click 'Save'" # planner brain
|
| 6 |
+
python -m tiny_computer_agent --script '[{"kind": "done"}]'
|
| 7 |
+
|
| 8 |
+
Real mouse and keyboard input additionally requires ``--real-input`` *and*
|
| 9 |
+
``TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1`` in the environment. That is deliberate:
|
| 10 |
+
dry-run is the default so you can read a whole episode first.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import json
|
| 17 |
+
import os
|
| 18 |
+
import sys
|
| 19 |
+
|
| 20 |
+
from tiny_computer_agent.act import DryRunExecutor, PyAutoGuiExecutor
|
| 21 |
+
from tiny_computer_agent.brain import MockBrain, PlannerBrain
|
| 22 |
+
from tiny_computer_agent.loop import AgentLoop, Step
|
| 23 |
+
from tiny_computer_agent.observe import Element, FakeScreen, GrabScreen
|
| 24 |
+
from tiny_computer_agent.safety import Policy
|
| 25 |
+
|
| 26 |
+
ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
|
| 27 |
+
DEFAULT_KILL_SWITCH = "/tmp/tiny-computer-agent.STOP"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def demo_screen() -> FakeScreen:
|
| 31 |
+
"""A three-frame desktop: a dialog, then an editor, then a saved file."""
|
| 32 |
+
return FakeScreen(
|
| 33 |
+
[
|
| 34 |
+
(Element("Terminal", x=0, y=0, width=40, height=24),
|
| 35 |
+
Element("Files", x=60, y=0, width=40, height=24)),
|
| 36 |
+
(Element("Name", kind="input", x=100, y=120, width=200, height=24),
|
| 37 |
+
Element("Save", x=320, y=120, width=80, height=24)),
|
| 38 |
+
(Element("Save", x=320, y=120, width=80, height=24),),
|
| 39 |
+
],
|
| 40 |
+
text="Untitled document - unsaved changes",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def build_parser() -> argparse.ArgumentParser:
|
| 45 |
+
parser = argparse.ArgumentParser(
|
| 46 |
+
prog="tiny-computer-agent",
|
| 47 |
+
description="Drive a computer with a tiny local model (dry-run by default).",
|
| 48 |
+
)
|
| 49 |
+
parser.add_argument("goal", nargs="?", default="click 'Save' to save the document")
|
| 50 |
+
parser.add_argument("--max-steps", type=int, default=6)
|
| 51 |
+
parser.add_argument("--script", default=None,
|
| 52 |
+
help="JSON list of actions to replay instead of using a brain")
|
| 53 |
+
parser.add_argument("--live", action="store_true",
|
| 54 |
+
help="capture your real screen instead of the scripted demo")
|
| 55 |
+
parser.add_argument("--real-input", action="store_true",
|
| 56 |
+
help="actually move the mouse and type (requires the allow env var)")
|
| 57 |
+
parser.add_argument("--kill-switch", default=DEFAULT_KILL_SWITCH,
|
| 58 |
+
help="create this file to abort the run before the next action")
|
| 59 |
+
return parser
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def main(argv: list[str] | None = None) -> int:
|
| 63 |
+
args = build_parser().parse_args(argv)
|
| 64 |
+
|
| 65 |
+
if args.real_input and os.environ.get(ALLOW_REAL_INPUT_ENV) != "1":
|
| 66 |
+
print(f"refusing --real-input: set {ALLOW_REAL_INPUT_ENV}=1 to confirm you "
|
| 67 |
+
f"understand this will drive your actual mouse and keyboard.", file=sys.stderr)
|
| 68 |
+
return 2
|
| 69 |
+
|
| 70 |
+
screen = GrabScreen() if args.live else demo_screen()
|
| 71 |
+
|
| 72 |
+
if args.script:
|
| 73 |
+
try:
|
| 74 |
+
script = json.loads(args.script)
|
| 75 |
+
except json.JSONDecodeError as exc:
|
| 76 |
+
print(f"--script is not valid JSON: {exc}", file=sys.stderr)
|
| 77 |
+
return 2
|
| 78 |
+
brain = MockBrain(script)
|
| 79 |
+
else:
|
| 80 |
+
brain = PlannerBrain(done_when_text_contains=None)
|
| 81 |
+
|
| 82 |
+
policy = Policy(
|
| 83 |
+
dry_run=not args.real_input,
|
| 84 |
+
step_budget=args.max_steps,
|
| 85 |
+
kill_switch_path=args.kill_switch,
|
| 86 |
+
)
|
| 87 |
+
executor = PyAutoGuiExecutor() if args.real_input else DryRunExecutor()
|
| 88 |
+
|
| 89 |
+
mode = "REAL INPUT (mouse and keyboard live)" if args.real_input else "dry-run"
|
| 90 |
+
print(f"goal: {args.goal}\nmode: {mode} | screen: "
|
| 91 |
+
f"{'real desktop' if args.live else 'scripted demo'} | "
|
| 92 |
+
f"kill switch: create {args.kill_switch} to stop\n")
|
| 93 |
+
|
| 94 |
+
def on_step(step: Step) -> None:
|
| 95 |
+
print(" " + step.line())
|
| 96 |
+
|
| 97 |
+
episode = AgentLoop(screen=screen, brain=brain, executor=executor,
|
| 98 |
+
policy=policy, on_step=on_step).run(args.goal)
|
| 99 |
+
print()
|
| 100 |
+
print(episode.render())
|
| 101 |
+
return 0 if episode.success else 1
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
raise SystemExit(main())
|
tiny_computer_agent/act.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Execution: the only module in the harness that touches the machine."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Protocol
|
| 8 |
+
|
| 9 |
+
from tiny_computer_agent.actions import Action
|
| 10 |
+
from tiny_computer_agent.observe import Observation
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class ActionResult:
|
| 15 |
+
action: Action
|
| 16 |
+
ok: bool
|
| 17 |
+
note: str = ""
|
| 18 |
+
|
| 19 |
+
def summary(self) -> str:
|
| 20 |
+
marker = "ok" if self.ok else "FAILED"
|
| 21 |
+
return f"{marker}: {self.note}"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class InputUnavailable(RuntimeError):
|
| 25 |
+
"""Raised when real input is requested but pyautogui cannot drive the machine."""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def resolve_point(
|
| 29 |
+
action: Action, observation: Observation | None = None
|
| 30 |
+
) -> tuple[int, int] | None:
|
| 31 |
+
"""Turn a click into pixels, using the element index for label clicks."""
|
| 32 |
+
if action.kind != "click":
|
| 33 |
+
return None
|
| 34 |
+
if action.x is not None and action.y is not None:
|
| 35 |
+
return (action.x, action.y)
|
| 36 |
+
if observation is not None and action.label:
|
| 37 |
+
element = observation.find(action.label)
|
| 38 |
+
if element is not None:
|
| 39 |
+
return element.center
|
| 40 |
+
return None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class Executor(Protocol):
|
| 44 |
+
dry_run: bool
|
| 45 |
+
|
| 46 |
+
def execute(
|
| 47 |
+
self, action: Action, observation: Observation | None = None
|
| 48 |
+
) -> ActionResult: ...
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class DryRunExecutor:
|
| 52 |
+
"""Records actions instead of performing them. The default everywhere.
|
| 53 |
+
|
| 54 |
+
Lets you read an entire episode — including which pixel each label resolved
|
| 55 |
+
to — before a single event reaches the machine.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
dry_run = True
|
| 59 |
+
|
| 60 |
+
def __init__(self) -> None:
|
| 61 |
+
self.performed: list[Action] = []
|
| 62 |
+
self.log: list[str] = []
|
| 63 |
+
|
| 64 |
+
def execute(
|
| 65 |
+
self, action: Action, observation: Observation | None = None
|
| 66 |
+
) -> ActionResult:
|
| 67 |
+
note = f"(dry-run) would {action.describe()}"
|
| 68 |
+
ok = True
|
| 69 |
+
if action.kind == "click":
|
| 70 |
+
point = resolve_point(action, observation)
|
| 71 |
+
if point is not None:
|
| 72 |
+
note += f" -> pixel {point}"
|
| 73 |
+
elif action.label:
|
| 74 |
+
# Fail exactly where live input would, so a dry run never gives
|
| 75 |
+
# false confidence about a plan the executor would refuse.
|
| 76 |
+
ok = False
|
| 77 |
+
note += " (label absent from the element index; live input refuses this)"
|
| 78 |
+
self.performed.append(action)
|
| 79 |
+
self.log.append(note)
|
| 80 |
+
return ActionResult(action=action, ok=ok, note=note)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class PyAutoGuiExecutor:
|
| 84 |
+
"""Real mouse and keyboard input.
|
| 85 |
+
|
| 86 |
+
Refuses to click when a label cannot be resolved to an element: a tiny model
|
| 87 |
+
guessing pixel coordinates is exactly how you click "Delete account".
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
dry_run = False
|
| 91 |
+
|
| 92 |
+
def __init__(self, *, pause: float = 0.1, typing_interval: float = 0.02,
|
| 93 |
+
scroll_ticks: int = 100) -> None:
|
| 94 |
+
self.pause = pause
|
| 95 |
+
self.typing_interval = typing_interval
|
| 96 |
+
self.scroll_ticks = scroll_ticks
|
| 97 |
+
|
| 98 |
+
@staticmethod
|
| 99 |
+
def _pyautogui():
|
| 100 |
+
try:
|
| 101 |
+
import pyautogui
|
| 102 |
+
except ImportError as exc: # pragma: no cover - depends on the machine
|
| 103 |
+
raise InputUnavailable(
|
| 104 |
+
"real input needs pyautogui: pip install \'tiny-computer-agent[desktop]\'"
|
| 105 |
+
) from exc
|
| 106 |
+
return pyautogui
|
| 107 |
+
|
| 108 |
+
def execute(
|
| 109 |
+
self, action: Action, observation: Observation | None = None
|
| 110 |
+
) -> ActionResult:
|
| 111 |
+
if action.kind == "done":
|
| 112 |
+
return ActionResult(action=action, ok=True, note="goal reported met")
|
| 113 |
+
|
| 114 |
+
point: tuple[int, int] | None = None
|
| 115 |
+
if action.kind == "click":
|
| 116 |
+
point = resolve_point(action, observation)
|
| 117 |
+
if point is None:
|
| 118 |
+
return ActionResult(
|
| 119 |
+
action=action,
|
| 120 |
+
ok=False,
|
| 121 |
+
note=f"no element matching {action.label!r}; refusing to guess pixels",
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
pyautogui = self._pyautogui()
|
| 125 |
+
try:
|
| 126 |
+
if action.kind == "click":
|
| 127 |
+
pyautogui.click(*point)
|
| 128 |
+
note = f"clicked {point}"
|
| 129 |
+
elif action.kind == "type":
|
| 130 |
+
pyautogui.write(action.text or "", interval=self.typing_interval)
|
| 131 |
+
note = f"typed {len(action.text or '')} characters"
|
| 132 |
+
elif action.kind == "key":
|
| 133 |
+
chord = [k.strip() for k in (action.keys or "").split("+") if k.strip()]
|
| 134 |
+
pyautogui.hotkey(*chord)
|
| 135 |
+
note = f"pressed {action.keys}"
|
| 136 |
+
elif action.kind == "scroll":
|
| 137 |
+
pyautogui.scroll(action.amount * self.scroll_ticks)
|
| 138 |
+
note = f"scrolled {action.amount} ticks"
|
| 139 |
+
elif action.kind == "wait":
|
| 140 |
+
time.sleep(action.seconds)
|
| 141 |
+
note = f"waited {action.seconds}s"
|
| 142 |
+
else: # unreachable: Action already validated the vocabulary
|
| 143 |
+
return ActionResult(action=action, ok=False, note=f"unhandled kind {action.kind!r}")
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
return ActionResult(action=action, ok=False, note=f"{type(exc).__name__}: {exc}")
|
| 146 |
+
if self.pause:
|
| 147 |
+
time.sleep(self.pause)
|
| 148 |
+
return ActionResult(action=action, ok=True, note=note)
|
tiny_computer_agent/actions.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The closed action vocabulary.
|
| 2 |
+
|
| 3 |
+
A tiny model is only dependable when its output space is small and every mistake
|
| 4 |
+
is cheap to catch. The harness therefore accepts exactly six verbs, and validates
|
| 5 |
+
each field *before* anything touches the machine.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from typing import Any, Final, Mapping
|
| 12 |
+
|
| 13 |
+
ACTION_KINDS: Final[tuple[str, ...]] = ("click", "type", "key", "scroll", "wait", "done")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ActionError(ValueError):
|
| 17 |
+
"""Raised when a brain proposes something outside the vocabulary."""
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass(frozen=True)
|
| 21 |
+
class Action:
|
| 22 |
+
"""One step of computer use: exactly one verb, validated on construction.
|
| 23 |
+
|
| 24 |
+
label : click target *by element label* — the preferred form, because it
|
| 25 |
+
survives window moves that would break raw pixel coordinates
|
| 26 |
+
x, y : pixel fallback, used only when no label matches
|
| 27 |
+
text : literal text to type
|
| 28 |
+
keys : key chord, e.g. ``ctrl+s``
|
| 29 |
+
amount : scroll ticks; negative scrolls up
|
| 30 |
+
seconds : how long to wait
|
| 31 |
+
reason : the brain's stated rationale — kept for traces, never executed
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
kind: str
|
| 35 |
+
label: str | None = None
|
| 36 |
+
x: int | None = None
|
| 37 |
+
y: int | None = None
|
| 38 |
+
text: str | None = None
|
| 39 |
+
keys: str | None = None
|
| 40 |
+
amount: int = 0
|
| 41 |
+
seconds: float = 0.0
|
| 42 |
+
reason: str = ""
|
| 43 |
+
|
| 44 |
+
def __post_init__(self) -> None:
|
| 45 |
+
if self.kind not in ACTION_KINDS:
|
| 46 |
+
raise ActionError(f"unknown action {self.kind!r}; expected one of {ACTION_KINDS}")
|
| 47 |
+
if self.kind == "click":
|
| 48 |
+
if not self.label and not (self.x is not None and self.y is not None):
|
| 49 |
+
raise ActionError("click needs a label, or x and y")
|
| 50 |
+
if (self.x is not None and self.x < 0) or (self.y is not None and self.y < 0):
|
| 51 |
+
raise ActionError("click coordinates must be non-negative")
|
| 52 |
+
if self.kind == "type" and not self.text:
|
| 53 |
+
raise ActionError("type needs non-empty text")
|
| 54 |
+
if self.kind == "key" and not self.keys:
|
| 55 |
+
raise ActionError("key needs a chord, e.g. 'ctrl+s'")
|
| 56 |
+
if self.kind == "scroll" and self.amount == 0:
|
| 57 |
+
raise ActionError("scroll needs a non-zero amount")
|
| 58 |
+
if self.kind == "wait" and self.seconds <= 0:
|
| 59 |
+
raise ActionError("wait needs a positive number of seconds")
|
| 60 |
+
|
| 61 |
+
def describe(self) -> str:
|
| 62 |
+
"""One-line, human-readable form used in traces and dry-run logs."""
|
| 63 |
+
parts: list[str] = [self.kind]
|
| 64 |
+
if self.label:
|
| 65 |
+
parts.append(f"label={self.label!r}")
|
| 66 |
+
if self.x is not None and self.y is not None:
|
| 67 |
+
parts.append(f"at=({self.x},{self.y})")
|
| 68 |
+
if self.text is not None:
|
| 69 |
+
parts.append(f"text={self.text!r}")
|
| 70 |
+
if self.keys:
|
| 71 |
+
parts.append(f"keys={self.keys!r}")
|
| 72 |
+
if self.kind == "scroll":
|
| 73 |
+
parts.append(f"amount={self.amount}")
|
| 74 |
+
if self.kind == "wait":
|
| 75 |
+
parts.append(f"seconds={self.seconds}")
|
| 76 |
+
if self.reason:
|
| 77 |
+
parts.append(f"reason={self.reason!r}")
|
| 78 |
+
return " ".join(parts)
|
| 79 |
+
|
| 80 |
+
def to_dict(self) -> dict[str, Any]:
|
| 81 |
+
return {
|
| 82 |
+
"kind": self.kind,
|
| 83 |
+
"label": self.label,
|
| 84 |
+
"x": self.x,
|
| 85 |
+
"y": self.y,
|
| 86 |
+
"text": self.text,
|
| 87 |
+
"keys": self.keys,
|
| 88 |
+
"amount": self.amount,
|
| 89 |
+
"seconds": self.seconds,
|
| 90 |
+
"reason": self.reason,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
ACTION_MENU: Final[str] = """\
|
| 95 |
+
Reply with exactly one JSON object and nothing else. Allowed verbs:
|
| 96 |
+
|
| 97 |
+
{"kind": "click", "label": "<text of the element to click>", "reason": "..."}
|
| 98 |
+
{"kind": "click", "x": 640, "y": 400, "reason": "..."} // pixels, only if no label fits
|
| 99 |
+
{"kind": "type", "text": "...", "reason": "..."}
|
| 100 |
+
{"kind": "key", "keys": "ctrl+s", "reason": "..."}
|
| 101 |
+
{"kind": "scroll", "amount": -3, "reason": "..."}
|
| 102 |
+
{"kind": "wait", "seconds": 1.5, "reason": "..."}
|
| 103 |
+
{"kind": "done", "reason": "why the goal is met"}
|
| 104 |
+
|
| 105 |
+
Rules: one action per reply; prefer a label over guessing pixels; reply "done"
|
| 106 |
+
as soon as the goal is met; never invent new verbs."""
|
| 107 |
+
|
| 108 |
+
_FIELDS: Final[tuple[str, ...]] = (
|
| 109 |
+
"label", "x", "y", "text", "keys", "amount", "seconds", "reason",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def parse_action(payload: Mapping[str, Any]) -> Action:
|
| 114 |
+
"""Turn a brain's raw mapping into a validated :class:`Action`.
|
| 115 |
+
|
| 116 |
+
Unknown keys are ignored, so a chatty model cannot crash the loop. An unknown
|
| 117 |
+
verb or a missing field raises :class:`ActionError`, which the loop records as
|
| 118 |
+
a failed step *without executing anything*.
|
| 119 |
+
"""
|
| 120 |
+
if not isinstance(payload, Mapping):
|
| 121 |
+
raise ActionError(f"expected a mapping, got {type(payload).__name__}")
|
| 122 |
+
kind = payload.get("kind")
|
| 123 |
+
if kind not in ACTION_KINDS:
|
| 124 |
+
raise ActionError(f"unknown action {kind!r}; expected one of {ACTION_KINDS}")
|
| 125 |
+
kwargs = {k: payload[k] for k in _FIELDS if k in payload}
|
| 126 |
+
return Action(kind=str(kind), **kwargs)
|
tiny_computer_agent/brain.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Decision making.
|
| 2 |
+
|
| 3 |
+
Three brains ship here, and only one of them is a model:
|
| 4 |
+
|
| 5 |
+
* :class:`MockBrain` - replays a scripted action list (tests, demos, replays)
|
| 6 |
+
* :class:`PlannerBrain` - rule-based: clicks the element whose label matches the
|
| 7 |
+
goal. No model, no download, yet it solves real "click the thing labelled X"
|
| 8 |
+
tasks, because the *element index* does the grounding a VLM would otherwise do
|
| 9 |
+
* :class:`VLMBrain` - the seam for a real tiny VLM. Prompt building and reply
|
| 10 |
+
validation are done; you implement ``_generate()``
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
from typing import Any, Mapping, Protocol, Sequence
|
| 18 |
+
|
| 19 |
+
from tiny_computer_agent.actions import ACTION_MENU, Action, ActionError, parse_action
|
| 20 |
+
from tiny_computer_agent.observe import Observation
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class BrainError(RuntimeError):
|
| 24 |
+
"""Raised when a brain cannot produce a valid action."""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class GoalUnreachable(BrainError):
|
| 28 |
+
"""Raised by a brain that can decide nothing further, without claiming success.
|
| 29 |
+
|
| 30 |
+
Deliberately distinct from a crash: the harness records the episode as a
|
| 31 |
+
*failure*, because a brain admitting "I give up" must never be logged as
|
| 32 |
+
"goal met" - and the harness, not the brain, owns that judgement.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def extract_json_object(raw: str) -> dict[str, Any]:
|
| 37 |
+
"""Pull the first JSON object out of a reply, ignoring any surrounding prose."""
|
| 38 |
+
start = raw.find("{")
|
| 39 |
+
end = raw.rfind("}")
|
| 40 |
+
if start == -1 or end <= start:
|
| 41 |
+
raise BrainError(f"no JSON object in the model reply: {raw[:200]!r}")
|
| 42 |
+
try:
|
| 43 |
+
payload = json.loads(raw[start : end + 1])
|
| 44 |
+
except json.JSONDecodeError as exc:
|
| 45 |
+
raise BrainError(f"malformed JSON in the model reply: {exc}") from exc
|
| 46 |
+
if not isinstance(payload, dict):
|
| 47 |
+
raise BrainError(f"expected a JSON object, got {type(payload).__name__}")
|
| 48 |
+
return payload
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class Brain(Protocol):
|
| 52 |
+
"""Anything that can turn (goal, observation) into one validated action."""
|
| 53 |
+
|
| 54 |
+
name: str
|
| 55 |
+
|
| 56 |
+
def decide(self, goal: str, observation: Observation) -> Action: ...
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class MockBrain:
|
| 60 |
+
"""Replays a fixed script of actions, exactly once. Deterministic and offline."""
|
| 61 |
+
|
| 62 |
+
name = "mock"
|
| 63 |
+
|
| 64 |
+
def __init__(self, script: Sequence[Action | Mapping[str, Any]]) -> None:
|
| 65 |
+
self._script: list[Action] = [
|
| 66 |
+
a if isinstance(a, Action) else parse_action(a) for a in script
|
| 67 |
+
]
|
| 68 |
+
self._cursor = 0
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def remaining(self) -> int:
|
| 72 |
+
return max(0, len(self._script) - self._cursor)
|
| 73 |
+
|
| 74 |
+
def decide(self, goal: str, observation: Observation) -> Action:
|
| 75 |
+
if self._cursor >= len(self._script):
|
| 76 |
+
return Action("done", reason="script exhausted")
|
| 77 |
+
action = self._script[self._cursor]
|
| 78 |
+
self._cursor += 1
|
| 79 |
+
return action
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class PlannerBrain:
|
| 83 |
+
"""Rule-based baseline: click the element matching the goal, then stop.
|
| 84 |
+
|
| 85 |
+
Its purpose is twofold - it proves the harness works end to end with no model
|
| 86 |
+
at all, and it shows how much of a "computer use" task is really an indexing
|
| 87 |
+
problem. It waits when the target is not on screen yet, refuses to click the
|
| 88 |
+
same label twice, and gives up rather than flailing. It will not generalise
|
| 89 |
+
the way a VLM can.
|
| 90 |
+
"""
|
| 91 |
+
|
| 92 |
+
name = "planner"
|
| 93 |
+
|
| 94 |
+
def __init__(self, target: str | None = None, *,
|
| 95 |
+
done_when_text_contains: str | None = None,
|
| 96 |
+
max_waits: int = 3, wait_seconds: float = 0.5) -> None:
|
| 97 |
+
self.target = target
|
| 98 |
+
self.done_when_text_contains = done_when_text_contains
|
| 99 |
+
self.max_waits = max_waits
|
| 100 |
+
self.wait_seconds = wait_seconds
|
| 101 |
+
self._clicked: set[str] = set()
|
| 102 |
+
self._waits = 0
|
| 103 |
+
|
| 104 |
+
def decide(self, goal: str, observation: Observation) -> Action:
|
| 105 |
+
if self.done_when_text_contains:
|
| 106 |
+
needle = self.done_when_text_contains.lower()
|
| 107 |
+
if needle in observation.text.lower():
|
| 108 |
+
return Action("done", reason=f"page text contains {self.done_when_text_contains!r}")
|
| 109 |
+
|
| 110 |
+
target = self.target or self._target_from(goal)
|
| 111 |
+
if target:
|
| 112 |
+
element = observation.find(target)
|
| 113 |
+
if element is None:
|
| 114 |
+
if self._waits < self.max_waits:
|
| 115 |
+
self._waits += 1
|
| 116 |
+
return Action("wait", seconds=self.wait_seconds,
|
| 117 |
+
reason=f"{target!r} is not on screen yet")
|
| 118 |
+
raise GoalUnreachable(f"{target!r} never appeared on screen")
|
| 119 |
+
key = element.label.strip().lower()
|
| 120 |
+
if key in self._clicked:
|
| 121 |
+
return Action("done", reason=f"already clicked {element.label!r}; goal assumed met")
|
| 122 |
+
self._clicked.add(key)
|
| 123 |
+
return Action("click", label=element.label,
|
| 124 |
+
reason=f"matched {target!r} in the element index")
|
| 125 |
+
raise GoalUnreachable(f"no element name found in the goal {goal!r}")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def _target_from(goal: str) -> str | None:
|
| 130 |
+
"""Best effort: quoted text wins, else the last capitalised word."""
|
| 131 |
+
quoted = re.search(r"[\"\']([^\"\']{2,40})[\"\']", goal)
|
| 132 |
+
if quoted:
|
| 133 |
+
return quoted.group(1)
|
| 134 |
+
words = [w for w in goal.split() if w[:1].isupper()]
|
| 135 |
+
return words[-1].strip(".,!?") if words else None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class VLMBrain:
|
| 139 |
+
"""Seam for a real (small) vision-language model.
|
| 140 |
+
|
| 141 |
+
``build_prompt`` and reply validation are implemented; subclass this and
|
| 142 |
+
implement ``_generate()`` to return the raw model text. Swap the model by
|
| 143 |
+
changing ``model_id`` - nothing else in the harness cares which weights you use.
|
| 144 |
+
"""
|
| 145 |
+
|
| 146 |
+
name = "vlm"
|
| 147 |
+
|
| 148 |
+
def __init__(self, model_id: str = "vikhyatk/moondream2", *,
|
| 149 |
+
max_new_tokens: int = 128) -> None:
|
| 150 |
+
self.model_id = model_id
|
| 151 |
+
self.max_new_tokens = max_new_tokens
|
| 152 |
+
|
| 153 |
+
def build_prompt(self, goal: str, observation: Observation) -> str:
|
| 154 |
+
return (
|
| 155 |
+
"You are controlling a computer. Choose the single next action.\n\n"
|
| 156 |
+
f"Goal: {goal}\n\n"
|
| 157 |
+
f"{observation.summary()}\n\n"
|
| 158 |
+
f"{ACTION_MENU}"
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
def _generate(self, prompt: str, observation: Observation) -> str:
|
| 162 |
+
raise NotImplementedError(
|
| 163 |
+
"No vision backend is wired up yet. Subclass VLMBrain and implement "
|
| 164 |
+
"_generate(prompt, observation) to return raw model text, or use "
|
| 165 |
+
"MockBrain / PlannerBrain while the harness is being tested."
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
def decide(self, goal: str, observation: Observation) -> Action:
|
| 169 |
+
raw = self._generate(self.build_prompt(goal, observation), observation)
|
| 170 |
+
payload = extract_json_object(raw)
|
| 171 |
+
try:
|
| 172 |
+
return parse_action(payload)
|
| 173 |
+
except ActionError as exc:
|
| 174 |
+
raise BrainError(f"model proposed an invalid action: {exc}") from exc
|
tiny_computer_agent/loop.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The harness itself: observe -> think -> act -> verify, bounded and traced."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
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.safety import Policy, PolicyViolation
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@dataclass(frozen=True)
|
| 16 |
+
class Step:
|
| 17 |
+
"""One turn of the loop, with everything needed to audit it afterwards."""
|
| 18 |
+
|
| 19 |
+
index: int
|
| 20 |
+
observation: Observation
|
| 21 |
+
action: Action | None = None
|
| 22 |
+
result: ActionResult | None = None
|
| 23 |
+
error: str | None = None
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def ok(self) -> bool:
|
| 27 |
+
return self.error is None and (self.result is None or self.result.ok)
|
| 28 |
+
|
| 29 |
+
def line(self) -> str:
|
| 30 |
+
bits = [f"step {self.index}"]
|
| 31 |
+
if self.action is not None:
|
| 32 |
+
bits.append(self.action.describe())
|
| 33 |
+
if self.error:
|
| 34 |
+
bits.append(f"ERROR {self.error}")
|
| 35 |
+
elif self.result is not None:
|
| 36 |
+
bits.append(self.result.summary())
|
| 37 |
+
return " | ".join(bits)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class Episode:
|
| 42 |
+
"""An append-only record of one run: the unit you replay and score."""
|
| 43 |
+
|
| 44 |
+
goal: str
|
| 45 |
+
steps: list[Step] = field(default_factory=list)
|
| 46 |
+
stop_reason: str = ""
|
| 47 |
+
success: bool = False
|
| 48 |
+
|
| 49 |
+
def to_rows(self) -> list[dict[str, Any]]:
|
| 50 |
+
"""Rows for a dataframe / notebook table."""
|
| 51 |
+
rows = []
|
| 52 |
+
for step in self.steps:
|
| 53 |
+
rows.append({
|
| 54 |
+
"step": step.index,
|
| 55 |
+
"elements": len(step.observation.elements),
|
| 56 |
+
"action": step.action.describe() if step.action else "",
|
| 57 |
+
"outcome": step.error or (step.result.summary() if step.result else ""),
|
| 58 |
+
})
|
| 59 |
+
return rows
|
| 60 |
+
|
| 61 |
+
def render(self) -> str:
|
| 62 |
+
lines = [
|
| 63 |
+
f"goal: {self.goal}",
|
| 64 |
+
f"result: {'success' if self.success else 'failure'} - {self.stop_reason}",
|
| 65 |
+
]
|
| 66 |
+
lines += [f" {step.line()}" for step in self.steps]
|
| 67 |
+
return "\n".join(lines)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class AgentLoop:
|
| 71 |
+
"""Wires a screen, a brain, an executor and a policy into an agent.
|
| 72 |
+
|
| 73 |
+
Guarantees, by construction rather than by prompting:
|
| 74 |
+
|
| 75 |
+
* every action is one of six validated verbs
|
| 76 |
+
* nothing executes until policy has passed it
|
| 77 |
+
* the episode ends on a step budget, a kill switch, or a brain error
|
| 78 |
+
* the screen is re-read after every action, so the next decision sees the
|
| 79 |
+
consequence of the last one
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
def __init__(self, *, screen: ScreenSource, brain: Brain,
|
| 83 |
+
executor: Executor | None = None, policy: Policy | None = None,
|
| 84 |
+
on_step: Callable[[Step], None] | None = None) -> None:
|
| 85 |
+
self.screen = screen
|
| 86 |
+
self.brain = brain
|
| 87 |
+
self.policy = policy or Policy()
|
| 88 |
+
if executor is None:
|
| 89 |
+
executor = DryRunExecutor() if self.policy.dry_run else PyAutoGuiExecutor()
|
| 90 |
+
self.executor = executor
|
| 91 |
+
self.on_step = on_step
|
| 92 |
+
|
| 93 |
+
def run(self, goal: str, *, max_steps: int | None = None) -> Episode:
|
| 94 |
+
budget = max_steps or self.policy.step_budget
|
| 95 |
+
episode = Episode(goal=goal, stop_reason=f"step budget of {budget} exhausted")
|
| 96 |
+
observation = self.screen.capture()
|
| 97 |
+
|
| 98 |
+
for index in range(budget):
|
| 99 |
+
if self.policy.should_stop():
|
| 100 |
+
episode.stop_reason = "kill switch engaged"
|
| 101 |
+
break
|
| 102 |
+
|
| 103 |
+
try:
|
| 104 |
+
action = self.brain.decide(goal, observation)
|
| 105 |
+
except GoalUnreachable as exc:
|
| 106 |
+
self._record(episode, Step(index, observation, error=f"gave up: {exc}"))
|
| 107 |
+
episode.stop_reason = f"brain gave up: {exc}"
|
| 108 |
+
break
|
| 109 |
+
except Exception as exc:
|
| 110 |
+
self._record(episode, Step(index, observation,
|
| 111 |
+
error=f"brain failed: {type(exc).__name__}: {exc}"))
|
| 112 |
+
episode.stop_reason = "brain error"
|
| 113 |
+
break
|
| 114 |
+
|
| 115 |
+
if action.kind == "done":
|
| 116 |
+
self._record(episode, Step(
|
| 117 |
+
index, observation, action=action,
|
| 118 |
+
result=ActionResult(action=action, ok=True, note="goal reported met"),
|
| 119 |
+
))
|
| 120 |
+
episode.success = True
|
| 121 |
+
episode.stop_reason = f"brain reported done: {action.reason or 'no reason given'}"
|
| 122 |
+
break
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
self.policy.check(action, observation)
|
| 126 |
+
except PolicyViolation as exc:
|
| 127 |
+
self._record(episode, Step(index, observation, action=action,
|
| 128 |
+
error=f"refused: {exc}"))
|
| 129 |
+
episode.stop_reason = f"policy refusal: {exc}"
|
| 130 |
+
break
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
result = self.executor.execute(action, observation)
|
| 134 |
+
except Exception as exc:
|
| 135 |
+
self._record(episode, Step(index, observation, action=action,
|
| 136 |
+
error=f"executor failed: {type(exc).__name__}: {exc}"))
|
| 137 |
+
episode.stop_reason = "executor error"
|
| 138 |
+
break
|
| 139 |
+
|
| 140 |
+
self._record(episode, Step(index, observation, action=action, result=result))
|
| 141 |
+
if not result.ok:
|
| 142 |
+
episode.stop_reason = f"action failed: {result.note}"
|
| 143 |
+
break
|
| 144 |
+
|
| 145 |
+
observation = self.screen.capture() # verify: what did that change?
|
| 146 |
+
|
| 147 |
+
return episode
|
| 148 |
+
|
| 149 |
+
def _record(self, episode: Episode, step: Step) -> None:
|
| 150 |
+
episode.steps.append(step)
|
| 151 |
+
if self.on_step is not None:
|
| 152 |
+
self.on_step(step)
|
tiny_computer_agent/observe.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Perception: turn the world into an :class:`Observation` the brain can read.
|
| 2 |
+
|
| 3 |
+
The harness never asks the model to *find* anything. OCR or the accessibility tree
|
| 4 |
+
produces a labelled element index; the model only chooses among those labels.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any, Protocol, Sequence
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class Element:
|
| 15 |
+
"""A clickable thing, located by the platform — not by the model."""
|
| 16 |
+
|
| 17 |
+
label: str
|
| 18 |
+
kind: str = "button"
|
| 19 |
+
x: int = 0
|
| 20 |
+
y: int = 0
|
| 21 |
+
width: int = 0
|
| 22 |
+
height: int = 0
|
| 23 |
+
|
| 24 |
+
def __post_init__(self) -> None:
|
| 25 |
+
if not self.label.strip():
|
| 26 |
+
raise ValueError("Element.label must be non-empty")
|
| 27 |
+
if min(self.x, self.y, self.width, self.height) < 0:
|
| 28 |
+
raise ValueError("Element geometry must be non-negative")
|
| 29 |
+
|
| 30 |
+
@property
|
| 31 |
+
def center(self) -> tuple[int, int]:
|
| 32 |
+
if self.width and self.height:
|
| 33 |
+
return (self.x + self.width // 2, self.y + self.height // 2)
|
| 34 |
+
return (self.x, self.y)
|
| 35 |
+
|
| 36 |
+
def matches(self, needle: str) -> bool:
|
| 37 |
+
return needle.strip().lower() in self.label.strip().lower()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass(frozen=True)
|
| 41 |
+
class Observation:
|
| 42 |
+
"""One frame of the world: pixels plus whatever index we could build."""
|
| 43 |
+
|
| 44 |
+
index: int = 0
|
| 45 |
+
frame: Any = None
|
| 46 |
+
elements: tuple[Element, ...] = ()
|
| 47 |
+
text: str = ""
|
| 48 |
+
|
| 49 |
+
def find(self, label: str) -> Element | None:
|
| 50 |
+
"""Exact label first, then substring; None if nothing matches."""
|
| 51 |
+
want = label.strip().lower()
|
| 52 |
+
exact = [e for e in self.elements if e.label.strip().lower() == want]
|
| 53 |
+
if exact:
|
| 54 |
+
return exact[0]
|
| 55 |
+
loose = [e for e in self.elements if e.matches(label)]
|
| 56 |
+
return loose[0] if loose else None
|
| 57 |
+
|
| 58 |
+
def summary(self, max_elements: int = 40) -> str:
|
| 59 |
+
"""Compact text form — this is what a model would be shown."""
|
| 60 |
+
lines = [f"step {self.index}"]
|
| 61 |
+
if self.text:
|
| 62 |
+
lines.append("visible text: " + " ".join(self.text.split())[:400])
|
| 63 |
+
if self.elements:
|
| 64 |
+
lines.append("clickable elements:")
|
| 65 |
+
for e in self.elements[:max_elements]:
|
| 66 |
+
x, y = e.center
|
| 67 |
+
lines.append(f" - {e.label!r} ({e.kind}) at {x},{y}")
|
| 68 |
+
if len(self.elements) > max_elements:
|
| 69 |
+
lines.append(f" ... and {len(self.elements) - max_elements} more")
|
| 70 |
+
if not self.elements and not self.text:
|
| 71 |
+
lines.append("no element index available (pixels only)")
|
| 72 |
+
return "\n".join(lines)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ScreenSource(Protocol):
|
| 76 |
+
def capture(self) -> Observation: ...
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class FakeScreen:
|
| 80 |
+
"""Scripted screen: each capture() advances one frame.
|
| 81 |
+
|
| 82 |
+
Used to exercise the loop with no display at all. The final frame repeats
|
| 83 |
+
forever, so a runaway agent cannot exhaust the script and crash.
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
def __init__(self, frames: Sequence[Sequence[Element]], *, text: str = "") -> None:
|
| 87 |
+
if not frames:
|
| 88 |
+
raise ValueError("FakeScreen needs at least one frame")
|
| 89 |
+
self._frames: tuple[tuple[Element, ...], ...] = tuple(tuple(f) for f in frames)
|
| 90 |
+
self._text = text
|
| 91 |
+
self.captures = 0
|
| 92 |
+
|
| 93 |
+
def capture(self) -> Observation:
|
| 94 |
+
i = min(self.captures, len(self._frames) - 1)
|
| 95 |
+
observation = Observation(
|
| 96 |
+
index=self.captures,
|
| 97 |
+
frame=f"frame-{i}",
|
| 98 |
+
elements=self._frames[i],
|
| 99 |
+
text=self._text,
|
| 100 |
+
)
|
| 101 |
+
self.captures += 1
|
| 102 |
+
return observation
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class ScreenUnavailable(RuntimeError):
|
| 106 |
+
"""Raised when real capture is requested but the machine cannot provide it."""
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class GrabScreen:
|
| 110 |
+
"""Real desktop capture via ``mss`` + Pillow.
|
| 111 |
+
|
| 112 |
+
Pixels only: nothing here builds an element index. Wiring OCR (tesseract) or
|
| 113 |
+
the accessibility tree (AT-SPI on Linux) is what turns frames into labels.
|
| 114 |
+
Needs a display, so it is untested in a headless sandbox.
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
def __init__(self, *, monitor: int = 1, max_width: int = 1280) -> None:
|
| 118 |
+
self.monitor = monitor
|
| 119 |
+
self.max_width = max_width
|
| 120 |
+
self._index = 0
|
| 121 |
+
|
| 122 |
+
def capture(self) -> Observation:
|
| 123 |
+
try:
|
| 124 |
+
import mss
|
| 125 |
+
from PIL import Image
|
| 126 |
+
except ImportError as exc: # pragma: no cover - depends on the machine
|
| 127 |
+
raise ScreenUnavailable(
|
| 128 |
+
"GrabScreen needs 'mss' and 'pillow': pip install \'tiny-computer-agent[desktop]\'"
|
| 129 |
+
) from exc
|
| 130 |
+
try:
|
| 131 |
+
with mss.mss() as sct:
|
| 132 |
+
shot = sct.grab(sct.monitors[self.monitor])
|
| 133 |
+
image = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
|
| 134 |
+
except Exception as exc: # pragma: no cover - depends on the machine
|
| 135 |
+
raise ScreenUnavailable(f"screen capture failed (no display?): {exc}") from exc
|
| 136 |
+
if image.width > self.max_width:
|
| 137 |
+
ratio = self.max_width / image.width
|
| 138 |
+
image = image.resize((self.max_width, int(image.height * ratio)))
|
| 139 |
+
observation = Observation(index=self._index, frame=image, elements=(), text="")
|
| 140 |
+
self._index += 1
|
| 141 |
+
return observation
|
tiny_computer_agent/safety.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The gates between a small model's mistake and your filesystem."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from typing import Pattern
|
| 9 |
+
|
| 10 |
+
from tiny_computer_agent.actions import Action
|
| 11 |
+
from tiny_computer_agent.observe import Observation
|
| 12 |
+
|
| 13 |
+
DEFAULT_DENY_TEXT = (
|
| 14 |
+
r"\bsudo\b",
|
| 15 |
+
r"\brm\s+-rf\b",
|
| 16 |
+
r"\bmkfs\b",
|
| 17 |
+
r"\bdd\s+if=",
|
| 18 |
+
r"\bchmod\s+777\b",
|
| 19 |
+
r"\bgit\s+push\s+--force\b",
|
| 20 |
+
r"password",
|
| 21 |
+
r"api[_-]?key",
|
| 22 |
+
r"\bseed\s+phrase\b",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
DEFAULT_DENY_LABELS = (
|
| 26 |
+
"terminal",
|
| 27 |
+
"shell",
|
| 28 |
+
"keychain",
|
| 29 |
+
"password manager",
|
| 30 |
+
"bank",
|
| 31 |
+
"wallet",
|
| 32 |
+
"credit card",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
DEFAULT_DENY_KEYS = ("ctrl+alt+t", "ctrl+alt+delete", "ctrl+shift+escape")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class PolicyViolation(RuntimeError):
|
| 39 |
+
"""Raised when an action is refused. The loop stops; nothing is executed."""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass
|
| 43 |
+
class Policy:
|
| 44 |
+
"""Rules an action must pass before it is executed.
|
| 45 |
+
|
| 46 |
+
dry_run : when True (the default) the loop uses a DryRunExecutor
|
| 47 |
+
step_budget : hard cap on actions per episode
|
| 48 |
+
deny_text_patterns : regexes a `type` action may not contain
|
| 49 |
+
deny_labels : substrings that block a click on a matching element
|
| 50 |
+
deny_keys : key chords that are never pressed
|
| 51 |
+
kill_switch_path : if this file exists, the episode aborts before the next
|
| 52 |
+
action — create it to stop a live run
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
dry_run: bool = True
|
| 56 |
+
step_budget: int = 20
|
| 57 |
+
deny_text_patterns: tuple[str, ...] = DEFAULT_DENY_TEXT
|
| 58 |
+
deny_labels: tuple[str, ...] = DEFAULT_DENY_LABELS
|
| 59 |
+
deny_keys: tuple[str, ...] = DEFAULT_DENY_KEYS
|
| 60 |
+
kill_switch_path: str | None = None
|
| 61 |
+
_compiled: tuple[Pattern[str], ...] = field(default=(), init=False, repr=False)
|
| 62 |
+
|
| 63 |
+
def __post_init__(self) -> None:
|
| 64 |
+
self._compiled = tuple(re.compile(p, re.IGNORECASE) for p in self.deny_text_patterns)
|
| 65 |
+
if self.step_budget < 1:
|
| 66 |
+
raise ValueError("step_budget must be at least 1")
|
| 67 |
+
|
| 68 |
+
def should_stop(self) -> bool:
|
| 69 |
+
"""True when the kill switch exists. Checked before every action."""
|
| 70 |
+
return bool(self.kill_switch_path) and os.path.exists(self.kill_switch_path)
|
| 71 |
+
|
| 72 |
+
def check(self, action: Action, observation: Observation | None = None) -> None:
|
| 73 |
+
"""Raise :class:`PolicyViolation` if the action is not allowed."""
|
| 74 |
+
if action.kind == "type" and action.text:
|
| 75 |
+
for pattern in self._compiled:
|
| 76 |
+
if pattern.search(action.text):
|
| 77 |
+
raise PolicyViolation(
|
| 78 |
+
f"typing {pattern.pattern!r} is on the deny list"
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
if action.kind == "key" and action.keys:
|
| 82 |
+
chord = "+".join(k.strip().lower() for k in action.keys.split("+") if k.strip())
|
| 83 |
+
if chord in {k.lower() for k in self.deny_keys}:
|
| 84 |
+
raise PolicyViolation(f"key chord {action.keys!r} is on the deny list")
|
| 85 |
+
|
| 86 |
+
if action.kind == "click" and action.label:
|
| 87 |
+
element = observation.find(action.label) if observation is not None else None
|
| 88 |
+
haystack = (element.label if element is not None else action.label).lower()
|
| 89 |
+
for denied in self.deny_labels:
|
| 90 |
+
if denied.lower() in haystack:
|
| 91 |
+
raise PolicyViolation(
|
| 92 |
+
f"clicking {action.label!r} is on the deny list ({denied!r})"
|
| 93 |
+
)
|