Publish tiny_computer_agent/ from marimo
Browse files- tiny_computer_agent/__init__.py +27 -16
- tiny_computer_agent/__main__.py +4 -2
- tiny_computer_agent/accessibility.py +266 -0
- tiny_computer_agent/brain.py +226 -5
- tiny_computer_agent/decoding.py +179 -0
- tiny_computer_agent/loop.py +150 -77
- tiny_computer_agent/models.py +136 -0
- tiny_computer_agent/perception.py +35 -1
tiny_computer_agent/__init__.py
CHANGED
|
@@ -25,6 +25,15 @@ An episode can be saved as a portable *trace*, replayed offline, and scored, so
|
|
| 25 |
print(benchmark(scenarios, {"planner": lambda s: PlannerBrain()}).render())
|
| 26 |
"""
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
from tiny_computer_agent.act import (
|
| 29 |
ActionResult,
|
| 30 |
DryRunExecutor,
|
|
@@ -115,23 +124,25 @@ from tiny_computer_agent.trace import (
|
|
| 115 |
__version__ = "0.3.0"
|
| 116 |
|
| 117 |
__all__ = [
|
| 118 |
-
"ACTION_KINDS", "ACTION_MENU", "
|
| 119 |
-
"
|
| 120 |
-
"
|
| 121 |
-
"
|
| 122 |
-
"
|
| 123 |
-
"
|
| 124 |
-
"
|
|
|
|
| 125 |
"MODEL_BRAINS", "MenuItem", "MockBrain", "ModelBrain",
|
| 126 |
"Observation", "OcrDetector", "PlannerBrain", "Policy",
|
| 127 |
"PolicyViolation", "PyAutoGuiExecutor", "Scenario", "Scoreboard",
|
| 128 |
-
"ScreenSource", "ScreenUnavailable", "Step", "
|
| 129 |
-
"
|
| 130 |
-
"
|
| 131 |
-
"
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
-
"
|
| 136 |
-
"
|
|
|
|
| 137 |
]
|
|
|
|
| 25 |
print(benchmark(scenarios, {"planner": lambda s: PlannerBrain()}).render())
|
| 26 |
"""
|
| 27 |
|
| 28 |
+
from tiny_computer_agent.accessibility import (
|
| 29 |
+
INTERACTIVE_ROLES,
|
| 30 |
+
TEXT_ROLES,
|
| 31 |
+
AccessibleNode,
|
| 32 |
+
AtspiDetector,
|
| 33 |
+
AtspiReader,
|
| 34 |
+
TreeReader,
|
| 35 |
+
accessibility_available,
|
| 36 |
+
)
|
| 37 |
from tiny_computer_agent.act import (
|
| 38 |
ActionResult,
|
| 39 |
DryRunExecutor,
|
|
|
|
| 124 |
__version__ = "0.3.0"
|
| 125 |
|
| 126 |
__all__ = [
|
| 127 |
+
"ACTION_KINDS", "ACTION_MENU", "AccessibleNode", "Action",
|
| 128 |
+
"ActionError", "ActionResult", "AgentLoop", "AtspiDetector",
|
| 129 |
+
"AtspiReader", "AutoDetector", "BRAIN_NAMES", "Brain",
|
| 130 |
+
"BrainError", "Check", "DEFAULT_KILL_SWITCH", "DEFAULT_TEXT_MODEL",
|
| 131 |
+
"DEFAULT_VISION_MODEL", "DecisionMenu", "Detector", "DetectorUnavailable",
|
| 132 |
+
"DryRunExecutor", "Element", "Episode", "Executor",
|
| 133 |
+
"FakeScreen", "GoalUnreachable", "GrabScreen", "GridDetector",
|
| 134 |
+
"INTERACTIVE_ROLES", "ImageScreen", "IndexedScreen", "InputUnavailable",
|
| 135 |
"MODEL_BRAINS", "MenuItem", "MockBrain", "ModelBrain",
|
| 136 |
"Observation", "OcrDetector", "PlannerBrain", "Policy",
|
| 137 |
"PolicyViolation", "PyAutoGuiExecutor", "Scenario", "Scoreboard",
|
| 138 |
+
"ScreenSource", "ScreenUnavailable", "Step", "TEXT_ROLES",
|
| 139 |
+
"TextPlannerBrain", "Trace", "TraceStep", "TreeReader",
|
| 140 |
+
"VLMBrain", "Verdict", "__version__", "accessibility_available",
|
| 141 |
+
"benchmark", "benchmark_traces", "build_agent", "detector_available",
|
| 142 |
+
"display_available", "doctor", "extract_json_object", "extract_json_objects",
|
| 143 |
+
"fake_desktop", "load_traces", "make_detector", "make_model_brain",
|
| 144 |
+
"model_cached", "model_dependencies", "normalise_label", "parse_action",
|
| 145 |
+
"parse_decision", "pick_dtype", "resolve_device", "resolve_point",
|
| 146 |
+
"run_checks", "run_scenario", "score", "score_replay",
|
| 147 |
+
"score_trace", "write_fake_desktop",
|
| 148 |
]
|
tiny_computer_agent/__main__.py
CHANGED
|
@@ -68,8 +68,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 68 |
f"'{EXAMPLE_IMAGE}' uses the built-in one")
|
| 69 |
parser.add_argument("--brain", default="planner", choices=BRAIN_NAMES,
|
| 70 |
help="planner (rule-based, no weights) | text-planner | vlm")
|
| 71 |
-
parser.add_argument("--detector", default="auto",
|
| 72 |
-
|
|
|
|
|
|
|
| 73 |
parser.add_argument("--live", action="store_true",
|
| 74 |
help="capture your real screen instead of the scripted demo")
|
| 75 |
parser.add_argument("--real-input", action="store_true",
|
|
|
|
| 68 |
f"'{EXAMPLE_IMAGE}' uses the built-in one")
|
| 69 |
parser.add_argument("--brain", default="planner", choices=BRAIN_NAMES,
|
| 70 |
help="planner (rule-based, no weights) | text-planner | vlm")
|
| 71 |
+
parser.add_argument("--detector", default="auto",
|
| 72 |
+
choices=("auto", "best", "atspi", "ocr", "grid"),
|
| 73 |
+
help="how to build the element index "
|
| 74 |
+
"(best = accessibility tree, then OCR, then grid)")
|
| 75 |
parser.add_argument("--live", action="store_true",
|
| 76 |
help="capture your real screen instead of the scripted demo")
|
| 77 |
parser.add_argument("--real-input", action="store_true",
|
tiny_computer_agent/accessibility.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Elements from the operating system's accessibility tree.
|
| 2 |
+
|
| 3 |
+
OCR reads pixels and *guesses* which marks are widgets. The accessibility tree
|
| 4 |
+
simply knows: every application that publishes one advertises each button, entry,
|
| 5 |
+
menu item and label with a role, a name, and its real screen extents. That is
|
| 6 |
+
faster than OCR, exact on small text the pixels lose, and - the part that matters
|
| 7 |
+
for a tiny model - it produces the same short, labelled menu the rest of the
|
| 8 |
+
harness already expects.
|
| 9 |
+
|
| 10 |
+
The catch is honest and load-bearing: not every application publishes a tree.
|
| 11 |
+
GTK/Qt/Electron and most native Windows apps do; Tk barely does, and a raw canvas
|
| 12 |
+
does not at all. So this is a detector, not a replacement - when the tree is
|
| 13 |
+
empty, `AutoDetector` falls through to OCR, and then to the numbered grid.
|
| 14 |
+
|
| 15 |
+
Backends:
|
| 16 |
+
|
| 17 |
+
* **AT-SPI** (Linux) - via PyGObject's ``Atspi`` typelib, implemented here and
|
| 18 |
+
exercised against a real GTK window.
|
| 19 |
+
* **UI Automation** (Windows) - *not implemented*: :func:`accessibility_available`
|
| 20 |
+
reports it plainly rather than pretending, so an agent on Windows keeps using
|
| 21 |
+
OCR instead of silently indexing nothing.
|
| 22 |
+
|
| 23 |
+
The coordinates AT-SPI reports are already absolute screen pixels, which is why
|
| 24 |
+
:class:`AtspiDetector` sets ``absolute_coordinates``: :class:`IndexedScreen` must
|
| 25 |
+
not re-map them through a downscale, or every click would land in the wrong place.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
from dataclasses import dataclass, field
|
| 31 |
+
from typing import Any, Protocol, Sequence
|
| 32 |
+
|
| 33 |
+
from tiny_computer_agent.observe import Element
|
| 34 |
+
|
| 35 |
+
# Roles that are worth clicking. Deliberately not "everything with a name": a
|
| 36 |
+
# frame, a panel or a scrollbar is named too, and offering them to a model is how
|
| 37 |
+
# you get an agent that clicks the window furniture.
|
| 38 |
+
INTERACTIVE_ROLES: frozenset[str] = frozenset({
|
| 39 |
+
"button", "push button", "toggle button", "check box", "radio button",
|
| 40 |
+
"menu item", "check menu item", "radio menu item", "page tab", "list item",
|
| 41 |
+
"combo box", "entry", "text", "password text", "spin button", "slider",
|
| 42 |
+
"link", "icon", "table cell", "tree item", "tool bar", "menu",
|
| 43 |
+
})
|
| 44 |
+
|
| 45 |
+
# Named things that are not clickable, but are worth *reading* (a verifier, or a
|
| 46 |
+
# VLM, needs the words on screen).
|
| 47 |
+
TEXT_ROLES: frozenset[str] = frozenset({
|
| 48 |
+
"label", "paragraph", "heading", "static", "status bar", "text",
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class AccessibleNode:
|
| 53 |
+
"""One node of an accessibility tree, reduced to what a decision needs."""
|
| 54 |
+
|
| 55 |
+
__slots__ = ("role", "name", "x", "y", "width", "height", "depth")
|
| 56 |
+
|
| 57 |
+
def __init__(self, role: str, name: str, x: int, y: int, width: int, height: int,
|
| 58 |
+
depth: int = 0) -> None:
|
| 59 |
+
self.role = role
|
| 60 |
+
self.name = name
|
| 61 |
+
self.x = x
|
| 62 |
+
self.y = y
|
| 63 |
+
self.width = width
|
| 64 |
+
self.height = height
|
| 65 |
+
self.depth = depth
|
| 66 |
+
|
| 67 |
+
def __repr__(self) -> str: # pragma: no cover
|
| 68 |
+
return (f"AccessibleNode({self.role!r}, {self.name!r}, "
|
| 69 |
+
f"{self.x},{self.y} {self.width}x{self.height})")
|
| 70 |
+
|
| 71 |
+
def __eq__(self, other: object) -> bool:
|
| 72 |
+
return isinstance(other, AccessibleNode) and self.__slots__ and all(
|
| 73 |
+
getattr(self, slot) == getattr(other, slot) for slot in self.__slots__
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
def __hash__(self) -> int:
|
| 77 |
+
return hash((self.role, self.name, self.x, self.y, self.width, self.height))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TreeReader(Protocol):
|
| 81 |
+
"""Reads the accessibility tree of the whole desktop."""
|
| 82 |
+
|
| 83 |
+
name: str
|
| 84 |
+
|
| 85 |
+
def read(self) -> tuple[AccessibleNode, ...]: ...
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class AtspiReader:
|
| 89 |
+
"""Reads the live AT-SPI desktop through PyGObject.
|
| 90 |
+
|
| 91 |
+
Everything here is guarded: a machine without the typelib, without a session
|
| 92 |
+
bus, or without the accessibility bridge raises :class:`DetectorUnavailable`
|
| 93 |
+
with the fix, rather than returning an empty index that would look like "the
|
| 94 |
+
screen is empty" when it is really "the bridge is off".
|
| 95 |
+
"""
|
| 96 |
+
|
| 97 |
+
name = "atspi"
|
| 98 |
+
|
| 99 |
+
def __init__(self, *, desktop: int = 0, max_depth: int = 10,
|
| 100 |
+
max_nodes: int = 600, timeout_ms: int = 500) -> None:
|
| 101 |
+
self.desktop = desktop
|
| 102 |
+
self.max_depth = max_depth
|
| 103 |
+
self.max_nodes = max_nodes
|
| 104 |
+
self.timeout_ms = timeout_ms
|
| 105 |
+
|
| 106 |
+
@staticmethod
|
| 107 |
+
def _atspi():
|
| 108 |
+
try:
|
| 109 |
+
import gi
|
| 110 |
+
except ImportError as exc: # pragma: no cover - env
|
| 111 |
+
raise _unavailable(
|
| 112 |
+
"the AT-SPI reader needs PyGObject: pip install PyGObject, and "
|
| 113 |
+
"apt install gir1.2-atspi-2.0 at-spi2-core"
|
| 114 |
+
) from exc
|
| 115 |
+
try:
|
| 116 |
+
gi.require_version("Atspi", "2.0")
|
| 117 |
+
from gi.repository import Atspi
|
| 118 |
+
except Exception as exc: # pragma: no cover - env
|
| 119 |
+
raise _unavailable(
|
| 120 |
+
"the Atspi typelib is missing: apt install gir1.2-atspi-2.0 "
|
| 121 |
+
"at-spi2-core (and run inside a session bus)"
|
| 122 |
+
) from exc
|
| 123 |
+
return Atspi
|
| 124 |
+
|
| 125 |
+
def read(self) -> tuple[AccessibleNode, ...]:
|
| 126 |
+
Atspi = self._atspi()
|
| 127 |
+
try:
|
| 128 |
+
desktop = Atspi.get_desktop(self.desktop)
|
| 129 |
+
except Exception as exc: # pragma: no cover - env
|
| 130 |
+
raise _unavailable(
|
| 131 |
+
f"could not reach the accessibility bus: {type(exc).__name__}: {exc}. "
|
| 132 |
+
"Start a session bus (dbus-run-session) and leave NO_AT_BRIDGE unset."
|
| 133 |
+
) from exc
|
| 134 |
+
|
| 135 |
+
nodes: list[AccessibleNode] = []
|
| 136 |
+
for index in range(desktop.get_child_count()):
|
| 137 |
+
self._walk(Atspi, desktop.get_child_at_index(index), 0, nodes)
|
| 138 |
+
if len(nodes) >= self.max_nodes:
|
| 139 |
+
break
|
| 140 |
+
return tuple(nodes)
|
| 141 |
+
|
| 142 |
+
def _walk(self, Atspi, node: Any, depth: int, out: list[AccessibleNode]) -> None:
|
| 143 |
+
if depth > self.max_depth or len(out) >= self.max_nodes:
|
| 144 |
+
return
|
| 145 |
+
try:
|
| 146 |
+
role = (node.get_role_name() or "").strip()
|
| 147 |
+
name = " ".join((node.get_name() or "").split())
|
| 148 |
+
except Exception:
|
| 149 |
+
return
|
| 150 |
+
extents = self._extents(Atspi, node)
|
| 151 |
+
if extents is not None:
|
| 152 |
+
x, y, width, height = extents
|
| 153 |
+
out.append(AccessibleNode(role, name, x, y, width, height, depth))
|
| 154 |
+
try:
|
| 155 |
+
children = node.get_child_count()
|
| 156 |
+
except Exception:
|
| 157 |
+
children = 0
|
| 158 |
+
for index in range(children):
|
| 159 |
+
try:
|
| 160 |
+
child = node.get_child_at_index(index)
|
| 161 |
+
except Exception:
|
| 162 |
+
continue
|
| 163 |
+
self._walk(Atspi, child, depth + 1, out)
|
| 164 |
+
|
| 165 |
+
def _extents(self, Atspi, node: Any) -> tuple[int, int, int, int] | None:
|
| 166 |
+
try:
|
| 167 |
+
component = node.get_component_iface()
|
| 168 |
+
if component is None:
|
| 169 |
+
return None
|
| 170 |
+
box = component.get_extents(Atspi.CoordType.SCREEN)
|
| 171 |
+
except Exception:
|
| 172 |
+
return None
|
| 173 |
+
if box is None:
|
| 174 |
+
return None
|
| 175 |
+
return int(box.x), int(box.y), int(box.width), int(box.height)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _unavailable(detail: str):
|
| 179 |
+
from tiny_computer_agent.perception import DetectorUnavailable
|
| 180 |
+
return DetectorUnavailable(detail)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
@dataclass
|
| 184 |
+
class AtspiDetector:
|
| 185 |
+
"""Turns the accessibility tree into the harness's element index.
|
| 186 |
+
|
| 187 |
+
``kind`` is the AT-SPI role (``button``, ``check box``, ...), so a policy can
|
| 188 |
+
distinguish a button from a text field, and ``absolute_coordinates`` tells
|
| 189 |
+
:class:`IndexedScreen` these are already screen pixels.
|
| 190 |
+
"""
|
| 191 |
+
|
| 192 |
+
name: str = "atspi"
|
| 193 |
+
absolute_coordinates: bool = True
|
| 194 |
+
reader: TreeReader | None = field(default=None)
|
| 195 |
+
interactive_roles: frozenset[str] = INTERACTIVE_ROLES
|
| 196 |
+
include_text: bool = False
|
| 197 |
+
min_label_chars: int = 1
|
| 198 |
+
max_label_chars: int = 64
|
| 199 |
+
min_size: int = 4
|
| 200 |
+
max_nodes: int = 120
|
| 201 |
+
_last_roles: tuple[str, ...] = field(default=(), init=False, repr=False)
|
| 202 |
+
|
| 203 |
+
def detect(self, image: Any = None) -> tuple[Element, ...]:
|
| 204 |
+
"""Index the live tree. The image is accepted and deliberately ignored."""
|
| 205 |
+
reader = self.reader if self.reader is not None else AtspiReader()
|
| 206 |
+
nodes = reader.read()
|
| 207 |
+
wanted = self.interactive_roles | (TEXT_ROLES if self.include_text else frozenset())
|
| 208 |
+
elements: list[Element] = []
|
| 209 |
+
seen: set[tuple[str, int, int]] = set()
|
| 210 |
+
for node in nodes:
|
| 211 |
+
role = node.role.lower()
|
| 212 |
+
if role not in wanted:
|
| 213 |
+
continue
|
| 214 |
+
label = node.name
|
| 215 |
+
if not label or not (self.min_label_chars <= len(label) <= self.max_label_chars):
|
| 216 |
+
continue
|
| 217 |
+
if node.width < self.min_size or node.height < self.min_size:
|
| 218 |
+
continue
|
| 219 |
+
if node.x < 0 or node.y < 0: # off screen
|
| 220 |
+
continue
|
| 221 |
+
key = (label.lower(), node.x, node.y)
|
| 222 |
+
if key in seen:
|
| 223 |
+
continue
|
| 224 |
+
seen.add(key)
|
| 225 |
+
elements.append(Element(label=label, kind=role, x=node.x, y=node.y,
|
| 226 |
+
width=node.width, height=node.height))
|
| 227 |
+
if len(elements) >= self.max_nodes:
|
| 228 |
+
break
|
| 229 |
+
self._last_roles = tuple(element.kind for element in elements)
|
| 230 |
+
return tuple(elements)
|
| 231 |
+
|
| 232 |
+
def text(self) -> str:
|
| 233 |
+
"""The words on screen, for a verifier - straight from the tree."""
|
| 234 |
+
reader = self.reader if self.reader is not None else AtspiReader()
|
| 235 |
+
return " ".join(node.name for node in reader.read()
|
| 236 |
+
if node.name and node.role.lower() in (TEXT_ROLES | self.interactive_roles))
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def accessibility_available(backend: str = "atspi") -> tuple[bool, str]:
|
| 240 |
+
"""(available, detail) for preflight and the doctor, honestly reported."""
|
| 241 |
+
if backend == "uia":
|
| 242 |
+
return False, ("UI Automation is not implemented yet: on Windows the harness "
|
| 243 |
+
"falls back to OCR (see accessibility.py)")
|
| 244 |
+
if backend != "atspi":
|
| 245 |
+
return False, f"unknown accessibility backend {backend!r}"
|
| 246 |
+
try:
|
| 247 |
+
Atspi = AtspiReader._atspi()
|
| 248 |
+
except Exception as exc:
|
| 249 |
+
return False, str(exc)
|
| 250 |
+
try:
|
| 251 |
+
desktop = Atspi.get_desktop(0)
|
| 252 |
+
applications = desktop.get_child_count()
|
| 253 |
+
except Exception as exc:
|
| 254 |
+
return False, f"Atspi is installed but the a11y bus is unreachable: {exc}"
|
| 255 |
+
return True, f"AT-SPI via PyGObject ({applications} application(s) on the desktop)"
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
__all__ = [
|
| 259 |
+
"INTERACTIVE_ROLES",
|
| 260 |
+
"TEXT_ROLES",
|
| 261 |
+
"AccessibleNode",
|
| 262 |
+
"AtspiDetector",
|
| 263 |
+
"AtspiReader",
|
| 264 |
+
"TreeReader",
|
| 265 |
+
"accessibility_available",
|
| 266 |
+
]
|
tiny_computer_agent/brain.py
CHANGED
|
@@ -23,6 +23,15 @@ from dataclasses import dataclass
|
|
| 23 |
from typing import Any, Iterator, Mapping, Protocol, Sequence
|
| 24 |
|
| 25 |
from tiny_computer_agent.actions import ACTION_KINDS, Action, ActionError, parse_action
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
from tiny_computer_agent.observe import Element, Observation, normalise_label
|
| 27 |
|
| 28 |
|
|
@@ -348,12 +357,27 @@ class ModelBrain:
|
|
| 348 |
|
| 349 |
name = "model"
|
| 350 |
requires_index = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
def __init__(self, model_id: str, *, max_menu_items: int = 12,
|
| 353 |
-
max_new_tokens: int = 64
|
|
|
|
| 354 |
self.model_id = model_id
|
| 355 |
self.max_menu_items = max_menu_items
|
| 356 |
self.max_new_tokens = max_new_tokens
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
self.last_prompt: str | None = None
|
| 358 |
self.last_reply: str | None = None
|
| 359 |
# "what I already tried", newest last; each entry's outcome is filled in at
|
|
@@ -367,13 +391,21 @@ class ModelBrain:
|
|
| 367 |
observation.text[:200])
|
| 368 |
|
| 369 |
def build_prompt(self, goal: str, menu: DecisionMenu, observation: Observation,
|
| 370 |
-
*, history: Sequence[str] = (), screen_changed: bool = True
|
|
|
|
| 371 |
"""A short prompt with a strict output shape.
|
| 372 |
|
| 373 |
Deliberately contains placeholders (``<number>``) rather than worked
|
| 374 |
examples: the weakest models echo the prompt back, and an echoed
|
| 375 |
placeholder fails validation instead of being mistaken for a decision.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
"""
|
|
|
|
|
|
|
|
|
|
| 377 |
lines = [
|
| 378 |
"You are controlling a computer. Choose exactly one action.",
|
| 379 |
"",
|
|
@@ -396,6 +428,27 @@ class ModelBrain:
|
|
| 396 |
"Choose a DIFFERENT action."]
|
| 397 |
return "\n".join(lines)
|
| 398 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
def decide(self, goal: str, observation: Observation) -> Action:
|
| 400 |
menu = DecisionMenu(observation, max_items=self.max_menu_items)
|
| 401 |
if self.requires_index and not menu:
|
|
@@ -420,14 +473,182 @@ class ModelBrain:
|
|
| 420 |
goal, menu, observation, history=tuple(self.history),
|
| 421 |
screen_changed=screen_changed,
|
| 422 |
)
|
| 423 |
-
self.
|
| 424 |
-
|
| 425 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
self.history.append(action.describe())
|
| 427 |
del self.history[:-3]
|
| 428 |
self._last_fingerprint = fingerprint
|
| 429 |
return action
|
| 430 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
@property
|
| 432 |
def loaded(self) -> bool:
|
| 433 |
"""True once the weights are in memory - they load lazily, on the first decide()."""
|
|
|
|
| 23 |
from typing import Any, Iterator, Mapping, Protocol, Sequence
|
| 24 |
|
| 25 |
from tiny_computer_agent.actions import ACTION_KINDS, Action, ActionError, parse_action
|
| 26 |
+
from tiny_computer_agent.decoding import (
|
| 27 |
+
brevity_ladder,
|
| 28 |
+
choice_token_ids,
|
| 29 |
+
digit_token_ids,
|
| 30 |
+
majority_choice,
|
| 31 |
+
pair_token_ids,
|
| 32 |
+
read_choice,
|
| 33 |
+
read_verdict,
|
| 34 |
+
)
|
| 35 |
from tiny_computer_agent.observe import Element, Observation, normalise_label
|
| 36 |
|
| 37 |
|
|
|
|
| 357 |
|
| 358 |
name = "model"
|
| 359 |
requires_index = False
|
| 360 |
+
# a backend that can mask its own logits opts in, and then decision_mode
|
| 361 |
+
# "choice" is available: ask for a menu number instead of asking for JSON
|
| 362 |
+
supports_constrained = False
|
| 363 |
+
# "json" - free text, parsed leniently (the original behaviour)
|
| 364 |
+
# "choice" - one masked generation, a menu number
|
| 365 |
+
# "scan" - one yes/no question per item, masked, first yes wins
|
| 366 |
+
# "rank" - the same questions, but ranked by the yes/no log-odds margin,
|
| 367 |
+
# which does not reward whichever item happened to be asked first
|
| 368 |
+
decision_mode = "json"
|
| 369 |
|
| 370 |
def __init__(self, model_id: str, *, max_menu_items: int = 12,
|
| 371 |
+
max_new_tokens: int = 64, decision_mode: str = "json",
|
| 372 |
+
retries: int = 3, votes: int = 1) -> None:
|
| 373 |
self.model_id = model_id
|
| 374 |
self.max_menu_items = max_menu_items
|
| 375 |
self.max_new_tokens = max_new_tokens
|
| 376 |
+
self.decision_mode = decision_mode
|
| 377 |
+
self.retries = max(1, retries)
|
| 378 |
+
self.votes = max(1, votes)
|
| 379 |
+
self.last_choice: int | None = None
|
| 380 |
+
self.echoes_refused = 0
|
| 381 |
self.last_prompt: str | None = None
|
| 382 |
self.last_reply: str | None = None
|
| 383 |
# "what I already tried", newest last; each entry's outcome is filled in at
|
|
|
|
| 391 |
observation.text[:200])
|
| 392 |
|
| 393 |
def build_prompt(self, goal: str, menu: DecisionMenu, observation: Observation,
|
| 394 |
+
*, history: Sequence[str] = (), screen_changed: bool = True,
|
| 395 |
+
brevity: int = 0) -> str:
|
| 396 |
"""A short prompt with a strict output shape.
|
| 397 |
|
| 398 |
Deliberately contains placeholders (``<number>``) rather than worked
|
| 399 |
examples: the weakest models echo the prompt back, and an echoed
|
| 400 |
placeholder fails validation instead of being mistaken for a decision.
|
| 401 |
+
|
| 402 |
+
``brevity`` is the retry ladder: echoing is driven by how much text the
|
| 403 |
+
model has to copy, so each step upwards drops a section - first the screen
|
| 404 |
+
text and the history, then everything but the menu.
|
| 405 |
"""
|
| 406 |
+
if self.decision_mode == "choice":
|
| 407 |
+
return self._choice_prompt(goal, menu, observation,
|
| 408 |
+
history=history, brevity=brevity)
|
| 409 |
lines = [
|
| 410 |
"You are controlling a computer. Choose exactly one action.",
|
| 411 |
"",
|
|
|
|
| 428 |
"Choose a DIFFERENT action."]
|
| 429 |
return "\n".join(lines)
|
| 430 |
|
| 431 |
+
def _choice_prompt(self, goal: str, menu: DecisionMenu, observation: Observation,
|
| 432 |
+
*, history: Sequence[str] = (), brevity: int = 0) -> str:
|
| 433 |
+
"""The constrained-mode prompt: one number, and as little text as possible.
|
| 434 |
+
|
| 435 |
+
There is no JSON shape to copy and no worked example - the whole point of
|
| 436 |
+
the masked path is that the model has nothing to echo.
|
| 437 |
+
"""
|
| 438 |
+
lines = [
|
| 439 |
+
"You are controlling a computer. Reply with ONE NUMBER and nothing else.",
|
| 440 |
+
"0 means the goal is already met. Any other number is the item to click.",
|
| 441 |
+
"",
|
| 442 |
+
f"Goal: {goal}",
|
| 443 |
+
]
|
| 444 |
+
if brevity == 0 and observation.text:
|
| 445 |
+
lines += ["", f"Text on screen: {observation.text[:200]}"]
|
| 446 |
+
lines += ["", f"Items ({len(menu)}):", menu.render() if menu else " (none detected)"]
|
| 447 |
+
if brevity == 0 and history:
|
| 448 |
+
lines += ["", "Already tried, and it did not work:"]
|
| 449 |
+
lines += [f" - {entry}" for entry in history]
|
| 450 |
+
return "\n".join(lines)
|
| 451 |
+
|
| 452 |
def decide(self, goal: str, observation: Observation) -> Action:
|
| 453 |
menu = DecisionMenu(observation, max_items=self.max_menu_items)
|
| 454 |
if self.requires_index and not menu:
|
|
|
|
| 473 |
goal, menu, observation, history=tuple(self.history),
|
| 474 |
screen_changed=screen_changed,
|
| 475 |
)
|
| 476 |
+
if self.decision_mode == "rank" and self.supports_constrained:
|
| 477 |
+
action = self._decide_by_ranking(goal, menu, observation)
|
| 478 |
+
elif self.decision_mode == "scan" and self.supports_constrained:
|
| 479 |
+
action = self._decide_by_scanning(goal, menu, observation)
|
| 480 |
+
elif self.decision_mode == "choice" and self.supports_constrained:
|
| 481 |
+
action = self._decide_by_choice(goal, menu, observation)
|
| 482 |
+
else:
|
| 483 |
+
self.last_reply = self._generate(self.last_prompt, observation)
|
| 484 |
+
action = parse_decision(self.last_reply, observation,
|
| 485 |
+
max_menu_items=self.max_menu_items)
|
| 486 |
self.history.append(action.describe())
|
| 487 |
del self.history[:-3]
|
| 488 |
self._last_fingerprint = fingerprint
|
| 489 |
return action
|
| 490 |
|
| 491 |
+
def _decide_by_choice(self, goal: str, menu: DecisionMenu,
|
| 492 |
+
observation: Observation) -> Action:
|
| 493 |
+
"""Ask for a menu number with every other token masked out.
|
| 494 |
+
|
| 495 |
+
The echo failure cannot happen here: the model is unable to emit the prompt
|
| 496 |
+
back, only one of the numbers we allow. When even that fails, the prompt
|
| 497 |
+
gets shorter (echoing is driven by prompt length) and, if ``votes`` is set,
|
| 498 |
+
the answer is taken by majority over several samples.
|
| 499 |
+
"""
|
| 500 |
+
if not menu:
|
| 501 |
+
raise GoalUnreachable("no clickable items to choose from")
|
| 502 |
+
tokenizer = self._tokenizer_for_choice()
|
| 503 |
+
allowed = choice_token_ids(tokenizer, len(menu))
|
| 504 |
+
if not allowed:
|
| 505 |
+
allowed = digit_token_ids(tokenizer)
|
| 506 |
+
if not allowed:
|
| 507 |
+
raise BrainError(
|
| 508 |
+
"this tokenizer cannot express a menu number as a single token, so "
|
| 509 |
+
"the choice cannot be constrained; use decision_mode='json'"
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
last_error: BrainError | None = None
|
| 513 |
+
for brevity in brevity_ladder(self.retries):
|
| 514 |
+
self.last_prompt = self.build_prompt(
|
| 515 |
+
goal, menu, observation, history=tuple(self.history), brevity=brevity)
|
| 516 |
+
replies = [
|
| 517 |
+
self._generate_constrained(self.last_prompt, observation, allowed, 4,
|
| 518 |
+
sample=index > 0)
|
| 519 |
+
for index in range(self.votes)
|
| 520 |
+
]
|
| 521 |
+
self.last_reply = replies[0] if replies else ""
|
| 522 |
+
choices = [read_choice(reply, len(menu)) for reply in replies]
|
| 523 |
+
number = majority_choice(choices)
|
| 524 |
+
if number is None:
|
| 525 |
+
self.echoes_refused += sum(1 for reply in replies
|
| 526 |
+
if reply and reply.strip() == self.last_prompt.strip())
|
| 527 |
+
last_error = BrainError(
|
| 528 |
+
f"the constrained reply was not a menu number: "
|
| 529 |
+
f"{self.last_reply[:80]!r} (brevity {brevity}, {len(replies)} vote(s))"
|
| 530 |
+
)
|
| 531 |
+
continue
|
| 532 |
+
self.last_choice = number
|
| 533 |
+
if number == 0:
|
| 534 |
+
return Action("done", reason="the model chose 0: the goal is already met")
|
| 535 |
+
item = menu.item(number)
|
| 536 |
+
if item is None: # pragma: no cover
|
| 537 |
+
last_error = BrainError(f"the model chose {number}, outside the menu")
|
| 538 |
+
continue
|
| 539 |
+
return Action("click", label=item.label,
|
| 540 |
+
reason=f"constrained choice {number} of {len(menu)}")
|
| 541 |
+
|
| 542 |
+
raise last_error or BrainError("the model produced no constrained choice")
|
| 543 |
+
|
| 544 |
+
def _scan_prompt(self, goal: str, label: str) -> str:
|
| 545 |
+
"""The prompt for one binary question: does *this* item do the job?
|
| 546 |
+
|
| 547 |
+
Short on purpose. Every word a tiny model is given is a word it can copy
|
| 548 |
+
instead of answering, and there is nothing here worth copying back.
|
| 549 |
+
"""
|
| 550 |
+
return "\n".join([
|
| 551 |
+
"You are controlling a computer. Answer with one word: yes or no.",
|
| 552 |
+
"",
|
| 553 |
+
f"Goal: {goal}",
|
| 554 |
+
f"Candidate item: {label}",
|
| 555 |
+
"Does clicking this item achieve the goal?",
|
| 556 |
+
])
|
| 557 |
+
|
| 558 |
+
def _decide_by_scanning(self, goal: str, menu: DecisionMenu,
|
| 559 |
+
observation: Observation) -> Action:
|
| 560 |
+
"""Ask one masked yes/no question per menu item, and take the first yes.
|
| 561 |
+
|
| 562 |
+
Why this rather than one N-way choice: a 256M model cannot hold twelve
|
| 563 |
+
options in mind, but "does this button do what the goal says?" is a
|
| 564 |
+
question it can answer. Each answer is one token out of two, so the mask
|
| 565 |
+
makes the reply space as small as it can possibly be - and the scan stops
|
| 566 |
+
at the first yes, so it is usually cheaper than it looks.
|
| 567 |
+
"""
|
| 568 |
+
if not menu:
|
| 569 |
+
raise GoalUnreachable("no clickable items to choose from")
|
| 570 |
+
tokenizer = self._tokenizer_for_choice()
|
| 571 |
+
yes_ids, no_ids = pair_token_ids(tokenizer)
|
| 572 |
+
if not yes_ids or not no_ids:
|
| 573 |
+
raise BrainError(
|
| 574 |
+
"this tokenizer cannot express yes/no as single tokens, so the scan "
|
| 575 |
+
"cannot be constrained; use decision_mode='json'"
|
| 576 |
+
)
|
| 577 |
+
allowed = sorted(set(yes_ids) | set(no_ids))
|
| 578 |
+
scanned: list[tuple[str, str | None]] = []
|
| 579 |
+
for item in menu:
|
| 580 |
+
self.last_prompt = self._scan_prompt(goal, item.label)
|
| 581 |
+
self.last_reply = self._generate_constrained(
|
| 582 |
+
self.last_prompt, observation, allowed, 1, sample=False)
|
| 583 |
+
verdict = read_verdict(self.last_reply)
|
| 584 |
+
scanned.append((item.label, verdict))
|
| 585 |
+
if verdict is None:
|
| 586 |
+
# a reply that is neither yes nor no means the mask is not being
|
| 587 |
+
# applied; refuse rather than fall through to a guess
|
| 588 |
+
raise BrainError(
|
| 589 |
+
f"the masked reply to a yes/no question was {self.last_reply[:40]!r}"
|
| 590 |
+
)
|
| 591 |
+
if verdict == "yes":
|
| 592 |
+
return Action("click", label=item.label,
|
| 593 |
+
reason=f"the model said yes to {item.label!r} "
|
| 594 |
+
f"({len(scanned)} of {len(menu)} scanned)")
|
| 595 |
+
self.last_reply = ""
|
| 596 |
+
raise BrainError(
|
| 597 |
+
f"the model said no to all {len(menu)} item(s): "
|
| 598 |
+
f"{[label for label, _ in scanned][:6]}"
|
| 599 |
+
)
|
| 600 |
+
|
| 601 |
+
def _decide_by_ranking(self, goal: str, menu: DecisionMenu,
|
| 602 |
+
observation: Observation) -> Action:
|
| 603 |
+
"""Ask every item, then click the one the model liked most.
|
| 604 |
+
|
| 605 |
+
``scan`` stops at the first yes, which quietly rewards position. Here the
|
| 606 |
+
model is asked about every candidate and the winner is the largest
|
| 607 |
+
yes-minus-no log-odds margin - the model's own opinion, read off the
|
| 608 |
+
distribution instead of off a sampled token. Costs N short generations,
|
| 609 |
+
and it is the cheapest way to find out whether the weights can do it at all.
|
| 610 |
+
"""
|
| 611 |
+
if not menu:
|
| 612 |
+
raise GoalUnreachable("no clickable items to choose from")
|
| 613 |
+
tokenizer = self._tokenizer_for_choice()
|
| 614 |
+
yes_ids, no_ids = pair_token_ids(tokenizer)
|
| 615 |
+
if not yes_ids or not no_ids:
|
| 616 |
+
raise BrainError(
|
| 617 |
+
"this tokenizer cannot express yes/no as single tokens, so the ranking "
|
| 618 |
+
"cannot be constrained; use decision_mode='json'"
|
| 619 |
+
)
|
| 620 |
+
scored: list[tuple[Any, float]] = []
|
| 621 |
+
for item in menu:
|
| 622 |
+
self.last_prompt = self._scan_prompt(goal, item.label)
|
| 623 |
+
margin = self._score_pair(self.last_prompt, observation, yes_ids, no_ids)
|
| 624 |
+
scored.append((item, margin))
|
| 625 |
+
scored.sort(key=lambda pair: pair[1], reverse=True)
|
| 626 |
+
best, margin = scored[0]
|
| 627 |
+
self.last_reply = f"margin {margin:+.2f}"
|
| 628 |
+
if margin <= 0:
|
| 629 |
+
raise BrainError(
|
| 630 |
+
f"the model preferred no item: best was {best.label!r} at {margin:+.2f}; "
|
| 631 |
+
f"runner-up {scored[1][0].label!r} at {scored[1][1]:+.2f}"
|
| 632 |
+
if len(scored) > 1 else
|
| 633 |
+
f"the model preferred no item (only {best.label!r}, {margin:+.2f})"
|
| 634 |
+
)
|
| 635 |
+
return Action("click", label=best.label,
|
| 636 |
+
reason=f"highest yes/no margin {margin:+.2f} of {len(scored)} item(s)")
|
| 637 |
+
|
| 638 |
+
def _score_pair(self, prompt: str, observation: Observation,
|
| 639 |
+
yes_ids: Sequence[int], no_ids: Sequence[int]) -> float:
|
| 640 |
+
"""How much more likely "yes" is than "no". Backends implement this."""
|
| 641 |
+
raise NotImplementedError("a constrained backend implements _score_pair")
|
| 642 |
+
|
| 643 |
+
def _tokenizer_for_choice(self) -> Any:
|
| 644 |
+
"""The tokenizer used to build the mask. Subclasses load it lazily."""
|
| 645 |
+
raise NotImplementedError("a constrained backend provides its tokenizer")
|
| 646 |
+
|
| 647 |
+
def _generate_constrained(self, prompt: str, observation: Observation,
|
| 648 |
+
allowed_ids: Sequence[int], max_new_tokens: int,
|
| 649 |
+
*, sample: bool = False) -> str:
|
| 650 |
+
raise NotImplementedError("a constrained backend implements this")
|
| 651 |
+
|
| 652 |
@property
|
| 653 |
def loaded(self) -> bool:
|
| 654 |
"""True once the weights are in memory - they load lazily, on the first decide()."""
|
tiny_computer_agent/decoding.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Making a tiny model answer with a choice instead of repeating the question.
|
| 2 |
+
|
| 3 |
+
A 256M model asked for JSON on a busy screen does the thing it is best at: it
|
| 4 |
+
continues the text it was handed, and gives the prompt back. The harness already
|
| 5 |
+
*detects* that (echo detection refuses the reply) but detecting it is not fixing
|
| 6 |
+
it - the episode simply fails.
|
| 7 |
+
|
| 8 |
+
The fix is to stop asking for free text. A menu of twelve items has twelve legal
|
| 9 |
+
answers, so mask every other token: an echo becomes impossible rather than merely
|
| 10 |
+
caught, and a model that cannot write a sentence can still pick a number.
|
| 11 |
+
|
| 12 |
+
Three tools, all model-agnostic:
|
| 13 |
+
|
| 14 |
+
* :func:`digit_token_ids` / :func:`choice_token_ids` - the token ids a menu answer
|
| 15 |
+
can possibly need
|
| 16 |
+
* :class:`MenuMask` - a transformers ``LogitsProcessor`` that forbids everything
|
| 17 |
+
else, so "hello, here is your prompt back" is not representable
|
| 18 |
+
* :func:`brevity_ladder` and :func:`majority_choice` - a shorter prompt on the
|
| 19 |
+
retry (echoing is driven by prompt length) and a vote over k attempts
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from typing import Any, Iterable, Sequence
|
| 25 |
+
|
| 26 |
+
DIGITS = tuple(str(d) for d in range(10))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _single_token_ids(tokenizer: Any, text: str) -> list[int]:
|
| 30 |
+
"""Ids that render ``text`` as exactly one token, with and without a space."""
|
| 31 |
+
ids: list[int] = []
|
| 32 |
+
for variant in (text, " " + text):
|
| 33 |
+
try:
|
| 34 |
+
encoded = tokenizer.encode(variant, add_special_tokens=False)
|
| 35 |
+
except Exception:
|
| 36 |
+
continue
|
| 37 |
+
if len(encoded) == 1:
|
| 38 |
+
ids.append(int(encoded[0]))
|
| 39 |
+
return ids
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def digit_token_ids(tokenizer: Any) -> list[int]:
|
| 43 |
+
"""Every single-token digit, so any menu number can be spelled out.
|
| 44 |
+
|
| 45 |
+
The loosest useful mask: ten or twenty tokens instead of a 50k vocabulary.
|
| 46 |
+
A model restricted to these cannot answer with prose or with the prompt.
|
| 47 |
+
"""
|
| 48 |
+
ids: list[int] = []
|
| 49 |
+
for digit in DIGITS:
|
| 50 |
+
ids += _single_token_ids(tokenizer, digit)
|
| 51 |
+
return sorted(set(ids))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def choice_token_ids(tokenizer: Any, count: int, *, allow_done: bool = True) -> list[int]:
|
| 55 |
+
"""The single-token ids for ``0..count`` (0 means "the goal is already met").
|
| 56 |
+
|
| 57 |
+
Stricter than :func:`digit_token_ids`: the model may emit exactly one of the
|
| 58 |
+
legal answers, and nothing else, in a single token.
|
| 59 |
+
"""
|
| 60 |
+
ids: list[int] = []
|
| 61 |
+
for number in range(0 if allow_done else 1, count + 1):
|
| 62 |
+
ids += _single_token_ids(tokenizer, str(number))
|
| 63 |
+
return sorted(set(ids))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def pair_token_ids(tokenizer: Any, *, yes: Sequence[str] = ("yes", "Yes"),
|
| 67 |
+
no: Sequence[str] = ("no", "No")) -> tuple[list[int], list[int]]:
|
| 68 |
+
"""Single-token ids for a yes/no answer.
|
| 69 |
+
|
| 70 |
+
Two tokens instead of a whole vocabulary: a 256M model that cannot pick 1-of-12
|
| 71 |
+
can often still answer "does this label do what the goal asks?".
|
| 72 |
+
"""
|
| 73 |
+
yes_ids: list[int] = []
|
| 74 |
+
no_ids: list[int] = []
|
| 75 |
+
for text in yes:
|
| 76 |
+
yes_ids += _single_token_ids(tokenizer, text)
|
| 77 |
+
for text in no:
|
| 78 |
+
no_ids += _single_token_ids(tokenizer, text)
|
| 79 |
+
return sorted(set(yes_ids)), sorted(set(no_ids))
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def read_verdict(text: str) -> str | None:
|
| 83 |
+
"""'yes', 'no', or None when the reply is neither."""
|
| 84 |
+
cleaned = (text or "").strip().lower().lstrip(":").strip()
|
| 85 |
+
if cleaned.startswith("yes"):
|
| 86 |
+
return "yes"
|
| 87 |
+
if cleaned.startswith("no"):
|
| 88 |
+
return "no"
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def MenuMask(allowed_ids: Sequence[int], *, eos_token_id: int | None = None,
|
| 93 |
+
force_first: bool = True):
|
| 94 |
+
"""A ``LogitsProcessor`` that permits only the ids in ``allowed_ids``.
|
| 95 |
+
|
| 96 |
+
Returns a plain callable with the transformers signature
|
| 97 |
+
``(input_ids, scores) -> scores``, so it needs no torch import at module load
|
| 98 |
+
and can be tested without a model.
|
| 99 |
+
|
| 100 |
+
``force_first`` closes a hole that a real 256M model found immediately: if the
|
| 101 |
+
end-of-sequence token is legal from the start, the cheapest continuation is to
|
| 102 |
+
stop, and the answer comes back *empty*. The mask therefore withholds EOS until
|
| 103 |
+
one real token has been emitted, so the model has to say something.
|
| 104 |
+
"""
|
| 105 |
+
allowed = sorted(set(int(i) for i in allowed_ids))
|
| 106 |
+
state = {"calls": 0}
|
| 107 |
+
|
| 108 |
+
def process(input_ids: Any, scores: Any) -> Any:
|
| 109 |
+
mask = scores.new_full(scores.shape, float("-inf"))
|
| 110 |
+
for index in allowed:
|
| 111 |
+
if 0 <= index < scores.shape[-1]:
|
| 112 |
+
mask[..., index] = scores[..., index]
|
| 113 |
+
if eos_token_id is not None:
|
| 114 |
+
stop = int(eos_token_id)
|
| 115 |
+
if not force_first or state["calls"] > 0:
|
| 116 |
+
if 0 <= stop < scores.shape[-1]:
|
| 117 |
+
mask[..., stop] = scores[..., stop]
|
| 118 |
+
state["calls"] += 1
|
| 119 |
+
return mask
|
| 120 |
+
|
| 121 |
+
process.allowed_ids = tuple(allowed) # for tests and for the trace
|
| 122 |
+
process.eos_token_id = eos_token_id
|
| 123 |
+
process.force_first = force_first
|
| 124 |
+
return process
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def read_choice(text: str, count: int, *, allow_done: bool = True) -> int | None:
|
| 128 |
+
"""The menu number in a reply, or None if there is not exactly one.
|
| 129 |
+
|
| 130 |
+
Only the digits are read: with the mask in place the reply *is* a number, and
|
| 131 |
+
anything else means the model is not being constrained (so it is refused
|
| 132 |
+
rather than guessed at).
|
| 133 |
+
"""
|
| 134 |
+
if not text:
|
| 135 |
+
return None
|
| 136 |
+
digits = "".join(character for character in text if character.isdigit())
|
| 137 |
+
if not digits:
|
| 138 |
+
return None
|
| 139 |
+
try:
|
| 140 |
+
number = int(digits)
|
| 141 |
+
except ValueError: # pragma: no cover - defensive
|
| 142 |
+
return None
|
| 143 |
+
low = 0 if allow_done else 1
|
| 144 |
+
return number if low <= number <= count else None
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def brevity_ladder(levels: int = 3) -> tuple[int, ...]:
|
| 148 |
+
"""The brevity settings to try, in order: full prompt first, terse last."""
|
| 149 |
+
return tuple(range(0, max(1, levels)))
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def majority_choice(choices: Iterable[int | None]) -> int | None:
|
| 153 |
+
"""The most common choice, ignoring the failures. Ties go to the first seen."""
|
| 154 |
+
counts: dict[int, int] = {}
|
| 155 |
+
order: list[int] = []
|
| 156 |
+
for choice in choices:
|
| 157 |
+
if choice is None:
|
| 158 |
+
continue
|
| 159 |
+
if choice not in counts:
|
| 160 |
+
counts[choice] = 0
|
| 161 |
+
order.append(choice)
|
| 162 |
+
counts[choice] += 1
|
| 163 |
+
if not counts:
|
| 164 |
+
return None
|
| 165 |
+
best = max(counts.values())
|
| 166 |
+
return next(choice for choice in order if counts[choice] == best)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
__all__ = [
|
| 170 |
+
"DIGITS",
|
| 171 |
+
"MenuMask",
|
| 172 |
+
"brevity_ladder",
|
| 173 |
+
"choice_token_ids",
|
| 174 |
+
"digit_token_ids",
|
| 175 |
+
"majority_choice",
|
| 176 |
+
"pair_token_ids",
|
| 177 |
+
"read_choice",
|
| 178 |
+
"read_verdict",
|
| 179 |
+
]
|
tiny_computer_agent/loop.py
CHANGED
|
@@ -97,97 +97,170 @@ class AgentLoop:
|
|
| 97 |
self.on_step = on_step
|
| 98 |
self.verify = verify
|
| 99 |
self.max_repeats = max_repeats
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
budget = max_steps or self.policy.step_budget
|
| 103 |
-
episode = Episode(goal=goal, stop_reason=f"step budget of {budget} exhausted")
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
| 109 |
try:
|
| 110 |
-
observation = self.screen.capture()
|
| 111 |
except Exception as exc:
|
| 112 |
-
|
| 113 |
-
|
|
|
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
episode.stop_reason = "brain error"
|
| 130 |
-
break
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
episode.stop_reason = f"brain reported done: {action.reason or 'no reason given'}"
|
| 139 |
-
break
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
f"against an unchanged screen",
|
| 157 |
-
))
|
| 158 |
-
episode.stop_reason = (
|
| 159 |
-
f"no progress after {repeats} identical actions on an unchanged "
|
| 160 |
-
f"screen ({action.kind} {action.label or action.text or ''})".strip()
|
| 161 |
-
)
|
| 162 |
-
break
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
episode.stop_reason = "executor error"
|
| 170 |
-
break
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
break
|
|
|
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
error=f"capture failed: {type(exc).__name__}: {exc}"))
|
| 182 |
-
episode.stop_reason = "perception failed mid-episode"
|
| 183 |
-
break
|
| 184 |
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
|
| 192 |
def _verified(self, observation: Observation) -> bool:
|
| 193 |
"""A broken verifier must never fake a success."""
|
|
|
|
| 97 |
self.on_step = on_step
|
| 98 |
self.verify = verify
|
| 99 |
self.max_repeats = max_repeats
|
| 100 |
+
# the stepwise state: an episode in progress, and the guards around it
|
| 101 |
+
self.episode: Episode | None = None
|
| 102 |
+
self.observation: Observation | None = None
|
| 103 |
+
self.paused = False
|
| 104 |
+
self._done = False
|
| 105 |
+
self._budget = self.policy.step_budget
|
| 106 |
+
self._turns = 0
|
| 107 |
+
self._previous: tuple | None = None
|
| 108 |
+
self._repeats = 0
|
| 109 |
+
|
| 110 |
+
# -- stepwise control -------------------------------------------------- #
|
| 111 |
+
|
| 112 |
+
def begin(self, goal: str, *, max_steps: int | None = None) -> "AgentLoop":
|
| 113 |
+
"""Start a new episode: read the screen once, and reset the guards.
|
| 114 |
+
|
| 115 |
+
The stepwise half of the API. :meth:`run` is the batch form and is built
|
| 116 |
+
from exactly this; a console calls ``begin`` then :meth:`step_once`, so one
|
| 117 |
+
button press can advance exactly one turn.
|
| 118 |
+
"""
|
| 119 |
budget = max_steps or self.policy.step_budget
|
| 120 |
+
self.episode = Episode(goal=goal, stop_reason=f"step budget of {budget} exhausted")
|
| 121 |
+
self._budget = budget
|
| 122 |
+
self._turns = 0
|
| 123 |
+
self._previous = None
|
| 124 |
+
self._repeats = 0
|
| 125 |
+
self._done = False
|
| 126 |
+
self.paused = False
|
| 127 |
try:
|
| 128 |
+
self.observation = self.screen.capture()
|
| 129 |
except Exception as exc:
|
| 130 |
+
self.observation = None
|
| 131 |
+
self._finish(f"capture failed: {type(exc).__name__}: {exc}")
|
| 132 |
+
return self
|
| 133 |
|
| 134 |
+
@property
|
| 135 |
+
def finished(self) -> bool:
|
| 136 |
+
"""True once this episode has ended - success, failure, budget or switch."""
|
| 137 |
+
return self._done
|
| 138 |
+
|
| 139 |
+
def pause(self) -> "AgentLoop":
|
| 140 |
+
"""Stop between steps: :meth:`run` returns and the episode stays open."""
|
| 141 |
+
self.paused = True
|
| 142 |
+
return self
|
| 143 |
+
|
| 144 |
+
def resume(self) -> "AgentLoop":
|
| 145 |
+
self.paused = False
|
| 146 |
+
return self
|
| 147 |
+
|
| 148 |
+
def step_once(self) -> Step | None:
|
| 149 |
+
"""One observe -> think -> act -> verify turn.
|
| 150 |
+
|
| 151 |
+
Returns the step it recorded, or ``None`` once the episode is over, so a
|
| 152 |
+
caller can loop on it without knowing any of the stopping rules.
|
| 153 |
+
"""
|
| 154 |
+
if self.episode is None or self._done or self.paused:
|
| 155 |
+
return None
|
| 156 |
+
episode, observation = self.episode, self.observation
|
| 157 |
+
|
| 158 |
+
if self._turns >= self._budget:
|
| 159 |
+
self._done = True
|
| 160 |
+
return None
|
| 161 |
+
if self.policy.should_stop():
|
| 162 |
+
self._finish("kill switch engaged")
|
| 163 |
+
return None
|
| 164 |
+
if observation is None: # a screen that never worked
|
| 165 |
+
self._done = True
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
index, self._turns = self._turns, self._turns + 1
|
| 169 |
|
| 170 |
+
try:
|
| 171 |
+
action = self.brain.decide(episode.goal, observation)
|
| 172 |
+
except GoalUnreachable as exc:
|
| 173 |
+
return self._record_step(Step(index, observation, error=f"gave up: {exc}"),
|
| 174 |
+
f"brain gave up: {exc}")
|
| 175 |
+
except Exception as exc:
|
| 176 |
+
return self._record_step(
|
| 177 |
+
Step(index, observation, error=f"brain failed: {type(exc).__name__}: {exc}"),
|
| 178 |
+
"brain error")
|
|
|
|
|
|
|
| 179 |
|
| 180 |
+
if action.kind == "done":
|
| 181 |
+
episode.success = True
|
| 182 |
+
return self._record_step(
|
| 183 |
+
Step(index, observation, action=action,
|
| 184 |
+
result=ActionResult(action=action, ok=True, note="goal reported met")),
|
| 185 |
+
f"brain reported done: {action.reason or 'no reason given'}")
|
|
|
|
|
|
|
| 186 |
|
| 187 |
+
try:
|
| 188 |
+
self.policy.check(action, observation)
|
| 189 |
+
except PolicyViolation as exc:
|
| 190 |
+
return self._record_step(Step(index, observation, action=action,
|
| 191 |
+
error=f"refused: {exc}"),
|
| 192 |
+
f"policy refusal: {exc}")
|
| 193 |
+
|
| 194 |
+
signature = (self._fingerprint(observation), action.describe())
|
| 195 |
+
self._repeats = self._repeats + 1 if signature == self._previous else 0
|
| 196 |
+
self._previous = signature
|
| 197 |
+
if self._repeats > self.max_repeats:
|
| 198 |
+
return self._record_step(
|
| 199 |
+
Step(index, observation, action=action,
|
| 200 |
+
error=f"no progress: repeating {action.describe()} "
|
| 201 |
+
f"against an unchanged screen"),
|
| 202 |
+
f"no progress after {self._repeats} identical actions on an unchanged "
|
| 203 |
+
f"screen ({action.kind} {action.label or action.text or ''})".strip())
|
| 204 |
|
| 205 |
+
try:
|
| 206 |
+
result = self.executor.execute(action, observation)
|
| 207 |
+
except Exception as exc:
|
| 208 |
+
return self._record_step(
|
| 209 |
+
Step(index, observation, action=action,
|
| 210 |
+
error=f"executor failed: {type(exc).__name__}: {exc}"),
|
| 211 |
+
"executor error")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
+
step = Step(index, observation, action=action, result=result)
|
| 214 |
+
self._append(step)
|
| 215 |
+
if not result.ok:
|
| 216 |
+
self._finish(f"action failed: {result.note}")
|
| 217 |
+
return step
|
|
|
|
|
|
|
| 218 |
|
| 219 |
+
try:
|
| 220 |
+
self.observation = self.screen.capture() # verify: what did that change?
|
| 221 |
+
except Exception as exc:
|
| 222 |
+
self._append(Step(index + 1, observation,
|
| 223 |
+
error=f"capture failed: {type(exc).__name__}: {exc}"))
|
| 224 |
+
self._finish("perception failed mid-episode")
|
| 225 |
+
return step
|
| 226 |
+
|
| 227 |
+
if self.verify is not None and self._verified(self.observation):
|
| 228 |
+
episode.success = True
|
| 229 |
+
self._finish(f"verified after {self._turns} action(s)")
|
| 230 |
+
return step
|
| 231 |
+
|
| 232 |
+
def run(self, goal: str | None = None, *, max_steps: int | None = None) -> Episode:
|
| 233 |
+
"""Run to the end of the episode - or continue one that was paused.
|
| 234 |
+
|
| 235 |
+
Guarantees, by construction rather than by prompting:
|
| 236 |
+
|
| 237 |
+
* every action is one of six validated verbs
|
| 238 |
+
* nothing executes until policy has passed it
|
| 239 |
+
* the episode ends on a step budget, a kill switch, a brain error, or a
|
| 240 |
+
failed screen capture (reported as a step, never as a traceback)
|
| 241 |
+
* the screen is re-read after every action, so the next decision sees the
|
| 242 |
+
consequence of the last one
|
| 243 |
+
"""
|
| 244 |
+
if self.episode is None or self._done or (goal is not None and goal != self.episode.goal):
|
| 245 |
+
self.begin(goal or "", max_steps=max_steps)
|
| 246 |
+
while not self.paused and not self._done:
|
| 247 |
+
if self.step_once() is None:
|
| 248 |
break
|
| 249 |
+
return self.episode
|
| 250 |
|
| 251 |
+
def _append(self, step: Step) -> None:
|
| 252 |
+
self.episode.steps.append(step)
|
| 253 |
+
if self.on_step is not None:
|
| 254 |
+
self.on_step(step)
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
+
def _record_step(self, step: Step, stop_reason: str) -> Step:
|
| 257 |
+
self._append(step)
|
| 258 |
+
self._finish(stop_reason)
|
| 259 |
+
return step
|
| 260 |
|
| 261 |
+
def _finish(self, stop_reason: str) -> None:
|
| 262 |
+
self.episode.stop_reason = stop_reason
|
| 263 |
+
self._done = True
|
| 264 |
|
| 265 |
def _verified(self, observation: Observation) -> bool:
|
| 266 |
"""A broken verifier must never fake a success."""
|
tiny_computer_agent/models.py
CHANGED
|
@@ -20,12 +20,37 @@ import os
|
|
| 20 |
from typing import Any
|
| 21 |
|
| 22 |
from tiny_computer_agent.brain import BrainError, ModelBrain
|
|
|
|
| 23 |
from tiny_computer_agent.observe import Observation
|
| 24 |
|
| 25 |
DEFAULT_TEXT_MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
|
| 26 |
DEFAULT_VISION_MODEL = "HuggingFaceTB/SmolVLM-256M-Instruct"
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
def resolve_device(prefer: str | None = None) -> str:
|
| 30 |
"""Pick a torch device: explicit preference, then CUDA, then CPU."""
|
| 31 |
try:
|
|
@@ -73,6 +98,7 @@ class TextPlannerBrain(ModelBrain):
|
|
| 73 |
|
| 74 |
name = "text-planner"
|
| 75 |
requires_index = True
|
|
|
|
| 76 |
|
| 77 |
def __init__(self, model_id: str = DEFAULT_TEXT_MODEL, *, device: str | None = None,
|
| 78 |
**kwargs: Any) -> None:
|
|
@@ -101,6 +127,53 @@ class TextPlannerBrain(ModelBrain):
|
|
| 101 |
self._model = None
|
| 102 |
self._tokenizer = None
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def _generate(self, prompt: str, observation: Observation) -> str:
|
| 105 |
import torch
|
| 106 |
|
|
@@ -127,6 +200,7 @@ class VLMBrain(ModelBrain):
|
|
| 127 |
|
| 128 |
name = "vlm"
|
| 129 |
requires_index = False
|
|
|
|
| 130 |
|
| 131 |
def __init__(self, model_id: str = DEFAULT_VISION_MODEL, *, device: str | None = None,
|
| 132 |
**kwargs: Any) -> None:
|
|
@@ -155,6 +229,68 @@ class VLMBrain(ModelBrain):
|
|
| 155 |
self._model = None
|
| 156 |
self._processor = None
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
def _generate(self, prompt: str, observation: Observation) -> str:
|
| 159 |
import torch
|
| 160 |
|
|
|
|
| 20 |
from typing import Any
|
| 21 |
|
| 22 |
from tiny_computer_agent.brain import BrainError, ModelBrain
|
| 23 |
+
from tiny_computer_agent.decoding import MenuMask
|
| 24 |
from tiny_computer_agent.observe import Observation
|
| 25 |
|
| 26 |
DEFAULT_TEXT_MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
|
| 27 |
DEFAULT_VISION_MODEL = "HuggingFaceTB/SmolVLM-256M-Instruct"
|
| 28 |
|
| 29 |
|
| 30 |
+
def _pair_margin(logits: Any, yes_ids: Any, no_ids: Any) -> float:
|
| 31 |
+
"""log P(yes) - log P(no) for one position, over the whole vocabulary."""
|
| 32 |
+
import torch
|
| 33 |
+
|
| 34 |
+
yes = torch.logsumexp(logits[list(yes_ids)], dim=0)
|
| 35 |
+
no = torch.logsumexp(logits[list(no_ids)], dim=0)
|
| 36 |
+
return float((yes - no).item())
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _masked_generation(model: Any, allowed_ids: Any, eos_token_id: Any) -> Any:
|
| 40 |
+
"""A transformers ``LogitsProcessorList`` holding the menu mask.
|
| 41 |
+
|
| 42 |
+
Imported here, not at module scope, so the harness still imports with no
|
| 43 |
+
transformers installed at all.
|
| 44 |
+
"""
|
| 45 |
+
try:
|
| 46 |
+
from transformers import LogitsProcessorList
|
| 47 |
+
except ImportError: # pragma: no cover - env
|
| 48 |
+
raise BrainError(
|
| 49 |
+
"constrained decoding needs transformers: pip install 'tiny-computer-agent[vision]'"
|
| 50 |
+
)
|
| 51 |
+
return LogitsProcessorList([MenuMask(allowed_ids, eos_token_id=eos_token_id)])
|
| 52 |
+
|
| 53 |
+
|
| 54 |
def resolve_device(prefer: str | None = None) -> str:
|
| 55 |
"""Pick a torch device: explicit preference, then CUDA, then CPU."""
|
| 56 |
try:
|
|
|
|
| 98 |
|
| 99 |
name = "text-planner"
|
| 100 |
requires_index = True
|
| 101 |
+
supports_constrained = True
|
| 102 |
|
| 103 |
def __init__(self, model_id: str = DEFAULT_TEXT_MODEL, *, device: str | None = None,
|
| 104 |
**kwargs: Any) -> None:
|
|
|
|
| 127 |
self._model = None
|
| 128 |
self._tokenizer = None
|
| 129 |
|
| 130 |
+
def _tokenizer_for_choice(self) -> Any:
|
| 131 |
+
self._load()
|
| 132 |
+
return self._tokenizer
|
| 133 |
+
|
| 134 |
+
def _generate_constrained(self, prompt: str, observation: Observation,
|
| 135 |
+
allowed_ids: Any, max_new_tokens: int,
|
| 136 |
+
*, sample: bool = False) -> str:
|
| 137 |
+
"""One short generation that can only contain a menu number."""
|
| 138 |
+
import torch
|
| 139 |
+
|
| 140 |
+
self._load()
|
| 141 |
+
messages = [{"role": "user", "content": prompt}]
|
| 142 |
+
text = self._tokenizer.apply_chat_template(
|
| 143 |
+
messages, add_generation_prompt=True, tokenize=False
|
| 144 |
+
)
|
| 145 |
+
inputs = self._tokenizer(text, return_tensors="pt").to(self.device)
|
| 146 |
+
settings: dict[str, Any] = {
|
| 147 |
+
"max_new_tokens": max_new_tokens,
|
| 148 |
+
"pad_token_id": self._tokenizer.eos_token_id,
|
| 149 |
+
"logits_processor": _masked_generation(
|
| 150 |
+
self._model, allowed_ids, self._tokenizer.eos_token_id),
|
| 151 |
+
}
|
| 152 |
+
if sample:
|
| 153 |
+
settings.update(do_sample=True, temperature=0.7, top_p=0.9)
|
| 154 |
+
else:
|
| 155 |
+
settings["do_sample"] = False
|
| 156 |
+
with torch.inference_mode():
|
| 157 |
+
output = self._model.generate(**inputs, **settings)
|
| 158 |
+
return self._tokenizer.decode(
|
| 159 |
+
output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
|
| 160 |
+
).strip()
|
| 161 |
+
|
| 162 |
+
def _score_pair(self, prompt: str, observation: Observation,
|
| 163 |
+
yes_ids: Any, no_ids: Any) -> float:
|
| 164 |
+
import torch
|
| 165 |
+
|
| 166 |
+
self._load()
|
| 167 |
+
messages = [{"role": "user", "content": prompt}]
|
| 168 |
+
text = self._tokenizer.apply_chat_template(
|
| 169 |
+
messages, add_generation_prompt=True, tokenize=False)
|
| 170 |
+
inputs = self._tokenizer(text, return_tensors="pt").to(self.device)
|
| 171 |
+
with torch.inference_mode():
|
| 172 |
+
out = self._model.generate(
|
| 173 |
+
**inputs, max_new_tokens=1, do_sample=False, return_dict_in_generate=True,
|
| 174 |
+
output_scores=True, pad_token_id=self._tokenizer.eos_token_id)
|
| 175 |
+
return _pair_margin(out.scores[0][0], yes_ids, no_ids)
|
| 176 |
+
|
| 177 |
def _generate(self, prompt: str, observation: Observation) -> str:
|
| 178 |
import torch
|
| 179 |
|
|
|
|
| 200 |
|
| 201 |
name = "vlm"
|
| 202 |
requires_index = False
|
| 203 |
+
supports_constrained = True
|
| 204 |
|
| 205 |
def __init__(self, model_id: str = DEFAULT_VISION_MODEL, *, device: str | None = None,
|
| 206 |
**kwargs: Any) -> None:
|
|
|
|
| 229 |
self._model = None
|
| 230 |
self._processor = None
|
| 231 |
|
| 232 |
+
def _tokenizer_for_choice(self) -> Any:
|
| 233 |
+
self._load()
|
| 234 |
+
return self._processor.tokenizer
|
| 235 |
+
|
| 236 |
+
def _generate_constrained(self, prompt: str, observation: Observation,
|
| 237 |
+
allowed_ids: Any, max_new_tokens: int,
|
| 238 |
+
*, sample: bool = False) -> str:
|
| 239 |
+
"""A vision generation that can only contain a menu number.
|
| 240 |
+
|
| 241 |
+
This is the fix for the echo: the model is no longer able to reply with
|
| 242 |
+
the prompt, because the prompt's tokens are masked out of the logits.
|
| 243 |
+
"""
|
| 244 |
+
import torch
|
| 245 |
+
|
| 246 |
+
image = observation.frame
|
| 247 |
+
if image is None or isinstance(image, str):
|
| 248 |
+
raise BrainError(
|
| 249 |
+
"the vision backend needs a real screenshot but the observation carries "
|
| 250 |
+
f"{type(image).__name__} - run with --live, or use the text planner"
|
| 251 |
+
)
|
| 252 |
+
self._load()
|
| 253 |
+
messages = [{"role": "user", "content": [{"type": "image"},
|
| 254 |
+
{"type": "text", "text": prompt}]}]
|
| 255 |
+
text = self._processor.apply_chat_template(messages, add_generation_prompt=True)
|
| 256 |
+
inputs = self._processor(text=text, images=[image], return_tensors="pt").to(self.device)
|
| 257 |
+
settings: dict[str, Any] = {
|
| 258 |
+
"max_new_tokens": max_new_tokens,
|
| 259 |
+
"logits_processor": _masked_generation(
|
| 260 |
+
self._model, allowed_ids,
|
| 261 |
+
getattr(self._processor.tokenizer, "eos_token_id", None)),
|
| 262 |
+
}
|
| 263 |
+
if sample:
|
| 264 |
+
settings.update(do_sample=True, temperature=0.7, top_p=0.9)
|
| 265 |
+
else:
|
| 266 |
+
settings["do_sample"] = False
|
| 267 |
+
with torch.inference_mode():
|
| 268 |
+
output = self._model.generate(**inputs, **settings)
|
| 269 |
+
return self._processor.batch_decode(
|
| 270 |
+
output[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True
|
| 271 |
+
)[0].strip()
|
| 272 |
+
|
| 273 |
+
def _score_pair(self, prompt: str, observation: Observation,
|
| 274 |
+
yes_ids: Any, no_ids: Any) -> float:
|
| 275 |
+
"""log P(yes) - log P(no) for one image question, read off the logits."""
|
| 276 |
+
import torch
|
| 277 |
+
|
| 278 |
+
image = observation.frame
|
| 279 |
+
if image is None or isinstance(image, str):
|
| 280 |
+
raise BrainError(
|
| 281 |
+
"the vision backend needs a real screenshot but the observation carries "
|
| 282 |
+
f"{type(image).__name__} - run with --live, or use the text planner"
|
| 283 |
+
)
|
| 284 |
+
self._load()
|
| 285 |
+
messages = [{"role": "user", "content": [{"type": "image"},
|
| 286 |
+
{"type": "text", "text": prompt}]}]
|
| 287 |
+
text = self._processor.apply_chat_template(messages, add_generation_prompt=True)
|
| 288 |
+
inputs = self._processor(text=text, images=[image], return_tensors="pt").to(self.device)
|
| 289 |
+
with torch.inference_mode():
|
| 290 |
+
out = self._model.generate(**inputs, max_new_tokens=1, do_sample=False,
|
| 291 |
+
return_dict_in_generate=True, output_scores=True)
|
| 292 |
+
return _pair_margin(out.scores[0][0], yes_ids, no_ids)
|
| 293 |
+
|
| 294 |
def _generate(self, prompt: str, observation: Observation) -> str:
|
| 295 |
import torch
|
| 296 |
|
tiny_computer_agent/perception.py
CHANGED
|
@@ -118,8 +118,11 @@ class IndexedScreen:
|
|
| 118 |
)
|
| 119 |
return observation
|
| 120 |
mapper = getattr(self.source, "map_to_screen", None)
|
| 121 |
-
|
|
|
|
| 122 |
# Frame pixels -> absolute screen pixels, so a click cannot drift.
|
|
|
|
|
|
|
| 123 |
elements = tuple(mapper(element) for element in elements)
|
| 124 |
text = observation.text
|
| 125 |
if self.fill_text and not text:
|
|
@@ -176,8 +179,18 @@ class AutoDetector:
|
|
| 176 |
name: str = "auto"
|
| 177 |
ocr: Detector = field(default_factory=OcrDetector)
|
| 178 |
grid: Detector = field(default_factory=GridDetector)
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
def detect(self, image: Any) -> tuple[Element, ...]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
try:
|
| 182 |
elements = self.ocr.detect(image)
|
| 183 |
except DetectorUnavailable:
|
|
@@ -201,6 +214,18 @@ def detector_available(name: str = "auto") -> tuple[bool, str]:
|
|
| 201 |
"""(available, detail) for preflight and the ``doctor`` command."""
|
| 202 |
if name == "grid":
|
| 203 |
return True, "numbered grid cells (no dependencies)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
if name == "auto":
|
| 205 |
available, detail = _ocr_available()
|
| 206 |
if available:
|
|
@@ -218,6 +243,15 @@ def make_detector(name: str = "auto", **kwargs: Any) -> Detector:
|
|
| 218 |
raise DetectorUnavailable(detail)
|
| 219 |
if name == "grid":
|
| 220 |
return GridDetector(**kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
if name == "auto":
|
| 222 |
if _ocr_available()[0]:
|
| 223 |
return AutoDetector(**kwargs)
|
|
|
|
| 118 |
)
|
| 119 |
return observation
|
| 120 |
mapper = getattr(self.source, "map_to_screen", None)
|
| 121 |
+
already_absolute = getattr(self.detector, "absolute_coordinates", False)
|
| 122 |
+
if mapper is not None and not already_absolute:
|
| 123 |
# Frame pixels -> absolute screen pixels, so a click cannot drift.
|
| 124 |
+
# A detector that already reports screen coordinates (the accessibility
|
| 125 |
+
# tree) opts out of this, or a downscaled capture would double-map it.
|
| 126 |
elements = tuple(mapper(element) for element in elements)
|
| 127 |
text = observation.text
|
| 128 |
if self.fill_text and not text:
|
|
|
|
| 179 |
name: str = "auto"
|
| 180 |
ocr: Detector = field(default_factory=OcrDetector)
|
| 181 |
grid: Detector = field(default_factory=GridDetector)
|
| 182 |
+
# opt in (the "best" detector does): the accessibility tree is tried first,
|
| 183 |
+
# because it is exact where OCR is a guess - and skipped when it is empty
|
| 184 |
+
accessibility: Detector | None = None
|
| 185 |
|
| 186 |
def detect(self, image: Any) -> tuple[Element, ...]:
|
| 187 |
+
if self.accessibility is not None:
|
| 188 |
+
try:
|
| 189 |
+
elements = self.accessibility.detect(image)
|
| 190 |
+
except DetectorUnavailable:
|
| 191 |
+
elements = ()
|
| 192 |
+
if elements:
|
| 193 |
+
return elements
|
| 194 |
try:
|
| 195 |
elements = self.ocr.detect(image)
|
| 196 |
except DetectorUnavailable:
|
|
|
|
| 214 |
"""(available, detail) for preflight and the ``doctor`` command."""
|
| 215 |
if name == "grid":
|
| 216 |
return True, "numbered grid cells (no dependencies)"
|
| 217 |
+
if name == "atspi":
|
| 218 |
+
from tiny_computer_agent.accessibility import accessibility_available
|
| 219 |
+
|
| 220 |
+
return accessibility_available("atspi")
|
| 221 |
+
if name == "best":
|
| 222 |
+
from tiny_computer_agent.accessibility import accessibility_available
|
| 223 |
+
|
| 224 |
+
reachable, detail = accessibility_available("atspi")
|
| 225 |
+
if reachable:
|
| 226 |
+
return True, (f"accessibility tree when it has content, then OCR, then a "
|
| 227 |
+
f"numbered grid ({detail})")
|
| 228 |
+
return True, (f"OCR, then a numbered grid (no accessibility tree: {detail})")
|
| 229 |
if name == "auto":
|
| 230 |
available, detail = _ocr_available()
|
| 231 |
if available:
|
|
|
|
| 243 |
raise DetectorUnavailable(detail)
|
| 244 |
if name == "grid":
|
| 245 |
return GridDetector(**kwargs)
|
| 246 |
+
if name == "atspi":
|
| 247 |
+
from tiny_computer_agent.accessibility import AtspiDetector
|
| 248 |
+
|
| 249 |
+
return AtspiDetector(**kwargs)
|
| 250 |
+
if name == "best":
|
| 251 |
+
from tiny_computer_agent.accessibility import AtspiDetector, accessibility_available
|
| 252 |
+
|
| 253 |
+
accessibility = AtspiDetector() if accessibility_available("atspi")[0] else None
|
| 254 |
+
return AutoDetector(accessibility=accessibility, **kwargs)
|
| 255 |
if name == "auto":
|
| 256 |
if _ocr_available()[0]:
|
| 257 |
return AutoDetector(**kwargs)
|