File size: 4,969 Bytes
e187916
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
"""The closed action vocabulary.

A tiny model is only dependable when its output space is small and every mistake
is cheap to catch. The harness therefore accepts exactly six verbs, and validates
each field *before* anything touches the machine.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Final, Mapping

ACTION_KINDS: Final[tuple[str, ...]] = ("click", "type", "key", "scroll", "wait", "done")


class ActionError(ValueError):
    """Raised when a brain proposes something outside the vocabulary."""


@dataclass(frozen=True)
class Action:
    """One step of computer use: exactly one verb, validated on construction.

    label   : click target *by element label* — the preferred form, because it
              survives window moves that would break raw pixel coordinates
    x, y    : pixel fallback, used only when no label matches
    text    : literal text to type
    keys    : key chord, e.g. ``ctrl+s``
    amount  : scroll ticks; negative scrolls up
    seconds : how long to wait
    reason  : the brain's stated rationale — kept for traces, never executed
    """

    kind: str
    label: str | None = None
    x: int | None = None
    y: int | None = None
    text: str | None = None
    keys: str | None = None
    amount: int = 0
    seconds: float = 0.0
    reason: str = ""

    def __post_init__(self) -> None:
        if self.kind not in ACTION_KINDS:
            raise ActionError(f"unknown action {self.kind!r}; expected one of {ACTION_KINDS}")
        if self.kind == "click":
            if not self.label and not (self.x is not None and self.y is not None):
                raise ActionError("click needs a label, or x and y")
            if (self.x is not None and self.x < 0) or (self.y is not None and self.y < 0):
                raise ActionError("click coordinates must be non-negative")
        if self.kind == "type" and not self.text:
            raise ActionError("type needs non-empty text")
        if self.kind == "key" and not self.keys:
            raise ActionError("key needs a chord, e.g. 'ctrl+s'")
        if self.kind == "scroll" and self.amount == 0:
            raise ActionError("scroll needs a non-zero amount")
        if self.kind == "wait" and self.seconds <= 0:
            raise ActionError("wait needs a positive number of seconds")

    def describe(self) -> str:
        """One-line, human-readable form used in traces and dry-run logs."""
        parts: list[str] = [self.kind]
        if self.label:
            parts.append(f"label={self.label!r}")
        if self.x is not None and self.y is not None:
            parts.append(f"at=({self.x},{self.y})")
        if self.text is not None:
            parts.append(f"text={self.text!r}")
        if self.keys:
            parts.append(f"keys={self.keys!r}")
        if self.kind == "scroll":
            parts.append(f"amount={self.amount}")
        if self.kind == "wait":
            parts.append(f"seconds={self.seconds}")
        if self.reason:
            parts.append(f"reason={self.reason!r}")
        return " ".join(parts)

    def to_dict(self) -> dict[str, Any]:
        return {
            "kind": self.kind,
            "label": self.label,
            "x": self.x,
            "y": self.y,
            "text": self.text,
            "keys": self.keys,
            "amount": self.amount,
            "seconds": self.seconds,
            "reason": self.reason,
        }


ACTION_MENU: Final[str] = """\
Reply with exactly one JSON object and nothing else. Allowed verbs:

  {"kind": "click",  "label": "<text of the element to click>", "reason": "..."}
  {"kind": "click",  "x": 640, "y": 400, "reason": "..."}   // pixels, only if no label fits
  {"kind": "type",   "text": "...", "reason": "..."}
  {"kind": "key",    "keys": "ctrl+s", "reason": "..."}
  {"kind": "scroll", "amount": -3, "reason": "..."}
  {"kind": "wait",   "seconds": 1.5, "reason": "..."}
  {"kind": "done",   "reason": "why the goal is met"}

Rules: one action per reply; prefer a label over guessing pixels; reply "done"
as soon as the goal is met; never invent new verbs."""

_FIELDS: Final[tuple[str, ...]] = (
    "label", "x", "y", "text", "keys", "amount", "seconds", "reason",
)


def parse_action(payload: Mapping[str, Any]) -> Action:
    """Turn a brain's raw mapping into a validated :class:`Action`.

    Unknown keys are ignored, so a chatty model cannot crash the loop. An unknown
    verb or a missing field raises :class:`ActionError`, which the loop records as
    a failed step *without executing anything*.
    """
    if not isinstance(payload, Mapping):
        raise ActionError(f"expected a mapping, got {type(payload).__name__}")
    kind = payload.get("kind")
    if kind not in ACTION_KINDS:
        raise ActionError(f"unknown action {kind!r}; expected one of {ACTION_KINDS}")
    kwargs = {k: payload[k] for k in _FIELDS if k in payload}
    return Action(kind=str(kind), **kwargs)