awp-python 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. awp/__init__.py +64 -0
  2. awp/_spec/lifecycle.json +190 -0
  3. awp/_spec/schemas/action-cancel-result.schema.json +13 -0
  4. awp/_spec/schemas/action-ref.schema.json +11 -0
  5. awp/_spec/schemas/action-schema.schema.json +37 -0
  6. awp/_spec/schemas/action-status.schema.json +39 -0
  7. awp/_spec/schemas/action-submit-result.schema.json +16 -0
  8. awp/_spec/schemas/action-submit.schema.json +19 -0
  9. awp/_spec/schemas/agent-manifest.schema.json +27 -0
  10. awp/_spec/schemas/approval-requested.schema.json +25 -0
  11. awp/_spec/schemas/approval-respond.schema.json +31 -0
  12. awp/_spec/schemas/common.schema.json +150 -0
  13. awp/_spec/schemas/embodiment.schema.json +22 -0
  14. awp/_spec/schemas/empty-result.schema.json +8 -0
  15. awp/_spec/schemas/error.schema.json +25 -0
  16. awp/_spec/schemas/frame-inline.schema.json +20 -0
  17. awp/_spec/schemas/frame-tree.schema.json +26 -0
  18. awp/_spec/schemas/obs-report.schema.json +40 -0
  19. awp/_spec/schemas/observation-channel.schema.json +28 -0
  20. awp/_spec/schemas/ping-result.schema.json +13 -0
  21. awp/_spec/schemas/ping.schema.json +12 -0
  22. awp/_spec/schemas/profiles/gui-actions.schema.json +57 -0
  23. awp/_spec/schemas/reset-result.schema.json +11 -0
  24. awp/_spec/schemas/reset.schema.json +12 -0
  25. awp/_spec/schemas/restore.schema.json +11 -0
  26. awp/_spec/schemas/safety-policy.schema.json +81 -0
  27. awp/_spec/schemas/session-open.schema.json +41 -0
  28. awp/_spec/schemas/session-ready.schema.json +50 -0
  29. awp/_spec/schemas/session-resume.schema.json +12 -0
  30. awp/_spec/schemas/session-state.schema.json +14 -0
  31. awp/_spec/schemas/session-telemetry.schema.json +22 -0
  32. awp/_spec/schemas/session-transfer-result.schema.json +12 -0
  33. awp/_spec/schemas/session-transfer.schema.json +11 -0
  34. awp/_spec/schemas/snapshot-result.schema.json +11 -0
  35. awp/_spec/schemas/subscribe-result.schema.json +11 -0
  36. awp/_spec/schemas/subscribe.schema.json +11 -0
  37. awp/_spec/schemas/task-update.schema.json +11 -0
  38. awp/_spec/schemas/tick-result.schema.json +9 -0
  39. awp/_spec/schemas/tick.schema.json +12 -0
  40. awp/_spec/schemas/unsubscribe.schema.json +11 -0
  41. awp/_spec/schemas/world-event.schema.json +45 -0
  42. awp/_spec/schemas/world-manifest.schema.json +61 -0
  43. awp/aio.py +380 -0
  44. awp/client.py +781 -0
  45. awp/clock.py +61 -0
  46. awp/errors.py +112 -0
  47. awp/frames.py +174 -0
  48. awp/jsonrpc.py +84 -0
  49. awp/lifecycle.py +78 -0
  50. awp/py.typed +0 -0
  51. awp/schema.py +158 -0
  52. awp_python-0.1.0a1.dist-info/METADATA +127 -0
  53. awp_python-0.1.0a1.dist-info/RECORD +71 -0
  54. awp_python-0.1.0a1.dist-info/WHEEL +4 -0
  55. awp_python-0.1.0a1.dist-info/entry_points.txt +2 -0
  56. awp_python-0.1.0a1.dist-info/licenses/LICENSE +201 -0
  57. awp_sim/__init__.py +8 -0
  58. awp_sim/__main__.py +5 -0
  59. awp_sim/arm.py +163 -0
  60. awp_sim/audit.py +138 -0
  61. awp_sim/cli.py +238 -0
  62. awp_sim/config.py +225 -0
  63. awp_sim/demo.py +85 -0
  64. awp_sim/loopback.py +222 -0
  65. awp_sim/py.typed +0 -0
  66. awp_sim/recorder.py +72 -0
  67. awp_sim/replay.py +134 -0
  68. awp_sim/scenarios.py +427 -0
  69. awp_sim/server.py +333 -0
  70. awp_sim/session.py +153 -0
  71. awp_sim/world.py +1612 -0
