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
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://agentworldprotocol.com/schemas/v0.1/tick-result.schema.json",
4
+ "title": "world.tick result (AWP-TIM-011)",
5
+ "type": "object",
6
+ "properties": { "tick": { "$ref": "common.schema.json#/$defs/tick" } },
7
+ "required": ["tick"],
8
+ "x-awp-closed": true
9
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://agentworldprotocol.com/schemas/v0.1/tick.schema.json",
4
+ "title": "world.tick params (AWP-TIM-011)",
5
+ "type": "object",
6
+ "properties": {
7
+ "expected_tick": {"$ref": "common.schema.json#/$defs/tick", "description": "The current tick as the agent knows it; a mismatch fails with AWP_TICK_MISMATCH."},
8
+ "count": {"type": "integer", "minimum": 1, "default": 1, "description": "Advances to perform."}
9
+ },
10
+ "required": ["expected_tick"],
11
+ "x-awp-closed": true
12
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://agentworldprotocol.com/schemas/v0.1/unsubscribe.schema.json",
4
+ "title": "obs.unsubscribe params (AWP-NEG-004)",
5
+ "type": "object",
6
+ "properties": {
7
+ "channels": {"type": "array", "minItems": 1, "items": {"$ref": "common.schema.json#/$defs/identifier"}, "uniqueItems": true}
8
+ },
9
+ "required": ["channels"],
10
+ "x-awp-closed": true
11
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://agentworldprotocol.com/schemas/v0.1/world-event.schema.json",
4
+ "title": "world.event params (AWP-EVT-001)",
5
+ "type": "object",
6
+ "properties": {
7
+ "event": {
8
+ "anyOf": [
9
+ {
10
+ "type": "string",
11
+ "enum": [
12
+ "entity_appeared", "entity_removed", "collision", "e_stop_engaged", "e_stop_released", "envelope_violation",
13
+ "grant_expired", "safe_state_entered", "safe_state_exited", "channel_degraded", "world_resetting", "world_shutdown", "embodiment_transferred"
14
+ ]
15
+ },
16
+ { "$ref": "common.schema.json#/$defs/vendor_key" }
17
+ ]
18
+ },
19
+ "status_seq": { "$ref": "common.schema.json#/$defs/status_seq" },
20
+ "ts_mono_ns": { "$ref": "common.schema.json#/$defs/ts_mono_ns" },
21
+ "tick": { "$ref": "common.schema.json#/$defs/tick" },
22
+ "detail": { "type": "object" }
23
+ },
24
+ "required": ["event", "status_seq", "ts_mono_ns"],
25
+ "allOf": [
26
+ {
27
+ "if": { "properties": { "event": { "const": "world_resetting" } } },
28
+ "then": {
29
+ "properties": {
30
+ "detail": {
31
+ "type": "object",
32
+ "properties": {
33
+ "initiator": { "type": "string" },
34
+ "kind": { "type": "string", "enum": ["reset", "restore"] },
35
+ "initial_state": { "type": "string" }
36
+ },
37
+ "required": ["initiator", "kind"]
38
+ }
39
+ },
40
+ "required": ["detail"]
41
+ }
42
+ }
43
+ ],
44
+ "additionalProperties": true
45
+ }
@@ -0,0 +1,61 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://agentworldprotocol.com/schemas/v0.1/world-manifest.schema.json",
4
+ "title": "World manifest",
5
+ "description": "Returned by initialize and world.manifest (spec/session/world-manifest).",
6
+ "type": "object",
7
+ "properties": {
8
+ "protocol_version": { "$ref": "common.schema.json#/$defs/protocol_version" },
9
+ "world": {
10
+ "type": "object",
11
+ "properties": {
12
+ "name": { "type": "string", "minLength": 1 },
13
+ "version": { "type": "string", "minLength": 1 },
14
+ "vendor": { "type": "string", "minLength": 1 }
15
+ },
16
+ "required": ["name", "version", "vendor"],
17
+ "additionalProperties": true
18
+ },
19
+ "time_models": { "type": "array", "items": { "$ref": "common.schema.json#/$defs/time_model" }, "minItems": 1, "uniqueItems": true },
20
+ "tick_policy": { "type": "string", "enum": ["on_tick"], "description": "Required when lockstep is offered; v0.1 defines only on_tick (AWP-TIM-002)." },
21
+ "tick_authority": { "type": "string", "enum": ["any_session", "barrier"], "description": "Who may advance a lockstep world (AWP-TIM-012). Required when lockstep is offered." },
22
+ "capabilities": {
23
+ "type": "object",
24
+ "description": "Open map; v0.1 standard keys listed. Vendor keys use x-<vendor>. (AWP-MAN-004).",
25
+ "properties": {
26
+ "seed": { "type": "boolean" },
27
+ "snapshot": { "type": "boolean" },
28
+ "replay": { "type": "boolean" },
29
+ "command_channels": { "type": "boolean" },
30
+ "task": { "type": "boolean" }
31
+ },
32
+ "patternProperties": { "^x-[a-z0-9]+\\.": {} },
33
+ "additionalProperties": true
34
+ },
35
+ "initial_states": { "type": "array", "items": { "$ref": "common.schema.json#/$defs/identifier" }, "minItems": 1, "description": "Named initial states for world.reset (AWP-SIM-001)." },
36
+ "embodiments": { "type": "array", "items": { "$ref": "embodiment.schema.json" }, "minItems": 1 },
37
+ "observation_channels": { "type": "array", "items": { "$ref": "observation-channel.schema.json" }, "minItems": 1 },
38
+ "command_channels": { "type": "array", "items": { "$ref": "observation-channel.schema.json" }, "description": "Present only with capabilities.command_channels (AWP-MAN-005)." },
39
+ "action_schemas": { "type": "array", "items": { "$ref": "action-schema.schema.json" }, "minItems": 1 },
40
+ "safety_policy": { "$ref": "safety-policy.schema.json" },
41
+ "$defs": { "type": "object", "description": "Shared sub-schemas referenced by params_schema entries via #/$defs/<name>." },
42
+ "extensions": { "$ref": "common.schema.json#/$defs/extensions" }
43
+ },
44
+ "required": ["protocol_version", "world", "time_models", "embodiments", "observation_channels", "action_schemas", "safety_policy"],
45
+ "allOf": [
46
+ {
47
+ "if": { "properties": { "time_models": { "contains": { "const": "lockstep" } } } },
48
+ "then": { "required": ["tick_policy", "tick_authority"] }
49
+ },
50
+ {
51
+ "if": { "properties": { "time_models": { "contains": { "const": "streaming" } } } },
52
+ "then": { "properties": { "safety_policy": { "required": ["safe_state"] } } }
53
+ },
54
+ {
55
+ "if": { "required": ["command_channels"] },
56
+ "then": { "properties": { "capabilities": { "properties": { "command_channels": { "const": true } }, "required": ["command_channels"] } }, "required": ["capabilities"] }
57
+ }
58
+ ],
59
+ "patternProperties": { "^x-[a-z0-9]+\\.": {} },
60
+ "additionalProperties": true
61
+ }
awp/aio.py ADDED
@@ -0,0 +1,380 @@
1
+ """Drive a ClientConnection over a WebSocket with asyncio.
2
+
3
+ This adapter adds only transport and waiting: it sends what the connection queues, feeds it what
4
+ arrives, runs the heartbeat, and lets callers await responses and events. Protocol behavior stays
5
+ in `ClientConnection`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import logging
13
+ from collections.abc import AsyncIterator, Callable
14
+ from types import TracebackType
15
+ from typing import Any
16
+
17
+ from websockets.asyncio.client import ClientConnection as WebSocket
18
+ from websockets.asyncio.client import connect
19
+ from websockets.exceptions import ConnectionClosed, InvalidHandshake
20
+ from websockets.typing import Subprotocol
21
+
22
+ from . import frames, jsonrpc
23
+ from .client import (
24
+ ActionRecord,
25
+ ActionUpdated,
26
+ ClientConnection,
27
+ ErrorResponse,
28
+ Event,
29
+ FrameReceived,
30
+ Response,
31
+ SessionStateChanged,
32
+ )
33
+ from .errors import AwpError, ErrorCode
34
+
35
+ log = logging.getLogger("awp")
36
+
37
+ SUBPROTOCOL = Subprotocol("awp")
38
+
39
+
40
+ class AsyncClient:
41
+ """An agent's connection to a world.
42
+
43
+ With `heartbeat=True` a timer sends pings. An agent that actuates something should pass
44
+ `heartbeat=False` and call `ping()` from its decision loop instead, so that a stalled policy
45
+ also stops the heartbeat that holds off the world's watchdog (AWP-SAF-005).
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ conn: ClientConnection,
51
+ url: str,
52
+ *,
53
+ token: str | None = None,
54
+ heartbeat: bool = True,
55
+ report_interval_s: float | None = 2.0,
56
+ open_timeout_s: float = 10.0,
57
+ streams: bool = True,
58
+ ) -> None:
59
+ self.conn = conn
60
+ self.streams = streams
61
+ self.url = url
62
+ self.token = token
63
+ self.heartbeat = heartbeat
64
+ self.report_interval_s = report_interval_s
65
+ self.open_timeout_s = open_timeout_s
66
+ self.latest: dict[str, FrameReceived] = {}
67
+ self._ws: WebSocket | None = None
68
+ self._tasks: list[asyncio.Task[None]] = []
69
+ self._send_lock = asyncio.Lock()
70
+ self._waiters: list[tuple[Callable[[Event], bool], asyncio.Future[Event]]] = []
71
+ self._subscribers: list[asyncio.Queue[Event]] = []
72
+ self._closed = asyncio.Event()
73
+ self._last_rx = 0.0
74
+ self._stream_ws: WebSocket | None = None
75
+
76
+ # ------------------------------------------------------------ transport
77
+
78
+ async def connect(self) -> None:
79
+ """Open the WebSocket. The token goes in the Authorization header (AWP-SEC-005)."""
80
+ if self._ws is not None:
81
+ await self._drop()
82
+ headers = {"Authorization": f"Bearer {self.token}"} if self.token else None
83
+ self._ws = await connect(
84
+ self.url,
85
+ subprotocols=[SUBPROTOCOL],
86
+ additional_headers=headers,
87
+ ping_interval=None, # AWP heartbeats govern liveness (AWP-SAF-001)
88
+ open_timeout=self.open_timeout_s,
89
+ max_size=None,
90
+ )
91
+ self._closed.clear()
92
+ self._last_rx = asyncio.get_running_loop().time()
93
+ self._tasks = [
94
+ asyncio.create_task(self._read(self._ws)),
95
+ asyncio.create_task(self._watch(self._ws)),
96
+ ]
97
+ if self.heartbeat:
98
+ self._tasks.append(asyncio.create_task(self._beat()))
99
+ if self.report_interval_s:
100
+ self._tasks.append(asyncio.create_task(self._report()))
101
+
102
+ async def reconnect(self) -> dict[str, Any]:
103
+ """Replace the transport and resume the session; returns the resume result."""
104
+ await self._drop()
105
+ await self.connect()
106
+ await self.call(self.conn.initialize())
107
+ live = self._waiter(lambda e: isinstance(e, SessionStateChanged) and not e.replayed)
108
+ try:
109
+ ready = await self.call(self.conn.resume())
110
+ if self.conn.session_state != "active":
111
+ await self._await(live, 10.0) # replay done; the world reports the resumption live
112
+ finally:
113
+ live.cancel()
114
+ self._waiters = [(p, f) for p, f in self._waiters if f is not live]
115
+ await self.ping()
116
+ await self._attach_streams()
117
+ return ready
118
+
119
+ async def aclose(self) -> None:
120
+ await self._drop()
121
+
122
+ async def __aenter__(self) -> AsyncClient:
123
+ await self.connect()
124
+ return self
125
+
126
+ async def __aexit__(
127
+ self,
128
+ exc_type: type[BaseException] | None,
129
+ exc: BaseException | None,
130
+ tb: TracebackType | None,
131
+ ) -> None:
132
+ await self.aclose()
133
+
134
+ @property
135
+ def connected(self) -> bool:
136
+ return self._ws is not None and not self._closed.is_set()
137
+
138
+ async def flush(self) -> None:
139
+ """Send everything the connection has queued, in order."""
140
+ async with self._send_lock:
141
+ for msg in self.conn.outgoing():
142
+ if self._ws is None:
143
+ raise ConnectionError("not connected")
144
+ await self._ws.send(jsonrpc.encode(msg))
145
+
146
+ async def _drop(self) -> None:
147
+ for task in self._tasks:
148
+ task.cancel()
149
+ for task in self._tasks:
150
+ with contextlib.suppress(asyncio.CancelledError, Exception):
151
+ await task
152
+ self._tasks = []
153
+ if self._ws is not None:
154
+ await self._ws.close()
155
+ self._ws = None
156
+ self._on_closed()
157
+
158
+ async def _attach_streams(self) -> None:
159
+ """Carry frames on the world's first `ws` stream endpoint, if it offers one."""
160
+ if not self.streams:
161
+ return
162
+ endpoint = next(
163
+ (e for e in self.conn.stream_endpoints if e.get("binding") == "ws" and e.get("url")),
164
+ None,
165
+ )
166
+ if endpoint is not None:
167
+ self._tasks.append(asyncio.create_task(self._stream(endpoint["url"])))
168
+
169
+ async def _stream(self, url: str) -> None:
170
+ """A stream connection, re-established while the control connection lives (AWP-TRN-010)."""
171
+ while not self._closed.is_set() and self.conn.session_token is not None:
172
+ try:
173
+ async with connect(
174
+ url,
175
+ subprotocols=[SUBPROTOCOL],
176
+ additional_headers={"Authorization": f"Bearer {self.conn.session_token}"},
177
+ ping_interval=None,
178
+ open_timeout=self.open_timeout_s,
179
+ max_size=None,
180
+ ) as ws:
181
+ self._stream_ws = ws
182
+ async for raw in ws:
183
+ if isinstance(raw, bytes):
184
+ for event in self.conn.receive_frame(raw):
185
+ self._dispatch(event)
186
+ except (OSError, ConnectionClosed, InvalidHandshake) as exc:
187
+ log.info("stream connection lost: %s", exc)
188
+ finally:
189
+ self._stream_ws = None
190
+ await asyncio.sleep(0.5)
191
+
192
+ def _on_closed(self) -> None:
193
+ if not self._closed.is_set():
194
+ self._closed.set()
195
+ self.conn.connection_lost()
196
+ for _, fut in self._waiters:
197
+ if not fut.done():
198
+ fut.set_exception(ConnectionError("connection closed"))
199
+ self._waiters.clear()
200
+
201
+ async def _read(self, ws: WebSocket) -> None:
202
+ try:
203
+ async for raw in ws:
204
+ self._last_rx = asyncio.get_running_loop().time()
205
+ try:
206
+ msg = jsonrpc.decode(raw)
207
+ except AwpError as err:
208
+ if err.code == ErrorCode.INTEGER_RANGE: # AWP-CTL-009
209
+ raise
210
+ log.warning("dropping undecodable message: %s", err)
211
+ continue
212
+ events = self.conn.receive(msg)
213
+ await self.flush()
214
+ for event in events:
215
+ self._dispatch(event)
216
+ except ConnectionClosed:
217
+ pass
218
+ except Exception:
219
+ # Never keep a connection whose reader has died: its heartbeat would hold off the
220
+ # world's watchdog while nothing processes the world's messages.
221
+ log.exception("closing the connection after a reader failure")
222
+ await ws.close(code=1011)
223
+ finally:
224
+ self._on_closed()
225
+
226
+ def _dispatch(self, event: Event) -> None:
227
+ if isinstance(event, FrameReceived):
228
+ self.latest[event.channel] = event
229
+ for q in self._subscribers:
230
+ q.put_nowait(event)
231
+ for item in list(self._waiters):
232
+ predicate, fut = item
233
+ if not fut.done() and predicate(event):
234
+ fut.set_result(event)
235
+ self._waiters.remove(item)
236
+
237
+ async def _beat(self) -> None:
238
+ loop = asyncio.get_running_loop()
239
+ last = loop.time()
240
+ while not self._closed.is_set():
241
+ await asyncio.sleep(0.05) # re-read the interval: it is only known once a session opens
242
+ if self.conn.ready is not None and loop.time() - last >= self._heartbeat_s():
243
+ last = loop.time()
244
+ self.conn.ping()
245
+ await self.flush()
246
+
247
+ async def _watch(self, ws: WebSocket) -> None:
248
+ """Three heartbeat intervals without any message from the world is loss (AWP-SAF-002)."""
249
+ loop = asyncio.get_running_loop()
250
+ while not self._closed.is_set():
251
+ await asyncio.sleep(0.1)
252
+ if self.conn.ready is None:
253
+ continue
254
+ interval = (self.conn.ready.get("heartbeat_interval_ms", 5000)) / 1000
255
+ if loop.time() - self._last_rx > 3 * interval:
256
+ log.warning("no message from the world for %.1f s; closing", 3 * interval)
257
+ await ws.close(code=1001, reason="heartbeat lost")
258
+ return
259
+
260
+ def _heartbeat_s(self) -> float:
261
+ """Every heartbeat interval, and at least twice per watchdog period (AWP-SAF-005)."""
262
+ interval = (self.conn.ready or {}).get("heartbeat_interval_ms", 5000)
263
+ safe_state = ((self.conn.manifest or {}).get("safety_policy") or {}).get("safe_state")
264
+ if safe_state:
265
+ interval = min(interval, safe_state["watchdog_ms"] / 2)
266
+ return float(interval) / 1000
267
+
268
+ async def _report(self) -> None:
269
+ assert self.report_interval_s is not None
270
+ while not self._closed.is_set():
271
+ await asyncio.sleep(self.report_interval_s)
272
+ streaming = self.conn.tick is None and self.conn.ready is not None
273
+ if streaming and self.conn.clock.samples:
274
+ self.conn.report()
275
+ await self.flush()
276
+
277
+ # ------------------------------------------------------------ waiting
278
+
279
+ def _waiter(self, predicate: Callable[[Event], bool]) -> asyncio.Future[Event]:
280
+ fut: asyncio.Future[Event] = asyncio.get_running_loop().create_future()
281
+ if self._closed.is_set():
282
+ fut.set_exception(ConnectionError("connection closed"))
283
+ else:
284
+ self._waiters.append((predicate, fut))
285
+ return fut
286
+
287
+ async def _await(self, fut: asyncio.Future[Event], timeout: float) -> Event:
288
+ try:
289
+ return await asyncio.wait_for(fut, timeout)
290
+ finally:
291
+ self._waiters = [(p, f) for p, f in self._waiters if f is not fut]
292
+
293
+ async def wait_for(self, predicate: Callable[[Event], bool], timeout: float = 10.0) -> Event:
294
+ return await self._await(self._waiter(predicate), timeout)
295
+
296
+ async def call(self, rid: int, timeout: float = 10.0) -> dict[str, Any]:
297
+ """Send what is queued and await the response to request `rid`."""
298
+ fut = self._waiter(lambda e: isinstance(e, (Response, ErrorResponse)) and e.id == rid)
299
+ await self.flush()
300
+ try:
301
+ event = await self._await(fut, timeout)
302
+ except TimeoutError:
303
+ self.conn.forget(rid)
304
+ raise
305
+ if isinstance(event, ErrorResponse):
306
+ raise event.error
307
+ assert isinstance(event, Response)
308
+ return event.result
309
+
310
+ async def events(self) -> AsyncIterator[Event]:
311
+ """Every event from now on, until the connection closes."""
312
+ q: asyncio.Queue[Event] = asyncio.Queue()
313
+ self._subscribers.append(q)
314
+ try:
315
+ while not self._closed.is_set() or not q.empty():
316
+ get = asyncio.ensure_future(q.get())
317
+ closed = asyncio.ensure_future(self._closed.wait())
318
+ done, _ = await asyncio.wait({get, closed}, return_when=asyncio.FIRST_COMPLETED)
319
+ closed.cancel()
320
+ if get in done:
321
+ yield get.result()
322
+ else:
323
+ get.cancel()
324
+ finally:
325
+ self._subscribers.remove(q)
326
+
327
+ # ------------------------------------------------------------ conveniences
328
+
329
+ async def ping(self) -> None:
330
+ """A heartbeat and clock sample, for agents that run their own heartbeat."""
331
+ await self.call(self.conn.ping())
332
+
333
+ async def initialize(self) -> dict[str, Any]:
334
+ return await self.call(self.conn.initialize())
335
+
336
+ async def open_session(self, mode: str, **kw: Any) -> dict[str, Any]:
337
+ """Open a session, then take the clock samples AWP-CLK-008 asks for."""
338
+ ready = await self.call(self.conn.open_session(mode, **kw))
339
+ for _ in range(4):
340
+ await self.ping()
341
+ await self._attach_streams()
342
+ return ready
343
+
344
+ async def submit(self, type: str, params: dict[str, Any], **kw: Any) -> ActionRecord:
345
+ """Submit and await admission. Raises AwpError if the world refuses it."""
346
+ action_id = self.conn.submit(type, params, **kw)
347
+ await self.call(self.conn.last_id)
348
+ return self.conn.actions[action_id]
349
+
350
+ async def wait_terminal(self, action_id: str, timeout: float = 30.0) -> ActionRecord:
351
+ record = self.conn.actions[action_id]
352
+ if not record.terminal:
353
+ await self.wait_for(
354
+ lambda e: (
355
+ isinstance(e, ActionUpdated)
356
+ and e.action.action_id == action_id
357
+ and e.action.terminal
358
+ ),
359
+ timeout,
360
+ )
361
+ return self.conn.actions[action_id]
362
+
363
+ async def cancel(self, action_id: str) -> dict[str, Any]:
364
+ return await self.call(self.conn.cancel(action_id))
365
+
366
+ async def advance(self, count: int | None = None) -> int:
367
+ result = await self.call(self.conn.advance(count))
368
+ return int(result["tick"])
369
+
370
+ async def command(self, channel: str, payload: bytes | dict[str, Any]) -> None:
371
+ """Send a setpoint: on the stream connection when there is one, inline otherwise."""
372
+ stream = self._stream_ws
373
+ frame = self.conn.command(channel, payload, inline=stream is None)
374
+ if stream is not None:
375
+ await stream.send(frames.encode(frame))
376
+ else:
377
+ await self.flush()
378
+
379
+ async def close_session(self, timeout: float = 10.0) -> None:
380
+ await self.call(self.conn.close(), timeout)