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

Publish tiny_computer_agent/ from marimo

Browse files
tiny_computer_agent/__init__.py ADDED
@@ -0,0 +1,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
5
+ vocabulary and the safety gates, so the model only ever answers one question -
6
+ "which action, from this menu, comes next?" Nothing here needs a network call
7
+ at run time, and the core is standard-library only.
8
+
9
+ from tiny_computer_agent import (
10
+ AgentLoop, DryRunExecutor, Element, FakeScreen, MockBrain, Policy,
11
+ )
12
+
13
+ screen = FakeScreen([(Element("Save", x=320, y=120, width=80, height=24),)])
14
+ loop = AgentLoop(screen=screen, brain=MockBrain([{"kind": "click", "label": "Save"}]))
15
+ episode = loop.run("save the document")
16
+ print(episode.render())
17
+ """
18
+
19
+ from tiny_computer_agent.act import (
20
+ ActionResult,
21
+ DryRunExecutor,
22
+ Executor,
23
+ InputUnavailable,
24
+ PyAutoGuiExecutor,
25
+ resolve_point,
26
+ )
27
+ from tiny_computer_agent.actions import (
28
+ ACTION_KINDS,
29
+ ACTION_MENU,
30
+ Action,
31
+ ActionError,
32
+ parse_action,
33
+ )
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,
47
+ GrabScreen,
48
+ Observation,
49
+ ScreenSource,
50
+ ScreenUnavailable,
51
+ )
52
+ from tiny_computer_agent.safety import Policy, PolicyViolation
53
+
54
+ __version__ = "0.1.0"
55
+
56
+ __all__ = [
57
+ "ACTION_KINDS", "ACTION_MENU", "Action", "ActionError", "ActionResult",
58
+ "AgentLoop", "Brain", "BrainError", "DryRunExecutor", "Element", "Episode",
59
+ "Executor", "FakeScreen", "GoalUnreachable", "GrabScreen", "InputUnavailable", "MockBrain",
60
+ "Observation", "PlannerBrain", "Policy", "PolicyViolation", "PyAutoGuiExecutor",
61
+ "ScreenSource", "ScreenUnavailable", "Step", "VLMBrain", "extract_json_object",
62
+ "parse_action", "resolve_point", "__version__",
63
+ ]
tiny_computer_agent/__main__.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Command line entry point.
2
+
3
+ python -m tiny_computer_agent # scripted demo, dry-run
4
+ python -m tiny_computer_agent --live # your real screen, dry-run
5
+ python -m tiny_computer_agent "click 'Save'" # planner brain
6
+ python -m tiny_computer_agent --script '[{"kind": "done"}]'
7
+
8
+ Real mouse and keyboard input additionally requires ``--real-input`` *and*
9
+ ``TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT=1`` in the environment. That is deliberate:
10
+ dry-run is the default so you can read a whole episode first.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
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 Element, FakeScreen, GrabScreen
24
+ from tiny_computer_agent.safety import Policy
25
+
26
+ ALLOW_REAL_INPUT_ENV = "TINY_COMPUTER_AGENT_ALLOW_REAL_INPUT"
27
+ DEFAULT_KILL_SWITCH = "/tmp/tiny-computer-agent.STOP"
28
+
29
+
30
+ def demo_screen() -> FakeScreen:
31
+ """A three-frame desktop: a dialog, then an editor, then a saved file."""
32
+ return FakeScreen(
33
+ [
34
+ (Element("Terminal", x=0, y=0, width=40, height=24),
35
+ Element("Files", x=60, y=0, width=40, height=24)),
36
+ (Element("Name", kind="input", x=100, y=120, width=200, height=24),
37
+ Element("Save", x=320, y=120, width=80, height=24)),
38
+ (Element("Save", x=320, y=120, width=80, height=24),),
39
+ ],
40
+ text="Untitled document - unsaved changes",
41
+ )
42
+
43
+
44
+ def build_parser() -> argparse.ArgumentParser:
45
+ parser = argparse.ArgumentParser(
46
+ prog="tiny-computer-agent",
47
+ description="Drive a computer with a tiny local model (dry-run by default).",
48
+ )
49
+ parser.add_argument("goal", nargs="?", default="click 'Save' to save the document")
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
+ screen = GrabScreen() if args.live else demo_screen()
71
+
72
+ if args.script:
73
+ try:
74
+ script = json.loads(args.script)
75
+ except json.JSONDecodeError as exc:
76
+ print(f"--script is not valid JSON: {exc}", file=sys.stderr)
77
+ return 2
78
+ brain = MockBrain(script)
79
+ else:
80
+ brain = PlannerBrain(done_when_text_contains=None)
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}\nmode: {mode} | screen: "
91
+ f"{'real desktop' if args.live else 'scripted demo'} | "
92
+ f"kill switch: create {args.kill_switch} to stop\n")
93
+
94
+ def on_step(step: Step) -> None:
95
+ print(" " + step.line())
96
+
97
+ episode = AgentLoop(screen=screen, brain=brain, executor=executor,
98
+ policy=policy, on_step=on_step).run(args.goal)
99
+ print()
100
+ print(episode.render())
101
+ return 0 if episode.success else 1
102
+
103
+
104
+ if __name__ == "__main__":
105
+ raise SystemExit(main())
tiny_computer_agent/act.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Execution: the only module in the harness that touches the machine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+
9
+ from tiny_computer_agent.actions import Action
10
+ from tiny_computer_agent.observe import Observation
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ActionResult:
15
+ action: Action
16
+ ok: bool
17
+ note: str = ""
18
+
19
+ def summary(self) -> str:
20
+ marker = "ok" if self.ok else "FAILED"
21
+ return f"{marker}: {self.note}"
22
+
23
+
24
+ class InputUnavailable(RuntimeError):
25
+ """Raised when real input is requested but pyautogui cannot drive the machine."""
26
+
27
+
28
+ def resolve_point(
29
+ action: Action, observation: Observation | None = None
30
+ ) -> tuple[int, int] | None:
31
+ """Turn a click into pixels, using the element index for label clicks."""
32
+ if action.kind != "click":
33
+ return None
34
+ if action.x is not None and action.y is not None:
35
+ return (action.x, action.y)
36
+ if observation is not None and action.label:
37
+ element = observation.find(action.label)
38
+ if element is not None:
39
+ return element.center
40
+ return None
41
+
42
+
43
+ class Executor(Protocol):
44
+ dry_run: bool
45
+
46
+ def execute(
47
+ self, action: Action, observation: Observation | None = None
48
+ ) -> ActionResult: ...
49
+
50
+
51
+ class DryRunExecutor:
52
+ """Records actions instead of performing them. The default everywhere.
53
+
54
+ Lets you read an entire episode — including which pixel each label resolved
55
+ to — before a single event reaches the machine.
56
+ """
57
+
58
+ dry_run = True
59
+
60
+ def __init__(self) -> None:
61
+ self.performed: list[Action] = []
62
+ self.log: list[str] = []
63
+
64
+ def execute(
65
+ self, action: Action, observation: Observation | None = None
66
+ ) -> ActionResult:
67
+ note = f"(dry-run) would {action.describe()}"
68
+ ok = True
69
+ if action.kind == "click":
70
+ point = resolve_point(action, observation)
71
+ if point is not None:
72
+ note += f" -> pixel {point}"
73
+ elif action.label:
74
+ # Fail exactly where live input would, so a dry run never gives
75
+ # false confidence about a plan the executor would refuse.
76
+ ok = False
77
+ note += " (label absent from the element index; live input refuses this)"
78
+ self.performed.append(action)
79
+ self.log.append(note)
80
+ return ActionResult(action=action, ok=ok, note=note)
81
+
82
+
83
+ class PyAutoGuiExecutor:
84
+ """Real mouse and keyboard input.
85
+
86
+ Refuses to click when a label cannot be resolved to an element: a tiny model
87
+ guessing pixel coordinates is exactly how you click "Delete account".
88
+ """
89
+
90
+ dry_run = False
91
+
92
+ def __init__(self, *, pause: float = 0.1, typing_interval: float = 0.02,
93
+ scroll_ticks: int = 100) -> None:
94
+ self.pause = pause
95
+ self.typing_interval = typing_interval
96
+ self.scroll_ticks = scroll_ticks
97
+
98
+ @staticmethod
99
+ def _pyautogui():
100
+ try:
101
+ import pyautogui
102
+ except ImportError as exc: # pragma: no cover - depends on the machine
103
+ raise InputUnavailable(
104
+ "real input needs pyautogui: pip install \'tiny-computer-agent[desktop]\'"
105
+ ) from exc
106
+ return pyautogui
107
+
108
+ def execute(
109
+ self, action: Action, observation: Observation | None = None
110
+ ) -> ActionResult:
111
+ if action.kind == "done":
112
+ return ActionResult(action=action, ok=True, note="goal reported met")
113
+
114
+ point: tuple[int, int] | None = None
115
+ if action.kind == "click":
116
+ point = resolve_point(action, observation)
117
+ if point is None:
118
+ return ActionResult(
119
+ action=action,
120
+ ok=False,
121
+ note=f"no element matching {action.label!r}; refusing to guess pixels",
122
+ )
123
+
124
+ pyautogui = self._pyautogui()
125
+ try:
126
+ if action.kind == "click":
127
+ pyautogui.click(*point)
128
+ note = f"clicked {point}"
129
+ elif action.kind == "type":
130
+ pyautogui.write(action.text or "", interval=self.typing_interval)
131
+ note = f"typed {len(action.text or '')} characters"
132
+ elif action.kind == "key":
133
+ chord = [k.strip() for k in (action.keys or "").split("+") if k.strip()]
134
+ pyautogui.hotkey(*chord)
135
+ note = f"pressed {action.keys}"
136
+ elif action.kind == "scroll":
137
+ pyautogui.scroll(action.amount * self.scroll_ticks)
138
+ note = f"scrolled {action.amount} ticks"
139
+ elif action.kind == "wait":
140
+ time.sleep(action.seconds)
141
+ note = f"waited {action.seconds}s"
142
+ else: # unreachable: Action already validated the vocabulary
143
+ return ActionResult(action=action, ok=False, note=f"unhandled kind {action.kind!r}")
144
+ except Exception as exc:
145
+ return ActionResult(action=action, ok=False, note=f"{type(exc).__name__}: {exc}")
146
+ if self.pause:
147
+ time.sleep(self.pause)
148
+ return ActionResult(action=action, ok=True, note=note)
tiny_computer_agent/actions.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The closed action vocabulary.
2
+
3
+ A tiny model is only dependable when its output space is small and every mistake
4
+ is cheap to catch. The harness therefore accepts exactly six verbs, and validates
5
+ each field *before* anything touches the machine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Final, Mapping
12
+
13
+ ACTION_KINDS: Final[tuple[str, ...]] = ("click", "type", "key", "scroll", "wait", "done")
14
+
15
+
16
+ class ActionError(ValueError):
17
+ """Raised when a brain proposes something outside the vocabulary."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Action:
22
+ """One step of computer use: exactly one verb, validated on construction.
23
+
24
+ label : click target *by element label* — the preferred form, because it
25
+ survives window moves that would break raw pixel coordinates
26
+ x, y : pixel fallback, used only when no label matches
27
+ text : literal text to type
28
+ keys : key chord, e.g. ``ctrl+s``
29
+ amount : scroll ticks; negative scrolls up
30
+ seconds : how long to wait
31
+ reason : the brain's stated rationale — kept for traces, never executed
32
+ """
33
+
34
+ kind: str
35
+ label: str | None = None
36
+ x: int | None = None
37
+ y: int | None = None
38
+ text: str | None = None
39
+ keys: str | None = None
40
+ amount: int = 0
41
+ seconds: float = 0.0
42
+ reason: str = ""
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.kind not in ACTION_KINDS:
46
+ raise ActionError(f"unknown action {self.kind!r}; expected one of {ACTION_KINDS}")
47
+ if self.kind == "click":
48
+ if not self.label and not (self.x is not None and self.y is not None):
49
+ raise ActionError("click needs a label, or x and y")
50
+ if (self.x is not None and self.x < 0) or (self.y is not None and self.y < 0):
51
+ raise ActionError("click coordinates must be non-negative")
52
+ if self.kind == "type" and not self.text:
53
+ raise ActionError("type needs non-empty text")
54
+ if self.kind == "key" and not self.keys:
55
+ raise ActionError("key needs a chord, e.g. 'ctrl+s'")
56
+ if self.kind == "scroll" and self.amount == 0:
57
+ raise ActionError("scroll needs a non-zero amount")
58
+ if self.kind == "wait" and self.seconds <= 0:
59
+ raise ActionError("wait needs a positive number of seconds")
60
+
61
+ def describe(self) -> str:
62
+ """One-line, human-readable form used in traces and dry-run logs."""
63
+ parts: list[str] = [self.kind]
64
+ if self.label:
65
+ parts.append(f"label={self.label!r}")
66
+ if self.x is not None and self.y is not None:
67
+ parts.append(f"at=({self.x},{self.y})")
68
+ if self.text is not None:
69
+ parts.append(f"text={self.text!r}")
70
+ if self.keys:
71
+ parts.append(f"keys={self.keys!r}")
72
+ if self.kind == "scroll":
73
+ parts.append(f"amount={self.amount}")
74
+ if self.kind == "wait":
75
+ parts.append(f"seconds={self.seconds}")
76
+ if self.reason:
77
+ parts.append(f"reason={self.reason!r}")
78
+ return " ".join(parts)
79
+
80
+ def to_dict(self) -> dict[str, Any]:
81
+ return {
82
+ "kind": self.kind,
83
+ "label": self.label,
84
+ "x": self.x,
85
+ "y": self.y,
86
+ "text": self.text,
87
+ "keys": self.keys,
88
+ "amount": self.amount,
89
+ "seconds": self.seconds,
90
+ "reason": self.reason,
91
+ }
92
+
93
+
94
+ ACTION_MENU: Final[str] = """\
95
+ Reply with exactly one JSON object and nothing else. Allowed verbs:
96
+
97
+ {"kind": "click", "label": "<text of the element to click>", "reason": "..."}
98
+ {"kind": "click", "x": 640, "y": 400, "reason": "..."} // pixels, only if no label fits
99
+ {"kind": "type", "text": "...", "reason": "..."}
100
+ {"kind": "key", "keys": "ctrl+s", "reason": "..."}
101
+ {"kind": "scroll", "amount": -3, "reason": "..."}
102
+ {"kind": "wait", "seconds": 1.5, "reason": "..."}
103
+ {"kind": "done", "reason": "why the goal is met"}
104
+
105
+ Rules: one action per reply; prefer a label over guessing pixels; reply "done"
106
+ as soon as the goal is met; never invent new verbs."""
107
+
108
+ _FIELDS: Final[tuple[str, ...]] = (
109
+ "label", "x", "y", "text", "keys", "amount", "seconds", "reason",
110
+ )
111
+
112
+
113
+ def parse_action(payload: Mapping[str, Any]) -> Action:
114
+ """Turn a brain's raw mapping into a validated :class:`Action`.
115
+
116
+ Unknown keys are ignored, so a chatty model cannot crash the loop. An unknown
117
+ verb or a missing field raises :class:`ActionError`, which the loop records as
118
+ a failed step *without executing anything*.
119
+ """
120
+ if not isinstance(payload, Mapping):
121
+ raise ActionError(f"expected a mapping, got {type(payload).__name__}")
122
+ kind = payload.get("kind")
123
+ if kind not in ACTION_KINDS:
124
+ raise ActionError(f"unknown action {kind!r}; expected one of {ACTION_KINDS}")
125
+ kwargs = {k: payload[k] for k in _FIELDS if k in payload}
126
+ return Action(kind=str(kind), **kwargs)
tiny_computer_agent/brain.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decision making.
2
+
3
+ Three brains ship here, and only one of them is a model:
4
+
5
+ * :class:`MockBrain` - replays a scripted action list (tests, demos, replays)
6
+ * :class:`PlannerBrain` - rule-based: clicks the element whose label matches the
7
+ goal. No model, no download, yet it solves real "click the thing labelled X"
8
+ tasks, because the *element index* does the grounding a VLM would otherwise do
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 typing import Any, Mapping, Protocol, Sequence
18
+
19
+ from tiny_computer_agent.actions import ACTION_MENU, Action, ActionError, parse_action
20
+ from tiny_computer_agent.observe import Observation
21
+
22
+
23
+ class BrainError(RuntimeError):
24
+ """Raised when a brain cannot produce a valid action."""
25
+
26
+
27
+ class GoalUnreachable(BrainError):
28
+ """Raised by a brain that can decide nothing further, without claiming success.
29
+
30
+ Deliberately distinct from a crash: the harness records the episode as a
31
+ *failure*, because a brain admitting "I give up" must never be logged as
32
+ "goal met" - and the harness, not the brain, owns that judgement.
33
+ """
34
+
35
+
36
+ def extract_json_object(raw: str) -> dict[str, Any]:
37
+ """Pull the first JSON object out of a reply, ignoring any surrounding prose."""
38
+ start = raw.find("{")
39
+ end = raw.rfind("}")
40
+ if start == -1 or end <= start:
41
+ raise BrainError(f"no JSON object in the model reply: {raw[:200]!r}")
42
+ try:
43
+ payload = json.loads(raw[start : end + 1])
44
+ except json.JSONDecodeError as exc:
45
+ raise BrainError(f"malformed JSON in the model reply: {exc}") from exc
46
+ if not isinstance(payload, dict):
47
+ raise BrainError(f"expected a JSON object, got {type(payload).__name__}")
48
+ return payload
49
+
50
+
51
+ class Brain(Protocol):
52
+ """Anything that can turn (goal, observation) into one validated action."""
53
+
54
+ name: str
55
+
56
+ def decide(self, goal: str, observation: Observation) -> Action: ...
57
+
58
+
59
+ class MockBrain:
60
+ """Replays a fixed script of actions, exactly once. Deterministic and offline."""
61
+
62
+ name = "mock"
63
+
64
+ def __init__(self, script: Sequence[Action | Mapping[str, Any]]) -> None:
65
+ self._script: list[Action] = [
66
+ a if isinstance(a, Action) else parse_action(a) for a in script
67
+ ]
68
+ self._cursor = 0
69
+
70
+ @property
71
+ def remaining(self) -> int:
72
+ return max(0, len(self._script) - self._cursor)
73
+
74
+ def decide(self, goal: str, observation: Observation) -> Action:
75
+ if self._cursor >= len(self._script):
76
+ return Action("done", reason="script exhausted")
77
+ action = self._script[self._cursor]
78
+ self._cursor += 1
79
+ return action
80
+
81
+
82
+ class PlannerBrain:
83
+ """Rule-based baseline: click the element matching the goal, then stop.
84
+
85
+ Its purpose is twofold - it proves the harness works end to end with no model
86
+ at all, and it shows how much of a "computer use" task is really an indexing
87
+ problem. It waits when the target is not on screen yet, refuses to click the
88
+ same label twice, and gives up rather than flailing. It will not generalise
89
+ the way a VLM can.
90
+ """
91
+
92
+ name = "planner"
93
+
94
+ def __init__(self, target: str | None = None, *,
95
+ done_when_text_contains: str | None = None,
96
+ max_waits: int = 3, wait_seconds: float = 0.5) -> None:
97
+ self.target = target
98
+ self.done_when_text_contains = done_when_text_contains
99
+ self.max_waits = max_waits
100
+ self.wait_seconds = wait_seconds
101
+ self._clicked: set[str] = set()
102
+ self._waits = 0
103
+
104
+ def decide(self, goal: str, observation: Observation) -> Action:
105
+ if self.done_when_text_contains:
106
+ needle = self.done_when_text_contains.lower()
107
+ if needle in observation.text.lower():
108
+ return Action("done", reason=f"page text contains {self.done_when_text_contains!r}")
109
+
110
+ target = self.target or self._target_from(goal)
111
+ if target:
112
+ element = observation.find(target)
113
+ if element is None:
114
+ if self._waits < self.max_waits:
115
+ self._waits += 1
116
+ return Action("wait", seconds=self.wait_seconds,
117
+ reason=f"{target!r} is not on screen yet")
118
+ raise GoalUnreachable(f"{target!r} never appeared on screen")
119
+ key = element.label.strip().lower()
120
+ if key in self._clicked:
121
+ return Action("done", reason=f"already clicked {element.label!r}; goal assumed met")
122
+ self._clicked.add(key)
123
+ return Action("click", label=element.label,
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."""
131
+ quoted = re.search(r"[\"\']([^\"\']{2,40})[\"\']", goal)
132
+ if quoted:
133
+ return quoted.group(1)
134
+ words = [w for w in goal.split() if w[:1].isupper()]
135
+ return words[-1].strip(".,!?") if words else None
136
+
137
+
138
+ class VLMBrain:
139
+ """Seam for a real (small) vision-language model.
140
+
141
+ ``build_prompt`` and reply validation are implemented; subclass this and
142
+ implement ``_generate()`` to return the raw model text. Swap the model by
143
+ changing ``model_id`` - nothing else in the harness cares which weights you use.
144
+ """
145
+
146
+ name = "vlm"
147
+
148
+ def __init__(self, model_id: str = "vikhyatk/moondream2", *,
149
+ max_new_tokens: int = 128) -> None:
150
+ self.model_id = model_id
151
+ self.max_new_tokens = max_new_tokens
152
+
153
+ def build_prompt(self, goal: str, observation: Observation) -> str:
154
+ return (
155
+ "You are controlling a computer. Choose the single next action.\n\n"
156
+ f"Goal: {goal}\n\n"
157
+ f"{observation.summary()}\n\n"
158
+ f"{ACTION_MENU}"
159
+ )
160
+
161
+ def _generate(self, prompt: str, observation: Observation) -> str:
162
+ raise NotImplementedError(
163
+ "No vision backend is wired up yet. Subclass VLMBrain and implement "
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
tiny_computer_agent/loop.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The harness itself: observe -> think -> act -> verify, bounded and traced."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Callable
7
+
8
+ from tiny_computer_agent.act import ActionResult, DryRunExecutor, Executor, PyAutoGuiExecutor
9
+ from tiny_computer_agent.actions import Action
10
+ from tiny_computer_agent.brain import Brain, GoalUnreachable
11
+ from tiny_computer_agent.observe import Observation, ScreenSource
12
+ from tiny_computer_agent.safety import Policy, PolicyViolation
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Step:
17
+ """One turn of the loop, with everything needed to audit it afterwards."""
18
+
19
+ index: int
20
+ observation: Observation
21
+ action: Action | None = None
22
+ result: ActionResult | None = None
23
+ error: str | None = None
24
+
25
+ @property
26
+ def ok(self) -> bool:
27
+ return self.error is None and (self.result is None or self.result.ok)
28
+
29
+ def line(self) -> str:
30
+ bits = [f"step {self.index}"]
31
+ if self.action is not None:
32
+ bits.append(self.action.describe())
33
+ if self.error:
34
+ bits.append(f"ERROR {self.error}")
35
+ elif self.result is not None:
36
+ bits.append(self.result.summary())
37
+ return " | ".join(bits)
38
+
39
+
40
+ @dataclass
41
+ class Episode:
42
+ """An append-only record of one run: the unit you replay and score."""
43
+
44
+ goal: str
45
+ steps: list[Step] = field(default_factory=list)
46
+ stop_reason: str = ""
47
+ success: bool = False
48
+
49
+ def to_rows(self) -> list[dict[str, Any]]:
50
+ """Rows for a dataframe / notebook table."""
51
+ rows = []
52
+ for step in self.steps:
53
+ rows.append({
54
+ "step": step.index,
55
+ "elements": len(step.observation.elements),
56
+ "action": step.action.describe() if step.action else "",
57
+ "outcome": step.error or (step.result.summary() if step.result else ""),
58
+ })
59
+ return rows
60
+
61
+ def render(self) -> str:
62
+ lines = [
63
+ f"goal: {self.goal}",
64
+ f"result: {'success' if self.success else 'failure'} - {self.stop_reason}",
65
+ ]
66
+ lines += [f" {step.line()}" for step in self.steps]
67
+ return "\n".join(lines)
68
+
69
+
70
+ class AgentLoop:
71
+ """Wires a screen, a brain, an executor and a policy into an agent.
72
+
73
+ Guarantees, by construction rather than by prompting:
74
+
75
+ * every action is one of six validated verbs
76
+ * nothing executes until policy has passed it
77
+ * the episode ends on a step budget, a kill switch, or a brain error
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) -> None:
85
+ self.screen = screen
86
+ self.brain = brain
87
+ self.policy = policy or Policy()
88
+ if executor is None:
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
+ observation = self.screen.capture()
97
+
98
+ for index in range(budget):
99
+ if self.policy.should_stop():
100
+ episode.stop_reason = "kill switch engaged"
101
+ break
102
+
103
+ try:
104
+ action = self.brain.decide(goal, observation)
105
+ except GoalUnreachable as exc:
106
+ self._record(episode, Step(index, observation, error=f"gave up: {exc}"))
107
+ episode.stop_reason = f"brain gave up: {exc}"
108
+ break
109
+ except Exception as exc:
110
+ self._record(episode, Step(index, observation,
111
+ error=f"brain failed: {type(exc).__name__}: {exc}"))
112
+ episode.stop_reason = "brain error"
113
+ break
114
+
115
+ if action.kind == "done":
116
+ self._record(episode, Step(
117
+ index, observation, action=action,
118
+ result=ActionResult(action=action, ok=True, note="goal reported met"),
119
+ ))
120
+ episode.success = True
121
+ episode.stop_reason = f"brain reported done: {action.reason or 'no reason given'}"
122
+ break
123
+
124
+ try:
125
+ self.policy.check(action, observation)
126
+ except PolicyViolation as exc:
127
+ self._record(episode, Step(index, observation, action=action,
128
+ error=f"refused: {exc}"))
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:
135
+ self._record(episode, Step(index, observation, action=action,
136
+ error=f"executor failed: {type(exc).__name__}: {exc}"))
137
+ episode.stop_reason = "executor error"
138
+ break
139
+
140
+ self._record(episode, Step(index, observation, action=action, result=result))
141
+ if not result.ok:
142
+ episode.stop_reason = f"action failed: {result.note}"
143
+ break
144
+
145
+ observation = self.screen.capture() # verify: what did that change?
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:
152
+ self.on_step(step)
tiny_computer_agent/observe.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Perception: turn the world into an :class:`Observation` the brain can read.
2
+
3
+ The harness never asks the model to *find* anything. OCR or the accessibility tree
4
+ produces a labelled element index; the model only chooses among those labels.
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."""
16
+
17
+ label: str
18
+ kind: str = "button"
19
+ x: int = 0
20
+ y: int = 0
21
+ width: int = 0
22
+ height: int = 0
23
+
24
+ def __post_init__(self) -> None:
25
+ if not self.label.strip():
26
+ raise ValueError("Element.label must be non-empty")
27
+ if min(self.x, self.y, self.width, self.height) < 0:
28
+ raise ValueError("Element geometry must be non-negative")
29
+
30
+ @property
31
+ def center(self) -> tuple[int, int]:
32
+ if self.width and self.height:
33
+ return (self.x + self.width // 2, self.y + self.height // 2)
34
+ return (self.x, self.y)
35
+
36
+ def matches(self, needle: str) -> bool:
37
+ return needle.strip().lower() in self.label.strip().lower()
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Observation:
42
+ """One frame of the world: pixels plus whatever index we could build."""
43
+
44
+ index: int = 0
45
+ frame: Any = None
46
+ elements: tuple[Element, ...] = ()
47
+ text: str = ""
48
+
49
+ def find(self, label: str) -> Element | None:
50
+ """Exact label first, then substring; None if nothing matches."""
51
+ want = label.strip().lower()
52
+ exact = [e for e in self.elements if e.label.strip().lower() == want]
53
+ if exact:
54
+ return exact[0]
55
+ loose = [e for e in self.elements if e.matches(label)]
56
+ return loose[0] if loose else None
57
+
58
+ def summary(self, max_elements: int = 40) -> str:
59
+ """Compact text form — this is what a model would be shown."""
60
+ lines = [f"step {self.index}"]
61
+ if self.text:
62
+ lines.append("visible text: " + " ".join(self.text.split())[:400])
63
+ if self.elements:
64
+ lines.append("clickable elements:")
65
+ for e in self.elements[:max_elements]:
66
+ x, y = e.center
67
+ lines.append(f" - {e.label!r} ({e.kind}) at {x},{y}")
68
+ if len(self.elements) > max_elements:
69
+ lines.append(f" ... and {len(self.elements) - max_elements} more")
70
+ if not self.elements and not self.text:
71
+ lines.append("no element index available (pixels only)")
72
+ return "\n".join(lines)
73
+
74
+
75
+ class ScreenSource(Protocol):
76
+ def capture(self) -> Observation: ...
77
+
78
+
79
+ class FakeScreen:
80
+ """Scripted screen: each capture() advances one frame.
81
+
82
+ Used to exercise the loop with no display at all. The final frame repeats
83
+ forever, so a runaway agent cannot exhaust the script and crash.
84
+ """
85
+
86
+ def __init__(self, frames: Sequence[Sequence[Element]], *, text: str = "") -> None:
87
+ if not frames:
88
+ raise ValueError("FakeScreen needs at least one frame")
89
+ self._frames: tuple[tuple[Element, ...], ...] = tuple(tuple(f) for f in frames)
90
+ self._text = text
91
+ self.captures = 0
92
+
93
+ def capture(self) -> Observation:
94
+ i = min(self.captures, len(self._frames) - 1)
95
+ observation = Observation(
96
+ index=self.captures,
97
+ frame=f"frame-{i}",
98
+ elements=self._frames[i],
99
+ text=self._text,
100
+ )
101
+ self.captures += 1
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
+ """Real desktop capture via ``mss`` + Pillow.
111
+
112
+ Pixels only: nothing here builds an element index. Wiring OCR (tesseract) or
113
+ the accessibility tree (AT-SPI on Linux) is what turns frames into labels.
114
+ Needs a display, so it is untested in a headless sandbox.
115
+ """
116
+
117
+ def __init__(self, *, monitor: int = 1, max_width: int = 1280) -> None:
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
+ except ImportError as exc: # pragma: no cover - depends on the machine
127
+ raise ScreenUnavailable(
128
+ "GrabScreen needs 'mss' and 'pillow': pip install \'tiny-computer-agent[desktop]\'"
129
+ ) from exc
130
+ try:
131
+ with mss.mss() as sct:
132
+ shot = sct.grab(sct.monitors[self.monitor])
133
+ image = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX")
134
+ except Exception as exc: # pragma: no cover - depends on the machine
135
+ raise ScreenUnavailable(f"screen capture failed (no display?): {exc}") from exc
136
+ if image.width > self.max_width:
137
+ ratio = self.max_width / image.width
138
+ image = image.resize((self.max_width, int(image.height * ratio)))
139
+ observation = Observation(index=self._index, frame=image, elements=(), text="")
140
+ self._index += 1
141
+ return observation
tiny_computer_agent/safety.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The gates between a small model's mistake and your filesystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from typing import Pattern
9
+
10
+ from tiny_computer_agent.actions import Action
11
+ from tiny_computer_agent.observe import Observation
12
+
13
+ DEFAULT_DENY_TEXT = (
14
+ r"\bsudo\b",
15
+ r"\brm\s+-rf\b",
16
+ r"\bmkfs\b",
17
+ r"\bdd\s+if=",
18
+ r"\bchmod\s+777\b",
19
+ r"\bgit\s+push\s+--force\b",
20
+ r"password",
21
+ r"api[_-]?key",
22
+ r"\bseed\s+phrase\b",
23
+ )
24
+
25
+ DEFAULT_DENY_LABELS = (
26
+ "terminal",
27
+ "shell",
28
+ "keychain",
29
+ "password manager",
30
+ "bank",
31
+ "wallet",
32
+ "credit card",
33
+ )
34
+
35
+ DEFAULT_DENY_KEYS = ("ctrl+alt+t", "ctrl+alt+delete", "ctrl+shift+escape")
36
+
37
+
38
+ class PolicyViolation(RuntimeError):
39
+ """Raised when an action is refused. The loop stops; nothing is executed."""
40
+
41
+
42
+ @dataclass
43
+ class Policy:
44
+ """Rules an action must pass before it is executed.
45
+
46
+ dry_run : when True (the default) the loop uses a DryRunExecutor
47
+ step_budget : hard cap on actions per episode
48
+ deny_text_patterns : regexes a `type` action may not contain
49
+ deny_labels : substrings that block a click on a matching element
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
56
+ step_budget: int = 20
57
+ deny_text_patterns: tuple[str, ...] = DEFAULT_DENY_TEXT
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:
64
+ self._compiled = tuple(re.compile(p, re.IGNORECASE) for p in self.deny_text_patterns)
65
+ if self.step_budget < 1:
66
+ raise ValueError("step_budget must be at least 1")
67
+
68
+ def should_stop(self) -> bool:
69
+ """True when the kill switch exists. Checked before every action."""
70
+ return bool(self.kill_switch_path) and os.path.exists(self.kill_switch_path)
71
+
72
+ def check(self, action: Action, observation: Observation | None = None) -> None:
73
+ """Raise :class:`PolicyViolation` if the action is not allowed."""
74
+ if action.kind == "type" and action.text:
75
+ for pattern in self._compiled:
76
+ if pattern.search(action.text):
77
+ raise PolicyViolation(
78
+ f"typing {pattern.pattern!r} is on the deny list"
79
+ )
80
+
81
+ if action.kind == "key" and action.keys:
82
+ chord = "+".join(k.strip().lower() for k in action.keys.split("+") if k.strip())
83
+ if chord in {k.lower() for k in self.deny_keys}:
84
+ raise PolicyViolation(f"key chord {action.keys!r} is on the deny list")
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:
91
+ raise PolicyViolation(
92
+ f"clicking {action.label!r} is on the deny list ({denied!r})"
93
+ )