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
|
@@ -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)
|