hkblockchaintechnology commited on
Commit
13df892
·
verified ·
1 Parent(s): 6789bc2

Publish preview/ from marimo

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ preview/01-top.png filter=lfs diff=lfs merge=lfs -text
37
+ preview/02-gallery-head.png filter=lfs diff=lfs merge=lfs -text
38
+ preview/05-gallery.png filter=lfs diff=lfs merge=lfs -text
39
+ preview/06-gallery.png filter=lfs diff=lfs merge=lfs -text
40
+ preview/07-gallery.png filter=lfs diff=lfs merge=lfs -text
preview/01-top.png ADDED

Git LFS Details

  • SHA256: 7502c7a8d283de1fc4bf926c17615d1b597fdac2198184d48d4baf0023648c2e
  • Pointer size: 131 Bytes
  • Size of remote file: 129 kB
preview/02-gallery-head.png ADDED

Git LFS Details

  • SHA256: f836cf219a86b120379ab66ff280a21cf18af7464fbf2d9161f23e6c7cb58619
  • Pointer size: 131 Bytes
  • Size of remote file: 122 kB
preview/03-gallery.png ADDED
preview/04-gallery.png ADDED
preview/05-gallery.png ADDED

Git LFS Details

  • SHA256: cc6dd36356509c775c1ebd28c9883f2f0cf38ace553fefde10d4efefe116162d
  • Pointer size: 131 Bytes
  • Size of remote file: 104 kB
preview/06-gallery.png ADDED

Git LFS Details

  • SHA256: 1633485770043b75ab3cfd09b6b410554ace4d0264b6875a5c13662e9ee0c9af
  • Pointer size: 131 Bytes
  • Size of remote file: 106 kB
preview/07-gallery.png ADDED

Git LFS Details

  • SHA256: f9a55d178220e59e53fd8596b924a6f48ef2448b3e2f95d571eff1930bd9fdae
  • Pointer size: 131 Bytes
  • Size of remote file: 112 kB
