awp-python 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. awp/__init__.py +64 -0
  2. awp/_spec/lifecycle.json +190 -0
  3. awp/_spec/schemas/action-cancel-result.schema.json +13 -0
  4. awp/_spec/schemas/action-ref.schema.json +11 -0
  5. awp/_spec/schemas/action-schema.schema.json +37 -0
  6. awp/_spec/schemas/action-status.schema.json +39 -0
  7. awp/_spec/schemas/action-submit-result.schema.json +16 -0
  8. awp/_spec/schemas/action-submit.schema.json +19 -0
  9. awp/_spec/schemas/agent-manifest.schema.json +27 -0
  10. awp/_spec/schemas/approval-requested.schema.json +25 -0
  11. awp/_spec/schemas/approval-respond.schema.json +31 -0
  12. awp/_spec/schemas/common.schema.json +150 -0
  13. awp/_spec/schemas/embodiment.schema.json +22 -0
  14. awp/_spec/schemas/empty-result.schema.json +8 -0
  15. awp/_spec/schemas/error.schema.json +25 -0
  16. awp/_spec/schemas/frame-inline.schema.json +20 -0
  17. awp/_spec/schemas/frame-tree.schema.json +26 -0
  18. awp/_spec/schemas/obs-report.schema.json +40 -0
  19. awp/_spec/schemas/observation-channel.schema.json +28 -0
  20. awp/_spec/schemas/ping-result.schema.json +13 -0
  21. awp/_spec/schemas/ping.schema.json +12 -0
  22. awp/_spec/schemas/profiles/gui-actions.schema.json +57 -0
  23. awp/_spec/schemas/reset-result.schema.json +11 -0
  24. awp/_spec/schemas/reset.schema.json +12 -0
  25. awp/_spec/schemas/restore.schema.json +11 -0
  26. awp/_spec/schemas/safety-policy.schema.json +81 -0
  27. awp/_spec/schemas/session-open.schema.json +41 -0
  28. awp/_spec/schemas/session-ready.schema.json +50 -0
  29. awp/_spec/schemas/session-resume.schema.json +12 -0
  30. awp/_spec/schemas/session-state.schema.json +14 -0
  31. awp/_spec/schemas/session-telemetry.schema.json +22 -0
  32. awp/_spec/schemas/session-transfer-result.schema.json +12 -0
  33. awp/_spec/schemas/session-transfer.schema.json +11 -0
  34. awp/_spec/schemas/snapshot-result.schema.json +11 -0
  35. awp/_spec/schemas/subscribe-result.schema.json +11 -0
  36. awp/_spec/schemas/subscribe.schema.json +11 -0
  37. awp/_spec/schemas/task-update.schema.json +11 -0
  38. awp/_spec/schemas/tick-result.schema.json +9 -0
  39. awp/_spec/schemas/tick.schema.json +12 -0
  40. awp/_spec/schemas/unsubscribe.schema.json +11 -0
  41. awp/_spec/schemas/world-event.schema.json +45 -0
  42. awp/_spec/schemas/world-manifest.schema.json +61 -0
  43. awp/aio.py +380 -0
  44. awp/client.py +781 -0
  45. awp/clock.py +61 -0
  46. awp/errors.py +112 -0
  47. awp/frames.py +174 -0
  48. awp/jsonrpc.py +84 -0
  49. awp/lifecycle.py +78 -0
  50. awp/py.typed +0 -0
  51. awp/schema.py +158 -0
  52. awp_python-0.1.0a1.dist-info/METADATA +127 -0
  53. awp_python-0.1.0a1.dist-info/RECORD +71 -0
  54. awp_python-0.1.0a1.dist-info/WHEEL +4 -0
  55. awp_python-0.1.0a1.dist-info/entry_points.txt +2 -0
  56. awp_python-0.1.0a1.dist-info/licenses/LICENSE +201 -0
  57. awp_sim/__init__.py +8 -0
  58. awp_sim/__main__.py +5 -0
  59. awp_sim/arm.py +163 -0
  60. awp_sim/audit.py +138 -0
  61. awp_sim/cli.py +238 -0
  62. awp_sim/config.py +225 -0
  63. awp_sim/demo.py +85 -0
  64. awp_sim/loopback.py +222 -0
  65. awp_sim/py.typed +0 -0
  66. awp_sim/recorder.py +72 -0
  67. awp_sim/replay.py +134 -0
  68. awp_sim/scenarios.py +427 -0
  69. awp_sim/server.py +333 -0
  70. awp_sim/session.py +153 -0
  71. awp_sim/world.py +1612 -0
