Publish showcase_catalog.py from marimo
Browse files- showcase_catalog.py +1493 -0
showcase_catalog.py
ADDED
|
@@ -0,0 +1,1493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The capability catalogue: what the harness can do, and the proof of each one.
|
| 2 |
+
|
| 3 |
+
Every entry names the public callables it demonstrates, so the gallery in the model
|
| 4 |
+
card can promise that nothing in the API is left without a picture.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
from showcase_core import (
|
| 12 |
+
CATALOG, PY, ROOT, SCENES, Shot, annotate, close_scene, crop, grab, open_scene,
|
| 13 |
+
overlay_lines, side_by_side,
|
| 14 |
+
)
|
| 15 |
+
import os
|
| 16 |
+
import subprocess
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def term(sid, category, title, shows, note="", argv=None, code=None, env=None):
|
| 20 |
+
CATALOG.append(Shot(id=sid, category=category, title=title, shows=shows, note=note,
|
| 21 |
+
kind="terminal", argv=argv, code=code, env=env))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def desk(sid, category, title, shows, note="", scene_name=None, make=None):
|
| 25 |
+
CATALOG.append(Shot(id=sid, category=category, title=title, shows=shows, note=note,
|
| 26 |
+
kind="desktop", scene=scene_name, make=make))
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def cli(*flags, goal=None):
|
| 30 |
+
argv = [PY, "-m", "tiny_computer_agent"]
|
| 31 |
+
if goal:
|
| 32 |
+
argv.append(goal)
|
| 33 |
+
return argv + list(flags)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# =========================================================================== #
|
| 37 |
+
# 1. the closed action vocabulary
|
| 38 |
+
# =========================================================================== #
|
| 39 |
+
|
| 40 |
+
term("vocab-verbs", "Action vocabulary",
|
| 41 |
+
"The six verbs, and only six",
|
| 42 |
+
["Action", "parse_action", "ACTION_KINDS", "ACTION_MENU", "Action.describe"],
|
| 43 |
+
note="Every action is validated on construction, before anything touches the machine.",
|
| 44 |
+
code="""
|
| 45 |
+
from tiny_computer_agent import ACTION_KINDS, parse_action
|
| 46 |
+
|
| 47 |
+
print("the whole vocabulary:", ", ".join(ACTION_KINDS))
|
| 48 |
+
print()
|
| 49 |
+
for payload in [
|
| 50 |
+
{"kind": "click", "label": "Save"},
|
| 51 |
+
{"kind": "click", "x": 640, "y": 400},
|
| 52 |
+
{"kind": "type", "text": "report"},
|
| 53 |
+
{"kind": "key", "keys": "ctrl+s"},
|
| 54 |
+
{"kind": "scroll", "amount": -3},
|
| 55 |
+
{"kind": "wait", "seconds": 1.5},
|
| 56 |
+
{"kind": "done", "reason": "the file is saved"},
|
| 57 |
+
]:
|
| 58 |
+
action = parse_action(dict(payload, reason="demonstration"))
|
| 59 |
+
print(f" parsed -> {action.describe()}")
|
| 60 |
+
""")
|
| 61 |
+
|
| 62 |
+
term("vocab-refusals", "Action vocabulary",
|
| 63 |
+
"Malformed actions are refused, not guessed at",
|
| 64 |
+
["ActionError", "Action", "parse_action"],
|
| 65 |
+
note="An unknown verb or a missing target raises before the machine is touched.",
|
| 66 |
+
code="""
|
| 67 |
+
from tiny_computer_agent import ActionError, parse_action
|
| 68 |
+
|
| 69 |
+
print("every one of these is refused, with the reason why:")
|
| 70 |
+
for payload in [
|
| 71 |
+
{"kind": "format_disk"}, # not a verb we have
|
| 72 |
+
{"kind": "click"}, # click with no target
|
| 73 |
+
{"kind": "click", "label": "Save", "x": -5, "y": 2}, # negative pixel
|
| 74 |
+
{"kind": "type"}, # type with no text
|
| 75 |
+
{"kind": "key"}, # key with no chord
|
| 76 |
+
{"kind": "scroll", "amount": 0}, # scroll that scrolls nowhere
|
| 77 |
+
{"kind": "wait", "seconds": 0}, # wait that does not wait
|
| 78 |
+
]:
|
| 79 |
+
try:
|
| 80 |
+
parse_action(payload)
|
| 81 |
+
print(f" ACCEPTED (bug!): {payload}")
|
| 82 |
+
except ActionError as exc:
|
| 83 |
+
print(f" {str(payload)[:38]:40} -> {exc}")
|
| 84 |
+
""")
|
| 85 |
+
|
| 86 |
+
term("vocab-menu", "Action vocabulary",
|
| 87 |
+
"The contract handed to a model",
|
| 88 |
+
["ACTION_MENU"],
|
| 89 |
+
note="The prompt-side menu: one JSON object, six shapes, no invented verbs.",
|
| 90 |
+
code="""
|
| 91 |
+
from tiny_computer_agent import ACTION_MENU
|
| 92 |
+
print(ACTION_MENU)
|
| 93 |
+
""")
|
| 94 |
+
|
| 95 |
+
# =========================================================================== #
|
| 96 |
+
# 2. menus and lenient parsing
|
| 97 |
+
# =========================================================================== #
|
| 98 |
+
|
| 99 |
+
term("menu-numbered", "Decisions",
|
| 100 |
+
"A short numbered menu, in reading order",
|
| 101 |
+
["DecisionMenu", "DecisionMenu.render", "MenuItem", "MenuItem.label"],
|
| 102 |
+
note="A 256M model cannot emit coordinates; it can pick a number from twelve.",
|
| 103 |
+
code="""
|
| 104 |
+
from tiny_computer_agent import DecisionMenu, Element, Observation
|
| 105 |
+
|
| 106 |
+
frame = Observation(elements=(
|
| 107 |
+
Element("Dialog", x=40, y=20, width=90, height=24),
|
| 108 |
+
Element("File name", x=200, y=95, width=120, height=22),
|
| 109 |
+
Element("Cancel", x=400, y=270, width=110, height=30),
|
| 110 |
+
Element("Save", x=200, y=270, width=110, height=30),
|
| 111 |
+
), text="Untitled document - unsaved changes")
|
| 112 |
+
|
| 113 |
+
menu = DecisionMenu(frame)
|
| 114 |
+
print(f"{len(menu)} numbered choices, sorted top-to-bottom then left-to-right:")
|
| 115 |
+
print(menu.render())
|
| 116 |
+
print()
|
| 117 |
+
print("capped at 3 for a small context window:")
|
| 118 |
+
print(DecisionMenu(frame, max_items=3).render())
|
| 119 |
+
""")
|
| 120 |
+
|
| 121 |
+
term("menu-lookup", "Decisions",
|
| 122 |
+
"Menu lookup tolerates what models actually send",
|
| 123 |
+
["DecisionMenu.item", "DecisionMenu.find", "DecisionMenu.mentioned_in"],
|
| 124 |
+
note="1, \"2\", 2.0 and \"Save.\" all resolve; a stray word resolves to nothing.",
|
| 125 |
+
code="""
|
| 126 |
+
from tiny_computer_agent import DecisionMenu, Element, Observation
|
| 127 |
+
|
| 128 |
+
frame = Observation(elements=(
|
| 129 |
+
Element("Files", x=10, y=6, width=60, height=20),
|
| 130 |
+
Element("Save .", x=200, y=270, width=90, height=30), # OCR kept the full stop
|
| 131 |
+
Element("Cancel", x=400, y=270, width=90, height=30),
|
| 132 |
+
))
|
| 133 |
+
menu = DecisionMenu(frame)
|
| 134 |
+
|
| 135 |
+
for probe in [1, "2", 2.0, "2 ", 0, 99, "two"]:
|
| 136 |
+
item = menu.item(probe)
|
| 137 |
+
print(f" item({probe!r:6}) -> {item.label if item else 'no such item'}")
|
| 138 |
+
|
| 139 |
+
print()
|
| 140 |
+
for label in ["save", "SAVE", "Save .", "nothing here"]:
|
| 141 |
+
found = menu.find(label)
|
| 142 |
+
print(f" find({label!r:15}) -> {found.label if found else 'not on screen'}")
|
| 143 |
+
|
| 144 |
+
print()
|
| 145 |
+
print("mentioned_in('click Save or Cancel'):",
|
| 146 |
+
[i.label for i in menu.mentioned_in("click Save or Cancel")])
|
| 147 |
+
""")
|
| 148 |
+
|
| 149 |
+
term("decision-json", "Decisions",
|
| 150 |
+
"Reading a decision out of a small model's reply",
|
| 151 |
+
["parse_decision", "extract_json_object", "extract_json_objects", "DecisionMenu"],
|
| 152 |
+
note="The LAST JSON object wins, because a model that echoes its prompt answers last.",
|
| 153 |
+
code="""
|
| 154 |
+
from tiny_computer_agent import DecisionMenu, Element, Observation, extract_json_objects, parse_decision
|
| 155 |
+
|
| 156 |
+
frame = Observation(elements=(
|
| 157 |
+
Element("Files", x=10, y=6, width=60, height=20),
|
| 158 |
+
Element("Save", x=200, y=270, width=90, height=30),
|
| 159 |
+
Element("Cancel", x=400, y=270, width=90, height=30),
|
| 160 |
+
))
|
| 161 |
+
|
| 162 |
+
reply = 'Menu: 1. Files 2. Save 3. Cancel\nAnswer: {"kind": "click", "index": 2}'
|
| 163 |
+
print("reply:", reply.replace(chr(10), " | "), chr(10))
|
| 164 |
+
print("every JSON object found, in order:", extract_json_objects(reply))
|
| 165 |
+
print("the decision taken:", parse_decision(reply, frame).describe())
|
| 166 |
+
print()
|
| 167 |
+
for reply in ["2", "Click on \"Save\" in the dialogue.", "The goal is already done."]:
|
| 168 |
+
print(f" {reply!r:42} -> {parse_decision(reply, frame).describe()}")
|
| 169 |
+
""")
|
| 170 |
+
|
| 171 |
+
term("decision-echo-guard", "Decisions",
|
| 172 |
+
"An echoed prompt is refused; a right answer in the wrong field is recovered",
|
| 173 |
+
["parse_decision", "BrainError", "DecisionMenu.mentioned_in"],
|
| 174 |
+
note="Real 256M/0.5B failure modes, both caught: no blind click either way.",
|
| 175 |
+
code="""
|
| 176 |
+
from tiny_computer_agent import BrainError, DecisionMenu, Element, Observation, parse_decision
|
| 177 |
+
|
| 178 |
+
frame = Observation(elements=(
|
| 179 |
+
Element("Files", x=10, y=6, width=60, height=20),
|
| 180 |
+
Element("Save", x=200, y=270, width=90, height=30),
|
| 181 |
+
Element("Cancel", x=400, y=270, width=90, height=30),
|
| 182 |
+
))
|
| 183 |
+
|
| 184 |
+
echoed = DecisionMenu(frame).render() # the model read the menu back to us
|
| 185 |
+
try:
|
| 186 |
+
parse_decision(echoed, frame)
|
| 187 |
+
print("echo ACCEPTED (bug!)")
|
| 188 |
+
except BrainError as exc:
|
| 189 |
+
print("echo refused:", exc)
|
| 190 |
+
|
| 191 |
+
print()
|
| 192 |
+
print("a 0.5B model really did answer like this - correct answer in the wrong field:")
|
| 193 |
+
salvaged = parse_decision('{"kind": "click", "index": 0, "reason": "Save"}', frame)
|
| 194 |
+
print(" recovered ->", salvaged.describe())
|
| 195 |
+
|
| 196 |
+
print()
|
| 197 |
+
try:
|
| 198 |
+
parse_decision('{"kind": "click", "index": 42}', frame)
|
| 199 |
+
except BrainError as exc:
|
| 200 |
+
print("an index outside the menu is refused:", exc)
|
| 201 |
+
try:
|
| 202 |
+
parse_decision('{"kind": "click", "label": "Format disk"}', frame)
|
| 203 |
+
except BrainError as exc:
|
| 204 |
+
print("a label that is not on screen is refused:", exc)
|
| 205 |
+
""")
|
| 206 |
+
|
| 207 |
+
term("decision-garbled", "Decisions",
|
| 208 |
+
"A garbled verb with a valid label is still usable",
|
| 209 |
+
["parse_decision", "BrainError"],
|
| 210 |
+
note="A 256M model really does emit {\"kind\": \"file\", \"label\": \"Save\"}.",
|
| 211 |
+
code="""
|
| 212 |
+
from tiny_computer_agent import Element, Observation, parse_decision
|
| 213 |
+
frame = Observation(elements=(Element("Save", x=200, y=270, width=90, height=30),))
|
| 214 |
+
print('reply: {"kind": "file", "label": "Save"} <- not a verb we have')
|
| 215 |
+
print(" ->", parse_decision('{"kind": "file", "label": "Save"}', frame).describe())
|
| 216 |
+
""")
|
| 217 |
+
|
| 218 |
+
# =========================================================================== #
|
| 219 |
+
# 3. execution: the only two places that touch the machine
|
| 220 |
+
# =========================================================================== #
|
| 221 |
+
|
| 222 |
+
term("exec-dry-run", "Execution",
|
| 223 |
+
"Dry run resolves every click to pixels - and touches nothing",
|
| 224 |
+
["DryRunExecutor", "DryRunExecutor.execute", "resolve_point", "ActionResult"],
|
| 225 |
+
note="The default everywhere: read a whole episode before anything clicks.",
|
| 226 |
+
code="""
|
| 227 |
+
from tiny_computer_agent import Action, DryRunExecutor, Element, Observation
|
| 228 |
+
|
| 229 |
+
frame = Observation(elements=(Element("Save", x=200, y=270, width=110, height=30),))
|
| 230 |
+
executor = DryRunExecutor()
|
| 231 |
+
|
| 232 |
+
for action in [Action("click", label="Save"),
|
| 233 |
+
Action("click", label="Nowhere"),
|
| 234 |
+
Action("type", text="report.txt"),
|
| 235 |
+
Action("key", keys="ctrl+s"),
|
| 236 |
+
Action("scroll", amount=-3),
|
| 237 |
+
Action("wait", seconds=0.5),
|
| 238 |
+
Action("done", reason="finished")]:
|
| 239 |
+
result = executor.execute(action, frame)
|
| 240 |
+
print(f" {result.summary()}")
|
| 241 |
+
|
| 242 |
+
print()
|
| 243 |
+
print("actions the executor *performed* on the machine:", executor.performed)
|
| 244 |
+
print("the log it kept instead:")
|
| 245 |
+
for line in executor.log:
|
| 246 |
+
print(" ", line)
|
| 247 |
+
""")
|
| 248 |
+
|
| 249 |
+
term("exec-refuses-unresolved", "Execution",
|
| 250 |
+
"Real input refuses to guess a pixel for a label it cannot find",
|
| 251 |
+
["PyAutoGuiExecutor", "PyAutoGuiExecutor.execute", "ActionResult.summary"],
|
| 252 |
+
note="This is the gate between a hallucinated label and 'Delete account'.",
|
| 253 |
+
code="""
|
| 254 |
+
from tiny_computer_agent import Action, Element, Observation, PyAutoGuiExecutor
|
| 255 |
+
|
| 256 |
+
frame = Observation(elements=(Element("Save", x=200, y=270, width=110, height=30),))
|
| 257 |
+
executor = PyAutoGuiExecutor()
|
| 258 |
+
|
| 259 |
+
result = executor.execute(Action("click", label="Delete account"), frame)
|
| 260 |
+
print("a label that is not on screen:")
|
| 261 |
+
print(" ", result.summary())
|
| 262 |
+
print(" ok =", result.ok, " - nothing was clicked")
|
| 263 |
+
|
| 264 |
+
print()
|
| 265 |
+
resolved = executor.execute(Action("done", reason="nothing to do"), frame)
|
| 266 |
+
print("a verb that needs no target passes straight through:")
|
| 267 |
+
print(" ", resolved.summary())
|
| 268 |
+
""")
|
| 269 |
+
|
| 270 |
+
# =========================================================================== #
|
| 271 |
+
# 4. the loop
|
| 272 |
+
# =========================================================================== #
|
| 273 |
+
|
| 274 |
+
term("loop-end-to-end", "The loop",
|
| 275 |
+
"observe -> think -> act -> verify, traced end to end",
|
| 276 |
+
["AgentLoop", "AgentLoop.run", "Episode", "Episode.render", "Episode.to_rows", "Step", "Step.line", "Step.ok"],
|
| 277 |
+
note="One call runs a whole episode; every step is kept for audit.",
|
| 278 |
+
code="""
|
| 279 |
+
from tiny_computer_agent import AgentLoop, Element, FakeScreen, MockBrain
|
| 280 |
+
|
| 281 |
+
screen = FakeScreen([
|
| 282 |
+
(Element("Terminal", x=0, y=0, width=70, height=24), Element("Files", x=80, y=0, width=70, height=24)),
|
| 283 |
+
(Element("Name", x=100, y=120, width=200, height=24), Element("Save", x=320, y=120, width=80, height=24)),
|
| 284 |
+
(Element("Save", x=320, y=120, width=80, height=24),),
|
| 285 |
+
], text="Untitled document - unsaved changes")
|
| 286 |
+
|
| 287 |
+
episode = AgentLoop(
|
| 288 |
+
screen=screen,
|
| 289 |
+
brain=MockBrain([
|
| 290 |
+
{"kind": "click", "label": "Save"},
|
| 291 |
+
{"kind": "type", "text": "report"},
|
| 292 |
+
{"kind": "done", "reason": "the document is saved"},
|
| 293 |
+
]),
|
| 294 |
+
).run("save the report")
|
| 295 |
+
|
| 296 |
+
print(episode.render())
|
| 297 |
+
print()
|
| 298 |
+
if episode.success:
|
| 299 |
+
print("the same episode as rows for a table:")
|
| 300 |
+
for row in episode.to_rows():
|
| 301 |
+
print(" ", row)
|
| 302 |
+
""")
|
| 303 |
+
|
| 304 |
+
term("loop-budget", "The loop",
|
| 305 |
+
"A hard step budget, so a run cannot wander",
|
| 306 |
+
["Policy", "Policy.step_budget", "AgentLoop.run"],
|
| 307 |
+
note="The budget ends the episode and says so; nothing executes past it.",
|
| 308 |
+
code="""
|
| 309 |
+
from tiny_computer_agent import AgentLoop, Element, FakeScreen, MockBrain, Policy
|
| 310 |
+
|
| 311 |
+
episode = AgentLoop(
|
| 312 |
+
screen=FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)]),
|
| 313 |
+
brain=MockBrain([{"kind": "wait", "seconds": 0.2 + i} for i in range(10)]),
|
| 314 |
+
policy=Policy(step_budget=3),
|
| 315 |
+
).run("click Save")
|
| 316 |
+
|
| 317 |
+
print(episode.render())
|
| 318 |
+
print()
|
| 319 |
+
print("steps taken:", len(episode.steps), "| success:", episode.success)
|
| 320 |
+
""")
|
| 321 |
+
|
| 322 |
+
term("loop-no-progress-guard", "The loop",
|
| 323 |
+
"Repeating one action against an unchanged screen is stopped",
|
| 324 |
+
["AgentLoop.run", "Step", "Step.error"],
|
| 325 |
+
note="A stuck agent is not a persistent one - it is stopped and told why.",
|
| 326 |
+
code="""
|
| 327 |
+
from tiny_computer_agent import AgentLoop, Element, FakeScreen, MockBrain, Policy
|
| 328 |
+
|
| 329 |
+
episode = AgentLoop(
|
| 330 |
+
screen=FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)]),
|
| 331 |
+
brain=MockBrain([{"kind": "click", "label": "Save"}] * 10),
|
| 332 |
+
policy=Policy(step_budget=10),
|
| 333 |
+
).run("click Save")
|
| 334 |
+
|
| 335 |
+
print(episode.render())
|
| 336 |
+
print()
|
| 337 |
+
print("stopped after", len(episode.steps), "steps instead of burning all 10:")
|
| 338 |
+
print(" ", episode.steps[-1].error)
|
| 339 |
+
""")
|
| 340 |
+
|
| 341 |
+
term("loop-kill-switch", "The loop",
|
| 342 |
+
"A kill-switch file aborts before the next action",
|
| 343 |
+
["Policy.kill_switch_path", "Policy.should_stop", "AgentLoop.run"], env={"TCA_DEMO": "1"},
|
| 344 |
+
note="Checked before every single action: create the file, the run stops.",
|
| 345 |
+
code="""
|
| 346 |
+
import os, tempfile
|
| 347 |
+
from pathlib import Path
|
| 348 |
+
from tiny_computer_agent import AgentLoop, Element, FakeScreen, MockBrain, Policy
|
| 349 |
+
|
| 350 |
+
marker = Path(tempfile.mkdtemp()) / "tiny-computer-agent.STOP"
|
| 351 |
+
marker.write_text("stop")
|
| 352 |
+
print("kill switch file:", marker, "- present:", marker.exists())
|
| 353 |
+
|
| 354 |
+
episode = AgentLoop(
|
| 355 |
+
screen=FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)]),
|
| 356 |
+
brain=MockBrain([{"kind": "click", "label": "Save"}]),
|
| 357 |
+
policy=Policy(kill_switch_path=str(marker)),
|
| 358 |
+
).run("click Save")
|
| 359 |
+
|
| 360 |
+
print()
|
| 361 |
+
print("steps taken:", episode.steps, "(none)")
|
| 362 |
+
print("stop reason:", episode.stop_reason)
|
| 363 |
+
""")
|
| 364 |
+
|
| 365 |
+
term("loop-survives-failures", "The loop",
|
| 366 |
+
"A dead screen or a broken brain is an episode, not a traceback",
|
| 367 |
+
["AgentLoop.run", "Episode.stop_reason", "GoalUnreachable", "BrainError"],
|
| 368 |
+
note="Every failure path is recorded as a failed step with a reason.",
|
| 369 |
+
code="""
|
| 370 |
+
from tiny_computer_agent import AgentLoop, Element, FakeScreen, MockBrain, PlannerBrain, Policy, GoalUnreachable
|
| 371 |
+
|
| 372 |
+
class Blind:
|
| 373 |
+
def capture(self):
|
| 374 |
+
raise RuntimeError("X connection failed")
|
| 375 |
+
|
| 376 |
+
class Exploding:
|
| 377 |
+
def decide(self, goal, observation):
|
| 378 |
+
raise RuntimeError("the model died")
|
| 379 |
+
|
| 380 |
+
print("a capture that fails:")
|
| 381 |
+
print(" ", AgentLoop(screen=Blind(), brain=MockBrain([{"kind": "done"}])).run("anything").stop_reason)
|
| 382 |
+
print("a brain that crashes:")
|
| 383 |
+
print(" ", AgentLoop(screen=FakeScreen([(Element("Save"),)]), brain=Exploding()).run("save").stop_reason)
|
| 384 |
+
print("a brain that gives up (a failure, never a success):")
|
| 385 |
+
episode = AgentLoop(screen=FakeScreen([(Element("Files"),)]), brain=PlannerBrain(max_waits=0)).run("click 'Save'")
|
| 386 |
+
print(" success:", episode.success, "|", episode.stop_reason)
|
| 387 |
+
""")
|
| 388 |
+
|
| 389 |
+
term("loop-verifier", "The loop",
|
| 390 |
+
"Success is evidence, not the model's word",
|
| 391 |
+
["AgentLoop.run", "Episode.success", "Observation.text"],
|
| 392 |
+
note="Without a verifier, a successful action that removes its own target looks like a failure.",
|
| 393 |
+
code="""
|
| 394 |
+
from tiny_computer_agent import AgentLoop, Element, MockBrain, Observation, Policy
|
| 395 |
+
|
| 396 |
+
class ScriptedTextScreen:
|
| 397 |
+
def __init__(self, texts): self.texts, self.n = list(texts), 0
|
| 398 |
+
def capture(self) -> Observation:
|
| 399 |
+
text = self.texts[min(self.n, len(self.texts) - 1)]; self.n += 1
|
| 400 |
+
return Observation(index=self.n - 1, elements=(Element("Save"),), text=text)
|
| 401 |
+
|
| 402 |
+
waits = [{"kind": "wait", "seconds": 0.1}] * 4
|
| 403 |
+
screen = lambda: ScriptedTextScreen(["Unsaved changes", "before", "Saved"])
|
| 404 |
+
|
| 405 |
+
without = AgentLoop(screen=screen(), brain=MockBrain(waits), policy=Policy(step_budget=3)).run("save")
|
| 406 |
+
print("no verifier ->", without.success, "|", without.stop_reason)
|
| 407 |
+
|
| 408 |
+
with_verifier = AgentLoop(
|
| 409 |
+
screen=screen(), brain=MockBrain(waits), policy=Policy(step_budget=4),
|
| 410 |
+
verify=lambda observation: "saved" in observation.text.lower(),
|
| 411 |
+
).run("save")
|
| 412 |
+
print("with verifier ->", with_verifier.success, "|", with_verifier.stop_reason)
|
| 413 |
+
""")
|
| 414 |
+
|
| 415 |
+
term("loop-on-step", "The loop",
|
| 416 |
+
"Every step can be streamed out as it happens",
|
| 417 |
+
["AgentLoop.run", "Step.line", "on_step"],
|
| 418 |
+
note="This is the hook a live console, a progress bar or a websocket uses.",
|
| 419 |
+
code="""
|
| 420 |
+
from tiny_computer_agent import AgentLoop, Element, FakeScreen, MockBrain
|
| 421 |
+
|
| 422 |
+
seen = []
|
| 423 |
+
episode = AgentLoop(
|
| 424 |
+
screen=FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)]),
|
| 425 |
+
brain=MockBrain([{"kind": "click", "label": "Save"}, {"kind": "done", "reason": "saved"}]),
|
| 426 |
+
on_step=lambda step: seen.append(step.line()),
|
| 427 |
+
).run("save")
|
| 428 |
+
|
| 429 |
+
print("streamed live, one line per step:")
|
| 430 |
+
for line in seen:
|
| 431 |
+
print(" ", line)
|
| 432 |
+
print()
|
| 433 |
+
print("and the finished episode has", len(episode.steps), "steps")
|
| 434 |
+
""")
|
| 435 |
+
|
| 436 |
+
# =========================================================================== #
|
| 437 |
+
# 5. the safety gates
|
| 438 |
+
# =========================================================================== #
|
| 439 |
+
|
| 440 |
+
term("safety-deny-text", "Safety",
|
| 441 |
+
"Typing from the deny list never reaches the keyboard",
|
| 442 |
+
["Policy", "Policy.check", "PolicyViolation", "DEFAULT_DENY_TEXT"],
|
| 443 |
+
note="Checked before execution; a refusal ends the episode and clicks nothing.",
|
| 444 |
+
code="""
|
| 445 |
+
from tiny_computer_agent import Action, DryRunExecutor, AgentLoop, Element, FakeScreen, MockBrain, Policy
|
| 446 |
+
|
| 447 |
+
for text in ["report.txt", "sudo rm -rf /", "my api_key is here", "correct horse battery staple"]:
|
| 448 |
+
episode = AgentLoop(
|
| 449 |
+
screen=FakeScreen([(Element("Name", kind="input", x=100, y=120, width=200, height=24),)]),
|
| 450 |
+
brain=MockBrain([{"kind": "type", "text": text}]),
|
| 451 |
+
).run("fill the form")
|
| 452 |
+
verdict = "typed" if episode.steps[-1].error is None else "REFUSED"
|
| 453 |
+
print(f" type({text!r:32}) -> {verdict}")
|
| 454 |
+
if episode.steps[-1].error:
|
| 455 |
+
print(f" {episode.steps[-1].error}")
|
| 456 |
+
""")
|
| 457 |
+
|
| 458 |
+
term("safety-deny-label", "Safety",
|
| 459 |
+
"Clicking a denied widget is refused, however the label is spelled",
|
| 460 |
+
["Policy.deny_labels", "Policy.check", "Observation.find"],
|
| 461 |
+
note="Terminal, keychain, wallet, password manager, bank - matched on the label.",
|
| 462 |
+
code="""
|
| 463 |
+
from tiny_computer_agent import Action, Element, Observation, Policy, PolicyViolation
|
| 464 |
+
|
| 465 |
+
frame = Observation(elements=(
|
| 466 |
+
Element("Terminal", x=0, y=0, width=80, height=24),
|
| 467 |
+
Element("Password Manager", x=100, y=0, width=160, height=24),
|
| 468 |
+
Element("Save", x=320, y=120, width=80, height=24),
|
| 469 |
+
))
|
| 470 |
+
policy = Policy()
|
| 471 |
+
|
| 472 |
+
for label in ["Terminal", "Password Manager", "T E R M I N A L", "Save"]:
|
| 473 |
+
try:
|
| 474 |
+
policy.check(Action("click", label=label), frame)
|
| 475 |
+
print(f" click({label!r:20}) -> allowed")
|
| 476 |
+
except PolicyViolation as exc:
|
| 477 |
+
print(f" click({label!r:20}) -> refused: {exc}")
|
| 478 |
+
""")
|
| 479 |
+
|
| 480 |
+
term("safety-deny-keys", "Safety",
|
| 481 |
+
"Key chords on the deny list are never pressed",
|
| 482 |
+
["Policy.deny_keys", "Policy.check", "PolicyViolation"],
|
| 483 |
+
note="ctrl+alt+t, ctrl+alt+delete and ctrl+shift+escape are refused by default.",
|
| 484 |
+
code="""
|
| 485 |
+
from tiny_computer_agent import Action, Policy, PolicyViolation
|
| 486 |
+
|
| 487 |
+
policy = Policy()
|
| 488 |
+
for chord in ["ctrl+s", "ctrl+alt+t", "CTRL+SHIFT+ESCAPE", "ctrl+alt+delete"]:
|
| 489 |
+
try:
|
| 490 |
+
policy.check(Action("key", keys=chord))
|
| 491 |
+
print(f" key({chord!r:20}) -> allowed")
|
| 492 |
+
except PolicyViolation as exc:
|
| 493 |
+
print(f" key({chord!r:20}) -> refused: {exc}")
|
| 494 |
+
""")
|
| 495 |
+
|
| 496 |
+
term("safety-blind-grid", "Safety",
|
| 497 |
+
"A numbered grid cell is a blind click: allowed to plan, refused for real",
|
| 498 |
+
["Policy.allow_grid_clicks", "Policy.check", "GridDetector"],
|
| 499 |
+
note="With real input the blind guess is refused unless you pass --allow-grid.",
|
| 500 |
+
code="""
|
| 501 |
+
from tiny_computer_agent import Action, GridDetector, Policy, PolicyViolation
|
| 502 |
+
from tiny_computer_agent.demo import fake_desktop
|
| 503 |
+
|
| 504 |
+
elements = GridDetector(rows=2, cols=3).detect(fake_desktop())
|
| 505 |
+
from tiny_computer_agent import Observation
|
| 506 |
+
frame = Observation(elements=elements)
|
| 507 |
+
print("a screen with no text degrades to", len(elements), "numbered cells:",
|
| 508 |
+
", ".join(e.label for e in elements))
|
| 509 |
+
print()
|
| 510 |
+
for policy, name in [(Policy(dry_run=True), "dry run (planning)"),
|
| 511 |
+
(Policy(dry_run=False), "real input, default"),
|
| 512 |
+
(Policy(dry_run=False, allow_grid_clicks=True), "real input, --allow-grid")]:
|
| 513 |
+
try:
|
| 514 |
+
policy.check(Action("click", label=elements[4].label), frame)
|
| 515 |
+
print(f" {name:32} -> allowed")
|
| 516 |
+
except PolicyViolation as exc:
|
| 517 |
+
print(f" {name:32} -> refused: {exc}")
|
| 518 |
+
""")
|
| 519 |
+
|
| 520 |
+
# =========================================================================== #
|
| 521 |
+
# 6. screens: where the pixels come from
|
| 522 |
+
# =========================================================================== #
|
| 523 |
+
|
| 524 |
+
term("screen-fake", "Screens",
|
| 525 |
+
"A scripted screen, for testing the loop with no display",
|
| 526 |
+
["FakeScreen", "FakeScreen.capture", "Observation"],
|
| 527 |
+
note="The final frame repeats forever, so a runaway agent cannot exhaust the script.",
|
| 528 |
+
code="""
|
| 529 |
+
from tiny_computer_agent import Element, FakeScreen
|
| 530 |
+
screen = FakeScreen([(Element("Files", x=10, y=6, width=60, height=20),),
|
| 531 |
+
(Element("Save", x=320, y=120, width=80, height=24),)], text="Untitled document")
|
| 532 |
+
for i in range(4):
|
| 533 |
+
observation = screen.capture()
|
| 534 |
+
print(f" capture {i} -> index={observation.index} elements={[e.label for e in observation.elements]}")
|
| 535 |
+
""")
|
| 536 |
+
|
| 537 |
+
term("screen-image", "Screens",
|
| 538 |
+
"A still image (or a sequence) replayed as a screen",
|
| 539 |
+
["ImageScreen", "ImageScreen.capture", "fake_desktop"],
|
| 540 |
+
note="Lets the whole pipeline be exercised with no display, and a failure be replayed.",
|
| 541 |
+
code="""
|
| 542 |
+
from tiny_computer_agent import ImageScreen, fake_desktop
|
| 543 |
+
screen = ImageScreen(fake_desktop())
|
| 544 |
+
for i in range(3):
|
| 545 |
+
observation = screen.capture()
|
| 546 |
+
print(f" capture {i} -> index={observation.index} frame={type(observation.frame).__name__} {observation.frame.size}")
|
| 547 |
+
print()
|
| 548 |
+
print("captures beyond the scripted frames keep returning the last one")
|
| 549 |
+
""")
|
| 550 |
+
|
| 551 |
+
term("capture-options", "Screens",
|
| 552 |
+
"Real capture: monitors, downscaling, and HiDPI mapping",
|
| 553 |
+
["GrabScreen", "GrabScreen.capture", "GrabScreen.map_to_screen", "ScreenUnavailable"],
|
| 554 |
+
note="Frame pixel -> screen pixel is preserved, so a click cannot drift.",
|
| 555 |
+
code="""
|
| 556 |
+
from tiny_computer_agent import Element, GrabScreen
|
| 557 |
+
for name, screen in [("monitor 1 (native)", GrabScreen(monitor=1)),
|
| 558 |
+
("max_width=640", GrabScreen(monitor=1, max_width=640)),
|
| 559 |
+
("scale=2.0 (HiDPI)", GrabScreen(monitor=1, scale=2.0))]:
|
| 560 |
+
observation = screen.capture()
|
| 561 |
+
print(f" {name:20} frame={observation.frame.width}x{observation.frame.height}px "
|
| 562 |
+
f"origin={screen.origin} frame_scale={screen.frame_scale:.3f}")
|
| 563 |
+
print()
|
| 564 |
+
scaled = GrabScreen(monitor=1, max_width=640)
|
| 565 |
+
scaled.capture()
|
| 566 |
+
label = Element("Save", x=100, y=50, width=80, height=24)
|
| 567 |
+
print("a label found at (100,50) in the DOWNSCALED frame maps back to", scaled.map_to_screen(label).center)
|
| 568 |
+
print("on the real screen - which is why the downscale cannot break a click.")
|
| 569 |
+
""")
|
| 570 |
+
|
| 571 |
+
term("element-matching", "Screens",
|
| 572 |
+
"Labels are compared on their alphanumeric core",
|
| 573 |
+
["Element", "Element.center", "Element.matches", "Observation.find", "normalise_label"],
|
| 574 |
+
note="OCR loses spaces, models add punctuation - so nothing matches by exact string.",
|
| 575 |
+
code="""
|
| 576 |
+
from tiny_computer_agent import Element, Observation, normalise_label
|
| 577 |
+
|
| 578 |
+
save = Element("Save", x=200, y=270, width=110, height=30)
|
| 579 |
+
print("Element('Save', x=200, y=270, width=110, height=30)")
|
| 580 |
+
print(" .center:", save.center, "| .matches('save'):", save.matches("save"))
|
| 581 |
+
print()
|
| 582 |
+
print("normalise_label keeps only letters and digits:")
|
| 583 |
+
for raw in ["Save", "Save .", "S A V E", "save!", "Untitled document - unsaved changes"]:
|
| 584 |
+
print(f" {raw!r:36} -> {normalise_label(raw)!r}")
|
| 585 |
+
print()
|
| 586 |
+
frame = Observation(elements=(Element("Save .", x=10, y=10, width=80, height=24),
|
| 587 |
+
Element("Save As", x=110, y=10, width=80, height=24)))
|
| 588 |
+
for probe in ["Save", "save", "Save As", "Nothing"]:
|
| 589 |
+
hit = frame.find(probe)
|
| 590 |
+
print(f" find({probe!r:10}) -> {hit.label if hit else 'not on screen'!r}")
|
| 591 |
+
""")
|
| 592 |
+
|
| 593 |
+
term("observation-summary", "Screens",
|
| 594 |
+
"The compact text a model is shown",
|
| 595 |
+
["Observation.summary"],
|
| 596 |
+
note="This is the whole world as the brain sees it - capped, and honest when empty.",
|
| 597 |
+
code="""
|
| 598 |
+
from tiny_computer_agent import Element, Observation
|
| 599 |
+
frame = Observation(index=3, text="Untitled document - unsaved changes", elements=(
|
| 600 |
+
Element("Files", x=10, y=6, width=60, height=20),
|
| 601 |
+
Element("Save", x=200, y=270, width=110, height=30),
|
| 602 |
+
Element("Cancel", x=400, y=270, width=110, height=30),
|
| 603 |
+
))
|
| 604 |
+
print(frame.summary())
|
| 605 |
+
print()
|
| 606 |
+
print("an empty index says so rather than pretending:")
|
| 607 |
+
print(Observation(index=4).summary())
|
| 608 |
+
""")
|
| 609 |
+
|
| 610 |
+
# =========================================================================== #
|
| 611 |
+
# 7. perception: pixels -> a labelled element index
|
| 612 |
+
# =========================================================================== #
|
| 613 |
+
|
| 614 |
+
term("detector-available", "Perception",
|
| 615 |
+
"Probing for a detector, and building one by name",
|
| 616 |
+
["detector_available", "make_detector", "DetectorUnavailable", "Detector"],
|
| 617 |
+
note="The doctor and the CLI both ask this before promising anything.",
|
| 618 |
+
code="""
|
| 619 |
+
from tiny_computer_agent import DetectorUnavailable, detector_available, make_detector
|
| 620 |
+
for name in ["auto", "ocr", "grid", "telepathy"]:
|
| 621 |
+
ok, detail = detector_available(name)
|
| 622 |
+
print(f" detector_available({name!r:11}) -> {ok!s:5} {detail}")
|
| 623 |
+
print()
|
| 624 |
+
for name in ["auto", "grid"]:
|
| 625 |
+
print(f" make_detector({name!r:6}) -> {type(make_detector(name)).__name__}")
|
| 626 |
+
try:
|
| 627 |
+
make_detector("telepathy")
|
| 628 |
+
except DetectorUnavailable as exc:
|
| 629 |
+
print(" make_detector('telepathy') -> DetectorUnavailable:", exc)
|
| 630 |
+
""")
|
| 631 |
+
|
| 632 |
+
term("ocr-labels", "Perception",
|
| 633 |
+
"OCR turns a screenshot into labelled, clickable elements",
|
| 634 |
+
["OcrDetector", "OcrDetector.detect", "Element"],
|
| 635 |
+
note="The model never has to guess where anything is: this is the grounding.",
|
| 636 |
+
code="""
|
| 637 |
+
from tiny_computer_agent import OcrDetector, fake_desktop
|
| 638 |
+
elements = OcrDetector().detect(fake_desktop())
|
| 639 |
+
print(f"OCR read {len(elements)} labelled elements off one screenshot:")
|
| 640 |
+
print()
|
| 641 |
+
for element in elements:
|
| 642 |
+
print(f" {element.label!r:34} kind={element.kind:6} centre={str(element.center):12} box={element.width}x{element.height}")
|
| 643 |
+
""")
|
| 644 |
+
|
| 645 |
+
term("ocr-filters", "Perception",
|
| 646 |
+
"Three knobs keep dialog prose out of the menu",
|
| 647 |
+
["OcrDetector.min_score", "OcrDetector.max_label_chars", "OcrDetector.min_label_chars"],
|
| 648 |
+
note="Every extra line of noise is one more chance for a small model to click prose.",
|
| 649 |
+
code="""
|
| 650 |
+
from tiny_computer_agent import OcrDetector, fake_desktop
|
| 651 |
+
image = fake_desktop()
|
| 652 |
+
for label, detector in [
|
| 653 |
+
("default", OcrDetector()),
|
| 654 |
+
("min_score=0.99", OcrDetector(min_score=0.99)),
|
| 655 |
+
("max_label_chars=6", OcrDetector(max_label_chars=6)),
|
| 656 |
+
("min_label_chars=8", OcrDetector(min_label_chars=8)),
|
| 657 |
+
]:
|
| 658 |
+
print(f" {label:18} -> {[e.label for e in detector.detect(image)]}")
|
| 659 |
+
""")
|
| 660 |
+
|
| 661 |
+
term("grid-detector", "Perception",
|
| 662 |
+
"A numbered grid, for screens with no text at all",
|
| 663 |
+
["GridDetector", "GridDetector.detect"],
|
| 664 |
+
note="An agent with an empty index has no legal moves; a grid always has moves.",
|
| 665 |
+
code="""
|
| 666 |
+
from tiny_computer_agent import GridDetector, fake_desktop
|
| 667 |
+
for rows, cols in [(2, 3), (8, 12)]:
|
| 668 |
+
cells = GridDetector(rows=rows, cols=cols).detect(fake_desktop())
|
| 669 |
+
print(f" {rows}x{cols} grid -> {len(cells)} cells: "
|
| 670 |
+
f"first={cells[0].label!r} at {cells[0].center}, last={cells[-1].label!r} at {cells[-1].center}")
|
| 671 |
+
""")
|
| 672 |
+
|
| 673 |
+
term("auto-detector", "Perception",
|
| 674 |
+
"OCR when there is text, a grid when there is none",
|
| 675 |
+
["AutoDetector", "AutoDetector.detect"],
|
| 676 |
+
note="The default: useful with nothing installed, better the moment OCR appears.",
|
| 677 |
+
code="""
|
| 678 |
+
from PIL import Image
|
| 679 |
+
from tiny_computer_agent import AutoDetector, fake_desktop
|
| 680 |
+
detector = AutoDetector()
|
| 681 |
+
with_text = detector.detect(fake_desktop())
|
| 682 |
+
blank = detector.detect(Image.new("RGB", (720, 460), (18, 18, 22)))
|
| 683 |
+
print(" a screenshot with text ->", len(with_text), "elements:", [e.label for e in with_text][:6])
|
| 684 |
+
print(" a screen with no text ->", len(blank), "elements:", [e.label for e in blank][:6])
|
| 685 |
+
""")
|
| 686 |
+
|
| 687 |
+
term("indexed-screen", "Perception",
|
| 688 |
+
"IndexedScreen: capture + detect, composed",
|
| 689 |
+
["IndexedScreen", "IndexedScreen.capture", "Observation.summary"],
|
| 690 |
+
note="One capture() call gives the loop an index it can act on.",
|
| 691 |
+
code="""
|
| 692 |
+
from tiny_computer_agent import ImageScreen, IndexedScreen, OcrDetector, fake_desktop
|
| 693 |
+
screen = IndexedScreen(ImageScreen(fake_desktop()), OcrDetector())
|
| 694 |
+
observation = screen.capture()
|
| 695 |
+
print(f"capture() composed pixels -> {len(observation.elements)} labelled elements:")
|
| 696 |
+
print()
|
| 697 |
+
print(observation.summary())
|
| 698 |
+
""")
|
| 699 |
+
|
| 700 |
+
# =========================================================================== #
|
| 701 |
+
# 8. preflight: what can this machine do?
|
| 702 |
+
# =========================================================================== #
|
| 703 |
+
|
| 704 |
+
term("display-available", "Preflight",
|
| 705 |
+
"Is there a desktop session to drive at all?",
|
| 706 |
+
["display_available"],
|
| 707 |
+
note="The first question the CLI and the doctor both ask.",
|
| 708 |
+
code="""
|
| 709 |
+
from tiny_computer_agent import display_available
|
| 710 |
+
ok, detail = display_available()
|
| 711 |
+
print("display_available() ->", ok)
|
| 712 |
+
print(" detail:", detail)
|
| 713 |
+
""")
|
| 714 |
+
|
| 715 |
+
term("run-checks", "Preflight",
|
| 716 |
+
"Every precondition, one list, each with the fix",
|
| 717 |
+
["run_checks", "Check", "Check.render"],
|
| 718 |
+
note="Eight checks; a required one that fails is a blocking problem.",
|
| 719 |
+
code="""
|
| 720 |
+
from tiny_computer_agent import run_checks
|
| 721 |
+
checks = run_checks(brain="planner", detector="ocr", real_input=False)
|
| 722 |
+
for check in checks:
|
| 723 |
+
print(check.render())
|
| 724 |
+
blocking = [c for c in checks if c.required and not c.ok]
|
| 725 |
+
print()
|
| 726 |
+
print(f"{len(checks)} checks, {len(blocking)} blocking: {[c.name for c in blocking]}")
|
| 727 |
+
""")
|
| 728 |
+
|
| 729 |
+
term("cli-doctor", "Preflight",
|
| 730 |
+
"python -m tiny_computer_agent --doctor",
|
| 731 |
+
["doctor", "display_available", "detector_available"],
|
| 732 |
+
note="The first thing to run on a new machine. Exits non-zero when not ready.",
|
| 733 |
+
argv=["{py}", "-m", "tiny_computer_agent", "--doctor"])
|
| 734 |
+
|
| 735 |
+
term("build-agent", "Preflight",
|
| 736 |
+
"One call wires screen + detector + brain + executor + policy",
|
| 737 |
+
["build_agent", "Policy", "AgentLoop"],
|
| 738 |
+
note="Defaults are the safe ones: dry run, a step budget, and a kill-switch file.",
|
| 739 |
+
code="""
|
| 740 |
+
from tiny_computer_agent import Element, FakeScreen, PlannerBrain, build_agent
|
| 741 |
+
loop = build_agent(
|
| 742 |
+
brain=PlannerBrain(),
|
| 743 |
+
screen=FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)]),
|
| 744 |
+
dry_run=True, max_steps=4,
|
| 745 |
+
)
|
| 746 |
+
print("build_agent(...) ->", type(loop).__name__)
|
| 747 |
+
print(" executor :", type(loop.executor).__name__, "| dry_run:", loop.executor.dry_run)
|
| 748 |
+
print(" step budget :", loop.policy.step_budget)
|
| 749 |
+
print(" kill switch :", loop.policy.kill_switch_path)
|
| 750 |
+
print(" grid clicks :", loop.policy.allow_grid_clicks)
|
| 751 |
+
print()
|
| 752 |
+
print(loop.run("click 'Save'").render())
|
| 753 |
+
""")
|
| 754 |
+
|
| 755 |
+
# =========================================================================== #
|
| 756 |
+
# 9. the model backends
|
| 757 |
+
# =========================================================================== #
|
| 758 |
+
|
| 759 |
+
term("models-deps", "Models",
|
| 760 |
+
"Which model runtime is present, and are the weights cached?",
|
| 761 |
+
["model_dependencies", "model_cached", "MODEL_BRAINS", "make_model_brain",
|
| 762 |
+
"DEFAULT_TEXT_MODEL", "DEFAULT_VISION_MODEL", "BrainError"],
|
| 763 |
+
note="Weights are loaded lazily, so asking is free.",
|
| 764 |
+
code="""
|
| 765 |
+
from tiny_computer_agent import (DEFAULT_TEXT_MODEL, DEFAULT_VISION_MODEL, MODEL_BRAINS,
|
| 766 |
+
BrainError, make_model_brain, model_cached, model_dependencies)
|
| 767 |
+
ok, detail = model_dependencies()
|
| 768 |
+
print("model_dependencies() ->", ok, "|", detail)
|
| 769 |
+
print("default text model :", DEFAULT_TEXT_MODEL)
|
| 770 |
+
print(" weights cached :", model_cached(DEFAULT_TEXT_MODEL))
|
| 771 |
+
print("default vision model :", DEFAULT_VISION_MODEL)
|
| 772 |
+
print(" weights cached :", model_cached(DEFAULT_VISION_MODEL))
|
| 773 |
+
print()
|
| 774 |
+
print("brains by name, and an honest refusal for the rest:")
|
| 775 |
+
for name in MODEL_BRAINS:
|
| 776 |
+
print(f" make_model_brain({name!r:13}) -> {type(make_model_brain(name)).__name__}")
|
| 777 |
+
try:
|
| 778 |
+
make_model_brain("gpt-9")
|
| 779 |
+
except BrainError as exc:
|
| 780 |
+
print(" make_model_brain('gpt-9') -> BrainError:", exc)
|
| 781 |
+
""")
|
| 782 |
+
|
| 783 |
+
term("models-device", "Models",
|
| 784 |
+
"Device and dtype selection",
|
| 785 |
+
["resolve_device", "pick_dtype"],
|
| 786 |
+
note="CUDA if it is there, CPU otherwise; the harness never needs a GPU.",
|
| 787 |
+
code="""
|
| 788 |
+
from tiny_computer_agent import pick_dtype, resolve_device
|
| 789 |
+
print("resolve_device() ->", resolve_device())
|
| 790 |
+
print("resolve_device('cpu') ->", resolve_device("cpu"))
|
| 791 |
+
print("pick_dtype('cpu') ->", pick_dtype("cpu"))
|
| 792 |
+
print("pick_dtype('cuda') ->", pick_dtype("cuda"))
|
| 793 |
+
""")
|
| 794 |
+
|
| 795 |
+
term("models-prompt", "Models",
|
| 796 |
+
"The prompt a model actually receives - and how it learns from a dead end",
|
| 797 |
+
["ModelBrain", "ModelBrain.build_prompt", "ModelBrain.decide"],
|
| 798 |
+
note="Placeholders, not worked examples: a weak model echoes the prompt, and an echo fails validation.",
|
| 799 |
+
code="""
|
| 800 |
+
from tiny_computer_agent import Element, ModelBrain, Observation
|
| 801 |
+
|
| 802 |
+
class Scripted(ModelBrain):
|
| 803 |
+
name = "scripted"
|
| 804 |
+
def _generate(self, prompt, observation):
|
| 805 |
+
self.prompt_seen = prompt
|
| 806 |
+
return '{"kind": "click", "index": 2, "reason": "saves the document"}'
|
| 807 |
+
|
| 808 |
+
brain = Scripted("example/model")
|
| 809 |
+
frame = Observation(text="Untitled document - unsaved changes", elements=(
|
| 810 |
+
Element("Files", x=10, y=6, width=60, height=20),
|
| 811 |
+
Element("Save", x=200, y=270, width=110, height=30),
|
| 812 |
+
Element("Cancel", x=400, y=270, width=110, height=30),
|
| 813 |
+
))
|
| 814 |
+
action = brain.decide("save the document", frame)
|
| 815 |
+
print("the model chose:", action.describe())
|
| 816 |
+
print()
|
| 817 |
+
print("the prompt it was given:")
|
| 818 |
+
print(brain.prompt_seen)
|
| 819 |
+
""")
|
| 820 |
+
|
| 821 |
+
term("models-prompt-history", "Models",
|
| 822 |
+
"A second decision is told what already failed",
|
| 823 |
+
["ModelBrain", "ModelBrain.build_prompt", "ModelBrain.history"],
|
| 824 |
+
note="'you already tried this and the screen did NOT change' is the most useful line a small model gets.",
|
| 825 |
+
code="""
|
| 826 |
+
from tiny_computer_agent import Element, ModelBrain, Observation
|
| 827 |
+
|
| 828 |
+
class Scripted(ModelBrain):
|
| 829 |
+
name = "scripted"
|
| 830 |
+
def _generate(self, prompt, observation):
|
| 831 |
+
self.prompt_seen = prompt
|
| 832 |
+
return '{"kind": "click", "index": 2, "reason": "again"}'
|
| 833 |
+
|
| 834 |
+
brain = Scripted("example/model")
|
| 835 |
+
frame = Observation(text="Untitled document", elements=(
|
| 836 |
+
Element("Files", x=10, y=6, width=60, height=20),
|
| 837 |
+
Element("Save", x=200, y=270, width=110, height=30),
|
| 838 |
+
))
|
| 839 |
+
brain.decide("save", frame) # first attempt
|
| 840 |
+
brain.decide("save", frame) # same screen: nothing changed
|
| 841 |
+
print("history the brain is carrying:", brain.history)
|
| 842 |
+
print()
|
| 843 |
+
print("the tail of the second prompt:")
|
| 844 |
+
print(brain.prompt_seen[-420:])
|
| 845 |
+
""")
|
| 846 |
+
|
| 847 |
+
# =========================================================================== #
|
| 848 |
+
# 10. traces and scoring
|
| 849 |
+
# =========================================================================== #
|
| 850 |
+
|
| 851 |
+
term("scenario", "Traces & scoring",
|
| 852 |
+
"A task with one right answer, and the frames to show",
|
| 853 |
+
["Scenario", "Scenario.screen", "run_scenario", "Scenario.label"],
|
| 854 |
+
note="Ground truth lives with the task, so a run can be scored at all.",
|
| 855 |
+
code="""
|
| 856 |
+
from tiny_computer_agent import Element, PlannerBrain, Scenario, run_scenario
|
| 857 |
+
|
| 858 |
+
def item(label, x): return Element(label, x=x, y=6, width=80, height=20)
|
| 859 |
+
def button(label, x): return Element(label, x=x, y=270, width=110, height=30)
|
| 860 |
+
|
| 861 |
+
scenario = Scenario(
|
| 862 |
+
"click 'Save' to save the document",
|
| 863 |
+
frames=[(item("Files", 10), item("Edit", 100)),
|
| 864 |
+
(Element("Name", x=200, y=195, width=200, height=26), button("Save", 200)),
|
| 865 |
+
(item("Files", 10), button("Save", 200))],
|
| 866 |
+
expected="Save", name="save the document",
|
| 867 |
+
)
|
| 868 |
+
print("Scenario:", scenario.label, "| frames:", len(scenario.frames), "| the right answer:", scenario.expected)
|
| 869 |
+
print()
|
| 870 |
+
trace = run_scenario(scenario, PlannerBrain())
|
| 871 |
+
print(trace.render())
|
| 872 |
+
""")
|
| 873 |
+
|
| 874 |
+
term("trace-record", "Traces & scoring",
|
| 875 |
+
"An episode becomes a portable, pixel-free trace",
|
| 876 |
+
["Trace", "Trace.record", "Trace.save", "Trace.load", "Trace.to_json",
|
| 877 |
+
"Trace.to_dict", "Trace.choice", "Trace.decided", "Trace.frames"],
|
| 878 |
+
note="JSON, so it diffs in review and replays on a machine with nothing installed.",
|
| 879 |
+
code="""
|
| 880 |
+
import json, tempfile
|
| 881 |
+
from pathlib import Path
|
| 882 |
+
from tiny_computer_agent import Element, PlannerBrain, Scenario, Trace, run_scenario
|
| 883 |
+
|
| 884 |
+
scenario = Scenario("click 'Save'", frames=[(Element("Save", x=320, y=120, width=80, height=24),)],
|
| 885 |
+
expected="Save", name="save")
|
| 886 |
+
trace = run_scenario(scenario, PlannerBrain())
|
| 887 |
+
print("frames:", trace.frames, "| choice:", trace.choice,
|
| 888 |
+
"| decided:", trace.decided, "| blocked:", trace.blocked)
|
| 889 |
+
path = Path(tempfile.mkdtemp()) / "save.json"
|
| 890 |
+
trace.save(path)
|
| 891 |
+
print("saved:", path.name, f"({path.stat().st_size} bytes) | reloaded identical:",
|
| 892 |
+
Trace.load(path).to_dict() == trace.to_dict())
|
| 893 |
+
print()
|
| 894 |
+
print(json.dumps(trace.to_dict(), indent=2)[:560] + " ...")
|
| 895 |
+
""")
|
| 896 |
+
|
| 897 |
+
term("trace-replay", "Traces & scoring",
|
| 898 |
+
"Replay a recorded trace against a different brain",
|
| 899 |
+
["Trace.replay", "Trace.observations", "score_trace"],
|
| 900 |
+
note="The frames are the fixture; the brain is the only variable. Nothing is captured or clicked.",
|
| 901 |
+
code="""
|
| 902 |
+
from tiny_computer_agent import Action, Element, PlannerBrain, Scenario, run_scenario, score_trace
|
| 903 |
+
|
| 904 |
+
scenario = Scenario("click 'Save'", frames=[
|
| 905 |
+
(Element("Files", x=10, y=6, width=60, height=20),),
|
| 906 |
+
(Element("Save", x=320, y=120, width=80, height=24),)], expected="Save", name="save")
|
| 907 |
+
|
| 908 |
+
recorded = run_scenario(scenario, PlannerBrain())
|
| 909 |
+
print("recorded by the planner :", recorded.choice, "->", score_trace(recorded).correct)
|
| 910 |
+
|
| 911 |
+
class AlwaysFirst:
|
| 912 |
+
name = "first-item"
|
| 913 |
+
def decide(self, goal, observation):
|
| 914 |
+
return Action("click", label=observation.elements[0].label, reason="first item")
|
| 915 |
+
|
| 916 |
+
replayed = recorded.replay(AlwaysFirst())
|
| 917 |
+
print("the same frames, another brain:", replayed.choice, "->", score_trace(replayed).correct)
|
| 918 |
+
print()
|
| 919 |
+
print("replayed frames carried over:", replayed.frames, "| ground truth carried over:", replayed.expected)
|
| 920 |
+
""")
|
| 921 |
+
|
| 922 |
+
term("score-trace", "Traces & scoring",
|
| 923 |
+
"Counting how often the choice was right",
|
| 924 |
+
["score_trace", "score", "score_replay", "Verdict", "Verdict.line"],
|
| 925 |
+
note="Scored on normalised text, so OCR spacing is forgiven - the same bargain the index makes.",
|
| 926 |
+
code="""
|
| 927 |
+
from tiny_computer_agent import (Action, Element, PlannerBrain, Scenario, Trace, TraceStep,
|
| 928 |
+
score, score_replay, score_trace)
|
| 929 |
+
scenario = Scenario("click 'Save'", frames=[(Element("Save", x=320, y=120, width=80, height=24),)],
|
| 930 |
+
expected="Save", name="save")
|
| 931 |
+
print("score(scenario, PlannerBrain()) ->", score(scenario, PlannerBrain()).line().strip())
|
| 932 |
+
print()
|
| 933 |
+
print("a choice the OCR rendered as 'Save .' is still credited for 'Save':")
|
| 934 |
+
print(" ", score_trace(Trace(goal="g", expected="Save",
|
| 935 |
+
steps=[TraceStep(0, action=Action("click", label="Save ."))])).line().strip())
|
| 936 |
+
print()
|
| 937 |
+
print("a trace with no ground truth says so instead of inventing a verdict:")
|
| 938 |
+
print(" ", score_trace(Trace(goal="g",
|
| 939 |
+
steps=[TraceStep(0, action=Action("click", label="Save"))])).line().strip())
|
| 940 |
+
print()
|
| 941 |
+
print("score_replay(trace, another_brain) is the single-trace form of the roadmap step:")
|
| 942 |
+
print(" ", score_replay(Trace(goal="g", expected="Save",
|
| 943 |
+
steps=[TraceStep(0, action=Action("click", label="Cancel"))]), PlannerBrain()).line().strip())
|
| 944 |
+
""")
|
| 945 |
+
|
| 946 |
+
term("scoreboard", "Traces & scoring",
|
| 947 |
+
"Verdicts collected into an accuracy",
|
| 948 |
+
["Scoreboard", "Scoreboard.render", "Scoreboard.accuracy", "Scoreboard.by_brain",
|
| 949 |
+
"Scoreboard.to_rows", "Scoreboard.confusion", "Scoreboard.blocked"],
|
| 950 |
+
note="The number the whole module exists for: which brain is right, and how often.",
|
| 951 |
+
code="""
|
| 952 |
+
from tiny_computer_agent import Action, Element, MockBrain, PlannerBrain, Scenario, benchmark
|
| 953 |
+
|
| 954 |
+
class FirstItem:
|
| 955 |
+
name = "first-item"
|
| 956 |
+
def decide(self, goal, observation):
|
| 957 |
+
return Action("click", label=observation.elements[0].label, reason="first item")
|
| 958 |
+
|
| 959 |
+
def scenario(goal, expected, frames):
|
| 960 |
+
return Scenario(goal, frames=frames, expected=expected, name=expected.lower())
|
| 961 |
+
|
| 962 |
+
suite = [
|
| 963 |
+
scenario("click 'Save' to save the document", "Save",
|
| 964 |
+
[(Element("Files", x=10, y=6, width=60, height=20),),
|
| 965 |
+
(Element("Save", x=320, y=120, width=80, height=24),)]),
|
| 966 |
+
scenario("click 'Cancel' to discard the change", "Cancel",
|
| 967 |
+
[(Element("Save", x=200, y=270, width=110, height=30),
|
| 968 |
+
Element("Cancel", x=400, y=270, width=110, height=30),)]),
|
| 969 |
+
]
|
| 970 |
+
board = benchmark(suite, {
|
| 971 |
+
"planner": lambda s: PlannerBrain(),
|
| 972 |
+
"first-item": lambda s: FirstItem(),
|
| 973 |
+
"oracle": lambda s: MockBrain([{"kind": "click", "label": s.expected}]),
|
| 974 |
+
})
|
| 975 |
+
print(board.render())
|
| 976 |
+
print()
|
| 977 |
+
print("accuracy:", f"{board.accuracy:.0%}", "| by brain:", board.by_brain())
|
| 978 |
+
print("as rows :", board.to_rows())
|
| 979 |
+
print("confusion (expected, chose) -> count:", board.confusion())
|
| 980 |
+
""")
|
| 981 |
+
|
| 982 |
+
term("benchmark", "Traces & scoring",
|
| 983 |
+
"Every scenario through every brain, in one table",
|
| 984 |
+
["benchmark", "Scoreboard.to_rows"],
|
| 985 |
+
note="A fresh brain per task, so state like 'already clicked this' cannot leak between them.",
|
| 986 |
+
code="""
|
| 987 |
+
from tiny_computer_agent import Action, Element, PlannerBrain, Scenario, benchmark
|
| 988 |
+
|
| 989 |
+
class FirstItem:
|
| 990 |
+
name = "first-item"
|
| 991 |
+
def decide(self, goal, observation):
|
| 992 |
+
return Action("click", label=observation.elements[0].label, reason="first item")
|
| 993 |
+
|
| 994 |
+
suite = [Scenario(f"click {label!r}", frames=[(Element("Files", x=10, y=6, width=60, height=20),
|
| 995 |
+
Element(label, x=200, y=270, width=110, height=30),)],
|
| 996 |
+
expected=label, name=label.lower())
|
| 997 |
+
for label in ("Save", "Cancel")]
|
| 998 |
+
board = benchmark(suite, {"planner": lambda s: PlannerBrain(), "first-item": lambda s: FirstItem()})
|
| 999 |
+
for row in board.to_rows():
|
| 1000 |
+
print(f" {row['brain']:12} {row['scenario']:8} expected={row['expected']:7} "
|
| 1001 |
+
f"chose={row['chose']:7} correct={row['correct']}")
|
| 1002 |
+
""")
|
| 1003 |
+
|
| 1004 |
+
term("benchmark-traces", "Traces & scoring",
|
| 1005 |
+
"Replay a whole recorded suite against one brain",
|
| 1006 |
+
["benchmark_traces", "load_traces", "Trace.save"],
|
| 1007 |
+
note="The literal roadmap step: the frames are fixed, nothing new is captured.",
|
| 1008 |
+
code="""
|
| 1009 |
+
import tempfile
|
| 1010 |
+
from pathlib import Path
|
| 1011 |
+
from tiny_computer_agent import (Element, PlannerBrain, Scenario, benchmark_traces,
|
| 1012 |
+
load_traces, run_scenario)
|
| 1013 |
+
|
| 1014 |
+
def scenario(goal, expected, frames):
|
| 1015 |
+
return Scenario(goal, frames=frames, expected=expected, name=expected.lower())
|
| 1016 |
+
|
| 1017 |
+
suite = [
|
| 1018 |
+
scenario("click 'Save'", "Save", [(Element("Files", x=10, y=6, width=60, height=20),),
|
| 1019 |
+
(Element("Save", x=320, y=120, width=80, height=24),)]),
|
| 1020 |
+
scenario("click 'Cancel'", "Cancel", [(Element("Save", x=200, y=270, width=110, height=30),
|
| 1021 |
+
Element("Cancel", x=400, y=270, width=110, height=30),)]),
|
| 1022 |
+
]
|
| 1023 |
+
folder = Path(tempfile.mkdtemp())
|
| 1024 |
+
for item in suite:
|
| 1025 |
+
run_scenario(item, PlannerBrain()).save(folder / f"{item.name}.json")
|
| 1026 |
+
print("recorded suite on disk:", [p.name for p in sorted(folder.glob("*.json"))])
|
| 1027 |
+
traces = load_traces(folder)
|
| 1028 |
+
print("load_traces ->", [t.name for t in traces])
|
| 1029 |
+
print()
|
| 1030 |
+
board = benchmark_traces(traces, PlannerBrain(), name="planner (replayed)")
|
| 1031 |
+
print(board.render())
|
| 1032 |
+
""")
|
| 1033 |
+
|
| 1034 |
+
# =========================================================================== #
|
| 1035 |
+
# 11. the command line
|
| 1036 |
+
# =========================================================================== #
|
| 1037 |
+
|
| 1038 |
+
term("cli-image", "Command line",
|
| 1039 |
+
"--image example (replay a screenshot, dry run)",
|
| 1040 |
+
["main", "build_parser", "ImageScreen", "detector_available"],
|
| 1041 |
+
note="No display needed: the whole pipeline over a still image.",
|
| 1042 |
+
argv=["{py}", "-m", "tiny_computer_agent", "--image", "example"])
|
| 1043 |
+
|
| 1044 |
+
term("cli-script", "Command line",
|
| 1045 |
+
"--script (replay a fixed action list instead of a brain)",
|
| 1046 |
+
["MockBrain", "parse_action"],
|
| 1047 |
+
note="The script is validated exactly like a model's answer: nothing skips the gates.",
|
| 1048 |
+
argv=["{py}", "-m", "tiny_computer_agent", "--script",
|
| 1049 |
+
'[{"kind": "click", "label": "Save"}, {"kind": "done", "reason": "finished"}]'])
|
| 1050 |
+
|
| 1051 |
+
term("cli-verify-text", "Command line",
|
| 1052 |
+
"--verify-text (the harness decides success, not the brain)",
|
| 1053 |
+
["AgentLoop.run", "Episode.success"],
|
| 1054 |
+
note="Without it, an action that removes its own target looks like a failure.",
|
| 1055 |
+
argv=["{py}", "-m", "tiny_computer_agent", "--script",
|
| 1056 |
+
'[{"kind": "wait", "seconds": 0.1}]', "--verify-text", "Unsaved", "--max-steps", "2"])
|
| 1057 |
+
|
| 1058 |
+
term("cli-trace", "Command line",
|
| 1059 |
+
"--trace / --expected (record the run, then score it)",
|
| 1060 |
+
["Trace.record", "Trace.save", "score_trace"],
|
| 1061 |
+
note="Writes JSON next to the code, and prints the score straight away.",
|
| 1062 |
+
argv=["{py}", "-m", "tiny_computer_agent", "--image", "example",
|
| 1063 |
+
"--trace", "/tmp/gallery-save.json", "--expected", "Save"])
|
| 1064 |
+
|
| 1065 |
+
term("cli-detector", "Command line",
|
| 1066 |
+
"--no-ocr (index a numbered grid instead)",
|
| 1067 |
+
["GridDetector", "make_detector", "detector_available"],
|
| 1068 |
+
note="Useful on a machine with no OCR engine, or for a canvas app.",
|
| 1069 |
+
argv=["{py}", "-m", "tiny_computer_agent", "--image", "example", "--no-ocr"])
|
| 1070 |
+
|
| 1071 |
+
term("cli-exit-codes", "Command line",
|
| 1072 |
+
"Exit codes: 0 success, 1 failure, 2 bad usage, 3 no screen",
|
| 1073 |
+
["main"],
|
| 1074 |
+
note="A shell can tell 'it worked' from 'it gave up' without parsing any text.",
|
| 1075 |
+
code="""
|
| 1076 |
+
import subprocess, sys
|
| 1077 |
+
cases = [
|
| 1078 |
+
(["--script", '[{"kind": "done", "reason": "nothing to do"}]'], "a brain-reported success"),
|
| 1079 |
+
(["click 'Nonexistent button'"], "a goal that cannot be reached"),
|
| 1080 |
+
(["--real-input"], "real input without the allow env"),
|
| 1081 |
+
(["--brain", "vlm"], "the vlm brain with no pixels"),
|
| 1082 |
+
(["--image", "/nonexistent/shot.png"], "a screenshot that is not there"),
|
| 1083 |
+
]
|
| 1084 |
+
for flags, what in cases:
|
| 1085 |
+
code = subprocess.run([sys.executable, "-m", "tiny_computer_agent", *flags],
|
| 1086 |
+
capture_output=True, text=True).returncode
|
| 1087 |
+
print(f" exit {code} {what:38} ({' '.join(flags)[:46]})")
|
| 1088 |
+
""", env={"TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT": ""})
|
| 1089 |
+
|
| 1090 |
+
term("cli-refusals", "Command line",
|
| 1091 |
+
"The two deliberate acts before the mouse can move",
|
| 1092 |
+
["main", "ALLOW_REAL_INPUT_ENV"],
|
| 1093 |
+
note="--real-input without the env var, and --brain vlm with no pixels: both refuse.",
|
| 1094 |
+
code="""
|
| 1095 |
+
import subprocess, sys
|
| 1096 |
+
for flags in (["--real-input"], ["--brain", "vlm"]):
|
| 1097 |
+
print(f"$ python -m tiny_computer_agent {' '.join(flags)}")
|
| 1098 |
+
subprocess.run([sys.executable, "-m", "tiny_computer_agent", *flags])
|
| 1099 |
+
print()
|
| 1100 |
+
""", env={"TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT": ""})
|
| 1101 |
+
|
| 1102 |
+
term("cli-live-headless", "Command line",
|
| 1103 |
+
"--live with no display is refused, with the fix",
|
| 1104 |
+
["display_available", "main"],
|
| 1105 |
+
note="It tells you what to do instead of dying inside an episode.",
|
| 1106 |
+
argv=["{py}", "-m", "tiny_computer_agent", "--live"],
|
| 1107 |
+
env={"DISPLAY": "", "WAYLAND_DISPLAY": ""})
|
| 1108 |
+
|
| 1109 |
+
term("cli-live-dry-run", "Command line",
|
| 1110 |
+
"--live (your real screen, dry run)",
|
| 1111 |
+
["GrabScreen", "IndexedScreen", "AutoDetector", "main"],
|
| 1112 |
+
note="Real capture, real OCR, a real element index - and not one click.",
|
| 1113 |
+
argv=["{py}", "-m", "tiny_computer_agent", "--live", "--max-steps", "2"],
|
| 1114 |
+
env={"DISPLAY": ":99", "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT": ""})
|
| 1115 |
+
|
| 1116 |
+
|
| 1117 |
+
# =========================================================================== #
|
| 1118 |
+
# 12. on a real desktop: real capture, real OCR, a real click
|
| 1119 |
+
# =========================================================================== #
|
| 1120 |
+
|
| 1121 |
+
SCENES["dialog"] = (ROOT / "showcase_scenes" / "dialog.py").read_text()
|
| 1122 |
+
SCENES["canvas"] = (ROOT / "showcase_scenes" / "canvas.py").read_text()
|
| 1123 |
+
SCENES["toolbar"] = (ROOT / "showcase_scenes" / "toolbar.py").read_text()
|
| 1124 |
+
|
| 1125 |
+
DIALOG_BOX = (100, 100, 600, 380)
|
| 1126 |
+
TOOLBAR_BOX = (140, 140, 820, 480)
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
def make_capture(tmp):
|
| 1130 |
+
proc = open_scene("dialog", tmp)
|
| 1131 |
+
try:
|
| 1132 |
+
return annotate(grab(), [], title="GrabScreen(monitor=1).capture() - a real 1280x800 desktop")
|
| 1133 |
+
finally:
|
| 1134 |
+
close_scene(proc)
|
| 1135 |
+
|
| 1136 |
+
|
| 1137 |
+
def make_ocr_index(tmp):
|
| 1138 |
+
from tiny_computer_agent import OcrDetector
|
| 1139 |
+
proc = open_scene("dialog", tmp)
|
| 1140 |
+
try:
|
| 1141 |
+
frame = grab()
|
| 1142 |
+
elements = OcrDetector().detect(frame)
|
| 1143 |
+
return annotate(frame, elements, title="OcrDetector().detect(frame) -> "
|
| 1144 |
+
+ str(len(elements)) + " labelled elements with real coordinates")
|
| 1145 |
+
finally:
|
| 1146 |
+
close_scene(proc)
|
| 1147 |
+
|
| 1148 |
+
|
| 1149 |
+
def make_click(tmp):
|
| 1150 |
+
from tiny_computer_agent import (AgentLoop, GrabScreen, IndexedScreen, OcrDetector,
|
| 1151 |
+
PlannerBrain, Policy, PyAutoGuiExecutor)
|
| 1152 |
+
proc = open_scene("dialog", tmp)
|
| 1153 |
+
try:
|
| 1154 |
+
screen = IndexedScreen(GrabScreen(monitor=1), OcrDetector())
|
| 1155 |
+
before_observation = screen.capture()
|
| 1156 |
+
before = crop(annotate(grab(), before_observation.elements), DIALOG_BOX)
|
| 1157 |
+
episode = AgentLoop(
|
| 1158 |
+
screen=screen, brain=PlannerBrain(), executor=PyAutoGuiExecutor(),
|
| 1159 |
+
policy=Policy(dry_run=False, step_budget=3),
|
| 1160 |
+
verify=lambda observation: "saved" in observation.text.lower(),
|
| 1161 |
+
).run("click 'Save' to save the document")
|
| 1162 |
+
target = before_observation.find("Save")
|
| 1163 |
+
after = crop(annotate(grab(), [], click=target.center if target else None), DIALOG_BOX)
|
| 1164 |
+
lines = ["result: " + ("success" if episode.success else "failure")
|
| 1165 |
+
+ " - " + episode.stop_reason]
|
| 1166 |
+
lines += [" " + step.line() for step in episode.steps][:2]
|
| 1167 |
+
return overlay_lines(side_by_side(
|
| 1168 |
+
before, after, title="PyAutoGuiExecutor: a real click lands on a real button"), lines)
|
| 1169 |
+
finally:
|
| 1170 |
+
close_scene(proc)
|
| 1171 |
+
|
| 1172 |
+
|
| 1173 |
+
def make_verify(tmp):
|
| 1174 |
+
from tiny_computer_agent import (AgentLoop, GrabScreen, IndexedScreen, OcrDetector,
|
| 1175 |
+
PlannerBrain, Policy, PyAutoGuiExecutor)
|
| 1176 |
+
proc = open_scene("dialog", tmp)
|
| 1177 |
+
try:
|
| 1178 |
+
episode = AgentLoop(
|
| 1179 |
+
screen=IndexedScreen(GrabScreen(monitor=1), OcrDetector()),
|
| 1180 |
+
brain=PlannerBrain(), executor=PyAutoGuiExecutor(),
|
| 1181 |
+
policy=Policy(dry_run=False, step_budget=3),
|
| 1182 |
+
verify=lambda observation: "saved" in observation.text.lower(),
|
| 1183 |
+
).run("click 'Save' to save the document")
|
| 1184 |
+
marker = tmp / "dialog.marker"
|
| 1185 |
+
frame = crop(annotate(grab(), []), DIALOG_BOX)
|
| 1186 |
+
return overlay_lines(frame, [
|
| 1187 |
+
"the window changed its own title to 'Saved to disk',",
|
| 1188 |
+
"and wrote " + repr(marker.read_text()) + " to " + marker.name,
|
| 1189 |
+
"verify(...) saw it -> episode.success = " + str(episode.success),
|
| 1190 |
+
"stop reason: " + episode.stop_reason,
|
| 1191 |
+
])
|
| 1192 |
+
finally:
|
| 1193 |
+
close_scene(proc)
|
| 1194 |
+
|
| 1195 |
+
|
| 1196 |
+
def make_grid_fallback(tmp):
|
| 1197 |
+
from tiny_computer_agent import AutoDetector, OcrDetector
|
| 1198 |
+
proc = open_scene("canvas", tmp)
|
| 1199 |
+
try:
|
| 1200 |
+
frame = grab()
|
| 1201 |
+
ocr = OcrDetector().detect(frame)
|
| 1202 |
+
elements = AutoDetector().detect(frame)
|
| 1203 |
+
return overlay_lines(annotate(
|
| 1204 |
+
frame, elements, title="a canvas app has no text: the grid fallback"), [
|
| 1205 |
+
"OcrDetector found " + str(len(ocr)) + " labels on these pixels",
|
| 1206 |
+
"AutoDetector fell back to a " + str(len(elements))
|
| 1207 |
+
+ "-cell numbered grid, so the agent still has moves",
|
| 1208 |
+
"with real input a blind grid click is refused unless --allow-grid is passed",
|
| 1209 |
+
])
|
| 1210 |
+
finally:
|
| 1211 |
+
close_scene(proc)
|
| 1212 |
+
|
| 1213 |
+
|
| 1214 |
+
def make_deny(tmp):
|
| 1215 |
+
from tiny_computer_agent import Action, Observation, OcrDetector, Policy, PolicyViolation
|
| 1216 |
+
proc = open_scene("toolbar", tmp)
|
| 1217 |
+
try:
|
| 1218 |
+
frame = grab()
|
| 1219 |
+
elements = OcrDetector().detect(frame)
|
| 1220 |
+
target = next((e for e in elements if e.matches("Terminal")), None)
|
| 1221 |
+
message = "no Terminal button on screen"
|
| 1222 |
+
if target is not None:
|
| 1223 |
+
try:
|
| 1224 |
+
Policy(dry_run=False).check(
|
| 1225 |
+
Action("click", label=target.label), Observation(elements=tuple(elements)))
|
| 1226 |
+
message = "allowed (unexpected)"
|
| 1227 |
+
except PolicyViolation as exc:
|
| 1228 |
+
message = "refused: " + str(exc)
|
| 1229 |
+
return overlay_lines(crop(annotate(
|
| 1230 |
+
frame, elements,
|
| 1231 |
+
title="the harness can see a Terminal button - and will not press it"), TOOLBAR_BOX),
|
| 1232 |
+
["Policy(dry_run=False).check(click 'Terminal')", message])
|
| 1233 |
+
finally:
|
| 1234 |
+
close_scene(proc)
|
| 1235 |
+
|
| 1236 |
+
|
| 1237 |
+
def make_cli_real_input(tmp):
|
| 1238 |
+
from tiny_computer_agent import OcrDetector
|
| 1239 |
+
proc = open_scene("dialog", tmp)
|
| 1240 |
+
try:
|
| 1241 |
+
elements = OcrDetector().detect(grab())
|
| 1242 |
+
before = crop(annotate(grab(), elements), DIALOG_BOX)
|
| 1243 |
+
result = subprocess.run(
|
| 1244 |
+
[PY, "-m", "tiny_computer_agent", "--live", "--real-input", "--verify-text", "Saved",
|
| 1245 |
+
"click 'Save' to save the document"],
|
| 1246 |
+
capture_output=True, text=True,
|
| 1247 |
+
env=dict(os.environ, TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT="1"))
|
| 1248 |
+
after = crop(grab(), DIALOG_BOX)
|
| 1249 |
+
lines = [line for line in result.stdout.splitlines() if line.strip()][:3]
|
| 1250 |
+
return overlay_lines(side_by_side(
|
| 1251 |
+
before, after,
|
| 1252 |
+
title="the CLI driving the real mouse: --live --real-input --verify-text Saved"), lines)
|
| 1253 |
+
finally:
|
| 1254 |
+
close_scene(proc)
|
| 1255 |
+
|
| 1256 |
+
|
| 1257 |
+
desk("desk-capture", "Real desktop",
|
| 1258 |
+
"GrabScreen: capture a real desktop",
|
| 1259 |
+
["GrabScreen", "GrabScreen.capture", "ScreenUnavailable"],
|
| 1260 |
+
note="1280x800 of a real X server. mss first, Pillow's ImageGrab as the fallback.",
|
| 1261 |
+
scene_name="dialog", make=make_capture)
|
| 1262 |
+
|
| 1263 |
+
desk("desk-ocr-index", "Real desktop",
|
| 1264 |
+
"The element index, drawn on the pixels it came from",
|
| 1265 |
+
["OcrDetector", "OcrDetector.detect", "IndexedScreen", "Element.center"],
|
| 1266 |
+
note="Every box is a real OCR word with a real screen coordinate: the model picks a label, not a pixel.",
|
| 1267 |
+
scene_name="dialog", make=make_ocr_index)
|
| 1268 |
+
|
| 1269 |
+
desk("desk-click", "Real desktop",
|
| 1270 |
+
"A real click, and the window's own reaction",
|
| 1271 |
+
["PyAutoGuiExecutor", "AgentLoop.run", "Policy.dry_run", "resolve_point"],
|
| 1272 |
+
note="Pixels in, a label chosen, a pixel resolved, a button pressed - and the dialog closed itself.",
|
| 1273 |
+
scene_name="dialog", make=make_click)
|
| 1274 |
+
|
| 1275 |
+
desk("desk-verify", "Real desktop",
|
| 1276 |
+
"Success only when the harness can see it",
|
| 1277 |
+
["AgentLoop.run", "Episode.success", "Episode.stop_reason"],
|
| 1278 |
+
note="The app changed its own title and wrote a marker file; the verifier saw the change.",
|
| 1279 |
+
scene_name="dialog", make=make_verify)
|
| 1280 |
+
|
| 1281 |
+
desk("desk-grid-fallback", "Real desktop",
|
| 1282 |
+
"A screen with no text still has legal moves",
|
| 1283 |
+
["AutoDetector", "GridDetector", "OcrDetector"],
|
| 1284 |
+
note="OCR finds nothing on a canvas, so the index degrades to numbered cells - and says so.",
|
| 1285 |
+
scene_name="canvas", make=make_grid_fallback)
|
| 1286 |
+
|
| 1287 |
+
desk("desk-policy-refusal", "Real desktop",
|
| 1288 |
+
"The safety gate, on a real button, in a real window",
|
| 1289 |
+
["Policy.check", "Policy.deny_labels", "PolicyViolation", "Observation.find"],
|
| 1290 |
+
note="The Terminal button is visible and labelled - and the policy refuses to press it.",
|
| 1291 |
+
scene_name="toolbar", make=make_deny)
|
| 1292 |
+
|
| 1293 |
+
desk("desk-cli-real-input", "Real desktop",
|
| 1294 |
+
"The same click, driven end to end from the CLI",
|
| 1295 |
+
["main", "PyAutoGuiExecutor", "AgentLoop.run"],
|
| 1296 |
+
note="--live --real-input --verify-text Saved: capture, OCR, click, verify, exit code.",
|
| 1297 |
+
scene_name="dialog", make=make_cli_real_input)
|
| 1298 |
+
|
| 1299 |
+
# =========================================================================== #
|
| 1300 |
+
# 13. the pieces a user swaps out
|
| 1301 |
+
# =========================================================================== #
|
| 1302 |
+
|
| 1303 |
+
term("protocols", "Extensibility",
|
| 1304 |
+
"Every stage is a Protocol: bring your own screen, brain, detector, executor",
|
| 1305 |
+
["Brain", "Brain.decide", "ScreenSource", "ScreenSource.capture", "Detector",
|
| 1306 |
+
"Detector.detect", "Executor", "Executor.execute", "ActionResult"],
|
| 1307 |
+
note="The loop depends on four one-method contracts, not on the shipped implementations.",
|
| 1308 |
+
code="""
|
| 1309 |
+
from tiny_computer_agent import ActionResult, Action, AgentLoop, Element, Observation
|
| 1310 |
+
|
| 1311 |
+
|
| 1312 |
+
class MyScreen: # ScreenSource: capture() -> Observation
|
| 1313 |
+
def capture(self):
|
| 1314 |
+
return Observation(elements=(Element("Deploy", x=10, y=10, width=90, height=30),),
|
| 1315 |
+
text="release ready")
|
| 1316 |
+
|
| 1317 |
+
|
| 1318 |
+
class MyBrain: # Brain: decide(goal, observation) -> Action
|
| 1319 |
+
def __init__(self):
|
| 1320 |
+
self.turns = 0
|
| 1321 |
+
|
| 1322 |
+
def decide(self, goal, observation):
|
| 1323 |
+
self.turns += 1
|
| 1324 |
+
if self.turns > 1:
|
| 1325 |
+
return Action("done", reason="the release is deployed")
|
| 1326 |
+
return Action("click", label="Deploy", reason="the goal names it")
|
| 1327 |
+
|
| 1328 |
+
|
| 1329 |
+
class MyDetector: # Detector: detect(image) -> tuple[Element, ...]
|
| 1330 |
+
def detect(self, image):
|
| 1331 |
+
return (Element("Deploy", x=10, y=10, width=90, height=30),)
|
| 1332 |
+
|
| 1333 |
+
|
| 1334 |
+
class MyExecutor: # Executor: execute(action, observation) -> ActionResult
|
| 1335 |
+
def __init__(self):
|
| 1336 |
+
self.saw = []
|
| 1337 |
+
|
| 1338 |
+
def execute(self, action, observation=None):
|
| 1339 |
+
self.saw.append(action.describe())
|
| 1340 |
+
return ActionResult(action=action, ok=True, note="my own executor ran it")
|
| 1341 |
+
|
| 1342 |
+
|
| 1343 |
+
executor = MyExecutor()
|
| 1344 |
+
episode = AgentLoop(screen=MyScreen(), brain=MyBrain(), executor=executor).run("deploy the release")
|
| 1345 |
+
print(episode.render())
|
| 1346 |
+
print()
|
| 1347 |
+
print("and what my executor was handed:", executor.saw)
|
| 1348 |
+
""")
|
| 1349 |
+
|
| 1350 |
+
term("brain-mock", "Decisions",
|
| 1351 |
+
"MockBrain: replay a fixed script, exactly once",
|
| 1352 |
+
["MockBrain", "MockBrain.decide", "MockBrain.remaining"],
|
| 1353 |
+
note="Deterministic and offline - the workhorse for tests, demos and replays.",
|
| 1354 |
+
code="""
|
| 1355 |
+
from tiny_computer_agent import Element, MockBrain, Observation
|
| 1356 |
+
|
| 1357 |
+
brain = MockBrain([
|
| 1358 |
+
{"kind": "click", "label": "Save"},
|
| 1359 |
+
{"kind": "key", "keys": "ctrl+s"},
|
| 1360 |
+
{"kind": "done", "reason": "the document is saved"},
|
| 1361 |
+
])
|
| 1362 |
+
frame = Observation(elements=(Element("Save", x=320, y=120, width=80, height=24),))
|
| 1363 |
+
|
| 1364 |
+
print("scripted actions:", 3)
|
| 1365 |
+
while brain.remaining:
|
| 1366 |
+
action = brain.decide("save the document", frame)
|
| 1367 |
+
print(f" remaining={brain.remaining} -> {action.describe()}")
|
| 1368 |
+
print()
|
| 1369 |
+
print("with the script exhausted it stops claiming work it has not planned:")
|
| 1370 |
+
print(" ", brain.decide("save the document", frame).describe())
|
| 1371 |
+
""")
|
| 1372 |
+
|
| 1373 |
+
term("brain-planner", "Decisions",
|
| 1374 |
+
"PlannerBrain: no weights, no download, and it gives up honestly",
|
| 1375 |
+
["PlannerBrain", "PlannerBrain.decide", "GoalUnreachable"],
|
| 1376 |
+
note="The proof the harness works end to end, and a real fallback when no model is wanted.",
|
| 1377 |
+
code="""
|
| 1378 |
+
from tiny_computer_agent import Element, FakeScreen, GoalUnreachable, PlannerBrain
|
| 1379 |
+
|
| 1380 |
+
brain = PlannerBrain()
|
| 1381 |
+
goal = "click 'Save' to save the document"
|
| 1382 |
+
frames = FakeScreen([(Element("Files", x=10, y=6, width=60, height=20),),
|
| 1383 |
+
(Element("Save", x=320, y=120, width=80, height=24),)])
|
| 1384 |
+
|
| 1385 |
+
for step in range(3):
|
| 1386 |
+
observation = frames.capture()
|
| 1387 |
+
chosen = brain.decide(goal, observation)
|
| 1388 |
+
print(f" step {step}: {chosen.describe()}")
|
| 1389 |
+
|
| 1390 |
+
print()
|
| 1391 |
+
print("when the target never appears it gives up rather than flailing:")
|
| 1392 |
+
try:
|
| 1393 |
+
PlannerBrain(max_waits=0).decide(goal, FakeScreen([(Element("Files"),)]).capture())
|
| 1394 |
+
except GoalUnreachable as exc:
|
| 1395 |
+
print(" GoalUnreachable:", exc)
|
| 1396 |
+
""")
|
| 1397 |
+
|
| 1398 |
+
term("models-swap", "Models",
|
| 1399 |
+
"The two real model backends, and lazy loading",
|
| 1400 |
+
["TextPlannerBrain", "VLMBrain", "TextPlannerBrain.unload", "VLMBrain.unload",
|
| 1401 |
+
"ModelBrain.loaded", "make_model_brain"],
|
| 1402 |
+
note="Constructing a brain costs nothing; the weights arrive on the first decide().",
|
| 1403 |
+
code="""
|
| 1404 |
+
from tiny_computer_agent import make_model_brain
|
| 1405 |
+
|
| 1406 |
+
for name in ["text-planner", "vlm"]:
|
| 1407 |
+
brain = make_model_brain(name)
|
| 1408 |
+
print(f"{name:13} -> {type(brain).__name__}")
|
| 1409 |
+
print(f" model id : {brain.model_id}")
|
| 1410 |
+
print(f" weights loaded : {brain.loaded}")
|
| 1411 |
+
print(f" needs an index : {brain.requires_index}")
|
| 1412 |
+
print(f" menu size cap : {brain.max_menu_items} items")
|
| 1413 |
+
brain.unload()
|
| 1414 |
+
print(f" after unload() : loaded={brain.loaded}")
|
| 1415 |
+
print()
|
| 1416 |
+
print("neither download happens until decide(), which is what makes --doctor cheap")
|
| 1417 |
+
""")
|
| 1418 |
+
|
| 1419 |
+
term("fake-desktop", "Screens",
|
| 1420 |
+
"The built-in synthetic desktop",
|
| 1421 |
+
["fake_desktop", "write_fake_desktop", "OcrDetector"],
|
| 1422 |
+
note="Draws a screenshot-like desktop offline - the same image --image example replays.",
|
| 1423 |
+
code="""
|
| 1424 |
+
import tempfile
|
| 1425 |
+
from pathlib import Path
|
| 1426 |
+
from tiny_computer_agent import OcrDetector, fake_desktop, write_fake_desktop
|
| 1427 |
+
|
| 1428 |
+
image = fake_desktop()
|
| 1429 |
+
print("fake_desktop() ->", type(image).__name__, image.size, image.mode)
|
| 1430 |
+
path = Path(tempfile.mkdtemp()) / "example.png"
|
| 1431 |
+
print("write_fake_desktop(path) ->", write_fake_desktop(path).name, f"({path.stat().st_size} bytes)")
|
| 1432 |
+
print()
|
| 1433 |
+
print("and OCR reads it like any other screenshot:")
|
| 1434 |
+
for element in OcrDetector().detect(image):
|
| 1435 |
+
print(f" {element.label!r:34} at {element.center}")
|
| 1436 |
+
""")
|
| 1437 |
+
|
| 1438 |
+
# =========================================================================== #
|
| 1439 |
+
# 14. closing the last gaps in the picture map
|
| 1440 |
+
# =========================================================================== #
|
| 1441 |
+
|
| 1442 |
+
# names the existing pictures already exercise but did not claim
|
| 1443 |
+
_AUGMENT = {
|
| 1444 |
+
"vocab-verbs": ["Action.to_dict"],
|
| 1445 |
+
"exec-refuses-unresolved": ["InputUnavailable"],
|
| 1446 |
+
"scenario": ["Trace.render"],
|
| 1447 |
+
"trace-record": ["Trace.name", "Trace.decision_step", "Trace.blocked", "TraceStep",
|
| 1448 |
+
"TraceStep.observation", "TraceStep.to_dict"],
|
| 1449 |
+
"scoreboard": ["Scoreboard.add", "Scoreboard.total", "Scoreboard.earned",
|
| 1450 |
+
"Scoreboard.accuracy_for", "Scoreboard.brains"],
|
| 1451 |
+
"cli-script": ["demo_screen"],
|
| 1452 |
+
"cli-doctor": ["report"],
|
| 1453 |
+
}
|
| 1454 |
+
for _shot in CATALOG:
|
| 1455 |
+
_extra = _AUGMENT.get(_shot.id)
|
| 1456 |
+
if _extra:
|
| 1457 |
+
_shot.shows = sorted(set(_shot.shows) | set(_extra))
|
| 1458 |
+
|
| 1459 |
+
# and the two demos whose output should actually print what they claim
|
| 1460 |
+
def _edit(sid, old, new):
|
| 1461 |
+
for _shot in CATALOG:
|
| 1462 |
+
if _shot.id == sid:
|
| 1463 |
+
assert old in _shot.code, f"{sid}: anchor not found"
|
| 1464 |
+
_shot.code = _shot.code.replace(old, new)
|
| 1465 |
+
return
|
| 1466 |
+
raise AssertionError(sid)
|
| 1467 |
+
|
| 1468 |
+
_edit("vocab-verbs",
|
| 1469 |
+
' print(f" parsed -> {action.describe()}")',
|
| 1470 |
+
' print(f" parsed -> {action.describe()}")\n'
|
| 1471 |
+
' if payload["kind"] == "click":\n'
|
| 1472 |
+
' print(f" as data: {action.to_dict()}")')
|
| 1473 |
+
|
| 1474 |
+
_edit("trace-record",
|
| 1475 |
+
'print("frames:", trace.frames, "| choice:", trace.choice,',
|
| 1476 |
+
'print("name:", trace.name, "| decision_step:", trace.decision_step().action.describe())\n'
|
| 1477 |
+
'step = trace.steps[0]\n'
|
| 1478 |
+
'labels = [e.label for e in step.observation().elements]\n'
|
| 1479 |
+
'print("first step as data:", step.to_dict()["action"], "| labels:", labels)\n'
|
| 1480 |
+
'print("frames:", trace.frames, "| choice:", trace.choice,')
|
| 1481 |
+
|
| 1482 |
+
_edit("scoreboard",
|
| 1483 |
+
'print("accuracy:", f"{board.accuracy:.0%}", "| by brain:", board.by_brain())',
|
| 1484 |
+
'print("total:", board.total, "| earned:", board.earned, "| accuracy:", f"{board.accuracy:.0%}")\n'
|
| 1485 |
+
'print("brains:", board.brains(), "| accuracy_for(\'planner\'):", f"{board.accuracy_for(\'planner\'):.0%}")')
|
| 1486 |
+
|
| 1487 |
+
_edit("exec-refuses-unresolved",
|
| 1488 |
+
'print()\nresolved = executor.execute(Action("done", reason="nothing to do"), frame)',
|
| 1489 |
+
'print()\nfrom tiny_computer_agent import InputUnavailable\n'
|
| 1490 |
+
'print("and when pyautogui itself is missing, it says so:")\n'
|
| 1491 |
+
'print(" ", InputUnavailable("real input needs pyautogui and a live display: '
|
| 1492 |
+
'pip install \'tiny-computer-agent[desktop]\'"))\nprint()\n'
|
| 1493 |
+
'resolved = executor.execute(Action("done", reason="nothing to do"), frame)')
|