Publish tiny_computer_agent/ from marimo
Browse files- tiny_computer_agent/__init__.py +45 -16
- tiny_computer_agent/__main__.py +155 -17
- tiny_computer_agent/brain.py +313 -53
- tiny_computer_agent/demo.py +60 -0
- tiny_computer_agent/desktop.py +197 -0
- tiny_computer_agent/loop.py +43 -4
- tiny_computer_agent/models.py +187 -0
- tiny_computer_agent/observe.py +137 -25
- tiny_computer_agent/perception.py +225 -0
- tiny_computer_agent/safety.py +15 -0
tiny_computer_agent/__init__.py
CHANGED
|
@@ -1,17 +1,17 @@
|
|
| 1 |
"""Tiny Computer Agent - an agentic harness that drives a real computer with a
|
| 2 |
tiny, fully local model.
|
| 3 |
|
| 4 |
-
The harness is deliberately boring: it owns the loop, the closed action
|
| 5 |
-
|
| 6 |
-
"which
|
| 7 |
-
at run time, and the core
|
| 8 |
|
| 9 |
-
from tiny_computer_agent import
|
| 10 |
-
|
| 11 |
-
)
|
| 12 |
|
| 13 |
-
screen =
|
| 14 |
-
|
|
|
|
| 15 |
episode = loop.run("save the document")
|
| 16 |
print(episode.render())
|
| 17 |
"""
|
|
@@ -34,13 +34,28 @@ from tiny_computer_agent.actions import (
|
|
| 34 |
from tiny_computer_agent.brain import (
|
| 35 |
Brain,
|
| 36 |
BrainError,
|
|
|
|
| 37 |
GoalUnreachable,
|
|
|
|
| 38 |
MockBrain,
|
|
|
|
| 39 |
PlannerBrain,
|
| 40 |
-
VLMBrain,
|
| 41 |
extract_json_object,
|
|
|
|
|
|
|
| 42 |
)
|
| 43 |
from tiny_computer_agent.loop import AgentLoop, Episode, Step
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
from tiny_computer_agent.observe import (
|
| 45 |
Element,
|
| 46 |
FakeScreen,
|
|
@@ -48,16 +63,30 @@ from tiny_computer_agent.observe import (
|
|
| 48 |
Observation,
|
| 49 |
ScreenSource,
|
| 50 |
ScreenUnavailable,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
)
|
| 52 |
from tiny_computer_agent.safety import Policy, PolicyViolation
|
| 53 |
|
| 54 |
-
__version__ = "0.
|
| 55 |
|
| 56 |
__all__ = [
|
| 57 |
"ACTION_KINDS", "ACTION_MENU", "Action", "ActionError", "ActionResult",
|
| 58 |
-
"AgentLoop", "Brain", "BrainError", "
|
| 59 |
-
"
|
| 60 |
-
"
|
| 61 |
-
"
|
| 62 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
]
|
|
|
|
| 1 |
"""Tiny Computer Agent - an agentic harness that drives a real computer with a
|
| 2 |
tiny, fully local model.
|
| 3 |
|
| 4 |
+
The harness is deliberately boring: it owns the loop, the closed action vocabulary,
|
| 5 |
+
perception and the safety gates, so the model only ever answers one question -
|
| 6 |
+
"which item from this short menu, and which verb?" Nothing here needs a network
|
| 7 |
+
call at run time, and the core imports nothing outside the standard library.
|
| 8 |
|
| 9 |
+
from tiny_computer_agent import AgentLoop, GrabScreen, IndexedScreen, OcrDetector
|
| 10 |
+
from tiny_computer_agent.models import TextPlannerBrain
|
|
|
|
| 11 |
|
| 12 |
+
screen = IndexedScreen(GrabScreen(), OcrDetector()) # pixels -> labels
|
| 13 |
+
brain = TextPlannerBrain() # 0.5B, CPU-friendly
|
| 14 |
+
loop = AgentLoop(screen=screen, brain=brain) # dry run by default
|
| 15 |
episode = loop.run("save the document")
|
| 16 |
print(episode.render())
|
| 17 |
"""
|
|
|
|
| 34 |
from tiny_computer_agent.brain import (
|
| 35 |
Brain,
|
| 36 |
BrainError,
|
| 37 |
+
DecisionMenu,
|
| 38 |
GoalUnreachable,
|
| 39 |
+
MenuItem,
|
| 40 |
MockBrain,
|
| 41 |
+
ModelBrain,
|
| 42 |
PlannerBrain,
|
|
|
|
| 43 |
extract_json_object,
|
| 44 |
+
extract_json_objects,
|
| 45 |
+
parse_decision,
|
| 46 |
)
|
| 47 |
from tiny_computer_agent.loop import AgentLoop, Episode, Step
|
| 48 |
+
from tiny_computer_agent.models import (
|
| 49 |
+
DEFAULT_TEXT_MODEL,
|
| 50 |
+
DEFAULT_VISION_MODEL,
|
| 51 |
+
MODEL_BRAINS,
|
| 52 |
+
TextPlannerBrain,
|
| 53 |
+
VLMBrain,
|
| 54 |
+
make_model_brain,
|
| 55 |
+
model_cached,
|
| 56 |
+
model_dependencies,
|
| 57 |
+
resolve_device,
|
| 58 |
+
)
|
| 59 |
from tiny_computer_agent.observe import (
|
| 60 |
Element,
|
| 61 |
FakeScreen,
|
|
|
|
| 63 |
Observation,
|
| 64 |
ScreenSource,
|
| 65 |
ScreenUnavailable,
|
| 66 |
+
normalise_label,
|
| 67 |
+
)
|
| 68 |
+
from tiny_computer_agent.perception import (
|
| 69 |
+
Detector,
|
| 70 |
+
DetectorUnavailable,
|
| 71 |
+
IndexedScreen,
|
| 72 |
+
OcrDetector,
|
| 73 |
+
detector_available,
|
| 74 |
+
make_detector,
|
| 75 |
)
|
| 76 |
from tiny_computer_agent.safety import Policy, PolicyViolation
|
| 77 |
|
| 78 |
+
__version__ = "0.2.0"
|
| 79 |
|
| 80 |
__all__ = [
|
| 81 |
"ACTION_KINDS", "ACTION_MENU", "Action", "ActionError", "ActionResult",
|
| 82 |
+
"AgentLoop", "Brain", "BrainError", "DEFAULT_TEXT_MODEL", "DEFAULT_VISION_MODEL",
|
| 83 |
+
"DecisionMenu", "Detector", "DetectorUnavailable", "DryRunExecutor", "Element",
|
| 84 |
+
"Episode", "Executor", "FakeScreen", "GoalUnreachable", "GrabScreen",
|
| 85 |
+
"IndexedScreen", "InputUnavailable", "MODEL_BRAINS", "MenuItem", "MockBrain",
|
| 86 |
+
"ModelBrain", "Observation", "OcrDetector", "PlannerBrain", "Policy",
|
| 87 |
+
"PolicyViolation", "PyAutoGuiExecutor", "ScreenSource", "ScreenUnavailable",
|
| 88 |
+
"Step", "TextPlannerBrain", "VLMBrain", "detector_available",
|
| 89 |
+
"extract_json_object", "extract_json_objects", "make_detector", "make_model_brain",
|
| 90 |
+
"model_cached", "model_dependencies", "normalise_label", "parse_action",
|
| 91 |
+
"parse_decision", "resolve_device", "resolve_point", "__version__",
|
| 92 |
]
|
tiny_computer_agent/__main__.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
| 1 |
"""Command line entry point.
|
| 2 |
|
| 3 |
-
python -m tiny_computer_agent
|
| 4 |
-
python -m tiny_computer_agent --
|
| 5 |
-
python -m tiny_computer_agent
|
| 6 |
-
python -m tiny_computer_agent --
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
|
@@ -15,16 +17,24 @@ from __future__ import annotations
|
|
| 15 |
import argparse
|
| 16 |
import json
|
| 17 |
import os
|
|
|
|
| 18 |
import sys
|
| 19 |
|
| 20 |
from tiny_computer_agent.act import DryRunExecutor, PyAutoGuiExecutor
|
| 21 |
from tiny_computer_agent.brain import MockBrain, PlannerBrain
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
from tiny_computer_agent.loop import AgentLoop, Step
|
| 23 |
-
from tiny_computer_agent.observe import
|
|
|
|
|
|
|
|
|
|
| 24 |
from tiny_computer_agent.safety import Policy
|
| 25 |
|
| 26 |
ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
|
| 27 |
-
|
| 28 |
|
| 29 |
|
| 30 |
def demo_screen() -> FakeScreen:
|
|
@@ -50,24 +60,114 @@ def build_parser() -> argparse.ArgumentParser:
|
|
| 50 |
parser.add_argument("--max-steps", type=int, default=6)
|
| 51 |
parser.add_argument("--script", default=None,
|
| 52 |
help="JSON list of actions to replay instead of using a brain")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
parser.add_argument("--live", action="store_true",
|
| 54 |
help="capture your real screen instead of the scripted demo")
|
| 55 |
parser.add_argument("--real-input", action="store_true",
|
| 56 |
help="actually move the mouse and type (requires the allow env var)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
parser.add_argument("--kill-switch", default=DEFAULT_KILL_SWITCH,
|
| 58 |
help="create this file to abort the run before the next action")
|
| 59 |
return parser
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def main(argv: list[str] | None = None) -> int:
|
| 63 |
args = build_parser().parse_args(argv)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
if args.real_input and os.environ.get(ALLOW_REAL_INPUT_ENV) != "1":
|
| 66 |
print(f"refusing --real-input: set {ALLOW_REAL_INPUT_ENV}=1 to confirm you "
|
| 67 |
f"understand this will drive your actual mouse and keyboard.", file=sys.stderr)
|
| 68 |
return 2
|
| 69 |
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
if args.script:
|
| 73 |
try:
|
|
@@ -76,26 +176,64 @@ def main(argv: list[str] | None = None) -> int:
|
|
| 76 |
print(f"--script is not valid JSON: {exc}", file=sys.stderr)
|
| 77 |
return 2
|
| 78 |
brain = MockBrain(script)
|
|
|
|
|
|
|
| 79 |
else:
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
policy = Policy(
|
| 83 |
dry_run=not args.real_input,
|
| 84 |
step_budget=args.max_steps,
|
| 85 |
kill_switch_path=args.kill_switch,
|
|
|
|
| 86 |
)
|
| 87 |
executor = PyAutoGuiExecutor() if args.real_input else DryRunExecutor()
|
| 88 |
|
| 89 |
mode = "REAL INPUT (mouse and keyboard live)" if args.real_input else "dry-run"
|
| 90 |
-
print(f"goal: {args.goal}
|
| 91 |
-
|
| 92 |
-
f"kill switch: create {args.kill_switch} to stop
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
def on_step(step: Step) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
print(" " + step.line())
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
print()
|
| 100 |
print(episode.render())
|
| 101 |
return 0 if episode.success else 1
|
|
|
|
| 1 |
"""Command line entry point.
|
| 2 |
|
| 3 |
+
python -m tiny_computer_agent # scripted demo, dry-run
|
| 4 |
+
python -m tiny_computer_agent --doctor # what can this machine do?
|
| 5 |
+
python -m tiny_computer_agent --image example # replay a screenshot, dry-run
|
| 6 |
+
python -m tiny_computer_agent --image shot.png # replay your own screenshot
|
| 7 |
+
python -m tiny_computer_agent --live # your real screen, dry-run
|
| 8 |
+
python -m tiny_computer_agent --live --real-input "click 'OK'"
|
| 9 |
+
|
| 10 |
+
Real mouse and keyboard input additionally requires
|
| 11 |
+
``TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1``. Dry run is the default, so you can read a
|
| 12 |
+
whole episode before anything touches your pointer.
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
|
|
|
| 17 |
import argparse
|
| 18 |
import json
|
| 19 |
import os
|
| 20 |
+
import platform
|
| 21 |
import sys
|
| 22 |
|
| 23 |
from tiny_computer_agent.act import DryRunExecutor, PyAutoGuiExecutor
|
| 24 |
from tiny_computer_agent.brain import MockBrain, PlannerBrain
|
| 25 |
+
from tiny_computer_agent.demo import fake_desktop
|
| 26 |
+
from tiny_computer_agent.desktop import (
|
| 27 |
+
BRAIN_NAMES, DEFAULT_KILL_SWITCH, Check, display_available, run_checks,
|
| 28 |
+
)
|
| 29 |
from tiny_computer_agent.loop import AgentLoop, Step
|
| 30 |
+
from tiny_computer_agent.observe import (
|
| 31 |
+
Element, FakeScreen, GrabScreen, ImageScreen, ScreenUnavailable,
|
| 32 |
+
)
|
| 33 |
+
from tiny_computer_agent.perception import IndexedScreen, detector_available, make_detector
|
| 34 |
from tiny_computer_agent.safety import Policy
|
| 35 |
|
| 36 |
ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
|
| 37 |
+
EXAMPLE_IMAGE = "example"
|
| 38 |
|
| 39 |
|
| 40 |
def demo_screen() -> FakeScreen:
|
|
|
|
| 60 |
parser.add_argument("--max-steps", type=int, default=6)
|
| 61 |
parser.add_argument("--script", default=None,
|
| 62 |
help="JSON list of actions to replay instead of using a brain")
|
| 63 |
+
parser.add_argument("--doctor", action="store_true",
|
| 64 |
+
help="report what this machine can and cannot do, then exit")
|
| 65 |
+
parser.add_argument("--image", default=None,
|
| 66 |
+
help="replay a screenshot instead of the screen; "
|
| 67 |
+
f"'{EXAMPLE_IMAGE}' uses the built-in one")
|
| 68 |
+
parser.add_argument("--brain", default="planner", choices=BRAIN_NAMES,
|
| 69 |
+
help="planner (rule-based, no weights) | text-planner | vlm")
|
| 70 |
+
parser.add_argument("--detector", default="auto", choices=("auto", "ocr", "grid"),
|
| 71 |
+
help="how to build the element index (auto = OCR, grid fallback)")
|
| 72 |
parser.add_argument("--live", action="store_true",
|
| 73 |
help="capture your real screen instead of the scripted demo")
|
| 74 |
parser.add_argument("--real-input", action="store_true",
|
| 75 |
help="actually move the mouse and type (requires the allow env var)")
|
| 76 |
+
parser.add_argument("--monitor", type=int, default=1, help="1 is the primary monitor")
|
| 77 |
+
parser.add_argument("--scale", type=float, default=None,
|
| 78 |
+
help="screen pixels per captured pixel (HiDPI, secondary monitors)")
|
| 79 |
+
parser.add_argument("--no-ocr", action="store_true",
|
| 80 |
+
help="skip OCR and index a numbered grid instead")
|
| 81 |
+
parser.add_argument("--max-width", type=int, default=1600,
|
| 82 |
+
help="downscale captures wider than this before indexing")
|
| 83 |
+
parser.add_argument("--allow-grid", action="store_true",
|
| 84 |
+
help="permit clicks on numbered grid cells with --real-input")
|
| 85 |
parser.add_argument("--kill-switch", default=DEFAULT_KILL_SWITCH,
|
| 86 |
help="create this file to abort the run before the next action")
|
| 87 |
return parser
|
| 88 |
|
| 89 |
|
| 90 |
+
def report(checks: list[Check]) -> int:
|
| 91 |
+
"""Print a preflight report; non-zero when something required is missing."""
|
| 92 |
+
blocking = [c for c in checks if c.required and not c.ok]
|
| 93 |
+
lines = [
|
| 94 |
+
f"tiny-computer-agent preflight ({platform.system()} {platform.machine()}, "
|
| 95 |
+
f"python {platform.python_version()})",
|
| 96 |
+
"",
|
| 97 |
+
*(c.render() for c in checks),
|
| 98 |
+
"",
|
| 99 |
+
]
|
| 100 |
+
if blocking:
|
| 101 |
+
lines.append(f"NOT READY: {len(blocking)} blocking problem(s): "
|
| 102 |
+
+ ", ".join(c.name for c in blocking))
|
| 103 |
+
else:
|
| 104 |
+
lines.append("READY: the harness can drive this desktop.")
|
| 105 |
+
print("\n".join(lines))
|
| 106 |
+
return 1 if blocking else 0
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _probe_live(args: argparse.Namespace) -> Check:
|
| 110 |
+
"""Actually grab one frame, because only a real capture proves it works."""
|
| 111 |
+
try:
|
| 112 |
+
observation = GrabScreen(
|
| 113 |
+
monitor=args.monitor, max_width=args.max_width, scale=args.scale
|
| 114 |
+
).capture()
|
| 115 |
+
frame = observation.frame
|
| 116 |
+
return Check("live capture", True, f"captured {frame.width}x{frame.height} pixels")
|
| 117 |
+
except ScreenUnavailable as exc:
|
| 118 |
+
return Check("live capture", False, str(exc),
|
| 119 |
+
fix="run this on the desktop session itself, not a headless shell")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _build_screen(args: argparse.Namespace):
|
| 123 |
+
"""(screen, description). Raises ScreenUnavailable / OSError on failure."""
|
| 124 |
+
detector_name = "grid" if args.no_ocr else args.detector
|
| 125 |
+
if args.image is not None:
|
| 126 |
+
source = ImageScreen(fake_desktop() if args.image == EXAMPLE_IMAGE else args.image)
|
| 127 |
+
return IndexedScreen(source, make_detector(detector_name)), f"screenshot replay: {args.image}"
|
| 128 |
+
if args.live:
|
| 129 |
+
source = GrabScreen(monitor=args.monitor, max_width=args.max_width, scale=args.scale)
|
| 130 |
+
return IndexedScreen(source, make_detector(detector_name)), f"real desktop (monitor {args.monitor})"
|
| 131 |
+
return demo_screen(), "scripted demo"
|
| 132 |
+
|
| 133 |
+
|
| 134 |
def main(argv: list[str] | None = None) -> int:
|
| 135 |
args = build_parser().parse_args(argv)
|
| 136 |
+
detector_name = "grid" if args.no_ocr else args.detector
|
| 137 |
+
|
| 138 |
+
if args.doctor:
|
| 139 |
+
checks = run_checks(brain=args.brain, detector=detector_name,
|
| 140 |
+
real_input=args.real_input, kill_switch_path=args.kill_switch)
|
| 141 |
+
if args.live:
|
| 142 |
+
checks.append(_probe_live(args))
|
| 143 |
+
return report(checks)
|
| 144 |
|
| 145 |
if args.real_input and os.environ.get(ALLOW_REAL_INPUT_ENV) != "1":
|
| 146 |
print(f"refusing --real-input: set {ALLOW_REAL_INPUT_ENV}=1 to confirm you "
|
| 147 |
f"understand this will drive your actual mouse and keyboard.", file=sys.stderr)
|
| 148 |
return 2
|
| 149 |
|
| 150 |
+
if args.brain == "vlm" and not (args.live or args.image):
|
| 151 |
+
print("the vlm brain needs --live (or --image) so it has pixels to look at; "
|
| 152 |
+
"use --brain planner to exercise the harness offline.", file=sys.stderr)
|
| 153 |
+
return 2
|
| 154 |
+
|
| 155 |
+
if args.live:
|
| 156 |
+
available, detail = display_available()
|
| 157 |
+
if not available:
|
| 158 |
+
print(f"live capture needs a display session: {detail}")
|
| 159 |
+
print(f"refusing to run live: {detail}. Try --image to replay a screenshot, "
|
| 160 |
+
f"or --doctor to diagnose.", file=sys.stderr)
|
| 161 |
+
return 2
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
screen, description = _build_screen(args)
|
| 165 |
+
except ScreenUnavailable as exc:
|
| 166 |
+
print(f"cannot see the screen: {exc}", file=sys.stderr)
|
| 167 |
+
return 3
|
| 168 |
+
except Exception as exc:
|
| 169 |
+
print(f"could not start the screen: {type(exc).__name__}: {exc}", file=sys.stderr)
|
| 170 |
+
return 2
|
| 171 |
|
| 172 |
if args.script:
|
| 173 |
try:
|
|
|
|
| 176 |
print(f"--script is not valid JSON: {exc}", file=sys.stderr)
|
| 177 |
return 2
|
| 178 |
brain = MockBrain(script)
|
| 179 |
+
elif args.brain == "planner":
|
| 180 |
+
brain = PlannerBrain()
|
| 181 |
else:
|
| 182 |
+
from tiny_computer_agent.models import make_model_brain
|
| 183 |
+
|
| 184 |
+
try:
|
| 185 |
+
brain = make_model_brain(args.brain)
|
| 186 |
+
except Exception as exc:
|
| 187 |
+
print(f"could not load the {args.brain} brain: {type(exc).__name__}: {exc}",
|
| 188 |
+
file=sys.stderr)
|
| 189 |
+
return 2
|
| 190 |
|
| 191 |
policy = Policy(
|
| 192 |
dry_run=not args.real_input,
|
| 193 |
step_budget=args.max_steps,
|
| 194 |
kill_switch_path=args.kill_switch,
|
| 195 |
+
allow_grid_clicks=args.allow_grid,
|
| 196 |
)
|
| 197 |
executor = PyAutoGuiExecutor() if args.real_input else DryRunExecutor()
|
| 198 |
|
| 199 |
mode = "REAL INPUT (mouse and keyboard live)" if args.real_input else "dry-run"
|
| 200 |
+
print(f"goal: {args.goal}")
|
| 201 |
+
print(f"mode: {mode} | screen: {description} | brain: {args.brain} | "
|
| 202 |
+
f"kill switch: create {args.kill_switch} to stop")
|
| 203 |
+
if args.image is not None or args.live:
|
| 204 |
+
_, index_detail = detector_available(detector_name)
|
| 205 |
+
print(f"index: {detector_name} - {index_detail}")
|
| 206 |
+
if args.image is not None:
|
| 207 |
+
# One read, so the header can state how big the action space is.
|
| 208 |
+
try:
|
| 209 |
+
preview = screen.capture()
|
| 210 |
+
except ScreenUnavailable as exc:
|
| 211 |
+
print(f"cannot see the screen: {exc}", file=sys.stderr)
|
| 212 |
+
return 3
|
| 213 |
+
print(f"elements detected: {len(preview.elements)}")
|
| 214 |
+
print()
|
| 215 |
+
|
| 216 |
+
warned = False
|
| 217 |
|
| 218 |
def on_step(step: Step) -> None:
|
| 219 |
+
nonlocal warned
|
| 220 |
+
elements = step.observation.elements
|
| 221 |
+
if not warned and elements and all(e.kind == "grid" for e in elements):
|
| 222 |
+
warned = True
|
| 223 |
+
_, index_detail = detector_available(detector_name)
|
| 224 |
+
print(f"warning: no text was found on screen, so the index is "
|
| 225 |
+
f"{len(elements)} numbered grid cells and the agent is choosing "
|
| 226 |
+
f"blind ({index_detail})", file=sys.stderr)
|
| 227 |
print(" " + step.line())
|
| 228 |
|
| 229 |
+
try:
|
| 230 |
+
episode = AgentLoop(screen=screen, brain=brain, executor=executor,
|
| 231 |
+
policy=policy, on_step=on_step).run(args.goal)
|
| 232 |
+
except ScreenUnavailable as exc:
|
| 233 |
+
print(f"cannot see the screen: {exc}", file=sys.stderr)
|
| 234 |
+
print("run `python -m tiny_computer_agent --doctor --live` for details.", file=sys.stderr)
|
| 235 |
+
return 3
|
| 236 |
+
|
| 237 |
print()
|
| 238 |
print(episode.render())
|
| 239 |
return 0 if episode.success else 1
|
tiny_computer_agent/brain.py
CHANGED
|
@@ -1,23 +1,29 @@
|
|
| 1 |
-
"""Decision making.
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
* :class:`MockBrain` - replays a scripted action list (tests, demos, replays)
|
| 6 |
-
* :class:`PlannerBrain` - rule-based: clicks the element
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
* :class:`VLMBrain` - the seam for a real tiny VLM. Prompt building and reply
|
| 10 |
-
validation are done; you implement ``_generate()``
|
| 11 |
"""
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import json
|
| 16 |
import re
|
| 17 |
-
from
|
|
|
|
| 18 |
|
| 19 |
-
from tiny_computer_agent.actions import
|
| 20 |
-
from tiny_computer_agent.observe import Observation
|
| 21 |
|
| 22 |
|
| 23 |
class BrainError(RuntimeError):
|
|
@@ -33,19 +39,217 @@ class GoalUnreachable(BrainError):
|
|
| 33 |
"""
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def extract_json_object(raw: str) -> dict[str, Any]:
|
| 37 |
-
"""
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
class Brain(Protocol):
|
|
@@ -82,11 +286,11 @@ class MockBrain:
|
|
| 82 |
class PlannerBrain:
|
| 83 |
"""Rule-based baseline: click the element matching the goal, then stop.
|
| 84 |
|
| 85 |
-
Its purpose is
|
| 86 |
-
at all,
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
the way a
|
| 90 |
"""
|
| 91 |
|
| 92 |
name = "planner"
|
|
@@ -124,7 +328,6 @@ class PlannerBrain:
|
|
| 124 |
reason=f"matched {target!r} in the element index")
|
| 125 |
raise GoalUnreachable(f"no element name found in the goal {goal!r}")
|
| 126 |
|
| 127 |
-
|
| 128 |
@staticmethod
|
| 129 |
def _target_from(goal: str) -> str | None:
|
| 130 |
"""Best effort: quoted text wins, else the last capitalised word."""
|
|
@@ -135,40 +338,97 @@ class PlannerBrain:
|
|
| 135 |
return words[-1].strip(".,!?") if words else None
|
| 136 |
|
| 137 |
|
| 138 |
-
class
|
| 139 |
-
"""
|
| 140 |
|
| 141 |
-
``
|
| 142 |
-
|
| 143 |
-
|
| 144 |
"""
|
| 145 |
|
| 146 |
-
name = "
|
|
|
|
| 147 |
|
| 148 |
-
def __init__(self, model_id: str
|
| 149 |
-
max_new_tokens: int =
|
| 150 |
self.model_id = model_id
|
|
|
|
| 151 |
self.max_new_tokens = max_new_tokens
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
def
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
def _generate(self, prompt: str, observation: Observation) -> str:
|
| 162 |
raise NotImplementedError(
|
| 163 |
-
"
|
| 164 |
-
"_generate(prompt, observation) to return raw model text, or use "
|
| 165 |
-
"MockBrain / PlannerBrain while the harness is being tested."
|
| 166 |
)
|
| 167 |
-
|
| 168 |
-
def decide(self, goal: str, observation: Observation) -> Action:
|
| 169 |
-
raw = self._generate(self.build_prompt(goal, observation), observation)
|
| 170 |
-
payload = extract_json_object(raw)
|
| 171 |
-
try:
|
| 172 |
-
return parse_action(payload)
|
| 173 |
-
except ActionError as exc:
|
| 174 |
-
raise BrainError(f"model proposed an invalid action: {exc}") from exc
|
|
|
|
| 1 |
+
"""Decision making: menu-mediated, model-agnostic.
|
| 2 |
|
| 3 |
+
The design lesson from testing tiny models on real screenshots: a 256M VLM can
|
| 4 |
+
*read* a button label from pixels but cannot reliably reason, and a 0.5B text model
|
| 5 |
+
writes perfect JSON but picks the wrong widget when handed noisy free-form text.
|
| 6 |
+
Both do much better when the harness hands them a **short numbered menu** and asks
|
| 7 |
+
for a number. That is what this module builds, and it keeps the model's output space
|
| 8 |
+
finite no matter how small the model is.
|
| 9 |
+
|
| 10 |
+
Brains shipped here:
|
| 11 |
|
| 12 |
* :class:`MockBrain` - replays a scripted action list (tests, demos, replays)
|
| 13 |
+
* :class:`PlannerBrain` - rule-based, no model: clicks the element matching the goal
|
| 14 |
+
* :class:`ModelBrain` - base for real models: menu, prompt, lenient parsing
|
| 15 |
+
(see :mod:`tiny_computer_agent.models` for the text and vision backends)
|
|
|
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
from __future__ import annotations
|
| 19 |
|
| 20 |
import json
|
| 21 |
import re
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
from typing import Any, Iterator, Mapping, Protocol, Sequence
|
| 24 |
|
| 25 |
+
from tiny_computer_agent.actions import ACTION_KINDS, Action, ActionError, parse_action
|
| 26 |
+
from tiny_computer_agent.observe import Element, Observation, normalise_label
|
| 27 |
|
| 28 |
|
| 29 |
class BrainError(RuntimeError):
|
|
|
|
| 39 |
"""
|
| 40 |
|
| 41 |
|
| 42 |
+
def extract_json_objects(raw: str) -> list[dict[str, Any]]:
|
| 43 |
+
"""Every flat JSON object in a reply, in the order they appear.
|
| 44 |
+
|
| 45 |
+
Regex-based and non-nesting: replies from tiny models are one-level objects.
|
| 46 |
+
Callers should prefer the **last** object, because a model that echoes its own
|
| 47 |
+
prompt before answering puts the prompt's example first and its answer last.
|
| 48 |
+
"""
|
| 49 |
+
objects: list[dict[str, Any]] = []
|
| 50 |
+
for match in re.finditer(r"\{[^{}]*\}", raw or "", re.DOTALL):
|
| 51 |
+
try:
|
| 52 |
+
payload = json.loads(match.group(0))
|
| 53 |
+
except json.JSONDecodeError:
|
| 54 |
+
continue
|
| 55 |
+
if isinstance(payload, dict):
|
| 56 |
+
objects.append(payload)
|
| 57 |
+
return objects
|
| 58 |
+
|
| 59 |
+
|
| 60 |
def extract_json_object(raw: str) -> dict[str, Any]:
|
| 61 |
+
"""The decision out of a reply: the *last* JSON object, not the first."""
|
| 62 |
+
objects = extract_json_objects(raw)
|
| 63 |
+
if not objects:
|
| 64 |
+
raise BrainError(f"no JSON object in the model reply: {(raw or '')[:200]!r}")
|
| 65 |
+
return objects[-1]
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass(frozen=True)
|
| 69 |
+
class MenuItem:
|
| 70 |
+
"""One numbered choice shown to the model."""
|
| 71 |
+
|
| 72 |
+
index: int
|
| 73 |
+
element: Element
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def label(self) -> str:
|
| 77 |
+
return self.element.label
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class DecisionMenu:
|
| 81 |
+
"""A short, numbered, closed choice set built from the element index.
|
| 82 |
+
|
| 83 |
+
Elements are presented in reading order (top to bottom, then left to right),
|
| 84 |
+
and capped: a 20-item menu is as good as free-form text to a small model.
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
def __init__(self, observation: Observation, *, max_items: int = 12) -> None:
|
| 88 |
+
ordered = sorted(observation.elements, key=lambda e: (e.y, e.x))[:max_items]
|
| 89 |
+
self.items: tuple[MenuItem, ...] = tuple(
|
| 90 |
+
MenuItem(index=i + 1, element=e) for i, e in enumerate(ordered)
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
def __len__(self) -> int:
|
| 94 |
+
return len(self.items)
|
| 95 |
+
|
| 96 |
+
def __bool__(self) -> bool:
|
| 97 |
+
return bool(self.items)
|
| 98 |
+
|
| 99 |
+
def __iter__(self) -> Iterator[MenuItem]:
|
| 100 |
+
return iter(self.items)
|
| 101 |
+
|
| 102 |
+
def item(self, value: Any) -> MenuItem | None:
|
| 103 |
+
"""Look up a 1-based index, tolerating the strings models actually send."""
|
| 104 |
+
try:
|
| 105 |
+
number = int(float(str(value).strip()))
|
| 106 |
+
except (TypeError, ValueError):
|
| 107 |
+
return None
|
| 108 |
+
if 1 <= number <= len(self.items):
|
| 109 |
+
return self.items[number - 1]
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
def find(self, label: str) -> Element | None:
|
| 113 |
+
"""Resolve a label against the menu (normalised, like Observation.find)."""
|
| 114 |
+
wanted = normalise_label(label)
|
| 115 |
+
if not wanted:
|
| 116 |
+
return None
|
| 117 |
+
for item in self.items:
|
| 118 |
+
if normalise_label(item.label) == wanted:
|
| 119 |
+
return item.element
|
| 120 |
+
for item in self.items:
|
| 121 |
+
if wanted in normalise_label(item.label):
|
| 122 |
+
return item.element
|
| 123 |
+
return None
|
| 124 |
+
|
| 125 |
+
def mentioned_in(self, text: str) -> tuple[MenuItem, ...]:
|
| 126 |
+
"""Menu entries whose label appears in the text, in order of first mention."""
|
| 127 |
+
haystack = normalise_label(text)
|
| 128 |
+
found: list[tuple[int, MenuItem]] = []
|
| 129 |
+
for item in self.items:
|
| 130 |
+
needle = normalise_label(item.label)
|
| 131 |
+
if needle and (position := haystack.find(needle)) != -1:
|
| 132 |
+
found.append((position, item))
|
| 133 |
+
return tuple(item for _, item in sorted(found, key=lambda pair: pair[0]))
|
| 134 |
+
|
| 135 |
+
def render(self) -> str:
|
| 136 |
+
return "\n".join(
|
| 137 |
+
f" {item.index}. {item.label} (at {item.element.center[0]},{item.element.center[1]})"
|
| 138 |
+
for item in self.items
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _recover_from_text(payload: Mapping[str, Any], menu: DecisionMenu) -> Action | None:
|
| 143 |
+
"""Salvage a decision from a payload that named the right thing in the wrong field.
|
| 144 |
+
|
| 145 |
+
Observed for real: a 0.5B planner replied ``{"kind": "click", "index": 0,
|
| 146 |
+
"reason": "Save"}`` - the correct answer, in the wrong place. If the index is
|
| 147 |
+
unusable but exactly one menu label is mentioned anywhere in the payload, take
|
| 148 |
+
the label. It is still only ever a label the detector saw in this frame, so the
|
| 149 |
+
deny list, the dry run and the step budget all continue to apply.
|
| 150 |
+
"""
|
| 151 |
+
text = json.dumps(payload, ensure_ascii=False)
|
| 152 |
+
mentioned = menu.mentioned_in(text)
|
| 153 |
+
if len({item.label for item in mentioned}) == 1:
|
| 154 |
+
item = mentioned[0]
|
| 155 |
+
return Action(
|
| 156 |
+
"click", label=item.label,
|
| 157 |
+
reason=f"recovered {item.label!r} from the model's reply: {text[:80]}",
|
| 158 |
+
)
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _action_from_payload(payload: Mapping[str, Any], menu: DecisionMenu) -> Action | None:
|
| 163 |
+
"""Interpret one JSON object, tolerating the ways small models mangle the shape."""
|
| 164 |
+
kind = payload.get("kind")
|
| 165 |
+
if isinstance(kind, str) and kind.strip().lower() not in ACTION_KINDS:
|
| 166 |
+
kind = None # e.g. {"kind": "file"} - a garbled click; fall back to index/label
|
| 167 |
+
|
| 168 |
+
if payload.get("index") is not None:
|
| 169 |
+
item = menu.item(payload["index"])
|
| 170 |
+
if item is not None:
|
| 171 |
+
return Action("click", label=item.label, reason=f"menu index {payload['index']}")
|
| 172 |
+
recovered = _recover_from_text(payload, menu)
|
| 173 |
+
if recovered is not None:
|
| 174 |
+
return recovered
|
| 175 |
+
raise BrainError(
|
| 176 |
+
f"the model chose index {payload['index']!r}, which is not in the "
|
| 177 |
+
f"menu of {len(menu)} items"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
if kind in (None, "click") and "label" in payload:
|
| 181 |
+
element = menu.find(str(payload["label"]))
|
| 182 |
+
if element is None:
|
| 183 |
+
raise BrainError(f"the model named {payload['label']!r}, which is not on screen")
|
| 184 |
+
return Action("click", label=element.label, reason=f"the model named {element.label!r}")
|
| 185 |
+
|
| 186 |
+
if kind is not None:
|
| 187 |
+
try:
|
| 188 |
+
return parse_action(dict(payload, kind=kind))
|
| 189 |
+
except ActionError as exc:
|
| 190 |
+
# a valid-looking verb with no usable target: maybe the label is in the prose
|
| 191 |
+
return _recover_from_text(payload, menu) or (_raise_brain_error(exc))
|
| 192 |
+
|
| 193 |
+
return _recover_from_text(payload, menu)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _raise_brain_error(exc: Exception) -> Action:
|
| 197 |
+
raise BrainError(f"the model proposed an invalid action: {exc}") from exc
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
_DONE = re.compile(r"\b(done|finished|complete[d]?)\b", re.IGNORECASE)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def parse_decision(raw: str, observation: Observation, *, max_menu_items: int = 12) -> Action:
|
| 204 |
+
"""Turn a small model's reply into a validated Action - leniently, but safely.
|
| 205 |
+
|
| 206 |
+
Tried in order:
|
| 207 |
+
|
| 208 |
+
1. the **last** JSON object in the reply (``index``, ``label`` or a full action)
|
| 209 |
+
2. a label the reply names (most specific evidence of intent)
|
| 210 |
+
3. a bare menu number
|
| 211 |
+
4. a "done" word
|
| 212 |
+
|
| 213 |
+
A reply that mentions three or more menu entries is rejected as an echo of the
|
| 214 |
+
prompt rather than a decision - the failure mode of the weakest models, and the
|
| 215 |
+
one most likely to make an agent click something arbitrary.
|
| 216 |
+
"""
|
| 217 |
+
text = " ".join((raw or "").split())
|
| 218 |
+
if not text:
|
| 219 |
+
raise BrainError("the model returned an empty reply")
|
| 220 |
+
menu = DecisionMenu(observation, max_items=max_menu_items)
|
| 221 |
+
|
| 222 |
+
for payload in reversed(extract_json_objects(text)):
|
| 223 |
+
action = _action_from_payload(payload, menu)
|
| 224 |
+
if action is not None:
|
| 225 |
+
return action
|
| 226 |
+
|
| 227 |
+
mentioned = menu.mentioned_in(text)
|
| 228 |
+
if len(mentioned) >= 3:
|
| 229 |
+
raise BrainError(
|
| 230 |
+
f"the reply mentions {len(mentioned)} menu entries, so it looks like an echo "
|
| 231 |
+
f"of the prompt rather than a decision: {text[:120]!r}"
|
| 232 |
+
)
|
| 233 |
+
if mentioned:
|
| 234 |
+
item = mentioned[0]
|
| 235 |
+
note = f"the reply names {item.label!r}"
|
| 236 |
+
if len(mentioned) > 1:
|
| 237 |
+
note += f" (ambiguous: {len(mentioned)} labels mentioned; took the first)"
|
| 238 |
+
return Action("click", label=item.label, reason=note)
|
| 239 |
+
|
| 240 |
+
number = re.search(r"\b(\d{1,2})\b", text)
|
| 241 |
+
if number is not None:
|
| 242 |
+
item = menu.item(number.group(1))
|
| 243 |
+
if item is not None:
|
| 244 |
+
return Action(
|
| 245 |
+
"click", label=item.label,
|
| 246 |
+
reason=f"menu index {number.group(1)} from reply {text[:40]!r}",
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
if _DONE.search(text):
|
| 250 |
+
return Action("done", reason=_DONE.search(text).group(0).lower())
|
| 251 |
+
|
| 252 |
+
raise BrainError(f"could not read a decision out of the reply: {text[:120]!r}")
|
| 253 |
|
| 254 |
|
| 255 |
class Brain(Protocol):
|
|
|
|
| 286 |
class PlannerBrain:
|
| 287 |
"""Rule-based baseline: click the element matching the goal, then stop.
|
| 288 |
|
| 289 |
+
Its purpose is threefold - it proves the harness works end to end with no model
|
| 290 |
+
at all, it is a fallback when a model download is undesirable, and it shows how
|
| 291 |
+
much of a "computer use" task is really an indexing problem. It waits when the
|
| 292 |
+
target is not on screen yet, refuses to click the same label twice, and gives up
|
| 293 |
+
rather than flailing. It will not generalise the way a model can.
|
| 294 |
"""
|
| 295 |
|
| 296 |
name = "planner"
|
|
|
|
| 328 |
reason=f"matched {target!r} in the element index")
|
| 329 |
raise GoalUnreachable(f"no element name found in the goal {goal!r}")
|
| 330 |
|
|
|
|
| 331 |
@staticmethod
|
| 332 |
def _target_from(goal: str) -> str | None:
|
| 333 |
"""Best effort: quoted text wins, else the last capitalised word."""
|
|
|
|
| 338 |
return words[-1].strip(".,!?") if words else None
|
| 339 |
|
| 340 |
|
| 341 |
+
class ModelBrain:
|
| 342 |
+
"""Shared plumbing for model-backed brains: menu, prompt, lenient parsing.
|
| 343 |
|
| 344 |
+
Subclasses implement :meth:`_generate`. ``requires_index`` marks brains that
|
| 345 |
+
cannot decide without an element index (a text-only planner): for those, an
|
| 346 |
+
empty screen is a hard stop rather than a prompt.
|
| 347 |
"""
|
| 348 |
|
| 349 |
+
name = "model"
|
| 350 |
+
requires_index = False
|
| 351 |
|
| 352 |
+
def __init__(self, model_id: str, *, max_menu_items: int = 12,
|
| 353 |
+
max_new_tokens: int = 64) -> None:
|
| 354 |
self.model_id = model_id
|
| 355 |
+
self.max_menu_items = max_menu_items
|
| 356 |
self.max_new_tokens = max_new_tokens
|
| 357 |
+
self.last_prompt: str | None = None
|
| 358 |
+
self.last_reply: str | None = None
|
| 359 |
+
# "what I already tried", newest last; each entry's outcome is filled in at
|
| 360 |
+
# the next decision, when the new observation can be compared to the old one
|
| 361 |
+
self.history: list[str] = []
|
| 362 |
+
self._last_fingerprint: tuple | None = None
|
| 363 |
+
|
| 364 |
+
@staticmethod
|
| 365 |
+
def _fingerprint(observation: Observation) -> tuple:
|
| 366 |
+
return (tuple(element.label for element in observation.elements),
|
| 367 |
+
observation.text[:200])
|
| 368 |
+
|
| 369 |
+
def build_prompt(self, goal: str, menu: DecisionMenu, observation: Observation,
|
| 370 |
+
*, history: Sequence[str] = (), screen_changed: bool = True) -> str:
|
| 371 |
+
"""A short prompt with a strict output shape.
|
| 372 |
+
|
| 373 |
+
Deliberately contains placeholders (``<number>``) rather than worked
|
| 374 |
+
examples: the weakest models echo the prompt back, and an echoed
|
| 375 |
+
placeholder fails validation instead of being mistaken for a decision.
|
| 376 |
+
"""
|
| 377 |
+
lines = [
|
| 378 |
+
"You are controlling a computer. Choose exactly one action.",
|
| 379 |
+
"",
|
| 380 |
+
"Reply with one JSON object and nothing else, in this shape:",
|
| 381 |
+
' {"kind": "click", "index": <number from the list>, "reason": "<brief>"}',
|
| 382 |
+
' {"kind": "done", "reason": "<brief>"}',
|
| 383 |
+
"Use done only when the goal is already met.",
|
| 384 |
+
"",
|
| 385 |
+
f"Goal: {goal}",
|
| 386 |
+
]
|
| 387 |
+
if observation.text:
|
| 388 |
+
lines += ["", f"Text on screen: {observation.text[:300]}"]
|
| 389 |
+
lines += ["", f"Clickable items ({len(menu)}):"]
|
| 390 |
+
lines.append(menu.render() if menu else " (none detected)")
|
| 391 |
+
if history:
|
| 392 |
+
lines += ["", "Already attempted - do NOT repeat these, they did not work:"]
|
| 393 |
+
lines += [f" - {entry}" for entry in history]
|
| 394 |
+
if not screen_changed:
|
| 395 |
+
lines += ["", "The screen did not change after your last action. "
|
| 396 |
+
"Choose a DIFFERENT action."]
|
| 397 |
+
return "\n".join(lines)
|
| 398 |
|
| 399 |
+
def decide(self, goal: str, observation: Observation) -> Action:
|
| 400 |
+
menu = DecisionMenu(observation, max_items=self.max_menu_items)
|
| 401 |
+
if self.requires_index and not menu:
|
| 402 |
+
raise GoalUnreachable(
|
| 403 |
+
"no clickable elements on screen - is screen capture and OCR working?"
|
| 404 |
+
)
|
| 405 |
+
fingerprint = self._fingerprint(observation)
|
| 406 |
+
screen_changed = (self._last_fingerprint is None
|
| 407 |
+
or fingerprint != self._last_fingerprint)
|
| 408 |
+
|
| 409 |
+
# Finalise the previous attempt first: only now do we know whether it changed
|
| 410 |
+
# anything, and "you already tried this and nothing happened" is the single most
|
| 411 |
+
# useful thing to tell a small model.
|
| 412 |
+
if self.history:
|
| 413 |
+
previous = self.history[-1].split(" -> ")[0]
|
| 414 |
+
self.history[-1] = (
|
| 415 |
+
f"{previous} -> "
|
| 416 |
+
+ ("the screen changed" if screen_changed else "the screen did NOT change")
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
self.last_prompt = self.build_prompt(
|
| 420 |
+
goal, menu, observation, history=tuple(self.history),
|
| 421 |
+
screen_changed=screen_changed,
|
| 422 |
)
|
| 423 |
+
self.last_reply = self._generate(self.last_prompt, observation)
|
| 424 |
+
action = parse_decision(self.last_reply, observation,
|
| 425 |
+
max_menu_items=self.max_menu_items)
|
| 426 |
+
self.history.append(action.describe())
|
| 427 |
+
del self.history[:-3]
|
| 428 |
+
self._last_fingerprint = fingerprint
|
| 429 |
+
return action
|
| 430 |
|
| 431 |
def _generate(self, prompt: str, observation: Observation) -> str:
|
| 432 |
raise NotImplementedError(
|
| 433 |
+
"subclass ModelBrain and implement _generate(prompt, observation)"
|
|
|
|
|
|
|
| 434 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tiny_computer_agent/demo.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Synthetic screens for offline demos and tests.
|
| 2 |
+
|
| 3 |
+
No display required: this module draws a small fake desktop - a top bar with Files
|
| 4 |
+
and Terminal, plus a Save dialog with a file-name field and Save/Cancel buttons - so
|
| 5 |
+
the whole pipeline (pixels, OCR, index, menu, model, action) can be exercised on a
|
| 6 |
+
headless machine, in CI, or before you let an agent near your real desktop.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import glob
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _font(size: int) -> Any:
|
| 17 |
+
from PIL import ImageFont
|
| 18 |
+
|
| 19 |
+
candidates = [
|
| 20 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
| 21 |
+
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
| 22 |
+
"C:/Windows/Fonts/arial.ttf",
|
| 23 |
+
] + glob.glob("/usr/share/fonts/**/*Regular*.ttf", recursive=True)
|
| 24 |
+
for path in candidates:
|
| 25 |
+
try:
|
| 26 |
+
return ImageFont.truetype(path, size)
|
| 27 |
+
except Exception:
|
| 28 |
+
continue
|
| 29 |
+
return ImageFont.load_default()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def fake_desktop(width: int = 720, height: int = 460) -> Any:
|
| 33 |
+
"""Draw a screenshot-like image: a desktop with an unsaved Save dialog."""
|
| 34 |
+
from PIL import Image, ImageDraw
|
| 35 |
+
|
| 36 |
+
image = Image.new("RGB", (width, height), (245, 246, 248))
|
| 37 |
+
draw = ImageDraw.Draw(image)
|
| 38 |
+
draw.rectangle([0, 0, width, 28], fill=(38, 40, 48))
|
| 39 |
+
draw.text((12, 7), "Files", fill="white", font=_font(15))
|
| 40 |
+
draw.text((90, 7), "Terminal", fill="white", font=_font(15))
|
| 41 |
+
draw.rectangle([180, 90, 540, 330], fill="white", outline=(120, 120, 130), width=2)
|
| 42 |
+
draw.text((200, 108), "Untitled document - unsaved changes",
|
| 43 |
+
fill=(30, 30, 30), font=_font(16))
|
| 44 |
+
draw.text((200, 170), "File name", fill=(60, 60, 60), font=_font(15))
|
| 45 |
+
draw.rectangle([200, 195, 520, 225], fill=(252, 252, 252),
|
| 46 |
+
outline=(120, 120, 130), width=2)
|
| 47 |
+
draw.rectangle([200, 265, 380, 300], fill=(66, 111, 220),
|
| 48 |
+
outline=(40, 70, 160), width=2)
|
| 49 |
+
draw.text((275, 274), "Save", fill="white", font=_font(16))
|
| 50 |
+
draw.rectangle([400, 265, 520, 300], fill=(230, 230, 235),
|
| 51 |
+
outline=(120, 120, 130), width=2)
|
| 52 |
+
draw.text((440, 274), "Cancel", fill=(30, 30, 30), font=_font(16))
|
| 53 |
+
return image
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def write_fake_desktop(path: str | Path) -> Path:
|
| 57 |
+
"""Save :func:`fake_desktop` to a PNG (handy for ``--image``)."""
|
| 58 |
+
target = Path(path)
|
| 59 |
+
fake_desktop().save(target)
|
| 60 |
+
return target
|
tiny_computer_agent/desktop.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wiring for a real desktop: preflight checks, then one-call agent construction.
|
| 2 |
+
|
| 3 |
+
This module exists because "make it work on my computer" is mostly a diagnosis
|
| 4 |
+
problem. Rather than failing deep inside an episode, :func:`doctor` reports exactly
|
| 5 |
+
which piece is missing - display, capture backend, input backend, OCR engine, model
|
| 6 |
+
runtime - and what to do about each.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import importlib.util
|
| 12 |
+
import os
|
| 13 |
+
import platform
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
from typing import Any, Callable, Iterable, Sequence
|
| 16 |
+
|
| 17 |
+
from tiny_computer_agent.act import DryRunExecutor, Executor, PyAutoGuiExecutor
|
| 18 |
+
from tiny_computer_agent.brain import Brain, PlannerBrain
|
| 19 |
+
from tiny_computer_agent.loop import AgentLoop, Step
|
| 20 |
+
from tiny_computer_agent.observe import GrabScreen, ScreenSource
|
| 21 |
+
from tiny_computer_agent.perception import (
|
| 22 |
+
Detector, IndexedScreen, detector_available, make_detector,
|
| 23 |
+
)
|
| 24 |
+
from tiny_computer_agent.safety import Policy
|
| 25 |
+
|
| 26 |
+
DEFAULT_KILL_SWITCH = "/tmp/tiny-computer-agent.STOP"
|
| 27 |
+
BRAIN_NAMES = ("planner", "text-planner", "vlm")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@dataclass(frozen=True)
|
| 31 |
+
class Check:
|
| 32 |
+
"""One preflight result: what was checked, whether it works, and how to fix it."""
|
| 33 |
+
|
| 34 |
+
name: str
|
| 35 |
+
ok: bool
|
| 36 |
+
detail: str
|
| 37 |
+
fix: str = ""
|
| 38 |
+
required: bool = True
|
| 39 |
+
|
| 40 |
+
def render(self) -> str:
|
| 41 |
+
marker = "ok " if self.ok else ("FAIL" if self.required else "warn")
|
| 42 |
+
line = f" [{marker}] {self.name}: {self.detail}"
|
| 43 |
+
if not self.ok and self.fix:
|
| 44 |
+
line += f"\n fix: {self.fix}"
|
| 45 |
+
return line
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _installed(module: str) -> bool:
|
| 49 |
+
return importlib.util.find_spec(module) is not None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def display_available() -> tuple[bool, str]:
|
| 53 |
+
"""Whether a windowing session looks reachable from this process."""
|
| 54 |
+
system = platform.system()
|
| 55 |
+
if system in ("Darwin", "Windows"):
|
| 56 |
+
return True, f"{system} desktop session assumed"
|
| 57 |
+
display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
|
| 58 |
+
if display:
|
| 59 |
+
return True, f"DISPLAY={display}"
|
| 60 |
+
return False, "no DISPLAY or WAYLAND_DISPLAY set (headless container or SSH session)"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def run_checks(*, brain: str = "planner", model_id: str | None = None,
|
| 64 |
+
detector: str = "ocr", real_input: bool = False,
|
| 65 |
+
kill_switch_path: str | None = DEFAULT_KILL_SWITCH) -> list[Check]:
|
| 66 |
+
"""Every precondition for driving a real desktop, in one list."""
|
| 67 |
+
checks: list[Check] = []
|
| 68 |
+
|
| 69 |
+
ok, detail = display_available()
|
| 70 |
+
checks.append(Check(
|
| 71 |
+
"display server", ok, detail,
|
| 72 |
+
fix="run this on the desktop session itself (a headless server cannot be driven)",
|
| 73 |
+
))
|
| 74 |
+
|
| 75 |
+
checks.append(Check(
|
| 76 |
+
"pillow", _installed("PIL"),
|
| 77 |
+
"installed" if _installed("PIL") else "not installed",
|
| 78 |
+
fix="pip install 'tiny-computer-agent[desktop]'",
|
| 79 |
+
))
|
| 80 |
+
|
| 81 |
+
has_mss = _installed("mss")
|
| 82 |
+
checks.append(Check(
|
| 83 |
+
"screen capture", has_mss or _installed("PIL"),
|
| 84 |
+
"mss (fast, multi-monitor)" if has_mss else "pillow ImageGrab fallback",
|
| 85 |
+
fix="pip install mss for faster multi-monitor capture",
|
| 86 |
+
required=False,
|
| 87 |
+
))
|
| 88 |
+
|
| 89 |
+
has_pyautogui = _installed("pyautogui")
|
| 90 |
+
checks.append(Check(
|
| 91 |
+
"input backend (pyautogui)", has_pyautogui,
|
| 92 |
+
"installed" if has_pyautogui else "not installed",
|
| 93 |
+
fix="pip install 'tiny-computer-agent[desktop]'",
|
| 94 |
+
required=real_input,
|
| 95 |
+
))
|
| 96 |
+
|
| 97 |
+
detector_ok, detector_detail = detector_available(detector)
|
| 98 |
+
checks.append(Check(
|
| 99 |
+
f"element detector ({detector})", detector_ok, detector_detail,
|
| 100 |
+
fix="pip install 'tiny-computer-agent[ocr]'",
|
| 101 |
+
))
|
| 102 |
+
|
| 103 |
+
if brain == "planner":
|
| 104 |
+
checks.append(Check(
|
| 105 |
+
"model runtime", True,
|
| 106 |
+
"not needed: the planner is rule-based (no weights, no download)",
|
| 107 |
+
required=False,
|
| 108 |
+
))
|
| 109 |
+
else:
|
| 110 |
+
from tiny_computer_agent.models import (
|
| 111 |
+
DEFAULT_TEXT_MODEL, DEFAULT_VISION_MODEL, model_cached, model_dependencies,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
deps_ok, deps_detail = model_dependencies()
|
| 115 |
+
model = model_id or (DEFAULT_TEXT_MODEL if brain == "text-planner" else DEFAULT_VISION_MODEL)
|
| 116 |
+
checks.append(Check(
|
| 117 |
+
"model runtime", deps_ok, deps_detail,
|
| 118 |
+
fix="pip install 'tiny-computer-agent[vision]'",
|
| 119 |
+
))
|
| 120 |
+
checks.append(Check(
|
| 121 |
+
f"model weights ({model})",
|
| 122 |
+
model_cached(model),
|
| 123 |
+
"cached locally" if model_cached(model) else "not downloaded yet",
|
| 124 |
+
fix=f"first run will download {model} from the Hub (one time)",
|
| 125 |
+
required=False,
|
| 126 |
+
))
|
| 127 |
+
|
| 128 |
+
checks.append(Check(
|
| 129 |
+
"execution mode",
|
| 130 |
+
True,
|
| 131 |
+
"REAL mouse and keyboard input" if real_input else "dry run (nothing is clicked)",
|
| 132 |
+
fix="pass --real-input AND set TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1",
|
| 133 |
+
required=False,
|
| 134 |
+
))
|
| 135 |
+
|
| 136 |
+
checks.append(Check(
|
| 137 |
+
"kill switch", True,
|
| 138 |
+
f"create {kill_switch_path} to abort the next action"
|
| 139 |
+
if kill_switch_path else "disabled",
|
| 140 |
+
required=False,
|
| 141 |
+
))
|
| 142 |
+
return checks
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def doctor(*, brain: str = "planner", model_id: str | None = None,
|
| 146 |
+
detector: str = "ocr", real_input: bool = False,
|
| 147 |
+
kill_switch_path: str | None = DEFAULT_KILL_SWITCH) -> str:
|
| 148 |
+
"""A human-readable preflight report."""
|
| 149 |
+
checks = run_checks(brain=brain, model_id=model_id, detector=detector,
|
| 150 |
+
real_input=real_input, kill_switch_path=kill_switch_path)
|
| 151 |
+
blocking = [c for c in checks if c.required and not c.ok]
|
| 152 |
+
lines = [
|
| 153 |
+
f"tiny-computer-agent preflight ({platform.system()} {platform.machine()}, python {platform.python_version()})",
|
| 154 |
+
"",
|
| 155 |
+
*(c.render() for c in checks),
|
| 156 |
+
"",
|
| 157 |
+
]
|
| 158 |
+
if blocking:
|
| 159 |
+
lines.append(f"NOT READY: {len(blocking)} blocking problem(s): "
|
| 160 |
+
+ ", ".join(c.name for c in blocking))
|
| 161 |
+
else:
|
| 162 |
+
lines.append("READY: the harness can drive this desktop.")
|
| 163 |
+
if not real_input:
|
| 164 |
+
lines.append("Dry run is on: nothing will be clicked. Read the plan first.")
|
| 165 |
+
return "\n".join(lines)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def build_agent(*, brain: Brain | str | None = None, screen: ScreenSource | None = None,
|
| 169 |
+
detector: Detector | str | None = None, dry_run: bool = True,
|
| 170 |
+
max_steps: int = 10, kill_switch_path: str | None = DEFAULT_KILL_SWITCH,
|
| 171 |
+
allow_grid_clicks: bool = False,
|
| 172 |
+
on_step: Callable[[Step], None] | None = None,
|
| 173 |
+
detector_options: dict[str, Any] | None = None) -> AgentLoop:
|
| 174 |
+
"""Wire a real desktop into an :class:`AgentLoop` in one call.
|
| 175 |
+
|
| 176 |
+
Defaults are the safe ones: dry run, a step budget of 10, and a kill-switch file
|
| 177 |
+
on disk that aborts the next action if it appears. ``screen``/``detector``/
|
| 178 |
+
``brain`` can be injected, which is how the tests drive this without a desktop.
|
| 179 |
+
"""
|
| 180 |
+
if detector is None or isinstance(detector, str):
|
| 181 |
+
detector = make_detector(detector or "ocr", **(detector_options or {}))
|
| 182 |
+
if screen is None:
|
| 183 |
+
screen = IndexedScreen(GrabScreen(), detector)
|
| 184 |
+
if brain is None or isinstance(brain, str):
|
| 185 |
+
name = brain or "planner"
|
| 186 |
+
if name == "planner":
|
| 187 |
+
brain = PlannerBrain()
|
| 188 |
+
else:
|
| 189 |
+
from tiny_computer_agent.models import make_model_brain
|
| 190 |
+
|
| 191 |
+
brain = make_model_brain(name)
|
| 192 |
+
executor: Executor = DryRunExecutor() if dry_run else PyAutoGuiExecutor()
|
| 193 |
+
policy = Policy(dry_run=dry_run, step_budget=max_steps,
|
| 194 |
+
kill_switch_path=kill_switch_path,
|
| 195 |
+
allow_grid_clicks=allow_grid_clicks)
|
| 196 |
+
return AgentLoop(screen=screen, brain=brain, executor=executor, policy=policy,
|
| 197 |
+
on_step=on_step)
|
tiny_computer_agent/loop.py
CHANGED
|
@@ -74,14 +74,16 @@ class AgentLoop:
|
|
| 74 |
|
| 75 |
* every action is one of six validated verbs
|
| 76 |
* nothing executes until policy has passed it
|
| 77 |
-
* the episode ends on a step budget, a kill switch,
|
|
|
|
| 78 |
* the screen is re-read after every action, so the next decision sees the
|
| 79 |
consequence of the last one
|
| 80 |
"""
|
| 81 |
|
| 82 |
def __init__(self, *, screen: ScreenSource, brain: Brain,
|
| 83 |
executor: Executor | None = None, policy: Policy | None = None,
|
| 84 |
-
on_step: Callable[[Step], None] | None = None
|
|
|
|
| 85 |
self.screen = screen
|
| 86 |
self.brain = brain
|
| 87 |
self.policy = policy or Policy()
|
|
@@ -89,11 +91,21 @@ class AgentLoop:
|
|
| 89 |
executor = DryRunExecutor() if self.policy.dry_run else PyAutoGuiExecutor()
|
| 90 |
self.executor = executor
|
| 91 |
self.on_step = on_step
|
|
|
|
| 92 |
|
| 93 |
def run(self, goal: str, *, max_steps: int | None = None) -> Episode:
|
| 94 |
budget = max_steps or self.policy.step_budget
|
| 95 |
episode = Episode(goal=goal, stop_reason=f"step budget of {budget} exhausted")
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
for index in range(budget):
|
| 99 |
if self.policy.should_stop():
|
|
@@ -129,6 +141,21 @@ class AgentLoop:
|
|
| 129 |
episode.stop_reason = f"policy refusal: {exc}"
|
| 130 |
break
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
try:
|
| 133 |
result = self.executor.execute(action, observation)
|
| 134 |
except Exception as exc:
|
|
@@ -142,10 +169,22 @@ class AgentLoop:
|
|
| 142 |
episode.stop_reason = f"action failed: {result.note}"
|
| 143 |
break
|
| 144 |
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
return episode
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
def _record(self, episode: Episode, step: Step) -> None:
|
| 150 |
episode.steps.append(step)
|
| 151 |
if self.on_step is not None:
|
|
|
|
| 74 |
|
| 75 |
* every action is one of six validated verbs
|
| 76 |
* nothing executes until policy has passed it
|
| 77 |
+
* the episode ends on a step budget, a kill switch, a brain error, or a
|
| 78 |
+
failed screen capture (reported as a step, never as a traceback)
|
| 79 |
* the screen is re-read after every action, so the next decision sees the
|
| 80 |
consequence of the last one
|
| 81 |
"""
|
| 82 |
|
| 83 |
def __init__(self, *, screen: ScreenSource, brain: Brain,
|
| 84 |
executor: Executor | None = None, policy: Policy | None = None,
|
| 85 |
+
on_step: Callable[[Step], None] | None = None,
|
| 86 |
+
max_repeats: int = 1) -> None:
|
| 87 |
self.screen = screen
|
| 88 |
self.brain = brain
|
| 89 |
self.policy = policy or Policy()
|
|
|
|
| 91 |
executor = DryRunExecutor() if self.policy.dry_run else PyAutoGuiExecutor()
|
| 92 |
self.executor = executor
|
| 93 |
self.on_step = on_step
|
| 94 |
+
self.max_repeats = max_repeats
|
| 95 |
|
| 96 |
def run(self, goal: str, *, max_steps: int | None = None) -> Episode:
|
| 97 |
budget = max_steps or self.policy.step_budget
|
| 98 |
episode = Episode(goal=goal, stop_reason=f"step budget of {budget} exhausted")
|
| 99 |
+
# A small model that repeats an action against an unchanged screen is stuck,
|
| 100 |
+
# not persistent. Tolerate `max_repeats` such repeats, then stop - burning the
|
| 101 |
+
# whole budget on the same click teaches the user nothing.
|
| 102 |
+
previous: tuple[tuple, str] | None = None
|
| 103 |
+
repeats = 0
|
| 104 |
+
try:
|
| 105 |
+
observation = self.screen.capture()
|
| 106 |
+
except Exception as exc:
|
| 107 |
+
episode.stop_reason = f"capture failed: {type(exc).__name__}: {exc}"
|
| 108 |
+
return episode
|
| 109 |
|
| 110 |
for index in range(budget):
|
| 111 |
if self.policy.should_stop():
|
|
|
|
| 141 |
episode.stop_reason = f"policy refusal: {exc}"
|
| 142 |
break
|
| 143 |
|
| 144 |
+
signature = (self._fingerprint(observation), action.describe())
|
| 145 |
+
repeats = repeats + 1 if signature == previous else 0
|
| 146 |
+
previous = signature
|
| 147 |
+
if repeats > self.max_repeats:
|
| 148 |
+
self._record(episode, Step(
|
| 149 |
+
index, observation, action=action,
|
| 150 |
+
error=f"no progress: repeating {action.describe()} "
|
| 151 |
+
f"against an unchanged screen",
|
| 152 |
+
))
|
| 153 |
+
episode.stop_reason = (
|
| 154 |
+
f"no progress after {repeats} identical actions on an unchanged "
|
| 155 |
+
f"screen ({action.kind} {action.label or action.text or ''})".strip()
|
| 156 |
+
)
|
| 157 |
+
break
|
| 158 |
+
|
| 159 |
try:
|
| 160 |
result = self.executor.execute(action, observation)
|
| 161 |
except Exception as exc:
|
|
|
|
| 169 |
episode.stop_reason = f"action failed: {result.note}"
|
| 170 |
break
|
| 171 |
|
| 172 |
+
try:
|
| 173 |
+
observation = self.screen.capture() # verify: what did that change?
|
| 174 |
+
except Exception as exc:
|
| 175 |
+
self._record(episode, Step(index + 1, observation,
|
| 176 |
+
error=f"capture failed: {type(exc).__name__}: {exc}"))
|
| 177 |
+
episode.stop_reason = "perception failed mid-episode"
|
| 178 |
+
break
|
| 179 |
|
| 180 |
return episode
|
| 181 |
|
| 182 |
+
@staticmethod
|
| 183 |
+
def _fingerprint(observation: Observation) -> tuple:
|
| 184 |
+
"""What the agent can see: enough to tell "nothing changed" from "it worked"."""
|
| 185 |
+
return (tuple(element.label for element in observation.elements),
|
| 186 |
+
observation.text[:200])
|
| 187 |
+
|
| 188 |
def _record(self, episode: Episode, step: Step) -> None:
|
| 189 |
episode.steps.append(step)
|
| 190 |
if self.on_step is not None:
|
tiny_computer_agent/models.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Real (tiny) model backends, small enough to run on a laptop CPU.
|
| 2 |
+
|
| 3 |
+
Two of them, because they trade off differently:
|
| 4 |
+
|
| 5 |
+
* :class:`TextPlannerBrain` - a ~0.5B *text* model over the OCR index. No vision,
|
| 6 |
+
no big download, and the element index does all the grounding, which is why it
|
| 7 |
+
works at this size at all.
|
| 8 |
+
* :class:`VLMBrain` - a tiny *vision* model that sees the screenshot itself
|
| 9 |
+
(256M-class models can read a button label from pixels, but reason poorly, so the
|
| 10 |
+
menu still carries most of the decision).
|
| 11 |
+
|
| 12 |
+
Both inherit menu building, prompt shape and lenient parsing from
|
| 13 |
+
:class:`~tiny_computer_agent.brain.ModelBrain`, so swapping weights never changes the
|
| 14 |
+
harness contract.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
from tiny_computer_agent.brain import BrainError, ModelBrain
|
| 23 |
+
from tiny_computer_agent.observe import Observation
|
| 24 |
+
|
| 25 |
+
DEFAULT_TEXT_MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
|
| 26 |
+
DEFAULT_VISION_MODEL = "HuggingFaceTB/SmolVLM-256M-Instruct"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def resolve_device(prefer: str | None = None) -> str:
|
| 30 |
+
"""Pick a torch device: explicit preference, then CUDA, then CPU."""
|
| 31 |
+
try:
|
| 32 |
+
import torch
|
| 33 |
+
except ImportError: # pragma: no cover - depends on the machine
|
| 34 |
+
raise BrainError("model backends need torch: pip install 'tiny-computer-agent[vision]'")
|
| 35 |
+
if prefer:
|
| 36 |
+
return prefer
|
| 37 |
+
return "cuda" if torch.cuda.is_available() else "cpu"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def pick_dtype(device: str) -> Any:
|
| 41 |
+
import torch
|
| 42 |
+
|
| 43 |
+
if device.startswith("cuda"):
|
| 44 |
+
return torch.bfloat16
|
| 45 |
+
return torch.float32
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def model_dependencies() -> tuple[bool, str]:
|
| 49 |
+
"""(available, detail) for preflight and the doctor command."""
|
| 50 |
+
try:
|
| 51 |
+
import transformers
|
| 52 |
+
except ImportError:
|
| 53 |
+
return False, "transformers is not installed (pip install 'tiny-computer-agent[vision]')"
|
| 54 |
+
try:
|
| 55 |
+
import torch
|
| 56 |
+
except ImportError:
|
| 57 |
+
return False, "torch is not installed (pip install 'tiny-computer-agent[vision]')"
|
| 58 |
+
return True, f"transformers {transformers.__version__}, torch {torch.__version__}"
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def model_cached(model_id: str) -> bool:
|
| 62 |
+
"""True when the weights are already in the local Hugging Face cache."""
|
| 63 |
+
try:
|
| 64 |
+
from huggingface_hub import constants
|
| 65 |
+
except ImportError: # pragma: no cover
|
| 66 |
+
return False
|
| 67 |
+
folder = os.path.join(constants.HF_HUB_CACHE, "models--" + model_id.replace("/", "--"))
|
| 68 |
+
return os.path.isdir(folder)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class TextPlannerBrain(ModelBrain):
|
| 72 |
+
"""Causal-LM planner over the OCR index: no pixels in, one menu number out."""
|
| 73 |
+
|
| 74 |
+
name = "text-planner"
|
| 75 |
+
requires_index = True
|
| 76 |
+
|
| 77 |
+
def __init__(self, model_id: str = DEFAULT_TEXT_MODEL, *, device: str | None = None,
|
| 78 |
+
**kwargs: Any) -> None:
|
| 79 |
+
super().__init__(model_id, **kwargs)
|
| 80 |
+
self.device = device
|
| 81 |
+
self._tokenizer: Any = None
|
| 82 |
+
self._model: Any = None
|
| 83 |
+
|
| 84 |
+
def _load(self) -> None:
|
| 85 |
+
if self._model is not None:
|
| 86 |
+
return
|
| 87 |
+
try:
|
| 88 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 89 |
+
except ImportError as exc: # pragma: no cover - depends on the machine
|
| 90 |
+
raise BrainError(
|
| 91 |
+
"text planner needs transformers and torch: "
|
| 92 |
+
"pip install 'tiny-computer-agent[vision]'"
|
| 93 |
+
) from exc
|
| 94 |
+
self.device = resolve_device(self.device)
|
| 95 |
+
self._tokenizer = AutoTokenizer.from_pretrained(self.model_id)
|
| 96 |
+
self._model = AutoModelForCausalLM.from_pretrained(
|
| 97 |
+
self.model_id, dtype=pick_dtype(self.device)
|
| 98 |
+
).to(self.device).eval()
|
| 99 |
+
|
| 100 |
+
def unload(self) -> None:
|
| 101 |
+
self._model = None
|
| 102 |
+
self._tokenizer = None
|
| 103 |
+
|
| 104 |
+
def _generate(self, prompt: str, observation: Observation) -> str:
|
| 105 |
+
import torch
|
| 106 |
+
|
| 107 |
+
self._load()
|
| 108 |
+
messages = [{"role": "user", "content": prompt}]
|
| 109 |
+
text = self._tokenizer.apply_chat_template(
|
| 110 |
+
messages, add_generation_prompt=True, tokenize=False
|
| 111 |
+
)
|
| 112 |
+
inputs = self._tokenizer(text, return_tensors="pt").to(self.device)
|
| 113 |
+
with torch.inference_mode():
|
| 114 |
+
output = self._model.generate(
|
| 115 |
+
**inputs,
|
| 116 |
+
max_new_tokens=self.max_new_tokens,
|
| 117 |
+
do_sample=False,
|
| 118 |
+
pad_token_id=self._tokenizer.eos_token_id,
|
| 119 |
+
)
|
| 120 |
+
return self._tokenizer.decode(
|
| 121 |
+
output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
|
| 122 |
+
).strip()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
class VLMBrain(ModelBrain):
|
| 126 |
+
"""Tiny vision-language model: sees the screenshot, answers with a menu number."""
|
| 127 |
+
|
| 128 |
+
name = "vlm"
|
| 129 |
+
requires_index = False
|
| 130 |
+
|
| 131 |
+
def __init__(self, model_id: str = DEFAULT_VISION_MODEL, *, device: str | None = None,
|
| 132 |
+
**kwargs: Any) -> None:
|
| 133 |
+
super().__init__(model_id, **kwargs)
|
| 134 |
+
self.device = device
|
| 135 |
+
self._processor: Any = None
|
| 136 |
+
self._model: Any = None
|
| 137 |
+
|
| 138 |
+
def _load(self) -> None:
|
| 139 |
+
if self._model is not None:
|
| 140 |
+
return
|
| 141 |
+
try:
|
| 142 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 143 |
+
except ImportError as exc: # pragma: no cover - depends on the machine
|
| 144 |
+
raise BrainError(
|
| 145 |
+
"the vision backend needs transformers and torch: "
|
| 146 |
+
"pip install 'tiny-computer-agent[vision]'"
|
| 147 |
+
) from exc
|
| 148 |
+
self.device = resolve_device(self.device)
|
| 149 |
+
self._processor = AutoProcessor.from_pretrained(self.model_id)
|
| 150 |
+
self._model = AutoModelForImageTextToText.from_pretrained(
|
| 151 |
+
self.model_id, dtype=pick_dtype(self.device)
|
| 152 |
+
).to(self.device).eval()
|
| 153 |
+
|
| 154 |
+
def unload(self) -> None:
|
| 155 |
+
self._model = None
|
| 156 |
+
self._processor = None
|
| 157 |
+
|
| 158 |
+
def _generate(self, prompt: str, observation: Observation) -> str:
|
| 159 |
+
import torch
|
| 160 |
+
|
| 161 |
+
image = observation.frame
|
| 162 |
+
if image is None or isinstance(image, str):
|
| 163 |
+
raise BrainError(
|
| 164 |
+
"the vision backend needs a real screenshot but the observation carries "
|
| 165 |
+
f"{type(image).__name__} - run with --live, or use the text planner"
|
| 166 |
+
)
|
| 167 |
+
self._load()
|
| 168 |
+
messages = [{"role": "user", "content": [{"type": "image"}, {"type": "text", "text": prompt}]}]
|
| 169 |
+
text = self._processor.apply_chat_template(messages, add_generation_prompt=True)
|
| 170 |
+
inputs = self._processor(text=text, images=[image], return_tensors="pt").to(self.device)
|
| 171 |
+
with torch.inference_mode():
|
| 172 |
+
output = self._model.generate(
|
| 173 |
+
**inputs, max_new_tokens=self.max_new_tokens, do_sample=False
|
| 174 |
+
)
|
| 175 |
+
return self._processor.batch_decode(
|
| 176 |
+
output[:, inputs["input_ids"].shape[1]:], skip_special_tokens=True
|
| 177 |
+
)[0].strip()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
MODEL_BRAINS = {"text-planner": TextPlannerBrain, "vlm": VLMBrain}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def make_model_brain(name: str = "text-planner", **kwargs: Any) -> ModelBrain:
|
| 184 |
+
"""Build a model brain by name, with an actionable error for unknown names."""
|
| 185 |
+
if name not in MODEL_BRAINS:
|
| 186 |
+
raise BrainError(f"unknown brain {name!r}; expected one of {tuple(MODEL_BRAINS)}")
|
| 187 |
+
return MODEL_BRAINS[name](**kwargs)
|
tiny_computer_agent/observe.py
CHANGED
|
@@ -1,15 +1,30 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
The harness never asks the model to *find* anything. OCR or the accessibility tree
|
| 4 |
-
produces a labelled element index
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
from dataclasses import dataclass
|
|
|
|
| 10 |
from typing import Any, Protocol, Sequence
|
| 11 |
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
@dataclass(frozen=True)
|
| 14 |
class Element:
|
| 15 |
"""A clickable thing, located by the platform — not by the model."""
|
|
@@ -34,7 +49,8 @@ class Element:
|
|
| 34 |
return (self.x, self.y)
|
| 35 |
|
| 36 |
def matches(self, needle: str) -> bool:
|
| 37 |
-
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
@dataclass(frozen=True)
|
|
@@ -47,9 +63,15 @@ class Observation:
|
|
| 47 |
text: str = ""
|
| 48 |
|
| 49 |
def find(self, label: str) -> Element | None:
|
| 50 |
-
"""Exact label first, then substring; None if nothing matches.
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
if exact:
|
| 54 |
return exact[0]
|
| 55 |
loose = [e for e in self.elements if e.matches(label)]
|
|
@@ -102,40 +124,130 @@ class FakeScreen:
|
|
| 102 |
return observation
|
| 103 |
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
class ScreenUnavailable(RuntimeError):
|
| 106 |
"""Raised when real capture is requested but the machine cannot provide it."""
|
| 107 |
|
| 108 |
|
| 109 |
class GrabScreen:
|
| 110 |
-
"""
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
"""
|
| 116 |
|
| 117 |
-
def __init__(self, *, monitor: int = 1, max_width: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
self.monitor = monitor
|
| 119 |
self.max_width = max_width
|
|
|
|
|
|
|
|
|
|
| 120 |
self._index = 0
|
| 121 |
|
| 122 |
def capture(self) -> Observation:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
try:
|
| 124 |
import mss
|
|
|
|
|
|
|
|
|
|
| 125 |
from PIL import Image
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
raise ScreenUnavailable(
|
| 128 |
-
"
|
| 129 |
) from exc
|
|
|
|
|
|
|
| 130 |
try:
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Observation contract: screen frames and the element index built from them.
|
| 2 |
|
| 3 |
The harness never asks the model to *find* anything. OCR or the accessibility tree
|
| 4 |
+
produces a labelled element index (see :mod:`tiny_computer_agent.perception`); the
|
| 5 |
+
model only chooses among those labels.
|
| 6 |
+
|
| 7 |
+
Every :class:`Element` carries **absolute screen coordinates**, whoever built it.
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
+
import sys
|
| 13 |
from dataclasses import dataclass
|
| 14 |
+
from pathlib import Path
|
| 15 |
from typing import Any, Protocol, Sequence
|
| 16 |
|
| 17 |
|
| 18 |
+
def normalise_label(text: str) -> str:
|
| 19 |
+
"""Lower-case and keep only letters and digits.
|
| 20 |
+
|
| 21 |
+
OCR routinely loses or invents spaces ("Untitleddocument-unsavedchanges") and
|
| 22 |
+
models add punctuation ("Save."), so labels are compared on their alphanumeric
|
| 23 |
+
core - otherwise nothing a model says ever matches what was seen on screen.
|
| 24 |
+
"""
|
| 25 |
+
return "".join(character for character in text.lower() if character.isalnum())
|
| 26 |
+
|
| 27 |
+
|
| 28 |
@dataclass(frozen=True)
|
| 29 |
class Element:
|
| 30 |
"""A clickable thing, located by the platform — not by the model."""
|
|
|
|
| 49 |
return (self.x, self.y)
|
| 50 |
|
| 51 |
def matches(self, needle: str) -> bool:
|
| 52 |
+
wanted = normalise_label(needle)
|
| 53 |
+
return bool(wanted) and wanted in normalise_label(self.label)
|
| 54 |
|
| 55 |
|
| 56 |
@dataclass(frozen=True)
|
|
|
|
| 63 |
text: str = ""
|
| 64 |
|
| 65 |
def find(self, label: str) -> Element | None:
|
| 66 |
+
"""Exact label first, then substring; None if nothing matches.
|
| 67 |
+
|
| 68 |
+
Comparison is on normalised text (see :func:`normalise_label`), so
|
| 69 |
+
``find("Save")`` still matches a label the OCR rendered as ``"Save ."``.
|
| 70 |
+
"""
|
| 71 |
+
wanted = normalise_label(label)
|
| 72 |
+
if not wanted:
|
| 73 |
+
return None
|
| 74 |
+
exact = [e for e in self.elements if normalise_label(e.label) == wanted]
|
| 75 |
if exact:
|
| 76 |
return exact[0]
|
| 77 |
loose = [e for e in self.elements if e.matches(label)]
|
|
|
|
| 124 |
return observation
|
| 125 |
|
| 126 |
|
| 127 |
+
class ImageScreen:
|
| 128 |
+
"""A static screen source: replay one screenshot (or a sequence of them).
|
| 129 |
+
|
| 130 |
+
Lets the full pipeline - OCR, index, menu, model, action - be exercised with no
|
| 131 |
+
display at all, and lets you replay a captured failure offline. Accepts a path,
|
| 132 |
+
a PIL image, or a sequence of either.
|
| 133 |
+
"""
|
| 134 |
+
|
| 135 |
+
def __init__(self, source: Any) -> None:
|
| 136 |
+
from PIL import Image
|
| 137 |
+
|
| 138 |
+
frames = [source] if isinstance(source, (str, Path)) or not isinstance(source, (list, tuple)) else list(source)
|
| 139 |
+
self._images: list[Any] = []
|
| 140 |
+
for frame in frames:
|
| 141 |
+
if isinstance(frame, (str, Path)):
|
| 142 |
+
self._images.append(Image.open(str(frame)).convert("RGB"))
|
| 143 |
+
else:
|
| 144 |
+
self._images.append(frame)
|
| 145 |
+
if not self._images:
|
| 146 |
+
raise ValueError("ImageScreen needs at least one image")
|
| 147 |
+
self.captures = 0
|
| 148 |
+
|
| 149 |
+
def capture(self) -> Observation:
|
| 150 |
+
index = min(self.captures, len(self._images) - 1)
|
| 151 |
+
self.captures += 1
|
| 152 |
+
return Observation(index=self.captures - 1, frame=self._images[index], elements=(), text="")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
class ScreenUnavailable(RuntimeError):
|
| 156 |
"""Raised when real capture is requested but the machine cannot provide it."""
|
| 157 |
|
| 158 |
|
| 159 |
class GrabScreen:
|
| 160 |
+
"""Capture the real desktop as an image.
|
| 161 |
+
|
| 162 |
+
Prefers ``mss`` (fast, multi-monitor) and falls back to Pillow's ``ImageGrab``.
|
| 163 |
+
Caps are deliberately absent: a frame pixel must be a *screen* pixel, which is
|
| 164 |
+
what lets every detector produce Elements with absolute screen coordinates.
|
| 165 |
+
|
| 166 |
+
Two options break that identity, so both are explicit: ``monitor`` may select a
|
| 167 |
+
display whose origin is not (0, 0), and ``max_width`` downscales a wide capture
|
| 168 |
+
(faster OCR, and a smaller image for a model to look at).
|
| 169 |
+
:meth:`map_to_screen` converts a frame coordinate back to a real one, and
|
| 170 |
+
:class:`~tiny_computer_agent.perception.IndexedScreen` applies it to every
|
| 171 |
+
detected element, so a click never lands on the wrong pixel.
|
| 172 |
"""
|
| 173 |
|
| 174 |
+
def __init__(self, *, monitor: int = 1, max_width: int | None = None,
|
| 175 |
+
scale: float | None = None) -> None:
|
| 176 |
+
if monitor < 0:
|
| 177 |
+
raise ValueError("monitor must be 0 (all displays) or a positive index")
|
| 178 |
+
if max_width is not None and max_width < 1:
|
| 179 |
+
raise ValueError("max_width must be positive")
|
| 180 |
+
if scale is not None and scale <= 0:
|
| 181 |
+
raise ValueError("scale must be positive")
|
| 182 |
self.monitor = monitor
|
| 183 |
self.max_width = max_width
|
| 184 |
+
self.scale = scale
|
| 185 |
+
self.origin: tuple[int, int] = (0, 0)
|
| 186 |
+
self.frame_scale: float = 1.0
|
| 187 |
self._index = 0
|
| 188 |
|
| 189 |
def capture(self) -> Observation:
|
| 190 |
+
image, origin = self._grab()
|
| 191 |
+
native_width = image.width
|
| 192 |
+
if self.max_width is not None and image.width > self.max_width:
|
| 193 |
+
ratio = self.max_width / image.width
|
| 194 |
+
image = image.resize((self.max_width, max(1, round(image.height * ratio))))
|
| 195 |
+
if self.scale is not None:
|
| 196 |
+
self.frame_scale = float(self.scale)
|
| 197 |
+
else:
|
| 198 |
+
self.frame_scale = native_width / image.width
|
| 199 |
+
self.origin = origin
|
| 200 |
+
observation = Observation(index=self._index, frame=image, elements=(), text="")
|
| 201 |
+
self._index += 1
|
| 202 |
+
return observation
|
| 203 |
+
|
| 204 |
+
def map_to_screen(self, element: Element) -> Element:
|
| 205 |
+
"""Frame pixel -> absolute screen pixel, honouring the origin and any scale."""
|
| 206 |
+
if self.frame_scale == 1.0 and self.origin == (0, 0):
|
| 207 |
+
return element
|
| 208 |
+
factor = self.frame_scale
|
| 209 |
+
return Element(
|
| 210 |
+
label=element.label, kind=element.kind,
|
| 211 |
+
x=round(element.x * factor) + self.origin[0],
|
| 212 |
+
y=round(element.y * factor) + self.origin[1],
|
| 213 |
+
width=max(1, round(element.width * factor)),
|
| 214 |
+
height=max(1, round(element.height * factor)),
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
def _grab(self) -> tuple[Any, tuple[int, int]]:
|
| 218 |
+
"""(image, screen position of the image's top-left pixel)."""
|
| 219 |
try:
|
| 220 |
import mss
|
| 221 |
+
except ImportError:
|
| 222 |
+
return self._grab_with_pillow()
|
| 223 |
+
try:
|
| 224 |
from PIL import Image
|
| 225 |
+
|
| 226 |
+
with mss.mss() as sct:
|
| 227 |
+
monitors = sct.monitors
|
| 228 |
+
monitor = monitors[self.monitor] if self.monitor < len(monitors) else monitors[-1]
|
| 229 |
+
shot = sct.grab(monitor)
|
| 230 |
+
image = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
|
| 231 |
+
return image, (int(monitor.get("left", 0)), int(monitor.get("top", 0)))
|
| 232 |
+
except Exception as exc:
|
| 233 |
raise ScreenUnavailable(
|
| 234 |
+
f"screen capture failed (no display?): {type(exc).__name__}: {exc}"
|
| 235 |
) from exc
|
| 236 |
+
|
| 237 |
+
def _grab_with_pillow(self) -> tuple[Any, tuple[int, int]]:
|
| 238 |
try:
|
| 239 |
+
from PIL import ImageGrab
|
| 240 |
+
except ImportError as exc: # pragma: no cover - pillow is a desktop extra
|
| 241 |
+
raise ScreenUnavailable(
|
| 242 |
+
"screen capture needs mss or Pillow: pip install \'tiny-computer-agent[desktop]\'"
|
| 243 |
+
) from exc
|
| 244 |
+
try:
|
| 245 |
+
if sys.platform.startswith("win"):
|
| 246 |
+
image = ImageGrab.grab(all_screens=self.monitor == 0)
|
| 247 |
+
else:
|
| 248 |
+
image = ImageGrab.grab()
|
| 249 |
+
except Exception as exc:
|
| 250 |
+
raise ScreenUnavailable(
|
| 251 |
+
f"screen capture failed (no display?): {type(exc).__name__}: {exc}"
|
| 252 |
+
) from exc
|
| 253 |
+
return image.convert("RGB"), (0, 0)
|
tiny_computer_agent/perception.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turning pixels into a labelled element index.
|
| 2 |
+
|
| 3 |
+
This is the module that makes a *tiny* model viable. The model never has to guess
|
| 4 |
+
where anything is, because OCR has already turned the screen into a short list of
|
| 5 |
+
labels with coordinates - and the model only has to pick one.
|
| 6 |
+
|
| 7 |
+
Filters matter as much as the engine. Raw OCR returns dialog prose, fragmentary
|
| 8 |
+
debris and stray punctuation, and every extra line of noise is another chance for a
|
| 9 |
+
small model to pick the wrong element, so the index is cleaned aggressively.
|
| 10 |
+
|
| 11 |
+
Everything here is optional at import time: a missing OCR engine raises a clear
|
| 12 |
+
:class:`DetectorUnavailable` instead of breaking the harness.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from dataclasses import dataclass, field
|
| 18 |
+
from typing import Any, Protocol
|
| 19 |
+
|
| 20 |
+
from tiny_computer_agent.observe import Element, Observation, ScreenSource
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class DetectorUnavailable(RuntimeError):
|
| 24 |
+
"""Raised when an element detector is requested but cannot run on this machine."""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Detector(Protocol):
|
| 28 |
+
"""Turns one frame into labelled, clickable elements."""
|
| 29 |
+
|
| 30 |
+
name: str
|
| 31 |
+
|
| 32 |
+
def detect(self, image: Any) -> tuple[Element, ...]: ...
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class OcrDetector:
|
| 37 |
+
"""Indexes on-screen text with RapidOCR: ONNX, CPU, offline after install.
|
| 38 |
+
|
| 39 |
+
``max_label_chars`` is the important knob. Screen text includes paragraphs and
|
| 40 |
+
titles that are not widgets, and a model shown them will happily click prose -
|
| 41 |
+
which is exactly what a 0.5B planner did when dialog text sat in its menu. Short
|
| 42 |
+
strings are much more likely to be buttons and menu items, so the default is
|
| 43 |
+
deliberately tight.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
min_score: float = 0.6
|
| 47 |
+
max_label_chars: int = 24
|
| 48 |
+
min_label_chars: int = 2
|
| 49 |
+
name: str = "ocr"
|
| 50 |
+
_engine: Any = field(default=None, init=False, repr=False)
|
| 51 |
+
|
| 52 |
+
def _loaded(self) -> Any:
|
| 53 |
+
if self._engine is None:
|
| 54 |
+
try:
|
| 55 |
+
from rapidocr_onnxruntime import RapidOCR
|
| 56 |
+
except ImportError as exc: # pragma: no cover - depends on the machine
|
| 57 |
+
raise DetectorUnavailable(
|
| 58 |
+
"no OCR engine available: pip install 'tiny-computer-agent[ocr]'"
|
| 59 |
+
) from exc
|
| 60 |
+
self._engine = RapidOCR()
|
| 61 |
+
return self._engine
|
| 62 |
+
|
| 63 |
+
def detect(self, image: Any) -> tuple[Element, ...]:
|
| 64 |
+
import numpy as np
|
| 65 |
+
|
| 66 |
+
array = np.asarray(image.convert("RGB") if hasattr(image, "convert") else image)
|
| 67 |
+
result, _ = self._loaded()(array)
|
| 68 |
+
elements: list[Element] = []
|
| 69 |
+
for box, text, score in result or []:
|
| 70 |
+
element = self._to_element(box, text, float(score))
|
| 71 |
+
if element is not None:
|
| 72 |
+
elements.append(element)
|
| 73 |
+
return tuple(elements)
|
| 74 |
+
|
| 75 |
+
def _to_element(self, box: Any, text: Any, score: float) -> Element | None:
|
| 76 |
+
"""Convert one OCR box to an Element, or None if it is not a usable label."""
|
| 77 |
+
label = " ".join(str(text).split())
|
| 78 |
+
if score < self.min_score:
|
| 79 |
+
return None
|
| 80 |
+
if not self.min_label_chars <= len(label) <= self.max_label_chars:
|
| 81 |
+
return None
|
| 82 |
+
if not any(character.isalnum() for character in label):
|
| 83 |
+
return None
|
| 84 |
+
xs = [float(point[0]) for point in box]
|
| 85 |
+
ys = [float(point[1]) for point in box]
|
| 86 |
+
x, y = int(min(xs)), int(min(ys))
|
| 87 |
+
return Element(
|
| 88 |
+
label=label, kind="text",
|
| 89 |
+
x=x, y=y,
|
| 90 |
+
width=int(max(xs)) - x, height=int(max(ys)) - y,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@dataclass
|
| 95 |
+
class IndexedScreen:
|
| 96 |
+
"""Composes pixel capture with a detector, so ``capture()`` yields an index.
|
| 97 |
+
|
| 98 |
+
This is the wiring that turns the harness from a scripted demo into something
|
| 99 |
+
that can act on a real desktop.
|
| 100 |
+
"""
|
| 101 |
+
|
| 102 |
+
source: ScreenSource
|
| 103 |
+
detector: Detector
|
| 104 |
+
fill_text: bool = True
|
| 105 |
+
|
| 106 |
+
def capture(self) -> Observation:
|
| 107 |
+
observation = self.source.capture()
|
| 108 |
+
try:
|
| 109 |
+
elements = self.detector.detect(observation.frame)
|
| 110 |
+
except DetectorUnavailable:
|
| 111 |
+
raise
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
# A perception failure must not kill a run that is otherwise fine.
|
| 114 |
+
elements = ()
|
| 115 |
+
observation = Observation(
|
| 116 |
+
index=observation.index, frame=observation.frame,
|
| 117 |
+
elements=(), text=f"perception error: {type(exc).__name__}: {exc}",
|
| 118 |
+
)
|
| 119 |
+
return observation
|
| 120 |
+
mapper = getattr(self.source, "map_to_screen", None)
|
| 121 |
+
if mapper is not None:
|
| 122 |
+
# Frame pixels -> absolute screen pixels, so a click cannot drift.
|
| 123 |
+
elements = tuple(mapper(element) for element in elements)
|
| 124 |
+
text = observation.text
|
| 125 |
+
if self.fill_text and not text:
|
| 126 |
+
# Grid labels are coordinates, not visible text, so they are not page text.
|
| 127 |
+
text = " ".join(element.label for element in elements if element.kind != "grid")
|
| 128 |
+
return Observation(
|
| 129 |
+
index=observation.index, frame=observation.frame, elements=elements, text=text,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@dataclass
|
| 134 |
+
class GridDetector:
|
| 135 |
+
"""A numbered grid over the frame: click targets that need nothing installed.
|
| 136 |
+
|
| 137 |
+
The guarantee that matters. OCR finds text, the accessibility tree finds widgets,
|
| 138 |
+
but a canvas app, a game, a photo, a custom-drawn toolbar - anything with no text
|
| 139 |
+
- leaves a text index empty, and an agent with an empty index has *no legal
|
| 140 |
+
moves*. A grid always has moves, and a numbered menu is something even a 256M
|
| 141 |
+
model can choose from reliably.
|
| 142 |
+
"""
|
| 143 |
+
|
| 144 |
+
rows: int = 8
|
| 145 |
+
cols: int = 12
|
| 146 |
+
name: str = "grid"
|
| 147 |
+
|
| 148 |
+
def __post_init__(self) -> None:
|
| 149 |
+
if self.rows < 1 or self.cols < 1:
|
| 150 |
+
raise ValueError("rows and cols must be at least 1")
|
| 151 |
+
|
| 152 |
+
def detect(self, image: Any) -> tuple[Element, ...]:
|
| 153 |
+
width, height = image.size
|
| 154 |
+
cell_w = width / self.cols
|
| 155 |
+
cell_h = height / self.rows
|
| 156 |
+
elements = []
|
| 157 |
+
for row in range(self.rows):
|
| 158 |
+
for col in range(self.cols):
|
| 159 |
+
elements.append(Element(
|
| 160 |
+
label=f"cell {row * self.cols + col + 1}",
|
| 161 |
+
kind="grid",
|
| 162 |
+
x=round(col * cell_w), y=round(row * cell_h),
|
| 163 |
+
width=max(1, round(cell_w)), height=max(1, round(cell_h)),
|
| 164 |
+
))
|
| 165 |
+
return tuple(elements)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@dataclass
|
| 169 |
+
class AutoDetector:
|
| 170 |
+
"""OCR when it finds text, numbered cells when it does not.
|
| 171 |
+
|
| 172 |
+
This is the default, so the harness is useful on a machine with nothing installed
|
| 173 |
+
beyond Pillow - and gets better automatically once an OCR engine appears.
|
| 174 |
+
"""
|
| 175 |
+
|
| 176 |
+
name: str = "auto"
|
| 177 |
+
ocr: Detector = field(default_factory=OcrDetector)
|
| 178 |
+
grid: Detector = field(default_factory=GridDetector)
|
| 179 |
+
|
| 180 |
+
def detect(self, image: Any) -> tuple[Element, ...]:
|
| 181 |
+
try:
|
| 182 |
+
elements = self.ocr.detect(image)
|
| 183 |
+
except DetectorUnavailable:
|
| 184 |
+
return self.grid.detect(image)
|
| 185 |
+
return elements or self.grid.detect(image)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _ocr_available() -> tuple[bool, str]:
|
| 189 |
+
try:
|
| 190 |
+
import rapidocr_onnxruntime # noqa: F401
|
| 191 |
+
except ImportError:
|
| 192 |
+
return False, "rapidocr-onnxruntime is not installed (pip install 'tiny-computer-agent[ocr]')"
|
| 193 |
+
try:
|
| 194 |
+
import onnxruntime # noqa: F401
|
| 195 |
+
except ImportError: # pragma: no cover
|
| 196 |
+
return False, "onnxruntime is not installed"
|
| 197 |
+
return True, "rapidocr-onnxruntime ready (CPU, offline)"
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def detector_available(name: str = "auto") -> tuple[bool, str]:
|
| 201 |
+
"""(available, detail) for preflight and the ``doctor`` command."""
|
| 202 |
+
if name == "grid":
|
| 203 |
+
return True, "numbered grid cells (no dependencies)"
|
| 204 |
+
if name == "auto":
|
| 205 |
+
available, detail = _ocr_available()
|
| 206 |
+
if available:
|
| 207 |
+
return True, f"OCR when it finds text, grid otherwise ({detail})"
|
| 208 |
+
return True, "grid only: no OCR engine installed (pip install 'tiny-computer-agent[ocr]')"
|
| 209 |
+
if name != "ocr":
|
| 210 |
+
return False, f"unknown detector {name!r}"
|
| 211 |
+
return _ocr_available()
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def make_detector(name: str = "auto", **kwargs: Any) -> Detector:
|
| 215 |
+
"""Build a detector by name, with an actionable error when it cannot run."""
|
| 216 |
+
available, detail = detector_available(name)
|
| 217 |
+
if not available:
|
| 218 |
+
raise DetectorUnavailable(detail)
|
| 219 |
+
if name == "grid":
|
| 220 |
+
return GridDetector(**kwargs)
|
| 221 |
+
if name == "auto":
|
| 222 |
+
if _ocr_available()[0]:
|
| 223 |
+
return AutoDetector(**kwargs)
|
| 224 |
+
return GridDetector(**kwargs)
|
| 225 |
+
return OcrDetector(**kwargs)
|
tiny_computer_agent/safety.py
CHANGED
|
@@ -50,6 +50,11 @@ class Policy:
|
|
| 50 |
deny_keys : key chords that are never pressed
|
| 51 |
kill_switch_path : if this file exists, the episode aborts before the next
|
| 52 |
action — create it to stop a live run
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
"""
|
| 54 |
|
| 55 |
dry_run: bool = True
|
|
@@ -58,6 +63,7 @@ class Policy:
|
|
| 58 |
deny_labels: tuple[str, ...] = DEFAULT_DENY_LABELS
|
| 59 |
deny_keys: tuple[str, ...] = DEFAULT_DENY_KEYS
|
| 60 |
kill_switch_path: str | None = None
|
|
|
|
| 61 |
_compiled: tuple[Pattern[str], ...] = field(default=(), init=False, repr=False)
|
| 62 |
|
| 63 |
def __post_init__(self) -> None:
|
|
@@ -85,6 +91,15 @@ class Policy:
|
|
| 85 |
|
| 86 |
if action.kind == "click" and action.label:
|
| 87 |
element = observation.find(action.label) if observation is not None else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
haystack = (element.label if element is not None else action.label).lower()
|
| 89 |
for denied in self.deny_labels:
|
| 90 |
if denied.lower() in haystack:
|
|
|
|
| 50 |
deny_keys : key chords that are never pressed
|
| 51 |
kill_switch_path : if this file exists, the episode aborts before the next
|
| 52 |
action — create it to stop a live run
|
| 53 |
+
allow_grid_clicks : numbered grid cells are *blind* clicks: they carry no
|
| 54 |
+
meaning, only coordinates. Allowed while planning (a dry
|
| 55 |
+
run touches nothing), refused with real input unless this
|
| 56 |
+
is set, because "click cell 47" is a guess about your
|
| 57 |
+
desktop, not a decision about it.
|
| 58 |
"""
|
| 59 |
|
| 60 |
dry_run: bool = True
|
|
|
|
| 63 |
deny_labels: tuple[str, ...] = DEFAULT_DENY_LABELS
|
| 64 |
deny_keys: tuple[str, ...] = DEFAULT_DENY_KEYS
|
| 65 |
kill_switch_path: str | None = None
|
| 66 |
+
allow_grid_clicks: bool = False
|
| 67 |
_compiled: tuple[Pattern[str], ...] = field(default=(), init=False, repr=False)
|
| 68 |
|
| 69 |
def __post_init__(self) -> None:
|
|
|
|
| 91 |
|
| 92 |
if action.kind == "click" and action.label:
|
| 93 |
element = observation.find(action.label) if observation is not None else None
|
| 94 |
+
if not self.dry_run and not self.allow_grid_clicks:
|
| 95 |
+
is_grid = (element is not None and element.kind == "grid") or (
|
| 96 |
+
element is None and action.label.strip().lower().startswith("cell ")
|
| 97 |
+
)
|
| 98 |
+
if is_grid:
|
| 99 |
+
raise PolicyViolation(
|
| 100 |
+
"clicking a numbered grid cell is a blind click; install an OCR "
|
| 101 |
+
"engine or pass --allow-grid if you really mean it"
|
| 102 |
+
)
|
| 103 |
haystack = (element.label if element is not None else action.label).lower()
|
| 104 |
for denied in self.deny_labels:
|
| 105 |
if denied.lower() in haystack:
|