Publish tiny_computer_agent_app.py from marimo
Browse files- tiny_computer_agent_app.py +150 -18
tiny_computer_agent_app.py
CHANGED
|
@@ -17,10 +17,27 @@ with app.setup:
|
|
| 17 |
import marimo as mo
|
| 18 |
from huggingface_hub import HfApi
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
@app.cell(hide_code=True)
|
| 22 |
def readme():
|
| 23 |
-
readme_md = '''
|
| 24 |
|
| 25 |
---
|
| 26 |
title: Tiny Computer Agent
|
|
@@ -46,7 +63,9 @@ def readme():
|
|
| 46 |
|
| 47 |
> **Status 0.3.0: it drives a real desktop — on Linux and on Windows.** Screen capture,
|
| 48 |
> an OCR element index with real coordinates, a real mouse click, and a *verified*
|
| 49 |
-
> outcome.
|
|
|
|
|
|
|
| 50 |
> 2022 guest; what is not solved is model reliability — see
|
| 51 |
> [measured results](#measured-results).
|
| 52 |
|
|
@@ -106,7 +125,7 @@ def readme():
|
|
| 106 |
```bash
|
| 107 |
python -m pip install -e ".[desktop,ocr,dev]"
|
| 108 |
pytest tests -q
|
| 109 |
-
#
|
| 110 |
```
|
| 111 |
|
| 112 |
The two skips are the live-desktop tests. Give them a display and they run for real:
|
|
@@ -176,6 +195,37 @@ def readme():
|
|
| 176 |
step 0 | click label='Save' reason="the reply names 'Save'" -> pixel (295, 284)
|
| 177 |
```
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
### Which brain drove which run
|
| 180 |
|
| 181 |
The harness and the brain are separable, and it matters which one a result came from.
|
|
@@ -207,7 +257,7 @@ def readme():
|
|
| 207 |
`warning: no text was found on screen, so the index is 96 numbered grid cells and the
|
| 208 |
agent is choosing blind` — instead of silently guessing.
|
| 209 |
|
| 210 |
-
**
|
| 211 |
that click a real button.
|
| 212 |
|
| 213 |
## Verified on real Windows
|
|
@@ -249,7 +299,7 @@ def readme():
|
|
| 249 |
|
| 250 |
None of the three failures in the first row is a harness defect: two are
|
| 251 |
`ModuleNotFoundError: numpy` and the third is the guard bug documented below. With the
|
| 252 |
-
OCR engine installed, the same suite exits **0**.
|
| 253 |
|
| 254 |
**The emulated-OCR caveat.** onnxruntime requires AVX2, and QEMU's software emulation of
|
| 255 |
AVX2 is roughly two orders of magnitude slower than native. A suite that finishes in 17
|
|
@@ -275,7 +325,7 @@ def readme():
|
|
| 275 |
|
| 276 |
## The test suite, in full
|
| 277 |
|
| 278 |
-
`pytest tests` collects **
|
| 279 |
weights. The last 2 are the opt-in live-desktop tests, skipped unless
|
| 280 |
`TINY_COMPUTER_AGENT_LIVE_TEST=1`.
|
| 281 |
|
|
@@ -292,11 +342,12 @@ def readme():
|
|
| 292 |
| `tests/test_grid_fallback.py` | 11 | the fallback index announces itself instead of guessing |
|
| 293 |
| `tests/test_desktop.py` | 10 | display detection, the preflight checks, `doctor` |
|
| 294 |
| `tests/test_cli.py` | 10 | the command line: `--doctor`, `--image`, `--live`, argument handling |
|
|
|
|
| 295 |
| `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 |
|
| 296 |
|
| 297 |
```bash
|
| 298 |
-
pytest tests -q #
|
| 299 |
-
TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q #
|
| 300 |
```
|
| 301 |
|
| 302 |
## Install
|
|
@@ -305,7 +356,7 @@ def readme():
|
|
| 305 |
pip install -e ".[ocr]" # labels from pixels (RapidOCR: no system binary)
|
| 306 |
pip install -e ".[desktop,ocr]" # + real capture and real input
|
| 307 |
pip install -e ".[vision,ocr]" # + the tiny model brains
|
| 308 |
-
pytest #
|
| 309 |
```
|
| 310 |
|
| 311 |
## Run it on your machine
|
|
@@ -382,7 +433,7 @@ def readme():
|
|
| 382 |
|
| 383 |
# 2. inside the guest, after autologon
|
| 384 |
# vc_redist.x64.exe is required before onnxruntime will load
|
| 385 |
-
curl.exe -s -o C:
|
| 386 |
```
|
| 387 |
|
| 388 |
Serve files to the guest over QEMU's user-mode networking (the host is `10.0.2.2`), which
|
|
@@ -406,6 +457,7 @@ def readme():
|
|
| 406 |
| `loop.py` | `AgentLoop`, `Episode`, `Step`, the no-progress guard and the verifier |
|
| 407 |
| `desktop.py` | Preflight checks, `doctor`, and `build_agent` in one call |
|
| 408 |
| `demo.py` | The synthetic desktop used by tests and `--image example` |
|
|
|
|
| 409 |
|
| 410 |
## Why a tiny model can do this at all
|
| 411 |
|
|
@@ -438,7 +490,7 @@ def readme():
|
|
| 438 |
|
| 439 |
**Works:** real capture, a real OCR index with coordinates that land on the right pixel,
|
| 440 |
a real click an app reacts to, verified success, the closed vocabulary, the policy gates,
|
| 441 |
-
the no-progress guard, echo detection, the doctor, the CLI,
|
| 442 |
reads a button label from pixels on a clean screenshot — and the same suite passing inside
|
| 443 |
a real Windows Server 2022 guest with no KVM (109 passed, exit 0, with the OCR-inference
|
| 444 |
tests excluded as impractically slow under emulation).
|
|
@@ -459,7 +511,7 @@ def readme():
|
|
| 459 |
- [ ] Make the OCR guard in `test_cli.py` skip, rather than fail, when no engine is installed
|
| 460 |
- [ ] Make the VLM reliable on a live screen (it echoes the prompt instead of choosing)
|
| 461 |
- [ ] Accessibility-tree detector (AT-SPI on Linux, UI Automation on Windows)
|
| 462 |
-
- [
|
| 463 |
- [ ] marimo console: live screen preview, action log, pause / resume / step
|
| 464 |
|
| 465 |
## License
|
|
@@ -551,7 +603,7 @@ def repo_files():
|
|
| 551 |
'''
|
| 552 |
|
| 553 |
mo.md(f"packaging: {len(pyproject_toml) + len(license_txt) + len(gitignore_txt)} chars, "
|
| 554 |
-
f"v0.
|
| 555 |
return gitignore_txt, license_txt, pyproject_toml
|
| 556 |
|
| 557 |
|
|
@@ -639,11 +691,6 @@ def publish_status(
|
|
| 639 |
|
| 640 |
@app.cell
|
| 641 |
def demo():
|
| 642 |
-
from tiny_computer_agent import AgentLoop, DryRunExecutor, PlannerBrain, Policy
|
| 643 |
-
from tiny_computer_agent.demo import fake_desktop
|
| 644 |
-
from tiny_computer_agent.observe import ImageScreen
|
| 645 |
-
from tiny_computer_agent.perception import IndexedScreen, OcrDetector
|
| 646 |
-
|
| 647 |
# A real screenshot -> real OCR -> labelled, clickable elements. No display needed:
|
| 648 |
# this is the same path --image takes, and it is what makes a tiny model viable.
|
| 649 |
_detector = OcrDetector()
|
|
@@ -677,5 +724,90 @@ def demo():
|
|
| 677 |
return
|
| 678 |
|
| 679 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
if __name__ == "__main__":
|
| 681 |
app.run()
|
|
|
|
| 17 |
import marimo as mo
|
| 18 |
from huggingface_hub import HfApi
|
| 19 |
|
| 20 |
+
# The project's public API, imported once so no cell has to duplicate it.
|
| 21 |
+
from tiny_computer_agent import (
|
| 22 |
+
AgentLoop,
|
| 23 |
+
DryRunExecutor,
|
| 24 |
+
Element,
|
| 25 |
+
MockBrain,
|
| 26 |
+
PlannerBrain,
|
| 27 |
+
Policy,
|
| 28 |
+
)
|
| 29 |
+
from tiny_computer_agent.actions import Action
|
| 30 |
+
from tiny_computer_agent.brain import DecisionMenu
|
| 31 |
+
from tiny_computer_agent.demo import fake_desktop
|
| 32 |
+
from tiny_computer_agent.observe import ImageScreen
|
| 33 |
+
from tiny_computer_agent.perception import IndexedScreen, OcrDetector
|
| 34 |
+
from tiny_computer_agent.trace import Scenario, benchmark
|
| 35 |
+
|
| 36 |
+
|
| 37 |
|
| 38 |
@app.cell(hide_code=True)
|
| 39 |
def readme():
|
| 40 |
+
readme_md = r'''
|
| 41 |
|
| 42 |
---
|
| 43 |
title: Tiny Computer Agent
|
|
|
|
| 63 |
|
| 64 |
> **Status 0.3.0: it drives a real desktop — on Linux and on Windows.** Screen capture,
|
| 65 |
> an OCR element index with real coordinates, a real mouse click, and a *verified*
|
| 66 |
+
> outcome. A run can be saved as a portable *trace*, replayed offline and **scored**, so
|
| 67 |
+
> "which brain is right, and how often?" is a number in a table.
|
| 68 |
+
> The suite passes on a live Linux desktop and inside a real Windows Server
|
| 69 |
> 2022 guest; what is not solved is model reliability — see
|
| 70 |
> [measured results](#measured-results).
|
| 71 |
|
|
|
|
| 125 |
```bash
|
| 126 |
python -m pip install -e ".[desktop,ocr,dev]"
|
| 127 |
pytest tests -q
|
| 128 |
+
# 151 passed, 2 skipped in 18.7s
|
| 129 |
```
|
| 130 |
|
| 131 |
The two skips are the live-desktop tests. Give them a display and they run for real:
|
|
|
|
| 195 |
step 0 | click label='Save' reason="the reply names 'Save'" -> pixel (295, 284)
|
| 196 |
```
|
| 197 |
|
| 198 |
+
### Scoring a brain, offline
|
| 199 |
+
|
| 200 |
+
"Which brain is right, and how often?" needs no desktop, no model and no network,
|
| 201 |
+
because a **trace** keeps every frame's element index and throws the pixels away. Record a
|
| 202 |
+
run once; replay it against any brain, as often as you like.
|
| 203 |
+
|
| 204 |
+
```
|
| 205 |
+
$ python -m tiny_computer_agent --image example --trace runs/save.json --expected Save
|
| 206 |
+
...
|
| 207 |
+
trace: wrote runs/save.json (2 frames)
|
| 208 |
+
score: correct click 'Save' to save the document: expected 'Save', chose 'Save'
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
A trace is JSON, so it diffs in review, commits next to the tests, and replays on a machine
|
| 212 |
+
with nothing installed. Scoring a whole suite is a table:
|
| 213 |
+
|
| 214 |
+
```
|
| 215 |
+
scoreboard: 7/9 correct (78%)
|
| 216 |
+
planner (rule-based): 100%
|
| 217 |
+
first-item (baseline): 33%
|
| 218 |
+
oracle (always right): 100%
|
| 219 |
+
correct save the document: expected 'Save', chose 'Save'
|
| 220 |
+
incorrect save the document: expected 'Save', chose 'Files' (chose 'Files', not 'Save')
|
| 221 |
+
...
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
The number is the brain's **decision** - what it chose when it had to commit. Whether the
|
| 225 |
+
harness *allowed* that decision to run is a separate axis: a click the policy refused is
|
| 226 |
+
still the choice it was, and it is flagged `(harness refused it)` rather than passed off as
|
| 227 |
+
a clean success. Keeping those two apart is the point.
|
| 228 |
+
|
| 229 |
### Which brain drove which run
|
| 230 |
|
| 231 |
The harness and the brain are separable, and it matters which one a result came from.
|
|
|
|
| 257 |
`warning: no text was found on screen, so the index is 96 numbered grid cells and the
|
| 258 |
agent is choosing blind` — instead of silently guessing.
|
| 259 |
|
| 260 |
+
**153 tests pass**, including real OCR over a real image and (opt-in) live-desktop tests
|
| 261 |
that click a real button.
|
| 262 |
|
| 263 |
## Verified on real Windows
|
|
|
|
| 299 |
|
| 300 |
None of the three failures in the first row is a harness defect: two are
|
| 301 |
`ModuleNotFoundError: numpy` and the third is the guard bug documented below. With the
|
| 302 |
+
OCR engine installed, the same suite exits **0**. (Those Windows counts predate `trace.py`; the harness suite has since grown to 153 tests.)
|
| 303 |
|
| 304 |
**The emulated-OCR caveat.** onnxruntime requires AVX2, and QEMU's software emulation of
|
| 305 |
AVX2 is roughly two orders of magnitude slower than native. A suite that finishes in 17
|
|
|
|
| 325 |
|
| 326 |
## The test suite, in full
|
| 327 |
|
| 328 |
+
`pytest tests` collects **153 tests**. 151 run headless — no display, no network, no
|
| 329 |
weights. The last 2 are the opt-in live-desktop tests, skipped unless
|
| 330 |
`TINY_COMPUTER_AGENT_LIVE_TEST=1`.
|
| 331 |
|
|
|
|
| 342 |
| `tests/test_grid_fallback.py` | 11 | the fallback index announces itself instead of guessing |
|
| 343 |
| `tests/test_desktop.py` | 10 | display detection, the preflight checks, `doctor` |
|
| 344 |
| `tests/test_cli.py` | 10 | the command line: `--doctor`, `--image`, `--live`, argument handling |
|
| 345 |
+
| `tests/test_trace.py` | 28 | traces: a run recorded with no pixels, replayed against another brain, and scored against ground truth |
|
| 346 |
| `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 |
|
| 347 |
|
| 348 |
```bash
|
| 349 |
+
pytest tests -q # 151 passed, 2 skipped
|
| 350 |
+
TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q # 153 passed, on a desktop
|
| 351 |
```
|
| 352 |
|
| 353 |
## Install
|
|
|
|
| 356 |
pip install -e ".[ocr]" # labels from pixels (RapidOCR: no system binary)
|
| 357 |
pip install -e ".[desktop,ocr]" # + real capture and real input
|
| 358 |
pip install -e ".[vision,ocr]" # + the tiny model brains
|
| 359 |
+
pytest # 153 tests, no display or network needed
|
| 360 |
```
|
| 361 |
|
| 362 |
## Run it on your machine
|
|
|
|
| 433 |
|
| 434 |
# 2. inside the guest, after autologon
|
| 435 |
# vc_redist.x64.exe is required before onnxruntime will load
|
| 436 |
+
curl.exe -s -o C:\tiny\vc_redist.x64.exe <vc_redist-url> && C:\tiny\vc_redist.x64.exe /install /quiet /norestart
|
| 437 |
```
|
| 438 |
|
| 439 |
Serve files to the guest over QEMU's user-mode networking (the host is `10.0.2.2`), which
|
|
|
|
| 457 |
| `loop.py` | `AgentLoop`, `Episode`, `Step`, the no-progress guard and the verifier |
|
| 458 |
| `desktop.py` | Preflight checks, `doctor`, and `build_agent` in one call |
|
| 459 |
| `demo.py` | The synthetic desktop used by tests and `--image example` |
|
| 460 |
+
| `trace.py` | Portable traces: record a run with no pixels, replay it offline, score the choice against ground truth |
|
| 461 |
|
| 462 |
## Why a tiny model can do this at all
|
| 463 |
|
|
|
|
| 490 |
|
| 491 |
**Works:** real capture, a real OCR index with coordinates that land on the right pixel,
|
| 492 |
a real click an app reacts to, verified success, the closed vocabulary, the policy gates,
|
| 493 |
+
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
|
| 494 |
reads a button label from pixels on a clean screenshot — and the same suite passing inside
|
| 495 |
a real Windows Server 2022 guest with no KVM (109 passed, exit 0, with the OCR-inference
|
| 496 |
tests excluded as impractically slow under emulation).
|
|
|
|
| 511 |
- [ ] Make the OCR guard in `test_cli.py` skip, rather than fail, when no engine is installed
|
| 512 |
- [ ] Make the VLM reliable on a live screen (it echoes the prompt instead of choosing)
|
| 513 |
- [ ] Accessibility-tree detector (AT-SPI on Linux, UI Automation on Windows)
|
| 514 |
+
- [x] Score episodes: replay a trace and count how often the choice was right - see [scoring a brain, offline](#scoring-a-brain-offline)
|
| 515 |
- [ ] marimo console: live screen preview, action log, pause / resume / step
|
| 516 |
|
| 517 |
## License
|
|
|
|
| 603 |
'''
|
| 604 |
|
| 605 |
mo.md(f"packaging: {len(pyproject_toml) + len(license_txt) + len(gitignore_txt)} chars, "
|
| 606 |
+
f"v0.3.0 with ocr/desktop/vision extras")
|
| 607 |
return gitignore_txt, license_txt, pyproject_toml
|
| 608 |
|
| 609 |
|
|
|
|
| 691 |
|
| 692 |
@app.cell
|
| 693 |
def demo():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
# A real screenshot -> real OCR -> labelled, clickable elements. No display needed:
|
| 695 |
# this is the same path --image takes, and it is what makes a tiny model viable.
|
| 696 |
_detector = OcrDetector()
|
|
|
|
| 724 |
return
|
| 725 |
|
| 726 |
|
| 727 |
+
@app.cell
|
| 728 |
+
def scoreboard():
|
| 729 |
+
# The brains are separable, so they can be scored against the same tasks with no
|
| 730 |
+
# display, no OCR and no weights: a scenario is an element index plus the answer that
|
| 731 |
+
# is right. Record a run once; replay it against any brain, and count how often right.
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
class FirstItemBrain:
|
| 735 |
+
"""A baseline with no model at all: click whatever is first in reading order."""
|
| 736 |
+
|
| 737 |
+
name = "first-item"
|
| 738 |
+
|
| 739 |
+
def decide(self, goal, observation):
|
| 740 |
+
menu = DecisionMenu(observation)
|
| 741 |
+
if not menu:
|
| 742 |
+
return Action("done", reason="nothing to click")
|
| 743 |
+
return Action("click", label=menu.items[0].label, reason="first item in reading order")
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def _bar(label, x):
|
| 747 |
+
return Element(label, x=x, y=6, width=80, height=20)
|
| 748 |
+
|
| 749 |
+
|
| 750 |
+
def _button(label, x, y=270):
|
| 751 |
+
return Element(label, x=x, y=y, width=90, height=30)
|
| 752 |
+
|
| 753 |
+
|
| 754 |
+
score_scenarios = [
|
| 755 |
+
Scenario(
|
| 756 |
+
"click 'Save' to save the document",
|
| 757 |
+
frames=[
|
| 758 |
+
(_bar("Files", 0), _bar("Edit", 100), _bar("View", 200)),
|
| 759 |
+
(Element("Name", x=200, y=195, width=200, height=26), _button("Save", 200)),
|
| 760 |
+
(_bar("Files", 0), _button("Save", 200)),
|
| 761 |
+
],
|
| 762 |
+
expected="Save", name="save the document",
|
| 763 |
+
),
|
| 764 |
+
Scenario(
|
| 765 |
+
"click 'Files' to open the file manager",
|
| 766 |
+
frames=[(_bar("Files", 0), _bar("Edit", 100), _button("Save", 200))],
|
| 767 |
+
expected="Files", name="open the file manager",
|
| 768 |
+
),
|
| 769 |
+
Scenario(
|
| 770 |
+
"click 'Cancel' to discard the change",
|
| 771 |
+
frames=[(_button("Save", 200), _button("Cancel", 400))],
|
| 772 |
+
expected="Cancel", name="discard the change",
|
| 773 |
+
),
|
| 774 |
+
]
|
| 775 |
+
|
| 776 |
+
scoreboard = benchmark(
|
| 777 |
+
score_scenarios,
|
| 778 |
+
{
|
| 779 |
+
"planner (rule-based)": lambda scenario: PlannerBrain(),
|
| 780 |
+
"first-item (baseline)": lambda scenario: FirstItemBrain(),
|
| 781 |
+
"oracle (always right)": lambda scenario: MockBrain(
|
| 782 |
+
[
|
| 783 |
+
{"kind": "click", "label": scenario.expected},
|
| 784 |
+
{"kind": "done", "reason": "clicked the answer the task names"},
|
| 785 |
+
]
|
| 786 |
+
),
|
| 787 |
+
},
|
| 788 |
+
)
|
| 789 |
+
|
| 790 |
+
mo.vstack(
|
| 791 |
+
[
|
| 792 |
+
mo.md(
|
| 793 |
+
f"**Score the choice, offline.** {len(score_scenarios)} tasks x "
|
| 794 |
+
f"{len(scoreboard.brains())} brains = **{scoreboard.total} decisions**, "
|
| 795 |
+
f"**{scoreboard.earned} of them right ({scoreboard.accuracy:.0%})** - read off "
|
| 796 |
+
f"saved element indexes, with no screen, no model and no network."
|
| 797 |
+
),
|
| 798 |
+
mo.ui.table(scoreboard.to_rows(), selection=None),
|
| 799 |
+
mo.md(
|
| 800 |
+
"Accuracy per brain: "
|
| 801 |
+
+ " · ".join(
|
| 802 |
+
f"**{_brain}** {_accuracy:.0%}"
|
| 803 |
+
for _brain, _accuracy in scoreboard.by_brain().items()
|
| 804 |
+
)
|
| 805 |
+
),
|
| 806 |
+
]
|
| 807 |
+
)
|
| 808 |
+
|
| 809 |
+
return
|
| 810 |
+
|
| 811 |
+
|
| 812 |
if __name__ == "__main__":
|
| 813 |
app.run()
|