hkblockchaintechnology commited on
Commit
0df70b6
·
verified ·
1 Parent(s): 36cccb6

Publish tiny_computer_agent_app.py from marimo

Browse files
Files changed (1) hide show
  1. tiny_computer_agent_app.py +408 -0
tiny_computer_agent_app.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import marimo
2
+
3
+ __generated_with = "0.25.2"
4
+ app = marimo.App(width="medium", auto_download=["html"])
5
+
6
+ with app.setup:
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import marimo as mo
11
+ from huggingface_hub import HfApi
12
+
13
+
14
+
15
+ @app.cell(hide_code=True)
16
+ def readme():
17
+ readme_md = '''
18
+ ---
19
+ title: Tiny Computer Agent
20
+ emoji: 🖱️
21
+ colorFrom: indigo
22
+ colorTo: green
23
+ license: mit
24
+ tags:
25
+ - computer-use
26
+ - gui-agent
27
+ - agentic-harness
28
+ - local-first
29
+ - tiny-models
30
+ - offline
31
+ - marimo
32
+ ---
33
+
34
+ # 🖱️ Tiny Computer Agent
35
+
36
+ **An agentic harness that drives a real computer with a tiny, fully local model.**
37
+ No cloud API, no API keys, no telemetry — and a core that imports nothing outside
38
+ the Python standard library.
39
+
40
+ > **Status: early alpha.** The loop runs end to end today, with a scripted screen,
41
+ > dry-run execution, a CLI and 56 offline tests. The vision backend is a
42
+ > documented seam, not a feature — see [what works](#-what-works-today).
43
+
44
+ ## The idea, in one loop
45
+
46
+ ```
47
+ observe think act verify
48
+ +---------+ +---------+ +---------+ +---------+
49
+ | screen |--->| tiny |--->| mouse / |--->| next |
50
+ | frame | | model | | keyboard| | frame |
51
+ +---------+ +---------+ +---------+ +----+----+
52
+ |
53
+ <-------------------------------------------------+
54
+ repeat until done, or the step budget runs out
55
+ ```
56
+
57
+ 1. **Observe** — grab the screen, downscale it, optionally attach OCR text or the
58
+ accessibility tree so the model never has to guess where things are.
59
+ 2. **Think** — a tiny (≤ 3B, quantised) vision-language model emits **exactly one
60
+ action** from a closed vocabulary.
61
+ 3. **Act** — the harness executes it with OS-level input (mouse, keyboard, scroll).
62
+ 4. **Verify** — the next frame shows whether it worked. Repeat, bounded by a step
63
+ budget and a kill switch.
64
+
65
+ ## What works today
66
+
67
+ ```
68
+ $ python -m tiny_computer_agent
69
+ goal: click 'Save' to save the document
70
+ mode: dry-run | screen: scripted demo | kill switch: create /tmp/tiny-computer-agent.STOP to stop
71
+
72
+ step 0 | wait seconds=0.5 reason="'Save' is not on screen yet" | ok: (dry-run) would wait 0.5s
73
+ step 1 | click label='Save' reason="matched 'Save' in the element index" | ok: (dry-run) would click -> pixel (360, 132)
74
+ step 2 | done reason="already clicked 'Save'; goal assumed met" | ok: goal reported met
75
+
76
+ result: success - brain reported done: already clicked 'Save'; goal assumed met
77
+ ```
78
+
79
+ Shipped and tested:
80
+
81
+ - **the loop** — `AgentLoop` runs observe → think → act → verify, **re-reads the
82
+ screen after every action**, and stops on done, on a step budget, on the kill
83
+ switch, on a policy refusal, on a failed action, or on a brain error. Every step
84
+ is recorded in an `Episode` you can render or turn into rows.
85
+ - **a closed action vocabulary** — `click` `type` `key` `scroll` `wait` `done`,
86
+ validated *before* anything executes. A model that invents a verb gets a recorded
87
+ error, not a click.
88
+ - **three brains** — `MockBrain` replays a script; `PlannerBrain` is rule-based and
89
+ genuinely clicks the element matching the goal (it waits for a dialog instead of
90
+ flailing, and refuses to click the same label twice); `VLMBrain` is the seam —
91
+ prompt assembly and reply validation are done, `_generate()` is yours.
92
+ - **two executors** — `DryRunExecutor` is the default: it logs the plan and resolves
93
+ every label to a pixel. `PyAutoGuiExecutor` does real input and **refuses to click
94
+ when a label matches no element**, because a tiny model guessing pixels is how you
95
+ click "Delete account". Dry-run fails exactly where live input refuses, so a
96
+ rehearsal never gives false confidence.
97
+ - **policy gates** — deny-listed text (`sudo`, `rm -rf`, "password", "api_key"),
98
+ deny-listed widgets (terminal, keychain, password manager, wallet, bank), four
99
+ deny-listed key chords, a kill-switch file checked before every action, and a step
100
+ budget.
101
+ - **real input needs two deliberate flags** — `--real-input` *and*
102
+ `TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1`. Default is a dry run.
103
+ - **56 tests** needing no display, no model and no network.
104
+
105
+ What does **not** work yet: seeing a real screen *usefully*. `GrabScreen` captures
106
+ pixels through `mss`, but nothing yet turns pixels into an element index (OCR or
107
+ AT-SPI), so a live screen currently yields zero elements — which is exactly what the
108
+ label path depends on. And no model is wired up.
109
+
110
+ ## Install and run
111
+
112
+ ```bash
113
+ pip install -e . # core: standard library only, no deps
114
+ pytest # 56 offline tests
115
+ python -m tiny_computer_agent # scripted demo, dry run
116
+ python -m tiny_computer_agent --live # your real screen, still dry run
117
+ ```
118
+
119
+ ## Put it on your own task
120
+
121
+ ```python
122
+ from tiny_computer_agent import AgentLoop, Element, FakeScreen, MockBrain
123
+
124
+ screen = FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
125
+ brain = MockBrain([{"kind": "click", "label": "Save"},
126
+ {"kind": "done", "reason": "saved"}])
127
+
128
+ episode = AgentLoop(screen=screen, brain=brain).run("save the document")
129
+ print(episode.render())
130
+ episode.to_rows() # rows for a dataframe or notebook table
131
+ ```
132
+
133
+ Swap `FakeScreen` for `GrabScreen()` and `MockBrain` for your `VLMBrain` subclass and
134
+ that is the whole distance between the tests and a live agent.
135
+
136
+ ## Why "tiny" is the whole point
137
+
138
+ The harness does the heavy lifting, so the model does not have to be clever:
139
+
140
+ | Job | Who does it |
141
+ |---|---|
142
+ | Finding *where* a button is | OCR / accessibility tree / element index — not the model |
143
+ | Choosing *what* to do | a tiny VLM over a **closed action menu** (click, type, key, scroll, wait, done) |
144
+ | Knowing whether it worked | the next frame, plus harness-side checks |
145
+ | Recovering from a bad step | the retry policy in `loop.py`, not the weights |
146
+
147
+ That keeps the agent offline, fast to start, and small enough that a laptop GPU
148
+ (or plain CPU) can run it on the same box it is controlling.
149
+
150
+ ## Candidate backends
151
+
152
+ Pluggable behind one interface; nothing here needs a network call at run time.
153
+
154
+ - **Moondream 2** / **SmolVLM-256M / 500M** — a few hundred MB, safe default
155
+ - **Qwen2-VL-2B-Instruct**, **Florence-2-base** — stronger grounding, still tiny
156
+ - **OCR + text-only planner** — no VLM at all; the cheapest credible baseline
157
+ - **mock brain** — deterministic scripted actions, used to test the harness itself
158
+
159
+ ## Layout
160
+
161
+ ```
162
+ tiny-computer-agent/
163
+ ├── README.md
164
+ ├── LICENSE
165
+ ├── pyproject.toml
166
+ ├── tiny_computer_agent_app.py # marimo console: run an episode, publish this card
167
+ ├── tiny_computer_agent/
168
+ │ ├── actions.py # the closed action vocabulary
169
+ │ ├── observe.py # Observation + element index; FakeScreen, GrabScreen
170
+ │ ├── brain.py # MockBrain, PlannerBrain, VLMBrain (the seam)
171
+ │ ├── act.py # DryRunExecutor (default) + PyAutoGuiExecutor
172
+ │ ├── safety.py # Policy: deny lists, kill switch, step budget
173
+ │ ├── loop.py # AgentLoop, Episode, Step
174
+ │ └── __main__.py # CLI
175
+ └── tests/ # 56 tests: no display, no model, no network
176
+ ```
177
+
178
+ ## Safety, because this thing has your mouse
179
+
180
+ - Run it in a **VM, container, or throwaway desktop account** — not your real session.
181
+ - Dry run is the default; read the whole plan before letting it near your pointer.
182
+ - Deny-list for shells, terminals, payment flows and password managers.
183
+ - A step budget plus a kill-switch file checked before every action.
184
+ - **Local-first, for real:** screen pixels and keystrokes never leave the machine.
185
+
186
+ ## Roadmap
187
+
188
+ - [x] `loop.py` + `act.py` with the mock brain — the harness is testable before any model download
189
+ - [x] policy gates: deny lists, kill switch, step budget, dry run by default
190
+ - [ ] OCR / AT-SPI element index, so a live screen has clickable labels
191
+ - [ ] tiny-VLM grounding backend, behind `VLMBrain._generate()`
192
+ - [ ] marimo console: live screen preview, action log, pause / resume / step
193
+ - [ ] persist episodes, so failed runs can be replayed and scored
194
+
195
+ ## License
196
+
197
+ MIT — see [LICENSE](LICENSE).
198
+ '''
199
+
200
+ mo.md(readme_md)
201
+
202
+ return (readme_md,)
203
+
204
+
205
+ @app.cell(hide_code=True)
206
+ def repo_files():
207
+ pyproject_toml = '''
208
+ [build-system]
209
+ requires = ["setuptools>=68"]
210
+ build-backend = "setuptools.build_meta"
211
+
212
+ [project]
213
+ name = "tiny-computer-agent"
214
+ version = "0.1.0"
215
+ description = "An agentic harness that drives a real computer with a tiny, fully local model - no cloud API."
216
+ readme = "README.md"
217
+ requires-python = ">=3.10"
218
+ license = { text = "MIT" }
219
+ authors = [{ name = "hkblockchaintechnology" }]
220
+ keywords = ["computer-use", "gui-agent", "agentic-harness", "local-first", "tiny-models"]
221
+ classifiers = [
222
+ "Development Status :: 3 - Alpha",
223
+ "Intended Audience :: Developers",
224
+ "Programming Language :: Python :: 3 :: Only",
225
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
226
+ ]
227
+ dependencies = [] # the harness core imports nothing outside the standard library
228
+
229
+ [project.optional-dependencies]
230
+ desktop = ["mss>=9.0", "pyautogui>=0.9", "pillow>=10"]
231
+ vision = ["transformers>=4.48", "torch>=2.2", "pillow>=10"]
232
+ dev = ["pytest>=8"]
233
+
234
+ [project.scripts]
235
+ tiny-computer-agent = "tiny_computer_agent.__main__:main"
236
+
237
+ [project.urls]
238
+ Homepage = "https://huggingface.co/hkblockchaintechnology/tiny-computer-agent"
239
+
240
+ [tool.setuptools]
241
+ packages = ["tiny_computer_agent"]
242
+
243
+ [tool.pytest.ini_options]
244
+ testpaths = ["tests"]
245
+ '''
246
+
247
+ license_txt = '''MIT License
248
+
249
+ Copyright (c) 2026 hkblockchaintechnology
250
+
251
+ Permission is hereby granted, free of charge, to any person obtaining a copy
252
+ of this software and associated documentation files (the "Software"), to deal
253
+ in the Software without restriction, including without limitation the rights
254
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
255
+ copies of the Software, and to permit persons to whom the Software is
256
+ furnished to do so, subject to the following conditions:
257
+
258
+ The above copyright notice and this permission notice shall be included in all
259
+ copies or substantial portions of the Software.
260
+
261
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
262
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
263
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
264
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
265
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
266
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
267
+ SOFTWARE.
268
+ '''
269
+
270
+ gitignore_txt = '''__pycache__/
271
+ *.py[cod]
272
+ .venv/
273
+ venv/
274
+ .env
275
+ *.gguf
276
+ models/
277
+ runs/
278
+ traces/
279
+ .ipynb_checkpoints/
280
+ .DS_Store
281
+ '''
282
+
283
+ mo.md(f"{len(pyproject_toml) + len(license_txt) + len(gitignore_txt)} chars of packaging ready to publish")
284
+
285
+ return gitignore_txt, license_txt, pyproject_toml
286
+
287
+
288
+ @app.cell
289
+ def publish():
290
+ REPO_ID = "hkblockchaintechnology/tiny-computer-agent"
291
+
292
+ publish_button = mo.ui.button(
293
+ label="⬆️ Publish README.md to the Hub",
294
+ value=0,
295
+ on_click=lambda value: value + 1,
296
+ )
297
+
298
+ publish_button
299
+
300
+ return REPO_ID, publish_button
301
+
302
+
303
+ @app.cell
304
+ def publish_status(
305
+ REPO_ID,
306
+ gitignore_txt,
307
+ license_txt,
308
+ publish_button,
309
+ pyproject_toml,
310
+ readme_md,
311
+ ):
312
+ _status = mo.md(
313
+ "This notebook owns the project: the card, the packaging files, the package and "
314
+ "the tests. Click **Publish** to push all of it to the Hub."
315
+ ).callout(kind="info")
316
+
317
+ if publish_button.value:
318
+ _token = os.environ.get("HF_TOKEN")
319
+ if not _token:
320
+ _status = mo.md(
321
+ "No `HF_TOKEN` in the environment. Run `export HF_TOKEN=...` "
322
+ "(or `hf auth login`), then restart the kernel."
323
+ ).callout(kind="warn")
324
+ else:
325
+ _api = HfApi(token=_token)
326
+ _root = Path(__file__).parent
327
+ _notebook = Path(__file__)
328
+ _files = {
329
+ "README.md": readme_md,
330
+ "pyproject.toml": pyproject_toml,
331
+ "LICENSE": license_txt,
332
+ ".gitignore": gitignore_txt,
333
+ "tiny_computer_agent_app.py": _notebook.read_text(encoding="utf-8"),
334
+ }
335
+ _missing = [f for f in ("tiny_computer_agent", "tests") if not (_root / f).is_dir()]
336
+ if _missing:
337
+ _status = mo.md(f"Nothing to publish: missing {_missing} next to `{_root}`").callout(kind="danger")
338
+ else:
339
+ try:
340
+ for _name, _text in _files.items():
341
+ _api.upload_file(
342
+ path_or_fileobj=_text.encode("utf-8"),
343
+ path_in_repo=_name,
344
+ repo_id=REPO_ID,
345
+ repo_type="model",
346
+ commit_message=f"Publish {_name} from marimo",
347
+ )
348
+ for _folder in ("tiny_computer_agent", "tests"):
349
+ _api.upload_folder(
350
+ folder_path=str(_root / _folder),
351
+ path_in_repo=_folder,
352
+ repo_id=REPO_ID,
353
+ repo_type="model",
354
+ commit_message=f"Publish {_folder}/ from marimo",
355
+ ignore_patterns=["__pycache__/*", "*.pyc"],
356
+ )
357
+ _status = mo.md(
358
+ f"✅ Published {len(_files)} files and 2 folders to "
359
+ f"[{REPO_ID}](https://huggingface.co/{REPO_ID})"
360
+ ).callout(kind="success")
361
+ except Exception as _e:
362
+ _status = mo.md(f"Publish failed: `{type(_e).__name__}: {_e}`").callout(kind="danger")
363
+
364
+ _status
365
+
366
+ return
367
+
368
+
369
+ @app.cell
370
+ def demo():
371
+ from tiny_computer_agent import (
372
+ AgentLoop, DryRunExecutor, Element, FakeScreen, PlannerBrain, Policy,
373
+ )
374
+
375
+ _scripted_screen = FakeScreen(
376
+ [
377
+ (Element("Terminal", x=0, y=0, width=40, height=24),
378
+ Element("Files", x=60, y=0, width=40, height=24)),
379
+ (Element("Name", kind="input", x=100, y=120, width=200, height=24),
380
+ Element("Save", x=320, y=120, width=80, height=24)),
381
+ (Element("Save", x=320, y=120, width=80, height=24),),
382
+ ],
383
+ text="Untitled document - unsaved changes",
384
+ )
385
+
386
+ demo_episode = AgentLoop(
387
+ screen=_scripted_screen,
388
+ brain=PlannerBrain(),
389
+ executor=DryRunExecutor(),
390
+ policy=Policy(step_budget=6),
391
+ ).run("click \'Save\' to save the document")
392
+
393
+ mo.vstack(
394
+ [
395
+ mo.md(
396
+ "**Scripted episode, dry run** — no model, no display, nothing touched. "
397
+ "The planner waits for the dialog, clicks the label, and stops."
398
+ ),
399
+ mo.ui.table(demo_episode.to_rows(), selection=None),
400
+ mo.md(f"`{demo_episode.render().splitlines()[1]}`"),
401
+ ]
402
+ )
403
+
404
+ return
405
+
406
+
407
+ if __name__ == "__main__":
408
+ app.run()