hkblockchaintechnology commited on
Commit
59022ca
·
verified ·
1 Parent(s): ba80edb

Publish tiny_computer_agent/ from marimo

Browse files
tiny_computer_agent/__init__.py CHANGED
@@ -1,17 +1,17 @@
1
  """Tiny Computer Agent - an agentic harness that drives a real computer with a
2
  tiny, fully local model.
3
 
4
- The harness is deliberately boring: it owns the loop, the closed action
5
- vocabulary and the safety gates, so the model only ever answers one question -
6
- "which action, from this menu, comes next?" Nothing here needs a network call
7
- at run time, and the core is standard-library only.
8
 
9
- from tiny_computer_agent import (
10
- AgentLoop, DryRunExecutor, Element, FakeScreen, MockBrain, Policy,
11
- )
12
 
13
- screen = FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
14
- loop = AgentLoop(screen=screen, brain=MockBrain([{"kind": "click", "label": "Save"}]))
 
15
  episode = loop.run("save the document")
16
  print(episode.render())
17
  """
@@ -34,13 +34,28 @@ from tiny_computer_agent.actions import (
34
  from tiny_computer_agent.brain import (
35
  Brain,
36
  BrainError,
 
37
  GoalUnreachable,
 
38
  MockBrain,
 
39
  PlannerBrain,
40
- VLMBrain,
41
  extract_json_object,
 
 
42
  )
43
  from tiny_computer_agent.loop import AgentLoop, Episode, Step
 
 
 
 
 
 
 
 
 
 
 
44
  from tiny_computer_agent.observe import (
45
  Element,
46
  FakeScreen,
@@ -48,16 +63,30 @@ from tiny_computer_agent.observe import (
48
  Observation,
49
  ScreenSource,
50
  ScreenUnavailable,
 
 
 
 
 
 
 
 
 
51
  )
52
  from tiny_computer_agent.safety import Policy, PolicyViolation
53
 
54
- __version__ = "0.1.0"
55
 
56
  __all__ = [
57
  "ACTION_KINDS", "ACTION_MENU", "Action", "ActionError", "ActionResult",
58
- "AgentLoop", "Brain", "BrainError", "DryRunExecutor", "Element", "Episode",
59
- "Executor", "FakeScreen", "GoalUnreachable", "GrabScreen", "InputUnavailable", "MockBrain",
60
- "Observation", "PlannerBrain", "Policy", "PolicyViolation", "PyAutoGuiExecutor",
61
- "ScreenSource", "ScreenUnavailable", "Step", "VLMBrain", "extract_json_object",
62
- "parse_action", "resolve_point", "__version__",
 
 
 
 
 
63
  ]
 
1
  """Tiny Computer Agent - an agentic harness that drives a real computer with a
2
  tiny, fully local model.
3
 
4
+ The harness is deliberately boring: it owns the loop, the closed action vocabulary,
5
+ perception and the safety gates, so the model only ever answers one question -
6
+ "which item from this short menu, and which verb?" Nothing here needs a network
7
+ call at run time, and the core imports nothing outside the standard library.
8
 
9
+ from tiny_computer_agent import AgentLoop, GrabScreen, IndexedScreen, OcrDetector
10
+ from tiny_computer_agent.models import TextPlannerBrain
 
11
 
12
+ screen = IndexedScreen(GrabScreen(), OcrDetector()) # pixels -> labels
13
+ brain = TextPlannerBrain() # 0.5B, CPU-friendly
14
+ loop = AgentLoop(screen=screen, brain=brain) # dry run by default
15
  episode = loop.run("save the document")
16
  print(episode.render())
17
  """
 
34
  from tiny_computer_agent.brain import (
35
  Brain,
36
  BrainError,
37
+ DecisionMenu,
38
  GoalUnreachable,
39
+ MenuItem,
40
  MockBrain,
41
+ ModelBrain,
42
  PlannerBrain,
 
43
  extract_json_object,
44
+ extract_json_objects,
45
+ parse_decision,
46
  )
47
  from tiny_computer_agent.loop import AgentLoop, Episode, Step
48
+ from tiny_computer_agent.models import (
49
+ DEFAULT_TEXT_MODEL,
50
+ DEFAULT_VISION_MODEL,
51
+ MODEL_BRAINS,
52
+ TextPlannerBrain,
53
+ VLMBrain,
54
+ make_model_brain,
55
+ model_cached,
56
+ model_dependencies,
57
+ resolve_device,
58
+ )
59
  from tiny_computer_agent.observe import (
60
  Element,
61
  FakeScreen,
 
63
  Observation,
64
  ScreenSource,
65
  ScreenUnavailable,
66
+ normalise_label,
67
+ )
68
+ from tiny_computer_agent.perception import (
69
+ Detector,
70
+ DetectorUnavailable,
71
+ IndexedScreen,
72
+ OcrDetector,
73
+ detector_available,
74
+ make_detector,
75
  )
76
  from tiny_computer_agent.safety import Policy, PolicyViolation
77
 
78
+ __version__ = "0.2.0"
79
 
80
  __all__ = [
81
  "ACTION_KINDS", "ACTION_MENU", "Action", "ActionError", "ActionResult",
82
+ "AgentLoop", "Brain", "BrainError", "DEFAULT_TEXT_MODEL", "DEFAULT_VISION_MODEL",
83
+ "DecisionMenu", "Detector", "DetectorUnavailable", "DryRunExecutor", "Element",
84
+ "Episode", "Executor", "FakeScreen", "GoalUnreachable", "GrabScreen",
85
+ "IndexedScreen", "InputUnavailable", "MODEL_BRAINS", "MenuItem", "MockBrain",
86
+ "ModelBrain", "Observation", "OcrDetector", "PlannerBrain", "Policy",
87
+ "PolicyViolation", "PyAutoGuiExecutor", "ScreenSource", "ScreenUnavailable",
88
+ "Step", "TextPlannerBrain", "VLMBrain", "detector_available",
89
+ "extract_json_object", "extract_json_objects", "make_detector", "make_model_brain",
90
+ "model_cached", "model_dependencies", "normalise_label", "parse_action",
91
+ "parse_decision", "resolve_device", "resolve_point", "__version__",
92
  ]
tiny_computer_agent/__main__.py CHANGED
@@ -1,13 +1,15 @@
1
  """Command line entry point.
2
 
3
- python -m tiny_computer_agent # scripted demo, dry-run
4
- python -m tiny_computer_agent --live # your real screen, dry-run
5
- python -m tiny_computer_agent "click 'Save'" # planner brain
6
- python -m tiny_computer_agent --script '[{"kind": "done"}]'
7
-
8
- Real mouse and keyboard input additionally requires ``--real-input`` *and*
9
- ``TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1`` in the environment. That is deliberate:
10
- dry-run is the default so you can read a whole episode first.
 
 
11
  """
12
 
13
  from __future__ import annotations
@@ -15,16 +17,24 @@ from __future__ import annotations
15
  import argparse
16
  import json
17
  import os
 
18
  import sys
19
 
20
  from tiny_computer_agent.act import DryRunExecutor, PyAutoGuiExecutor
21
  from tiny_computer_agent.brain import MockBrain, PlannerBrain
 
 
 
 
22
  from tiny_computer_agent.loop import AgentLoop, Step
23
- from tiny_computer_agent.observe import Element, FakeScreen, GrabScreen
 
 
 
24
  from tiny_computer_agent.safety import Policy
25
 
26
  ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
27
- DEFAULT_KILL_SWITCH = "/tmp/tiny-computer-agent.STOP"
28
 
29
 
30
  def demo_screen() -> FakeScreen:
@@ -50,24 +60,114 @@ def build_parser() -> argparse.ArgumentParser:
50
  parser.add_argument("--max-steps", type=int, default=6)
51
  parser.add_argument("--script", default=None,
52
  help="JSON list of actions to replay instead of using a brain")
 
 
 
 
 
 
 
 
 
53
  parser.add_argument("--live", action="store_true",
54
  help="capture your real screen instead of the scripted demo")
55
  parser.add_argument("--real-input", action="store_true",
56
  help="actually move the mouse and type (requires the allow env var)")
 
 
 
 
 
 
 
 
 
57
  parser.add_argument("--kill-switch", default=DEFAULT_KILL_SWITCH,
58
  help="create this file to abort the run before the next action")
59
  return parser
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def main(argv: list[str] | None = None) -> int:
63
  args = build_parser().parse_args(argv)
 
 
 
 
 
 
 
 
64
 
65
  if args.real_input and os.environ.get(ALLOW_REAL_INPUT_ENV) != "1":
66
  print(f"refusing --real-input: set {ALLOW_REAL_INPUT_ENV}=1 to confirm you "
67
  f"understand this will drive your actual mouse and keyboard.", file=sys.stderr)
68
  return 2
69
 
70
- screen = GrabScreen() if args.live else demo_screen()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  if args.script:
73
  try:
@@ -76,26 +176,64 @@ def main(argv: list[str] | None = None) -> int:
76
  print(f"--script is not valid JSON: {exc}", file=sys.stderr)
77
  return 2
78
  brain = MockBrain(script)
 
 
79
  else:
80
- brain = PlannerBrain(done_when_text_contains=None)
 
 
 
 
 
 
 
81
 
82
  policy = Policy(
83
  dry_run=not args.real_input,
84
  step_budget=args.max_steps,
85
  kill_switch_path=args.kill_switch,
 
86
  )
87
  executor = PyAutoGuiExecutor() if args.real_input else DryRunExecutor()
88
 
89
  mode = "REAL INPUT (mouse and keyboard live)" if args.real_input else "dry-run"
90
- print(f"goal: {args.goal}\nmode: {mode} | screen: "
91
- f"{'real desktop' if args.live else 'scripted demo'} | "
92
- f"kill switch: create {args.kill_switch} to stop\n")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  def on_step(step: Step) -> None:
 
 
 
 
 
 
 
 
95
  print(" " + step.line())
96
 
97
- episode = AgentLoop(screen=screen, brain=brain, executor=executor,
98
- policy=policy, on_step=on_step).run(args.goal)
 
 
 
 
 
 
99
  print()
100
  print(episode.render())
101
  return 0 if episode.success else 1
 
1
  """Command line entry point.
2
 
3
+ python -m tiny_computer_agent # scripted demo, dry-run
4
+ python -m tiny_computer_agent --doctor # what can this machine do?
5
+ python -m tiny_computer_agent --image example # replay a screenshot, dry-run
6
+ python -m tiny_computer_agent --image shot.png # replay your own screenshot
7
+ python -m tiny_computer_agent --live # your real screen, dry-run
8
+ python -m tiny_computer_agent --live --real-input "click 'OK'"
9
+
10
+ Real mouse and keyboard input additionally requires
11
+ ``TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1``. Dry run is the default, so you can read a
12
+ whole episode before anything touches your pointer.
13
  """
14
 
15
  from __future__ import annotations
 
17
  import argparse
18
  import json
19
  import os
20
+ import platform
21
  import sys
22
 
23
  from tiny_computer_agent.act import DryRunExecutor, PyAutoGuiExecutor
24
  from tiny_computer_agent.brain import MockBrain, PlannerBrain
25
+ from tiny_computer_agent.demo import fake_desktop
26
+ from tiny_computer_agent.desktop import (
27
+ BRAIN_NAMES, DEFAULT_KILL_SWITCH, Check, display_available, run_checks,
28
+ )
29
  from tiny_computer_agent.loop import AgentLoop, Step
30
+ from tiny_computer_agent.observe import (
31
+ Element, FakeScreen, GrabScreen, ImageScreen, ScreenUnavailable,
32
+ )
33
+ from tiny_computer_agent.perception import IndexedScreen, detector_available, make_detector
34
  from tiny_computer_agent.safety import Policy
35
 
36
  ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
37
+ EXAMPLE_IMAGE = "example"
38
 
39
 
40
  def demo_screen() -> FakeScreen:
 
60
  parser.add_argument("--max-steps", type=int, default=6)
61
  parser.add_argument("--script", default=None,
62
  help="JSON list of actions to replay instead of using a brain")
63
+ parser.add_argument("--doctor", action="store_true",
64
+ help="report what this machine can and cannot do, then exit")
65
+ parser.add_argument("--image", default=None,
66
+ help="replay a screenshot instead of the screen; "
67
+ f"'{EXAMPLE_IMAGE}' uses the built-in one")
68
+ parser.add_argument("--brain", default="planner", choices=BRAIN_NAMES,
69
+ help="planner (rule-based, no weights) | text-planner | vlm")
70
+ parser.add_argument("--detector", default="auto", choices=("auto", "ocr", "grid"),
71
+ help="how to build the element index (auto = OCR, grid fallback)")
72
  parser.add_argument("--live", action="store_true",
73
  help="capture your real screen instead of the scripted demo")
74
  parser.add_argument("--real-input", action="store_true",
75
  help="actually move the mouse and type (requires the allow env var)")
76
+ parser.add_argument("--monitor", type=int, default=1, help="1 is the primary monitor")
77
+ parser.add_argument("--scale", type=float, default=None,
78
+ help="screen pixels per captured pixel (HiDPI, secondary monitors)")
79
+ parser.add_argument("--no-ocr", action="store_true",
80
+ help="skip OCR and index a numbered grid instead")
81
+ parser.add_argument("--max-width", type=int, default=1600,
82
+ help="downscale captures wider than this before indexing")
83
+ parser.add_argument("--allow-grid", action="store_true",
84
+ help="permit clicks on numbered grid cells with --real-input")
85
  parser.add_argument("--kill-switch", default=DEFAULT_KILL_SWITCH,
86
  help="create this file to abort the run before the next action")
87
  return parser
88
 
89
 
90
+ def report(checks: list[Check]) -> int:
91
+ """Print a preflight report; non-zero when something required is missing."""
92
+ blocking = [c for c in checks if c.required and not c.ok]
93
+ lines = [
94
+ f"tiny-computer-agent preflight ({platform.system()} {platform.machine()}, "
95
+ f"python {platform.python_version()})",
96
+ "",
97
+ *(c.render() for c in checks),
98
+ "",
99
+ ]
100
+ if blocking:
101
+ lines.append(f"NOT READY: {len(blocking)} blocking problem(s): "
102
+ + ", ".join(c.name for c in blocking))
103
+ else:
104
+ lines.append("READY: the harness can drive this desktop.")
105
+ print("\n".join(lines))
106
+ return 1 if blocking else 0
107
+
108
+
109
+ def _probe_live(args: argparse.Namespace) -> Check:
110
+ """Actually grab one frame, because only a real capture proves it works."""
111
+ try:
112
+ observation = GrabScreen(
113
+ monitor=args.monitor, max_width=args.max_width, scale=args.scale
114
+ ).capture()
115
+ frame = observation.frame
116
+ return Check("live capture", True, f"captured {frame.width}x{frame.height} pixels")
117
+ except ScreenUnavailable as exc:
118
+ return Check("live capture", False, str(exc),
119
+ fix="run this on the desktop session itself, not a headless shell")
120
+
121
+
122
+ def _build_screen(args: argparse.Namespace):
123
+ """(screen, description). Raises ScreenUnavailable / OSError on failure."""
124
+ detector_name = "grid" if args.no_ocr else args.detector
125
+ if args.image is not None:
126
+ source = ImageScreen(fake_desktop() if args.image == EXAMPLE_IMAGE else args.image)
127
+ return IndexedScreen(source, make_detector(detector_name)), f"screenshot replay: {args.image}"
128
+ if args.live:
129
+ source = GrabScreen(monitor=args.monitor, max_width=args.max_width, scale=args.scale)
130
+ return IndexedScreen(source, make_detector(detector_name)), f"real desktop (monitor {args.monitor})"
131
+ return demo_screen(), "scripted demo"
132
+
133
+
134
  def main(argv: list[str] | None = None) -> int:
135
  args = build_parser().parse_args(argv)
136
+ detector_name = "grid" if args.no_ocr else args.detector
137
+
138
+ if args.doctor:
139
+ checks = run_checks(brain=args.brain, detector=detector_name,
140
+ real_input=args.real_input, kill_switch_path=args.kill_switch)
141
+ if args.live:
142
+ checks.append(_probe_live(args))
143
+ return report(checks)
144
 
145
  if args.real_input and os.environ.get(ALLOW_REAL_INPUT_ENV) != "1":
146
  print(f"refusing --real-input: set {ALLOW_REAL_INPUT_ENV}=1 to confirm you "
147
  f"understand this will drive your actual mouse and keyboard.", file=sys.stderr)
148
  return 2
149
 
150
+ if args.brain == "vlm" and not (args.live or args.image):
151
+ print("the vlm brain needs --live (or --image) so it has pixels to look at; "
152
+ "use --brain planner to exercise the harness offline.", file=sys.stderr)
153
+ return 2
154
+
155
+ if args.live:
156
+ available, detail = display_available()
157
+ if not available:
158
+ print(f"live capture needs a display session: {detail}")
159
+ print(f"refusing to run live: {detail}. Try --image to replay a screenshot, "
160
+ f"or --doctor to diagnose.", file=sys.stderr)
161
+ return 2
162
+
163
+ try:
164
+ screen, description = _build_screen(args)
165
+ except ScreenUnavailable as exc:
166
+ print(f"cannot see the screen: {exc}", file=sys.stderr)
167
+ return 3
168
+ except Exception as exc:
169
+ print(f"could not start the screen: {type(exc).__name__}: {exc}", file=sys.stderr)
170
+ return 2
171
 
172
  if args.script:
173
  try:
 
176
  print(f"--script is not valid JSON: {exc}", file=sys.stderr)
177
  return 2
178
  brain = MockBrain(script)
179
+ elif args.brain == "planner":
180
+ brain = PlannerBrain()
181
  else:
182
+ from tiny_computer_agent.models import make_model_brain
183
+
184
+ try:
185
+ brain = make_model_brain(args.brain)
186
+ except Exception as exc:
187
+ print(f"could not load the {args.brain} brain: {type(exc).__name__}: {exc}",
188
+ file=sys.stderr)
189
+ return 2
190
 
191
  policy = Policy(
192
  dry_run=not args.real_input,
193
  step_budget=args.max_steps,
194
  kill_switch_path=args.kill_switch,
195
+ allow_grid_clicks=args.allow_grid,
196
  )
197
  executor = PyAutoGuiExecutor() if args.real_input else DryRunExecutor()
198
 
199
  mode = "REAL INPUT (mouse and keyboard live)" if args.real_input else "dry-run"
200
+ print(f"goal: {args.goal}")
201
+ print(f"mode: {mode} | screen: {description} | brain: {args.brain} | "
202
+ f"kill switch: create {args.kill_switch} to stop")
203
+ if args.image is not None or args.live:
204
+ _, index_detail = detector_available(detector_name)
205
+ print(f"index: {detector_name} - {index_detail}")
206
+ if args.image is not None:
207
+ # One read, so the header can state how big the action space is.
208
+ try:
209
+ preview = screen.capture()
210
+ except ScreenUnavailable as exc:
211
+ print(f"cannot see the screen: {exc}", file=sys.stderr)
212
+ return 3
213
+ print(f"elements detected: {len(preview.elements)}")
214
+ print()
215
+
216
+ warned = False
217
 
218
  def on_step(step: Step) -> None:
219
+ nonlocal warned
220
+ elements = step.observation.elements
221
+ if not warned and elements and all(e.kind == "grid" for e in elements):
222
+ warned = True
223
+ _, index_detail = detector_available(detector_name)
224
+ print(f"warning: no text was found on screen, so the index is "
225
+ f"{len(elements)} numbered grid cells and the agent is choosing "
226
+ f"blind ({index_detail})", file=sys.stderr)
227
  print(" " + step.line())
228
 
229
+ try:
230
+ episode = AgentLoop(screen=screen, brain=brain, executor=executor,
231
+ policy=policy, on_step=on_step).run(args.goal)
232
+ except ScreenUnavailable as exc:
233
+ print(f"cannot see the screen: {exc}", file=sys.stderr)
234
+ print("run `python -m tiny_computer_agent --doctor --live` for details.", file=sys.stderr)
235
+ return 3
236
+
237
  print()
238
  print(episode.render())
239
  return 0 if episode.success else 1
tiny_computer_agent/brain.py CHANGED
@@ -1,23 +1,29 @@
1
- """Decision making.
2
 
3
- Three brains ship here, and only one of them is a model:
 
 
 
 
 
 
 
4
 
5
  * :class:`MockBrain` - replays a scripted action list (tests, demos, replays)
6
- * :class:`PlannerBrain` - rule-based: clicks the element whose label matches the
7
- goal. No model, no download, yet it solves real "click the thing labelled X"
8
- tasks, because the *element index* does the grounding a VLM would otherwise do
9
- * :class:`VLMBrain` - the seam for a real tiny VLM. Prompt building and reply
10
- validation are done; you implement ``_generate()``
11
  """
12
 
13
  from __future__ import annotations
14
 
15
  import json
16
  import re
17
- from typing import Any, Mapping, Protocol, Sequence
 
18
 
19
- from tiny_computer_agent.actions import ACTION_MENU, Action, ActionError, parse_action
20
- from tiny_computer_agent.observe import Observation
21
 
22
 
23
  class BrainError(RuntimeError):
@@ -33,19 +39,217 @@ class GoalUnreachable(BrainError):
33
  """
34
 
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  def extract_json_object(raw: str) -> dict[str, Any]:
37
- """Pull the first JSON object out of a reply, ignoring any surrounding prose."""
38
- start = raw.find("{")
39
- end = raw.rfind("}")
40
- if start == -1 or end <= start:
41
- raise BrainError(f"no JSON object in the model reply: {raw[:200]!r}")
42
- try:
43
- payload = json.loads(raw[start : end + 1])
44
- except json.JSONDecodeError as exc:
45
- raise BrainError(f"malformed JSON in the model reply: {exc}") from exc
46
- if not isinstance(payload, dict):
47
- raise BrainError(f"expected a JSON object, got {type(payload).__name__}")
48
- return payload
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  class Brain(Protocol):
@@ -82,11 +286,11 @@ class MockBrain:
82
  class PlannerBrain:
83
  """Rule-based baseline: click the element matching the goal, then stop.
84
 
85
- Its purpose is twofold - it proves the harness works end to end with no model
86
- at all, and it shows how much of a "computer use" task is really an indexing
87
- problem. It waits when the target is not on screen yet, refuses to click the
88
- same label twice, and gives up rather than flailing. It will not generalise
89
- the way a VLM can.
90
  """
91
 
92
  name = "planner"
@@ -124,7 +328,6 @@ class PlannerBrain:
124
  reason=f"matched {target!r} in the element index")
125
  raise GoalUnreachable(f"no element name found in the goal {goal!r}")
126
 
127
-
128
  @staticmethod
129
  def _target_from(goal: str) -> str | None:
130
  """Best effort: quoted text wins, else the last capitalised word."""
@@ -135,40 +338,97 @@ class PlannerBrain:
135
  return words[-1].strip(".,!?") if words else None
136
 
137
 
138
- class VLMBrain:
139
- """Seam for a real (small) vision-language model.
140
 
141
- ``build_prompt`` and reply validation are implemented; subclass this and
142
- implement ``_generate()`` to return the raw model text. Swap the model by
143
- changing ``model_id`` - nothing else in the harness cares which weights you use.
144
  """
145
 
146
- name = "vlm"
 
147
 
148
- def __init__(self, model_id: str = "vikhyatk/moondream2", *,
149
- max_new_tokens: int = 128) -> None:
150
  self.model_id = model_id
 
151
  self.max_new_tokens = max_new_tokens
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
 
153
- def build_prompt(self, goal: str, observation: Observation) -> str:
154
- return (
155
- "You are controlling a computer. Choose the single next action.\n\n"
156
- f"Goal: {goal}\n\n"
157
- f"{observation.summary()}\n\n"
158
- f"{ACTION_MENU}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  )
 
 
 
 
 
 
 
160
 
161
  def _generate(self, prompt: str, observation: Observation) -> str:
162
  raise NotImplementedError(
163
- "No vision backend is wired up yet. Subclass VLMBrain and implement "
164
- "_generate(prompt, observation) to return raw model text, or use "
165
- "MockBrain / PlannerBrain while the harness is being tested."
166
  )
167
-
168
- def decide(self, goal: str, observation: Observation) -> Action:
169
- raw = self._generate(self.build_prompt(goal, observation), observation)
170
- payload = extract_json_object(raw)
171
- try:
172
- return parse_action(payload)
173
- except ActionError as exc:
174
- raise BrainError(f"model proposed an invalid action: {exc}") from exc
 
1
+ """Decision making: menu-mediated, model-agnostic.
2
 
3
+ The design lesson from testing tiny models on real screenshots: a 256M VLM can
4
+ *read* a button label from pixels but cannot reliably reason, and a 0.5B text model
5
+ writes perfect JSON but picks the wrong widget when handed noisy free-form text.
6
+ Both do much better when the harness hands them a **short numbered menu** and asks
7
+ for a number. That is what this module builds, and it keeps the model's output space
8
+ finite no matter how small the model is.
9
+
10
+ Brains shipped here:
11
 
12
  * :class:`MockBrain` - replays a scripted action list (tests, demos, replays)
13
+ * :class:`PlannerBrain` - rule-based, no model: clicks the element matching the goal
14
+ * :class:`ModelBrain` - base for real models: menu, prompt, lenient parsing
15
+ (see :mod:`tiny_computer_agent.models` for the text and vision backends)
 
 
16
  """
17
 
18
  from __future__ import annotations
19
 
20
  import json
21
  import re
22
+ 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
 
29
  class BrainError(RuntimeError):
 
39
  """
40
 
41
 
42
+ def extract_json_objects(raw: str) -> list[dict[str, Any]]:
43
+ """Every flat JSON object in a reply, in the order they appear.
44
+
45
+ Regex-based and non-nesting: replies from tiny models are one-level objects.
46
+ Callers should prefer the **last** object, because a model that echoes its own
47
+ prompt before answering puts the prompt's example first and its answer last.
48
+ """
49
+ objects: list[dict[str, Any]] = []
50
+ for match in re.finditer(r"\{[^{}]*\}", raw or "", re.DOTALL):
51
+ try:
52
+ payload = json.loads(match.group(0))
53
+ except json.JSONDecodeError:
54
+ continue
55
+ if isinstance(payload, dict):
56
+ objects.append(payload)
57
+ return objects
58
+
59
+
60
  def extract_json_object(raw: str) -> dict[str, Any]:
61
+ """The decision out of a reply: the *last* JSON object, not the first."""
62
+ objects = extract_json_objects(raw)
63
+ if not objects:
64
+ raise BrainError(f"no JSON object in the model reply: {(raw or '')[:200]!r}")
65
+ return objects[-1]
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class MenuItem:
70
+ """One numbered choice shown to the model."""
71
+
72
+ index: int
73
+ element: Element
74
+
75
+ @property
76
+ def label(self) -> str:
77
+ return self.element.label
78
+
79
+
80
+ class DecisionMenu:
81
+ """A short, numbered, closed choice set built from the element index.
82
+
83
+ Elements are presented in reading order (top to bottom, then left to right),
84
+ and capped: a 20-item menu is as good as free-form text to a small model.
85
+ """
86
+
87
+ def __init__(self, observation: Observation, *, max_items: int = 12) -> None:
88
+ ordered = sorted(observation.elements, key=lambda e: (e.y, e.x))[:max_items]
89
+ self.items: tuple[MenuItem, ...] = tuple(
90
+ MenuItem(index=i + 1, element=e) for i, e in enumerate(ordered)
91
+ )
92
+
93
+ def __len__(self) -> int:
94
+ return len(self.items)
95
+
96
+ def __bool__(self) -> bool:
97
+ return bool(self.items)
98
+
99
+ def __iter__(self) -> Iterator[MenuItem]:
100
+ return iter(self.items)
101
+
102
+ def item(self, value: Any) -> MenuItem | None:
103
+ """Look up a 1-based index, tolerating the strings models actually send."""
104
+ try:
105
+ number = int(float(str(value).strip()))
106
+ except (TypeError, ValueError):
107
+ return None
108
+ if 1 <= number <= len(self.items):
109
+ return self.items[number - 1]
110
+ return None
111
+
112
+ def find(self, label: str) -> Element | None:
113
+ """Resolve a label against the menu (normalised, like Observation.find)."""
114
+ wanted = normalise_label(label)
115
+ if not wanted:
116
+ return None
117
+ for item in self.items:
118
+ if normalise_label(item.label) == wanted:
119
+ return item.element
120
+ for item in self.items:
121
+ if wanted in normalise_label(item.label):
122
+ return item.element
123
+ return None
124
+
125
+ def mentioned_in(self, text: str) -> tuple[MenuItem, ...]:
126
+ """Menu entries whose label appears in the text, in order of first mention."""
127
+ haystack = normalise_label(text)
128
+ found: list[tuple[int, MenuItem]] = []
129
+ for item in self.items:
130
+ needle = normalise_label(item.label)
131
+ if needle and (position := haystack.find(needle)) != -1:
132
+ found.append((position, item))
133
+ return tuple(item for _, item in sorted(found, key=lambda pair: pair[0]))
134
+
135
+ def render(self) -> str:
136
+ return "\n".join(
137
+ f" {item.index}. {item.label} (at {item.element.center[0]},{item.element.center[1]})"
138
+ for item in self.items
139
+ )
140
+
141
+
142
+ def _recover_from_text(payload: Mapping[str, Any], menu: DecisionMenu) -> Action | None:
143
+ """Salvage a decision from a payload that named the right thing in the wrong field.
144
+
145
+ Observed for real: a 0.5B planner replied ``{"kind": "click", "index": 0,
146
+ "reason": "Save"}`` - the correct answer, in the wrong place. If the index is
147
+ unusable but exactly one menu label is mentioned anywhere in the payload, take
148
+ the label. It is still only ever a label the detector saw in this frame, so the
149
+ deny list, the dry run and the step budget all continue to apply.
150
+ """
151
+ text = json.dumps(payload, ensure_ascii=False)
152
+ mentioned = menu.mentioned_in(text)
153
+ if len({item.label for item in mentioned}) == 1:
154
+ item = mentioned[0]
155
+ return Action(
156
+ "click", label=item.label,
157
+ reason=f"recovered {item.label!r} from the model's reply: {text[:80]}",
158
+ )
159
+ return None
160
+
161
+
162
+ def _action_from_payload(payload: Mapping[str, Any], menu: DecisionMenu) -> Action | None:
163
+ """Interpret one JSON object, tolerating the ways small models mangle the shape."""
164
+ kind = payload.get("kind")
165
+ if isinstance(kind, str) and kind.strip().lower() not in ACTION_KINDS:
166
+ kind = None # e.g. {"kind": "file"} - a garbled click; fall back to index/label
167
+
168
+ if payload.get("index") is not None:
169
+ item = menu.item(payload["index"])
170
+ if item is not None:
171
+ return Action("click", label=item.label, reason=f"menu index {payload['index']}")
172
+ recovered = _recover_from_text(payload, menu)
173
+ if recovered is not None:
174
+ return recovered
175
+ raise BrainError(
176
+ f"the model chose index {payload['index']!r}, which is not in the "
177
+ f"menu of {len(menu)} items"
178
+ )
179
+
180
+ if kind in (None, "click") and "label" in payload:
181
+ element = menu.find(str(payload["label"]))
182
+ if element is None:
183
+ raise BrainError(f"the model named {payload['label']!r}, which is not on screen")
184
+ return Action("click", label=element.label, reason=f"the model named {element.label!r}")
185
+
186
+ if kind is not None:
187
+ try:
188
+ return parse_action(dict(payload, kind=kind))
189
+ except ActionError as exc:
190
+ # a valid-looking verb with no usable target: maybe the label is in the prose
191
+ return _recover_from_text(payload, menu) or (_raise_brain_error(exc))
192
+
193
+ return _recover_from_text(payload, menu)
194
+
195
+
196
+ def _raise_brain_error(exc: Exception) -> Action:
197
+ raise BrainError(f"the model proposed an invalid action: {exc}") from exc
198
+
199
+
200
+ _DONE = re.compile(r"\b(done|finished|complete[d]?)\b", re.IGNORECASE)
201
+
202
+
203
+ def parse_decision(raw: str, observation: Observation, *, max_menu_items: int = 12) -> Action:
204
+ """Turn a small model's reply into a validated Action - leniently, but safely.
205
+
206
+ Tried in order:
207
+
208
+ 1. the **last** JSON object in the reply (``index``, ``label`` or a full action)
209
+ 2. a label the reply names (most specific evidence of intent)
210
+ 3. a bare menu number
211
+ 4. a "done" word
212
+
213
+ A reply that mentions three or more menu entries is rejected as an echo of the
214
+ prompt rather than a decision - the failure mode of the weakest models, and the
215
+ one most likely to make an agent click something arbitrary.
216
+ """
217
+ text = " ".join((raw or "").split())
218
+ if not text:
219
+ raise BrainError("the model returned an empty reply")
220
+ menu = DecisionMenu(observation, max_items=max_menu_items)
221
+
222
+ for payload in reversed(extract_json_objects(text)):
223
+ action = _action_from_payload(payload, menu)
224
+ if action is not None:
225
+ return action
226
+
227
+ mentioned = menu.mentioned_in(text)
228
+ if len(mentioned) >= 3:
229
+ raise BrainError(
230
+ f"the reply mentions {len(mentioned)} menu entries, so it looks like an echo "
231
+ f"of the prompt rather than a decision: {text[:120]!r}"
232
+ )
233
+ if mentioned:
234
+ item = mentioned[0]
235
+ note = f"the reply names {item.label!r}"
236
+ if len(mentioned) > 1:
237
+ note += f" (ambiguous: {len(mentioned)} labels mentioned; took the first)"
238
+ return Action("click", label=item.label, reason=note)
239
+
240
+ number = re.search(r"\b(\d{1,2})\b", text)
241
+ if number is not None:
242
+ item = menu.item(number.group(1))
243
+ if item is not None:
244
+ return Action(
245
+ "click", label=item.label,
246
+ reason=f"menu index {number.group(1)} from reply {text[:40]!r}",
247
+ )
248
+
249
+ if _DONE.search(text):
250
+ return Action("done", reason=_DONE.search(text).group(0).lower())
251
+
252
+ raise BrainError(f"could not read a decision out of the reply: {text[:120]!r}")
253
 
254
 
255
  class Brain(Protocol):
 
286
  class PlannerBrain:
287
  """Rule-based baseline: click the element matching the goal, then stop.
288
 
289
+ Its purpose is threefold - it proves the harness works end to end with no model
290
+ at all, it is a fallback when a model download is undesirable, and it shows how
291
+ much of a "computer use" task is really an indexing problem. It waits when the
292
+ target is not on screen yet, refuses to click the same label twice, and gives up
293
+ rather than flailing. It will not generalise the way a model can.
294
  """
295
 
296
  name = "planner"
 
328
  reason=f"matched {target!r} in the element index")
329
  raise GoalUnreachable(f"no element name found in the goal {goal!r}")
330
 
 
331
  @staticmethod
332
  def _target_from(goal: str) -> str | None:
333
  """Best effort: quoted text wins, else the last capitalised word."""
 
338
  return words[-1].strip(".,!?") if words else None
339
 
340
 
341
+ class ModelBrain:
342
+ """Shared plumbing for model-backed brains: menu, prompt, lenient parsing.
343
 
344
+ Subclasses implement :meth:`_generate`. ``requires_index`` marks brains that
345
+ cannot decide without an element index (a text-only planner): for those, an
346
+ empty screen is a hard stop rather than a prompt.
347
  """
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
360
+ # the next decision, when the new observation can be compared to the old one
361
+ self.history: list[str] = []
362
+ self._last_fingerprint: tuple | None = None
363
+
364
+ @staticmethod
365
+ def _fingerprint(observation: Observation) -> tuple:
366
+ return (tuple(element.label for element in observation.elements),
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
+ "",
380
+ "Reply with one JSON object and nothing else, in this shape:",
381
+ ' {"kind": "click", "index": <number from the list>, "reason": "<brief>"}',
382
+ ' {"kind": "done", "reason": "<brief>"}',
383
+ "Use done only when the goal is already met.",
384
+ "",
385
+ f"Goal: {goal}",
386
+ ]
387
+ if observation.text:
388
+ lines += ["", f"Text on screen: {observation.text[:300]}"]
389
+ lines += ["", f"Clickable items ({len(menu)}):"]
390
+ lines.append(menu.render() if menu else " (none detected)")
391
+ if history:
392
+ lines += ["", "Already attempted - do NOT repeat these, they did not work:"]
393
+ lines += [f" - {entry}" for entry in history]
394
+ if not screen_changed:
395
+ lines += ["", "The screen did not change after your last action. "
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:
402
+ raise GoalUnreachable(
403
+ "no clickable elements on screen - is screen capture and OCR working?"
404
+ )
405
+ fingerprint = self._fingerprint(observation)
406
+ screen_changed = (self._last_fingerprint is None
407
+ or fingerprint != self._last_fingerprint)
408
+
409
+ # Finalise the previous attempt first: only now do we know whether it changed
410
+ # anything, and "you already tried this and nothing happened" is the single most
411
+ # useful thing to tell a small model.
412
+ if self.history:
413
+ previous = self.history[-1].split(" -> ")[0]
414
+ self.history[-1] = (
415
+ f"{previous} -> "
416
+ + ("the screen changed" if screen_changed else "the screen did NOT change")
417
+ )
418
+
419
+ self.last_prompt = self.build_prompt(
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
  def _generate(self, prompt: str, observation: Observation) -> str:
432
  raise NotImplementedError(
433
+ "subclass ModelBrain and implement _generate(prompt, observation)"
 
 
434
  )
 
 
 
 
 
 
 
 
tiny_computer_agent/demo.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic screens for offline demos and tests.
2
+
3
+ No display required: this module draws a small fake desktop - a top bar with Files
4
+ and Terminal, plus a Save dialog with a file-name field and Save/Cancel buttons - so
5
+ the whole pipeline (pixels, OCR, index, menu, model, action) can be exercised on a
6
+ headless machine, in CI, or before you let an agent near your real desktop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import glob
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+
16
+ def _font(size: int) -> Any:
17
+ from PIL import ImageFont
18
+
19
+ candidates = [
20
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
21
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
22
+ "C:/Windows/Fonts/arial.ttf",
23
+ ] + glob.glob("/usr/share/fonts/**/*Regular*.ttf", recursive=True)
24
+ for path in candidates:
25
+ try:
26
+ return ImageFont.truetype(path, size)
27
+ except Exception:
28
+ continue
29
+ return ImageFont.load_default()
30
+
31
+
32
+ def fake_desktop(width: int = 720, height: int = 460) -> Any:
33
+ """Draw a screenshot-like image: a desktop with an unsaved Save dialog."""
34
+ from PIL import Image, ImageDraw
35
+
36
+ image = Image.new("RGB", (width, height), (245, 246, 248))
37
+ draw = ImageDraw.Draw(image)
38
+ draw.rectangle([0, 0, width, 28], fill=(38, 40, 48))
39
+ draw.text((12, 7), "Files", fill="white", font=_font(15))
40
+ draw.text((90, 7), "Terminal", fill="white", font=_font(15))
41
+ draw.rectangle([180, 90, 540, 330], fill="white", outline=(120, 120, 130), width=2)
42
+ draw.text((200, 108), "Untitled document - unsaved changes",
43
+ fill=(30, 30, 30), font=_font(16))
44
+ draw.text((200, 170), "File name", fill=(60, 60, 60), font=_font(15))
45
+ draw.rectangle([200, 195, 520, 225], fill=(252, 252, 252),
46
+ outline=(120, 120, 130), width=2)
47
+ draw.rectangle([200, 265, 380, 300], fill=(66, 111, 220),
48
+ outline=(40, 70, 160), width=2)
49
+ draw.text((275, 274), "Save", fill="white", font=_font(16))
50
+ draw.rectangle([400, 265, 520, 300], fill=(230, 230, 235),
51
+ outline=(120, 120, 130), width=2)
52
+ draw.text((440, 274), "Cancel", fill=(30, 30, 30), font=_font(16))
53
+ return image
54
+
55
+
56
+ def write_fake_desktop(path: str | Path) -> Path:
57
+ """Save :func:`fake_desktop` to a PNG (handy for ``--image``)."""
58
+ target = Path(path)
59
+ fake_desktop().save(target)
60
+ return target
tiny_computer_agent/desktop.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wiring for a real desktop: preflight checks, then one-call agent construction.
2
+
3
+ This module exists because "make it work on my computer" is mostly a diagnosis
4
+ problem. Rather than failing deep inside an episode, :func:`doctor` reports exactly
5
+ which piece is missing - display, capture backend, input backend, OCR engine, model
6
+ runtime - and what to do about each.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import os
13
+ import platform
14
+ from dataclasses import dataclass
15
+ from typing import Any, Callable, Iterable, Sequence
16
+
17
+ from tiny_computer_agent.act import DryRunExecutor, Executor, PyAutoGuiExecutor
18
+ from tiny_computer_agent.brain import Brain, PlannerBrain
19
+ from tiny_computer_agent.loop import AgentLoop, Step
20
+ from tiny_computer_agent.observe import GrabScreen, ScreenSource
21
+ from tiny_computer_agent.perception import (
22
+ Detector, IndexedScreen, detector_available, make_detector,
23
+ )
24
+ from tiny_computer_agent.safety import Policy
25
+
26
+ DEFAULT_KILL_SWITCH = "/tmp/tiny-computer-agent.STOP"
27
+ BRAIN_NAMES = ("planner", "text-planner", "vlm")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Check:
32
+ """One preflight result: what was checked, whether it works, and how to fix it."""
33
+
34
+ name: str
35
+ ok: bool
36
+ detail: str
37
+ fix: str = ""
38
+ required: bool = True
39
+
40
+ def render(self) -> str:
41
+ marker = "ok " if self.ok else ("FAIL" if self.required else "warn")
42
+ line = f" [{marker}] {self.name}: {self.detail}"
43
+ if not self.ok and self.fix:
44
+ line += f"\n fix: {self.fix}"
45
+ return line
46
+
47
+
48
+ def _installed(module: str) -> bool:
49
+ return importlib.util.find_spec(module) is not None
50
+
51
+
52
+ def display_available() -> tuple[bool, str]:
53
+ """Whether a windowing session looks reachable from this process."""
54
+ system = platform.system()
55
+ if system in ("Darwin", "Windows"):
56
+ return True, f"{system} desktop session assumed"
57
+ display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
58
+ if display:
59
+ return True, f"DISPLAY={display}"
60
+ return False, "no DISPLAY or WAYLAND_DISPLAY set (headless container or SSH session)"
61
+
62
+
63
+ def run_checks(*, brain: str = "planner", model_id: str | None = None,
64
+ detector: str = "ocr", real_input: bool = False,
65
+ kill_switch_path: str | None = DEFAULT_KILL_SWITCH) -> list[Check]:
66
+ """Every precondition for driving a real desktop, in one list."""
67
+ checks: list[Check] = []
68
+
69
+ ok, detail = display_available()
70
+ checks.append(Check(
71
+ "display server", ok, detail,
72
+ fix="run this on the desktop session itself (a headless server cannot be driven)",
73
+ ))
74
+
75
+ checks.append(Check(
76
+ "pillow", _installed("PIL"),
77
+ "installed" if _installed("PIL") else "not installed",
78
+ fix="pip install 'tiny-computer-agent[desktop]'",
79
+ ))
80
+
81
+ has_mss = _installed("mss")
82
+ checks.append(Check(
83
+ "screen capture", has_mss or _installed("PIL"),
84
+ "mss (fast, multi-monitor)" if has_mss else "pillow ImageGrab fallback",
85
+ fix="pip install mss for faster multi-monitor capture",
86
+ required=False,
87
+ ))
88
+
89
+ has_pyautogui = _installed("pyautogui")
90
+ checks.append(Check(
91
+ "input backend (pyautogui)", has_pyautogui,
92
+ "installed" if has_pyautogui else "not installed",
93
+ fix="pip install 'tiny-computer-agent[desktop]'",
94
+ required=real_input,
95
+ ))
96
+
97
+ detector_ok, detector_detail = detector_available(detector)
98
+ checks.append(Check(
99
+ f"element detector ({detector})", detector_ok, detector_detail,
100
+ fix="pip install 'tiny-computer-agent[ocr]'",
101
+ ))
102
+
103
+ if brain == "planner":
104
+ checks.append(Check(
105
+ "model runtime", True,
106
+ "not needed: the planner is rule-based (no weights, no download)",
107
+ required=False,
108
+ ))
109
+ else:
110
+ from tiny_computer_agent.models import (
111
+ DEFAULT_TEXT_MODEL, DEFAULT_VISION_MODEL, model_cached, model_dependencies,
112
+ )
113
+
114
+ deps_ok, deps_detail = model_dependencies()
115
+ model = model_id or (DEFAULT_TEXT_MODEL if brain == "text-planner" else DEFAULT_VISION_MODEL)
116
+ checks.append(Check(
117
+ "model runtime", deps_ok, deps_detail,
118
+ fix="pip install 'tiny-computer-agent[vision]'",
119
+ ))
120
+ checks.append(Check(
121
+ f"model weights ({model})",
122
+ model_cached(model),
123
+ "cached locally" if model_cached(model) else "not downloaded yet",
124
+ fix=f"first run will download {model} from the Hub (one time)",
125
+ required=False,
126
+ ))
127
+
128
+ checks.append(Check(
129
+ "execution mode",
130
+ True,
131
+ "REAL mouse and keyboard input" if real_input else "dry run (nothing is clicked)",
132
+ fix="pass --real-input AND set TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1",
133
+ required=False,
134
+ ))
135
+
136
+ checks.append(Check(
137
+ "kill switch", True,
138
+ f"create {kill_switch_path} to abort the next action"
139
+ if kill_switch_path else "disabled",
140
+ required=False,
141
+ ))
142
+ return checks
143
+
144
+
145
+ def doctor(*, brain: str = "planner", model_id: str | None = None,
146
+ detector: str = "ocr", real_input: bool = False,
147
+ kill_switch_path: str | None = DEFAULT_KILL_SWITCH) -> str:
148
+ """A human-readable preflight report."""
149
+ checks = run_checks(brain=brain, model_id=model_id, detector=detector,
150
+ real_input=real_input, kill_switch_path=kill_switch_path)
151
+ blocking = [c for c in checks if c.required and not c.ok]
152
+ lines = [
153
+ f"tiny-computer-agent preflight ({platform.system()} {platform.machine()}, python {platform.python_version()})",
154
+ "",
155
+ *(c.render() for c in checks),
156
+ "",
157
+ ]
158
+ if blocking:
159
+ lines.append(f"NOT READY: {len(blocking)} blocking problem(s): "
160
+ + ", ".join(c.name for c in blocking))
161
+ else:
162
+ lines.append("READY: the harness can drive this desktop.")
163
+ if not real_input:
164
+ lines.append("Dry run is on: nothing will be clicked. Read the plan first.")
165
+ return "\n".join(lines)
166
+
167
+
168
+ def build_agent(*, brain: Brain | str | None = None, screen: ScreenSource | None = None,
169
+ detector: Detector | str | None = None, dry_run: bool = True,
170
+ max_steps: int = 10, kill_switch_path: str | None = DEFAULT_KILL_SWITCH,
171
+ allow_grid_clicks: bool = False,
172
+ on_step: Callable[[Step], None] | None = None,
173
+ detector_options: dict[str, Any] | None = None) -> AgentLoop:
174
+ """Wire a real desktop into an :class:`AgentLoop` in one call.
175
+
176
+ Defaults are the safe ones: dry run, a step budget of 10, and a kill-switch file
177
+ on disk that aborts the next action if it appears. ``screen``/``detector``/
178
+ ``brain`` can be injected, which is how the tests drive this without a desktop.
179
+ """
180
+ if detector is None or isinstance(detector, str):
181
+ detector = make_detector(detector or "ocr", **(detector_options or {}))
182
+ if screen is None:
183
+ screen = IndexedScreen(GrabScreen(), detector)
184
+ if brain is None or isinstance(brain, str):
185
+ name = brain or "planner"
186
+ if name == "planner":
187
+ brain = PlannerBrain()
188
+ else:
189
+ from tiny_computer_agent.models import make_model_brain
190
+
191
+ brain = make_model_brain(name)
192
+ executor: Executor = DryRunExecutor() if dry_run else PyAutoGuiExecutor()
193
+ policy = Policy(dry_run=dry_run, step_budget=max_steps,
194
+ kill_switch_path=kill_switch_path,
195
+ allow_grid_clicks=allow_grid_clicks)
196
+ return AgentLoop(screen=screen, brain=brain, executor=executor, policy=policy,
197
+ on_step=on_step)
tiny_computer_agent/loop.py CHANGED
@@ -74,14 +74,16 @@ class AgentLoop:
74
 
75
  * every action is one of six validated verbs
76
  * nothing executes until policy has passed it
77
- * the episode ends on a step budget, a kill switch, or a brain error
 
78
  * the screen is re-read after every action, so the next decision sees the
79
  consequence of the last one
80
  """
81
 
82
  def __init__(self, *, screen: ScreenSource, brain: Brain,
83
  executor: Executor | None = None, policy: Policy | None = None,
84
- on_step: Callable[[Step], None] | None = None) -> None:
 
85
  self.screen = screen
86
  self.brain = brain
87
  self.policy = policy or Policy()
@@ -89,11 +91,21 @@ class AgentLoop:
89
  executor = DryRunExecutor() if self.policy.dry_run else PyAutoGuiExecutor()
90
  self.executor = executor
91
  self.on_step = on_step
 
92
 
93
  def run(self, goal: str, *, max_steps: int | None = None) -> Episode:
94
  budget = max_steps or self.policy.step_budget
95
  episode = Episode(goal=goal, stop_reason=f"step budget of {budget} exhausted")
96
- observation = self.screen.capture()
 
 
 
 
 
 
 
 
 
97
 
98
  for index in range(budget):
99
  if self.policy.should_stop():
@@ -129,6 +141,21 @@ class AgentLoop:
129
  episode.stop_reason = f"policy refusal: {exc}"
130
  break
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  try:
133
  result = self.executor.execute(action, observation)
134
  except Exception as exc:
@@ -142,10 +169,22 @@ class AgentLoop:
142
  episode.stop_reason = f"action failed: {result.note}"
143
  break
144
 
145
- observation = self.screen.capture() # verify: what did that change?
 
 
 
 
 
 
146
 
147
  return episode
148
 
 
 
 
 
 
 
149
  def _record(self, episode: Episode, step: Step) -> None:
150
  episode.steps.append(step)
151
  if self.on_step is not None:
 
74
 
75
  * every action is one of six validated verbs
76
  * nothing executes until policy has passed it
77
+ * the episode ends on a step budget, a kill switch, a brain error, or a
78
+ failed screen capture (reported as a step, never as a traceback)
79
  * the screen is re-read after every action, so the next decision sees the
80
  consequence of the last one
81
  """
82
 
83
  def __init__(self, *, screen: ScreenSource, brain: Brain,
84
  executor: Executor | None = None, policy: Policy | None = None,
85
+ on_step: Callable[[Step], None] | None = None,
86
+ max_repeats: int = 1) -> None:
87
  self.screen = screen
88
  self.brain = brain
89
  self.policy = policy or Policy()
 
91
  executor = DryRunExecutor() if self.policy.dry_run else PyAutoGuiExecutor()
92
  self.executor = executor
93
  self.on_step = on_step
94
+ self.max_repeats = max_repeats
95
 
96
  def run(self, goal: str, *, max_steps: int | None = None) -> Episode:
97
  budget = max_steps or self.policy.step_budget
98
  episode = Episode(goal=goal, stop_reason=f"step budget of {budget} exhausted")
99
+ # A small model that repeats an action against an unchanged screen is stuck,
100
+ # not persistent. Tolerate `max_repeats` such repeats, then stop - burning the
101
+ # whole budget on the same click teaches the user nothing.
102
+ previous: tuple[tuple, str] | None = None
103
+ repeats = 0
104
+ try:
105
+ observation = self.screen.capture()
106
+ except Exception as exc:
107
+ episode.stop_reason = f"capture failed: {type(exc).__name__}: {exc}"
108
+ return episode
109
 
110
  for index in range(budget):
111
  if self.policy.should_stop():
 
141
  episode.stop_reason = f"policy refusal: {exc}"
142
  break
143
 
144
+ signature = (self._fingerprint(observation), action.describe())
145
+ repeats = repeats + 1 if signature == previous else 0
146
+ previous = signature
147
+ if repeats > self.max_repeats:
148
+ self._record(episode, Step(
149
+ index, observation, action=action,
150
+ error=f"no progress: repeating {action.describe()} "
151
+ f"against an unchanged screen",
152
+ ))
153
+ episode.stop_reason = (
154
+ f"no progress after {repeats} identical actions on an unchanged "
155
+ f"screen ({action.kind} {action.label or action.text or ''})".strip()
156
+ )
157
+ break
158
+
159
  try:
160
  result = self.executor.execute(action, observation)
161
  except Exception as exc:
 
169
  episode.stop_reason = f"action failed: {result.note}"
170
  break
171
 
172
+ try:
173
+ observation = self.screen.capture() # verify: what did that change?
174
+ except Exception as exc:
175
+ self._record(episode, Step(index + 1, observation,
176
+ error=f"capture failed: {type(exc).__name__}: {exc}"))
177
+ episode.stop_reason = "perception failed mid-episode"
178
+ break
179
 
180
  return episode
181
 
182
+ @staticmethod
183
+ def _fingerprint(observation: Observation) -> tuple:
184
+ """What the agent can see: enough to tell "nothing changed" from "it worked"."""
185
+ return (tuple(element.label for element in observation.elements),
186
+ observation.text[:200])
187
+
188
  def _record(self, episode: Episode, step: Step) -> None:
189
  episode.steps.append(step)
190
  if self.on_step is not None:
tiny_computer_agent/models.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real (tiny) model backends, small enough to run on a laptop CPU.
2
+
3
+ Two of them, because they trade off differently:
4
+
5
+ * :class:`TextPlannerBrain` - a ~0.5B *text* model over the OCR index. No vision,
6
+ no big download, and the element index does all the grounding, which is why it
7
+ works at this size at all.
8
+ * :class:`VLMBrain` - a tiny *vision* model that sees the screenshot itself
9
+ (256M-class models can read a button label from pixels, but reason poorly, so the
10
+ menu still carries most of the decision).
11
+
12
+ Both inherit menu building, prompt shape and lenient parsing from
13
+ :class:`~tiny_computer_agent.brain.ModelBrain`, so swapping weights never changes the
14
+ harness contract.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ 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:
32
+ import torch
33
+ except ImportError: # pragma: no cover - depends on the machine
34
+ raise BrainError("model backends need torch: pip install 'tiny-computer-agent[vision]'")
35
+ if prefer:
36
+ return prefer
37
+ return "cuda" if torch.cuda.is_available() else "cpu"
38
+
39
+
40
+ def pick_dtype(device: str) -> Any:
41
+ import torch
42
+
43
+ if device.startswith("cuda"):
44
+ return torch.bfloat16
45
+ return torch.float32
46
+
47
+
48
+ def model_dependencies() -> tuple[bool, str]:
49
+ """(available, detail) for preflight and the doctor command."""
50
+ try:
51
+ import transformers
52
+ except ImportError:
53
+ return False, "transformers is not installed (pip install 'tiny-computer-agent[vision]')"
54
+ try:
55
+ import torch
56
+ except ImportError:
57
+ return False, "torch is not installed (pip install 'tiny-computer-agent[vision]')"
58
+ return True, f"transformers {transformers.__version__}, torch {torch.__version__}"
59
+
60
+
61
+ def model_cached(model_id: str) -> bool:
62
+ """True when the weights are already in the local Hugging Face cache."""
63
+ try:
64
+ from huggingface_hub import constants
65
+ except ImportError: # pragma: no cover
66
+ return False
67
+ folder = os.path.join(constants.HF_HUB_CACHE, "models--" + model_id.replace("/", "--"))
68
+ return os.path.isdir(folder)
69
+
70
+
71
+ class TextPlannerBrain(ModelBrain):
72
+ """Causal-LM planner over the OCR index: no pixels in, one menu number out."""
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:
79
+ super().__init__(model_id, **kwargs)
80
+ self.device = device
81
+ self._tokenizer: Any = None
82
+ self._model: Any = None
83
+
84
+ def _load(self) -> None:
85
+ if self._model is not None:
86
+ return
87
+ try:
88
+ from transformers import AutoModelForCausalLM, AutoTokenizer
89
+ except ImportError as exc: # pragma: no cover - depends on the machine
90
+ raise BrainError(
91
+ "text planner needs transformers and torch: "
92
+ "pip install 'tiny-computer-agent[vision]'"
93
+ ) from exc
94
+ self.device = resolve_device(self.device)
95
+ self._tokenizer = AutoTokenizer.from_pretrained(self.model_id)
96
+ self._model = AutoModelForCausalLM.from_pretrained(
97
+ self.model_id, dtype=pick_dtype(self.device)
98
+ ).to(self.device).eval()
99
+
100
+ def unload(self) -> None:
101
+ self._model = None
102
+ self._tokenizer = None
103
+
104
+ def _generate(self, prompt: str, observation: Observation) -> str:
105
+ import torch
106
+
107
+ self._load()
108
+ messages = [{"role": "user", "content": prompt}]
109
+ text = self._tokenizer.apply_chat_template(
110
+ messages, add_generation_prompt=True, tokenize=False
111
+ )
112
+ inputs = self._tokenizer(text, return_tensors="pt").to(self.device)
113
+ with torch.inference_mode():
114
+ output = self._model.generate(
115
+ **inputs,
116
+ max_new_tokens=self.max_new_tokens,
117
+ do_sample=False,
118
+ pad_token_id=self._tokenizer.eos_token_id,
119
+ )
120
+ return self._tokenizer.decode(
121
+ output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
122
+ ).strip()
123
+
124
+
125
+ class VLMBrain(ModelBrain):
126
+ """Tiny vision-language model: sees the screenshot, answers with a menu number."""
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:
133
+ super().__init__(model_id, **kwargs)
134
+ self.device = device
135
+ self._processor: Any = None
136
+ self._model: Any = None
137
+
138
+ def _load(self) -> None:
139
+ if self._model is not None:
140
+ return
141
+ try:
142
+ from transformers import AutoModelForImageTextToText, AutoProcessor
143
+ except ImportError as exc: # pragma: no cover - depends on the machine
144
+ raise BrainError(
145
+ "the vision backend needs transformers and torch: "
146
+ "pip install 'tiny-computer-agent[vision]'"
147
+ ) from exc
148
+ self.device = resolve_device(self.device)
149
+ self._processor = AutoProcessor.from_pretrained(self.model_id)
150
+ self._model = AutoModelForImageTextToText.from_pretrained(
151
+ self.model_id, dtype=pick_dtype(self.device)
152
+ ).to(self.device).eval()
153
+
154
+ def unload(self) -> None:
155
+ self._model = None
156
+ self._processor = None
157
+
158
+ def _generate(self, prompt: str, observation: Observation) -> str:
159
+ import torch
160
+
161
+ image = observation.frame
162
+ if image is None or isinstance(image, str):
163
+ raise BrainError(
164
+ "the vision backend needs a real screenshot but the observation carries "
165
+ f"{type(image).__name__} - run with --live, or use the text planner"
166
+ )
167
+ self._load()
168
+ messages = [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": prompt}]}]
169
+ text = self._processor.apply_chat_template(messages, add_generation_prompt=True)
170
+ inputs = self._processor(text=text, images=[image], return_tensors="pt").to(self.device)
171
+ with torch.inference_mode():
172
+ output = self._model.generate(
173
+ **inputs, max_new_tokens=self.max_new_tokens, do_sample=False
174
+ )
175
+ return self._processor.batch_decode(
176
+ output[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True
177
+ )[0].strip()
178
+
179
+
180
+ MODEL_BRAINS = {"text-planner": TextPlannerBrain, "vlm": VLMBrain}
181
+
182
+
183
+ def make_model_brain(name: str = "text-planner", **kwargs: Any) -> ModelBrain:
184
+ """Build a model brain by name, with an actionable error for unknown names."""
185
+ if name not in MODEL_BRAINS:
186
+ raise BrainError(f"unknown brain {name!r}; expected one of {tuple(MODEL_BRAINS)}")
187
+ return MODEL_BRAINS[name](**kwargs)
tiny_computer_agent/observe.py CHANGED
@@ -1,15 +1,30 @@
1
- """Perception: turn the world into an :class:`Observation` the brain can read.
2
 
3
  The harness never asks the model to *find* anything. OCR or the accessibility tree
4
- produces a labelled element index; the model only chooses among those labels.
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
 
9
  from dataclasses import dataclass
 
10
  from typing import Any, Protocol, Sequence
11
 
12
 
 
 
 
 
 
 
 
 
 
 
13
  @dataclass(frozen=True)
14
  class Element:
15
  """A clickable thing, located by the platform — not by the model."""
@@ -34,7 +49,8 @@ class Element:
34
  return (self.x, self.y)
35
 
36
  def matches(self, needle: str) -> bool:
37
- return needle.strip().lower() in self.label.strip().lower()
 
38
 
39
 
40
  @dataclass(frozen=True)
@@ -47,9 +63,15 @@ class Observation:
47
  text: str = ""
48
 
49
  def find(self, label: str) -> Element | None:
50
- """Exact label first, then substring; None if nothing matches."""
51
- want = label.strip().lower()
52
- exact = [e for e in self.elements if e.label.strip().lower() == want]
 
 
 
 
 
 
53
  if exact:
54
  return exact[0]
55
  loose = [e for e in self.elements if e.matches(label)]
@@ -102,40 +124,130 @@ class FakeScreen:
102
  return observation
103
 
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  class ScreenUnavailable(RuntimeError):
106
  """Raised when real capture is requested but the machine cannot provide it."""
107
 
108
 
109
  class GrabScreen:
110
- """Real desktop capture via ``mss`` + Pillow.
111
-
112
- Pixels only: nothing here builds an element index. Wiring OCR (tesseract) or
113
- the accessibility tree (AT-SPI on Linux) is what turns frames into labels.
114
- Needs a display, so it is untested in a headless sandbox.
 
 
 
 
 
 
 
115
  """
116
 
117
- def __init__(self, *, monitor: int = 1, max_width: int = 1280) -> None:
 
 
 
 
 
 
 
118
  self.monitor = monitor
119
  self.max_width = max_width
 
 
 
120
  self._index = 0
121
 
122
  def capture(self) -> Observation:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  try:
124
  import mss
 
 
 
125
  from PIL import Image
126
- except ImportError as exc: # pragma: no cover - depends on the machine
 
 
 
 
 
 
 
127
  raise ScreenUnavailable(
128
- "GrabScreen needs 'mss' and 'pillow': pip install \'tiny-computer-agent[desktop]\'"
129
  ) from exc
 
 
130
  try:
131
- with mss.mss() as sct:
132
- shot = sct.grab(sct.monitors[self.monitor])
133
- image = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
134
- except Exception as exc: # pragma: no cover - depends on the machine
135
- raise ScreenUnavailable(f"screen capture failed (no display?): {exc}") from exc
136
- if image.width > self.max_width:
137
- ratio = self.max_width / image.width
138
- image = image.resize((self.max_width, int(image.height * ratio)))
139
- observation = Observation(index=self._index, frame=image, elements=(), text="")
140
- self._index += 1
141
- return observation
 
 
 
 
 
1
+ """The Observation contract: screen frames and the element index built from them.
2
 
3
  The harness never asks the model to *find* anything. OCR or the accessibility tree
4
+ produces a labelled element index (see :mod:`tiny_computer_agent.perception`); the
5
+ model only chooses among those labels.
6
+
7
+ Every :class:`Element` carries **absolute screen coordinates**, whoever built it.
8
  """
9
 
10
  from __future__ import annotations
11
 
12
+ import sys
13
  from dataclasses import dataclass
14
+ from pathlib import Path
15
  from typing import Any, Protocol, Sequence
16
 
17
 
18
+ def normalise_label(text: str) -> str:
19
+ """Lower-case and keep only letters and digits.
20
+
21
+ OCR routinely loses or invents spaces ("Untitleddocument-unsavedchanges") and
22
+ models add punctuation ("Save."), so labels are compared on their alphanumeric
23
+ core - otherwise nothing a model says ever matches what was seen on screen.
24
+ """
25
+ return "".join(character for character in text.lower() if character.isalnum())
26
+
27
+
28
  @dataclass(frozen=True)
29
  class Element:
30
  """A clickable thing, located by the platform — not by the model."""
 
49
  return (self.x, self.y)
50
 
51
  def matches(self, needle: str) -> bool:
52
+ wanted = normalise_label(needle)
53
+ return bool(wanted) and wanted in normalise_label(self.label)
54
 
55
 
56
  @dataclass(frozen=True)
 
63
  text: str = ""
64
 
65
  def find(self, label: str) -> Element | None:
66
+ """Exact label first, then substring; None if nothing matches.
67
+
68
+ Comparison is on normalised text (see :func:`normalise_label`), so
69
+ ``find("Save")`` still matches a label the OCR rendered as ``"Save ."``.
70
+ """
71
+ wanted = normalise_label(label)
72
+ if not wanted:
73
+ return None
74
+ exact = [e for e in self.elements if normalise_label(e.label) == wanted]
75
  if exact:
76
  return exact[0]
77
  loose = [e for e in self.elements if e.matches(label)]
 
124
  return observation
125
 
126
 
127
+ class ImageScreen:
128
+ """A static screen source: replay one screenshot (or a sequence of them).
129
+
130
+ Lets the full pipeline - OCR, index, menu, model, action - be exercised with no
131
+ display at all, and lets you replay a captured failure offline. Accepts a path,
132
+ a PIL image, or a sequence of either.
133
+ """
134
+
135
+ def __init__(self, source: Any) -> None:
136
+ from PIL import Image
137
+
138
+ frames = [source] if isinstance(source, (str, Path)) or not isinstance(source, (list, tuple)) else list(source)
139
+ self._images: list[Any] = []
140
+ for frame in frames:
141
+ if isinstance(frame, (str, Path)):
142
+ self._images.append(Image.open(str(frame)).convert("RGB"))
143
+ else:
144
+ self._images.append(frame)
145
+ if not self._images:
146
+ raise ValueError("ImageScreen needs at least one image")
147
+ self.captures = 0
148
+
149
+ def capture(self) -> Observation:
150
+ index = min(self.captures, len(self._images) - 1)
151
+ self.captures += 1
152
+ return Observation(index=self.captures - 1, frame=self._images[index], elements=(), text="")
153
+
154
+
155
  class ScreenUnavailable(RuntimeError):
156
  """Raised when real capture is requested but the machine cannot provide it."""
157
 
158
 
159
  class GrabScreen:
160
+ """Capture the real desktop as an image.
161
+
162
+ Prefers ``mss`` (fast, multi-monitor) and falls back to Pillow's ``ImageGrab``.
163
+ Caps are deliberately absent: a frame pixel must be a *screen* pixel, which is
164
+ what lets every detector produce Elements with absolute screen coordinates.
165
+
166
+ Two options break that identity, so both are explicit: ``monitor`` may select a
167
+ display whose origin is not (0, 0), and ``max_width`` downscales a wide capture
168
+ (faster OCR, and a smaller image for a model to look at).
169
+ :meth:`map_to_screen` converts a frame coordinate back to a real one, and
170
+ :class:`~tiny_computer_agent.perception.IndexedScreen` applies it to every
171
+ detected element, so a click never lands on the wrong pixel.
172
  """
173
 
174
+ def __init__(self, *, monitor: int = 1, max_width: int | None = None,
175
+ scale: float | None = None) -> None:
176
+ if monitor < 0:
177
+ raise ValueError("monitor must be 0 (all displays) or a positive index")
178
+ if max_width is not None and max_width < 1:
179
+ raise ValueError("max_width must be positive")
180
+ if scale is not None and scale <= 0:
181
+ raise ValueError("scale must be positive")
182
  self.monitor = monitor
183
  self.max_width = max_width
184
+ self.scale = scale
185
+ self.origin: tuple[int, int] = (0, 0)
186
+ self.frame_scale: float = 1.0
187
  self._index = 0
188
 
189
  def capture(self) -> Observation:
190
+ image, origin = self._grab()
191
+ native_width = image.width
192
+ if self.max_width is not None and image.width > self.max_width:
193
+ ratio = self.max_width / image.width
194
+ image = image.resize((self.max_width, max(1, round(image.height * ratio))))
195
+ if self.scale is not None:
196
+ self.frame_scale = float(self.scale)
197
+ else:
198
+ self.frame_scale = native_width / image.width
199
+ self.origin = origin
200
+ observation = Observation(index=self._index, frame=image, elements=(), text="")
201
+ self._index += 1
202
+ return observation
203
+
204
+ def map_to_screen(self, element: Element) -> Element:
205
+ """Frame pixel -> absolute screen pixel, honouring the origin and any scale."""
206
+ if self.frame_scale == 1.0 and self.origin == (0, 0):
207
+ return element
208
+ factor = self.frame_scale
209
+ return Element(
210
+ label=element.label, kind=element.kind,
211
+ x=round(element.x * factor) + self.origin[0],
212
+ y=round(element.y * factor) + self.origin[1],
213
+ width=max(1, round(element.width * factor)),
214
+ height=max(1, round(element.height * factor)),
215
+ )
216
+
217
+ def _grab(self) -> tuple[Any, tuple[int, int]]:
218
+ """(image, screen position of the image's top-left pixel)."""
219
  try:
220
  import mss
221
+ except ImportError:
222
+ return self._grab_with_pillow()
223
+ try:
224
  from PIL import Image
225
+
226
+ with mss.mss() as sct:
227
+ monitors = sct.monitors
228
+ monitor = monitors[self.monitor] if self.monitor < len(monitors) else monitors[-1]
229
+ shot = sct.grab(monitor)
230
+ image = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
231
+ return image, (int(monitor.get("left", 0)), int(monitor.get("top", 0)))
232
+ except Exception as exc:
233
  raise ScreenUnavailable(
234
+ f"screen capture failed (no display?): {type(exc).__name__}: {exc}"
235
  ) from exc
236
+
237
+ def _grab_with_pillow(self) -> tuple[Any, tuple[int, int]]:
238
  try:
239
+ from PIL import ImageGrab
240
+ except ImportError as exc: # pragma: no cover - pillow is a desktop extra
241
+ raise ScreenUnavailable(
242
+ "screen capture needs mss or Pillow: pip install \'tiny-computer-agent[desktop]\'"
243
+ ) from exc
244
+ try:
245
+ if sys.platform.startswith("win"):
246
+ image = ImageGrab.grab(all_screens=self.monitor == 0)
247
+ else:
248
+ image = ImageGrab.grab()
249
+ except Exception as exc:
250
+ raise ScreenUnavailable(
251
+ f"screen capture failed (no display?): {type(exc).__name__}: {exc}"
252
+ ) from exc
253
+ return image.convert("RGB"), (0, 0)
tiny_computer_agent/perception.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turning pixels into a labelled element index.
2
+
3
+ This is the module that makes a *tiny* model viable. The model never has to guess
4
+ where anything is, because OCR has already turned the screen into a short list of
5
+ labels with coordinates - and the model only has to pick one.
6
+
7
+ Filters matter as much as the engine. Raw OCR returns dialog prose, fragmentary
8
+ debris and stray punctuation, and every extra line of noise is another chance for a
9
+ small model to pick the wrong element, so the index is cleaned aggressively.
10
+
11
+ Everything here is optional at import time: a missing OCR engine raises a clear
12
+ :class:`DetectorUnavailable` instead of breaking the harness.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Protocol
19
+
20
+ from tiny_computer_agent.observe import Element, Observation, ScreenSource
21
+
22
+
23
+ class DetectorUnavailable(RuntimeError):
24
+ """Raised when an element detector is requested but cannot run on this machine."""
25
+
26
+
27
+ class Detector(Protocol):
28
+ """Turns one frame into labelled, clickable elements."""
29
+
30
+ name: str
31
+
32
+ def detect(self, image: Any) -> tuple[Element, ...]: ...
33
+
34
+
35
+ @dataclass
36
+ class OcrDetector:
37
+ """Indexes on-screen text with RapidOCR: ONNX, CPU, offline after install.
38
+
39
+ ``max_label_chars`` is the important knob. Screen text includes paragraphs and
40
+ titles that are not widgets, and a model shown them will happily click prose -
41
+ which is exactly what a 0.5B planner did when dialog text sat in its menu. Short
42
+ strings are much more likely to be buttons and menu items, so the default is
43
+ deliberately tight.
44
+ """
45
+
46
+ min_score: float = 0.6
47
+ max_label_chars: int = 24
48
+ min_label_chars: int = 2
49
+ name: str = "ocr"
50
+ _engine: Any = field(default=None, init=False, repr=False)
51
+
52
+ def _loaded(self) -> Any:
53
+ if self._engine is None:
54
+ try:
55
+ from rapidocr_onnxruntime import RapidOCR
56
+ except ImportError as exc: # pragma: no cover - depends on the machine
57
+ raise DetectorUnavailable(
58
+ "no OCR engine available: pip install 'tiny-computer-agent[ocr]'"
59
+ ) from exc
60
+ self._engine = RapidOCR()
61
+ return self._engine
62
+
63
+ def detect(self, image: Any) -> tuple[Element, ...]:
64
+ import numpy as np
65
+
66
+ array = np.asarray(image.convert("RGB") if hasattr(image, "convert") else image)
67
+ result, _ = self._loaded()(array)
68
+ elements: list[Element] = []
69
+ for box, text, score in result or []:
70
+ element = self._to_element(box, text, float(score))
71
+ if element is not None:
72
+ elements.append(element)
73
+ return tuple(elements)
74
+
75
+ def _to_element(self, box: Any, text: Any, score: float) -> Element | None:
76
+ """Convert one OCR box to an Element, or None if it is not a usable label."""
77
+ label = " ".join(str(text).split())
78
+ if score < self.min_score:
79
+ return None
80
+ if not self.min_label_chars <= len(label) <= self.max_label_chars:
81
+ return None
82
+ if not any(character.isalnum() for character in label):
83
+ return None
84
+ xs = [float(point[0]) for point in box]
85
+ ys = [float(point[1]) for point in box]
86
+ x, y = int(min(xs)), int(min(ys))
87
+ return Element(
88
+ label=label, kind="text",
89
+ x=x, y=y,
90
+ width=int(max(xs)) - x, height=int(max(ys)) - y,
91
+ )
92
+
93
+
94
+ @dataclass
95
+ class IndexedScreen:
96
+ """Composes pixel capture with a detector, so ``capture()`` yields an index.
97
+
98
+ This is the wiring that turns the harness from a scripted demo into something
99
+ that can act on a real desktop.
100
+ """
101
+
102
+ source: ScreenSource
103
+ detector: Detector
104
+ fill_text: bool = True
105
+
106
+ def capture(self) -> Observation:
107
+ observation = self.source.capture()
108
+ try:
109
+ elements = self.detector.detect(observation.frame)
110
+ except DetectorUnavailable:
111
+ raise
112
+ except Exception as exc:
113
+ # A perception failure must not kill a run that is otherwise fine.
114
+ elements = ()
115
+ observation = Observation(
116
+ index=observation.index, frame=observation.frame,
117
+ elements=(), text=f"perception error: {type(exc).__name__}: {exc}",
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:
126
+ # Grid labels are coordinates, not visible text, so they are not page text.
127
+ text = " ".join(element.label for element in elements if element.kind != "grid")
128
+ return Observation(
129
+ index=observation.index, frame=observation.frame, elements=elements, text=text,
130
+ )
131
+
132
+
133
+ @dataclass
134
+ class GridDetector:
135
+ """A numbered grid over the frame: click targets that need nothing installed.
136
+
137
+ The guarantee that matters. OCR finds text, the accessibility tree finds widgets,
138
+ but a canvas app, a game, a photo, a custom-drawn toolbar - anything with no text
139
+ - leaves a text index empty, and an agent with an empty index has *no legal
140
+ moves*. A grid always has moves, and a numbered menu is something even a 256M
141
+ model can choose from reliably.
142
+ """
143
+
144
+ rows: int = 8
145
+ cols: int = 12
146
+ name: str = "grid"
147
+
148
+ def __post_init__(self) -> None:
149
+ if self.rows < 1 or self.cols < 1:
150
+ raise ValueError("rows and cols must be at least 1")
151
+
152
+ def detect(self, image: Any) -> tuple[Element, ...]:
153
+ width, height = image.size
154
+ cell_w = width / self.cols
155
+ cell_h = height / self.rows
156
+ elements = []
157
+ for row in range(self.rows):
158
+ for col in range(self.cols):
159
+ elements.append(Element(
160
+ label=f"cell {row * self.cols + col + 1}",
161
+ kind="grid",
162
+ x=round(col * cell_w), y=round(row * cell_h),
163
+ width=max(1, round(cell_w)), height=max(1, round(cell_h)),
164
+ ))
165
+ return tuple(elements)
166
+
167
+
168
+ @dataclass
169
+ class AutoDetector:
170
+ """OCR when it finds text, numbered cells when it does not.
171
+
172
+ This is the default, so the harness is useful on a machine with nothing installed
173
+ beyond Pillow - and gets better automatically once an OCR engine appears.
174
+ """
175
+
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:
184
+ return self.grid.detect(image)
185
+ return elements or self.grid.detect(image)
186
+
187
+
188
+ def _ocr_available() -> tuple[bool, str]:
189
+ try:
190
+ import rapidocr_onnxruntime # noqa: F401
191
+ except ImportError:
192
+ return False, "rapidocr-onnxruntime is not installed (pip install 'tiny-computer-agent[ocr]')"
193
+ try:
194
+ import onnxruntime # noqa: F401
195
+ except ImportError: # pragma: no cover
196
+ return False, "onnxruntime is not installed"
197
+ return True, "rapidocr-onnxruntime ready (CPU, offline)"
198
+
199
+
200
+ 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:
207
+ return True, f"OCR when it finds text, grid otherwise ({detail})"
208
+ return True, "grid only: no OCR engine installed (pip install 'tiny-computer-agent[ocr]')"
209
+ if name != "ocr":
210
+ return False, f"unknown detector {name!r}"
211
+ return _ocr_available()
212
+
213
+
214
+ def make_detector(name: str = "auto", **kwargs: Any) -> Detector:
215
+ """Build a detector by name, with an actionable error when it cannot run."""
216
+ available, detail = detector_available(name)
217
+ if not available:
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)
224
+ return GridDetector(**kwargs)
225
+ return OcrDetector(**kwargs)
tiny_computer_agent/safety.py CHANGED
@@ -50,6 +50,11 @@ class Policy:
50
  deny_keys : key chords that are never pressed
51
  kill_switch_path : if this file exists, the episode aborts before the next
52
  action — create it to stop a live run
 
 
 
 
 
53
  """
54
 
55
  dry_run: bool = True
@@ -58,6 +63,7 @@ class Policy:
58
  deny_labels: tuple[str, ...] = DEFAULT_DENY_LABELS
59
  deny_keys: tuple[str, ...] = DEFAULT_DENY_KEYS
60
  kill_switch_path: str | None = None
 
61
  _compiled: tuple[Pattern[str], ...] = field(default=(), init=False, repr=False)
62
 
63
  def __post_init__(self) -> None:
@@ -85,6 +91,15 @@ class Policy:
85
 
86
  if action.kind == "click" and action.label:
87
  element = observation.find(action.label) if observation is not None else None
 
 
 
 
 
 
 
 
 
88
  haystack = (element.label if element is not None else action.label).lower()
89
  for denied in self.deny_labels:
90
  if denied.lower() in haystack:
 
50
  deny_keys : key chords that are never pressed
51
  kill_switch_path : if this file exists, the episode aborts before the next
52
  action — create it to stop a live run
53
+ allow_grid_clicks : numbered grid cells are *blind* clicks: they carry no
54
+ meaning, only coordinates. Allowed while planning (a dry
55
+ run touches nothing), refused with real input unless this
56
+ is set, because "click cell 47" is a guess about your
57
+ desktop, not a decision about it.
58
  """
59
 
60
  dry_run: bool = True
 
63
  deny_labels: tuple[str, ...] = DEFAULT_DENY_LABELS
64
  deny_keys: tuple[str, ...] = DEFAULT_DENY_KEYS
65
  kill_switch_path: str | None = None
66
+ allow_grid_clicks: bool = False
67
  _compiled: tuple[Pattern[str], ...] = field(default=(), init=False, repr=False)
68
 
69
  def __post_init__(self) -> None:
 
91
 
92
  if action.kind == "click" and action.label:
93
  element = observation.find(action.label) if observation is not None else None
94
+ if not self.dry_run and not self.allow_grid_clicks:
95
+ is_grid = (element is not None and element.kind == "grid") or (
96
+ element is None and action.label.strip().lower().startswith("cell ")
97
+ )
98
+ if is_grid:
99
+ raise PolicyViolation(
100
+ "clicking a numbered grid cell is a blind click; install an OCR "
101
+ "engine or pass --allow-grid if you really mean it"
102
+ )
103
  haystack = (element.label if element is not None else action.label).lower()
104
  for denied in self.deny_labels:
105
  if denied.lower() in haystack: