hkblockchaintechnology commited on
Commit
e9262d8
·
verified ·
1 Parent(s): 2acb889

Publish tiny_computer_agent_app.py from marimo

Browse files
Files changed (1) hide show
  1. tiny_computer_agent_app.py +289 -17
tiny_computer_agent_app.py CHANGED
@@ -1,7 +1,7 @@
1
  # /// script
2
  # requires-python = ">=3.13"
3
  # dependencies = [
4
- # "pillow>=10",
5
  # "rapidocr-onnxruntime==1.4.4",
6
  # ]
7
  # ///
@@ -23,9 +23,11 @@ with app.setup:
23
  AgentLoop,
24
  DryRunExecutor,
25
  Element,
 
26
  MockBrain,
27
  PlannerBrain,
28
  Policy,
 
29
  )
30
  from tiny_computer_agent.actions import Action
31
  from tiny_computer_agent.brain import DecisionMenu
@@ -64,7 +66,11 @@ def readme():
64
 
65
  > **Status 0.3.0: it drives a real desktop — on Linux and on Windows.** Screen capture,
66
  > an OCR element index with real coordinates, a real mouse click, and a *verified*
67
- > outcome. **All 68 capabilities have a real screenshot** in the [capability gallery](#capability-gallery), and a run can be saved as a portable *trace*, replayed offline and **scored**, so
 
 
 
 
68
  > "which brain is right, and how often?" is a number in a table.
69
  > The suite passes on a live Linux desktop and inside a real Windows Server
70
  > 2022 guest; what is not solved is model reliability — see
@@ -126,7 +132,7 @@ def readme():
126
  ```bash
127
  python -m pip install -e ".[desktop,ocr,dev]"
128
  pytest tests -q
129
- # 162 passed, 2 skipped in 18.7s
130
  ```
131
 
132
  The two skips are the live-desktop tests. Give them a display and they run for real:
@@ -227,6 +233,46 @@ def readme():
227
  still the choice it was, and it is flagged `(harness refused it)` rather than passed off as
228
  a clean success. Keeping those two apart is the point.
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  ### Which brain drove which run
231
 
232
  The harness and the brain are separable, and it matters which one a result came from.
@@ -258,7 +304,7 @@ def readme():
258
  `warning: no text was found on screen, so the index is 96 numbered grid cells and the
259
  agent is choosing blind` — instead of silently guessing.
260
 
261
- **164 tests pass**, including real OCR over a real image and (opt-in) live-desktop tests
262
  that click a real button.
263
 
264
  ## Verified on real Windows
@@ -300,7 +346,7 @@ def readme():
300
 
301
  None of the three failures in the first row is a harness defect: two are
302
  `ModuleNotFoundError: numpy` and the third is the guard bug documented below. With the
303
- OCR engine installed, the same suite exits **0**. (Those Windows counts predate `trace.py`; the harness suite has since grown to 164 tests.)
304
 
305
  **The emulated-OCR caveat.** onnxruntime requires AVX2, and QEMU's software emulation of
306
  AVX2 is roughly two orders of magnitude slower than native. A suite that finishes in 17
@@ -326,7 +372,7 @@ def readme():
326
 
327
  ## The test suite, in full
328
 
329
- `pytest tests` collects **164 tests**. 162 run headless — no display, no network, no
330
  weights. The last 2 are the opt-in live-desktop tests, skipped unless
331
  `TINY_COMPUTER_AGENT_LIVE_TEST=1`.
332
 
@@ -346,11 +392,15 @@ def readme():
346
  | `tests/test_trace.py` | 28 | traces: a run recorded with no pixels, replayed against another brain, and scored against ground truth |
347
  | `tests/test_models.py` | 7 | the model backends: lazy loading, device choice, honest refusals |
348
  | `tests/test_api_surface.py` | 4 | the public API is a promise: everything advertised is importable, and nothing public is left out |
 
 
 
 
349
  | `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 |
350
 
351
  ```bash
352
- pytest tests -q # 162 passed, 2 skipped
353
- TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q # 164 passed, on a desktop
354
  ```
355
 
356
  ## Install
@@ -359,7 +409,15 @@ def readme():
359
  pip install -e ".[ocr]" # labels from pixels (RapidOCR: no system binary)
360
  pip install -e ".[desktop,ocr]" # + real capture and real input
361
  pip install -e ".[vision,ocr]" # + the tiny model brains
362
- pytest # 164 tests, no display or network needed
 
 
 
 
 
 
 
 
363
  ```
364
 
365
  ## Run it on your machine
@@ -461,7 +519,61 @@ def readme():
461
  | `desktop.py` | Preflight checks, `doctor`, and `build_agent` in one call |
462
  | `demo.py` | The synthetic desktop used by tests and `--image example` |
463
  | `trace.py` | Portable traces: record a run with no pixels, replay it offline, score the choice against ground truth |
 
 
464
  | `showcase.py` | The capability gallery: one real screenshot per capability, checked against the public API |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
 
466
  ## Capability gallery
467
 
@@ -1108,15 +1220,22 @@ def readme():
1108
  - [x] A real click on a real window, with a verified outcome
1109
  - [x] Blind-grid-click guard, echo detection, no-progress guard
1110
  - [x] The harness suite running on real Windows (Server 2022 guest, no KVM — see above)
1111
- - [ ] Windows run of the *live desktop* test: the workflow is committed, but it needs a
1112
- GitHub mirror with Actions enabled — Hugging Face does not run GitHub Actions
1113
- - [ ] Make the OCR guard in `test_cli.py` skip, rather than fail, when no engine is installed
1114
- - [ ] Make the VLM reliable on a live screen (it echoes the prompt instead of choosing)
1115
- - [ ] Accessibility-tree detector (AT-SPI on Linux, UI Automation on Windows)
1116
  - [x] Score episodes: replay a trace and count how often the choice was right - see [scoring a brain, offline](#scoring-a-brain-offline)
1117
  - [x] A capability gallery: one real screenshot per capability, generated by `showcase.py`
1118
- - [ ] 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
1119
- - [ ] marimo console: live screen preview, action log, pause / resume / step
 
 
 
 
 
 
 
 
 
 
 
 
1120
 
1121
  ## License
1122
 
@@ -1258,7 +1377,8 @@ def publish_status(
1258
  "tiny_computer_agent_app.py": _notebook.read_text(encoding="utf-8"),
1259
  }
1260
  # the tool that builds the capability gallery, shipped with the card it fills
1261
- for _tool in ("showcase.py", "showcase_core.py", "showcase_catalog.py"):
 
1262
  if (_root / _tool).is_file():
1263
  _files[_tool] = (_root / _tool).read_text(encoding="utf-8")
1264
  _folders = [f for f in ("tiny_computer_agent", "tests", "gallery",
@@ -1455,5 +1575,157 @@ def scoreboard():
1455
  return
1456
 
1457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1458
  if __name__ == "__main__":
1459
  app.run()
 
1
  # /// script
2
  # requires-python = ">=3.13"
3
  # dependencies = [
4
+ # "pillow==13.0.0",
5
  # "rapidocr-onnxruntime==1.4.4",
6
  # ]
7
  # ///
 
23
  AgentLoop,
24
  DryRunExecutor,
25
  Element,
26
+ GrabScreen,
27
  MockBrain,
28
  PlannerBrain,
29
  Policy,
30
+ PyAutoGuiExecutor,
31
  )
32
  from tiny_computer_agent.actions import Action
33
  from tiny_computer_agent.brain import DecisionMenu
 
66
 
67
  > **Status 0.3.0: it drives a real desktop — on Linux and on Windows.** Screen capture,
68
  > an OCR element index with real coordinates, a real mouse click, and a *verified*
69
+ > outcome. **All 73 capabilities have a real screenshot** in the [capability gallery](#capability-gallery).
70
+ > The loop can be driven from a [marimo console](#the-marimo-console) with a live preview, an
71
+ > action log and pause / resume / step; elements can come from the
72
+ > [accessibility tree](#what-the-machine-already-knows-the-accessibility-tree) instead of OCR;
73
+ > and a run can be saved as a portable *trace*, replayed offline and **scored**, so
74
  > "which brain is right, and how often?" is a number in a table.
75
  > The suite passes on a live Linux desktop and inside a real Windows Server
76
  > 2022 guest; what is not solved is model reliability — see
 
132
  ```bash
133
  python -m pip install -e ".[desktop,ocr,dev]"
134
  pytest tests -q
135
+ # 217 passed, 2 skipped in 18.7s
136
  ```
137
 
138
  The two skips are the live-desktop tests. Give them a display and they run for real:
 
233
  still the choice it was, and it is flagged `(harness refused it)` rather than passed off as
234
  a clean success. Keeping those two apart is the point.
235
 
236
+ ### Asking a tiny model in four ways (measured)
237
+
238
+ The card used to say the 256M VLM "echoes the prompt instead of choosing". That is
239
+ reproducible - and so is the fix. A live GTK window, a menu of four exact widgets
240
+ (Files, Terminal, Save, Cancel), and the goal `click 'Save' to save the document`:
241
+
242
+ ```
243
+ mode echoed seconds outcome
244
+ json no 8.9 BrainError: the reply mentions 4 menu entries, so it looks like an echo
245
+ choice no 1.9 BrainError: the constrained reply was not a menu number: '1000'
246
+ scan no 1.4 click label='Files' (the model said yes to the first candidate)
247
+ rank no 9.4 click label='Files' (highest yes/no log-odds margin, +0.94)
248
+ ```
249
+
250
+ Four ways to ask, all of them the same weights:
251
+
252
+ | mode | how the model is asked | answer space |
253
+ |---|---|---|
254
+ | `json` | reply with JSON, parsed leniently | the whole vocabulary |
255
+ | `choice` | one masked generation for a menu number | a handful of digits |
256
+ | `scan` | one masked yes/no question per item, first yes wins | two tokens |
257
+ | `rank` | the same questions, ranked by the yes/no log-odds margin | two tokens |
258
+
259
+ What the numbers actually say:
260
+
261
+ - **The echo is fixed, structurally.** In `json` mode the model hands the prompt
262
+ back (`"The goal is to click 'Save' to save the document..."`) and the harness
263
+ refuses it. In the three masked modes the reply *cannot* be the prompt, because
264
+ every one of its tokens is masked out of the logits. The failure stopped being
265
+ *detected* and started being *not representable*.
266
+ - **It is about six times faster** - 8.9 s of free text becomes 1.4 s for a
267
+ one-token answer - because the model is no longer asked to write.
268
+ - **Accuracy is not fixed, and the numbers say why.** The model answers now, and
269
+ answers *wrongly*: it accepts `Files` for a goal that names `Save`, even when the
270
+ answer space is two tokens and each candidate is judged on its own. A 256M model
271
+ cannot do this task. That is a finding about the weights, not about the harness -
272
+ which is why the rule-based planner is still the default brain.
273
+
274
+ Reproduce it with `python measure_vlm.py --screen live --detector best --open-gtk`.
275
+
276
  ### Which brain drove which run
277
 
278
  The harness and the brain are separable, and it matters which one a result came from.
 
304
  `warning: no text was found on screen, so the index is 96 numbered grid cells and the
305
  agent is choosing blind` — instead of silently guessing.
306
 
307
+ **219 tests pass**, including real OCR over a real image and (opt-in) live-desktop tests
308
  that click a real button.
309
 
310
  ## Verified on real Windows
 
346
 
347
  None of the three failures in the first row is a harness defect: two are
348
  `ModuleNotFoundError: numpy` and the third is the guard bug documented below. With the
349
+ OCR engine installed, the same suite exits **0**. (Those Windows counts predate `trace.py`; the harness suite has since grown to 219 tests.)
350
 
351
  **The emulated-OCR caveat.** onnxruntime requires AVX2, and QEMU's software emulation of
352
  AVX2 is roughly two orders of magnitude slower than native. A suite that finishes in 17
 
372
 
373
  ## The test suite, in full
374
 
375
+ `pytest tests` collects **219 tests**. 217 run headless — no display, no network, no
376
  weights. The last 2 are the opt-in live-desktop tests, skipped unless
377
  `TINY_COMPUTER_AGENT_LIVE_TEST=1`.
378
 
 
392
  | `tests/test_trace.py` | 28 | traces: a run recorded with no pixels, replayed against another brain, and scored against ground truth |
393
  | `tests/test_models.py` | 7 | the model backends: lazy loading, device choice, honest refusals |
394
  | `tests/test_api_surface.py` | 4 | the public API is a promise: everything advertised is importable, and nothing public is left out |
395
+ | `tests/test_decoding.py` | 24 | the mask, the answer space, the retry ladder, the vote - and that an echo is refused |
396
+ | `tests/test_accessibility.py` | 16 | role filtering, the coordinate contract, the fallback chain, and real AT-SPI |
397
+ | `tests/test_stepwise.py` | 11 | begin / step / pause / resume, and that it equals the batch loop |
398
+ | `tests/test_ocr_guard.py` | 4 | the OCR guard really skips with no engine - the guard that never fired |
399
  | `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 |
400
 
401
  ```bash
402
+ pytest tests -q # 217 passed, 2 skipped
403
+ TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q # 219 passed, on a desktop
404
  ```
405
 
406
  ## Install
 
409
  pip install -e ".[ocr]" # labels from pixels (RapidOCR: no system binary)
410
  pip install -e ".[desktop,ocr]" # + real capture and real input
411
  pip install -e ".[vision,ocr]" # + the tiny model brains
412
+ pytest # 219 tests, no display or network needed
413
+ ```
414
+
415
+ The accessibility detector needs system packages rather than an extra:
416
+
417
+ ```bash
418
+ apt install gir1.2-atspi-2.0 at-spi2-core gir1.2-gtk-3.0
419
+ pip install PyGObject
420
+ python -m tiny_computer_agent --doctor --detector best # is the tree reachable?
421
  ```
422
 
423
  ## Run it on your machine
 
519
  | `desktop.py` | Preflight checks, `doctor`, and `build_agent` in one call |
520
  | `demo.py` | The synthetic desktop used by tests and `--image example` |
521
  | `trace.py` | Portable traces: record a run with no pixels, replay it offline, score the choice against ground truth |
522
+ | `decoding.py` | Constrained decoding: mask the logits to a menu number or a yes/no, so an echo is impossible |
523
+ | `accessibility.py` | The accessibility tree: `AtspiReader` and `AtspiDetector`, roles and real extents |
524
  | `showcase.py` | The capability gallery: one real screenshot per capability, checked against the public API |
525
+ | `measure_vlm.py` | Measures the four ways to ask a model, on a real screen, and prints the table |
526
+
527
+ ## The marimo console
528
+
529
+ The loop is a library, but it is also a *session* you can drive one turn at a time -
530
+ which is what a console needs: a live screen preview, an action log, and
531
+ step / pause / resume.
532
+
533
+ <img src="gallery/console.png" width="900">
534
+
535
+ `AgentLoop.run()` is the batch form; `begin()`, `step_once()`, `pause()`, `resume()`
536
+ and `finished` are the stepwise one. `tests/test_stepwise.py` asserts the two cannot
537
+ drift apart - the same scenario has to produce the same episode either way. The
538
+ console cell in this notebook wires that to marimo widgets:
539
+
540
+ - **live preview** - the current screen with the element index drawn over it, so you
541
+ can see what the agent sees and what it thinks is clickable
542
+ - **action log** - one row per step: the action, and the outcome or the refusal
543
+ - **step / run / reset** - `mo.ui.run_button`, so an auto-refresh can never fire a
544
+ step by accident
545
+ - **pause** - respected *between* steps: the episode stays open, and a single step
546
+ can still be taken through it
547
+ - **panic** - creates the same kill-switch file the CLI checks, so the next step
548
+ refuses to run
549
+
550
+ Switch the screen to "this machine, live" and it is the real desktop, indexed through
551
+ OCR or through the accessibility tree.
552
+
553
+ ## What the machine already knows: the accessibility tree
554
+
555
+ OCR reads pixels and *guesses* which marks are widgets. An operating system's
556
+ accessibility tree simply knows: every application that publishes one advertises each
557
+ button with a role, a name, and its real screen extents.
558
+
559
+ <img src="gallery/atspi-index.png" width="860">
560
+
561
+ `AtspiDetector` reads that tree through PyGObject's `Atspi` typelib and keeps only the
562
+ roles worth clicking - a frame and a panel have names too, and offering them to a
563
+ model is how you get an agent that clicks the window furniture. The coordinates come
564
+ back as absolute screen pixels, so `absolute_coordinates` tells `IndexedScreen` not to
565
+ re-map them through a downscale.
566
+
567
+ <img src="gallery/desk-atspi.png" width="860">
568
+
569
+ That click was found **without reading a single pixel**: the window's own status label
570
+ reads `last action: Save` afterwards, and the app wrote `Save` to its marker file.
571
+ `--detector best` tries the tree first, then OCR, then the numbered grid.
572
+
573
+ **Honest limits.** AT-SPI is Linux-only here; **UI Automation is not implemented**, and
574
+ `accessibility_available("uia")` says exactly that rather than pretending - so an agent
575
+ on Windows keeps using OCR. Tk barely publishes a tree and a canvas publishes none,
576
+ which is why this is a detector and not a replacement.
577
 
578
  ## Capability gallery
579
 
 
1220
  - [x] A real click on a real window, with a verified outcome
1221
  - [x] Blind-grid-click guard, echo detection, no-progress guard
1222
  - [x] The harness suite running on real Windows (Server 2022 guest, no KVM — see above)
 
 
 
 
 
1223
  - [x] Score episodes: replay a trace and count how often the choice was right - see [scoring a brain, offline](#scoring-a-brain-offline)
1224
  - [x] A capability gallery: one real screenshot per capability, generated by `showcase.py`
1225
+ - [x] The OCR guard skips instead of failing: one tested guard, `conftest.require_ocr`
1226
+ - [x] Stop the 256M VLM echoing the prompt - masked decoding, measured in
1227
+ [asking a tiny model in four ways](#asking-a-tiny-model-in-four-ways-measured)
1228
+ - [x] The marimo console: live screen preview, action log, pause / resume / step
1229
+ - [x] An accessibility-tree detector on Linux (AT-SPI), driving a real click with no OCR
1230
+ - [ ] **Make the 256M VLM choose correctly.** Masking fixed the echo and the latency; the model
1231
+ still picks the wrong item from a four-item menu of exact widgets. That is now a question
1232
+ about weights, or about a better default (the rule-based planner), not about prompt shape.
1233
+ - [ ] **UI Automation** (Windows) for the accessibility detector. The interface is here and
1234
+ `accessibility_available("uia")` reports it as not implemented rather than pretending.
1235
+ - [ ] Windows run of the *live desktop* test: the workflow is committed, but it needs a
1236
+ GitHub mirror with Actions enabled — Hugging Face does not run GitHub Actions
1237
+ - [ ] A **Windows** run of the gallery: the workflow builds it on `windows-latest` (job
1238
+ `gallery-windows`), with the same GitHub-mirror caveat
1239
 
1240
  ## License
1241
 
 
1377
  "tiny_computer_agent_app.py": _notebook.read_text(encoding="utf-8"),
1378
  }
1379
  # the tool that builds the capability gallery, shipped with the card it fills
1380
+ for _tool in ("showcase.py", "showcase_core.py", "showcase_catalog.py",
1381
+ "measure_vlm.py"):
1382
  if (_root / _tool).is_file():
1383
  _files[_tool] = (_root / _tool).read_text(encoding="utf-8")
1384
  _folders = [f for f in ("tiny_computer_agent", "tests", "gallery",
 
1575
  return
1576
 
1577
 
1578
+ @app.cell
1579
+ def console_setup():
1580
+ # The console's controls. This cell owns the mutable session, so the console below
1581
+ # can re-run on every click without ever losing the episode in progress.
1582
+ import tempfile
1583
+
1584
+ console_goal = mo.ui.text(value="click 'Save' to save the document", label="goal",
1585
+ full_width=True)
1586
+ console_brain = mo.ui.dropdown(
1587
+ options={"planner (rule-based, no weights)": "planner", "scripted clicks": "scripted"},
1588
+ value="planner (rule-based, no weights)", label="brain")
1589
+ console_screen = mo.ui.dropdown(
1590
+ options={"replayed screenshot + real OCR": "image", "this machine, live": "live"},
1591
+ value="replayed screenshot + real OCR", label="screen")
1592
+ console_interval = mo.ui.refresh(options=[5, 10, 30], default_interval=10,
1593
+ label="live preview")
1594
+ console_step = mo.ui.run_button(label="▶ step")
1595
+ console_run = mo.ui.run_button(label="⏩ run", kind="success")
1596
+ console_reset = mo.ui.run_button(label="⟲ reset", kind="warn")
1597
+ console_pause = mo.ui.switch(label="pause", value=False)
1598
+ console_real_input = mo.ui.switch(label="real mouse (needs the allow env var)", value=False)
1599
+ console_panic = mo.ui.switch(label="panic: create the kill switch", value=False)
1600
+
1601
+ console_session = {
1602
+ "loop": None,
1603
+ "signature": None,
1604
+ "kill": str(Path(tempfile.gettempdir()) / "tiny-computer-agent.console.STOP"),
1605
+ }
1606
+
1607
+ return (
1608
+ console_brain,
1609
+ console_goal,
1610
+ console_interval,
1611
+ console_panic,
1612
+ console_pause,
1613
+ console_real_input,
1614
+ console_reset,
1615
+ console_run,
1616
+ console_screen,
1617
+ console_session,
1618
+ console_step,
1619
+ )
1620
+
1621
+
1622
+ @app.cell
1623
+ def console(
1624
+ console_brain,
1625
+ console_goal,
1626
+ console_interval,
1627
+ console_panic,
1628
+ console_pause,
1629
+ console_real_input,
1630
+ console_reset,
1631
+ console_run,
1632
+ console_screen,
1633
+ console_session,
1634
+ console_step,
1635
+ ):
1636
+ # The console: live screen preview, the action log, and step / pause / resume.
1637
+ from PIL import ImageDraw
1638
+
1639
+ _ALLOW_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
1640
+ _goal = console_goal.value.strip() or "click 'Save' to save the document"
1641
+ _signature = (_goal, console_brain.value, console_screen.value,
1642
+ bool(console_real_input.value))
1643
+ _session = console_session
1644
+ _loop = _session["loop"]
1645
+
1646
+ # the panic switch drives the same kill-switch file the CLI checks
1647
+ if console_panic.value:
1648
+ Path(_session["kill"]).write_text("stop")
1649
+ else:
1650
+ Path(_session["kill"]).unlink(missing_ok=True)
1651
+
1652
+ if _loop is None or _session["signature"] != _signature:
1653
+ if console_brain.value == "scripted clicks":
1654
+ _brain = MockBrain([{"kind": "click", "label": "Save"},
1655
+ {"kind": "done", "reason": "clicked Save"}])
1656
+ else:
1657
+ _brain = PlannerBrain()
1658
+ if console_screen.value == "this machine, live":
1659
+ _screen = IndexedScreen(GrabScreen(monitor=1), OcrDetector())
1660
+ else:
1661
+ _screen = IndexedScreen(ImageScreen(fake_desktop()), OcrDetector())
1662
+ _real = bool(console_real_input.value) and os.environ.get(_ALLOW_ENV) == "1"
1663
+ _loop = AgentLoop(
1664
+ screen=_screen, brain=_brain,
1665
+ executor=PyAutoGuiExecutor() if _real else DryRunExecutor(),
1666
+ policy=Policy(dry_run=not _real, step_budget=8, kill_switch_path=_session["kill"]),
1667
+ )
1668
+ _loop.begin(_goal)
1669
+ _session.update(loop=_loop, signature=_signature)
1670
+
1671
+ # the controls: run_button is True only on the run its own click started, so an
1672
+ # auto-refresh can never fire a step by accident
1673
+ _loop.paused = bool(console_pause.value)
1674
+ if console_reset.value:
1675
+ _loop.begin(_goal)
1676
+ if console_step.value:
1677
+ _loop.paused = False # a single step is allowed through a pause
1678
+ _loop.step_once()
1679
+ if console_run.value:
1680
+ _loop.run(_goal, max_steps=8) # respects the pause switch, and the goal
1681
+
1682
+ # a live peek at the screen, without taking a turn
1683
+ _observation = _loop.observation
1684
+ if console_screen.value == "this machine, live":
1685
+ _observation = _loop.screen.capture()
1686
+ _armed = console_interval.value # referencing it arms auto-refresh
1687
+
1688
+ _preview = None
1689
+ if _observation is not None and hasattr(_observation.frame, "convert"):
1690
+ _preview = _observation.frame.convert("RGB").copy()
1691
+ _pen = ImageDraw.Draw(_preview)
1692
+ for _element in _observation.elements:
1693
+ _pen.rectangle(
1694
+ [_element.x, _element.y,
1695
+ _element.x + max(_element.width, 1), _element.y + max(_element.height, 1)],
1696
+ outline=(255, 138, 0), width=2)
1697
+ _pen.text((_element.x + 3, max(0, _element.y - 12)), _element.label[:30],
1698
+ fill=(210, 90, 0))
1699
+
1700
+ _rows = [{"step": _step.index,
1701
+ "action": _step.action.describe() if _step.action else "",
1702
+ "outcome": _step.error or (_step.result.summary() if _step.result else "")}
1703
+ for _step in _loop.episode.steps]
1704
+
1705
+ _state = "finished" if _loop.finished else ("paused" if _loop.paused else "ready")
1706
+ _mode = "REAL mouse" if not _loop.executor.dry_run else "dry run (nothing is clicked)"
1707
+ console_status = mo.md(
1708
+ f"**{_state}** · {len(_rows)} step(s) · {_mode} · budget {_loop.policy.step_budget} · "
1709
+ f"stop: `{_loop.episode.stop_reason}`"
1710
+ )
1711
+ if console_real_input.value and os.environ.get(_ALLOW_ENV) != "1":
1712
+ console_status = mo.vstack([
1713
+ console_status,
1714
+ mo.md(f"`{_ALLOW_ENV}` is not set, so this stays a dry run.").callout(kind="warn"),
1715
+ ])
1716
+
1717
+ mo.vstack([
1718
+ mo.hstack([console_goal, console_brain, console_screen, console_interval],
1719
+ justify="start", wrap=True),
1720
+ mo.hstack([console_step, console_run, console_pause, console_reset,
1721
+ console_real_input, console_panic], justify="start", wrap=True),
1722
+ console_status,
1723
+ mo.image(_preview) if _preview is not None else mo.md("_no frame yet_"),
1724
+ mo.ui.table(_rows, selection=None) if _rows else mo.md("_no actions yet_"),
1725
+ ])
1726
+
1727
+ return
1728
+
1729
+
1730
  if __name__ == "__main__":
1731
  app.run()