awp/clock.py ADDED
@@ -0,0 +1,61 @@
1
+ """Clock synchronization over the heartbeat (AWP-CLK-007, AWP-CLK-008)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class ClockSample:
11
+ offset_ns: int
12
+ rtt_ns: int
13
+
14
+
15
+ def sample(origin_ns: int, receive_ns: int, transmit_ns: int, destination_ns: int) -> ClockSample:
16
+ rtt = (destination_ns - origin_ns) - (transmit_ns - receive_ns)
17
+ offset = ((receive_ns - origin_ns) + (transmit_ns - destination_ns)) // 2
18
+ return ClockSample(offset_ns=offset, rtt_ns=max(rtt, 0))
19
+
20
+
21
+ class ClockEstimator:
22
+ """Keeps the minimum-RTT sample of the most recent eight exchanges."""
23
+
24
+ WINDOW = 8
25
+
26
+ def __init__(self) -> None:
27
+ self._samples: deque[ClockSample] = deque(maxlen=self.WINDOW)
28
+
29
+ def add(self, s: ClockSample) -> None:
30
+ self._samples.append(s)
31
+
32
+ @property
33
+ def samples(self) -> int:
34
+ return len(self._samples)
35
+
36
+ @property
37
+ def best(self) -> ClockSample | None:
38
+ return min(self._samples, key=lambda s: s.rtt_ns) if self._samples else None
39
+
40
+ @property
41
+ def offset_ns(self) -> int | None:
42
+ best = self.best
43
+ return best.offset_ns if best else None
44
+
45
+ @property
46
+ def error_bound_ns(self) -> int | None:
47
+ best = self.best
48
+ return best.rtt_ns // 2 if best else None
49
+
50
+ def to_session(self, agent_ns: int) -> int:
51
+ """Map an agent-clock value to the session clock (AWP-CLK-009)."""
52
+ offset = self.offset_ns
53
+ if offset is None:
54
+ raise RuntimeError("no clock sample yet; complete a ping exchange first (AWP-CLK-008)")
55
+ return agent_ns + offset
56
+
57
+ def to_agent(self, session_ns: int) -> int:
58
+ offset = self.offset_ns
59
+ if offset is None:
60
+ raise RuntimeError("no clock sample yet")
61
+ return session_ns - offset
awp/errors.py ADDED
@@ -0,0 +1,112 @@
1
+ """AWP error codes (spec/loop/events-and-errors) and the exception that carries them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+ from typing import Any
7
+
8
+
9
+ class ErrorCode(IntEnum):
10
+ PARSE_ERROR = -32700
11
+ INVALID_REQUEST = -32600
12
+ METHOD_NOT_FOUND = -32601
13
+ INVALID_PARAMS = -32602
14
+ INTERNAL_ERROR = -32603
15
+
16
+ VERSION_UNSUPPORTED = 1001
17
+ MALFORMED = 1002
18
+ EMBODIMENT_UNAVAILABLE = 2001
19
+ TIME_MODEL_UNSUPPORTED = 2002
20
+ SESSION_EXPIRED = 2003
21
+ SESSION_EXISTS = 2004
22
+ SESSION_UNKNOWN = 2005
23
+ INTEGER_RANGE = 2006
24
+ CHANNEL_UNKNOWN = 2007
25
+ PARAMS_INVALID = 3001
26
+ BUSY = 3002
27
+ QUEUE_FULL = 3003
28
+ ACTION_ID_CONFLICT = 3004
29
+ ESTOP_ACTIVE = 3005
30
+ TICK_NOT_AUTHORIZED = 3006
31
+ STALE_INTENT = 3007
32
+ ACTION_UNKNOWN = 3008
33
+ TICK_MISMATCH = 3009
34
+ FORBIDDEN = 4001
35
+ ENVELOPE_EXCEEDED = 4002
36
+ APPROVAL_DENIED = 4003
37
+ APPROVAL_TIMEOUT = 4004
38
+
39
+ @property
40
+ def is_jsonrpc(self) -> bool:
41
+ return -32768 <= self.value <= -32000
42
+
43
+ @property
44
+ def wire_name(self) -> str:
45
+ if self.is_jsonrpc:
46
+ return _JSONRPC_MESSAGES[self]
47
+ return f"AWP_{self.name}"
48
+
49
+
50
+ _JSONRPC_MESSAGES = {
51
+ ErrorCode.PARSE_ERROR: "Parse error",
52
+ ErrorCode.INVALID_REQUEST: "Invalid request",
53
+ ErrorCode.METHOD_NOT_FOUND: "Method not found",
54
+ ErrorCode.INVALID_PARAMS: "Invalid params",
55
+ ErrorCode.INTERNAL_ERROR: "Internal error",
56
+ }
57
+
58
+ # AWP_ENVELOPE_EXCEEDED is retryable only for rate limits; callers pass retryable explicitly.
59
+ RETRYABLE = frozenset(
60
+ {
61
+ ErrorCode.EMBODIMENT_UNAVAILABLE,
62
+ ErrorCode.BUSY,
63
+ ErrorCode.QUEUE_FULL,
64
+ ErrorCode.ESTOP_ACTIVE,
65
+ ErrorCode.STALE_INTENT,
66
+ ErrorCode.APPROVAL_TIMEOUT,
67
+ }
68
+ )
69
+
70
+
71
+ class AwpError(Exception):
72
+ """A JSON-RPC error object with AWP data (AWP-ERR-001)."""
73
+
74
+ def __init__(
75
+ self,
76
+ code: int,
77
+ detail: str | None = None,
78
+ *,
79
+ retryable: bool | None = None,
80
+ message: str | None = None,
81
+ **data: Any,
82
+ ) -> None:
83
+ try:
84
+ known: ErrorCode | None = ErrorCode(code)
85
+ except ValueError:
86
+ known = None
87
+ self.code = code
88
+ self.message = message or (known.wire_name if known else f"AWP_{code}")
89
+ self.retryable = retryable if retryable is not None else known in RETRYABLE
90
+ self.detail = detail
91
+ self.data = data
92
+ super().__init__(f"{self.message} ({code})" + (f": {detail}" if detail else ""))
93
+
94
+ def to_dict(self) -> dict[str, Any]:
95
+ err: dict[str, Any] = {"code": self.code, "message": self.message}
96
+ if not (-32768 <= self.code <= -32000) or self.detail or self.data:
97
+ data: dict[str, Any] = {"retryable": self.retryable, **self.data}
98
+ if self.detail:
99
+ data["detail"] = self.detail
100
+ err["data"] = data
101
+ return err
102
+
103
+ @classmethod
104
+ def from_dict(cls, err: dict[str, Any]) -> AwpError:
105
+ data = dict(err.get("data") or {})
106
+ retryable = bool(data.pop("retryable", False))
107
+ detail = data.pop("detail", None)
108
+ return cls(err["code"], detail, retryable=retryable, message=err.get("message"), **data)
109
+
110
+
111
+ class ProtocolError(Exception):
112
+ """The peer violated the protocol in a way the receiver cannot recover from."""
awp/frames.py ADDED
@@ -0,0 +1,174 @@
1
+ """The frame envelope (spec/transport/frames): binary codec and the inline JSON form."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import binascii
7
+ import struct
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from .errors import AwpError, ErrorCode
12
+ from .jsonrpc import MAX_SAFE_INT
13
+
14
+ MAGIC = b"AWPF"
15
+ VERSION = 1
16
+ HEADER = struct.Struct("<4sBBHQQI")
17
+
18
+ KEYFRAME = 0x01
19
+ END_OF_BURST = 0x02
20
+ HAS_EXTENSIONS = 0x04
21
+ RESYNC = 0x08
22
+
23
+ EXT_TICK = 0x01
24
+ EXT_TS_SIM_NS = 0x02
25
+ EXT_TS_SEND_NS = 0x03
26
+ _REGISTERED_LEN = {EXT_TICK: 8, EXT_TS_SIM_NS: 8, EXT_TS_SEND_NS: 8}
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class Frame:
31
+ channel_id: int
32
+ seq: int
33
+ ts_mono_ns: int
34
+ payload: bytes = b""
35
+ keyframe: bool = False
36
+ end_of_burst: bool = False
37
+ resync: bool = False
38
+ tick: int | None = None
39
+ ts_sim_ns: int | None = None
40
+ ts_send_ns: int | None = None
41
+ vendor: tuple[tuple[int, bytes], ...] = field(default=())
42
+
43
+ @property
44
+ def flags(self) -> int:
45
+ """Flag bits 0, 1, and 3; bit 2 is a property of the binary encoding only."""
46
+ return (
47
+ (KEYFRAME if self.keyframe else 0)
48
+ | (END_OF_BURST if self.end_of_burst else 0)
49
+ | (RESYNC if self.resync else 0)
50
+ )
51
+
52
+
53
+ def _malformed(detail: str) -> AwpError:
54
+ return AwpError(ErrorCode.MALFORMED, detail)
55
+
56
+
57
+ def _bounded(value: int, name: str, *, signed: bool = False) -> int:
58
+ if value > MAX_SAFE_INT or (signed and value < -MAX_SAFE_INT):
59
+ raise AwpError(ErrorCode.INTEGER_RANGE, f"{name} exceeds 2^53-1")
60
+ return value
61
+
62
+
63
+ def decode(data: bytes) -> Frame:
64
+ """Decode a binary frame (AWP-DAT-001..009). Raises AwpError MALFORMED or INTEGER_RANGE."""
65
+ if len(data) < HEADER.size:
66
+ raise _malformed("frame shorter than 28-byte header")
67
+ magic, version, flags, channel_id, seq, ts, payload_len = HEADER.unpack_from(data)
68
+ if magic != MAGIC:
69
+ raise _malformed("bad magic")
70
+ if version != VERSION:
71
+ raise _malformed(f"unsupported version {version}")
72
+ ext: dict[str, int] = {}
73
+ vendor: list[tuple[int, bytes]] = []
74
+ offset = HEADER.size
75
+ if flags & HAS_EXTENSIONS:
76
+ if len(data) < offset + 2:
77
+ raise _malformed("missing ext_len")
78
+ (ext_len,) = struct.unpack_from("<H", data, offset)
79
+ offset += 2
80
+ end = offset + ext_len
81
+ if end > len(data):
82
+ raise _malformed("ext_len exceeds frame")
83
+ seen: set[int] = set()
84
+ while offset < end:
85
+ if offset + 2 > end:
86
+ raise _malformed("truncated TLV header")
87
+ kind, length = data[offset], data[offset + 1]
88
+ value = data[offset + 2 : offset + 2 + length]
89
+ if offset + 2 + length > end:
90
+ raise _malformed("TLV value exceeds ext_len")
91
+ if kind in seen:
92
+ raise _malformed(f"duplicate extension type {kind:#04x}")
93
+ seen.add(kind)
94
+ if kind in _REGISTERED_LEN and length != _REGISTERED_LEN[kind]:
95
+ raise _malformed(f"extension {kind:#04x} has len {length}")
96
+ if kind == EXT_TICK:
97
+ ext["tick"] = _bounded(int.from_bytes(value, "little"), "tick")
98
+ elif kind == EXT_TS_SIM_NS:
99
+ ext["ts_sim_ns"] = _bounded(
100
+ int.from_bytes(value, "little", signed=True), "ts_sim_ns", signed=True
101
+ )
102
+ elif kind == EXT_TS_SEND_NS:
103
+ ext["ts_send_ns"] = _bounded(int.from_bytes(value, "little"), "ts_send_ns")
104
+ elif kind >= 0x80:
105
+ vendor.append((kind, bytes(value)))
106
+ offset += 2 + length
107
+ if offset + payload_len != len(data):
108
+ raise _malformed(f"frame length {len(data)} != {offset} + payload_len {payload_len}")
109
+ return Frame(
110
+ channel_id=channel_id,
111
+ seq=_bounded(seq, "seq"),
112
+ ts_mono_ns=_bounded(ts, "ts_mono_ns"),
113
+ payload=bytes(data[offset:]),
114
+ keyframe=bool(flags & KEYFRAME),
115
+ end_of_burst=bool(flags & END_OF_BURST),
116
+ resync=bool(flags & RESYNC),
117
+ vendor=tuple(vendor),
118
+ **ext,
119
+ )
120
+
121
+
122
+ def encode(frame: Frame) -> bytes:
123
+ entries = bytearray()
124
+ for kind, value, fmt in (
125
+ (EXT_TICK, frame.tick, "<Q"),
126
+ (EXT_TS_SIM_NS, frame.ts_sim_ns, "<q"),
127
+ (EXT_TS_SEND_NS, frame.ts_send_ns, "<Q"),
128
+ ):
129
+ if value is not None:
130
+ entries += bytes((kind, 8)) + struct.pack(fmt, value)
131
+ for kind, raw in frame.vendor:
132
+ entries += bytes((kind, len(raw))) + raw
133
+ flags = frame.flags | (HAS_EXTENSIONS if entries else 0)
134
+ header = HEADER.pack(
135
+ MAGIC, VERSION, flags, frame.channel_id, frame.seq, frame.ts_mono_ns, len(frame.payload)
136
+ )
137
+ ext = struct.pack("<H", len(entries)) + entries if entries else b""
138
+ return header + ext + frame.payload
139
+
140
+
141
+ def to_inline(frame: Frame) -> dict[str, Any]:
142
+ """params of an obs.frame / cmd.frame notification (AWP-DAT-004)."""
143
+ params: dict[str, Any] = {
144
+ "channel_id": frame.channel_id,
145
+ "seq": frame.seq,
146
+ "ts_mono_ns": frame.ts_mono_ns,
147
+ "flags": frame.flags,
148
+ }
149
+ for name in ("tick", "ts_sim_ns", "ts_send_ns"):
150
+ value = getattr(frame, name)
151
+ if value is not None:
152
+ params[name] = value
153
+ params["payload_b64"] = base64.b64encode(frame.payload).decode("ascii")
154
+ return params
155
+
156
+
157
+ def from_inline(params: dict[str, Any]) -> Frame:
158
+ try:
159
+ payload = base64.b64decode(params["payload_b64"], validate=True)
160
+ flags = int(params["flags"])
161
+ return Frame(
162
+ channel_id=int(params["channel_id"]),
163
+ seq=int(params["seq"]),
164
+ ts_mono_ns=int(params["ts_mono_ns"]),
165
+ payload=payload,
166
+ keyframe=bool(flags & KEYFRAME),
167
+ end_of_burst=bool(flags & END_OF_BURST),
168
+ resync=bool(flags & RESYNC),
169
+ tick=params.get("tick"),
170
+ ts_sim_ns=params.get("ts_sim_ns"),
171
+ ts_send_ns=params.get("ts_send_ns"),
172
+ )
173
+ except (KeyError, TypeError, ValueError, binascii.Error) as exc:
174
+ raise _malformed(f"invalid inline frame: {exc}") from None
awp/jsonrpc.py ADDED
@@ -0,0 +1,84 @@
1
+ """JSON-RPC 2.0 framing for the control channel (AWP-CTL-001..009)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .errors import AwpError, ErrorCode
9
+
10
+ MAX_SAFE_INT = 2**53 - 1
11
+
12
+ Message = dict[str, Any]
13
+
14
+
15
+ def request(id: int | str, method: str, params: dict[str, Any] | None = None) -> Message:
16
+ msg: Message = {"jsonrpc": "2.0", "id": id, "method": method}
17
+ if params is not None:
18
+ msg["params"] = params
19
+ return msg
20
+
21
+
22
+ def notification(method: str, params: dict[str, Any]) -> Message:
23
+ return {"jsonrpc": "2.0", "method": method, "params": params}
24
+
25
+
26
+ def result(id: int | str | None, value: dict[str, Any]) -> Message:
27
+ return {"jsonrpc": "2.0", "id": id, "result": value}
28
+
29
+
30
+ def error(id: int | str | None, err: AwpError) -> Message:
31
+ return {"jsonrpc": "2.0", "id": id, "error": err.to_dict()}
32
+
33
+
34
+ def is_request(msg: Message) -> bool:
35
+ return "method" in msg and "id" in msg
36
+
37
+
38
+ def is_notification(msg: Message) -> bool:
39
+ return "method" in msg and "id" not in msg
40
+
41
+
42
+ def is_response(msg: Message) -> bool:
43
+ return "method" not in msg and ("result" in msg or "error" in msg)
44
+
45
+
46
+ def encode(msg: Message) -> str:
47
+ return json.dumps(msg, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
48
+
49
+
50
+ def decode(text: str | bytes) -> Message:
51
+ """Parse one control-channel message; raise AwpError for anything a receiver must reject."""
52
+ try:
53
+ msg = json.loads(text, parse_constant=_reject_constant)
54
+ except (ValueError, UnicodeDecodeError) as exc:
55
+ raise AwpError(ErrorCode.PARSE_ERROR, str(exc)) from None
56
+ if isinstance(msg, list):
57
+ raise AwpError(ErrorCode.INVALID_REQUEST, "batch requests are not permitted (AWP-CTL-006)")
58
+ if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
59
+ raise AwpError(ErrorCode.INVALID_REQUEST, "not a JSON-RPC 2.0 object")
60
+ if not (is_request(msg) or is_notification(msg) or is_response(msg)):
61
+ raise AwpError(ErrorCode.INVALID_REQUEST, "neither request, notification, nor response")
62
+ if "method" in msg and not isinstance(msg["method"], str):
63
+ raise AwpError(ErrorCode.INVALID_REQUEST, "method must be a string")
64
+ check_integer_range(msg)
65
+ return msg
66
+
67
+
68
+ def check_integer_range(value: Any) -> None:
69
+ """AWP-CTL-009: JSON integers beyond ±(2^53 - 1) close the session."""
70
+ if isinstance(value, bool):
71
+ return
72
+ if isinstance(value, int):
73
+ if not -MAX_SAFE_INT <= value <= MAX_SAFE_INT:
74
+ raise AwpError(ErrorCode.INTEGER_RANGE, f"integer {value} exceeds 2^53-1")
75
+ elif isinstance(value, dict):
76
+ for v in value.values():
77
+ check_integer_range(v)
78
+ elif isinstance(value, list):
79
+ for v in value:
80
+ check_integer_range(v)
81
+
82
+
83
+ def _reject_constant(name: str) -> Any:
84
+ raise ValueError(f"{name} is not valid JSON")
awp/lifecycle.py ADDED
@@ -0,0 +1,78 @@
1
+ """The normative action lifecycle, loaded from the spec's transition table (AWP-LIF-001)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+ from importlib.resources import files
9
+ from typing import Any
10
+
11
+
12
+ class ActionState(StrEnum):
13
+ SUBMITTED = "submitted"
14
+ PENDING_APPROVAL = "pending_approval"
15
+ QUEUED = "queued"
16
+ ACCEPTED = "accepted"
17
+ EXECUTING = "executing"
18
+ CANCELLING = "cancelling"
19
+ REJECTED = "rejected"
20
+ COMPLETED = "completed"
21
+ FAILED = "failed"
22
+ PREEMPTED = "preempted"
23
+ CANCELLED = "cancelled"
24
+
25
+ @property
26
+ def terminal(self) -> bool:
27
+ return _CLASSES[self] == "terminal"
28
+
29
+ @property
30
+ def pre_execution(self) -> bool:
31
+ return _CLASSES[self] == "pre-execution"
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class Transition:
36
+ source: ActionState
37
+ target: ActionState
38
+ reasons: frozenset[str] | None
39
+ via_error: bool
40
+
41
+
42
+ _TABLE = json.loads((files("awp") / "_spec" / "lifecycle.json").read_text())
43
+ _CLASSES: dict[ActionState, str] = {ActionState(s): c for s, c in _TABLE["states"].items()}
44
+ TRANSITIONS: dict[tuple[ActionState, ActionState], Transition] = {
45
+ (ActionState(t["from"]), ActionState(t["to"])): Transition(
46
+ ActionState(t["from"]),
47
+ ActionState(t["to"]),
48
+ frozenset(t["reasons"]) if "reasons" in t else None,
49
+ t.get("wire") == "error",
50
+ )
51
+ for t in _TABLE["transitions"]
52
+ }
53
+
54
+
55
+ def permitted(source: ActionState, target: ActionState, reason: str | None = None) -> bool:
56
+ """Whether a status notification may move an action from `source` to `target`."""
57
+ t = TRANSITIONS.get((source, target))
58
+ if t is None or t.via_error:
59
+ return False
60
+ if reason is None or t.reasons is None or reason.startswith("x-"):
61
+ return True
62
+ return reason in t.reasons
63
+
64
+
65
+ SUBMIT_FIELDS = (
66
+ "type",
67
+ "params",
68
+ "embodiment_id",
69
+ "preempt",
70
+ "deadline_ms",
71
+ "basis_ts_mono_ns",
72
+ "valid_until_ns",
73
+ )
74
+
75
+
76
+ def same_submission(a: dict[str, Any], b: dict[str, Any]) -> bool:
77
+ """AWP-ACT-009: absent never equals present; x- fields are not compared."""
78
+ return all((f in a) == (f in b) and a.get(f) == b.get(f) for f in SUBMIT_FIELDS)
awp/py.typed ADDED
File without changes
awp/schema.py ADDED
@@ -0,0 +1,158 @@
1
+ """Validation against the canonical schemas bundled from the pinned spec.
2
+
3
+ The canonical schemas are receiver schemas: they accept unknown fields (AWP-VER-003). The sender
4
+ form applies the `x-awp-closed` and `x-awp-lint` annotations, exactly as the spec's CI does.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from functools import cache
11
+ from importlib.resources import files
12
+ from typing import Any
13
+
14
+ from jsonschema import Draft202012Validator
15
+ from referencing import Registry, Resource
16
+ from referencing.jsonschema import DRAFT202012
17
+
18
+ from .errors import AwpError, ErrorCode
19
+
20
+ BASE = "https://agentworldprotocol.com/schemas/v0.1/"
21
+ VENDOR_FIELD = r"^x-[a-z0-9]+\."
22
+ _META = "https://json-schema.org/draft/2020-12/schema"
23
+
24
+
25
+ def _load() -> dict[str, dict[str, Any]]:
26
+ root = files("awp") / "_spec" / "schemas"
27
+ out: dict[str, dict[str, Any]] = {}
28
+
29
+ def walk(node: Any, prefix: str) -> None:
30
+ for child in node.iterdir():
31
+ if child.is_dir():
32
+ walk(child, f"{prefix}{child.name}/")
33
+ elif child.name.endswith(".schema.json"):
34
+ out[prefix + child.name.removesuffix(".schema.json")] = json.loads(
35
+ child.read_text()
36
+ )
37
+
38
+ walk(root, "")
39
+ return out
40
+
41
+
42
+ SCHEMAS = _load()
43
+
44
+ # Method → schema of its params, result, and notification params, as the spec's validator maps them.
45
+ METHODS: dict[str, dict[str, str]] = {
46
+ "initialize": {"params": "agent-manifest", "result": "world-manifest"},
47
+ "world.manifest": {"params": "empty-result", "result": "world-manifest"},
48
+ "ping": {"params": "ping", "result": "ping-result"},
49
+ "session.open": {"params": "session-open", "result": "session-ready"},
50
+ "session.resume": {"params": "session-resume", "result": "session-ready"},
51
+ "session.close": {"params": "empty-result", "result": "empty-result"},
52
+ "session.transfer": {"params": "session-transfer", "result": "session-transfer-result"},
53
+ "session.state": {"notification": "session-state"},
54
+ "session.telemetry": {"notification": "session-telemetry"},
55
+ "task.update": {"params": "task-update", "result": "empty-result"},
56
+ "obs.subscribe": {"params": "subscribe", "result": "subscribe-result"},
57
+ "obs.unsubscribe": {"params": "unsubscribe", "result": "subscribe-result"},
58
+ "obs.frame": {"notification": "frame-inline"},
59
+ "obs.report": {"notification": "obs-report"},
60
+ "cmd.frame": {"notification": "frame-inline"},
61
+ "action.submit": {"params": "action-submit", "result": "action-submit-result"},
62
+ "action.cancel": {"params": "action-ref", "result": "action-cancel-result"},
63
+ "action.status": {
64
+ "params": "action-ref",
65
+ "result": "action-status",
66
+ "notification": "action-status",
67
+ },
68
+ "world.tick": {"params": "tick", "result": "tick-result"},
69
+ "world.snapshot": {"params": "empty-result", "result": "snapshot-result"},
70
+ "world.restore": {"params": "restore", "result": "reset-result"},
71
+ "world.reset": {"params": "reset", "result": "reset-result"},
72
+ "world.event": {"notification": "world-event"},
73
+ "safety.approval_requested": {"notification": "approval-requested"},
74
+ "safety.approval.respond": {"params": "approval-respond", "result": "empty-result"},
75
+ }
76
+
77
+
78
+ def schema_for(method: str, part: str) -> str | None:
79
+ """The schema name for `part` ("params", "result", or "notification") of `method`."""
80
+ return METHODS.get(method, {}).get(part)
81
+
82
+
83
+ def lint(node: Any) -> Any:
84
+ """The sender form of a canonical schema."""
85
+ if isinstance(node, list):
86
+ return [lint(v) for v in node]
87
+ if not isinstance(node, dict):
88
+ return node
89
+ out = {k: lint(v) for k, v in node.items() if k not in ("x-awp-closed", "x-awp-lint")}
90
+ if "x-awp-lint" in node:
91
+ out.update(lint(node["x-awp-lint"]))
92
+ if node.get("x-awp-closed") is True:
93
+ out["additionalProperties"] = False
94
+ out["patternProperties"] = {VENDOR_FIELD: {}, **out.get("patternProperties", {})}
95
+ return out
96
+
97
+
98
+ @cache
99
+ def _registry(sender: bool) -> Registry:
100
+ resources = [
101
+ (
102
+ s["$id"],
103
+ Resource.from_contents(lint(s) if sender else s, default_specification=DRAFT202012),
104
+ )
105
+ for s in SCHEMAS.values()
106
+ ]
107
+ return Registry().with_resources(resources)
108
+
109
+
110
+ @cache
111
+ def validator(name: str, *, sender: bool = False) -> Draft202012Validator:
112
+ """Validator for `name` (e.g. "action-submit" or "safety-policy#/$defs/envelope")."""
113
+ file, _, fragment = name.partition("#")
114
+ if file not in SCHEMAS:
115
+ raise KeyError(f"no schema {file!r}")
116
+ ref = BASE + file + ".schema.json" + (f"#{fragment}" if fragment else "")
117
+ return Draft202012Validator({"$ref": ref}, registry=_registry(sender))
118
+
119
+
120
+ def errors(name: str, instance: Any, *, sender: bool = False) -> list[str]:
121
+ return [
122
+ f"{'/'.join(map(str, e.absolute_path)) or '<root>'}: {e.message}"
123
+ for e in validator(name, sender=sender).iter_errors(instance)
124
+ ]
125
+
126
+
127
+ def check(name: str, instance: Any, *, sender: bool = False) -> None:
128
+ """Raise AwpError(MALFORMED) if `instance` does not validate."""
129
+ problems = errors(name, instance, sender=sender)
130
+ if problems:
131
+ raise AwpError(ErrorCode.MALFORMED, f"{name}: " + "; ".join(problems[:3]))
132
+
133
+
134
+ class ParamsValidator:
135
+ """Validates action params against a manifest's params_schema (AWP-ACT-002, AWP-MAN-002).
136
+
137
+ Local `$ref`s resolve against the manifest's own `$defs`; shared primitives against the
138
+ published common schema.
139
+ """
140
+
141
+ _URI = "urn:awp:manifest"
142
+
143
+ def __init__(self, manifest: dict[str, Any]) -> None:
144
+ doc = {**manifest, "$schema": _META}
145
+ registry = _registry(False).with_resource(
146
+ self._URI, Resource.from_contents(doc, default_specification=DRAFT202012)
147
+ )
148
+ self._validators: dict[str, Draft202012Validator] = {}
149
+ for i, decl in enumerate(manifest["action_schemas"]):
150
+ Draft202012Validator.check_schema(decl["params_schema"])
151
+ ref = f"{self._URI}#/action_schemas/{i}/params_schema"
152
+ self._validators[decl["type"]] = Draft202012Validator({"$ref": ref}, registry=registry)
153
+
154
+ def errors(self, action_type: str, params: Any) -> list[str]:
155
+ return [
156
+ f"{'/'.join(map(str, e.absolute_path)) or '<root>'}: {e.message}"
157
+ for e in self._validators[action_type].iter_errors(params)
158
+ ]