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/client.py
ADDED
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
"""The agent side of AWP as a sans-IO state machine.
|
|
2
|
+
|
|
3
|
+
`ClientConnection` never touches a socket or a timer. Callers feed it decoded messages with
|
|
4
|
+
`receive()`, send whatever `outgoing()` returns, and call the request methods to act. Every
|
|
5
|
+
request method returns the JSON-RPC id it used.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections import deque
|
|
14
|
+
from collections.abc import Callable, Iterable
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import frames, jsonrpc, schema
|
|
19
|
+
from .clock import ClockEstimator, sample
|
|
20
|
+
from .errors import AwpError, ErrorCode, ProtocolError
|
|
21
|
+
from .jsonrpc import Message
|
|
22
|
+
from .lifecycle import ActionState, permitted
|
|
23
|
+
|
|
24
|
+
PROTOCOL_VERSIONS = ("0.1",)
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------- events
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class Initialized:
|
|
31
|
+
manifest: dict[str, Any]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class SessionOpened:
|
|
36
|
+
ready: dict[str, Any]
|
|
37
|
+
resumed: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class SessionStateChanged:
|
|
42
|
+
state: str
|
|
43
|
+
reason: str | None
|
|
44
|
+
replayed: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class ActionUpdated:
|
|
49
|
+
action: ActionRecord
|
|
50
|
+
status: dict[str, Any]
|
|
51
|
+
replayed: bool
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class WorldEvent:
|
|
56
|
+
event: str
|
|
57
|
+
params: dict[str, Any]
|
|
58
|
+
replayed: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class FrameReceived:
|
|
63
|
+
channel: str
|
|
64
|
+
frame: frames.Frame
|
|
65
|
+
received_ns: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Telemetry:
|
|
70
|
+
params: dict[str, Any]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class TickCompleted:
|
|
75
|
+
tick: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class ReplayCompleted:
|
|
80
|
+
status_seq: int
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class Response:
|
|
85
|
+
id: int
|
|
86
|
+
method: str
|
|
87
|
+
result: dict[str, Any]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class ErrorResponse:
|
|
92
|
+
id: int
|
|
93
|
+
method: str
|
|
94
|
+
error: AwpError
|
|
95
|
+
action_id: str | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class ApprovalRequested:
|
|
100
|
+
"""For an approver connection: an action awaits its decision (AWP-APR-001)."""
|
|
101
|
+
|
|
102
|
+
params: dict[str, Any]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class ProtocolViolation:
|
|
107
|
+
"""The world broke the specification. Informational; the connection stays usable."""
|
|
108
|
+
|
|
109
|
+
detail: str
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
Event = (
|
|
113
|
+
Initialized
|
|
114
|
+
| SessionOpened
|
|
115
|
+
| SessionStateChanged
|
|
116
|
+
| ActionUpdated
|
|
117
|
+
| WorldEvent
|
|
118
|
+
| FrameReceived
|
|
119
|
+
| Telemetry
|
|
120
|
+
| TickCompleted
|
|
121
|
+
| ReplayCompleted
|
|
122
|
+
| Response
|
|
123
|
+
| ErrorResponse
|
|
124
|
+
| ApprovalRequested
|
|
125
|
+
| ProtocolViolation
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------- state
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(slots=True)
|
|
133
|
+
class ActionRecord:
|
|
134
|
+
action_id: str
|
|
135
|
+
content: dict[str, Any]
|
|
136
|
+
state: ActionState = ActionState.SUBMITTED
|
|
137
|
+
status_seq: int = 0
|
|
138
|
+
status: dict[str, Any] = field(default_factory=dict)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def terminal(self) -> bool:
|
|
142
|
+
return self.state.terminal
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def reason(self) -> str | None:
|
|
146
|
+
return self.status.get("reason")
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def progress(self) -> float | None:
|
|
150
|
+
return self.status.get("progress")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(slots=True)
|
|
154
|
+
class _ChannelStats:
|
|
155
|
+
frames: int = 0
|
|
156
|
+
gaps: int = 0
|
|
157
|
+
last_seq: int | None = None
|
|
158
|
+
last_transit: int | None = None
|
|
159
|
+
binding: str = "inline"
|
|
160
|
+
jitter_sum: int = 0
|
|
161
|
+
jitter_n: int = 0
|
|
162
|
+
staleness: deque[int] = field(default_factory=lambda: deque(maxlen=_SAMPLES))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
_SAMPLES = 4096 # latency samples kept between reports
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _stats(values: Iterable[int]) -> dict[str, int] | None:
|
|
169
|
+
if not values:
|
|
170
|
+
return None
|
|
171
|
+
ordered = sorted(values)
|
|
172
|
+
|
|
173
|
+
def pct(p: float) -> int:
|
|
174
|
+
return ordered[min(len(ordered) - 1, round(p * (len(ordered) - 1)))]
|
|
175
|
+
|
|
176
|
+
return {"count": len(ordered), "p50": pct(0.5), "p95": pct(0.95), "max": ordered[-1]}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------- connection
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ClientConnection:
|
|
183
|
+
def __init__(
|
|
184
|
+
self,
|
|
185
|
+
agent: dict[str, str],
|
|
186
|
+
consumes_modalities: Iterable[str],
|
|
187
|
+
*,
|
|
188
|
+
time_models: Iterable[str] | None = None,
|
|
189
|
+
clock_ns: Callable[[], int] = time.monotonic_ns,
|
|
190
|
+
validate: bool = True,
|
|
191
|
+
) -> None:
|
|
192
|
+
self.agent = agent
|
|
193
|
+
self.consumes_modalities = list(consumes_modalities)
|
|
194
|
+
self.time_models = list(time_models) if time_models else None
|
|
195
|
+
self.clock_ns = clock_ns
|
|
196
|
+
self.validate = validate
|
|
197
|
+
|
|
198
|
+
self.manifest: dict[str, Any] | None = None
|
|
199
|
+
self.ready: dict[str, Any] | None = None
|
|
200
|
+
self.session_state: str | None = None
|
|
201
|
+
self.tick: int | None = None
|
|
202
|
+
self.last_status_seq = 0 # every status_seq up to here has been processed (the ack point)
|
|
203
|
+
self.actions: dict[str, ActionRecord] = {}
|
|
204
|
+
self.clock = ClockEstimator()
|
|
205
|
+
|
|
206
|
+
self._out: list[Message] = []
|
|
207
|
+
self._next_id = 1
|
|
208
|
+
self._pending: dict[int, tuple[str, dict[str, Any]]] = {}
|
|
209
|
+
self._replay_to: int | None = None
|
|
210
|
+
self._seen_above: set[int] = set() # processed status_seqs beyond a gap
|
|
211
|
+
self._session_pings: set[int] = set()
|
|
212
|
+
self._channel_names: dict[int, str] = {}
|
|
213
|
+
self._channel_stats: dict[int, _ChannelStats] = {}
|
|
214
|
+
self._receipts: deque[tuple[int, int]] = deque(maxlen=512) # (ts_mono_ns, agent receipt)
|
|
215
|
+
self._decision_latency: deque[int] = deque(maxlen=_SAMPLES)
|
|
216
|
+
self._last_report_ns: int | None = None
|
|
217
|
+
self._command_seq: dict[int, int] = {}
|
|
218
|
+
|
|
219
|
+
# ------------------------------------------------------------ plumbing
|
|
220
|
+
|
|
221
|
+
def outgoing(self) -> list[Message]:
|
|
222
|
+
"""Drain the messages to send, in order."""
|
|
223
|
+
out, self._out = self._out, []
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def last_id(self) -> int:
|
|
228
|
+
"""The id of the most recent request."""
|
|
229
|
+
return self._next_id - 1
|
|
230
|
+
|
|
231
|
+
def request(self, method: str, params: dict[str, Any] | None = None) -> int:
|
|
232
|
+
rid = self._next_id
|
|
233
|
+
self._next_id += 1
|
|
234
|
+
self._pending[rid] = (method, params or {})
|
|
235
|
+
self._out.append(jsonrpc.request(rid, method, params))
|
|
236
|
+
return rid
|
|
237
|
+
|
|
238
|
+
def forget(self, rid: int) -> None:
|
|
239
|
+
"""Stop waiting for the response to `rid` (e.g. after a timeout)."""
|
|
240
|
+
self._pending.pop(rid, None)
|
|
241
|
+
self._session_pings.discard(rid)
|
|
242
|
+
|
|
243
|
+
def connection_lost(self) -> None:
|
|
244
|
+
"""The transport closed. Unanswered requests will never be answered on this connection."""
|
|
245
|
+
self._pending.clear()
|
|
246
|
+
self._session_pings.clear()
|
|
247
|
+
if self.session_state not in (None, "closed"):
|
|
248
|
+
self.session_state = "suspended"
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def replaying(self) -> bool:
|
|
252
|
+
"""True between a session.resume result and the end of its replay."""
|
|
253
|
+
return self._replay_to is not None
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def session_token(self) -> str | None:
|
|
257
|
+
return self.ready["session_token"] if self.ready else None
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def channels(self) -> dict[str, int]:
|
|
261
|
+
"""Granted channel name → channel_id."""
|
|
262
|
+
return {name: cid for cid, name in self._channel_names.items()}
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def stream_endpoints(self) -> list[dict[str, Any]]:
|
|
266
|
+
"""Endpoints from session.ready, by world preference; inline is always last."""
|
|
267
|
+
return list(self.ready.get("stream_endpoints", [])) if self.ready else []
|
|
268
|
+
|
|
269
|
+
def receive_frame(self, data: bytes) -> list[Event]:
|
|
270
|
+
"""A binary frame from a stream connection (AWP-TRN-003)."""
|
|
271
|
+
events: list[Event] = []
|
|
272
|
+
try:
|
|
273
|
+
frame = frames.decode(data)
|
|
274
|
+
except AwpError as exc:
|
|
275
|
+
return [ProtocolViolation(f"stream frame: {exc}")]
|
|
276
|
+
self._frame(frame, "ws", events)
|
|
277
|
+
return events
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def granted_action_types(self) -> list[str]:
|
|
281
|
+
"""Action types this session may submit; empty before a session opens."""
|
|
282
|
+
if self.ready is None:
|
|
283
|
+
return []
|
|
284
|
+
return list(self.ready["granted"]["action_types"])
|
|
285
|
+
|
|
286
|
+
# ------------------------------------------------------------ requests
|
|
287
|
+
|
|
288
|
+
def initialize(self) -> int:
|
|
289
|
+
params: dict[str, Any] = {
|
|
290
|
+
"protocol_versions": list(PROTOCOL_VERSIONS),
|
|
291
|
+
"agent": self.agent,
|
|
292
|
+
"consumes_modalities": self.consumes_modalities,
|
|
293
|
+
}
|
|
294
|
+
if self.time_models:
|
|
295
|
+
params["time_models"] = self.time_models
|
|
296
|
+
return self.request("initialize", params)
|
|
297
|
+
|
|
298
|
+
def open_session(
|
|
299
|
+
self,
|
|
300
|
+
mode: str,
|
|
301
|
+
*,
|
|
302
|
+
embodiment: str | None = None,
|
|
303
|
+
subscribe: Iterable[dict[str, Any] | str] = (),
|
|
304
|
+
action_types: Iterable[str] | None = None,
|
|
305
|
+
admin: Iterable[str] | None = None,
|
|
306
|
+
**extra: Any,
|
|
307
|
+
) -> int:
|
|
308
|
+
if self.manifest is None:
|
|
309
|
+
raise ProtocolError("initialize first")
|
|
310
|
+
params: dict[str, Any] = {"mode": mode, **extra}
|
|
311
|
+
if embodiment is not None:
|
|
312
|
+
params["embodiment"] = embodiment
|
|
313
|
+
subs = [s if isinstance(s, dict) else {"channel": s} for s in subscribe]
|
|
314
|
+
if subs:
|
|
315
|
+
params["subscribe"] = subs
|
|
316
|
+
if action_types is not None:
|
|
317
|
+
params["action_types"] = list(action_types)
|
|
318
|
+
if admin is not None:
|
|
319
|
+
params["admin"] = list(admin)
|
|
320
|
+
return self.request("session.open", params)
|
|
321
|
+
|
|
322
|
+
def resume(self) -> int:
|
|
323
|
+
if self.ready is None:
|
|
324
|
+
raise ProtocolError("no session to resume")
|
|
325
|
+
return self.request(
|
|
326
|
+
"session.resume",
|
|
327
|
+
{"session_token": self.ready["session_token"], "last_status_seq": self.last_status_seq},
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def close(self) -> int:
|
|
331
|
+
return self.request("session.close")
|
|
332
|
+
|
|
333
|
+
def submit(
|
|
334
|
+
self,
|
|
335
|
+
type: str,
|
|
336
|
+
params: dict[str, Any],
|
|
337
|
+
*,
|
|
338
|
+
action_id: str | None = None,
|
|
339
|
+
preempt: str | None = None,
|
|
340
|
+
deadline_ms: int | None = None,
|
|
341
|
+
basis: frames.Frame | int | None = None,
|
|
342
|
+
valid_until_ns: int | None = None,
|
|
343
|
+
valid_for_ms: float | None = None,
|
|
344
|
+
embodiment_id: str | None = None,
|
|
345
|
+
) -> str:
|
|
346
|
+
"""Submit an action; returns its action_id. The admission arrives as a Response.
|
|
347
|
+
|
|
348
|
+
`basis` is the observation the intent rests on (a frame or its `ts_mono_ns`). The validity
|
|
349
|
+
window is either an absolute session-clock `valid_until_ns` or `valid_for_ms` from now,
|
|
350
|
+
mapped through the clock offset (AWP-CLK-009).
|
|
351
|
+
"""
|
|
352
|
+
action_id = action_id or f"a-{uuid.uuid4().hex[:16]}"
|
|
353
|
+
if action_id in self.actions:
|
|
354
|
+
raise ValueError(f"action_id {action_id} already used; use resubmit() to retry")
|
|
355
|
+
if type not in self.granted_action_types:
|
|
356
|
+
raise AwpError(ErrorCode.FORBIDDEN, f"{type} is not granted (AWP-AGT-003)")
|
|
357
|
+
now = self.clock_ns()
|
|
358
|
+
content: dict[str, Any] = {"action_id": action_id, "type": type, "params": params}
|
|
359
|
+
if embodiment_id is not None:
|
|
360
|
+
content["embodiment_id"] = embodiment_id
|
|
361
|
+
if preempt is not None:
|
|
362
|
+
content["preempt"] = preempt
|
|
363
|
+
if deadline_ms is not None:
|
|
364
|
+
content["deadline_ms"] = deadline_ms
|
|
365
|
+
if basis is not None:
|
|
366
|
+
basis_ts = basis.ts_mono_ns if isinstance(basis, frames.Frame) else basis
|
|
367
|
+
content["basis_ts_mono_ns"] = basis_ts
|
|
368
|
+
received = self._receipt_of(basis_ts)
|
|
369
|
+
if received is not None:
|
|
370
|
+
self._decision_latency.append(now - received)
|
|
371
|
+
if valid_until_ns is not None:
|
|
372
|
+
content["valid_until_ns"] = valid_until_ns
|
|
373
|
+
elif valid_for_ms is not None:
|
|
374
|
+
content["valid_until_ns"] = self.clock.to_session(now) + int(valid_for_ms * 1e6)
|
|
375
|
+
self.actions[action_id] = ActionRecord(action_id, content)
|
|
376
|
+
self.request("action.submit", content)
|
|
377
|
+
return action_id
|
|
378
|
+
|
|
379
|
+
def resubmit(self, action_id: str) -> int:
|
|
380
|
+
"""Retry a submission with identical content. While the world retains the action it is
|
|
381
|
+
not executed again (AWP-ACT-001, AWP-ACT-006)."""
|
|
382
|
+
return self.request("action.submit", self.actions[action_id].content)
|
|
383
|
+
|
|
384
|
+
def cancel(self, action_id: str) -> int:
|
|
385
|
+
return self.request("action.cancel", {"action_id": action_id})
|
|
386
|
+
|
|
387
|
+
def pull_status(self, action_id: str) -> int:
|
|
388
|
+
return self.request("action.status", {"action_id": action_id})
|
|
389
|
+
|
|
390
|
+
def advance(self, count: int | None = None) -> int:
|
|
391
|
+
"""world.tick from the tick the agent holds (AWP-TIM-011). Never implicit (AWP-AGT-009)."""
|
|
392
|
+
if self.tick is None:
|
|
393
|
+
raise ProtocolError("not a lockstep session")
|
|
394
|
+
params: dict[str, Any] = {"expected_tick": self.tick}
|
|
395
|
+
if count is not None:
|
|
396
|
+
params["count"] = count
|
|
397
|
+
return self.request("world.tick", params)
|
|
398
|
+
|
|
399
|
+
def update_task(self, task: dict[str, Any]) -> int:
|
|
400
|
+
"""Replace the session's task (AWP-TSK-003); needs capabilities.task."""
|
|
401
|
+
return self.request("task.update", {"task": task})
|
|
402
|
+
|
|
403
|
+
def transfer(self, expires_in_ms: int | None = None) -> int:
|
|
404
|
+
"""Mint a single-use token another session presents to take the embodiment over."""
|
|
405
|
+
return self.request(
|
|
406
|
+
"session.transfer", {} if expires_in_ms is None else {"expires_in_ms": expires_in_ms}
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def reset(self, initial_state: str | None = None, seed: int | None = None) -> int:
|
|
410
|
+
params = {
|
|
411
|
+
k: v for k, v in (("initial_state", initial_state), ("seed", seed)) if v is not None
|
|
412
|
+
}
|
|
413
|
+
return self.request("world.reset", params)
|
|
414
|
+
|
|
415
|
+
def snapshot(self) -> int:
|
|
416
|
+
return self.request("world.snapshot", {})
|
|
417
|
+
|
|
418
|
+
def restore(self, snapshot_token: str) -> int:
|
|
419
|
+
return self.request("world.restore", {"snapshot_token": snapshot_token})
|
|
420
|
+
|
|
421
|
+
def respond_approval(self, approval_id: str, decision: str, note: str | None = None) -> int:
|
|
422
|
+
"""For an approver connection: `approve` or `deny` (AWP-APR-002)."""
|
|
423
|
+
params = {"approval_id": approval_id, "decision": decision}
|
|
424
|
+
if note is not None:
|
|
425
|
+
params["note"] = note
|
|
426
|
+
return self.request("safety.approval.respond", params)
|
|
427
|
+
|
|
428
|
+
def command(
|
|
429
|
+
self, channel: str, payload: bytes | dict[str, Any], *, inline: bool = True
|
|
430
|
+
) -> frames.Frame:
|
|
431
|
+
"""A setpoint on a granted command channel (AWP-CMD-007). Its `ts_mono_ns` is the issue
|
|
432
|
+
time mapped through the clock offset (AWP-CLK-009). With `inline`, it is queued as a
|
|
433
|
+
`cmd.frame` notification; otherwise the caller sends it on a stream connection."""
|
|
434
|
+
cid = self.channels[channel]
|
|
435
|
+
seq = self._command_seq[cid] = self._command_seq.get(cid, 0) + 1
|
|
436
|
+
data = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
|
|
437
|
+
frame = frames.Frame(cid, seq, self.clock.to_session(self.clock_ns()), data)
|
|
438
|
+
if inline:
|
|
439
|
+
self._out.append(jsonrpc.notification("cmd.frame", frames.to_inline(frame)))
|
|
440
|
+
return frame
|
|
441
|
+
|
|
442
|
+
def subscribe(self, channels: Iterable[dict[str, Any] | str]) -> int:
|
|
443
|
+
subs = [c if isinstance(c, dict) else {"channel": c} for c in channels]
|
|
444
|
+
return self.request("obs.subscribe", {"channels": subs})
|
|
445
|
+
|
|
446
|
+
def unsubscribe(self, channels: Iterable[str]) -> int:
|
|
447
|
+
return self.request("obs.unsubscribe", {"channels": list(channels)})
|
|
448
|
+
|
|
449
|
+
def ping(self, *, ack: bool = True) -> int:
|
|
450
|
+
"""A heartbeat and clock-sync sample; with `ack`, acknowledges delivery (AWP-CTL-010)."""
|
|
451
|
+
now = self.clock_ns()
|
|
452
|
+
params: dict[str, Any] = {"origin_ns": now}
|
|
453
|
+
if ack and self.ready is not None:
|
|
454
|
+
params["last_status_seq"] = self.last_status_seq
|
|
455
|
+
rid = self.request("ping", params)
|
|
456
|
+
if self.ready is not None and "tick" not in self.ready:
|
|
457
|
+
# Only streaming pongs are stamped on a clock the offset can track (AWP-CLK-008).
|
|
458
|
+
self._session_pings.add(rid)
|
|
459
|
+
return rid
|
|
460
|
+
|
|
461
|
+
def report(self) -> Message:
|
|
462
|
+
"""Queue an obs.report covering the time since the previous one (AWP-OBS-007)."""
|
|
463
|
+
now = self.clock_ns()
|
|
464
|
+
best = self.clock.best
|
|
465
|
+
if best is None:
|
|
466
|
+
raise ProtocolError("no clock sample yet")
|
|
467
|
+
since = self._last_report_ns if self._last_report_ns is not None else now - 1_000_000_000
|
|
468
|
+
channels: dict[str, Any] = {}
|
|
469
|
+
for cid, st in self._channel_stats.items():
|
|
470
|
+
entry: dict[str, Any] = {"frames": st.frames, "gaps": st.gaps}
|
|
471
|
+
if st.jitter_n:
|
|
472
|
+
entry["jitter_ns"] = st.jitter_sum // st.jitter_n
|
|
473
|
+
staleness = _stats(st.staleness)
|
|
474
|
+
if staleness:
|
|
475
|
+
entry["staleness_ns"] = staleness
|
|
476
|
+
channels[str(cid)] = entry
|
|
477
|
+
self._channel_stats[cid] = _ChannelStats(
|
|
478
|
+
last_seq=st.last_seq, last_transit=st.last_transit
|
|
479
|
+
)
|
|
480
|
+
params: dict[str, Any] = {
|
|
481
|
+
"window_ms": max(1, (now - since) // 1_000_000),
|
|
482
|
+
"sync": {
|
|
483
|
+
"offset_ns": best.offset_ns,
|
|
484
|
+
"rtt_ns": best.rtt_ns,
|
|
485
|
+
"samples": self.clock.samples,
|
|
486
|
+
},
|
|
487
|
+
"channels": channels,
|
|
488
|
+
}
|
|
489
|
+
decision = _stats(self._decision_latency)
|
|
490
|
+
if decision:
|
|
491
|
+
params["decision_latency_ns"] = decision
|
|
492
|
+
self._decision_latency.clear()
|
|
493
|
+
self._last_report_ns = now
|
|
494
|
+
msg = jsonrpc.notification("obs.report", params)
|
|
495
|
+
self._out.append(msg)
|
|
496
|
+
return msg
|
|
497
|
+
|
|
498
|
+
# ------------------------------------------------------------ input
|
|
499
|
+
|
|
500
|
+
def receive(self, msg: Message) -> list[Event]:
|
|
501
|
+
if jsonrpc.is_response(msg):
|
|
502
|
+
return self._on_response(msg)
|
|
503
|
+
if jsonrpc.is_request(msg):
|
|
504
|
+
return self._on_world_request(msg)
|
|
505
|
+
return self._on_notification(msg)
|
|
506
|
+
|
|
507
|
+
def _check(self, name: str, instance: Any, events: list[Event]) -> bool:
|
|
508
|
+
if not self.validate:
|
|
509
|
+
return True
|
|
510
|
+
problems = schema.errors(name, instance)
|
|
511
|
+
if problems:
|
|
512
|
+
events.append(ProtocolViolation(f"{name}: {'; '.join(problems[:3])}"))
|
|
513
|
+
return not problems
|
|
514
|
+
|
|
515
|
+
def _on_world_request(self, msg: Message) -> list[Event]:
|
|
516
|
+
if msg["method"] == "ping":
|
|
517
|
+
now = self.clock_ns()
|
|
518
|
+
origin = (msg.get("params") or {}).get("origin_ns", 0)
|
|
519
|
+
self._out.append(
|
|
520
|
+
jsonrpc.result(
|
|
521
|
+
msg["id"],
|
|
522
|
+
{"origin_ns": origin, "receive_ns": now, "transmit_ns": self.clock_ns()},
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
err = AwpError(ErrorCode.METHOD_NOT_FOUND, msg["method"])
|
|
527
|
+
self._out.append(jsonrpc.error(msg["id"], err))
|
|
528
|
+
return []
|
|
529
|
+
|
|
530
|
+
def _on_response(self, msg: Message) -> list[Event]:
|
|
531
|
+
events: list[Event] = []
|
|
532
|
+
pending = self._pending.pop(msg["id"], None) if isinstance(msg["id"], int) else None
|
|
533
|
+
if pending is None:
|
|
534
|
+
return [ProtocolViolation(f"response to unknown request id {msg['id']!r}")]
|
|
535
|
+
method, params = pending
|
|
536
|
+
if "error" in msg:
|
|
537
|
+
err = AwpError.from_dict(msg["error"])
|
|
538
|
+
action_id = params.get("action_id") if method.startswith("action.") else None
|
|
539
|
+
if method == "action.submit":
|
|
540
|
+
rec = self.actions.get(params["action_id"])
|
|
541
|
+
if (
|
|
542
|
+
rec
|
|
543
|
+
and rec.state is ActionState.SUBMITTED
|
|
544
|
+
and err.code != ErrorCode.ACTION_ID_CONFLICT
|
|
545
|
+
):
|
|
546
|
+
del self.actions[params["action_id"]] # no action was created (AWP-ACT-010)
|
|
547
|
+
elif (
|
|
548
|
+
method == "world.tick"
|
|
549
|
+
and err.code == ErrorCode.TICK_MISMATCH
|
|
550
|
+
and "tick" in err.data
|
|
551
|
+
):
|
|
552
|
+
self.tick = err.data["tick"]
|
|
553
|
+
events.append(ErrorResponse(msg["id"], method, err, action_id))
|
|
554
|
+
return events
|
|
555
|
+
res = msg["result"]
|
|
556
|
+
result_schema = schema.schema_for(method, "result")
|
|
557
|
+
valid = result_schema is None or self._check(result_schema, res, events)
|
|
558
|
+
if not valid and method in ("initialize", "world.manifest"):
|
|
559
|
+
raise ProtocolError("world manifest does not validate (AWP-AGT-002)")
|
|
560
|
+
if method == "ping":
|
|
561
|
+
if msg["id"] in self._session_pings:
|
|
562
|
+
self._session_pings.discard(msg["id"])
|
|
563
|
+
if valid:
|
|
564
|
+
received = self.clock_ns()
|
|
565
|
+
s = sample(params["origin_ns"], res["receive_ns"], res["transmit_ns"], received)
|
|
566
|
+
self.clock.add(s)
|
|
567
|
+
elif valid:
|
|
568
|
+
handler = getattr(self, "_result_" + method.replace(".", "_"), None)
|
|
569
|
+
if handler is not None:
|
|
570
|
+
handler(params, res, events)
|
|
571
|
+
events.append(Response(msg["id"], method, res))
|
|
572
|
+
return events
|
|
573
|
+
|
|
574
|
+
def _result_initialize(
|
|
575
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
576
|
+
) -> None:
|
|
577
|
+
if res.get("protocol_version") not in PROTOCOL_VERSIONS:
|
|
578
|
+
raise ProtocolError(
|
|
579
|
+
f"world selected unsupported version {res.get('protocol_version')!r}"
|
|
580
|
+
)
|
|
581
|
+
self.manifest = res
|
|
582
|
+
events.append(Initialized(res))
|
|
583
|
+
|
|
584
|
+
_result_world_manifest = _result_initialize
|
|
585
|
+
|
|
586
|
+
def _result_session_open(
|
|
587
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
588
|
+
) -> None:
|
|
589
|
+
self.ready = res
|
|
590
|
+
self.clock = ClockEstimator() # a new session clock
|
|
591
|
+
self._seen_above.clear()
|
|
592
|
+
self.last_status_seq = 0
|
|
593
|
+
self.tick = res.get("tick")
|
|
594
|
+
self._set_channels(res["granted"]["channels"])
|
|
595
|
+
self.session_state = "ready"
|
|
596
|
+
events.append(SessionOpened(res, resumed=False))
|
|
597
|
+
|
|
598
|
+
def _result_session_resume(
|
|
599
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
600
|
+
) -> None:
|
|
601
|
+
self.ready = {**res, "session_token": res.get("session_token", self.session_token)}
|
|
602
|
+
if "tick" in res:
|
|
603
|
+
self.tick = res["tick"]
|
|
604
|
+
self._set_channels(res["granted"]["channels"])
|
|
605
|
+
for st in self._channel_stats.values():
|
|
606
|
+
st.last_seq = None # the seq gap across a resumption is not loss
|
|
607
|
+
self._replay_to = res.get("replay_to_status_seq")
|
|
608
|
+
events.append(SessionOpened(res, resumed=True))
|
|
609
|
+
if self._replay_to is not None and self._replay_to <= self.last_status_seq:
|
|
610
|
+
events.append(ReplayCompleted(self._replay_to))
|
|
611
|
+
self._replay_to = None
|
|
612
|
+
|
|
613
|
+
def _result_session_close(
|
|
614
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
615
|
+
) -> None:
|
|
616
|
+
self.session_state = "closed"
|
|
617
|
+
|
|
618
|
+
def _result_action_submit(
|
|
619
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
620
|
+
) -> None:
|
|
621
|
+
rec = self.actions.get(res["action_id"])
|
|
622
|
+
if rec is None:
|
|
623
|
+
rec = self.actions[res["action_id"]] = ActionRecord(res["action_id"], params)
|
|
624
|
+
if rec.state is ActionState.SUBMITTED:
|
|
625
|
+
self._apply_status(res, events)
|
|
626
|
+
elif res["status_seq"] > rec.status_seq:
|
|
627
|
+
self._apply_status(res, events) # idempotent result newer than anything we processed
|
|
628
|
+
|
|
629
|
+
def _result_action_cancel(
|
|
630
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
631
|
+
) -> None:
|
|
632
|
+
self._apply_status(res, events)
|
|
633
|
+
|
|
634
|
+
def _result_action_status(
|
|
635
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
636
|
+
) -> None:
|
|
637
|
+
rec = self.actions.get(res["action_id"])
|
|
638
|
+
if rec is not None and res["status_seq"] > rec.status_seq:
|
|
639
|
+
self._apply_status(res, events)
|
|
640
|
+
|
|
641
|
+
def _result_world_tick(
|
|
642
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
643
|
+
) -> None:
|
|
644
|
+
self.tick = res["tick"]
|
|
645
|
+
events.append(TickCompleted(res["tick"]))
|
|
646
|
+
|
|
647
|
+
def _result_world_reset(
|
|
648
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
649
|
+
) -> None:
|
|
650
|
+
if "tick" in res:
|
|
651
|
+
self.tick = res["tick"]
|
|
652
|
+
|
|
653
|
+
_result_world_restore = _result_world_reset
|
|
654
|
+
|
|
655
|
+
def _result_obs_subscribe(
|
|
656
|
+
self, params: dict[str, Any], res: dict[str, Any], events: list[Event]
|
|
657
|
+
) -> None:
|
|
658
|
+
self._set_channels(res["granted"])
|
|
659
|
+
|
|
660
|
+
_result_obs_unsubscribe = _result_obs_subscribe
|
|
661
|
+
|
|
662
|
+
def _on_notification(self, msg: Message) -> list[Event]:
|
|
663
|
+
method, params = msg["method"], msg.get("params") or {}
|
|
664
|
+
events: list[Event] = []
|
|
665
|
+
note_schema = schema.schema_for(method, "notification")
|
|
666
|
+
if note_schema is not None and not self._check(note_schema, params, events):
|
|
667
|
+
return events
|
|
668
|
+
if method == "obs.frame":
|
|
669
|
+
self._on_frame(params, events)
|
|
670
|
+
elif method == "session.telemetry":
|
|
671
|
+
events.append(Telemetry(params))
|
|
672
|
+
elif method == "safety.approval_requested":
|
|
673
|
+
events.append(ApprovalRequested(params))
|
|
674
|
+
elif method == "action.status":
|
|
675
|
+
self._apply_status(params, events)
|
|
676
|
+
elif method == "world.event":
|
|
677
|
+
if self._sequence(params["status_seq"], events):
|
|
678
|
+
events.append(WorldEvent(params["event"], params, self._replaying(params)))
|
|
679
|
+
elif method == "session.state" and self._sequence(params["status_seq"], events):
|
|
680
|
+
self.session_state = params["state"]
|
|
681
|
+
events.append(
|
|
682
|
+
SessionStateChanged(params["state"], params.get("reason"), self._replaying(params))
|
|
683
|
+
)
|
|
684
|
+
self._finish_replay(events)
|
|
685
|
+
return events
|
|
686
|
+
|
|
687
|
+
# ------------------------------------------------------------ status sequencing
|
|
688
|
+
|
|
689
|
+
def _replaying(self, params: dict[str, Any]) -> bool:
|
|
690
|
+
return self._replay_to is not None and params["status_seq"] <= self._replay_to
|
|
691
|
+
|
|
692
|
+
def _sequence(self, seq: int, events: list[Event]) -> bool:
|
|
693
|
+
"""True for a status_seq not yet processed; False for a redelivery (AWP-LIF-009).
|
|
694
|
+
|
|
695
|
+
A gap is reported but not acknowledged: `last_status_seq` stays below it, so a resume
|
|
696
|
+
replays what is missing instead of the world discarding it.
|
|
697
|
+
"""
|
|
698
|
+
if seq <= self.last_status_seq or seq in self._seen_above:
|
|
699
|
+
return False
|
|
700
|
+
if seq != self.last_status_seq + 1:
|
|
701
|
+
events.append(ProtocolViolation(f"status_seq {seq} after {self.last_status_seq}"))
|
|
702
|
+
self._seen_above.add(seq)
|
|
703
|
+
return True
|
|
704
|
+
self.last_status_seq = seq
|
|
705
|
+
while self.last_status_seq + 1 in self._seen_above:
|
|
706
|
+
self._seen_above.remove(self.last_status_seq + 1)
|
|
707
|
+
self.last_status_seq += 1
|
|
708
|
+
return True
|
|
709
|
+
|
|
710
|
+
def _finish_replay(self, events: list[Event]) -> None:
|
|
711
|
+
if self._replay_to is not None and self.last_status_seq >= self._replay_to:
|
|
712
|
+
events.append(ReplayCompleted(self._replay_to))
|
|
713
|
+
self._replay_to = None
|
|
714
|
+
|
|
715
|
+
def _apply_status(self, status: dict[str, Any], events: list[Event]) -> None:
|
|
716
|
+
if not self._sequence(status["status_seq"], events):
|
|
717
|
+
return
|
|
718
|
+
replayed = self._replaying(status)
|
|
719
|
+
action_id = status["action_id"]
|
|
720
|
+
rec = self.actions.get(action_id)
|
|
721
|
+
if rec is None:
|
|
722
|
+
rec = self.actions[action_id] = ActionRecord(action_id, {"action_id": action_id})
|
|
723
|
+
target = ActionState(status["state"])
|
|
724
|
+
if rec.state.terminal:
|
|
725
|
+
events.append(ProtocolViolation(f"{action_id}: {target} after terminal {rec.state}"))
|
|
726
|
+
elif target is not rec.state and not permitted(rec.state, target, status.get("reason")):
|
|
727
|
+
events.append(
|
|
728
|
+
ProtocolViolation(f"{action_id}: {rec.state} → {target} is not permitted")
|
|
729
|
+
)
|
|
730
|
+
rec.state = target
|
|
731
|
+
rec.status_seq = status["status_seq"]
|
|
732
|
+
rec.status = status
|
|
733
|
+
events.append(ActionUpdated(rec, status, replayed))
|
|
734
|
+
self._finish_replay(events)
|
|
735
|
+
|
|
736
|
+
# ------------------------------------------------------------ frames
|
|
737
|
+
|
|
738
|
+
def _set_channels(self, grants: list[dict[str, Any]]) -> None:
|
|
739
|
+
self._channel_names = {g["channel_id"]: g["channel"] for g in grants}
|
|
740
|
+
|
|
741
|
+
def _receipt_of(self, ts_mono_ns: int) -> int | None:
|
|
742
|
+
return next((r for ts, r in reversed(self._receipts) if ts == ts_mono_ns), None)
|
|
743
|
+
|
|
744
|
+
def _on_frame(self, params: dict[str, Any], events: list[Event]) -> None:
|
|
745
|
+
try:
|
|
746
|
+
frame = frames.from_inline(params)
|
|
747
|
+
except AwpError as exc:
|
|
748
|
+
events.append(ProtocolViolation(str(exc)))
|
|
749
|
+
return
|
|
750
|
+
self._frame(frame, "inline", events)
|
|
751
|
+
|
|
752
|
+
def _frame(self, frame: frames.Frame, binding: str, events: list[Event]) -> None:
|
|
753
|
+
now = self.clock_ns()
|
|
754
|
+
name = self._channel_names.get(frame.channel_id)
|
|
755
|
+
if name is None:
|
|
756
|
+
events.append(ProtocolViolation(f"frame on ungranted channel {frame.channel_id}"))
|
|
757
|
+
return
|
|
758
|
+
st = self._channel_stats.setdefault(frame.channel_id, _ChannelStats())
|
|
759
|
+
if st.last_seq is not None and frame.seq <= st.last_seq and binding != st.binding:
|
|
760
|
+
return # overtaken on the connection the channel moved to (AWP-TRN-012)
|
|
761
|
+
st.binding = binding
|
|
762
|
+
if st.last_seq is not None:
|
|
763
|
+
if frame.seq <= st.last_seq:
|
|
764
|
+
events.append(ProtocolViolation(f"channel {name}: seq {frame.seq} not increasing"))
|
|
765
|
+
elif frame.seq != st.last_seq + 1 and not frame.resync:
|
|
766
|
+
st.gaps += frame.seq - st.last_seq - 1
|
|
767
|
+
st.last_seq = frame.seq
|
|
768
|
+
st.frames += 1
|
|
769
|
+
offset = self.clock.offset_ns
|
|
770
|
+
if offset is not None and frame.ts_send_ns is not None:
|
|
771
|
+
received = now + offset
|
|
772
|
+
st.staleness.append(max(0, received - frame.ts_mono_ns))
|
|
773
|
+
transit = received - frame.ts_send_ns
|
|
774
|
+
if st.last_transit is not None:
|
|
775
|
+
st.jitter_sum += abs(transit - st.last_transit)
|
|
776
|
+
st.jitter_n += 1
|
|
777
|
+
st.last_transit = transit
|
|
778
|
+
self._receipts.append((frame.ts_mono_ns, now))
|
|
779
|
+
if self.session_state == "ready":
|
|
780
|
+
self.session_state = "active"
|
|
781
|
+
events.append(FrameReceived(name, frame, now))
|