2.0: skills, approval gates, a resumable task runner, structural verification
Browse files- README.md +35 -15
- pyproject.toml +2 -2
- tasks/day.json +12 -0
- tests/test_api_surface.py +3 -1
- tests/test_cli.py +84 -0
- tests/test_desktop.py +27 -0
- tests/test_diff.py +91 -0
- tests/test_gates.py +97 -0
- tests/test_runner.py +143 -0
- tests/test_skills.py +205 -0
- tiny_computer_agent/__init__.py +52 -22
- tiny_computer_agent/__main__.py +197 -0
- tiny_computer_agent/desktop.py +12 -2
- tiny_computer_agent/diff.py +154 -0
- tiny_computer_agent/gates.py +163 -0
- tiny_computer_agent/loop.py +14 -3
- tiny_computer_agent/runner.py +236 -0
- tiny_computer_agent/skills.py +329 -0
- tiny_computer_agent_app.py +39 -19
README.md
CHANGED
|
@@ -22,9 +22,13 @@ tags:
|
|
| 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.
|
| 26 |
-
> an OCR element index with real coordinates, a real mouse click, and a
|
| 27 |
-
> outcome.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
> The loop can be driven from a [marimo console](#the-marimo-console) with a live preview, an
|
| 29 |
> action log and pause / resume / step; elements can come from the
|
| 30 |
> [accessibility tree](#what-the-machine-already-knows-the-accessibility-tree) instead of OCR;
|
|
@@ -90,7 +94,7 @@ X server. First the suite, headless:
|
|
| 90 |
```bash
|
| 91 |
python -m pip install -e ".[desktop,ocr,dev]"
|
| 92 |
pytest tests -q
|
| 93 |
-
# 227 passed, 2 skipped in 18.7s
|
| 94 |
```
|
| 95 |
|
| 96 |
The two skips are the live-desktop tests. Give them a display and they run for real:
|
|
@@ -262,7 +266,7 @@ open problem: it is reliable on a clean screenshot and unreliable on a busy live
|
|
| 262 |
`warning: no text was found on screen, so the index is 96 numbered grid cells and the
|
| 263 |
agent is choosing blind` — instead of silently guessing.
|
| 264 |
|
| 265 |
-
**
|
| 266 |
that click a real button.
|
| 267 |
|
| 268 |
## Verified on real Windows
|
|
@@ -310,7 +314,7 @@ the harness itself, installed into site-packages
|
|
| 310 |
|
| 311 |
None of the three failures in the first row is a harness defect: two are
|
| 312 |
`ModuleNotFoundError: numpy` and the third is the guard bug documented below. With the
|
| 313 |
-
OCR engine installed, the same suite exits **0**. (Those Windows counts predate `trace.py`; the harness suite has since grown to
|
| 314 |
|
| 315 |
**The emulated-OCR caveat.** onnxruntime requires AVX2, and QEMU's software emulation of
|
| 316 |
AVX2 is roughly two orders of magnitude slower than native. A suite that finishes in 17
|
|
@@ -336,9 +340,10 @@ exactly the kind of thing that only surfaces on a machine without an OCR engine.
|
|
| 336 |
|
| 337 |
## The test suite, in full
|
| 338 |
|
| 339 |
-
`pytest tests` collects **
|
| 340 |
weights. The last 2 are the opt-in live-desktop tests, skipped unless
|
| 341 |
-
`TINY_COMPUTER_AGENT_LIVE_TEST=1`.
|
|
|
|
| 342 |
|
| 343 |
| File | Tests | What it proves |
|
| 344 |
|---|---:|---|
|
|
@@ -352,19 +357,24 @@ weights. The last 2 are the opt-in live-desktop tests, skipped unless
|
|
| 352 |
| `tests/test_safety.py` | 11 | deny lists, the kill switch, the blind-grid-click guard |
|
| 353 |
| `tests/test_grid_fallback.py` | 11 | the fallback index announces itself instead of guessing |
|
| 354 |
| `tests/test_desktop.py` | 10 | display detection, the preflight checks, `doctor` |
|
| 355 |
-
| `tests/test_cli.py` |
|
| 356 |
| `tests/test_trace.py` | 28 | traces: a run recorded with no pixels, replayed against another brain, and scored against ground truth |
|
| 357 |
| `tests/test_models.py` | 7 | the model backends: lazy loading, device choice, honest refusals |
|
| 358 |
| `tests/test_api_surface.py` | 4 | the public API is a promise: everything advertised is importable, and nothing public is left out |
|
| 359 |
| `tests/test_decoding.py` | 24 | the mask, the answer space, the retry ladder, the vote - and that an echo is refused |
|
| 360 |
-
| `tests/test_accessibility.py` |
|
|
|
|
| 361 |
| `tests/test_stepwise.py` | 11 | begin / step / pause / resume, and that it equals the batch loop |
|
| 362 |
| `tests/test_ocr_guard.py` | 4 | the OCR guard really skips with no engine - the guard that never fired |
|
|
|
|
|
|
|
|
|
|
| 363 |
| `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 |
|
| 364 |
|
| 365 |
```bash
|
| 366 |
-
pytest tests -q #
|
| 367 |
-
|
|
|
|
| 368 |
```
|
| 369 |
|
| 370 |
## Install
|
|
@@ -373,7 +383,7 @@ TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q # 229 passed, on a desktop
|
|
| 373 |
pip install -e ".[ocr]" # labels from pixels (RapidOCR: no system binary)
|
| 374 |
pip install -e ".[desktop,ocr]" # + real capture and real input
|
| 375 |
pip install -e ".[vision,ocr]" # + the tiny model brains
|
| 376 |
-
pytest #
|
| 377 |
```
|
| 378 |
|
| 379 |
The accessibility detector needs system packages rather than an extra:
|
|
@@ -485,6 +495,10 @@ this: an answer file with non-canonical XML namespaces is **silently ignored** b
|
|
| 485 |
| `trace.py` | Portable traces: record a run with no pixels, replay it offline, score the choice against ground truth |
|
| 486 |
| `decoding.py` | Constrained decoding: mask the logits to a menu number or a yes/no, so an echo is impossible |
|
| 487 |
| `accessibility.py` | The accessibility tree: `AtspiReader` and `AtspiDetector`, roles and real extents |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
| `showcase.py` | The capability gallery: one real screenshot per capability, checked against the public API |
|
| 489 |
| `measure_vlm.py` | Measures the four ways to ask a model, on a real screen, and prints the table |
|
| 490 |
| `evaluate_daily.py` | A day's tasks against a real app, verified by the app's own log |
|
|
@@ -591,7 +605,7 @@ eleven tasks are a sample, not a survey.
|
|
| 591 |
|
| 592 |
## Capability gallery
|
| 593 |
|
| 594 |
-
**74 capabilities, one screenshot each - and
|
| 595 |
|
| 596 |
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.
|
| 597 |
|
|
@@ -1218,7 +1232,8 @@ success.
|
|
| 1218 |
|
| 1219 |
**Works:** real capture, a real OCR index with coordinates that land on the right pixel,
|
| 1220 |
a real click an app reacts to, verified success, the closed vocabulary, the policy gates,
|
| 1221 |
-
the no-progress guard, echo detection, the doctor, the CLI, portable traces that score a brain's choices offline,
|
|
|
|
| 1222 |
reads a button label from pixels on a clean screenshot — and the same suite passing inside
|
| 1223 |
a real Windows Server 2022 guest with no KVM (109 passed, exit 0, with the OCR-inference
|
| 1224 |
tests excluded as impractically slow under emulation).
|
|
@@ -1247,6 +1262,11 @@ when no verifier is given.
|
|
| 1247 |
- [x] A daily-usage evaluation: 11 tasks (menu, scroll, typing, shortcut, second window,
|
| 1248 |
verify, refuse, kill switch) against a real GTK app, checked against the application's
|
| 1249 |
own log - see [can it do a day's work](#can-it-do-a-days-work)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1250 |
- [ ] **Verify UI Automation** (Windows) against a real Windows desktop. The reader and the
|
| 1251 |
detector are implemented and unit-tested against a synthetic control tree, and
|
| 1252 |
`accessibility_available("uia")` reports the truth on any machine - but no Windows
|
|
|
|
| 22 |
No cloud API, no API keys, no telemetry — and a core that imports nothing outside the
|
| 23 |
Python standard library.
|
| 24 |
|
| 25 |
+
> **Status 2.0.0: it drives a real desktop — on Linux and on Windows — and it learns.**
|
| 26 |
+
> Screen capture, an OCR element index with real coordinates, a real mouse click, and a
|
| 27 |
+
> *verified* outcome. A verified run can be written down as a **skill** and replayed with no
|
| 28 |
+
> model at all; a queue of goals can be run, resumed after a kill, and reported on; an
|
| 29 |
+
> **approval gate** can stand in front of every action; and what changed on screen is a
|
| 30 |
+
> **measured** structural diff rather than a guess.
|
| 31 |
+
> **All 74 capabilities have a real screenshot** in the [capability gallery](#capability-gallery) (Linux captures).
|
| 32 |
> The loop can be driven from a [marimo console](#the-marimo-console) with a live preview, an
|
| 33 |
> action log and pause / resume / step; elements can come from the
|
| 34 |
> [accessibility tree](#what-the-machine-already-knows-the-accessibility-tree) instead of OCR;
|
|
|
|
| 94 |
```bash
|
| 95 |
python -m pip install -e ".[desktop,ocr,dev]"
|
| 96 |
pytest tests -q
|
| 97 |
+
# 227 passed, 2 skipped in 18.7s (that run was 0.3.0; 2.0 has grown this to 284)
|
| 98 |
```
|
| 99 |
|
| 100 |
The two skips are the live-desktop tests. Give them a display and they run for real:
|
|
|
|
| 266 |
`warning: no text was found on screen, so the index is 96 numbered grid cells and the
|
| 267 |
agent is choosing blind` — instead of silently guessing.
|
| 268 |
|
| 269 |
+
**284 tests pass**, including real OCR over a real image and (opt-in) live-desktop tests
|
| 270 |
that click a real button.
|
| 271 |
|
| 272 |
## Verified on real Windows
|
|
|
|
| 314 |
|
| 315 |
None of the three failures in the first row is a harness defect: two are
|
| 316 |
`ModuleNotFoundError: numpy` and the third is the guard bug documented below. With the
|
| 317 |
+
OCR engine installed, the same suite exits **0**. (Those Windows counts predate `trace.py` and 2.0; the harness suite has since grown to 284 tests.)
|
| 318 |
|
| 319 |
**The emulated-OCR caveat.** onnxruntime requires AVX2, and QEMU's software emulation of
|
| 320 |
AVX2 is roughly two orders of magnitude slower than native. A suite that finishes in 17
|
|
|
|
| 340 |
|
| 341 |
## The test suite, in full
|
| 342 |
|
| 343 |
+
`pytest tests` collects **284 tests**. 282 run headless — no display, no network, no
|
| 344 |
weights. The last 2 are the opt-in live-desktop tests, skipped unless
|
| 345 |
+
`TINY_COMPUTER_AGENT_LIVE_TEST=1`. On a box without PyGObject, two of the 282 *fail* rather
|
| 346 |
+
than skip: they assert the AT-SPI reader is reachable, and that is the honest outcome.
|
| 347 |
|
| 348 |
| File | Tests | What it proves |
|
| 349 |
|---|---:|---|
|
|
|
|
| 357 |
| `tests/test_safety.py` | 11 | deny lists, the kill switch, the blind-grid-click guard |
|
| 358 |
| `tests/test_grid_fallback.py` | 11 | the fallback index announces itself instead of guessing |
|
| 359 |
| `tests/test_desktop.py` | 10 | display detection, the preflight checks, `doctor` |
|
| 360 |
+
| `tests/test_cli.py` | 16 | the command line: `--doctor`, `--image`, `--live`, and the 2.0 `skills` / `batch` subcommands |
|
| 361 |
| `tests/test_trace.py` | 28 | traces: a run recorded with no pixels, replayed against another brain, and scored against ground truth |
|
| 362 |
| `tests/test_models.py` | 7 | the model backends: lazy loading, device choice, honest refusals |
|
| 363 |
| `tests/test_api_surface.py` | 4 | the public API is a promise: everything advertised is importable, and nothing public is left out |
|
| 364 |
| `tests/test_decoding.py` | 24 | the mask, the answer space, the retry ladder, the vote - and that an echo is refused |
|
| 365 |
+
| `tests/test_accessibility.py` | 26 | role filtering, the coordinate contract, the fallback chain, and real AT-SPI |
|
| 366 |
+
| `tests/test_diff.py` | 8 | what changed between two frames: added, removed, moved, and the verifier that remembers |
|
| 367 |
| `tests/test_stepwise.py` | 11 | begin / step / pause / resume, and that it equals the batch loop |
|
| 368 |
| `tests/test_ocr_guard.py` | 4 | the OCR guard really skips with no engine - the guard that never fired |
|
| 369 |
+
| `tests/test_gates.py` | 8 | the operator's yes: whitelists, ask-once gates, and a refusal that is a step |
|
| 370 |
+
| `tests/test_skills.py` | 17 | only a verified run becomes a skill; re-grounding by label; aiming at nothing raises |
|
| 371 |
+
| `tests/test_runner.py` | 10 | a queue, a JSONL resume file, and a report counts what was verified |
|
| 372 |
| `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 |
|
| 373 |
|
| 374 |
```bash
|
| 375 |
+
pytest tests -q # 274 passed, 8 skipped, 2 failed only where
|
| 376 |
+
# PyGObject/AT-SPI is missing (gir1.2-atspi-2.0)
|
| 377 |
+
TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q # + the 2 live-desktop tests, on a desktop
|
| 378 |
```
|
| 379 |
|
| 380 |
## Install
|
|
|
|
| 383 |
pip install -e ".[ocr]" # labels from pixels (RapidOCR: no system binary)
|
| 384 |
pip install -e ".[desktop,ocr]" # + real capture and real input
|
| 385 |
pip install -e ".[vision,ocr]" # + the tiny model brains
|
| 386 |
+
pytest # 284 tests, no display or network needed
|
| 387 |
```
|
| 388 |
|
| 389 |
The accessibility detector needs system packages rather than an extra:
|
|
|
|
| 495 |
| `trace.py` | Portable traces: record a run with no pixels, replay it offline, score the choice against ground truth |
|
| 496 |
| `decoding.py` | Constrained decoding: mask the logits to a menu number or a yes/no, so an echo is impossible |
|
| 497 |
| `accessibility.py` | The accessibility tree: `AtspiReader` and `AtspiDetector`, roles and real extents |
|
| 498 |
+
| `diff.py` | What changed between two frames: `Delta` (added / removed / moved) and `ChangeVerifier`, so "the dialog closed" is checkable |
|
| 499 |
+
| `skills.py` | **2.0** - Skills: a *verified* run written down once, replayed by `SkillBrain` with no model. Labels, never coordinates |
|
| 500 |
+
| `gates.py` | **2.0** - Approval gates: `AllowOnlyLabels`, `InteractiveGate` (yes / no / always / never), `AllOf` / `AnyOf`. A refusal is a step |
|
| 501 |
+
| `runner.py` | **2.0** - `TaskRunner`: a queue of goals, a JSONL resume file, and a `RunReport` that counts what was *verified* |
|
| 502 |
| `showcase.py` | The capability gallery: one real screenshot per capability, checked against the public API |
|
| 503 |
| `measure_vlm.py` | Measures the four ways to ask a model, on a real screen, and prints the table |
|
| 504 |
| `evaluate_daily.py` | A day's tasks against a real app, verified by the app's own log |
|
|
|
|
| 605 |
|
| 606 |
## Capability gallery
|
| 607 |
|
| 608 |
+
**74 capabilities, one screenshot each - and 173 of the package's 180 pre-2.0 public callables (functions, methods, classes and protocols) appear in at least one of them**, together with 10 of its public constants. The 2.0 additions are not pictured yet: **84 new public callables** (skills, approval gates, the batch runner and the structural diff) plus four new CLI entry points. The counts are read off `gallery/index.json`, which records what each picture shows.
|
| 609 |
|
| 610 |
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.
|
| 611 |
|
|
|
|
| 1232 |
|
| 1233 |
**Works:** real capture, a real OCR index with coordinates that land on the right pixel,
|
| 1234 |
a real click an app reacts to, verified success, the closed vocabulary, the policy gates,
|
| 1235 |
+
the no-progress guard, echo detection, the doctor, the CLI, portable traces that score a brain's choices offline, skills learned from verified runs,
|
| 1236 |
+
a resumable task runner, operator approval gates, 284 tests, a 256M VLM that
|
| 1237 |
reads a button label from pixels on a clean screenshot — and the same suite passing inside
|
| 1238 |
a real Windows Server 2022 guest with no KVM (109 passed, exit 0, with the OCR-inference
|
| 1239 |
tests excluded as impractically slow under emulation).
|
|
|
|
| 1262 |
- [x] A daily-usage evaluation: 11 tasks (menu, scroll, typing, shortcut, second window,
|
| 1263 |
verify, refuse, kill switch) against a real GTK app, checked against the application's
|
| 1264 |
own log - see [can it do a day's work](#can-it-do-a-days-work)
|
| 1265 |
+
- [x] **2.0** Skills: a verified run is written down once and replayed with no model at all
|
| 1266 |
+
- [x] **2.0** Operator approval gates - a label whitelist, or ask before every action
|
| 1267 |
+
- [x] **2.0** A resumable task queue with a report, and structural verification of "it worked"
|
| 1268 |
+
- [x] **2.0** `skills` and `batch` subcommands, and an example queue at `tasks/day.json`
|
| 1269 |
+
- [ ] A gallery run that pictures the 2.0 additions: the 84 new public callables have no screenshot yet
|
| 1270 |
- [ ] **Verify UI Automation** (Windows) against a real Windows desktop. The reader and the
|
| 1271 |
detector are implemented and unit-tested against a synthetic control tree, and
|
| 1272 |
`accessibility_available("uia")` reports the truth on any machine - but no Windows
|
pyproject.toml
CHANGED
|
@@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta"
|
|
| 5 |
|
| 6 |
[project]
|
| 7 |
name = "tiny-computer-agent"
|
| 8 |
-
version = "0.
|
| 9 |
-
description = "An agentic harness that drives a real computer with a tiny, fully local model - no cloud API."
|
| 10 |
readme = "README.md"
|
| 11 |
requires-python = ">=3.10"
|
| 12 |
license = { text = "MIT" }
|
|
|
|
| 5 |
|
| 6 |
[project]
|
| 7 |
name = "tiny-computer-agent"
|
| 8 |
+
version = "2.0.0"
|
| 9 |
+
description = "An agentic harness that drives a real computer with a tiny, fully local model - no cloud API. It learns from verified runs."
|
| 10 |
readme = "README.md"
|
| 11 |
requires-python = ">=3.10"
|
| 12 |
license = { text = "MIT" }
|
tasks/day.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"goal": "click 'Save' to save the document",
|
| 4 |
+
"name": "save",
|
| 5 |
+
"verify_gone": "Name"
|
| 6 |
+
},
|
| 7 |
+
{
|
| 8 |
+
"goal": "click 'Files' to open the file manager",
|
| 9 |
+
"name": "open-files",
|
| 10 |
+
"verify_text": "untitled"
|
| 11 |
+
}
|
| 12 |
+
]
|
tests/test_api_surface.py
CHANGED
|
@@ -19,7 +19,9 @@ def test_all_is_sorted_and_free_of_duplicates():
|
|
| 19 |
def test_no_public_callable_in_a_module_is_missing_from_all():
|
| 20 |
"""A capability a user cannot import is a capability they cannot use."""
|
| 21 |
modules = ["actions", "act", "observe", "perception", "brain", "models", "loop",
|
| 22 |
-
"safety", "desktop", "demo", "trace"
|
|
|
|
|
|
|
| 23 |
unexported = []
|
| 24 |
for name in modules:
|
| 25 |
module = importlib.import_module(f"tiny_computer_agent.{name}")
|
|
|
|
| 19 |
def test_no_public_callable_in_a_module_is_missing_from_all():
|
| 20 |
"""A capability a user cannot import is a capability they cannot use."""
|
| 21 |
modules = ["actions", "act", "observe", "perception", "brain", "models", "loop",
|
| 22 |
+
"safety", "desktop", "demo", "trace",
|
| 23 |
+
# 2.0: the operator layer, all of which must stay importable from the root
|
| 24 |
+
"diff", "gates", "runner", "skills"]
|
| 25 |
unexported = []
|
| 26 |
for name in modules:
|
| 27 |
module = importlib.import_module(f"tiny_computer_agent.{name}")
|
tests/test_cli.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
import pytest
|
| 2 |
|
| 3 |
from tiny_computer_agent import __main__ as cli
|
|
@@ -69,3 +71,85 @@ def test_a_missing_image_is_reported_not_crashed(capsys):
|
|
| 69 |
def test_vlm_brain_requires_a_live_screen(capsys):
|
| 70 |
assert cli.main(["--brain", "vlm"]) == 2
|
| 71 |
assert "needs --live" in capsys.readouterr().err
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
from tiny_computer_agent import __main__ as cli
|
|
|
|
| 71 |
def test_vlm_brain_requires_a_live_screen(capsys):
|
| 72 |
assert cli.main(["--brain", "vlm"]) == 2
|
| 73 |
assert "needs --live" in capsys.readouterr().err
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# --- 2.0: the subcommands, which are how a run becomes a habit --------------- #
|
| 77 |
+
|
| 78 |
+
def test_skills_list_on_an_empty_library_says_how_to_start(tmp_path, capsys):
|
| 79 |
+
assert cli.main(["skills", "list", "--path", str(tmp_path / "none.json")]) == 0
|
| 80 |
+
assert "no skills" in capsys.readouterr().out
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def test_learning_refuses_without_evidence(tmp_path, capsys):
|
| 84 |
+
code = cli.main(["skills", "learn", "click 'Save'",
|
| 85 |
+
"--script", '[{"kind": "click", "label": "Save"}]',
|
| 86 |
+
"--path", str(tmp_path / "skills.json")])
|
| 87 |
+
assert code == 2
|
| 88 |
+
assert "refusing to learn" in capsys.readouterr().err
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_learn_then_list_then_show_then_remove(tmp_path, capsys):
|
| 92 |
+
path = str(tmp_path / "skills.json")
|
| 93 |
+
script = '[{"kind": "click", "label": "Files"}, {"kind": "click", "label": "Save"}]'
|
| 94 |
+
|
| 95 |
+
assert cli.main(["skills", "learn", "click 'Save' to save the document",
|
| 96 |
+
"--script", script, "--verify-gone", "Name", "--path", path]) == 0
|
| 97 |
+
assert "learned 'save-document'" in capsys.readouterr().out
|
| 98 |
+
|
| 99 |
+
assert cli.main(["skills", "list", "--path", path]) == 0
|
| 100 |
+
out = capsys.readouterr().out
|
| 101 |
+
assert "save-document" in out and "verified" in out
|
| 102 |
+
|
| 103 |
+
assert cli.main(["skills", "show", "save-document", "--path", path]) == 0
|
| 104 |
+
assert "'Name' goes away" in capsys.readouterr().out
|
| 105 |
+
|
| 106 |
+
assert cli.main(["skills", "remove", "save-document", "--path", path]) == 0
|
| 107 |
+
assert "removed" in capsys.readouterr().out
|
| 108 |
+
assert cli.main(["skills", "show", "save-document", "--path", path]) == 1
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_a_learned_skill_can_be_replayed_by_the_brain(tmp_path):
|
| 112 |
+
"""The point of the whole feature: the second run needs no script."""
|
| 113 |
+
path = tmp_path / "skills.json"
|
| 114 |
+
script = '[{"kind": "click", "label": "Files"}, {"kind": "click", "label": "Save"}]'
|
| 115 |
+
assert cli.main(["skills", "learn", "click 'Save' to save the document",
|
| 116 |
+
"--script", script, "--verify-gone", "Name", "--path", str(path)]) == 0
|
| 117 |
+
|
| 118 |
+
from tiny_computer_agent import SkillBrain, SkillLibrary
|
| 119 |
+
from tiny_computer_agent.__main__ import demo_screen
|
| 120 |
+
from tiny_computer_agent.loop import AgentLoop
|
| 121 |
+
|
| 122 |
+
library = SkillLibrary.load(path)
|
| 123 |
+
loop = AgentLoop(screen=demo_screen(), brain=SkillBrain(library))
|
| 124 |
+
episode = loop.run("click 'Save' to save the document")
|
| 125 |
+
|
| 126 |
+
assert [s.action.label for s in episode.steps if s.action and s.action.kind == "click"] \
|
| 127 |
+
== ["Files", "Save"]
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_batch_runs_a_queue_and_exits_on_its_report(tmp_path, capsys):
|
| 131 |
+
tasks = tmp_path / "tasks.json"
|
| 132 |
+
tasks.write_text(json.dumps([{"goal": "click 'Save' to save the document",
|
| 133 |
+
"verify_gone": "Name"},
|
| 134 |
+
{"goal": "click 'Files' to open the file manager",
|
| 135 |
+
"verify_text": "untitled"}]))
|
| 136 |
+
state = tmp_path / "state.jsonl"
|
| 137 |
+
report = tmp_path / "report.json"
|
| 138 |
+
|
| 139 |
+
assert cli.main(["batch", str(tasks), "--state", str(state), "--report", str(report)]) == 0
|
| 140 |
+
out = capsys.readouterr().out
|
| 141 |
+
assert "2/2 verified" in out
|
| 142 |
+
assert json.loads(report.read_text())["passed"] == 2
|
| 143 |
+
|
| 144 |
+
assert cli.main(["batch", str(tasks), "--state", str(state)]) == 0
|
| 145 |
+
assert "already run" in capsys.readouterr().out
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def test_batch_rejects_tasks_that_are_not_tasks(tmp_path, capsys):
|
| 149 |
+
bad = tmp_path / "bad.json"
|
| 150 |
+
bad.write_text(json.dumps([{"nothing": "here"}]))
|
| 151 |
+
assert cli.main(["batch", str(bad)]) == 2
|
| 152 |
+
assert "needs at least a 'goal'" in capsys.readouterr().err
|
| 153 |
+
missing = tmp_path / "absent.json"
|
| 154 |
+
assert cli.main(["batch", str(missing)]) == 2
|
| 155 |
+
assert "cannot read" in capsys.readouterr().err
|
tests/test_desktop.py
CHANGED
|
@@ -92,3 +92,30 @@ def test_build_agent_wires_the_kill_switch(tmp_path):
|
|
| 92 |
kill_switch_path=str(marker))
|
| 93 |
assert loop.policy.kill_switch_path == str(marker)
|
| 94 |
assert DEFAULT_KILL_SWITCH.endswith("tiny-computer-agent.STOP")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
kill_switch_path=str(marker))
|
| 93 |
assert loop.policy.kill_switch_path == str(marker)
|
| 94 |
assert DEFAULT_KILL_SWITCH.endswith("tiny-computer-agent.STOP")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_the_one_call_builder_can_wire_learned_skills_and_a_gate():
|
| 98 |
+
"""2.0: one call wires the desktop, what it has learned, and the operator's gate."""
|
| 99 |
+
from tiny_computer_agent import (AllowOnlyLabels, Element, FakeScreen, Policy, Skill,
|
| 100 |
+
SkillBrain, SkillLibrary, SkillStep, Action, build_agent)
|
| 101 |
+
|
| 102 |
+
library = SkillLibrary()
|
| 103 |
+
library.add(Skill(name="save", goal="click 'Save' to save the document", verified=True,
|
| 104 |
+
steps=(SkillStep(Action("click", label="Save"), aimed_at="Save"),)))
|
| 105 |
+
gate = AllowOnlyLabels(["Save"])
|
| 106 |
+
|
| 107 |
+
loop = build_agent(brain="planner", screen=FakeScreen([(Element("Save", x=10, y=10, width=60, height=20),)]),
|
| 108 |
+
detector="ocr", dry_run=True, skills=library, gate=gate)
|
| 109 |
+
|
| 110 |
+
assert isinstance(loop.brain, SkillBrain)
|
| 111 |
+
assert loop.gate is gate
|
| 112 |
+
episode = loop.run("click 'Save' to save the document")
|
| 113 |
+
assert [s.action.label for s in episode.steps if s.action and s.action.kind == "click"] == ["Save"]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_without_skills_the_builder_leaves_the_brain_alone():
|
| 117 |
+
from tiny_computer_agent import Element, FakeScreen, PlannerBrain, build_agent
|
| 118 |
+
|
| 119 |
+
loop = build_agent(brain="planner", screen=FakeScreen([(Element("Save", x=1, y=1, width=9, height=9),)]),
|
| 120 |
+
detector="ocr")
|
| 121 |
+
assert isinstance(loop.brain, PlannerBrain) and loop.gate is None
|
tests/test_diff.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A change you cannot measure is a change you cannot verify."""
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import ChangeVerifier, Delta, Element, Observation, diff, element_key
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def frame(elements, text=""):
|
| 7 |
+
return Observation(index=0, frame="frame", elements=tuple(elements), text=text)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def button(label, x=10, y=10, width=60, height=20, kind="button"):
|
| 11 |
+
return Element(label, kind=kind, x=x, y=y, width=width, height=height)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_nothing_changed_is_measured_rather_than_guessed():
|
| 15 |
+
picture = frame([button("Save", x=100)])
|
| 16 |
+
delta = diff(picture, picture)
|
| 17 |
+
|
| 18 |
+
assert delta.empty and not delta.changed
|
| 19 |
+
assert delta.summary() == "no change"
|
| 20 |
+
assert isinstance(delta, Delta)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_a_dialog_closing_is_a_removed_element_not_a_missing_string():
|
| 24 |
+
"""The whole point: verification without depending on the dialog's own words."""
|
| 25 |
+
before = frame([button("Save As"), button("Cancel", x=80)])
|
| 26 |
+
after = frame([button("Save As")])
|
| 27 |
+
delta = diff(before, after)
|
| 28 |
+
|
| 29 |
+
assert delta.closed and not delta.opened
|
| 30 |
+
assert [e.label for e in delta.removed] == ["Cancel"]
|
| 31 |
+
assert delta.matches(disappeared=["cancel"])
|
| 32 |
+
assert not delta.matches(appeared=["cancel"])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_a_menu_opening_is_an_added_element():
|
| 36 |
+
delta = diff(frame([button("File")]), frame([button("File"), button("Save As", y=60)]))
|
| 37 |
+
|
| 38 |
+
assert delta.opened and not delta.closed
|
| 39 |
+
assert [e.label for e in delta.added] == ["Save As"]
|
| 40 |
+
assert delta.matches(appeared=["save as"])
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_a_redraw_is_not_movement_and_a_real_move_is():
|
| 44 |
+
still = diff(frame([button("Save", x=100)]), frame([button("Save", x=101)]))
|
| 45 |
+
assert still.empty, "a one-pixel re-render is not a change"
|
| 46 |
+
|
| 47 |
+
moved = diff(frame([button("Save", x=100)]), frame([button("Save", x=400)]))
|
| 48 |
+
assert moved.moved and moved.settled, "nothing appeared or vanished: it moved"
|
| 49 |
+
assert not moved.changed is False
|
| 50 |
+
assert moved.to_dict()["moved"] == ["Save"]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_labels_are_compared_the_way_the_index_compares_them():
|
| 54 |
+
"""OCR drops spaces and models add punctuation, so identity is the alphanumeric core."""
|
| 55 |
+
before = frame([button("Save As")])
|
| 56 |
+
after = frame([button("SaveAs.")])
|
| 57 |
+
delta = diff(before, after)
|
| 58 |
+
|
| 59 |
+
assert delta.empty, "the same label, differently spelled, is the same element"
|
| 60 |
+
assert element_key(button("Save As")) == element_key(button("SaveAs."))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_added_and_removed_are_told_apart_when_counts_differ():
|
| 64 |
+
before = frame([button("Row 1"), button("Row 2")])
|
| 65 |
+
after = frame([button("Row 2"), button("Row 3"), button("Row 4")])
|
| 66 |
+
delta = diff(before, after)
|
| 67 |
+
|
| 68 |
+
assert sorted(e.label for e in delta.added) == ["Row 3", "Row 4"]
|
| 69 |
+
assert [e.label for e in delta.removed] == ["Row 1"]
|
| 70 |
+
assert delta.summary().startswith("+2")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_the_verifier_remembers_the_previous_frame():
|
| 74 |
+
verify = ChangeVerifier(disappeared=["Cancel"])
|
| 75 |
+
with_cancel = frame([button("Save"), button("Cancel", x=200)])
|
| 76 |
+
without_cancel = frame([button("Save")])
|
| 77 |
+
|
| 78 |
+
assert verify(with_cancel) is False, "the first frame has nothing to compare against"
|
| 79 |
+
assert verify(without_cancel) is True
|
| 80 |
+
assert verify.last is not None and verify.last.closed, "Cancel is gone: the dialog closed"
|
| 81 |
+
assert verify(without_cancel) is False, "and the comparison advances with the frame"
|
| 82 |
+
assert verify.last is not None and verify.last.empty
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_the_verifier_can_be_reset_between_episodes():
|
| 86 |
+
verify = ChangeVerifier(appeared=["Saved"])
|
| 87 |
+
verify(frame([button("Save")]))
|
| 88 |
+
verify.reset()
|
| 89 |
+
|
| 90 |
+
assert verify.previous is None and verify.last is None
|
| 91 |
+
assert verify(frame([button("Saved")])) is False
|
tests/test_gates.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A refusal from the operator is a step, not a traceback."""
|
| 2 |
+
|
| 3 |
+
from tiny_computer_agent import (
|
| 4 |
+
Action, AgentLoop, AllOf, AllowAll, AllowOnlyLabels, AnyOf, Approval, DenyAll, Element,
|
| 5 |
+
FakeScreen, InteractiveGate, MockBrain, Policy, mutating,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def survey():
|
| 10 |
+
answers = ["y", "n", "a", "v"]
|
| 11 |
+
asked = []
|
| 12 |
+
|
| 13 |
+
def reader(question: str) -> str:
|
| 14 |
+
asked.append(question)
|
| 15 |
+
return answers.pop(0) if answers else ""
|
| 16 |
+
|
| 17 |
+
return reader, asked
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_read_only_verbs_never_need_consent():
|
| 21 |
+
assert not mutating(Action("wait", seconds=1))
|
| 22 |
+
assert not mutating(Action("done", reason="finished"))
|
| 23 |
+
assert mutating(Action("click", label="Save"))
|
| 24 |
+
assert mutating(Action("type", text="hello"))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_deny_all_kills_a_live_loop_but_lets_it_wait():
|
| 28 |
+
gate = DenyAll()
|
| 29 |
+
assert gate.approve(Action("wait", seconds=1)).approved
|
| 30 |
+
refused = gate.approve(Action("click", label="Save"))
|
| 31 |
+
assert refused.approved is False and "said no" in refused.reason
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_the_whitelist_gate_allows_a_renamed_button_and_refuses_the_rest():
|
| 35 |
+
gate = AllowOnlyLabels(["Save", "Cancel"])
|
| 36 |
+
|
| 37 |
+
assert gate.approve(Action("click", label="Save")).approved
|
| 38 |
+
assert gate.approve(Action("click", label="Save.")).approved, "the index's comparison applies"
|
| 39 |
+
assert not gate.approve(Action("click", label="Terminal")).approved
|
| 40 |
+
assert gate.approve(Action("type", text="rm -rf /")).approved is False, "text is not whitelisted"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_the_interactive_gate_asks_and_remembers():
|
| 44 |
+
reader, asked = survey()
|
| 45 |
+
gate = InteractiveGate(reader=reader, writer=lambda line: None)
|
| 46 |
+
|
| 47 |
+
assert gate.approve(Action("click", label="Save")).approved # y
|
| 48 |
+
assert not gate.approve(Action("click", label="Save")).approved # n
|
| 49 |
+
assert gate.approve(Action("click", label="Save")).approved # a -> remembered
|
| 50 |
+
assert gate.approve(Action("click", label="Save")).approved # ... so not asked again
|
| 51 |
+
assert len(asked) == 3
|
| 52 |
+
assert gate.approve(Action("wait", seconds=1)).approved, "and it is never asked about a wait"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_the_interactive_gate_remembers_a_never():
|
| 56 |
+
reader, _ = survey()
|
| 57 |
+
gate = InteractiveGate(reader=reader, writer=lambda line: None)
|
| 58 |
+
gate.approve(Action("click", label="Save")) # y
|
| 59 |
+
gate.approve(Action("click", label="Save")) # n
|
| 60 |
+
gate.approve(Action("click", label="Save")) # a
|
| 61 |
+
assert gate.approve(Action("click", label="Save")).approved
|
| 62 |
+
gate._always = False # a fresh session would ask again
|
| 63 |
+
answers = ["v", ""]
|
| 64 |
+
gate.reader = lambda question: answers.pop(0)
|
| 65 |
+
assert not gate.approve(Action("click", label="Save")).approved
|
| 66 |
+
assert not gate.approve(Action("click", label="Save")).approved, "never sticks"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_an_absent_operator_is_a_refusal_not_a_hang():
|
| 70 |
+
def no_one(question: str) -> str:
|
| 71 |
+
raise EOFError("no stdin")
|
| 72 |
+
|
| 73 |
+
assert not InteractiveGate(reader=no_one).approve(Action("click", label="Save")).approved
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_all_of_is_strict_and_any_of_is_permissive():
|
| 77 |
+
allowed = AllowOnlyLabels(["Save"])
|
| 78 |
+
assert not AllOf([allowed, DenyAll()]).approve(Action("click", label="Save")).approved
|
| 79 |
+
assert AllOf([allowed, AllowAll()]).approve(Action("click", label="Save")).approved
|
| 80 |
+
assert AnyOf([DenyAll(), allowed]).approve(Action("click", label="Save")).approved
|
| 81 |
+
assert not AnyOf([DenyAll()]).approve(Action("click", label="Save")).approved
|
| 82 |
+
assert isinstance(AllowAll().approve(Action("click", label="Save")), Approval)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_a_gate_refusal_stops_the_episode_and_is_recorded():
|
| 86 |
+
screen = FakeScreen([(Element("Save", x=10, y=10, width=60, height=20),)])
|
| 87 |
+
brain = MockBrain([{"kind": "click", "label": "Save"}, {"kind": "done", "reason": "saved"}])
|
| 88 |
+
loop = AgentLoop(screen=screen, brain=brain, policy=Policy(step_budget=4),
|
| 89 |
+
gate=AllowOnlyLabels(["Cancel"]))
|
| 90 |
+
|
| 91 |
+
episode = loop.run("click 'Save' to save the document")
|
| 92 |
+
|
| 93 |
+
assert episode.success is False
|
| 94 |
+
assert "operator refused" in episode.stop_reason
|
| 95 |
+
assert episode.steps[0].error is not None and episode.steps[0].error.startswith("not approved")
|
| 96 |
+
assert episode.steps[0].result is None, "nothing was executed"
|
| 97 |
+
assert len(episode.steps) == 1, "the episode ended there"
|
tests/test_runner.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A queue that survives being killed, and a report that counts what was verified."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
from tiny_computer_agent import (
|
| 6 |
+
AgentLoop, DryRunExecutor, Element, FakeScreen, MockBrain, Policy, RunReport, Task,
|
| 7 |
+
TaskOutcome, TaskRunner,
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def element(label="Save", x=10, y=10):
|
| 12 |
+
return Element(label, x=x, y=y, width=60, height=20)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_loop(task):
|
| 16 |
+
"""Fresh loop per task, exactly as the runner's contract requires."""
|
| 17 |
+
brain = MockBrain([{"kind": "click", "label": "Save"}])
|
| 18 |
+
return AgentLoop(screen=FakeScreen([(element(),)], text="Saved"), brain=brain,
|
| 19 |
+
executor=DryRunExecutor(), policy=Policy(step_budget=2))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_a_task_names_itself_and_carries_the_harness_check():
|
| 23 |
+
task = Task("click 'Save' to save the document", verify_text="Saved")
|
| 24 |
+
|
| 25 |
+
assert task.name == "save-document"
|
| 26 |
+
assert task.line().startswith("save-document")
|
| 27 |
+
verify = task.verifier()
|
| 28 |
+
assert verify is not None and verify.__call__ is not None
|
| 29 |
+
assert verify(type("O", (), {"text": "Saved"})()) is True
|
| 30 |
+
assert Task("no check here").verifier() is None
|
| 31 |
+
assert Task("explicit", name="mine").name == "mine"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_a_verified_task_is_reported_as_a_success():
|
| 35 |
+
runner = TaskRunner(build_loop)
|
| 36 |
+
report = runner.run([Task("click 'Save' to save the document", verify_text="Saved")])
|
| 37 |
+
|
| 38 |
+
assert isinstance(report, RunReport)
|
| 39 |
+
assert report.total == 1 and report.passed == 1 and report.accuracy == 1.0
|
| 40 |
+
assert report.outcomes[0].success is True
|
| 41 |
+
assert report.outcomes[0].steps >= 1
|
| 42 |
+
assert "verified" in report.render() or "1/1" in report.render()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_a_task_that_never_verifies_is_a_failure_not_a_guess():
|
| 46 |
+
report = TaskRunner(build_loop).run([Task("click 'Save'", verify_text="NeverAppears", budget=1)])
|
| 47 |
+
|
| 48 |
+
assert report.failed == 1 and report.accuracy == 0.0
|
| 49 |
+
assert report.outcomes[0].success is False
|
| 50 |
+
assert "budget" in report.outcomes[0].stop_reason
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_a_broken_loop_is_an_outcome_not_a_traceback():
|
| 54 |
+
def exploding(task):
|
| 55 |
+
raise RuntimeError("no display here")
|
| 56 |
+
|
| 57 |
+
outcome = TaskRunner(exploding).run_one(Task("anything"))
|
| 58 |
+
|
| 59 |
+
assert outcome.success is False
|
| 60 |
+
assert outcome.error == "RuntimeError: no display here"
|
| 61 |
+
assert "caught" in outcome.stop_reason
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_the_state_file_lets_a_killed_run_be_resumed(tmp_path):
|
| 65 |
+
state = tmp_path / "runs" / "today.jsonl"
|
| 66 |
+
tasks = [Task("click 'Save'", name="first", verify_text="Saved"),
|
| 67 |
+
Task("click 'Cancel'", name="second", verify_text="Saved")]
|
| 68 |
+
first = TaskRunner(build_loop, state_path=state).run(tasks)
|
| 69 |
+
|
| 70 |
+
assert first.total == 2 and first.passed == 2
|
| 71 |
+
lines = [json.loads(line) for line in state.read_text().splitlines()]
|
| 72 |
+
assert [row["name"] for row in lines] == ["first", "second"]
|
| 73 |
+
|
| 74 |
+
seen = []
|
| 75 |
+
def tracks(task):
|
| 76 |
+
seen.append(task.name)
|
| 77 |
+
return build_loop(task)
|
| 78 |
+
|
| 79 |
+
second = TaskRunner(tracks, state_path=state).run(tasks)
|
| 80 |
+
assert seen == [], "a resumed run does not repeat finished work"
|
| 81 |
+
assert second.total == 2 and second.passed == 2
|
| 82 |
+
assert all("already run" in o.stop_reason for o in second.outcomes)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_a_half_written_state_line_does_not_break_a_resume(tmp_path):
|
| 86 |
+
state = tmp_path / "today.jsonl"
|
| 87 |
+
state.write_text(json.dumps(TaskOutcome("done", "g", True, 1, "verified", 0.5).to_dict()) + "\n"
|
| 88 |
+
+ '{"name": "torn", "suc')
|
| 89 |
+
runner = TaskRunner(build_loop, state_path=state)
|
| 90 |
+
completed = runner.completed()
|
| 91 |
+
|
| 92 |
+
assert set(completed) == {"done"}, "a killed run leaves a torn line, and it is ignored"
|
| 93 |
+
report = runner.run([Task("g", name="done"), Task("click 'Save'", name="fresh", verify_text="Saved")])
|
| 94 |
+
assert report.passed == 2
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_the_report_saves_and_lists(tmp_path):
|
| 98 |
+
report = TaskRunner(build_loop).run([Task("click 'Save'", verify_text="Saved")])
|
| 99 |
+
path = report.save(tmp_path / "report.json")
|
| 100 |
+
saved = json.loads(path.read_text())
|
| 101 |
+
|
| 102 |
+
assert saved["passed"] == 1 and saved["total"] == 1
|
| 103 |
+
assert saved["outcomes"][0]["name"] == "save"
|
| 104 |
+
assert set(report.to_rows()[0]) == {"task", "goal", "success", "steps", "seconds", "reason"}
|
| 105 |
+
assert "report" in report.render() and report.steps >= 1
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_the_runner_tells_the_caller_about_each_task_as_it_finishes():
|
| 109 |
+
heard = []
|
| 110 |
+
runner = TaskRunner(build_loop, on_task=heard.append)
|
| 111 |
+
runner.run([Task("click 'Save'", verify_text="Saved")])
|
| 112 |
+
|
| 113 |
+
assert len(heard) == 1 and heard[0].name == "save"
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_a_task_can_be_verified_by_an_element_going_away():
|
| 117 |
+
"""The check for a task whose success is a dialog closing, where text says nothing."""
|
| 118 |
+
task = Task("click 'Save' to save the document", verify_gone="Name")
|
| 119 |
+
verify = task.verifier()
|
| 120 |
+
|
| 121 |
+
assert "'Name' to go" in task.line()
|
| 122 |
+
assert verify is not None
|
| 123 |
+
assert verify.__call__ is not None
|
| 124 |
+
|
| 125 |
+
from tiny_computer_agent import Element, Observation
|
| 126 |
+
|
| 127 |
+
dialog = Observation(elements=(Element("Name", kind="input", x=10, y=10),
|
| 128 |
+
Element("Save", x=90, y=10)))
|
| 129 |
+
closed = Observation(elements=(Element("Save", x=90, y=10),))
|
| 130 |
+
assert verify(dialog) is False, "the first frame only establishes the baseline"
|
| 131 |
+
assert verify(closed) is True
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_a_task_can_be_verified_by_an_element_arriving():
|
| 135 |
+
task = Task("open the File menu", verify_appeared="Save As")
|
| 136 |
+
verify = task.verifier()
|
| 137 |
+
from tiny_computer_agent import Element, Observation
|
| 138 |
+
|
| 139 |
+
menu = Observation(elements=(Element("File", x=0, y=0),
|
| 140 |
+
Element("Save As", x=0, y=30)))
|
| 141 |
+
assert verify(Observation(elements=(Element("File", x=0, y=0),))) is False
|
| 142 |
+
assert verify(menu) is True
|
| 143 |
+
assert task.to_dict()["verify_appeared"] == "Save As"
|
tests/test_skills.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A skill is only as trustworthy as the run that produced it."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
from tiny_computer_agent import (
|
| 6 |
+
Action, AgentLoop, BrainError, DryRunExecutor, Element, Episode, FakeScreen, GoalUnreachable,
|
| 7 |
+
MockBrain, Observation, Policy, Skill, SkillBrain, SkillLibrary, SkillStep, Step,
|
| 8 |
+
record_skill, slug,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def save_button(label="Save", x=10, y=10):
|
| 13 |
+
return Element(label, x=x, y=y, width=60, height=20)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def screen_of(*labels, text=""):
|
| 17 |
+
return FakeScreen([tuple(save_button(l, x=10 + 80 * i) for i, l in enumerate(labels))], text=text)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def verified_click_episode(goal="click 'Save' to save the document", label="Save"):
|
| 21 |
+
"""A real, verified episode - produced by a real loop, not hand-built."""
|
| 22 |
+
loop = AgentLoop(screen=screen_of(label, text="Saved"),
|
| 23 |
+
brain=MockBrain([{"kind": "click", "label": label},
|
| 24 |
+
{"kind": "done", "reason": "clicked it"}]),
|
| 25 |
+
verify=lambda observation: "Saved" in observation.text)
|
| 26 |
+
return loop.run(goal)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_slugs_are_readable_and_stable():
|
| 30 |
+
assert slug("click 'Save' to save the document") == "save-document"
|
| 31 |
+
assert slug("open the File menu and choose Save As") == "open-file-menu-choose-save-as"
|
| 32 |
+
assert slug("") == "skill"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_an_unverified_run_is_never_written_down_as_a_skill():
|
| 36 |
+
"""The one thing this project does not do: turn a guess into a library others trust."""
|
| 37 |
+
episode = verified_click_episode()
|
| 38 |
+
episode.success = False
|
| 39 |
+
assert record_skill(episode) is None
|
| 40 |
+
|
| 41 |
+
empty = Episode(goal="do something", success=True)
|
| 42 |
+
assert record_skill(empty) is None, "no steps is not a skill"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_a_verified_run_becomes_a_reusable_skill():
|
| 46 |
+
skill = record_skill(verified_click_episode())
|
| 47 |
+
|
| 48 |
+
assert skill is not None
|
| 49 |
+
assert skill.verified and skill.name == "save-document"
|
| 50 |
+
assert len(skill.steps) == 1
|
| 51 |
+
assert skill.steps[0].action.kind == "click"
|
| 52 |
+
assert skill.steps[0].aimed_at == "Save"
|
| 53 |
+
assert skill.recorded, "a skill says when it was recorded"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_a_skill_stores_labels_and_never_coordinates():
|
| 57 |
+
"""Coordinates are the thing that stops working tomorrow."""
|
| 58 |
+
skill = record_skill(verified_click_episode())
|
| 59 |
+
action = skill.actions()[0]
|
| 60 |
+
|
| 61 |
+
assert action.label == "Save"
|
| 62 |
+
assert action.x is None and action.y is None
|
| 63 |
+
assert "Save" in skill.steps[0].describe()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_the_entry_condition_is_the_first_label_not_every_label():
|
| 67 |
+
skill = Skill(name="menu", goal="open the File menu and choose Save As", verified=True,
|
| 68 |
+
steps=(SkillStep(Action("click", label="File"), aimed_at="File"),
|
| 69 |
+
SkillStep(Action("click", label="Save As"), aimed_at="Save As")))
|
| 70 |
+
|
| 71 |
+
assert skill.preconditions() == ("File",), "Save As does not exist until the menu is open"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_a_library_matches_only_when_the_entry_label_is_on_screen():
|
| 75 |
+
library = SkillLibrary()
|
| 76 |
+
library.add(Skill(name="save", goal="click 'Save' to save the document", verified=True,
|
| 77 |
+
steps=(SkillStep(Action("click", label="Save"), aimed_at="Save"),)))
|
| 78 |
+
|
| 79 |
+
goal = "click 'Save' to save the document"
|
| 80 |
+
assert library.match(goal, Observation(elements=(save_button(),))) is not None
|
| 81 |
+
assert library.match(goal, Observation(elements=())) is None, "aim at nothing, match nothing"
|
| 82 |
+
assert library.match("format the hard disk", Observation(elements=(save_button(),))) is None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_a_library_round_trips_through_json(tmp_path):
|
| 86 |
+
library = SkillLibrary()
|
| 87 |
+
record_skill(verified_click_episode(), library=library)
|
| 88 |
+
path = library.save(tmp_path / "skills.json")
|
| 89 |
+
|
| 90 |
+
reloaded = SkillLibrary.load(path)
|
| 91 |
+
assert reloaded.names() == library.names()
|
| 92 |
+
skill = reloaded.get("save-document")
|
| 93 |
+
assert skill is not None and skill.steps[0].action.kind == "click"
|
| 94 |
+
assert skill.verified is True
|
| 95 |
+
assert SkillLibrary.load(tmp_path / "absent.json").names() == ()
|
| 96 |
+
assert reloaded.to_rows()[0]["needs"] == "Save"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_the_brain_regrounds_on_the_label_the_screen_shows_now():
|
| 100 |
+
library = SkillLibrary()
|
| 101 |
+
library.add(Skill(name="save", goal="click 'Save' to save the document", verified=True,
|
| 102 |
+
steps=(SkillStep(Action("click", label="Save"), aimed_at="Save"),)))
|
| 103 |
+
brain = SkillBrain(library)
|
| 104 |
+
|
| 105 |
+
action = brain.decide("click 'Save' to save the document",
|
| 106 |
+
Observation(elements=(save_button("Save."),)))
|
| 107 |
+
assert action.kind == "click"
|
| 108 |
+
assert action.label == "Save.", "the screen's spelling wins over the recorded one"
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def test_a_skill_that_aimed_at_nothing_raises_instead_of_clicking():
|
| 112 |
+
library = SkillLibrary()
|
| 113 |
+
library.add(Skill(name="two", goal="save then confirm", verified=True,
|
| 114 |
+
steps=(SkillStep(Action("click", label="Save"), aimed_at="Save"),
|
| 115 |
+
SkillStep(Action("click", label="Confirm"), aimed_at="Confirm"))))
|
| 116 |
+
brain = SkillBrain(library)
|
| 117 |
+
goal = "save then confirm"
|
| 118 |
+
|
| 119 |
+
assert brain.decide(goal, Observation(elements=(save_button(),))).label == "Save"
|
| 120 |
+
with pytest.raises(BrainError, match="not on this screen"):
|
| 121 |
+
brain.decide(goal, Observation(elements=(save_button(),)))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def test_the_brain_says_done_when_the_skill_is_finished():
|
| 125 |
+
library = SkillLibrary()
|
| 126 |
+
library.add(Skill(name="one", goal="click 'Save' to save the document", verified=True,
|
| 127 |
+
steps=(SkillStep(Action("click", label="Save"), aimed_at="Save"),)))
|
| 128 |
+
brain = SkillBrain(library)
|
| 129 |
+
goal = "click 'Save' to save the document"
|
| 130 |
+
brain.decide(goal, Observation(elements=(save_button(),)))
|
| 131 |
+
|
| 132 |
+
finished = brain.decide(goal, Observation(elements=(save_button(),)))
|
| 133 |
+
assert finished.kind == "done" and "finished" in finished.reason
|
| 134 |
+
assert brain.progress == "1/1"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_the_brain_gives_up_instead_of_inventing_an_action():
|
| 138 |
+
brain = SkillBrain(SkillLibrary())
|
| 139 |
+
with pytest.raises(GoalUnreachable, match="no skill matches"):
|
| 140 |
+
brain.decide("format the hard disk", Observation(elements=(save_button(),)))
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def test_the_brain_defers_to_a_fallback_then_reuses_the_choice():
|
| 144 |
+
library = SkillLibrary()
|
| 145 |
+
brain = SkillBrain(library, fallback=MockBrain([{"kind": "done", "reason": "fallback chose"}]))
|
| 146 |
+
action = brain.decide("click 'Save'", Observation(elements=(save_button(),)))
|
| 147 |
+
|
| 148 |
+
assert action.kind == "done" and "fallback" in action.reason
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def test_a_skill_replays_through_a_real_loop_with_no_model():
|
| 152 |
+
library = SkillLibrary()
|
| 153 |
+
record_skill(verified_click_episode(), library=library)
|
| 154 |
+
goal = "click 'Save' to save the document"
|
| 155 |
+
|
| 156 |
+
loop = AgentLoop(screen=screen_of("Save", text="Saved"), brain=SkillBrain(library),
|
| 157 |
+
executor=DryRunExecutor(), policy=Policy(step_budget=3),
|
| 158 |
+
verify=lambda observation: "Saved" in observation.text)
|
| 159 |
+
episode = loop.run(goal)
|
| 160 |
+
|
| 161 |
+
assert episode.success, episode.render()
|
| 162 |
+
assert [s.action.label for s in episode.steps if s.action and s.action.kind == "click"] == ["Save"]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_a_skill_carries_the_check_that_made_it_trustworthy():
|
| 166 |
+
skill = Skill(name="s", goal="g", verified=True, verify_text="Saved",
|
| 167 |
+
steps=(SkillStep(Action("click", label="Save"), aimed_at="Save"),))
|
| 168 |
+
verify = skill.verifier()
|
| 169 |
+
|
| 170 |
+
assert verify is not None and verify(Observation(text="the file was Saved")) is True
|
| 171 |
+
assert verify(Observation(text="nothing happened")) is False
|
| 172 |
+
assert Skill(name="s", goal="g", steps=skill.steps).verifier() is None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_an_empty_skill_is_not_a_skill():
|
| 176 |
+
with pytest.raises(ValueError, match="no steps"):
|
| 177 |
+
Skill(name="empty", goal="nothing", steps=())
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_a_skill_keeps_a_structural_check_across_a_round_trip(tmp_path):
|
| 181 |
+
"""A skill whose success is a dialog closing must survive being written down."""
|
| 182 |
+
episode = verified_click_episode()
|
| 183 |
+
library = SkillLibrary()
|
| 184 |
+
skill = record_skill(episode, library=library, verify_gone="Name")
|
| 185 |
+
|
| 186 |
+
assert skill is not None and skill.verified
|
| 187 |
+
assert skill.verification() == "'Name' goes away"
|
| 188 |
+
verify = skill.verifier()
|
| 189 |
+
assert verify is not None and callable(verify)
|
| 190 |
+
|
| 191 |
+
reloaded = SkillLibrary.load(library.save(tmp_path / "skills.json")).get(skill.name)
|
| 192 |
+
assert reloaded is not None
|
| 193 |
+
assert reloaded.verify_gone == "Name"
|
| 194 |
+
assert reloaded.verification() == "'Name' goes away"
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_recording_refuses_a_claim_that_no_verifier_backed():
|
| 198 |
+
"""A brain reporting 'done' is a claim; only evidence makes a skill."""
|
| 199 |
+
from tiny_computer_agent import AgentLoop, MockBrain
|
| 200 |
+
|
| 201 |
+
loop = AgentLoop(screen=screen_of("Save", text=""), brain=MockBrain([{"kind": "done", "reason": "trust me"}]))
|
| 202 |
+
episode = loop.run("click 'Save' to save the document")
|
| 203 |
+
|
| 204 |
+
assert episode.success, "the loop takes the brain's word when there is no verifier"
|
| 205 |
+
assert record_skill(episode) is None, "but a skill library does not"
|
tiny_computer_agent/__init__.py
CHANGED
|
@@ -107,7 +107,30 @@ from tiny_computer_agent.perception import (
|
|
| 107 |
detector_available,
|
| 108 |
make_detector,
|
| 109 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
from tiny_computer_agent.safety import Policy, PolicyViolation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
from tiny_computer_agent.trace import (
|
| 112 |
Scenario,
|
| 113 |
Scoreboard,
|
|
@@ -123,28 +146,35 @@ from tiny_computer_agent.trace import (
|
|
| 123 |
score_trace,
|
| 124 |
)
|
| 125 |
|
| 126 |
-
__version__ = "0.
|
| 127 |
|
| 128 |
__all__ = [
|
| 129 |
-
"ACTION_KINDS",
|
| 130 |
-
"ActionError",
|
| 131 |
-
"
|
| 132 |
-
"
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
-
"
|
| 136 |
-
"
|
| 137 |
-
"
|
| 138 |
-
"
|
| 139 |
-
"
|
| 140 |
-
"
|
| 141 |
-
"
|
| 142 |
-
"
|
| 143 |
-
"
|
| 144 |
-
"
|
| 145 |
-
"
|
| 146 |
-
"
|
| 147 |
-
"
|
| 148 |
-
"
|
| 149 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
]
|
|
|
|
| 107 |
detector_available,
|
| 108 |
make_detector,
|
| 109 |
)
|
| 110 |
+
from tiny_computer_agent.diff import ChangeVerifier, Delta, diff, element_key
|
| 111 |
+
from tiny_computer_agent.gates import (
|
| 112 |
+
READ_ONLY_KINDS,
|
| 113 |
+
AllOf,
|
| 114 |
+
AllowAll,
|
| 115 |
+
AllowOnlyLabels,
|
| 116 |
+
AnyOf,
|
| 117 |
+
Approval,
|
| 118 |
+
DenyAll,
|
| 119 |
+
Gate,
|
| 120 |
+
InteractiveGate,
|
| 121 |
+
mutating,
|
| 122 |
+
)
|
| 123 |
+
from tiny_computer_agent.runner import RunReport, Task, TaskOutcome, TaskRunner
|
| 124 |
from tiny_computer_agent.safety import Policy, PolicyViolation
|
| 125 |
+
from tiny_computer_agent.skills import (
|
| 126 |
+
DEFAULT_SKILLS_PATH,
|
| 127 |
+
Skill,
|
| 128 |
+
SkillBrain,
|
| 129 |
+
SkillLibrary,
|
| 130 |
+
SkillStep,
|
| 131 |
+
record_skill,
|
| 132 |
+
slug,
|
| 133 |
+
)
|
| 134 |
from tiny_computer_agent.trace import (
|
| 135 |
Scenario,
|
| 136 |
Scoreboard,
|
|
|
|
| 146 |
score_trace,
|
| 147 |
)
|
| 148 |
|
| 149 |
+
__version__ = "2.0.0"
|
| 150 |
|
| 151 |
__all__ = [
|
| 152 |
+
"ACTION_KINDS", "ACTION_MENU", "AccessibleNode", "Action",
|
| 153 |
+
"ActionError", "ActionResult", "AgentLoop", "AllOf",
|
| 154 |
+
"AllowAll", "AllowOnlyLabels", "AnyOf", "Approval",
|
| 155 |
+
"AtspiDetector", "AtspiReader", "AutoDetector", "BRAIN_NAMES",
|
| 156 |
+
"Brain", "BrainError", "ChangeVerifier", "Check",
|
| 157 |
+
"DEFAULT_KILL_SWITCH", "DEFAULT_SKILLS_PATH", "DEFAULT_TEXT_MODEL", "DEFAULT_VISION_MODEL",
|
| 158 |
+
"DecisionMenu", "Delta", "DenyAll", "Detector",
|
| 159 |
+
"DetectorUnavailable", "DryRunExecutor", "Element", "Episode",
|
| 160 |
+
"Executor", "FakeScreen", "Gate", "GoalUnreachable",
|
| 161 |
+
"GrabScreen", "GridDetector", "INTERACTIVE_ROLES", "ImageScreen",
|
| 162 |
+
"IndexedScreen", "InputUnavailable", "InteractiveGate", "MODEL_BRAINS",
|
| 163 |
+
"MenuItem", "MockBrain", "ModelBrain", "Observation",
|
| 164 |
+
"OcrDetector", "PlannerBrain", "Policy", "PolicyViolation",
|
| 165 |
+
"PyAutoGuiExecutor", "READ_ONLY_KINDS", "RunReport", "Scenario",
|
| 166 |
+
"Scoreboard", "ScreenSource", "ScreenUnavailable", "Skill",
|
| 167 |
+
"SkillBrain", "SkillLibrary", "SkillStep", "Step",
|
| 168 |
+
"TEXT_ROLES", "Task", "TaskOutcome", "TaskRunner",
|
| 169 |
+
"TextPlannerBrain", "Trace", "TraceStep", "TreeReader",
|
| 170 |
+
"UiaDetector", "UiaReader", "VLMBrain", "Verdict",
|
| 171 |
+
"__version__", "accessibility_available", "benchmark", "benchmark_traces",
|
| 172 |
+
"build_agent", "detector_available", "diff", "display_available",
|
| 173 |
+
"doctor", "element_key", "extract_json_object", "extract_json_objects",
|
| 174 |
+
"fake_desktop", "load_traces", "make_detector", "make_model_brain",
|
| 175 |
+
"model_cached", "model_dependencies", "mutating", "normalise_label",
|
| 176 |
+
"parse_action", "parse_decision", "pick_dtype", "record_skill",
|
| 177 |
+
"resolve_device", "resolve_point", "run_checks", "run_scenario",
|
| 178 |
+
"score", "score_replay", "score_trace", "slug",
|
| 179 |
+
"write_fake_desktop",
|
| 180 |
]
|
tiny_computer_agent/__main__.py
CHANGED
|
@@ -7,6 +7,12 @@
|
|
| 7 |
python -m tiny_computer_agent --live # your real screen, dry-run
|
| 8 |
python -m tiny_computer_agent --live --real-input "click 'OK'"
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
Real mouse and keyboard input additionally requires
|
| 11 |
``TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1``. Dry run is the default, so you can read a
|
| 12 |
whole episode before anything touches your pointer.
|
|
@@ -21,6 +27,7 @@ import platform
|
|
| 21 |
import sys
|
| 22 |
|
| 23 |
from tiny_computer_agent.act import DryRunExecutor, PyAutoGuiExecutor
|
|
|
|
| 24 |
from tiny_computer_agent.brain import MockBrain, PlannerBrain
|
| 25 |
from tiny_computer_agent.demo import fake_desktop
|
| 26 |
from tiny_computer_agent.desktop import (
|
|
@@ -32,6 +39,8 @@ from tiny_computer_agent.observe import (
|
|
| 32 |
)
|
| 33 |
from tiny_computer_agent.perception import IndexedScreen, detector_available, make_detector
|
| 34 |
from tiny_computer_agent.safety import Policy
|
|
|
|
|
|
|
| 35 |
from tiny_computer_agent.trace import Trace, score_trace
|
| 36 |
|
| 37 |
ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
|
|
@@ -144,7 +153,195 @@ def _build_screen(args: argparse.Namespace):
|
|
| 144 |
return demo_screen(), "scripted demo"
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
def main(argv: list[str] | None = None) -> int:
|
|
|
|
|
|
|
|
|
|
| 148 |
args = build_parser().parse_args(argv)
|
| 149 |
detector_name = "grid" if args.no_ocr else args.detector
|
| 150 |
|
|
|
|
| 7 |
python -m tiny_computer_agent --live # your real screen, dry-run
|
| 8 |
python -m tiny_computer_agent --live --real-input "click 'OK'"
|
| 9 |
|
| 10 |
+
2.0 adds two subcommands, for everything after that first run:
|
| 11 |
+
|
| 12 |
+
python -m tiny_computer_agent skills list # what has been learned
|
| 13 |
+
python -m tiny_computer_agent skills learn "click 'Save'" --script '[{"kind":"click","label":"Save"}]' --verify-text Saved
|
| 14 |
+
python -m tiny_computer_agent batch tasks/day.json # a queue, resumable, reported
|
| 15 |
+
|
| 16 |
Real mouse and keyboard input additionally requires
|
| 17 |
``TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1``. Dry run is the default, so you can read a
|
| 18 |
whole episode before anything touches your pointer.
|
|
|
|
| 27 |
import sys
|
| 28 |
|
| 29 |
from tiny_computer_agent.act import DryRunExecutor, PyAutoGuiExecutor
|
| 30 |
+
from tiny_computer_agent.diff import ChangeVerifier
|
| 31 |
from tiny_computer_agent.brain import MockBrain, PlannerBrain
|
| 32 |
from tiny_computer_agent.demo import fake_desktop
|
| 33 |
from tiny_computer_agent.desktop import (
|
|
|
|
| 39 |
)
|
| 40 |
from tiny_computer_agent.perception import IndexedScreen, detector_available, make_detector
|
| 41 |
from tiny_computer_agent.safety import Policy
|
| 42 |
+
from tiny_computer_agent.runner import Task, TaskRunner
|
| 43 |
+
from tiny_computer_agent.skills import DEFAULT_SKILLS_PATH, SkillBrain, SkillLibrary, record_skill
|
| 44 |
from tiny_computer_agent.trace import Trace, score_trace
|
| 45 |
|
| 46 |
ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
|
|
|
|
| 153 |
return demo_screen(), "scripted demo"
|
| 154 |
|
| 155 |
|
| 156 |
+
SUBCOMMANDS = ("skills", "batch")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _screen_arguments(parser: argparse.ArgumentParser) -> None:
|
| 160 |
+
"""The screen flags the subcommands share with the main parser."""
|
| 161 |
+
parser.add_argument("--image", default=None,
|
| 162 |
+
help=f"replay a screenshot; '{EXAMPLE_IMAGE}' uses the built-in one")
|
| 163 |
+
parser.add_argument("--live", action="store_true", help="use this machine's real screen")
|
| 164 |
+
parser.add_argument("--detector", default="auto",
|
| 165 |
+
choices=("auto", "best", "atspi", "ocr", "grid"))
|
| 166 |
+
parser.add_argument("--no-ocr", action="store_true", help="index a numbered grid instead")
|
| 167 |
+
parser.add_argument("--monitor", type=int, default=1)
|
| 168 |
+
parser.add_argument("--scale", type=float, default=None)
|
| 169 |
+
parser.add_argument("--max-width", type=int, default=1600)
|
| 170 |
+
parser.add_argument("--path", default=str(DEFAULT_SKILLS_PATH),
|
| 171 |
+
help="the skill library to read or write")
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def build_skills_parser() -> argparse.ArgumentParser:
|
| 175 |
+
parser = argparse.ArgumentParser(
|
| 176 |
+
prog="tiny-computer-agent skills",
|
| 177 |
+
description="Skills are verified runs, written down once, replayed forever.")
|
| 178 |
+
actions = parser.add_subparsers(dest="action", required=True)
|
| 179 |
+
listing = actions.add_parser("list", help="what the library holds")
|
| 180 |
+
show = actions.add_parser("show", help="one skill, step by step")
|
| 181 |
+
show.add_argument("name")
|
| 182 |
+
remove = actions.add_parser("remove", help="forget a skill")
|
| 183 |
+
remove.add_argument("name")
|
| 184 |
+
learn = actions.add_parser("learn", help="run a script once, and keep it if it verifies")
|
| 185 |
+
learn.add_argument("goal")
|
| 186 |
+
learn.add_argument("--script", required=True, help="JSON list of actions to perform once")
|
| 187 |
+
learn.add_argument("--verify-text", default=None, metavar="TEXT",
|
| 188 |
+
help="what 'it worked' looks like on screen: some text appears")
|
| 189 |
+
learn.add_argument("--verify-gone", default=None, metavar="LABEL",
|
| 190 |
+
help="or: this element must disappear (the dialog closed)")
|
| 191 |
+
learn.add_argument("--verify-appeared", default=None, metavar="LABEL",
|
| 192 |
+
help="or: this element must appear (the menu opened)")
|
| 193 |
+
learn.add_argument("--name", default=None, help="name the skill yourself")
|
| 194 |
+
_screen_arguments(learn) # which also carries --path
|
| 195 |
+
for read_only in (listing, show, remove): # these only ever need the library
|
| 196 |
+
read_only.add_argument("--path", default=str(DEFAULT_SKILLS_PATH),
|
| 197 |
+
help="the skill library to read or write")
|
| 198 |
+
return parser
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def skills_main(argv: list[str]) -> int:
|
| 202 |
+
args = build_skills_parser().parse_args(argv)
|
| 203 |
+
library = SkillLibrary.load(args.path)
|
| 204 |
+
if args.action == "list":
|
| 205 |
+
if not library:
|
| 206 |
+
print(f"no skills in {args.path} yet. Learn one with:\n"
|
| 207 |
+
f" python -m tiny_computer_agent skills learn \"click 'Save'\" "
|
| 208 |
+
f"--script '[{{\"kind\": \"click\", \"label\": \"Save\"}}]' --verify-text Saved")
|
| 209 |
+
return 0
|
| 210 |
+
rows = library.to_rows()
|
| 211 |
+
widths = {key: max(len(key), *(len(str(row[key])) for row in rows)) for key in rows[0]}
|
| 212 |
+
print(f"{len(rows)} skill(s) in {args.path}")
|
| 213 |
+
print(" " + " ".join(key.ljust(widths[key]) for key in rows[0]))
|
| 214 |
+
for row in rows:
|
| 215 |
+
print(" " + " ".join(str(row[key]).ljust(widths[key]) for key in row))
|
| 216 |
+
return 0
|
| 217 |
+
if args.action == "show":
|
| 218 |
+
skill = library.get(args.name)
|
| 219 |
+
if skill is None:
|
| 220 |
+
print(f"no skill named {args.name!r} in {args.path}", file=sys.stderr)
|
| 221 |
+
return 1
|
| 222 |
+
print(skill.line())
|
| 223 |
+
print(f" verify: {skill.verification()}")
|
| 224 |
+
print(f" entry: {', '.join(skill.preconditions()) or '(none - no label to re-ground)'}")
|
| 225 |
+
for index, step in enumerate(skill.steps, 1):
|
| 226 |
+
print(f" {index}. {step.describe()}")
|
| 227 |
+
return 0
|
| 228 |
+
if args.action == "remove":
|
| 229 |
+
library = SkillLibrary.load(args.path)
|
| 230 |
+
if not library.remove(args.name):
|
| 231 |
+
print(f"no skill named {args.name!r} in {args.path}", file=sys.stderr)
|
| 232 |
+
return 1
|
| 233 |
+
print(f"removed {args.name!r}; {len(library)} left in {library.save()}")
|
| 234 |
+
return 0
|
| 235 |
+
|
| 236 |
+
# learn: the first run is scripted, and only a verified one becomes a skill
|
| 237 |
+
if not (args.verify_text or args.verify_gone or args.verify_appeared):
|
| 238 |
+
print("refusing to learn: pass --verify-text (or --verify-gone / --verify-appeared) so "
|
| 239 |
+
"the run has evidence. A skill recorded from a brain's own 'done' would be a "
|
| 240 |
+
"guess with a name.", file=sys.stderr)
|
| 241 |
+
return 2
|
| 242 |
+
try:
|
| 243 |
+
screen, description = _build_screen(args)
|
| 244 |
+
except ScreenUnavailable as exc:
|
| 245 |
+
print(f"cannot see the screen: {exc}", file=sys.stderr)
|
| 246 |
+
return 3
|
| 247 |
+
try:
|
| 248 |
+
script = json.loads(args.script)
|
| 249 |
+
except json.JSONDecodeError as exc:
|
| 250 |
+
print(f"--script is not valid JSON: {exc}", file=sys.stderr)
|
| 251 |
+
return 2
|
| 252 |
+
_, index_detail = detector_available("grid" if args.no_ocr else args.detector)
|
| 253 |
+
print(f"learning {args.goal!r} on {description} ({index_detail})")
|
| 254 |
+
if args.verify_text:
|
| 255 |
+
needle = args.verify_text.lower()
|
| 256 |
+
verify: object = lambda observation: needle in observation.text.lower()
|
| 257 |
+
else:
|
| 258 |
+
verify = ChangeVerifier(appeared=(args.verify_appeared,) if args.verify_appeared else (),
|
| 259 |
+
disappeared=(args.verify_gone,) if args.verify_gone else ())
|
| 260 |
+
loop = AgentLoop(screen=screen, brain=MockBrain(script), executor=DryRunExecutor(),
|
| 261 |
+
policy=Policy(dry_run=True, step_budget=len(script) + 2),
|
| 262 |
+
on_step=lambda step: print(" " + step.line()),
|
| 263 |
+
verify=verify)
|
| 264 |
+
episode = loop.run(args.goal)
|
| 265 |
+
print()
|
| 266 |
+
print(episode.render())
|
| 267 |
+
skill = record_skill(episode, name=args.name, library=library, verify_text=args.verify_text or "",
|
| 268 |
+
verify_gone=args.verify_gone or "",
|
| 269 |
+
verify_appeared=args.verify_appeared or "")
|
| 270 |
+
if skill is None:
|
| 271 |
+
print("\nnot learned: the run did not end verified, so there is nothing worth keeping.",
|
| 272 |
+
file=sys.stderr)
|
| 273 |
+
return 1
|
| 274 |
+
print(f"\nlearned {skill.name!r} ({len(skill.steps)} step(s)) -> {library.save()}")
|
| 275 |
+
return 0
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def build_batch_parser() -> argparse.ArgumentParser:
|
| 279 |
+
parser = argparse.ArgumentParser(
|
| 280 |
+
prog="tiny-computer-agent batch",
|
| 281 |
+
description="Run a queue of goals, resuming what already finished, and report on it.")
|
| 282 |
+
parser.add_argument("tasks", help="JSON: a list of {goal, name?, verify_text?, budget?}")
|
| 283 |
+
parser.add_argument("--state", default=None,
|
| 284 |
+
help="JSONL file appended as each task finishes (resume from it)")
|
| 285 |
+
parser.add_argument("--report", default=None, help="write the report as JSON here")
|
| 286 |
+
parser.add_argument("--max-steps", type=int, default=6)
|
| 287 |
+
parser.add_argument("--real-input", action="store_true",
|
| 288 |
+
help="actually drive the mouse (requires the allow env var)")
|
| 289 |
+
parser.add_argument("--brain", default="planner", choices=BRAIN_NAMES)
|
| 290 |
+
_screen_arguments(parser)
|
| 291 |
+
return parser
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def batch_main(argv: list[str]) -> int:
|
| 295 |
+
args = build_batch_parser().parse_args(argv)
|
| 296 |
+
try:
|
| 297 |
+
payload = json.loads(open(args.tasks).read())
|
| 298 |
+
except (OSError, json.JSONDecodeError) as exc:
|
| 299 |
+
print(f"cannot read {args.tasks}: {exc}", file=sys.stderr)
|
| 300 |
+
return 2
|
| 301 |
+
if not isinstance(payload, list) or not payload:
|
| 302 |
+
print(f"{args.tasks} should hold a non-empty list of tasks", file=sys.stderr)
|
| 303 |
+
return 2
|
| 304 |
+
try:
|
| 305 |
+
from_args = [Task(goal=entry["goal"], name=entry.get("name", ""),
|
| 306 |
+
budget=entry.get("budget"), verify_text=entry.get("verify_text", ""),
|
| 307 |
+
verify_gone=entry.get("verify_gone", ""),
|
| 308 |
+
verify_appeared=entry.get("verify_appeared", ""))
|
| 309 |
+
for entry in payload]
|
| 310 |
+
except (TypeError, KeyError) as exc:
|
| 311 |
+
print(f"a task needs at least a 'goal': {exc}", file=sys.stderr)
|
| 312 |
+
return 2
|
| 313 |
+
|
| 314 |
+
if args.real_input and os.environ.get(ALLOW_REAL_INPUT_ENV) != "1":
|
| 315 |
+
print(f"refusing --real-input: set {ALLOW_REAL_INPUT_ENV}=1 first.", file=sys.stderr)
|
| 316 |
+
return 2
|
| 317 |
+
try:
|
| 318 |
+
screen, description = _build_screen(args)
|
| 319 |
+
except ScreenUnavailable as exc:
|
| 320 |
+
print(f"cannot see the screen: {exc}", file=sys.stderr)
|
| 321 |
+
return 3
|
| 322 |
+
|
| 323 |
+
def make_loop(task: Task) -> AgentLoop:
|
| 324 |
+
from tiny_computer_agent.models import make_model_brain
|
| 325 |
+
brain = PlannerBrain() if args.brain == "planner" else make_model_brain(args.brain)
|
| 326 |
+
return AgentLoop(screen=screen, brain=brain,
|
| 327 |
+
executor=PyAutoGuiExecutor() if args.real_input else DryRunExecutor(),
|
| 328 |
+
policy=Policy(dry_run=not args.real_input, step_budget=task.budget or args.max_steps))
|
| 329 |
+
|
| 330 |
+
print(f"batch: {len(from_args)} task(s) on {description}, "
|
| 331 |
+
f"{'REAL INPUT' if args.real_input else 'dry-run'}")
|
| 332 |
+
report = TaskRunner(make_loop, state_path=args.state,
|
| 333 |
+
on_task=lambda outcome: print(" " + outcome.line())).run(from_args)
|
| 334 |
+
print()
|
| 335 |
+
print(report.render())
|
| 336 |
+
if args.report:
|
| 337 |
+
print(f"report: wrote {report.save(args.report)}")
|
| 338 |
+
return 0 if report.failed == 0 else 1
|
| 339 |
+
|
| 340 |
+
|
| 341 |
def main(argv: list[str] | None = None) -> int:
|
| 342 |
+
argv = list(sys.argv[1:] if argv is None else argv)
|
| 343 |
+
if argv and argv[0] in SUBCOMMANDS:
|
| 344 |
+
return skills_main(argv[1:]) if argv[0] == "skills" else batch_main(argv[1:])
|
| 345 |
args = build_parser().parse_args(argv)
|
| 346 |
detector_name = "grid" if args.no_ocr else args.detector
|
| 347 |
|
tiny_computer_agent/desktop.py
CHANGED
|
@@ -21,7 +21,9 @@ from tiny_computer_agent.observe import GrabScreen, ScreenSource
|
|
| 21 |
from tiny_computer_agent.perception import (
|
| 22 |
Detector, IndexedScreen, detector_available, make_detector,
|
| 23 |
)
|
|
|
|
| 24 |
from tiny_computer_agent.safety import Policy
|
|
|
|
| 25 |
|
| 26 |
DEFAULT_KILL_SWITCH = "/tmp/tiny-computer-agent.STOP"
|
| 27 |
BRAIN_NAMES = ("planner", "text-planner", "vlm")
|
|
@@ -170,12 +172,18 @@ def build_agent(*, brain: Brain | str | None = None, screen: ScreenSource | None
|
|
| 170 |
max_steps: int = 10, kill_switch_path: str | None = DEFAULT_KILL_SWITCH,
|
| 171 |
allow_grid_clicks: bool = False,
|
| 172 |
on_step: Callable[[Step], None] | None = None,
|
| 173 |
-
detector_options: dict[str, Any] | None = None
|
|
|
|
|
|
|
| 174 |
"""Wire a real desktop into an :class:`AgentLoop` in one call.
|
| 175 |
|
| 176 |
Defaults are the safe ones: dry run, a step budget of 10, and a kill-switch file
|
| 177 |
on disk that aborts the next action if it appears. ``screen``/``detector``/
|
| 178 |
``brain`` can be injected, which is how the tests drive this without a desktop.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
"""
|
| 180 |
if detector is None or isinstance(detector, str):
|
| 181 |
detector = make_detector(detector or "ocr", **(detector_options or {}))
|
|
@@ -189,9 +197,11 @@ def build_agent(*, brain: Brain | str | None = None, screen: ScreenSource | None
|
|
| 189 |
from tiny_computer_agent.models import make_model_brain
|
| 190 |
|
| 191 |
brain = make_model_brain(name)
|
|
|
|
|
|
|
| 192 |
executor: Executor = DryRunExecutor() if dry_run else PyAutoGuiExecutor()
|
| 193 |
policy = Policy(dry_run=dry_run, step_budget=max_steps,
|
| 194 |
kill_switch_path=kill_switch_path,
|
| 195 |
allow_grid_clicks=allow_grid_clicks)
|
| 196 |
return AgentLoop(screen=screen, brain=brain, executor=executor, policy=policy,
|
| 197 |
-
on_step=on_step)
|
|
|
|
| 21 |
from tiny_computer_agent.perception import (
|
| 22 |
Detector, IndexedScreen, detector_available, make_detector,
|
| 23 |
)
|
| 24 |
+
from tiny_computer_agent.gates import Gate
|
| 25 |
from tiny_computer_agent.safety import Policy
|
| 26 |
+
from tiny_computer_agent.skills import SkillBrain, SkillLibrary
|
| 27 |
|
| 28 |
DEFAULT_KILL_SWITCH = "/tmp/tiny-computer-agent.STOP"
|
| 29 |
BRAIN_NAMES = ("planner", "text-planner", "vlm")
|
|
|
|
| 172 |
max_steps: int = 10, kill_switch_path: str | None = DEFAULT_KILL_SWITCH,
|
| 173 |
allow_grid_clicks: bool = False,
|
| 174 |
on_step: Callable[[Step], None] | None = None,
|
| 175 |
+
detector_options: dict[str, Any] | None = None,
|
| 176 |
+
skills: SkillLibrary | None = None,
|
| 177 |
+
gate: Gate | None = None) -> AgentLoop:
|
| 178 |
"""Wire a real desktop into an :class:`AgentLoop` in one call.
|
| 179 |
|
| 180 |
Defaults are the safe ones: dry run, a step budget of 10, and a kill-switch file
|
| 181 |
on disk that aborts the next action if it appears. ``screen``/``detector``/
|
| 182 |
``brain`` can be injected, which is how the tests drive this without a desktop.
|
| 183 |
+
|
| 184 |
+
Pass ``skills`` and the brain becomes a :class:`~tiny_computer_agent.skills.SkillBrain`
|
| 185 |
+
that answers from the library first and only falls back to the model when no learned
|
| 186 |
+
skill applies; pass ``gate`` and the operator stands in front of every action.
|
| 187 |
"""
|
| 188 |
if detector is None or isinstance(detector, str):
|
| 189 |
detector = make_detector(detector or "ocr", **(detector_options or {}))
|
|
|
|
| 197 |
from tiny_computer_agent.models import make_model_brain
|
| 198 |
|
| 199 |
brain = make_model_brain(name)
|
| 200 |
+
if skills is not None:
|
| 201 |
+
brain = SkillBrain(skills, fallback=brain)
|
| 202 |
executor: Executor = DryRunExecutor() if dry_run else PyAutoGuiExecutor()
|
| 203 |
policy = Policy(dry_run=dry_run, step_budget=max_steps,
|
| 204 |
kill_switch_path=kill_switch_path,
|
| 205 |
allow_grid_clicks=allow_grid_clicks)
|
| 206 |
return AgentLoop(screen=screen, brain=brain, executor=executor, policy=policy,
|
| 207 |
+
on_step=on_step, gate=gate)
|
tiny_computer_agent/diff.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""What changed between two observations - in elements, not pixels.
|
| 2 |
+
|
| 3 |
+
The harness already refuses to guess and insists on evidence. This is the missing half of
|
| 4 |
+
that habit: a structural way to say *what changed* on a screen, so a verifier can be written
|
| 5 |
+
without hard-coding a string that only today's dialog happens to contain, and so "nothing
|
| 6 |
+
changed" is a measurement rather than a hunch.
|
| 7 |
+
|
| 8 |
+
before = screen.capture()
|
| 9 |
+
... an action ...
|
| 10 |
+
delta = diff(before, screen.capture())
|
| 11 |
+
if delta.closed: # the dialog went away
|
| 12 |
+
|
| 13 |
+
verify = ChangeVerifier(disappeared=["Save As"]) # AgentLoop(verify=verify)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from math import hypot
|
| 20 |
+
from typing import Any, Iterable
|
| 21 |
+
|
| 22 |
+
from .observe import Element, Observation, normalise_label
|
| 23 |
+
|
| 24 |
+
DEFAULT_TOLERANCE = 3
|
| 25 |
+
"""Pixels. Below this a box has not really moved, it has been re-rendered."""
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def element_key(element: Element) -> str:
|
| 29 |
+
"""Identity for matching across two frames: the label as the harness compares it, plus kind."""
|
| 30 |
+
return f"{normalise_label(element.label)}|{element.kind}"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _distance(left: Element, right: Element) -> float:
|
| 34 |
+
return hypot(left.center[0] - right.center[0], left.center[1] - right.center[1])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(frozen=True)
|
| 38 |
+
class Delta:
|
| 39 |
+
"""What a frame gained, lost, or moved."""
|
| 40 |
+
|
| 41 |
+
added: tuple[Element, ...] = ()
|
| 42 |
+
removed: tuple[Element, ...] = ()
|
| 43 |
+
moved: tuple[tuple[Element, Element], ...] = ()
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def changed(self) -> bool:
|
| 47 |
+
"""True when anything at all differs."""
|
| 48 |
+
return bool(self.added or self.removed or self.moved)
|
| 49 |
+
|
| 50 |
+
@property
|
| 51 |
+
def empty(self) -> bool:
|
| 52 |
+
return not self.changed
|
| 53 |
+
|
| 54 |
+
@property
|
| 55 |
+
def closed(self) -> bool:
|
| 56 |
+
"""Something disappeared and nothing appeared: a dialog, menu or panel went away."""
|
| 57 |
+
return bool(self.removed) and not self.added
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def opened(self) -> bool:
|
| 61 |
+
"""Something appeared: a menu, a dialog, a new row."""
|
| 62 |
+
return bool(self.added)
|
| 63 |
+
|
| 64 |
+
@property
|
| 65 |
+
def settled(self) -> bool:
|
| 66 |
+
"""Nothing moved without also appearing or disappearing - a redraw, not a reaction."""
|
| 67 |
+
return bool(self.moved) and not self.added and not self.removed
|
| 68 |
+
|
| 69 |
+
def labels(self) -> tuple[tuple[str, str], ...]:
|
| 70 |
+
"""(change, label) pairs, for a table or a log line."""
|
| 71 |
+
rows = [("added", e.label) for e in self.added]
|
| 72 |
+
rows += [("removed", e.label) for e in self.removed]
|
| 73 |
+
rows += [("moved", after.label) for _, after in self.moved]
|
| 74 |
+
return tuple(rows)
|
| 75 |
+
|
| 76 |
+
def summary(self) -> str:
|
| 77 |
+
if self.empty:
|
| 78 |
+
return "no change"
|
| 79 |
+
parts = []
|
| 80 |
+
if self.added:
|
| 81 |
+
parts.append(f"+{len(self.added)} ({', '.join(e.label for e in self.added[:4])})")
|
| 82 |
+
if self.removed:
|
| 83 |
+
parts.append(f"-{len(self.removed)} ({', '.join(e.label for e in self.removed[:4])})")
|
| 84 |
+
if self.moved:
|
| 85 |
+
parts.append(f"~{len(self.moved)}")
|
| 86 |
+
return " ".join(parts)
|
| 87 |
+
|
| 88 |
+
def matches(self, *, appeared: Iterable[str] = (), disappeared: Iterable[str] = ()) -> bool:
|
| 89 |
+
"""Does this delta contain the change a caller asked for? Labels compared as the index does."""
|
| 90 |
+
def seen(wanted: Iterable[str], pool: Iterable[Element]) -> bool:
|
| 91 |
+
labels = [normalise_label(e.label) for e in pool]
|
| 92 |
+
for needle in wanted:
|
| 93 |
+
target = normalise_label(needle)
|
| 94 |
+
if not any(target in label or label in target for label in labels):
|
| 95 |
+
return False
|
| 96 |
+
return True
|
| 97 |
+
|
| 98 |
+
return seen(appeared, self.added) and seen(disappeared, self.removed)
|
| 99 |
+
|
| 100 |
+
def to_dict(self) -> dict[str, Any]:
|
| 101 |
+
return {"added": [e.label for e in self.added],
|
| 102 |
+
"removed": [e.label for e in self.removed],
|
| 103 |
+
"moved": [after.label for _, after in self.moved]}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def diff(before: Observation, after: Observation, *, tolerance: int = DEFAULT_TOLERANCE) -> Delta:
|
| 107 |
+
"""Match elements by label and kind, and report what was added, removed or moved."""
|
| 108 |
+
def group(observation: Observation) -> dict[str, list[Element]]:
|
| 109 |
+
buckets: dict[str, list[Element]] = {}
|
| 110 |
+
for element in observation.elements:
|
| 111 |
+
buckets.setdefault(element_key(element), []).append(element)
|
| 112 |
+
return buckets
|
| 113 |
+
|
| 114 |
+
old, new = group(before), group(after)
|
| 115 |
+
added: list[Element] = []
|
| 116 |
+
removed: list[Element] = []
|
| 117 |
+
moved: list[tuple[Element, Element]] = []
|
| 118 |
+
for key in set(old) | set(new):
|
| 119 |
+
olds, news = list(old.get(key, ())), list(new.get(key, ()))
|
| 120 |
+
for left, right in zip(olds, news):
|
| 121 |
+
if _distance(left, right) > tolerance:
|
| 122 |
+
moved.append((left, right))
|
| 123 |
+
removed.extend(olds[len(news):])
|
| 124 |
+
added.extend(news[len(olds):])
|
| 125 |
+
return Delta(tuple(added), tuple(removed), tuple(moved))
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class ChangeVerifier:
|
| 129 |
+
"""A verifier that remembers the previous frame.
|
| 130 |
+
|
| 131 |
+
Pass it straight to ``AgentLoop(verify=...)``: it is called with each new observation and
|
| 132 |
+
answers whether the change it was asked for happened. Because it keeps the frame it last
|
| 133 |
+
saw, "the dialog closed" is checkable without a string that depends on the dialog's text.
|
| 134 |
+
"""
|
| 135 |
+
|
| 136 |
+
def __init__(self, *, appeared: Iterable[str] = (), disappeared: Iterable[str] = (),
|
| 137 |
+
tolerance: int = DEFAULT_TOLERANCE) -> None:
|
| 138 |
+
self.appeared = tuple(appeared)
|
| 139 |
+
self.disappeared = tuple(disappeared)
|
| 140 |
+
self.tolerance = tolerance
|
| 141 |
+
self.previous: Observation | None = None
|
| 142 |
+
self.last: Delta | None = None
|
| 143 |
+
|
| 144 |
+
def __call__(self, observation: Observation) -> bool:
|
| 145 |
+
if self.previous is None:
|
| 146 |
+
self.previous, self.last = observation, Delta()
|
| 147 |
+
return False
|
| 148 |
+
self.last = diff(self.previous, observation, tolerance=self.tolerance)
|
| 149 |
+
self.previous = observation
|
| 150 |
+
return self.last.matches(appeared=self.appeared, disappeared=self.disappeared)
|
| 151 |
+
|
| 152 |
+
def reset(self) -> None:
|
| 153 |
+
self.previous = None
|
| 154 |
+
self.last = None
|
tiny_computer_agent/gates.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The operator's yes, as a first-class part of the loop.
|
| 2 |
+
|
| 3 |
+
A :class:`~tiny_computer_agent.safety.Policy` answers "may the harness do this at all?".
|
| 4 |
+
A gate answers the other question: "does the person watching want it done *now*?" Both end
|
| 5 |
+
in the same place - a refusal that is recorded as a step and stops the episode, never a
|
| 6 |
+
traceback and never a silent click.
|
| 7 |
+
|
| 8 |
+
loop = AgentLoop(screen=..., brain=..., gate=InteractiveGate(), policy=Policy(dry_run=False))
|
| 9 |
+
loop = AgentLoop(..., gate=AllowOnlyLabels(["Save", "Cancel"])) # click nothing else
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from typing import Any, Callable, Protocol, Sequence
|
| 16 |
+
|
| 17 |
+
from .actions import Action
|
| 18 |
+
from .observe import Observation, normalise_label
|
| 19 |
+
|
| 20 |
+
READ_ONLY_KINDS = ("wait", "done")
|
| 21 |
+
"""Verbs that cannot change the machine, and so never need an operator's consent."""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def mutating(action: Action) -> bool:
|
| 25 |
+
"""True when this action would change something on the machine."""
|
| 26 |
+
return action.kind not in READ_ONLY_KINDS
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class Approval:
|
| 31 |
+
"""A gate's answer, with the reason it will be recorded under."""
|
| 32 |
+
|
| 33 |
+
approved: bool
|
| 34 |
+
reason: str = ""
|
| 35 |
+
|
| 36 |
+
def line(self) -> str:
|
| 37 |
+
return f"{'approved' if self.approved else 'refused'}: {self.reason}"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class Gate(Protocol):
|
| 41 |
+
"""Ask before acting. Return an :class:`Approval`; never raise to say no."""
|
| 42 |
+
|
| 43 |
+
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
|
| 44 |
+
...
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class AllowAll:
|
| 49 |
+
"""No gate: everything policy already allowed is approved. The default when none is given."""
|
| 50 |
+
|
| 51 |
+
reason: str = "no gate configured"
|
| 52 |
+
|
| 53 |
+
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
|
| 54 |
+
return Approval(True, self.reason)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass
|
| 58 |
+
class DenyAll:
|
| 59 |
+
"""Refuse every action that would change anything - a one-line kill switch for a live loop."""
|
| 60 |
+
|
| 61 |
+
reason: str = "the operator said no"
|
| 62 |
+
|
| 63 |
+
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
|
| 64 |
+
if not mutating(action):
|
| 65 |
+
return Approval(True, "read-only")
|
| 66 |
+
return Approval(False, self.reason)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass
|
| 70 |
+
class AllowOnlyLabels:
|
| 71 |
+
"""A whitelist of labels: the loop may click these and nothing else.
|
| 72 |
+
|
| 73 |
+
This is the gate for "watch it work, but it may only touch Save and Cancel" - the
|
| 74 |
+
coordinates still come from the index, so a renamed button is refused rather than
|
| 75 |
+
clicked at.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
labels: Sequence[str] = ()
|
| 79 |
+
reason: str = "not in the operator's whitelist"
|
| 80 |
+
|
| 81 |
+
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
|
| 82 |
+
if not mutating(action):
|
| 83 |
+
return Approval(True, "read-only")
|
| 84 |
+
if action.label and self.matches(action.label):
|
| 85 |
+
return Approval(True, f"{action.label!r} is whitelisted")
|
| 86 |
+
return Approval(False, self.reason)
|
| 87 |
+
|
| 88 |
+
def matches(self, label: str) -> bool:
|
| 89 |
+
target = normalise_label(label)
|
| 90 |
+
for allowed in self.labels:
|
| 91 |
+
wanted = normalise_label(allowed)
|
| 92 |
+
if target and (target == wanted or wanted in target or target in wanted):
|
| 93 |
+
return True
|
| 94 |
+
return False
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@dataclass
|
| 98 |
+
class InteractiveGate:
|
| 99 |
+
"""Ask a person, once per action, and remember what they said.
|
| 100 |
+
|
| 101 |
+
``always`` and ``never`` persist for the rest of the run, so a supervised session does not
|
| 102 |
+
turn into a keyboard exercise. ``reader``/``writer`` are injectable, so the gate is testable
|
| 103 |
+
and a console can route the question through its own widgets.
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
prompt: str = "run this action?"
|
| 107 |
+
reader: Callable[[str], str] = input
|
| 108 |
+
writer: Callable[[str], None] = print
|
| 109 |
+
_always: bool = field(default=False, init=False, repr=False)
|
| 110 |
+
_never: bool = field(default=False, init=False, repr=False)
|
| 111 |
+
asked: int = field(default=0, init=False)
|
| 112 |
+
|
| 113 |
+
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
|
| 114 |
+
if not mutating(action):
|
| 115 |
+
return Approval(True, "read-only")
|
| 116 |
+
if self._never:
|
| 117 |
+
return Approval(False, "the operator said never")
|
| 118 |
+
if self._always:
|
| 119 |
+
return Approval(True, "the operator said always")
|
| 120 |
+
self.asked += 1
|
| 121 |
+
question = f"{self.prompt} {action.describe()} [y]es / [n]o / [a]lways / [v]never: "
|
| 122 |
+
try:
|
| 123 |
+
answer = (self.reader(question) or "").strip().lower()
|
| 124 |
+
except (EOFError, KeyboardInterrupt):
|
| 125 |
+
return Approval(False, "the operator was not there to answer")
|
| 126 |
+
if answer.startswith("a"):
|
| 127 |
+
self._always = True
|
| 128 |
+
return Approval(True, "the operator said always")
|
| 129 |
+
if answer.startswith("v"):
|
| 130 |
+
self._never = True
|
| 131 |
+
return Approval(False, "the operator said never")
|
| 132 |
+
if answer.startswith("y"):
|
| 133 |
+
return Approval(True, "the operator said yes")
|
| 134 |
+
return Approval(False, "the operator said no")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@dataclass
|
| 138 |
+
class AllOf:
|
| 139 |
+
"""Every gate must approve - the strictest reading of a set of rules."""
|
| 140 |
+
|
| 141 |
+
gates: Sequence[Gate] = ()
|
| 142 |
+
|
| 143 |
+
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
|
| 144 |
+
for gate in self.gates:
|
| 145 |
+
approval = gate.approve(action, observation)
|
| 146 |
+
if not approval.approved:
|
| 147 |
+
return approval
|
| 148 |
+
return Approval(True, "every gate approved")
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@dataclass
|
| 152 |
+
class AnyOf:
|
| 153 |
+
"""Any gate may approve - the loosest, for "my colleague or my rulebook"."""
|
| 154 |
+
|
| 155 |
+
gates: Sequence[Gate] = ()
|
| 156 |
+
reason: str = "no gate approved"
|
| 157 |
+
|
| 158 |
+
def approve(self, action: Action, observation: Observation | None = None) -> Approval:
|
| 159 |
+
for gate in self.gates:
|
| 160 |
+
approval = gate.approve(action, observation)
|
| 161 |
+
if approval.approved:
|
| 162 |
+
return approval
|
| 163 |
+
return Approval(False, self.reason)
|
tiny_computer_agent/loop.py
CHANGED
|
@@ -9,6 +9,7 @@ from tiny_computer_agent.act import ActionResult, DryRunExecutor, Executor, PyAu
|
|
| 9 |
from tiny_computer_agent.actions import Action
|
| 10 |
from tiny_computer_agent.brain import Brain, GoalUnreachable
|
| 11 |
from tiny_computer_agent.observe import Observation, ScreenSource
|
|
|
|
| 12 |
from tiny_computer_agent.safety import Policy, PolicyViolation
|
| 13 |
|
| 14 |
|
|
@@ -73,7 +74,7 @@ class AgentLoop:
|
|
| 73 |
Guarantees, by construction rather than by prompting:
|
| 74 |
|
| 75 |
* every action is one of six validated verbs
|
| 76 |
-
* nothing executes until policy
|
| 77 |
* the episode ends on a step budget, a kill switch, a brain error, or a
|
| 78 |
failed screen capture (reported as a step, never as a traceback)
|
| 79 |
* the screen is re-read after every action, so the next decision sees the
|
|
@@ -84,7 +85,8 @@ class AgentLoop:
|
|
| 84 |
executor: Executor | None = None, policy: Policy | None = None,
|
| 85 |
on_step: Callable[[Step], None] | None = None,
|
| 86 |
max_repeats: int = 1,
|
| 87 |
-
verify: Callable[[Observation], bool] | None = None
|
|
|
|
| 88 |
"""``verify`` runs after every action: the harness, not the model, decides
|
| 89 |
whether the goal was reached. A brain saying "done" is a claim; a verifier
|
| 90 |
looking at the screen is evidence."""
|
|
@@ -96,6 +98,7 @@ class AgentLoop:
|
|
| 96 |
self.executor = executor
|
| 97 |
self.on_step = on_step
|
| 98 |
self.verify = verify
|
|
|
|
| 99 |
self.max_repeats = max_repeats
|
| 100 |
# the stepwise state: an episode in progress, and the guards around it
|
| 101 |
self.episode: Episode | None = None
|
|
@@ -191,6 +194,14 @@ class AgentLoop:
|
|
| 191 |
error=f"refused: {exc}"),
|
| 192 |
f"policy refusal: {exc}")
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
signature = (self._fingerprint(observation), action.describe())
|
| 195 |
self._repeats = self._repeats + 1 if signature == self._previous else 0
|
| 196 |
self._previous = signature
|
|
@@ -235,7 +246,7 @@ class AgentLoop:
|
|
| 235 |
Guarantees, by construction rather than by prompting:
|
| 236 |
|
| 237 |
* every action is one of six validated verbs
|
| 238 |
-
* nothing executes until policy
|
| 239 |
* the episode ends on a step budget, a kill switch, a brain error, or a
|
| 240 |
failed screen capture (reported as a step, never as a traceback)
|
| 241 |
* the screen is re-read after every action, so the next decision sees the
|
|
|
|
| 9 |
from tiny_computer_agent.actions import Action
|
| 10 |
from tiny_computer_agent.brain import Brain, GoalUnreachable
|
| 11 |
from tiny_computer_agent.observe import Observation, ScreenSource
|
| 12 |
+
from tiny_computer_agent.gates import Approval, Gate
|
| 13 |
from tiny_computer_agent.safety import Policy, PolicyViolation
|
| 14 |
|
| 15 |
|
|
|
|
| 74 |
Guarantees, by construction rather than by prompting:
|
| 75 |
|
| 76 |
* every action is one of six validated verbs
|
| 77 |
+
* nothing executes until policy **and** any gate have passed it
|
| 78 |
* the episode ends on a step budget, a kill switch, a brain error, or a
|
| 79 |
failed screen capture (reported as a step, never as a traceback)
|
| 80 |
* the screen is re-read after every action, so the next decision sees the
|
|
|
|
| 85 |
executor: Executor | None = None, policy: Policy | None = None,
|
| 86 |
on_step: Callable[[Step], None] | None = None,
|
| 87 |
max_repeats: int = 1,
|
| 88 |
+
verify: Callable[[Observation], bool] | None = None,
|
| 89 |
+
gate: Gate | None = None) -> None:
|
| 90 |
"""``verify`` runs after every action: the harness, not the model, decides
|
| 91 |
whether the goal was reached. A brain saying "done" is a claim; a verifier
|
| 92 |
looking at the screen is evidence."""
|
|
|
|
| 98 |
self.executor = executor
|
| 99 |
self.on_step = on_step
|
| 100 |
self.verify = verify
|
| 101 |
+
self.gate = gate
|
| 102 |
self.max_repeats = max_repeats
|
| 103 |
# the stepwise state: an episode in progress, and the guards around it
|
| 104 |
self.episode: Episode | None = None
|
|
|
|
| 194 |
error=f"refused: {exc}"),
|
| 195 |
f"policy refusal: {exc}")
|
| 196 |
|
| 197 |
+
if self.gate is not None: # the operator's yes, before anything moves
|
| 198 |
+
approval: Approval = self.gate.approve(action, observation)
|
| 199 |
+
if not approval.approved:
|
| 200 |
+
return self._record_step(
|
| 201 |
+
Step(index, observation, action=action,
|
| 202 |
+
error=f"not approved: {approval.reason}"),
|
| 203 |
+
f"operator refused: {approval.reason}")
|
| 204 |
+
|
| 205 |
signature = (self._fingerprint(observation), action.describe())
|
| 206 |
self._repeats = self._repeats + 1 if signature == self._previous else 0
|
| 207 |
self._previous = signature
|
|
|
|
| 246 |
Guarantees, by construction rather than by prompting:
|
| 247 |
|
| 248 |
* every action is one of six validated verbs
|
| 249 |
+
* nothing executes until policy **and** any gate have passed it
|
| 250 |
* the episode ends on a step budget, a kill switch, a brain error, or a
|
| 251 |
failed screen capture (reported as a step, never as a traceback)
|
| 252 |
* the screen is re-read after every action, so the next decision sees the
|
tiny_computer_agent/runner.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run a queue of goals, survive being killed, and report what actually happened.
|
| 2 |
+
|
| 3 |
+
The loop runs one episode. An operator runs a day of them: several goals against one screen,
|
| 4 |
+
some of which are slow, some of which fail, and any of which may be interrupted when the
|
| 5 |
+
sandbox dies. So this is the boring part done properly - a queue, a resume file, and a report
|
| 6 |
+
that counts what was verified rather than what was attempted.
|
| 7 |
+
|
| 8 |
+
tasks = [Task("click 'Save'", verify_text="Saved"), Task("click 'Cancel'")]
|
| 9 |
+
runner = TaskRunner(build_loop, state_path=Path("runs/today.jsonl"))
|
| 10 |
+
report = runner.run(tasks) # kill it and run again: finished tasks are skipped
|
| 11 |
+
print(report.render())
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import time
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from datetime import datetime, timezone
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any, Callable, Iterable, Sequence
|
| 22 |
+
|
| 23 |
+
from .diff import ChangeVerifier
|
| 24 |
+
from .loop import AgentLoop
|
| 25 |
+
from .observe import Observation, normalise_label
|
| 26 |
+
from .skills import slug
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _stamp() -> str:
|
| 30 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass(frozen=True)
|
| 34 |
+
class Task:
|
| 35 |
+
"""One goal, and how to tell whether it worked."""
|
| 36 |
+
|
| 37 |
+
goal: str
|
| 38 |
+
name: str = ""
|
| 39 |
+
budget: int | None = None
|
| 40 |
+
verify_text: str = ""
|
| 41 |
+
verify_gone: str = ""
|
| 42 |
+
"""A label that must *disappear* for this task to count - the dialog closed, verified."""
|
| 43 |
+
verify_appeared: str = ""
|
| 44 |
+
"""A label that must *appear* - the menu opened, the row was added."""
|
| 45 |
+
|
| 46 |
+
def __post_init__(self) -> None:
|
| 47 |
+
if not self.name:
|
| 48 |
+
object.__setattr__(self, "name", slug(self.goal))
|
| 49 |
+
|
| 50 |
+
def verifier(self) -> Callable[[Observation], bool] | None:
|
| 51 |
+
"""The harness's own check, so success is evidence rather than a brain's claim.
|
| 52 |
+
|
| 53 |
+
Text on screen is the simplest check; "this element went away" is the strongest one
|
| 54 |
+
for a task whose success is a dialog closing, which is exactly the case where text
|
| 55 |
+
alone cannot tell a winner from a no-op.
|
| 56 |
+
"""
|
| 57 |
+
if self.verify_text:
|
| 58 |
+
wanted = normalise_label(self.verify_text)
|
| 59 |
+
|
| 60 |
+
def by_text(observation: Observation) -> bool:
|
| 61 |
+
return wanted in normalise_label(observation.text)
|
| 62 |
+
|
| 63 |
+
return by_text
|
| 64 |
+
if self.verify_gone or self.verify_appeared:
|
| 65 |
+
return ChangeVerifier(appeared=(self.verify_appeared,) if self.verify_appeared else (),
|
| 66 |
+
disappeared=(self.verify_gone,) if self.verify_gone else ())
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
def line(self) -> str:
|
| 70 |
+
if self.verify_text:
|
| 71 |
+
check = f" expect {self.verify_text!r}"
|
| 72 |
+
elif self.verify_gone or self.verify_appeared:
|
| 73 |
+
bits = []
|
| 74 |
+
if self.verify_appeared:
|
| 75 |
+
bits.append(f"{self.verify_appeared!r} to appear")
|
| 76 |
+
if self.verify_gone:
|
| 77 |
+
bits.append(f"{self.verify_gone!r} to go")
|
| 78 |
+
check = " expect " + " and ".join(bits)
|
| 79 |
+
else:
|
| 80 |
+
check = ""
|
| 81 |
+
return f"{self.name:28s} {self.goal[:56]}{check}"
|
| 82 |
+
|
| 83 |
+
def to_dict(self) -> dict[str, Any]:
|
| 84 |
+
return {"name": self.name, "goal": self.goal, "budget": self.budget,
|
| 85 |
+
"verify_text": self.verify_text, "verify_gone": self.verify_gone,
|
| 86 |
+
"verify_appeared": self.verify_appeared}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@dataclass
|
| 90 |
+
class TaskOutcome:
|
| 91 |
+
"""What happened to one task, in the form a report and a resume file both need."""
|
| 92 |
+
|
| 93 |
+
name: str
|
| 94 |
+
goal: str
|
| 95 |
+
success: bool = False
|
| 96 |
+
steps: int = 0
|
| 97 |
+
stop_reason: str = ""
|
| 98 |
+
seconds: float = 0.0
|
| 99 |
+
error: str = ""
|
| 100 |
+
|
| 101 |
+
def line(self) -> str:
|
| 102 |
+
verdict = "success" if self.success else "failed"
|
| 103 |
+
detail = self.error or self.stop_reason
|
| 104 |
+
return f"{self.name:28s} {verdict:8s} {self.steps:2d} step(s) {self.seconds:6.1f}s {detail}"
|
| 105 |
+
|
| 106 |
+
def to_dict(self) -> dict[str, Any]:
|
| 107 |
+
return {"name": self.name, "goal": self.goal, "success": self.success, "steps": self.steps,
|
| 108 |
+
"stop_reason": self.stop_reason, "seconds": round(self.seconds, 3), "error": self.error}
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@dataclass
|
| 112 |
+
class RunReport:
|
| 113 |
+
"""The day's outcomes, with the only number this project counts."""
|
| 114 |
+
|
| 115 |
+
outcomes: list[TaskOutcome] = field(default_factory=list)
|
| 116 |
+
started: str = ""
|
| 117 |
+
finished: str = ""
|
| 118 |
+
|
| 119 |
+
@property
|
| 120 |
+
def total(self) -> int:
|
| 121 |
+
return len(self.outcomes)
|
| 122 |
+
|
| 123 |
+
@property
|
| 124 |
+
def passed(self) -> int:
|
| 125 |
+
return sum(1 for outcome in self.outcomes if outcome.success)
|
| 126 |
+
|
| 127 |
+
@property
|
| 128 |
+
def failed(self) -> int:
|
| 129 |
+
return self.total - self.passed
|
| 130 |
+
|
| 131 |
+
@property
|
| 132 |
+
def accuracy(self) -> float:
|
| 133 |
+
return self.passed / self.total if self.total else 0.0
|
| 134 |
+
|
| 135 |
+
@property
|
| 136 |
+
def steps(self) -> int:
|
| 137 |
+
return sum(outcome.steps for outcome in self.outcomes)
|
| 138 |
+
|
| 139 |
+
def to_rows(self) -> list[dict[str, Any]]:
|
| 140 |
+
return [{"task": o.name, "goal": o.goal, "success": o.success, "steps": o.steps,
|
| 141 |
+
"seconds": round(o.seconds, 1), "reason": o.error or o.stop_reason}
|
| 142 |
+
for o in self.outcomes]
|
| 143 |
+
|
| 144 |
+
def render(self) -> str:
|
| 145 |
+
lines = [f"report {self.started} -> {self.finished}",
|
| 146 |
+
f"{self.passed}/{self.total} verified ({self.accuracy:.0%}), {self.steps} step(s) total"]
|
| 147 |
+
lines += [f" {outcome.line()}" for outcome in self.outcomes]
|
| 148 |
+
return "\n".join(lines)
|
| 149 |
+
|
| 150 |
+
def save(self, path: Path | str) -> Path:
|
| 151 |
+
target = Path(path)
|
| 152 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 153 |
+
target.write_text(json.dumps({"started": self.started, "finished": self.finished,
|
| 154 |
+
"passed": self.passed, "total": self.total,
|
| 155 |
+
"outcomes": [o.to_dict() for o in self.outcomes]}, indent=2) + "\n")
|
| 156 |
+
return target
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@dataclass
|
| 160 |
+
class TaskRunner:
|
| 161 |
+
"""A queue that can be interrupted and resumed.
|
| 162 |
+
|
| 163 |
+
``build_loop`` is a callable returning a *fresh* loop per task, so state like "already
|
| 164 |
+
clicked this" cannot leak between goals - the same rule the benchmark uses. The state file
|
| 165 |
+
is JSONL, appended as each task finishes, so a run that is killed at task seven of nine
|
| 166 |
+
resumes at task eight rather than starting over.
|
| 167 |
+
"""
|
| 168 |
+
|
| 169 |
+
build_loop: Callable[[Task], AgentLoop]
|
| 170 |
+
state_path: Path | None = None
|
| 171 |
+
on_task: Callable[[TaskOutcome], None] | None = None
|
| 172 |
+
clock: Callable[[], float] = time.monotonic
|
| 173 |
+
|
| 174 |
+
def completed(self) -> dict[str, dict[str, Any]]:
|
| 175 |
+
"""The tasks already recorded in the state file, by name."""
|
| 176 |
+
if self.state_path is None or not Path(self.state_path).exists():
|
| 177 |
+
return {}
|
| 178 |
+
done: dict[str, dict[str, Any]] = {}
|
| 179 |
+
for line in Path(self.state_path).read_text().splitlines():
|
| 180 |
+
if not line.strip():
|
| 181 |
+
continue
|
| 182 |
+
try:
|
| 183 |
+
entry = json.loads(line)
|
| 184 |
+
except json.JSONDecodeError:
|
| 185 |
+
continue # a half-written line from a killed run
|
| 186 |
+
if "name" in entry:
|
| 187 |
+
done[entry["name"]] = entry
|
| 188 |
+
return done
|
| 189 |
+
|
| 190 |
+
def run(self, tasks: Sequence[Task], *, resume: bool = True,
|
| 191 |
+
max_steps: int | None = None) -> RunReport:
|
| 192 |
+
"""Run every task that has not already finished, and report on all of them."""
|
| 193 |
+
done = self.completed() if resume else {}
|
| 194 |
+
report = RunReport(started=_stamp())
|
| 195 |
+
for task in tasks:
|
| 196 |
+
if task.name in done:
|
| 197 |
+
entry = done[task.name]
|
| 198 |
+
report.outcomes.append(TaskOutcome(
|
| 199 |
+
name=task.name, goal=task.goal, success=bool(entry.get("success")),
|
| 200 |
+
steps=int(entry.get("steps", 0)),
|
| 201 |
+
stop_reason=f"already run ({entry.get('stop_reason', '')})".strip(),
|
| 202 |
+
seconds=float(entry.get("seconds", 0.0)), error=entry.get("error", "")))
|
| 203 |
+
continue
|
| 204 |
+
outcome = self.run_one(task, max_steps=max_steps)
|
| 205 |
+
report.outcomes.append(outcome)
|
| 206 |
+
self._remember(outcome)
|
| 207 |
+
if self.on_task is not None:
|
| 208 |
+
self.on_task(outcome)
|
| 209 |
+
report.finished = _stamp()
|
| 210 |
+
return report
|
| 211 |
+
|
| 212 |
+
def run_one(self, task: Task, *, max_steps: int | None = None) -> TaskOutcome:
|
| 213 |
+
"""One task, with every failure turned into an outcome rather than a traceback."""
|
| 214 |
+
outcome = TaskOutcome(name=task.name, goal=task.goal)
|
| 215 |
+
started = self.clock()
|
| 216 |
+
try:
|
| 217 |
+
loop = self.build_loop(task)
|
| 218 |
+
if task.verifier() is not None:
|
| 219 |
+
loop.verify = task.verifier()
|
| 220 |
+
episode = loop.run(task.goal, max_steps=task.budget or max_steps)
|
| 221 |
+
outcome.success = bool(episode.success)
|
| 222 |
+
outcome.steps = len(episode.steps)
|
| 223 |
+
outcome.stop_reason = episode.stop_reason
|
| 224 |
+
except Exception as exc: # noqa: BLE001
|
| 225 |
+
outcome.error = f"{type(exc).__name__}: {exc}"
|
| 226 |
+
outcome.stop_reason = "the runner caught a failure"
|
| 227 |
+
outcome.seconds = self.clock() - started
|
| 228 |
+
return outcome
|
| 229 |
+
|
| 230 |
+
def _remember(self, outcome: TaskOutcome) -> None:
|
| 231 |
+
if self.state_path is None:
|
| 232 |
+
return
|
| 233 |
+
target = Path(self.state_path)
|
| 234 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 235 |
+
with open(target, "a") as handle:
|
| 236 |
+
handle.write(json.dumps(outcome.to_dict()) + "\n")
|
tiny_computer_agent/skills.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Skills: a verified episode, written down once, replayed forever.
|
| 2 |
+
|
| 3 |
+
A skill is the cheapest brain there is - no weights, no prompt, no model - and it is exactly
|
| 4 |
+
as trustworthy as the run that produced it, because :func:`record_skill` refuses to record
|
| 5 |
+
anything that was not *verified*. That is the whole bargain of this project in one object:
|
| 6 |
+
the model is only ever needed to make a choice the first time; after that the harness owns it.
|
| 7 |
+
|
| 8 |
+
A skill stores *labels*, never coordinates, so it re-grounds itself on whatever screen it
|
| 9 |
+
meets - the same trick that lets a 256M model pick "Save" from a menu.
|
| 10 |
+
|
| 11 |
+
library = SkillLibrary(Path("skills.json"))
|
| 12 |
+
skill = record_skill(loop.run("click 'Save' to save the document"), library=library)
|
| 13 |
+
library.save()
|
| 14 |
+
|
| 15 |
+
loop = AgentLoop(screen=..., brain=SkillBrain(library), executor=DryRunExecutor())
|
| 16 |
+
loop.run("click 'Save' to save the document") # no model, no OCR guesswork
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import re
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from datetime import datetime, timezone
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Any, Callable, Iterable, Iterator, Sequence
|
| 27 |
+
|
| 28 |
+
from .actions import Action
|
| 29 |
+
from .brain import BrainError, GoalUnreachable
|
| 30 |
+
from .diff import ChangeVerifier
|
| 31 |
+
from .loop import Episode, Step
|
| 32 |
+
from .observe import Element, Observation, normalise_label
|
| 33 |
+
|
| 34 |
+
SKILLS_FORMAT = "tiny-computer-agent/skills"
|
| 35 |
+
SKILLS_VERSION = 1
|
| 36 |
+
DEFAULT_SKILLS_PATH = Path.home() / ".tiny-computer-agent" / "skills.json"
|
| 37 |
+
_STOPWORDS = frozenset({"the", "a", "an", "to", "in", "on", "of", "and", "then", "click", "press"})
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def slug(text: str, limit: int = 48) -> str:
|
| 41 |
+
"""A stable, readable name for a goal: 'click Save to save the document' -> 'save-document'."""
|
| 42 |
+
words = [w for w in re.split(r"[^a-z0-9]+", text.lower()) if w and w not in _STOPWORDS]
|
| 43 |
+
unique: list[str] = []
|
| 44 |
+
for word in words:
|
| 45 |
+
if word not in unique:
|
| 46 |
+
unique.append(word)
|
| 47 |
+
return "-".join(unique)[:limit] or "skill"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _tokens(text: str) -> frozenset[str]:
|
| 51 |
+
return frozenset(w for w in re.split(r"[^a-z0-9]+", text.lower()) if w and w not in _STOPWORDS)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass(frozen=True)
|
| 55 |
+
class SkillStep:
|
| 56 |
+
"""One action of a skill, with the label it resolved to at record time."""
|
| 57 |
+
|
| 58 |
+
action: Action
|
| 59 |
+
aimed_at: str = ""
|
| 60 |
+
"""The label the action was grounded on - the thing that has to still be on screen."""
|
| 61 |
+
|
| 62 |
+
def describe(self) -> str:
|
| 63 |
+
if self.aimed_at:
|
| 64 |
+
return f"{self.action.describe()} [{self.aimed_at}]"
|
| 65 |
+
return self.action.describe()
|
| 66 |
+
|
| 67 |
+
def to_dict(self) -> dict[str, Any]:
|
| 68 |
+
return {"action": self.action.to_dict(), "aimed_at": self.aimed_at}
|
| 69 |
+
|
| 70 |
+
@classmethod
|
| 71 |
+
def from_dict(cls, data: dict[str, Any]) -> "SkillStep":
|
| 72 |
+
return cls(action=Action(**data["action"]), aimed_at=data.get("aimed_at", ""))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@dataclass(frozen=True)
|
| 76 |
+
class Skill:
|
| 77 |
+
"""A named, verified action sequence for a goal."""
|
| 78 |
+
|
| 79 |
+
name: str
|
| 80 |
+
goal: str
|
| 81 |
+
steps: tuple[SkillStep, ...]
|
| 82 |
+
verified: bool = False
|
| 83 |
+
verify_text: str = ""
|
| 84 |
+
verify_gone: str = ""
|
| 85 |
+
verify_appeared: str = ""
|
| 86 |
+
recorded: str = ""
|
| 87 |
+
notes: str = ""
|
| 88 |
+
|
| 89 |
+
def __post_init__(self) -> None:
|
| 90 |
+
if not self.steps:
|
| 91 |
+
raise ValueError("a skill with no steps is not a skill")
|
| 92 |
+
|
| 93 |
+
def actions(self) -> tuple[Action, ...]:
|
| 94 |
+
return tuple(step.action for step in self.steps)
|
| 95 |
+
|
| 96 |
+
def preconditions(self) -> tuple[str, ...]:
|
| 97 |
+
"""The *entry* condition: the labels the first step needs.
|
| 98 |
+
|
| 99 |
+
Not every label in the skill - a skill that opens a menu and then clicks an item would
|
| 100 |
+
never qualify otherwise, because the item does not exist until the menu is open. Later
|
| 101 |
+
steps are grounded as they arrive, and a label that has vanished raises rather than
|
| 102 |
+
clicking at where the button used to be.
|
| 103 |
+
"""
|
| 104 |
+
first = self.steps[0]
|
| 105 |
+
return (first.aimed_at,) if first.aimed_at else ()
|
| 106 |
+
|
| 107 |
+
def score(self, goal: str) -> float:
|
| 108 |
+
"""How well a goal matches this skill: 1.0 for the same words, else a token overlap."""
|
| 109 |
+
if normalise_label(goal) == normalise_label(self.goal):
|
| 110 |
+
return 1.0
|
| 111 |
+
wanted = _tokens(goal)
|
| 112 |
+
if not wanted:
|
| 113 |
+
return 0.0
|
| 114 |
+
return len(wanted & _tokens(self.goal)) / len(wanted)
|
| 115 |
+
|
| 116 |
+
def applies(self, goal: str, observation: Observation, *, threshold: float = 0.6) -> bool:
|
| 117 |
+
"""Right goal *and* every precondition still on screen - otherwise it would aim at nothing."""
|
| 118 |
+
if self.score(goal) < threshold:
|
| 119 |
+
return False
|
| 120 |
+
return all(observation.find(label) is not None for label in self.preconditions())
|
| 121 |
+
|
| 122 |
+
def verification(self) -> str:
|
| 123 |
+
"""The check, as one line, for a table or a console."""
|
| 124 |
+
if self.verify_text:
|
| 125 |
+
return f"text {self.verify_text!r} appears"
|
| 126 |
+
bits = []
|
| 127 |
+
if self.verify_appeared:
|
| 128 |
+
bits.append(f"{self.verify_appeared!r} appears")
|
| 129 |
+
if self.verify_gone:
|
| 130 |
+
bits.append(f"{self.verify_gone!r} goes away")
|
| 131 |
+
return " and ".join(bits) or "(unverified)"
|
| 132 |
+
|
| 133 |
+
def verifier(self) -> Callable[[Observation], bool] | None:
|
| 134 |
+
"""The check that made this skill trustworthy, as a verifier the loop can reuse."""
|
| 135 |
+
if self.verify_text:
|
| 136 |
+
wanted = normalise_label(self.verify_text)
|
| 137 |
+
|
| 138 |
+
def by_text(observation: Observation) -> bool:
|
| 139 |
+
return wanted in normalise_label(observation.text)
|
| 140 |
+
|
| 141 |
+
return by_text
|
| 142 |
+
if self.verify_gone or self.verify_appeared:
|
| 143 |
+
return ChangeVerifier(appeared=(self.verify_appeared,) if self.verify_appeared else (),
|
| 144 |
+
disappeared=(self.verify_gone,) if self.verify_gone else ())
|
| 145 |
+
return None
|
| 146 |
+
|
| 147 |
+
def line(self) -> str:
|
| 148 |
+
mark = "verified" if self.verified else "UNVERIFIED"
|
| 149 |
+
return f"{self.name:28s} {len(self.steps)} step(s) {mark} {self.goal[:52]}"
|
| 150 |
+
|
| 151 |
+
def to_dict(self) -> dict[str, Any]:
|
| 152 |
+
return {"name": self.name, "goal": self.goal, "verified": self.verified,
|
| 153 |
+
"verify_text": self.verify_text, "verify_gone": self.verify_gone,
|
| 154 |
+
"verify_appeared": self.verify_appeared, "recorded": self.recorded,
|
| 155 |
+
"notes": self.notes, "steps": [s.to_dict() for s in self.steps]}
|
| 156 |
+
|
| 157 |
+
@classmethod
|
| 158 |
+
def from_dict(cls, data: dict[str, Any]) -> "Skill":
|
| 159 |
+
return cls(name=data["name"], goal=data.get("goal", ""), verified=bool(data.get("verified")),
|
| 160 |
+
verify_text=data.get("verify_text", ""), verify_gone=data.get("verify_gone", ""),
|
| 161 |
+
verify_appeared=data.get("verify_appeared", ""), recorded=data.get("recorded", ""),
|
| 162 |
+
notes=data.get("notes", ""),
|
| 163 |
+
steps=tuple(SkillStep.from_dict(s) for s in data.get("steps", ())))
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def record_skill(episode: Episode, *, name: str | None = None, library: "SkillLibrary | None" = None,
|
| 167 |
+
verify_text: str = "", verify_gone: str = "", verify_appeared: str = "",
|
| 168 |
+
require_verified: bool = True, overwrite: bool = True) -> Skill | None:
|
| 169 |
+
"""Turn a finished episode into a reusable skill - or refuse, loudly, if it earned it.
|
| 170 |
+
|
| 171 |
+
Returns ``None`` when the episode is not something worth repeating: no steps, no actions,
|
| 172 |
+
or (unless ``require_verified`` is False) not a verified success. Recording an unverified
|
| 173 |
+
run would turn a guess into a library others trust, which is the one thing this project
|
| 174 |
+
does not do.
|
| 175 |
+
"""
|
| 176 |
+
evidence = (episode.stop_reason.startswith("verified")
|
| 177 |
+
or bool(verify_text or verify_gone or verify_appeared))
|
| 178 |
+
if require_verified and not (episode.success and evidence):
|
| 179 |
+
# A brain reporting "done" is a claim, not evidence - the project's own rule. Recording
|
| 180 |
+
# that would turn a hunch into a library other runs trust, so it is refused here.
|
| 181 |
+
return None
|
| 182 |
+
steps: list[SkillStep] = []
|
| 183 |
+
for step in episode.steps:
|
| 184 |
+
action = step.action
|
| 185 |
+
if action is None or action.kind == "done":
|
| 186 |
+
continue
|
| 187 |
+
aimed = action.label or ""
|
| 188 |
+
steps.append(SkillStep(action=action, aimed_at=aimed))
|
| 189 |
+
if not steps:
|
| 190 |
+
return None
|
| 191 |
+
skill = Skill(name=name or slug(episode.goal), goal=episode.goal, steps=tuple(steps),
|
| 192 |
+
verified=bool(episode.success), verify_text=verify_text,
|
| 193 |
+
verify_gone=verify_gone, verify_appeared=verify_appeared,
|
| 194 |
+
recorded=datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 195 |
+
notes=f"stop_reason={episode.stop_reason}")
|
| 196 |
+
if library is not None:
|
| 197 |
+
library.add(skill, overwrite=overwrite)
|
| 198 |
+
return skill
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@dataclass
|
| 202 |
+
class SkillLibrary:
|
| 203 |
+
"""A portable, diffable set of skills - JSON on disk, labels inside, no weights anywhere."""
|
| 204 |
+
|
| 205 |
+
path: Path | None = None
|
| 206 |
+
skills: dict[str, Skill] = field(default_factory=dict)
|
| 207 |
+
|
| 208 |
+
def add(self, skill: Skill, *, overwrite: bool = True) -> Skill:
|
| 209 |
+
if skill.name in self.skills and not overwrite:
|
| 210 |
+
raise KeyError(f"a skill named {skill.name!r} is already in the library")
|
| 211 |
+
self.skills[skill.name] = skill
|
| 212 |
+
return skill
|
| 213 |
+
|
| 214 |
+
def remove(self, name: str) -> bool:
|
| 215 |
+
return self.skills.pop(name, None) is not None
|
| 216 |
+
|
| 217 |
+
def get(self, name: str) -> Skill | None:
|
| 218 |
+
return self.skills.get(name)
|
| 219 |
+
|
| 220 |
+
def names(self) -> tuple[str, ...]:
|
| 221 |
+
return tuple(sorted(self.skills))
|
| 222 |
+
|
| 223 |
+
def __len__(self) -> int:
|
| 224 |
+
return len(self.skills)
|
| 225 |
+
|
| 226 |
+
def __iter__(self) -> Iterator[Skill]:
|
| 227 |
+
return iter(self.skills.values())
|
| 228 |
+
|
| 229 |
+
def match(self, goal: str, observation: Observation, *,
|
| 230 |
+
threshold: float = 0.6) -> Skill | None:
|
| 231 |
+
"""The best applicable skill for a goal, or None. Never returns a skill aimed at nothing."""
|
| 232 |
+
candidates = [s for s in self.skills.values() if s.applies(goal, observation, threshold=threshold)]
|
| 233 |
+
if not candidates:
|
| 234 |
+
return None
|
| 235 |
+
candidates.sort(key=lambda s: (s.score(goal), s.recorded, s.name), reverse=True)
|
| 236 |
+
return candidates[0]
|
| 237 |
+
|
| 238 |
+
def to_rows(self) -> list[dict[str, Any]]:
|
| 239 |
+
return [{"name": s.name, "steps": len(s.steps), "verified": s.verified,
|
| 240 |
+
"goal": s.goal, "recorded": s.recorded, "needs": ", ".join(s.preconditions())}
|
| 241 |
+
for s in sorted(self.skills.values(), key=lambda s: s.name)]
|
| 242 |
+
|
| 243 |
+
def to_dict(self) -> dict[str, Any]:
|
| 244 |
+
return {"format": SKILLS_FORMAT, "version": SKILLS_VERSION,
|
| 245 |
+
"skills": [s.to_dict() for s in sorted(self.skills.values(), key=lambda s: s.name)]}
|
| 246 |
+
|
| 247 |
+
def save(self, path: Path | str | None = None) -> Path:
|
| 248 |
+
target = Path(path or self.path or DEFAULT_SKILLS_PATH)
|
| 249 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 250 |
+
target.write_text(json.dumps(self.to_dict(), indent=2) + "\n")
|
| 251 |
+
self.path = target
|
| 252 |
+
return target
|
| 253 |
+
|
| 254 |
+
@classmethod
|
| 255 |
+
def load(cls, path: Path | str | None = None, *, missing_ok: bool = True) -> "SkillLibrary":
|
| 256 |
+
source = Path(path or DEFAULT_SKILLS_PATH)
|
| 257 |
+
if not source.exists():
|
| 258 |
+
if missing_ok:
|
| 259 |
+
return cls(path=source)
|
| 260 |
+
raise FileNotFoundError(source)
|
| 261 |
+
data = json.loads(source.read_text())
|
| 262 |
+
if not isinstance(data, dict) or "skills" not in data:
|
| 263 |
+
raise ValueError(f"{source} is not a skill library")
|
| 264 |
+
library = cls(path=source)
|
| 265 |
+
for entry in data["skills"]:
|
| 266 |
+
library.add(Skill.from_dict(entry))
|
| 267 |
+
return library
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
class SkillBrain:
|
| 271 |
+
"""A brain that answers from a skill library, and says so when it cannot.
|
| 272 |
+
|
| 273 |
+
It never invents an action: it re-grounds the next step of the matched skill on the current
|
| 274 |
+
screen and, if that step's label is gone, raises instead of clicking at where the button used
|
| 275 |
+
to be. With a ``fallback`` brain it defers the *choice* the first time and reuses it after.
|
| 276 |
+
"""
|
| 277 |
+
|
| 278 |
+
def __init__(self, library: SkillLibrary, fallback: Any = None, *, threshold: float = 0.6,
|
| 279 |
+
name: str = "skills") -> None:
|
| 280 |
+
self.library = library
|
| 281 |
+
self.fallback = fallback
|
| 282 |
+
self.threshold = threshold
|
| 283 |
+
self.name = name
|
| 284 |
+
self._skill: Skill | None = None
|
| 285 |
+
self._goal = ""
|
| 286 |
+
self._cursor = 0
|
| 287 |
+
|
| 288 |
+
@property
|
| 289 |
+
def skill(self) -> Skill | None:
|
| 290 |
+
"""The skill currently being replayed, if any."""
|
| 291 |
+
return self._skill
|
| 292 |
+
|
| 293 |
+
@property
|
| 294 |
+
def cursor(self) -> int:
|
| 295 |
+
return self._cursor
|
| 296 |
+
|
| 297 |
+
@property
|
| 298 |
+
def progress(self) -> str:
|
| 299 |
+
if self._skill is None:
|
| 300 |
+
return "-"
|
| 301 |
+
return f"{self._cursor}/{len(self._skill.steps)}"
|
| 302 |
+
|
| 303 |
+
def reset(self) -> "SkillBrain":
|
| 304 |
+
self._skill, self._goal, self._cursor = None, "", 0
|
| 305 |
+
return self
|
| 306 |
+
|
| 307 |
+
def decide(self, goal: str, observation: Observation) -> Action:
|
| 308 |
+
if self._skill is None or goal != self._goal:
|
| 309 |
+
self._skill = self.library.match(goal, observation, threshold=self.threshold)
|
| 310 |
+
self._goal, self._cursor = goal, 0
|
| 311 |
+
if self._skill is None:
|
| 312 |
+
if self.fallback is not None:
|
| 313 |
+
return self.fallback.decide(goal, observation)
|
| 314 |
+
raise GoalUnreachable(f"no skill matches {goal!r} (the library has {len(self.library)})")
|
| 315 |
+
if self._cursor >= len(self._skill.steps):
|
| 316 |
+
return Action("done", reason=f"skill {self._skill.name!r} finished")
|
| 317 |
+
step = self._skill.steps[self._cursor]
|
| 318 |
+
self._cursor += 1
|
| 319 |
+
if not step.aimed_at:
|
| 320 |
+
return step.action
|
| 321 |
+
found = observation.find(step.aimed_at)
|
| 322 |
+
if found is None:
|
| 323 |
+
raise BrainError(f"skill {self._skill.name!r} step {self._cursor} needs "
|
| 324 |
+
f"{step.aimed_at!r}, which is not on this screen")
|
| 325 |
+
# re-ground on the label the screen actually shows now, not the one recorded months ago
|
| 326 |
+
return Action(step.action.kind, label=found.label,
|
| 327 |
+
text=step.action.text, keys=step.action.keys,
|
| 328 |
+
amount=step.action.amount, seconds=step.action.seconds,
|
| 329 |
+
reason=f"skill {self._skill.name!r} step {self._cursor}")
|
tiny_computer_agent_app.py
CHANGED
|
@@ -63,9 +63,13 @@ def readme():
|
|
| 63 |
No cloud API, no API keys, no telemetry — and a core that imports nothing outside the
|
| 64 |
Python standard library.
|
| 65 |
|
| 66 |
-
> **Status 0.
|
| 67 |
-
> an OCR element index with real coordinates, a real mouse click, and a
|
| 68 |
-
> outcome.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
> The loop can be driven from a [marimo console](#the-marimo-console) with a live preview, an
|
| 70 |
> action log and pause / resume / step; elements can come from the
|
| 71 |
> [accessibility tree](#what-the-machine-already-knows-the-accessibility-tree) instead of OCR;
|
|
@@ -131,7 +135,7 @@ def readme():
|
|
| 131 |
```bash
|
| 132 |
python -m pip install -e ".[desktop,ocr,dev]"
|
| 133 |
pytest tests -q
|
| 134 |
-
# 227 passed, 2 skipped in 18.7s
|
| 135 |
```
|
| 136 |
|
| 137 |
The two skips are the live-desktop tests. Give them a display and they run for real:
|
|
@@ -303,7 +307,7 @@ def readme():
|
|
| 303 |
`warning: no text was found on screen, so the index is 96 numbered grid cells and the
|
| 304 |
agent is choosing blind` — instead of silently guessing.
|
| 305 |
|
| 306 |
-
**
|
| 307 |
that click a real button.
|
| 308 |
|
| 309 |
## Verified on real Windows
|
|
@@ -351,7 +355,7 @@ def readme():
|
|
| 351 |
|
| 352 |
None of the three failures in the first row is a harness defect: two are
|
| 353 |
`ModuleNotFoundError: numpy` and the third is the guard bug documented below. With the
|
| 354 |
-
OCR engine installed, the same suite exits **0**. (Those Windows counts predate `trace.py`; the harness suite has since grown to
|
| 355 |
|
| 356 |
**The emulated-OCR caveat.** onnxruntime requires AVX2, and QEMU's software emulation of
|
| 357 |
AVX2 is roughly two orders of magnitude slower than native. A suite that finishes in 17
|
|
@@ -377,9 +381,10 @@ def readme():
|
|
| 377 |
|
| 378 |
## The test suite, in full
|
| 379 |
|
| 380 |
-
`pytest tests` collects **
|
| 381 |
weights. The last 2 are the opt-in live-desktop tests, skipped unless
|
| 382 |
-
`TINY_COMPUTER_AGENT_LIVE_TEST=1`.
|
|
|
|
| 383 |
|
| 384 |
| File | Tests | What it proves |
|
| 385 |
|---|---:|---|
|
|
@@ -393,19 +398,24 @@ def readme():
|
|
| 393 |
| `tests/test_safety.py` | 11 | deny lists, the kill switch, the blind-grid-click guard |
|
| 394 |
| `tests/test_grid_fallback.py` | 11 | the fallback index announces itself instead of guessing |
|
| 395 |
| `tests/test_desktop.py` | 10 | display detection, the preflight checks, `doctor` |
|
| 396 |
-
| `tests/test_cli.py` |
|
| 397 |
| `tests/test_trace.py` | 28 | traces: a run recorded with no pixels, replayed against another brain, and scored against ground truth |
|
| 398 |
| `tests/test_models.py` | 7 | the model backends: lazy loading, device choice, honest refusals |
|
| 399 |
| `tests/test_api_surface.py` | 4 | the public API is a promise: everything advertised is importable, and nothing public is left out |
|
| 400 |
| `tests/test_decoding.py` | 24 | the mask, the answer space, the retry ladder, the vote - and that an echo is refused |
|
| 401 |
-
| `tests/test_accessibility.py` |
|
|
|
|
| 402 |
| `tests/test_stepwise.py` | 11 | begin / step / pause / resume, and that it equals the batch loop |
|
| 403 |
| `tests/test_ocr_guard.py` | 4 | the OCR guard really skips with no engine - the guard that never fired |
|
|
|
|
|
|
|
|
|
|
| 404 |
| `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 |
|
| 405 |
|
| 406 |
```bash
|
| 407 |
-
pytest tests -q #
|
| 408 |
-
|
|
|
|
| 409 |
```
|
| 410 |
|
| 411 |
## Install
|
|
@@ -414,7 +424,7 @@ def readme():
|
|
| 414 |
pip install -e ".[ocr]" # labels from pixels (RapidOCR: no system binary)
|
| 415 |
pip install -e ".[desktop,ocr]" # + real capture and real input
|
| 416 |
pip install -e ".[vision,ocr]" # + the tiny model brains
|
| 417 |
-
pytest #
|
| 418 |
```
|
| 419 |
|
| 420 |
The accessibility detector needs system packages rather than an extra:
|
|
@@ -526,6 +536,10 @@ def readme():
|
|
| 526 |
| `trace.py` | Portable traces: record a run with no pixels, replay it offline, score the choice against ground truth |
|
| 527 |
| `decoding.py` | Constrained decoding: mask the logits to a menu number or a yes/no, so an echo is impossible |
|
| 528 |
| `accessibility.py` | The accessibility tree: `AtspiReader` and `AtspiDetector`, roles and real extents |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
| `showcase.py` | The capability gallery: one real screenshot per capability, checked against the public API |
|
| 530 |
| `measure_vlm.py` | Measures the four ways to ask a model, on a real screen, and prints the table |
|
| 531 |
| `evaluate_daily.py` | A day's tasks against a real app, verified by the app's own log |
|
|
@@ -632,7 +646,7 @@ def readme():
|
|
| 632 |
|
| 633 |
## Capability gallery
|
| 634 |
|
| 635 |
-
**74 capabilities, one screenshot each - and
|
| 636 |
|
| 637 |
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.
|
| 638 |
|
|
@@ -1259,7 +1273,8 @@ def readme():
|
|
| 1259 |
|
| 1260 |
**Works:** real capture, a real OCR index with coordinates that land on the right pixel,
|
| 1261 |
a real click an app reacts to, verified success, the closed vocabulary, the policy gates,
|
| 1262 |
-
the no-progress guard, echo detection, the doctor, the CLI, portable traces that score a brain's choices offline,
|
|
|
|
| 1263 |
reads a button label from pixels on a clean screenshot — and the same suite passing inside
|
| 1264 |
a real Windows Server 2022 guest with no KVM (109 passed, exit 0, with the OCR-inference
|
| 1265 |
tests excluded as impractically slow under emulation).
|
|
@@ -1288,6 +1303,11 @@ def readme():
|
|
| 1288 |
- [x] A daily-usage evaluation: 11 tasks (menu, scroll, typing, shortcut, second window,
|
| 1289 |
verify, refuse, kill switch) against a real GTK app, checked against the application's
|
| 1290 |
own log - see [can it do a day's work](#can-it-do-a-days-work)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1291 |
- [ ] **Verify UI Automation** (Windows) against a real Windows desktop. The reader and the
|
| 1292 |
detector are implemented and unit-tested against a synthetic control tree, and
|
| 1293 |
`accessibility_available("uia")` reports the truth on any machine - but no Windows
|
|
@@ -1317,8 +1337,8 @@ def repo_files():
|
|
| 1317 |
|
| 1318 |
[project]
|
| 1319 |
name = "tiny-computer-agent"
|
| 1320 |
-
version = "0.
|
| 1321 |
-
description = "An agentic harness that drives a real computer with a tiny, fully local model - no cloud API."
|
| 1322 |
readme = "README.md"
|
| 1323 |
requires-python = ">=3.10"
|
| 1324 |
license = { text = "MIT" }
|
|
@@ -1388,7 +1408,7 @@ def repo_files():
|
|
| 1388 |
'''
|
| 1389 |
|
| 1390 |
mo.md(f"packaging: {len(pyproject_toml) + len(license_txt) + len(gitignore_txt)} chars, "
|
| 1391 |
-
f"
|
| 1392 |
return gitignore_txt, license_txt, pyproject_toml
|
| 1393 |
|
| 1394 |
|
|
@@ -1444,7 +1464,7 @@ def publish_status(
|
|
| 1444 |
if (_root / _tool).is_file():
|
| 1445 |
_files[_tool] = (_root / _tool).read_text(encoding="utf-8")
|
| 1446 |
_folders = [f for f in ("tiny_computer_agent", "tests", "gallery",
|
| 1447 |
-
"showcase_scenes", "preview")
|
| 1448 |
if (_root / f).is_dir()]
|
| 1449 |
if (_root / ".github").is_dir():
|
| 1450 |
_folders.append(".github") # the CI that builds the Windows gallery
|
|
|
|
| 63 |
No cloud API, no API keys, no telemetry — and a core that imports nothing outside the
|
| 64 |
Python standard library.
|
| 65 |
|
| 66 |
+
> **Status 2.0.0: it drives a real desktop — on Linux and on Windows — and it learns.**
|
| 67 |
+
> Screen capture, an OCR element index with real coordinates, a real mouse click, and a
|
| 68 |
+
> *verified* outcome. A verified run can be written down as a **skill** and replayed with no
|
| 69 |
+
> model at all; a queue of goals can be run, resumed after a kill, and reported on; an
|
| 70 |
+
> **approval gate** can stand in front of every action; and what changed on screen is a
|
| 71 |
+
> **measured** structural diff rather than a guess.
|
| 72 |
+
> **All 74 capabilities have a real screenshot** in the [capability gallery](#capability-gallery) (Linux captures).
|
| 73 |
> The loop can be driven from a [marimo console](#the-marimo-console) with a live preview, an
|
| 74 |
> action log and pause / resume / step; elements can come from the
|
| 75 |
> [accessibility tree](#what-the-machine-already-knows-the-accessibility-tree) instead of OCR;
|
|
|
|
| 135 |
```bash
|
| 136 |
python -m pip install -e ".[desktop,ocr,dev]"
|
| 137 |
pytest tests -q
|
| 138 |
+
# 227 passed, 2 skipped in 18.7s (that run was 0.3.0; 2.0 has grown this to 284)
|
| 139 |
```
|
| 140 |
|
| 141 |
The two skips are the live-desktop tests. Give them a display and they run for real:
|
|
|
|
| 307 |
`warning: no text was found on screen, so the index is 96 numbered grid cells and the
|
| 308 |
agent is choosing blind` — instead of silently guessing.
|
| 309 |
|
| 310 |
+
**284 tests pass**, including real OCR over a real image and (opt-in) live-desktop tests
|
| 311 |
that click a real button.
|
| 312 |
|
| 313 |
## Verified on real Windows
|
|
|
|
| 355 |
|
| 356 |
None of the three failures in the first row is a harness defect: two are
|
| 357 |
`ModuleNotFoundError: numpy` and the third is the guard bug documented below. With the
|
| 358 |
+
OCR engine installed, the same suite exits **0**. (Those Windows counts predate `trace.py` and 2.0; the harness suite has since grown to 284 tests.)
|
| 359 |
|
| 360 |
**The emulated-OCR caveat.** onnxruntime requires AVX2, and QEMU's software emulation of
|
| 361 |
AVX2 is roughly two orders of magnitude slower than native. A suite that finishes in 17
|
|
|
|
| 381 |
|
| 382 |
## The test suite, in full
|
| 383 |
|
| 384 |
+
`pytest tests` collects **284 tests**. 282 run headless — no display, no network, no
|
| 385 |
weights. The last 2 are the opt-in live-desktop tests, skipped unless
|
| 386 |
+
`TINY_COMPUTER_AGENT_LIVE_TEST=1`. On a box without PyGObject, two of the 282 *fail* rather
|
| 387 |
+
than skip: they assert the AT-SPI reader is reachable, and that is the honest outcome.
|
| 388 |
|
| 389 |
| File | Tests | What it proves |
|
| 390 |
|---|---:|---|
|
|
|
|
| 398 |
| `tests/test_safety.py` | 11 | deny lists, the kill switch, the blind-grid-click guard |
|
| 399 |
| `tests/test_grid_fallback.py` | 11 | the fallback index announces itself instead of guessing |
|
| 400 |
| `tests/test_desktop.py` | 10 | display detection, the preflight checks, `doctor` |
|
| 401 |
+
| `tests/test_cli.py` | 16 | the command line: `--doctor`, `--image`, `--live`, and the 2.0 `skills` / `batch` subcommands |
|
| 402 |
| `tests/test_trace.py` | 28 | traces: a run recorded with no pixels, replayed against another brain, and scored against ground truth |
|
| 403 |
| `tests/test_models.py` | 7 | the model backends: lazy loading, device choice, honest refusals |
|
| 404 |
| `tests/test_api_surface.py` | 4 | the public API is a promise: everything advertised is importable, and nothing public is left out |
|
| 405 |
| `tests/test_decoding.py` | 24 | the mask, the answer space, the retry ladder, the vote - and that an echo is refused |
|
| 406 |
+
| `tests/test_accessibility.py` | 26 | role filtering, the coordinate contract, the fallback chain, and real AT-SPI |
|
| 407 |
+
| `tests/test_diff.py` | 8 | what changed between two frames: added, removed, moved, and the verifier that remembers |
|
| 408 |
| `tests/test_stepwise.py` | 11 | begin / step / pause / resume, and that it equals the batch loop |
|
| 409 |
| `tests/test_ocr_guard.py` | 4 | the OCR guard really skips with no engine - the guard that never fired |
|
| 410 |
+
| `tests/test_gates.py` | 8 | the operator's yes: whitelists, ask-once gates, and a refusal that is a step |
|
| 411 |
+
| `tests/test_skills.py` | 17 | only a verified run becomes a skill; re-grounding by label; aiming at nothing raises |
|
| 412 |
+
| `tests/test_runner.py` | 10 | a queue, a JSONL resume file, and a report counts what was verified |
|
| 413 |
| `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 |
|
| 414 |
|
| 415 |
```bash
|
| 416 |
+
pytest tests -q # 274 passed, 8 skipped, 2 failed only where
|
| 417 |
+
# PyGObject/AT-SPI is missing (gir1.2-atspi-2.0)
|
| 418 |
+
TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q # + the 2 live-desktop tests, on a desktop
|
| 419 |
```
|
| 420 |
|
| 421 |
## Install
|
|
|
|
| 424 |
pip install -e ".[ocr]" # labels from pixels (RapidOCR: no system binary)
|
| 425 |
pip install -e ".[desktop,ocr]" # + real capture and real input
|
| 426 |
pip install -e ".[vision,ocr]" # + the tiny model brains
|
| 427 |
+
pytest # 284 tests, no display or network needed
|
| 428 |
```
|
| 429 |
|
| 430 |
The accessibility detector needs system packages rather than an extra:
|
|
|
|
| 536 |
| `trace.py` | Portable traces: record a run with no pixels, replay it offline, score the choice against ground truth |
|
| 537 |
| `decoding.py` | Constrained decoding: mask the logits to a menu number or a yes/no, so an echo is impossible |
|
| 538 |
| `accessibility.py` | The accessibility tree: `AtspiReader` and `AtspiDetector`, roles and real extents |
|
| 539 |
+
| `diff.py` | What changed between two frames: `Delta` (added / removed / moved) and `ChangeVerifier`, so "the dialog closed" is checkable |
|
| 540 |
+
| `skills.py` | **2.0** - Skills: a *verified* run written down once, replayed by `SkillBrain` with no model. Labels, never coordinates |
|
| 541 |
+
| `gates.py` | **2.0** - Approval gates: `AllowOnlyLabels`, `InteractiveGate` (yes / no / always / never), `AllOf` / `AnyOf`. A refusal is a step |
|
| 542 |
+
| `runner.py` | **2.0** - `TaskRunner`: a queue of goals, a JSONL resume file, and a `RunReport` that counts what was *verified* |
|
| 543 |
| `showcase.py` | The capability gallery: one real screenshot per capability, checked against the public API |
|
| 544 |
| `measure_vlm.py` | Measures the four ways to ask a model, on a real screen, and prints the table |
|
| 545 |
| `evaluate_daily.py` | A day's tasks against a real app, verified by the app's own log |
|
|
|
|
| 646 |
|
| 647 |
## Capability gallery
|
| 648 |
|
| 649 |
+
**74 capabilities, one screenshot each - and 173 of the package's 180 pre-2.0 public callables (functions, methods, classes and protocols) appear in at least one of them**, together with 10 of its public constants. The 2.0 additions are not pictured yet: **84 new public callables** (skills, approval gates, the batch runner and the structural diff) plus four new CLI entry points. The counts are read off `gallery/index.json`, which records what each picture shows.
|
| 650 |
|
| 651 |
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.
|
| 652 |
|
|
|
|
| 1273 |
|
| 1274 |
**Works:** real capture, a real OCR index with coordinates that land on the right pixel,
|
| 1275 |
a real click an app reacts to, verified success, the closed vocabulary, the policy gates,
|
| 1276 |
+
the no-progress guard, echo detection, the doctor, the CLI, portable traces that score a brain's choices offline, skills learned from verified runs,
|
| 1277 |
+
a resumable task runner, operator approval gates, 284 tests, a 256M VLM that
|
| 1278 |
reads a button label from pixels on a clean screenshot — and the same suite passing inside
|
| 1279 |
a real Windows Server 2022 guest with no KVM (109 passed, exit 0, with the OCR-inference
|
| 1280 |
tests excluded as impractically slow under emulation).
|
|
|
|
| 1303 |
- [x] A daily-usage evaluation: 11 tasks (menu, scroll, typing, shortcut, second window,
|
| 1304 |
verify, refuse, kill switch) against a real GTK app, checked against the application's
|
| 1305 |
own log - see [can it do a day's work](#can-it-do-a-days-work)
|
| 1306 |
+
- [x] **2.0** Skills: a verified run is written down once and replayed with no model at all
|
| 1307 |
+
- [x] **2.0** Operator approval gates - a label whitelist, or ask before every action
|
| 1308 |
+
- [x] **2.0** A resumable task queue with a report, and structural verification of "it worked"
|
| 1309 |
+
- [x] **2.0** `skills` and `batch` subcommands, and an example queue at `tasks/day.json`
|
| 1310 |
+
- [ ] A gallery run that pictures the 2.0 additions: the 84 new public callables have no screenshot yet
|
| 1311 |
- [ ] **Verify UI Automation** (Windows) against a real Windows desktop. The reader and the
|
| 1312 |
detector are implemented and unit-tested against a synthetic control tree, and
|
| 1313 |
`accessibility_available("uia")` reports the truth on any machine - but no Windows
|
|
|
|
| 1337 |
|
| 1338 |
[project]
|
| 1339 |
name = "tiny-computer-agent"
|
| 1340 |
+
version = "2.0.0"
|
| 1341 |
+
description = "An agentic harness that drives a real computer with a tiny, fully local model - no cloud API. It learns from verified runs."
|
| 1342 |
readme = "README.md"
|
| 1343 |
requires-python = ">=3.10"
|
| 1344 |
license = { text = "MIT" }
|
|
|
|
| 1408 |
'''
|
| 1409 |
|
| 1410 |
mo.md(f"packaging: {len(pyproject_toml) + len(license_txt) + len(gitignore_txt)} chars, "
|
| 1411 |
+
f"v2.0.0 with ocr/desktop/vision extras")
|
| 1412 |
return gitignore_txt, license_txt, pyproject_toml
|
| 1413 |
|
| 1414 |
|
|
|
|
| 1464 |
if (_root / _tool).is_file():
|
| 1465 |
_files[_tool] = (_root / _tool).read_text(encoding="utf-8")
|
| 1466 |
_folders = [f for f in ("tiny_computer_agent", "tests", "gallery",
|
| 1467 |
+
"tasks", "showcase_scenes", "preview")
|
| 1468 |
if (_root / f).is_dir()]
|
| 1469 |
if (_root / ".github").is_dir():
|
| 1470 |
_folders.append(".github") # the CI that builds the Windows gallery
|