awp_sim/loopback.py ADDED
@@ -0,0 +1,222 @@
1
+ """Run agents against a World in-process on a virtual clock, recording wire traces.
2
+
3
+ The loopback delivers messages instantly and in order; time moves only when told to. Scenarios and
4
+ tests use it to exercise the real client and world deterministically, including failures.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools
10
+ from collections.abc import Callable, Iterable
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from awp.client import ClientConnection, ErrorResponse, Event, Response
15
+ from awp.jsonrpc import Message
16
+
17
+ from .world import Close, Output, Send, SendFrame, World
18
+
19
+ MS = 1_000_000
20
+
21
+
22
+ @dataclass
23
+ class TraceLine:
24
+ sender: str
25
+ msg: Message
26
+ delivered: bool = True
27
+
28
+ def to_json(self) -> dict[str, Any]:
29
+ method = self.msg.get("method") or ("error" if "error" in self.msg else "result")
30
+ line: dict[str, Any] = {"from": self.sender, "step": method, "msg": self.msg}
31
+ if not self.delivered:
32
+ line["delivered"] = False
33
+ return line
34
+
35
+
36
+ class Loopback:
37
+ def __init__(self, world: World, *, step_ms: float = 1.0, start_ns: int = 1_000 * MS) -> None:
38
+ self.world = world
39
+ self.now = start_ns
40
+ self.step_ns = int(step_ms * MS)
41
+ self.agents: list[LoopbackAgent] = []
42
+ self._conn_ids = itertools.count(1)
43
+
44
+ def agent(
45
+ self, name: str = "agent", *, clock_offset_ns: int = -5 * MS, **kw: Any
46
+ ) -> LoopbackAgent:
47
+ a = LoopbackAgent(self, name, clock_offset_ns, **kw)
48
+ self.agents.append(a)
49
+ return a
50
+
51
+ def advance(self, ms: float) -> None:
52
+ end = self.now + int(ms * MS)
53
+ while self.now < end:
54
+ self.now = min(end, self.now + self.step_ns)
55
+ for a in self.agents:
56
+ a.tick()
57
+ self.deliver(self.world.advance(self.now))
58
+ self.settle()
59
+
60
+ def run_until(self, predicate: Callable[[], bool], timeout_ms: float) -> bool:
61
+ end = self.now + int(timeout_ms * MS)
62
+ while not predicate():
63
+ if self.now >= end:
64
+ return False
65
+ self.advance(self.step_ns / MS)
66
+ return True
67
+
68
+ def settle(self) -> None:
69
+ """Exchange messages until neither side has anything left to send."""
70
+ for _ in range(10_000):
71
+ if not any(a.flush() for a in self.agents):
72
+ return
73
+ raise RuntimeError("loopback did not settle")
74
+
75
+ def deliver(self, outputs: Iterable[Output]) -> None:
76
+ for out in outputs:
77
+ stream = next(
78
+ (a for a in self.agents if a.stream is not None and a.stream == out.conn), None
79
+ )
80
+ if stream is not None:
81
+ if isinstance(out, SendFrame):
82
+ data = self.world.frame_bytes(out, self.now)
83
+ stream.events += stream.client.receive_frame(data)
84
+ elif isinstance(out, Close):
85
+ stream.stream = None
86
+ continue
87
+ agent = next((a for a in self.agents if a.conn == out.conn), None)
88
+ if agent is None or isinstance(out, SendFrame):
89
+ continue
90
+ if isinstance(out, Close):
91
+ agent.transport_closed()
92
+ else:
93
+ agent.on_world(out)
94
+
95
+
96
+ class LoopbackAgent:
97
+ def __init__(
98
+ self,
99
+ net: Loopback,
100
+ name: str,
101
+ clock_offset_ns: int,
102
+ *,
103
+ agent: dict[str, str] | None = None,
104
+ modalities: Iterable[str] = ("proprio/json", "text/event+json"),
105
+ heartbeat_ms: float | None = 500,
106
+ approver: bool = False,
107
+ ) -> None:
108
+ self.approver = approver
109
+ self.net = net
110
+ self.name = name
111
+ self.offset = clock_offset_ns
112
+ self.client = ClientConnection(
113
+ agent or {"name": name, "version": "0.1.0", "vendor": "awp-sim"},
114
+ modalities,
115
+ clock_ns=lambda: self.net.now + self.offset,
116
+ )
117
+ self.heartbeat_ms = heartbeat_ms
118
+ self.conn: int | None = None
119
+ self.stream: int | None = None # a ws stream connection, when attached
120
+ self.events: list[Event] = []
121
+ self.trace: list[TraceLine] = []
122
+ # Fault: the first world message matching this is lost and the connection goes half-open.
123
+ self.lose: Callable[[Message], bool] | None = None
124
+ self._last_ping = 0
125
+
126
+ # ------------------------------------------------------------ transport
127
+
128
+ def connect(self) -> None:
129
+ if self.conn is not None:
130
+ self.drop()
131
+ self.conn = next(self.net._conn_ids)
132
+ self.net.deliver(self.net.world.connect(self.conn, self.net.now, approver=self.approver))
133
+
134
+ def attach_stream(self) -> None:
135
+ """Open a stream connection with the session token (AWP-TRN-003, AWP-SEC-004)."""
136
+ assert self.client.session_token is not None
137
+ self.stream = next(self.net._conn_ids)
138
+ self.net.deliver(
139
+ self.net.world.attach_stream(self.stream, self.client.session_token, self.net.now)
140
+ )
141
+
142
+ def drop_stream(self) -> None:
143
+ if self.stream is not None:
144
+ stream, self.stream = self.stream, None
145
+ self.net.deliver(self.net.world.stream_lost(stream, self.net.now))
146
+
147
+ def drop(self) -> None:
148
+ """The agent's side of the connection dies."""
149
+ if self.conn is None:
150
+ return
151
+ conn, self.conn = self.conn, None
152
+ self.client.connection_lost()
153
+ self.net.deliver(self.net.world.disconnect(conn, self.net.now))
154
+
155
+ def abandon(self) -> None:
156
+ """The agent loses the connection but the world has not noticed: it is half-open."""
157
+ self.conn = None
158
+ self.client.connection_lost()
159
+
160
+ def transport_closed(self) -> None:
161
+ self.conn = None
162
+ self.client.connection_lost()
163
+
164
+ def flush(self) -> bool:
165
+ out = self.client.outgoing()
166
+ if self.conn is None:
167
+ return False
168
+ for msg in out:
169
+ self.trace.append(TraceLine("agent", msg))
170
+ self.net.deliver(self.net.world.receive(self.conn, msg, self.net.now))
171
+ return bool(out)
172
+
173
+ def on_world(self, send: Send) -> None:
174
+ msg = (
175
+ self.net.world.frame_sent(send, self.net.now)
176
+ if send.msg.get("method") == "obs.frame"
177
+ else send.msg
178
+ )
179
+ if self.lose is not None and self.lose(msg):
180
+ self.lose = None
181
+ self.trace.append(TraceLine("world", msg, delivered=False))
182
+ self.abandon()
183
+ return
184
+ self.trace.append(TraceLine("world", msg))
185
+ self.events += self.client.receive(msg)
186
+
187
+ def tick(self) -> None:
188
+ if self.heartbeat_ms is None or self.conn is None or self.client.ready is None:
189
+ return
190
+ if self.net.now - self._last_ping >= self.heartbeat_ms * MS:
191
+ self._last_ping = self.net.now
192
+ self.client.ping()
193
+
194
+ # ------------------------------------------------------------ requests
195
+
196
+ def call(self, rid: int) -> dict[str, Any]:
197
+ """Settle and return the result of request `rid`; raise AwpError on an error response."""
198
+ self.net.settle()
199
+ for e in self.events:
200
+ if isinstance(e, Response) and e.id == rid:
201
+ return e.result
202
+ if isinstance(e, ErrorResponse) and e.id == rid:
203
+ raise e.error
204
+ raise TimeoutError(f"no response to request {rid}")
205
+
206
+ def submit(self, type: str, params: dict[str, Any], **kw: Any) -> str:
207
+ action_id = self.client.submit(type, params, **kw)
208
+ self.call(self.client.last_id)
209
+ return action_id
210
+
211
+ def open(self, **kw: Any) -> dict[str, Any]:
212
+ self.connect()
213
+ self.call(self.client.initialize())
214
+ ready = self.call(self.client.open_session(**kw))
215
+ self.call(self.client.ping()) # a clock sample on the new session clock (AWP-CLK-008)
216
+ return ready
217
+
218
+ def of(self, kind: type[Any]) -> list[Any]:
219
+ return [e for e in self.events if isinstance(e, kind)]
220
+
221
+ def trace_json(self) -> list[dict[str, Any]]:
222
+ return [line.to_json() for line in self.trace]
awp_sim/py.typed ADDED
File without changes
awp_sim/recorder.py ADDED
@@ -0,0 +1,72 @@
1
+ """Record each session's wire traffic as a trace in the spec's JSON Lines format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import OrderedDict
7
+ from collections.abc import Hashable
8
+ from pathlib import Path
9
+ from typing import IO, Any
10
+
11
+ from awp.jsonrpc import Message
12
+
13
+ from .audit import redact
14
+
15
+
16
+ class TraceRecorder:
17
+ """Writes `<directory>/<session_id>.jsonl`, with credentials redacted as in the audit log.
18
+
19
+ Messages exchanged before a connection has a session (initialize, a failed open, the
20
+ reconnect before session.resume) are held until it does, then written ahead of the session's
21
+ own traffic.
22
+ """
23
+
24
+ PENDING_LIMIT = 256
25
+ OPEN_FILES = 64
26
+
27
+ def __init__(self, directory: Path | str) -> None:
28
+ self.directory = Path(directory)
29
+ self.directory.mkdir(parents=True, exist_ok=True)
30
+ self._pending: dict[Hashable, list[dict[str, Any]]] = {}
31
+ self._files: OrderedDict[str, IO[str]] = OrderedDict()
32
+
33
+ def record(
34
+ self,
35
+ conn: Hashable,
36
+ session: str | None,
37
+ sender: str,
38
+ msg: Message,
39
+ *,
40
+ delivered: bool = True,
41
+ ) -> None:
42
+ method = msg.get("method") or ("error" if "error" in msg else "result")
43
+ line: dict[str, Any] = {"from": sender, "step": method, "msg": redact(msg)}
44
+ if not delivered:
45
+ line["delivered"] = False
46
+ if session is None:
47
+ held = self._pending.setdefault(conn, [])
48
+ if len(held) < self.PENDING_LIMIT:
49
+ held.append(line)
50
+ return
51
+ f = self._file(session)
52
+ for held_line in self._pending.pop(conn, []):
53
+ f.write(json.dumps(held_line, separators=(",", ":")) + "\n")
54
+ f.write(json.dumps(line, separators=(",", ":")) + "\n")
55
+ f.flush()
56
+
57
+ def forget(self, conn: Hashable) -> None:
58
+ self._pending.pop(conn, None)
59
+
60
+ def close(self) -> None:
61
+ for f in self._files.values():
62
+ f.close()
63
+ self._files.clear()
64
+
65
+ def _file(self, session: str) -> IO[str]:
66
+ f = self._files.pop(session, None)
67
+ if f is None:
68
+ f = (self.directory / f"{session}.jsonl").open("a", encoding="utf-8")
69
+ while len(self._files) >= self.OPEN_FILES:
70
+ self._files.popitem(last=False)[1].close()
71
+ self._files[session] = f # most recently used last
72
+ return f
awp_sim/replay.py ADDED
@@ -0,0 +1,134 @@
1
+ """Replay a replay bundle and compare what the world does with what it recorded (AWP-REP-003).
2
+
3
+ The world is rebuilt from the bundle's configuration and initial state; the session is opened as
4
+ recorded, and the agent's calls that change action state are fed in their recorded order.
5
+ Compared: every frame's payload hash per channel, and the ordered `(state, reason)` sequence of
6
+ every action. Timestamps, sequence numbers, heartbeats, reports, and telemetry are not.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from awp.client import ActionUpdated, FrameReceived
18
+
19
+ from .loopback import Loopback
20
+ from .world import World, decode_config, decode_state
21
+
22
+ # What changes action state: the agent's calls, in their recorded order.
23
+ REPLAYED = (
24
+ "action.submit",
25
+ "action.cancel",
26
+ "world.tick",
27
+ "world.reset",
28
+ "world.restore",
29
+ "session.close",
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class Outcome:
35
+ frames: int
36
+ transitions: int
37
+ difference: str | None
38
+
39
+ @property
40
+ def reproduced(self) -> bool:
41
+ return self.difference is None
42
+
43
+
44
+ def _recorded(
45
+ records: list[dict[str, Any]],
46
+ ) -> tuple[dict[str, list[str]], dict[str, list[tuple[str, Any]]]]:
47
+ channel_of: dict[int, str] = {}
48
+ frames: dict[str, list[str]] = {}
49
+ states: dict[str, list[tuple[str, Any]]] = {}
50
+ for r in records:
51
+ body = r["body"]
52
+ if r["kind"] == "frame" and body.get("method") == "obs.frame":
53
+ name = channel_of.get(body["channel_id"], str(body["channel_id"]))
54
+ frames.setdefault(name, []).append(body["payload_sha256"])
55
+ elif r["kind"] == "message":
56
+ result = body.get("result") or {}
57
+ for g in (
58
+ (result.get("granted") or {}).get("channels", [])
59
+ if isinstance(result.get("granted"), dict)
60
+ else []
61
+ ):
62
+ channel_of[g["channel_id"]] = g["channel"]
63
+ params = body.get("params") or {}
64
+ status = params if body.get("method") == "action.status" else None
65
+ if (
66
+ status is None
67
+ and "action_id" in result
68
+ and "state" in result
69
+ and "status_seq" in result
70
+ ):
71
+ status = result
72
+ if status is not None and r["direction"] == "world":
73
+ seq = states.setdefault(status["action_id"], [])
74
+ entry = (status["state"], status.get("reason"))
75
+ if not seq or seq[-1] != entry:
76
+ seq.append(entry)
77
+ return frames, states
78
+
79
+
80
+ def replay(path: Path | str) -> Outcome:
81
+ records = [json.loads(line) for line in Path(path).read_text().splitlines() if line.strip()]
82
+ header = records[0]
83
+ if header.get("class") != "replay_bundle":
84
+ raise ValueError(f"{path} is a {header.get('class')}, not a replay_bundle (AWP-AUD-007)")
85
+ body = header["body"]
86
+ world = World(decode_config(body["config"]))
87
+ world.load_state(decode_state(body["initial_state"]))
88
+ net = Loopback(world)
89
+ agent = net.agent("replay", heartbeat_ms=None)
90
+ agent.connect()
91
+ agent.call(agent.client.initialize())
92
+ opened = next(r["body"]["params"] for r in records if r["body"].get("method") == "session.open")
93
+ agent.call(agent.client.request("session.open", opened))
94
+ for r in records:
95
+ msg = r["body"]
96
+ if r["direction"] == "agent" and msg.get("method") in REPLAYED and "id" in msg:
97
+ agent.client.request(msg["method"], msg.get("params") or {})
98
+ net.settle()
99
+ want_frames, want_states = _recorded(records)
100
+ got_frames: dict[str, list[str]] = {}
101
+ for e in agent.of(FrameReceived):
102
+ got_frames.setdefault(e.channel, []).append(hashlib.sha256(e.frame.payload).hexdigest())
103
+ got_states: dict[str, list[tuple[str, Any]]] = {}
104
+ for e in agent.of(ActionUpdated):
105
+ seq = got_states.setdefault(e.action.action_id, [])
106
+ entry = (e.status["state"], e.status.get("reason"))
107
+ if not seq or seq[-1] != entry:
108
+ seq.append(entry)
109
+ difference = None
110
+ for name, hashes in want_frames.items():
111
+ if got_frames.get(name, []) != hashes:
112
+ got = got_frames.get(name, [])
113
+ at = next(
114
+ (i for i, (a, b) in enumerate(zip(hashes, got, strict=False)) if a != b),
115
+ min(len(hashes), len(got)),
116
+ )
117
+ difference = (
118
+ f"channel {name}: frame {at} differs ({len(hashes)} recorded, {len(got)} replayed)"
119
+ )
120
+ break
121
+ if difference is None and got_states != want_states:
122
+ bad = next(
123
+ k
124
+ for k in sorted(set(want_states) | set(got_states))
125
+ if want_states.get(k) != got_states.get(k)
126
+ )
127
+ difference = (
128
+ f"action {bad}: recorded {want_states.get(bad)}, replayed {got_states.get(bad)}"
129
+ )
130
+ return Outcome(
131
+ sum(len(v) for v in want_frames.values()),
132
+ sum(len(v) for v in want_states.values()),
133
+ difference,
134
+ )