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.
- awp/__init__.py +64 -0
- awp/_spec/lifecycle.json +190 -0
- awp/_spec/schemas/action-cancel-result.schema.json +13 -0
- awp/_spec/schemas/action-ref.schema.json +11 -0
- awp/_spec/schemas/action-schema.schema.json +37 -0
- awp/_spec/schemas/action-status.schema.json +39 -0
- awp/_spec/schemas/action-submit-result.schema.json +16 -0
- awp/_spec/schemas/action-submit.schema.json +19 -0
- awp/_spec/schemas/agent-manifest.schema.json +27 -0
- awp/_spec/schemas/approval-requested.schema.json +25 -0
- awp/_spec/schemas/approval-respond.schema.json +31 -0
- awp/_spec/schemas/common.schema.json +150 -0
- awp/_spec/schemas/embodiment.schema.json +22 -0
- awp/_spec/schemas/empty-result.schema.json +8 -0
- awp/_spec/schemas/error.schema.json +25 -0
- awp/_spec/schemas/frame-inline.schema.json +20 -0
- awp/_spec/schemas/frame-tree.schema.json +26 -0
- awp/_spec/schemas/obs-report.schema.json +40 -0
- awp/_spec/schemas/observation-channel.schema.json +28 -0
- awp/_spec/schemas/ping-result.schema.json +13 -0
- awp/_spec/schemas/ping.schema.json +12 -0
- awp/_spec/schemas/profiles/gui-actions.schema.json +57 -0
- awp/_spec/schemas/reset-result.schema.json +11 -0
- awp/_spec/schemas/reset.schema.json +12 -0
- awp/_spec/schemas/restore.schema.json +11 -0
- awp/_spec/schemas/safety-policy.schema.json +81 -0
- awp/_spec/schemas/session-open.schema.json +41 -0
- awp/_spec/schemas/session-ready.schema.json +50 -0
- awp/_spec/schemas/session-resume.schema.json +12 -0
- awp/_spec/schemas/session-state.schema.json +14 -0
- awp/_spec/schemas/session-telemetry.schema.json +22 -0
- awp/_spec/schemas/session-transfer-result.schema.json +12 -0
- awp/_spec/schemas/session-transfer.schema.json +11 -0
- awp/_spec/schemas/snapshot-result.schema.json +11 -0
- awp/_spec/schemas/subscribe-result.schema.json +11 -0
- awp/_spec/schemas/subscribe.schema.json +11 -0
- awp/_spec/schemas/task-update.schema.json +11 -0
- awp/_spec/schemas/tick-result.schema.json +9 -0
- awp/_spec/schemas/tick.schema.json +12 -0
- awp/_spec/schemas/unsubscribe.schema.json +11 -0
- awp/_spec/schemas/world-event.schema.json +45 -0
- awp/_spec/schemas/world-manifest.schema.json +61 -0
- awp/aio.py +380 -0
- awp/client.py +781 -0
- awp/clock.py +61 -0
- awp/errors.py +112 -0
- awp/frames.py +174 -0
- awp/jsonrpc.py +84 -0
- awp/lifecycle.py +78 -0
- awp/py.typed +0 -0
- awp/schema.py +158 -0
- awp_python-0.1.0a1.dist-info/METADATA +127 -0
- awp_python-0.1.0a1.dist-info/RECORD +71 -0
- awp_python-0.1.0a1.dist-info/WHEEL +4 -0
- awp_python-0.1.0a1.dist-info/entry_points.txt +2 -0
- awp_python-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- awp_sim/__init__.py +8 -0
- awp_sim/__main__.py +5 -0
- awp_sim/arm.py +163 -0
- awp_sim/audit.py +138 -0
- awp_sim/cli.py +238 -0
- awp_sim/config.py +225 -0
- awp_sim/demo.py +85 -0
- awp_sim/loopback.py +222 -0
- awp_sim/py.typed +0 -0
- awp_sim/recorder.py +72 -0
- awp_sim/replay.py +134 -0
- awp_sim/scenarios.py +427 -0
- awp_sim/server.py +333 -0
- awp_sim/session.py +153 -0
- 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
|
+
]
|