hkblockchaintechnology commited on
Commit
e2f048d
·
verified ·
1 Parent(s): 4ed9d79

2.0: skills, approval gates, a resumable task runner, structural verification

Browse files
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.3.0: it drives a real desktop — on Linux and on Windows.** Screen capture,
26
- > an OCR element index with real coordinates, a real mouse click, and a *verified*
27
- > outcome. **All 74 capabilities have a real screenshot** in the [capability gallery](#capability-gallery) (Linux captures).
 
 
 
 
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
- **229 tests pass**, including real OCR over a real image and (opt-in) live-desktop tests
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 229 tests.)
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 **229 tests**. 227 run headless — no display, no network, no
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` | 10 | the command line: `--doctor`, `--image`, `--live`, argument handling |
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` | 16 | role filtering, the coordinate contract, the fallback chain, and real AT-SPI |
 
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 # 227 passed, 2 skipped
367
- TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q # 229 passed, on a desktop
 
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 # 229 tests, no display or network needed
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 every one of the 141 public callables in the package (functions, methods, classes and protocols) appears in at least one of them**, together with 6 of its public constants.
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, 164 tests, a 256M VLM that
 
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.3.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.3.0"
127
 
128
  __all__ = [
129
- "ACTION_KINDS", "ACTION_MENU", "AccessibleNode", "Action",
130
- "ActionError", "ActionResult", "AgentLoop", "AtspiDetector",
131
- "AtspiReader", "AutoDetector", "BRAIN_NAMES", "Brain",
132
- "BrainError", "Check", "DEFAULT_KILL_SWITCH", "DEFAULT_TEXT_MODEL",
133
- "DEFAULT_VISION_MODEL", "DecisionMenu", "Detector", "DetectorUnavailable",
134
- "DryRunExecutor", "Element", "Episode", "Executor",
135
- "FakeScreen", "GoalUnreachable", "GrabScreen", "GridDetector",
136
- "INTERACTIVE_ROLES", "ImageScreen", "IndexedScreen", "InputUnavailable",
137
- "MODEL_BRAINS", "MenuItem", "MockBrain", "ModelBrain",
138
- "Observation", "OcrDetector", "PlannerBrain", "Policy",
139
- "PolicyViolation", "PyAutoGuiExecutor", "Scenario", "Scoreboard",
140
- "ScreenSource", "ScreenUnavailable", "Step", "TEXT_ROLES",
141
- "TextPlannerBrain", "Trace", "TraceStep", "TreeReader",
142
- "UiaDetector", "UiaReader", "VLMBrain", "Verdict",
143
- "__version__", "accessibility_available", "benchmark", "benchmark_traces",
144
- "build_agent", "detector_available", "display_available", "doctor",
145
- "extract_json_object", "extract_json_objects", "fake_desktop", "load_traces",
146
- "make_detector", "make_model_brain", "model_cached", "model_dependencies",
147
- "normalise_label", "parse_action", "parse_decision", "pick_dtype",
148
- "resolve_device", "resolve_point", "run_checks", "run_scenario",
149
- "score", "score_replay", "score_trace", "write_fake_desktop",
 
 
 
 
 
 
 
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) -> AgentLoop:
 
 
174
  """Wire a real desktop into an :class:`AgentLoop` in one call.
175
 
176
  Defaults are the safe ones: dry run, a step budget of 10, and a kill-switch file
177
  on disk that aborts the next action if it appears. ``screen``/``detector``/
178
  ``brain`` can be injected, which is how the tests drive this without a desktop.
 
 
 
 
179
  """
180
  if detector is None or isinstance(detector, str):
181
  detector = make_detector(detector or "ocr", **(detector_options or {}))
@@ -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 has passed it
77
  * the episode ends on a step budget, a kill switch, a brain error, or a
78
  failed screen capture (reported as a step, never as a traceback)
79
  * the screen is re-read after every action, so the next decision sees the
@@ -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) -> 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 has passed it
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.3.0: it drives a real desktop — on Linux and on Windows.** Screen capture,
67
- > an OCR element index with real coordinates, a real mouse click, and a *verified*
68
- > outcome. **All 74 capabilities have a real screenshot** in the [capability gallery](#capability-gallery) (Linux captures).
 
 
 
 
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
- **229 tests pass**, including real OCR over a real image and (opt-in) live-desktop tests
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 229 tests.)
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 **229 tests**. 227 run headless — no display, no network, no
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` | 10 | the command line: `--doctor`, `--image`, `--live`, argument handling |
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` | 16 | role filtering, the coordinate contract, the fallback chain, and real AT-SPI |
 
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 # 227 passed, 2 skipped
408
- TINY_COMPUTER_AGENT_LIVE_TEST=1 pytest tests -q # 229 passed, on a desktop
 
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 # 229 tests, no display or network needed
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 every one of the 141 public callables in the package (functions, methods, classes and protocols) appears in at least one of them**, together with 6 of its public constants.
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, 164 tests, a 256M VLM that
 
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.3.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"v0.3.0 with ocr/desktop/vision extras")
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