hkblockchaintechnology commited on
Commit
b038669
·
verified ·
1 Parent(s): ef447f4

Publish tiny_computer_agent/ from marimo

Browse files
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", "Action", "ActionError",
119
- "ActionResult", "AgentLoop", "AutoDetector", "BRAIN_NAMES",
120
- "Brain", "BrainError", "Check", "DEFAULT_KILL_SWITCH",
121
- "DEFAULT_TEXT_MODEL", "DEFAULT_VISION_MODEL", "DecisionMenu", "Detector",
122
- "DetectorUnavailable", "DryRunExecutor", "Element", "Episode",
123
- "Executor", "FakeScreen", "GoalUnreachable", "GrabScreen",
124
- "GridDetector", "ImageScreen", "IndexedScreen", "InputUnavailable",
 
125
  "MODEL_BRAINS", "MenuItem", "MockBrain", "ModelBrain",
126
  "Observation", "OcrDetector", "PlannerBrain", "Policy",
127
  "PolicyViolation", "PyAutoGuiExecutor", "Scenario", "Scoreboard",
128
- "ScreenSource", "ScreenUnavailable", "Step", "TextPlannerBrain",
129
- "Trace", "TraceStep", "VLMBrain", "Verdict",
130
- "__version__", "benchmark", "benchmark_traces", "build_agent",
131
- "detector_available", "display_available", "doctor", "extract_json_object",
132
- "extract_json_objects", "fake_desktop", "load_traces", "make_detector",
133
- "make_model_brain", "model_cached", "model_dependencies", "normalise_label",
134
- "parse_action", "parse_decision", "pick_dtype", "resolve_device",
135
- "resolve_point", "run_checks", "run_scenario", "score",
136
- "score_replay", "score_trace", "write_fake_desktop",
 
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", choices=("auto", "ocr", "grid"),
72
- help="how to build the element index (auto = OCR, grid fallback)")
 
 
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) -> None:
 
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) -> str:
 
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.last_reply = self._generate(self.last_prompt, observation)
424
- action = parse_decision(self.last_reply, observation,
425
- max_menu_items=self.max_menu_items)
 
 
 
 
 
 
 
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
- def run(self, goal: str, *, max_steps: int | None = None) -> Episode:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  budget = max_steps or self.policy.step_budget
103
- episode = Episode(goal=goal, stop_reason=f"step budget of {budget} exhausted")
104
- # A small model that repeats an action against an unchanged screen is stuck,
105
- # not persistent. Tolerate `max_repeats` such repeats, then stop - burning the
106
- # whole budget on the same click teaches the user nothing.
107
- previous: tuple[tuple, str] | None = None
108
- repeats = 0
 
109
  try:
110
- observation = self.screen.capture()
111
  except Exception as exc:
112
- episode.stop_reason = f"capture failed: {type(exc).__name__}: {exc}"
113
- return episode
 
114
 
115
- for index in range(budget):
116
- if self.policy.should_stop():
117
- episode.stop_reason = "kill switch engaged"
118
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
- try:
121
- action = self.brain.decide(goal, observation)
122
- except GoalUnreachable as exc:
123
- self._record(episode, Step(index, observation, error=f"gave up: {exc}"))
124
- episode.stop_reason = f"brain gave up: {exc}"
125
- break
126
- except Exception as exc:
127
- self._record(episode, Step(index, observation,
128
- error=f"brain failed: {type(exc).__name__}: {exc}"))
129
- episode.stop_reason = "brain error"
130
- break
131
 
132
- if action.kind == "done":
133
- self._record(episode, Step(
134
- index, observation, action=action,
135
- result=ActionResult(action=action, ok=True, note="goal reported met"),
136
- ))
137
- episode.success = True
138
- episode.stop_reason = f"brain reported done: {action.reason or 'no reason given'}"
139
- break
140
 
141
- try:
142
- self.policy.check(action, observation)
143
- except PolicyViolation as exc:
144
- self._record(episode, Step(index, observation, action=action,
145
- error=f"refused: {exc}"))
146
- episode.stop_reason = f"policy refusal: {exc}"
147
- break
 
 
 
 
 
 
 
 
 
 
148
 
149
- signature = (self._fingerprint(observation), action.describe())
150
- repeats = repeats + 1 if signature == previous else 0
151
- previous = signature
152
- if repeats > self.max_repeats:
153
- self._record(episode, Step(
154
- index, observation, action=action,
155
- error=f"no progress: repeating {action.describe()} "
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
- try:
165
- result = self.executor.execute(action, observation)
166
- except Exception as exc:
167
- self._record(episode, Step(index, observation, action=action,
168
- error=f"executor failed: {type(exc).__name__}: {exc}"))
169
- episode.stop_reason = "executor error"
170
- break
171
 
172
- self._record(episode, Step(index, observation, action=action, result=result))
173
- if not result.ok:
174
- episode.stop_reason = f"action failed: {result.note}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  break
 
176
 
177
- try:
178
- observation = self.screen.capture() # verify: what did that change?
179
- except Exception as exc:
180
- self._record(episode, Step(index + 1, observation,
181
- error=f"capture failed: {type(exc).__name__}: {exc}"))
182
- episode.stop_reason = "perception failed mid-episode"
183
- break
184
 
185
- if self.verify is not None and self._verified(observation):
186
- episode.success = True
187
- episode.stop_reason = f"verified after {index + 1} action(s)"
188
- break
189
 
190
- return episode
 
 
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
- if mapper is not None:
 
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)