preview/capture.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Screenshot the live model card page, including the gallery."""
2
+
3
+ from pathlib import Path
4
+
5
+ from playwright.sync_api import sync_playwright
6
+
7
+ URL = "https://huggingface.co/hkblockchaintechnology/tiny-computer-agent"
8
+ OUT = Path("preview")
9
+ OUT.mkdir(exist_ok=True)
10
+
11
+ with sync_playwright() as p:
12
+ browser = p.chromium.launch()
13
+ page = browser.new_page(viewport={"width": 1440, "height": 1080})
14
+ page.goto(URL, wait_until="load", timeout=120000)
15
+ page.wait_for_timeout(6000)
16
+ page.screenshot(path=str(OUT / "01-top.png"))
17
+
18
+ # walk down the card so every lazy image is fetched, then shoot each band
19
+ heading = page.locator("h2:has-text('Capability gallery')").first
20
+ heading.scroll_into_view_if_needed(timeout=25000)
21
+ page.wait_for_timeout(4000)
22
+ page.screenshot(path=str(OUT / "02-gallery-head.png"))
23
+ for index, step in enumerate([1400, 1500, 1600, 3000, 3000], start=3):
24
+ page.mouse.wheel(0, step)
25
+ page.wait_for_timeout(2200)
26
+ page.screenshot(path=str(OUT / f"{index:02d}-gallery.png"))
27
+ print("shot bands:", len(list(OUT.glob("*.png"))))
28
+ browser.close()
29
+ print("done")
preview/card.md ADDED
@@ -0,0 +1,1083 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ ---
4
+ title: Tiny Computer Agent
5
+ emoji: 🖱️
6
+ colorFrom: indigo
7
+ colorTo: green
8
+ license: mit
9
+ tags:
10
+ - computer-use
11
+ - gui-agent
12
+ - agentic-harness
13
+ - local-first
14
+ - tiny-models
15
+ - offline
16
+ - marimo
17
+ ---
18
+
19
+ # 🖱️ Tiny Computer Agent
20
+
21
+ **An agentic harness that drives a real computer with a tiny, fully local model.**
22
+ No cloud API, no API keys, no telemetry — and a core that imports nothing outside the
23
+ Python standard library.
24
+
25
+ > **Status 0.3.0: it drives a real desktop — on Linux and on Windows.** Screen capture,
26
+ > an OCR element index with real coordinates, a real mouse click, and a *verified*
27
+ > outcome. Every capability has a real screenshot in the [capability gallery](#capability-gallery), and a run can be saved as a portable *trace*, replayed offline and **scored**, so
28
+ > "which brain is right, and how often?" is a number in a table.
29
+ > The suite passes on a live Linux desktop and inside a real Windows Server
30
+ > 2022 guest; what is not solved is model reliability — see
31
+ > [measured results](#measured-results).
32
+
33
+ ## The idea, in one loop
34
+
35
+ ```
36
+ observe think act verify
37
+ +---------+ +---------+ +---------+ +---------+
38
+ | screen |--->| tiny |--->| mouse / |--->| next |
39
+ | frame | | model | | keyboard| | frame |
40
+ +---------+ +---------+ +---------+ +----+----+
41
+ |
42
+ <-------------------------------------------------+
43
+ repeat until done, or the step budget runs out
44
+ ```
45
+
46
+ 1. **Observe** — capture the screen, then build an **element index**: OCR (and,
47
+ optionally, the OS accessibility tree) locates text and widgets and gives each one a
48
+ label and a real screen coordinate.
49
+ 2. **Think** — the model is shown a *short numbered menu* built from that index and
50
+ answers with one number (plus a verb). It never emits coordinates.
51
+ 3. **Act** — the harness maps the chosen label back to pixels and executes it.
52
+ 4. **Verify** — re-read the screen; stop if the same action repeats against an unchanged
53
+ screen, and only call the episode a success if a verifier can see it worked.
54
+
55
+ ## Measured results
56
+
57
+ Everything below was run from this repo on CPU, dry-run unless stated. Nothing is
58
+ aspirational.
59
+
60
+ ### On a live desktop, with a real click
61
+
62
+ A real Tk window (a Save dialog with Save/Cancel buttons) on a **real X server**
63
+ (`Xvfb :99`), driven with real mouse input. The button sits at window `+120+120`,
64
+ `x=20, y=155`, `110x40`, so its centre is `(195, 295)`:
65
+
66
+ ```
67
+ $ python -m tiny_computer_agent --live --real-input --verify-text Saved "click 'Save' to save the document"
68
+ mode: REAL INPUT (mouse and keyboard live) | screen: real desktop (monitor 1) | brain: planner
69
+ index: auto - OCR when it finds text, grid otherwise (rapidocr-onnxruntime ready (CPU, offline))
70
+
71
+ step 0 | click label='Save' reason="matched 'Save' in the element index" | ok: clicked (195, 296)
72
+
73
+ result: success - verified after 1 action(s)
74
+ ```
75
+
76
+ The click landed on the button, the app changed its own window to "Saved", and the
77
+ verifier confirmed it before the episode was called a success. **Pixels in, a real click
78
+ out, and evidence that it worked.**
79
+
80
+ ### Re-confirmed end to end on a headless Linux box
81
+
82
+ The whole path above was re-run from scratch on a headless Debian container (gVisor
83
+ sandbox, 20 vCPU, no GPU, no physical display, no network at run time) behind a virtual
84
+ X server. First the suite, headless:
85
+
86
+ ```bash
87
+ python -m pip install -e ".[desktop,ocr,dev]"
88
+ pytest tests -q
89
+ # 162 passed, 2 skipped in 18.7s
90
+ ```
91
+
92
+ The two skips are the live-desktop tests. Give them a display and they run for real:
93
+
94
+ ```bash
95
+ Xvfb :99 -screen 0 1280x800x24 &
96
+ export DISPLAY=:99
97
+ TINY_COMPUTER_AGENT_LIVE_TEST=1 python -m pytest tests/test_live_desktop.py -q
98
+ # tests/test_live_desktop.py::test_ocr_finds_a_real_button_at_the_right_pixels PASSED
99
+ # tests/test_live_desktop.py::test_a_real_click_lands_on_the_button PASSED
100
+ # 2 passed
101
+ ```
102
+
103
+ Then the same desktop, driven through the CLI with the mouse actually live:
104
+
105
+ ```bash
106
+ export TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1
107
+ python -m tiny_computer_agent --live --real-input --verify-text Saved \
108
+ "click 'Save' to save the document"
109
+ ```
110
+
111
+ ```
112
+ goal: click 'Save' to save the document
113
+ mode: REAL INPUT (mouse and keyboard live) | screen: real desktop (monitor 1) | brain: planner | kill switch: create /tmp/tiny-computer-agent.STOP to stop
114
+ index: auto - OCR when it finds text, grid otherwise (rapidocr-onnxruntime ready (CPU, offline))
115
+
116
+ step 0 | click label='Save' reason="matched 'Save' in the element index" | ok: clicked (194, 296)
117
+
118
+ result: success - verified after 1 action(s)
119
+ ```
120
+
121
+ The window was drawn at `+120+120` with its Save button at `x=20, y=155, 110x40`, so the
122
+ button centre is `(195, 295)`. The click at `(194, 296)` is *on the button*, not near it —
123
+ and the app wrote `Save` back to its own marker file. Pixels in, a real click out, and the
124
+ app's own side effect as the proof.
125
+
126
+ `--doctor` on that box, with the virtual display attached:
127
+
128
+ ```
129
+ tiny-computer-agent preflight (Linux x86_64, python 3.13.11)
130
+
131
+ [ok ] display server: DISPLAY=:99
132
+ [ok ] pillow: installed
133
+ [ok ] screen capture: mss (fast, multi-monitor)
134
+ [ok ] input backend (pyautogui): installed
135
+ [ok ] element detector (auto): OCR when it finds text, grid otherwise (rapidocr-onnxruntime ready (CPU, offline))
136
+ [ok ] model runtime: not needed: the planner is rule-based (no weights, no download)
137
+ [ok ] execution mode: dry run (nothing is clicked)
138
+ [ok ] kill switch: create /tmp/tiny-computer-agent.STOP to abort the next action
139
+
140
+ READY: the harness can drive this desktop.
141
+ ```
142
+
143
+
144
+ ### On a replayed screenshot
145
+
146
+ ```
147
+ $ python -m tiny_computer_agent --image example --brain planner
148
+ elements detected: 5
149
+ step 0 | click label='Save' ... -> pixel (295, 284) # OCR found the button
150
+ ```
151
+
152
+ **SmolVLM-256M-Instruct** (256M parameters, CPU) read the button off the pixels and
153
+ named the right target:
154
+
155
+ ```
156
+ step 0 | click label='Save' reason="the reply names 'Save'" -> pixel (295, 284)
157
+ ```
158
+
159
+ ### Scoring a brain, offline
160
+
161
+ "Which brain is right, and how often?" needs no desktop, no model and no network,
162
+ because a **trace** keeps every frame's element index and throws the pixels away. Record a
163
+ run once; replay it against any brain, as often as you like.
164
+
165
+ ```
166
+ $ python -m tiny_computer_agent --image example --trace runs/save.json --expected Save
167
+ ...
168
+ trace: wrote runs/save.json (2 frames)
169
+ score: correct click 'Save' to save the document: expected 'Save', chose 'Save'
170
+ ```
171
+
172
+ A trace is JSON, so it diffs in review, commits next to the tests, and replays on a machine
173
+ with nothing installed. Scoring a whole suite is a table:
174
+
175
+ ```
176
+ scoreboard: 7/9 correct (78%)
177
+ planner (rule-based): 100%
178
+ first-item (baseline): 33%
179
+ oracle (always right): 100%
180
+ correct save the document: expected 'Save', chose 'Save'
181
+ incorrect save the document: expected 'Save', chose 'Files' (chose 'Files', not 'Save')
182
+ ...
183
+ ```
184
+
185
+ The number is the brain's **decision** - what it chose when it had to commit. Whether the
186
+ harness *allowed* that decision to run is a separate axis: a click the policy refused is
187
+ still the choice it was, and it is flagged `(harness refused it)` rather than passed off as
188
+ a clean success. Keeping those two apart is the point.
189
+
190
+ ### Which brain drove which run
191
+
192
+ The harness and the brain are separable, and it matters which one a result came from.
193
+ None of the runs below used a cloud API; the two model brains run on CPU:
194
+
195
+ | Run | Brain | Weights | Outcome |
196
+ |---|---|---|---|
197
+ | Live Linux desktop, real click | `PlannerBrain` (rule-based) | none | **success, verified** |
198
+ | `/tmp/saveapp.py` window live | `PlannerBrain` | none | clicked `Save` at `(194, 296)` |
199
+ | `--image example` | `PlannerBrain` | none | picked `Save`, pixel `(295, 284)` |
200
+ | `--image example` | `VLMBrain` (SmolVLM-256M-Instruct) | 256M, CPU | picked `Save` |
201
+ | Live Linux desktop | `VLMBrain` (SmolVLM-256M-Instruct) | 256M, CPU | echoed the prompt; **refused** by echo detection |
202
+ | Live Linux desktop | `TextPlannerBrain` (Qwen2.5-0.5B) | 0.5B, CPU | clicked Cancel, then garbled JSON; budget exhausted |
203
+
204
+ The rule-based planner is what carries the verified end-to-end runs. The tiny model is the
205
+ open problem: it is reliable on a clean screenshot and unreliable on a busy live screen.
206
+
207
+ ### What did *not* work, and what caught it
208
+
209
+ - **The 256M VLM echoes the prompt on a busy live screen.** Asked to click Save on the
210
+ live Tk window it replied with the prompt text back. The harness's echo detection
211
+ refused it (`BrainError: the reply mentions 4 menu entries, so it looks like an echo of
212
+ the prompt`) and stopped — **no blind click**. On the cleaner synthetic screenshot the
213
+ same model chose correctly, so this is a prompt/robustness problem, not a wiring one.
214
+ - **Qwen2.5-0.5B text planner over the OCR index is not reliable**: it clicked Cancel
215
+ before Save, then recovered a garbled JSON reply, then found Save — budget exhausted.
216
+ - **The grid fallback announced itself.** After the dialog closed, OCR found no text, the
217
+ index degraded to 96 numbered cells, and the run printed
218
+ `warning: no text was found on screen, so the index is 96 numbered grid cells and the
219
+ agent is choosing blind` — instead of silently guessing.
220
+
221
+ **164 tests pass**, including real OCR over a real image and (opt-in) live-desktop tests
222
+ that click a real button.
223
+
224
+ ## Verified on real Windows
225
+
226
+ The Windows claims in this card are not taken on faith. A full Windows guest was built
227
+ and the harness installed inside it, on a headless Linux host with **no KVM** — the same
228
+ kind of sandbox the Linux runs above were produced in.
229
+
230
+ **What was built.**
231
+
232
+ ```
233
+ host headless Linux container (no /dev/kvm; software emulation only)
234
+ hypervisor QEMU 10.0.13 -accel tcg,thread=multi -m 8G -smp 8 -cpu Skylake-Client
235
+ firmware SeaBIOS (the OVMF/UEFI build would not auto-boot the installation media)
236
+ guest Windows Server 2022 Standard Evaluation (Desktop Experience), x86_64
237
+ install unattended from the public evaluation ISO, install.wim index 2,
238
+ answer file on removable media, autologon as Administrator
239
+ ```
240
+
241
+ The image-apply phase moved roughly 9 GB to disk in about thirteen minutes, and the
242
+ desktop was up a few minutes after that.
243
+
244
+ **What was installed inside it.**
245
+
246
+ ```
247
+ Python 3.12.8 (embeddable distribution, unpacked to C:\tiny\py)
248
+ pytest, pillow 13.0.0, mss 10.2.0
249
+ numpy 2.5.4, onnxruntime 1.31.1, opencv-python 5.0.0.93, rapidocr-onnxruntime 1.4.4
250
+ the harness itself, installed into site-packages
251
+ ```
252
+
253
+ **Results.**
254
+
255
+ | Configuration | Result | Wall clock |
256
+ |---|---|---|
257
+ | Harness suite, no OCR engine | **118 passed, 3 failed, 4 skipped** | 7.8 s |
258
+ | Harness suite, OCR engine installed, `test_perception.py` excluded | **109 passed, 3 skipped, exit 0** | 21 min |
259
+ | `test_perception.py` (OCR inference) | runs; roughly ten minutes per OCR pass | — |
260
+
261
+ None of the three failures in the first row is a harness defect: two are
262
+ `ModuleNotFoundError: numpy` and the third is the guard bug documented below. With the
263
+ OCR engine installed, the same suite exits **0**. (Those Windows counts predate `trace.py`; the harness suite has since grown to 164 tests.)
264
+
265
+ **The emulated-OCR caveat.** onnxruntime requires AVX2, and QEMU's software emulation of
266
+ AVX2 is roughly two orders of magnitude slower than native. A suite that finishes in 17
267
+ seconds on Linux takes over half an hour on this guest, and the time is spent entirely
268
+ inside `onnxruntime_inference_collection.py`. That is a property of emulation, not of the
269
+ harness: on real hardware — including a GitHub `windows-latest` runner — it does not
270
+ apply.
271
+
272
+ ### What running on Windows caught
273
+
274
+ Four things surfaced by actually running there. Three are genuine deployment requirements
275
+ that no Linux-only test run can reveal:
276
+
277
+ | Finding | Symptom | Fix |
278
+ |---|---|---|
279
+ | **onnxruntime needs AVX2** | `Windows fatal exception: code 0xc000001d` (`STATUS_ILLEGAL_INSTRUCTION`) the moment `onnxruntime` is imported, because QEMU's default `qemu64` CPU advertises no AVX | boot the guest with `-cpu Skylake-Client` (any AVX2 model will do) |
280
+ | **onnxruntime needs the MSVC runtime** | `ImportError: DLL load failed while importing onnxruntime_pybind11_state` on a clean Server 2022 | install `vc_redist.x64.exe` in the guest |
281
+ | **`pyautogui` ships as an sdist only** | `pip install pytest pillow mss pyautogui` fails as a whole, so `pytest` never arrives and the suite reports `No module named pytest` | install `pyautogui` in a separate step from the wheel-only packages |
282
+ | **A guard that never fires** | `test_screenshot_replay_needs_no_display` fails instead of skipping when no OCR engine is present: it guards on `detector_available()[0]`, which is `True` for the grid fallback | guard on the OCR detector specifically |
283
+
284
+ The last entry is a defect in this repository rather than in the environment, and it is
285
+ exactly the kind of thing that only surfaces on a machine without an OCR engine.
286
+
287
+ ## The test suite, in full
288
+
289
+ `pytest tests` collects **164 tests**. 162 run headless — no display, no network, no
290
+ weights. The last 2 are the opt-in live-desktop tests, skipped unless
291
+ `TINY_COMPUTER_AGENT_LIVE_TEST=1`.
292
+
293
+ | File | Tests | What it proves |
294
+ |---|---:|---|
295
+ | `tests/test_actions.py` | 13 | the closed vocabulary — every action is validated before anything executes |
296
+ | `tests/test_act.py` | 8 | `DryRunExecutor` is inert; the real executor refuses until explicitly armed |
297
+ | `tests/test_observe.py` | 8 | element/observation geometry, `FakeScreen`, `ImageScreen`, the capture fallback |
298
+ | `tests/test_perception.py` | 13 | OCR labels a real image with real coordinates; the grid fallback; `AutoDetector` |
299
+ | `tests/test_brain.py` | 18 | the numbered `DecisionMenu`, lenient parsing, echo detection, `MockBrain`, `PlannerBrain` |
300
+ | `tests/test_loop.py` | 14 | `AgentLoop` / `Episode` / `Step`, the no-progress guard, the step budget |
301
+ | `tests/test_verify.py` | 7 | success is only claimed when a verifier can actually see it |
302
+ | `tests/test_safety.py` | 11 | deny lists, the kill switch, the blind-grid-click guard |
303
+ | `tests/test_grid_fallback.py` | 11 | the fallback index announces itself instead of guessing |
304
+ | `tests/test_desktop.py` | 10 | display detection, the preflight checks, `doctor` |
305
+ | `tests/test_cli.py` | 10 | the command line: `--doctor`, `--image`, `--live`, argument handling |
306
+ | `tests/test_trace.py` | 28 | traces: a run recorded with no pixels, replayed against another brain, and scored against ground truth |
307
+ | `tests/test_models.py` | 7 | the model backends: lazy loading, device choice, honest refusals |
308
+ | `tests/test_api_surface.py` | 4 | the public API is a promise: everything advertised is importable, and nothing public is left out |
309
+ | `tests/test_live_desktop.py` | 2 | **needs a display, opt-in**: OCR finds a real button at the pixels it was drawn at, and a real click makes the app react |
310
+
311
+ ```bash
312
+ pytest tests -q # 162 passed, 2 skipped
313
+ TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q # 164 passed, on a desktop
314
+ ```
315
+
316
+ ## Install
317
+
318
+ ```bash
319
+ pip install -e ".[ocr]" # labels from pixels (RapidOCR: no system binary)
320
+ pip install -e ".[desktop,ocr]" # + real capture and real input
321
+ pip install -e ".[vision,ocr]" # + the tiny model brains
322
+ pytest # 164 tests, no display or network needed
323
+ ```
324
+
325
+ ## Run it on your machine
326
+
327
+ ```bash
328
+ python -m tiny_computer_agent --doctor # what can this machine do?
329
+ python -m tiny_computer_agent --image example # replay a screenshot, dry run
330
+ python -m tiny_computer_agent --live # your real screen, dry run
331
+ python -m tiny_computer_agent --live --real-input "click 'OK'"
332
+ python -m tiny_computer_agent --live --real-input --verify-text "Saved" "click 'Save'"
333
+ ```
334
+
335
+ `--doctor` is the first thing to run on a new machine: it checks the display session,
336
+ capture backend, pyautogui, the OCR engine, the model runtime and the kill switch, and
337
+ prints the fix for anything missing. Real input needs **two** deliberate acts:
338
+
339
+ ```bash
340
+ export TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1
341
+ ```
342
+
343
+ **Pass `--verify-text`.** Without it, a successful action that removes its own target
344
+ (clicking Save closes the dialog) is indistinguishable from a failure, and the episode is
345
+ recorded as one. With it, the harness only reports success when it can see the text it
346
+ was given.
347
+
348
+ ### No desktop? Use a virtual one
349
+
350
+ Xvfb is enough for everything except a real model's hallucinations:
351
+
352
+ ```bash
353
+ Xvfb :99 -screen 0 1280x800x24 &
354
+ export DISPLAY=:99
355
+ TINY_COMPUTER_AGENT_LIVE_TEST=1 python -m pytest tests/test_live_desktop.py
356
+ ```
357
+
358
+ That test starts its own Tk window, asserts OCR finds the button at the coordinates it
359
+ was actually drawn at, clicks it for real, and checks the app reacted. It is opt-in
360
+ because it moves your mouse — run it on a throwaway desktop.
361
+
362
+ ### Testing on Windows - three ways, and which to use
363
+
364
+ The verified Windows run above used method 2. All three remain valid:
365
+
366
+ | Method | Real Windows desktop? | Cost | Status |
367
+ |---|---|---|---|
368
+ | **GitHub Actions `windows-latest`** | yes, interactive session | free for public repos, minutes | workflow committed at `.github/workflows/live-desktop.yml` |
369
+ | **QEMU/TCG VM on a Linux host** | yes, fully emulated | ~25 min to install, hours to run OCR | **used for the results above** |
370
+ | **A cloud Windows box over RDP** | yes, native | paid by the hour | untested here |
371
+
372
+ Nothing in the harness is Linux-specific: `pyautogui` uses `SendInput` on Windows and
373
+ `GrabScreen` already falls back to Pillow's `ImageGrab` there. Two things can only fail on
374
+ a real Windows desktop, which is why these runs exist: **DPI scaling** (that is what
375
+ `--scale` is for) and the native input backend. At 125% or 150% display scaling, expect the
376
+ coordinate assertion in `test_live_desktop.py` to be the first thing to fail - that is the
377
+ signal, not a nuisance.
378
+
379
+ **Reproducing the local VM.** With no KVM, a Windows guest is still perfectly possible -
380
+ it is simply slow. The working recipe, including the two ingredients that onnxruntime
381
+ demands:
382
+
383
+ ```bash
384
+ # 1. install media, plus an answer file on removable media
385
+ qemu-img create -f qcow2 win.qcow2 64G
386
+ qemu-system-x86_64 -accel tcg,thread=multi -m 8G -smp 8 -machine pc \
387
+ -cpu Skylake-Client \
388
+ -drive file=win.qcow2,if=none,id=hd0,format=qcow2,cache=writeback \
389
+ -device ide-hd,drive=hd0,bus=ide.0 \
390
+ -drive file=win2022.iso,if=none,id=cd0,media=cdrom,readonly=on \
391
+ -device ide-cd,drive=cd0,bus=ide.1 \
392
+ -drive if=none,id=usb,format=raw,file=unattend.img \
393
+ -device usb-ehci,id=ehci -device usb-storage,bus=ehci.0,drive=usb \
394
+ -netdev user,id=n0 -device e1000,netdev=n0 \
395
+ -vga std -display none -vnc :1 -boot order=d,menu=off
396
+
397
+ # 2. inside the guest, after autologon
398
+ # vc_redist.x64.exe is required before onnxruntime will load
399
+ curl.exe -s -o C:\tiny\vc_redist.x64.exe <vc_redist-url> && C:\tiny\vc_redist.x64.exe /install /quiet /norestart
400
+ ```
401
+
402
+ Serve files to the guest over QEMU's user-mode networking (the host is `10.0.2.2`), which
403
+ avoids typing long commands into the emulated console. Two practical notes from doing
404
+ this: an answer file with non-canonical XML namespaces is **silently ignored** by Setup
405
+ (you land on the language page), and keyboard input over VNC **drops the shift modifier**
406
+ (`_` becomes `-`, `:` becomes `;`) - the QEMU monitor's `sendkey` handles shifts correctly.
407
+
408
+
409
+ ## How it works
410
+
411
+ | Module | Job |
412
+ |---|---|
413
+ | `actions.py` | The closed vocabulary — `click` `type` `key` `scroll` `wait` `done`, validated before anything executes |
414
+ | `observe.py` | `Element`/`Observation`, `FakeScreen`, `ImageScreen`, `GrabScreen` (mss → Pillow fallback) |
415
+ | `perception.py` | Detectors: `OcrDetector` (RapidOCR), `GridDetector`, `AutoDetector`, `IndexedScreen` |
416
+ | `brain.py` | `DecisionMenu` (the numbered menu), `ModelBrain`, lenient `parse_decision`, echo detection, `MockBrain`, `PlannerBrain` |
417
+ | `models.py` | `VLMBrain` (SmolVLM-256M) and `TextPlannerBrain` (Qwen2.5-0.5B), lazily loaded |
418
+ | `act.py` | `DryRunExecutor` (the default) and `PyAutoGuiExecutor` |
419
+ | `safety.py` | `Policy`: deny lists, kill switch, step budget, the blind-grid-click guard |
420
+ | `loop.py` | `AgentLoop`, `Episode`, `Step`, the no-progress guard and the verifier |
421
+ | `desktop.py` | Preflight checks, `doctor`, and `build_agent` in one call |
422
+ | `demo.py` | The synthetic desktop used by tests and `--image example` |
423
+ | `trace.py` | Portable traces: record a run with no pixels, replay it offline, score the choice against ground truth |
424
+ | `showcase.py` | The capability gallery: one real screenshot per capability, checked against the public API |
425
+
426
+ ## Capability gallery
427
+
428
+ **68 capabilities, one screenshot each - and every one of the 141 public callables in the package (functions, methods, classes and protocols) appears in at least one of them**, together with 6 of its public constants.
429
+
430
+ Nothing here is a mock-up. Each picture is produced by [`showcase.py`](showcase.py): a command or a call is run for real, and its actual output - or the real desktop it acted on - is what you see. The desktop shots are real screen captures on a real X server (Xvfb, 1280x800), including a real mouse click verified by the target application's own reaction.
431
+
432
+ > **An honest note on the platform.** These were captured on Linux, because the sandbox that builds this card has no Windows: no KVM, no QEMU and no Windows image. The harness is OS-agnostic (pyautogui uses `SendInput` on Windows, and `GrabScreen` falls back to Pillow's `ImageGrab`), so these are the same code paths - but they are not Windows pixels. The Windows twins are generated by the same script in [`.github/workflows/live-desktop.yml`](.github/workflows/live-desktop.yml).
433
+
434
+ Regenerate it with `python showcase.py` (`--list` to see the ids, `--only <id>` for a subset, `--card` to rewrite this section). It runs the calls for real, so a broken capability fails loudly instead of shipping a stale picture.
435
+
436
+ | area | capabilities |
437
+ |---|---:|
438
+ | Action vocabulary | 3 |
439
+ | Decisions | 7 |
440
+ | Execution | 2 |
441
+ | The loop | 7 |
442
+ | Safety | 4 |
443
+ | Screens | 6 |
444
+ | Perception | 6 |
445
+ | Preflight | 4 |
446
+ | Models | 5 |
447
+ | Traces & scoring | 7 |
448
+ | Command line | 9 |
449
+ | Real desktop | 7 |
450
+ | Extensibility | 1 |
451
+
452
+ ---
453
+
454
+ ### Action vocabulary
455
+
456
+ **The six verbs, and only six**
457
+
458
+ *Every action is validated on construction, before anything touches the machine.*
459
+
460
+ <img src="gallery/vocab-verbs.png" width="860">
461
+
462
+ `shows:` `ACTION_KINDS`, `ACTION_MENU`, `Action`, `Action.describe`, `Action.to_dict`, `parse_action`
463
+
464
+ **Malformed actions are refused, not guessed at**
465
+
466
+ *An unknown verb or a missing target raises before the machine is touched.*
467
+
468
+ <img src="gallery/vocab-refusals.png" width="860">
469
+
470
+ `shows:` `ActionError`, `Action`, `parse_action`
471
+
472
+ **The contract handed to a model**
473
+
474
+ *The prompt-side menu: one JSON object, six shapes, no invented verbs.*
475
+
476
+ <img src="gallery/vocab-menu.png" width="860">
477
+
478
+ `shows:` `ACTION_MENU`
479
+
480
+ ### Decisions
481
+
482
+ **A short numbered menu, in reading order**
483
+
484
+ *A 256M model cannot emit coordinates; it can pick a number from twelve.*
485
+
486
+ <img src="gallery/menu-numbered.png" width="860">
487
+
488
+ `shows:` `DecisionMenu`, `DecisionMenu.render`, `MenuItem`, `MenuItem.label`
489
+
490
+ **Menu lookup tolerates what models actually send**
491
+
492
+ *1, "2", 2.0 and "Save." all resolve; a stray word resolves to nothing.*
493
+
494
+ <img src="gallery/menu-lookup.png" width="860">
495
+
496
+ `shows:` `DecisionMenu.item`, `DecisionMenu.find`, `DecisionMenu.mentioned_in`
497
+
498
+ **Reading a decision out of a small model's reply**
499
+
500
+ *The LAST JSON object wins, because a model that echoes its prompt answers last.*
501
+
502
+ <img src="gallery/decision-json.png" width="860">
503
+
504
+ `shows:` `parse_decision`, `extract_json_object`, `extract_json_objects`, `DecisionMenu`
505
+
506
+ **An echoed prompt is refused; a right answer in the wrong field is recovered**
507
+
508
+ *Real 256M/0.5B failure modes, both caught: no blind click either way.*
509
+
510
+ <img src="gallery/decision-echo-guard.png" width="860">
511
+
512
+ `shows:` `parse_decision`, `BrainError`, `DecisionMenu.mentioned_in`
513
+
514
+ **A garbled verb with a valid label is still usable**
515
+
516
+ *A 256M model really does emit {"kind": "file", "label": "Save"}.*
517
+
518
+ <img src="gallery/decision-garbled.png" width="860">
519
+
520
+ `shows:` `parse_decision`, `BrainError`
521
+
522
+ **MockBrain: replay a fixed script, exactly once**
523
+
524
+ *Deterministic and offline - the workhorse for tests, demos and replays.*
525
+
526
+ <img src="gallery/brain-mock.png" width="860">
527
+
528
+ `shows:` `MockBrain`, `MockBrain.decide`, `MockBrain.remaining`
529
+
530
+ **PlannerBrain: no weights, no download, and it gives up honestly**
531
+
532
+ *The proof the harness works end to end, and a real fallback when no model is wanted.*
533
+
534
+ <img src="gallery/brain-planner.png" width="860">
535
+
536
+ `shows:` `PlannerBrain`, `PlannerBrain.decide`, `GoalUnreachable`
537
+
538
+ ### Execution
539
+
540
+ **Dry run resolves every click to pixels - and touches nothing**
541
+
542
+ *The default everywhere: read a whole episode before anything clicks.*
543
+
544
+ <img src="gallery/exec-dry-run.png" width="860">
545
+
546
+ `shows:` `DryRunExecutor`, `DryRunExecutor.execute`, `resolve_point`, `ActionResult`
547
+
548
+ **Real input refuses to guess a pixel for a label it cannot find**
549
+
550
+ *This is the gate between a hallucinated label and 'Delete account'.*
551
+
552
+ <img src="gallery/exec-refuses-unresolved.png" width="860">
553
+
554
+ `shows:` `ActionResult.summary`, `InputUnavailable`, `PyAutoGuiExecutor`, `PyAutoGuiExecutor.execute`
555
+
556
+ ### The loop
557
+
558
+ **observe -> think -> act -> verify, traced end to end**
559
+
560
+ *One call runs a whole episode; every step is kept for audit.*
561
+
562
+ <img src="gallery/loop-end-to-end.png" width="860">
563
+
564
+ `shows:` `AgentLoop`, `AgentLoop.run`, `Episode`, `Episode.render`, `Episode.to_rows`, `Step`, `Step.line`, `Step.ok`
565
+
566
+ **A hard step budget, so a run cannot wander**
567
+
568
+ *The budget ends the episode and says so; nothing executes past it.*
569
+
570
+ <img src="gallery/loop-budget.png" width="860">
571
+
572
+ `shows:` `Policy`, `Policy.step_budget`, `AgentLoop.run`
573
+
574
+ **Repeating one action against an unchanged screen is stopped**
575
+
576
+ *A stuck agent is not a persistent one - it is stopped and told why.*
577
+
578
+ <img src="gallery/loop-no-progress-guard.png" width="860">
579
+
580
+ `shows:` `AgentLoop.run`, `Step`, `Step.error`
581
+
582
+ **A kill-switch file aborts before the next action**
583
+
584
+ *Checked before every single action: create the file, the run stops.*
585
+
586
+ <img src="gallery/loop-kill-switch.png" width="860">
587
+
588
+ `shows:` `Policy.kill_switch_path`, `Policy.should_stop`, `AgentLoop.run`
589
+
590
+ **A dead screen or a broken brain is an episode, not a traceback**
591
+
592
+ *Every failure path is recorded as a failed step with a reason.*
593
+
594
+ <img src="gallery/loop-survives-failures.png" width="860">
595
+
596
+ `shows:` `AgentLoop.run`, `Episode.stop_reason`, `GoalUnreachable`, `BrainError`
597
+
598
+ **Success is evidence, not the model's word**
599
+
600
+ *Without a verifier, a successful action that removes its own target looks like a failure.*
601
+
602
+ <img src="gallery/loop-verifier.png" width="860">
603
+
604
+ `shows:` `AgentLoop.run`, `Episode.success`, `Observation.text`
605
+
606
+ **Every step can be streamed out as it happens**
607
+
608
+ *This is the hook a live console, a progress bar or a websocket uses.*
609
+
610
+ <img src="gallery/loop-on-step.png" width="860">
611
+
612
+ `shows:` `AgentLoop.run`, `Step.line`, `on_step`
613
+
614
+ ### Safety
615
+
616
+ **Typing from the deny list never reaches the keyboard**
617
+
618
+ *Checked before execution; a refusal ends the episode and clicks nothing.*
619
+
620
+ <img src="gallery/safety-deny-text.png" width="860">
621
+
622
+ `shows:` `Policy`, `Policy.check`, `PolicyViolation`, `DEFAULT_DENY_TEXT`
623
+
624
+ **Clicking a denied widget is refused, however the label is spelled**
625
+
626
+ *Terminal, keychain, wallet, password manager, bank - matched on the label.*
627
+
628
+ <img src="gallery/safety-deny-label.png" width="860">
629
+
630
+ `shows:` `Policy.deny_labels`, `Policy.check`, `Observation.find`
631
+
632
+ **Key chords on the deny list are never pressed**
633
+
634
+ *ctrl+alt+t, ctrl+alt+delete and ctrl+shift+escape are refused by default.*
635
+
636
+ <img src="gallery/safety-deny-keys.png" width="860">
637
+
638
+ `shows:` `Policy.deny_keys`, `Policy.check`, `PolicyViolation`
639
+
640
+ **A numbered grid cell is a blind click: allowed to plan, refused for real**
641
+
642
+ *With real input the blind guess is refused unless you pass --allow-grid.*
643
+
644
+ <img src="gallery/safety-blind-grid.png" width="860">
645
+
646
+ `shows:` `Policy.allow_grid_clicks`, `Policy.check`, `GridDetector`
647
+
648
+ ### Screens
649
+
650
+ **A scripted screen, for testing the loop with no display**
651
+
652
+ *The final frame repeats forever, so a runaway agent cannot exhaust the script.*
653
+
654
+ <img src="gallery/screen-fake.png" width="860">
655
+
656
+ `shows:` `FakeScreen`, `FakeScreen.capture`, `Observation`
657
+
658
+ **A still image (or a sequence) replayed as a screen**
659
+
660
+ *Lets the whole pipeline be exercised with no display, and a failure be replayed.*
661
+
662
+ <img src="gallery/screen-image.png" width="860">
663
+
664
+ `shows:` `ImageScreen`, `ImageScreen.capture`, `fake_desktop`
665
+
666
+ **Real capture: monitors, downscaling, and HiDPI mapping**
667
+
668
+ *Frame pixel -> screen pixel is preserved, so a click cannot drift.*
669
+
670
+ <img src="gallery/capture-options.png" width="860">
671
+
672
+ `shows:` `GrabScreen`, `GrabScreen.capture`, `GrabScreen.map_to_screen`, `ScreenUnavailable`
673
+
674
+ **Labels are compared on their alphanumeric core**
675
+
676
+ *OCR loses spaces, models add punctuation - so nothing matches by exact string.*
677
+
678
+ <img src="gallery/element-matching.png" width="860">
679
+
680
+ `shows:` `Element`, `Element.center`, `Element.matches`, `Observation.find`, `normalise_label`
681
+
682
+ **The compact text a model is shown**
683
+
684
+ *This is the whole world as the brain sees it - capped, and honest when empty.*
685
+
686
+ <img src="gallery/observation-summary.png" width="860">
687
+
688
+ `shows:` `Observation.summary`
689
+
690
+ **The built-in synthetic desktop**
691
+
692
+ *Draws a screenshot-like desktop offline - the same image --image example replays.*
693
+
694
+ <img src="gallery/fake-desktop.png" width="860">
695
+
696
+ `shows:` `fake_desktop`, `write_fake_desktop`, `OcrDetector`
697
+
698
+ ### Perception
699
+
700
+ **Probing for a detector, and building one by name**
701
+
702
+ *The doctor and the CLI both ask this before promising anything.*
703
+
704
+ <img src="gallery/detector-available.png" width="860">
705
+
706
+ `shows:` `detector_available`, `make_detector`, `DetectorUnavailable`, `Detector`
707
+
708
+ **OCR turns a screenshot into labelled, clickable elements**
709
+
710
+ *The model never has to guess where anything is: this is the grounding.*
711
+
712
+ <img src="gallery/ocr-labels.png" width="860">
713
+
714
+ `shows:` `OcrDetector`, `OcrDetector.detect`, `Element`
715
+
716
+ **Three knobs keep dialog prose out of the menu**
717
+
718
+ *Every extra line of noise is one more chance for a small model to click prose.*
719
+
720
+ <img src="gallery/ocr-filters.png" width="860">
721
+
722
+ `shows:` `OcrDetector.min_score`, `OcrDetector.max_label_chars`, `OcrDetector.min_label_chars`
723
+
724
+ **A numbered grid, for screens with no text at all**
725
+
726
+ *An agent with an empty index has no legal moves; a grid always has moves.*
727
+
728
+ <img src="gallery/grid-detector.png" width="860">
729
+
730
+ `shows:` `GridDetector`, `GridDetector.detect`
731
+
732
+ **OCR when there is text, a grid when there is none**
733
+
734
+ *The default: useful with nothing installed, better the moment OCR appears.*
735
+
736
+ <img src="gallery/auto-detector.png" width="860">
737
+
738
+ `shows:` `AutoDetector`, `AutoDetector.detect`
739
+
740
+ **IndexedScreen: capture + detect, composed**
741
+
742
+ *One capture() call gives the loop an index it can act on.*
743
+
744
+ <img src="gallery/indexed-screen.png" width="860">
745
+
746
+ `shows:` `IndexedScreen`, `IndexedScreen.capture`, `Observation.summary`
747
+
748
+ ### Preflight
749
+
750
+ **Is there a desktop session to drive at all?**
751
+
752
+ *The first question the CLI and the doctor both ask.*
753
+
754
+ <img src="gallery/display-available.png" width="860">
755
+
756
+ `shows:` `display_available`
757
+
758
+ **Every precondition, one list, each with the fix**
759
+
760
+ *Eight checks; a required one that fails is a blocking problem.*
761
+
762
+ <img src="gallery/run-checks.png" width="860">
763
+
764
+ `shows:` `run_checks`, `Check`, `Check.render`
765
+
766
+ **python -m tiny_computer_agent --doctor**
767
+
768
+ *The first thing to run on a new machine. Exits non-zero when not ready.*
769
+
770
+ <img src="gallery/cli-doctor.png" width="860">
771
+
772
+ `shows:` `detector_available`, `display_available`, `doctor`, `report`
773
+
774
+ **One call wires screen + detector + brain + executor + policy**
775
+
776
+ *Defaults are the safe ones: dry run, a step budget, and a kill-switch file.*
777
+
778
+ <img src="gallery/build-agent.png" width="860">
779
+
780
+ `shows:` `build_agent`, `Policy`, `AgentLoop`
781
+
782
+ ### Models
783
+
784
+ **Which model runtime is present, and are the weights cached?**
785
+
786
+ *Weights are loaded lazily, so asking is free.*
787
+
788
+ <img src="gallery/models-deps.png" width="860">
789
+
790
+ `shows:` `model_dependencies`, `model_cached`, `MODEL_BRAINS`, `make_model_brain`, `DEFAULT_TEXT_MODEL`, `DEFAULT_VISION_MODEL`, `BrainError`
791
+
792
+ **Device and dtype selection**
793
+
794
+ *CUDA if it is there, CPU otherwise; the harness never needs a GPU.*
795
+
796
+ <img src="gallery/models-device.png" width="860">
797
+
798
+ `shows:` `resolve_device`, `pick_dtype`
799
+
800
+ **The prompt a model actually receives - and how it learns from a dead end**
801
+
802
+ *Placeholders, not worked examples: a weak model echoes the prompt, and an echo fails validation.*
803
+
804
+ <img src="gallery/models-prompt.png" width="860">
805
+
806
+ `shows:` `ModelBrain`, `ModelBrain.build_prompt`, `ModelBrain.decide`
807
+
808
+ **A second decision is told what already failed**
809
+
810
+ *'you already tried this and the screen did NOT change' is the most useful line a small model gets.*
811
+
812
+ <img src="gallery/models-prompt-history.png" width="860">
813
+
814
+ `shows:` `ModelBrain`, `ModelBrain.build_prompt`, `ModelBrain.history`
815
+
816
+ **The two real model backends, and lazy loading**
817
+
818
+ *Constructing a brain costs nothing; the weights arrive on the first decide().*
819
+
820
+ <img src="gallery/models-swap.png" width="860">
821
+
822
+ `shows:` `TextPlannerBrain`, `VLMBrain`, `TextPlannerBrain.unload`, `VLMBrain.unload`, `ModelBrain.loaded`, `make_model_brain`
823
+
824
+ ### Traces & scoring
825
+
826
+ **A task with one right answer, and the frames to show**
827
+
828
+ *Ground truth lives with the task, so a run can be scored at all.*
829
+
830
+ <img src="gallery/scenario.png" width="860">
831
+
832
+ `shows:` `Scenario`, `Scenario.label`, `Scenario.screen`, `Trace.render`, `run_scenario`
833
+
834
+ **An episode becomes a portable, pixel-free trace**
835
+
836
+ *JSON, so it diffs in review and replays on a machine with nothing installed.*
837
+
838
+ <img src="gallery/trace-record.png" width="860">
839
+
840
+ `shows:` `Trace`, `Trace.blocked`, `Trace.choice`, `Trace.decided`, `Trace.decision_step`, `Trace.frames`, `Trace.load`, `Trace.name`, `Trace.record`, `Trace.save`, `Trace.to_dict`, `Trace.to_json`, `TraceStep`, `TraceStep.observation`, `TraceStep.to_dict`
841
+
842
+ **Replay a recorded trace against a different brain**
843
+
844
+ *The frames are the fixture; the brain is the only variable. Nothing is captured or clicked.*
845
+
846
+ <img src="gallery/trace-replay.png" width="860">
847
+
848
+ `shows:` `Trace.replay`, `Trace.observations`, `score_trace`
849
+
850
+ **Counting how often the choice was right**
851
+
852
+ *Scored on normalised text, so OCR spacing is forgiven - the same bargain the index makes.*
853
+
854
+ <img src="gallery/score-trace.png" width="860">
855
+
856
+ `shows:` `score_trace`, `score`, `score_replay`, `Verdict`, `Verdict.line`
857
+
858
+ **Verdicts collected into an accuracy**
859
+
860
+ *The number the whole module exists for: which brain is right, and how often.*
861
+
862
+ <img src="gallery/scoreboard.png" width="860">
863
+
864
+ `shows:` `Scoreboard`, `Scoreboard.accuracy`, `Scoreboard.accuracy_for`, `Scoreboard.add`, `Scoreboard.blocked`, `Scoreboard.brains`, `Scoreboard.by_brain`, `Scoreboard.confusion`, `Scoreboard.earned`, `Scoreboard.render`, `Scoreboard.to_rows`, `Scoreboard.total`
865
+
866
+ **Every scenario through every brain, in one table**
867
+
868
+ *A fresh brain per task, so state like 'already clicked this' cannot leak between them.*
869
+
870
+ <img src="gallery/benchmark.png" width="860">
871
+
872
+ `shows:` `benchmark`, `Scoreboard.to_rows`
873
+
874
+ **Replay a whole recorded suite against one brain**
875
+
876
+ *The literal roadmap step: the frames are fixed, nothing new is captured.*
877
+
878
+ <img src="gallery/benchmark-traces.png" width="860">
879
+
880
+ `shows:` `benchmark_traces`, `load_traces`, `Trace.save`
881
+
882
+ ### Command line
883
+
884
+ **--image example (replay a screenshot, dry run)**
885
+
886
+ *No display needed: the whole pipeline over a still image.*
887
+
888
+ <img src="gallery/cli-image.png" width="860">
889
+
890
+ `shows:` `main`, `build_parser`, `ImageScreen`, `detector_available`
891
+
892
+ **--script (replay a fixed action list instead of a brain)**
893
+
894
+ *The script is validated exactly like a model's answer: nothing skips the gates.*
895
+
896
+ <img src="gallery/cli-script.png" width="860">
897
+
898
+ `shows:` `MockBrain`, `demo_screen`, `parse_action`
899
+
900
+ **--verify-text (the harness decides success, not the brain)**
901
+
902
+ *Without it, an action that removes its own target looks like a failure.*
903
+
904
+ <img src="gallery/cli-verify-text.png" width="860">
905
+
906
+ `shows:` `AgentLoop.run`, `Episode.success`
907
+
908
+ **--trace / --expected (record the run, then score it)**
909
+
910
+ *Writes JSON next to the code, and prints the score straight away.*
911
+
912
+ <img src="gallery/cli-trace.png" width="860">
913
+
914
+ `shows:` `Trace.record`, `Trace.save`, `score_trace`
915
+
916
+ **--no-ocr (index a numbered grid instead)**
917
+
918
+ *Useful on a machine with no OCR engine, or for a canvas app.*
919
+
920
+ <img src="gallery/cli-detector.png" width="860">
921
+
922
+ `shows:` `GridDetector`, `make_detector`, `detector_available`
923
+
924
+ **Exit codes: 0 success, 1 failure, 2 bad usage, 3 no screen**
925
+
926
+ *A shell can tell 'it worked' from 'it gave up' without parsing any text.*
927
+
928
+ <img src="gallery/cli-exit-codes.png" width="860">
929
+
930
+ `shows:` `main`
931
+
932
+ **The two deliberate acts before the mouse can move**
933
+
934
+ *--real-input without the env var, and --brain vlm with no pixels: both refuse.*
935
+
936
+ <img src="gallery/cli-refusals.png" width="860">
937
+
938
+ `shows:` `main`, `ALLOW_REAL_INPUT_ENV`
939
+
940
+ **--live with no display is refused, with the fix**
941
+
942
+ *It tells you what to do instead of dying inside an episode.*
943
+
944
+ <img src="gallery/cli-live-headless.png" width="860">
945
+
946
+ `shows:` `display_available`, `main`
947
+
948
+ **--live (your real screen, dry run)**
949
+
950
+ *Real capture, real OCR, a real element index - and not one click.*
951
+
952
+ <img src="gallery/cli-live-dry-run.png" width="860">
953
+
954
+ `shows:` `GrabScreen`, `IndexedScreen`, `AutoDetector`, `main`
955
+
956
+ ### Real desktop
957
+
958
+ **GrabScreen: capture a real desktop**
959
+
960
+ *1280x800 of a real X server. mss first, Pillow's ImageGrab as the fallback.*
961
+
962
+ <img src="gallery/desk-capture.png" width="860">
963
+
964
+ `shows:` `GrabScreen`, `GrabScreen.capture`, `ScreenUnavailable`
965
+
966
+ **The element index, drawn on the pixels it came from**
967
+
968
+ *Every box is a real OCR word with a real screen coordinate: the model picks a label, not a pixel.*
969
+
970
+ <img src="gallery/desk-ocr-index.png" width="860">
971
+
972
+ `shows:` `OcrDetector`, `OcrDetector.detect`, `IndexedScreen`, `Element.center`
973
+
974
+ **A real click, and the window's own reaction**
975
+
976
+ *Pixels in, a label chosen, a pixel resolved, a button pressed - and the dialog closed itself.*
977
+
978
+ <img src="gallery/desk-click.png" width="860">
979
+
980
+ `shows:` `PyAutoGuiExecutor`, `AgentLoop.run`, `Policy.dry_run`, `resolve_point`
981
+
982
+ **Success only when the harness can see it**
983
+
984
+ *The app changed its own title and wrote a marker file; the verifier saw the change.*
985
+
986
+ <img src="gallery/desk-verify.png" width="860">
987
+
988
+ `shows:` `AgentLoop.run`, `Episode.success`, `Episode.stop_reason`
989
+
990
+ **A screen with no text still has legal moves**
991
+
992
+ *OCR finds nothing on a canvas, so the index degrades to numbered cells - and says so.*
993
+
994
+ <img src="gallery/desk-grid-fallback.png" width="860">
995
+
996
+ `shows:` `AutoDetector`, `GridDetector`, `OcrDetector`
997
+
998
+ **The safety gate, on a real button, in a real window**
999
+
1000
+ *The Terminal button is visible and labelled - and the policy refuses to press it.*
1001
+
1002
+ <img src="gallery/desk-policy-refusal.png" width="860">
1003
+
1004
+ `shows:` `Policy.check`, `Policy.deny_labels`, `PolicyViolation`, `Observation.find`
1005
+
1006
+ **The same click, driven end to end from the CLI**
1007
+
1008
+ *--live --real-input --verify-text Saved: capture, OCR, click, verify, exit code.*
1009
+
1010
+ <img src="gallery/desk-cli-real-input.png" width="860">
1011
+
1012
+ `shows:` `main`, `PyAutoGuiExecutor`, `AgentLoop.run`
1013
+
1014
+ ### Extensibility
1015
+
1016
+ **Every stage is a Protocol: bring your own screen, brain, detector, executor**
1017
+
1018
+ *The loop depends on four one-method contracts, not on the shipped implementations.*
1019
+
1020
+ <img src="gallery/protocols.png" width="860">
1021
+
1022
+ `shows:` `Brain`, `Brain.decide`, `ScreenSource`, `ScreenSource.capture`, `Detector`, `Detector.detect`, `Executor`, `Executor.execute`, `ActionResult`
1023
+
1024
+ ## Why a tiny model can do this at all
1025
+
1026
+ | Job | Who does it |
1027
+ |---|---|
1028
+ | Finding *where* a button is | OCR / accessibility tree — **not** the model |
1029
+ | Choosing *what* to do | the model, but only by picking a number from a short menu |
1030
+ | Knowing whether it worked | the verifier and the no-progress guard, not the model's word |
1031
+ | Recovering from a bad step | the retry policy in `loop.py`, not the weights |
1032
+
1033
+ A 256M model cannot emit `(295, 284)` reliably, but it can pick "4" out of five options —
1034
+ and the harness owns the coordinate lookup, the vocabulary check and the definition of
1035
+ success.
1036
+
1037
+ ## Safety, because this thing has your mouse
1038
+
1039
+ - Run it in a **VM, container, or throwaway desktop account**, not your real session.
1040
+ - **Dry run is the default**, so you can read a whole episode before anything clicks.
1041
+ - Deny lists for typing (`sudo`, `rm -rf`, "password"), widgets (terminal, keychain,
1042
+ wallet, bank) and key chords.
1043
+ - A **kill-switch file** checked before every action, plus a hard step budget.
1044
+ - **Blind grid clicks are refused with real input** unless you pass `--allow-grid`: when
1045
+ OCR finds nothing, the fallback index is coordinates with no meaning, and a model
1046
+ choosing one is guessing about your desktop.
1047
+ - **A brain's "done" is a claim, not evidence**: pass `--verify-text` and the harness
1048
+ decides success itself.
1049
+ - **Local-first, for real:** pixels and keystrokes never leave the machine.
1050
+
1051
+ ## What works, and what does not
1052
+
1053
+ **Works:** real capture, a real OCR index with coordinates that land on the right pixel,
1054
+ a real click an app reacts to, verified success, the closed vocabulary, the policy gates,
1055
+ the no-progress guard, echo detection, the doctor, the CLI, portable traces that score a brain's choices offline, 153 tests, a 256M VLM that
1056
+ reads a button label from pixels on a clean screenshot — and the same suite passing inside
1057
+ a real Windows Server 2022 guest with no KVM (109 passed, exit 0, with the OCR-inference
1058
+ tests excluded as impractically slow under emulation).
1059
+
1060
+ **Does not work yet:** reliability on a *live* screen. The 256M VLM echoed the prompt
1061
+ there; the 0.5B text planner picks wrong actions; the accessibility-tree detector is not
1062
+ implemented, so labels come from OCR only; and the harness still trusts a model's `done`
1063
+ when no verifier is given.
1064
+
1065
+ ## Roadmap
1066
+
1067
+ - [x] Capture, OCR index, closed vocabulary, dry-run default, policy gates, doctor
1068
+ - [x] A real click on a real window, with a verified outcome
1069
+ - [x] Blind-grid-click guard, echo detection, no-progress guard
1070
+ - [x] The harness suite running on real Windows (Server 2022 guest, no KVM — see above)
1071
+ - [ ] Windows run of the *live desktop* test: the workflow is committed, but it needs a
1072
+ GitHub mirror with Actions enabled — Hugging Face does not run GitHub Actions
1073
+ - [ ] Make the OCR guard in `test_cli.py` skip, rather than fail, when no engine is installed
1074
+ - [ ] Make the VLM reliable on a live screen (it echoes the prompt instead of choosing)
1075
+ - [ ] Accessibility-tree detector (AT-SPI on Linux, UI Automation on Windows)
1076
+ - [x] Score episodes: replay a trace and count how often the choice was right - see [scoring a brain, offline](#scoring-a-brain-offline)
1077
+ - [x] A capability gallery: one real screenshot per capability, generated by `showcase.py`
1078
+ - [ ] A **Windows** run of the gallery: the workflow builds it on `windows-latest`, but it needs a GitHub mirror with Actions enabled - Hugging Face does not run GitHub Actions
1079
+ - [ ] marimo console: live screen preview, action log, pause / resume / step
1080
+
1081
+ ## License
1082
+
1083
+ MIT — see [LICENSE](LICENSE).
preview/probe.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from playwright.sync_api import sync_playwright
2
+
3
+ URL = "https://huggingface.co/hkblockchaintechnology/tiny-computer-agent"
4
+ with sync_playwright() as p:
5
+ browser = p.chromium.launch()
6
+ page = browser.new_page(viewport={"width": 1440, "height": 1100})
7
+ page.goto(URL, wait_until="load", timeout=120000)
8
+ page.wait_for_timeout(6000)
9
+ html = page.content()
10
+ print("len(html):", len(html))
11
+ for needle in ["gallery/", "<img", "article img", "Capability gallery",
12
+ "desk-click", "resolve/main/gallery"]:
13
+ print(f" {needle!r:24} occurrences in DOM: {html.count(needle)}")
14
+ print("total <img> on page:", page.eval_on_selector_all("img", "els => els.length"))
15
+ print("containers:", page.eval_on_selector_all(
16
+ "main, article, [class*=prose]", "els => els.slice(0,4).map(e => e.tagName + '.' + e.className.slice(0,40))"))
17
+ # is the card text itself rendered?
18
+ print("has heading text:", page.locator("text=Capability gallery").count())
19
+ browser.close()