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/audit.py ADDED
@@ -0,0 +1,138 @@
1
+ """The per-session audit log (spec/safety/audit-log): JSON Lines, redacted, hash-chained."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import json
8
+ from collections.abc import Iterable
9
+ from pathlib import Path
10
+ from typing import IO, Any
11
+
12
+ from awp.jsonrpc import Message
13
+
14
+ CREDENTIAL_KEYS = frozenset({"session_token", "transfer_token", "snapshot_token"})
15
+
16
+
17
+ def redacted(value: Any) -> str:
18
+ digest = hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
19
+ return f"[redacted:sha256:{digest[:8]}]"
20
+
21
+
22
+ def redact(msg: Message, paths: Iterable[str] = ()) -> Message:
23
+ """Replace credentials and `redact_paths` values, relative to params or result (AWP-AUD-006)."""
24
+
25
+ def walk(node: Any) -> Any:
26
+ if isinstance(node, dict):
27
+ return {k: redacted(v) if k in CREDENTIAL_KEYS else walk(v) for k, v in node.items()}
28
+ if isinstance(node, list):
29
+ return [walk(v) for v in node]
30
+ return node
31
+
32
+ out: Message = walk(msg)
33
+ for pointer in paths:
34
+ for root in ("params", "result"):
35
+ _redact_pointer(out.get(root), pointer)
36
+ return out
37
+
38
+
39
+ def _redact_pointer(node: Any, pointer: str) -> None:
40
+ parts = [p.replace("~1", "/").replace("~0", "~") for p in pointer.lstrip("/").split("/")]
41
+ for part in parts[:-1]:
42
+ if isinstance(node, dict):
43
+ node = node.get(part)
44
+ elif isinstance(node, list) and part.isdigit() and int(part) < len(node):
45
+ node = node[int(part)]
46
+ else:
47
+ return
48
+ last = parts[-1]
49
+ if isinstance(node, dict) and last in node:
50
+ node[last] = redacted(node[last])
51
+ elif isinstance(node, list) and last.isdigit() and int(last) < len(node):
52
+ node[int(last)] = redacted(node[int(last)])
53
+
54
+
55
+ class AuditLog:
56
+ """Writes `<directory>/<session_id>.jsonl`, one audit record per line (AWP-AUD-001..007).
57
+
58
+ Frames are recorded as their header plus the SHA-256 of the payload, so a log is an audit
59
+ record, not a replay bundle. With the inline binding frames travel as control messages; they
60
+ are still logged by hash, as frames on any other channel would be.
61
+ """
62
+
63
+ def __init__(
64
+ self, directory: Path | str, *, redact_paths: Iterable[str] = (), bundle: bool = False
65
+ ) -> None:
66
+ self.bundle = bundle # a replay bundle: full payloads and the initial state (AWP-AUD-007)
67
+ self.directory = Path(directory)
68
+ self.directory.mkdir(parents=True, exist_ok=True)
69
+ self.redact_paths = list(redact_paths)
70
+ self._files: dict[str, IO[str]] = {}
71
+ self._prev: dict[str, str] = {}
72
+
73
+ def open(self, session_id: str, header: dict[str, Any]) -> None:
74
+ if not self.bundle: # only a replay bundle carries the initial snapshot
75
+ header = {
76
+ k: v for k, v in header.items() if k not in ("snapshot_token", "initial_state")
77
+ }
78
+ self._files[session_id] = (self.directory / f"{session_id}.jsonl").open(
79
+ "a", encoding="utf-8"
80
+ )
81
+ self._write(
82
+ session_id,
83
+ {
84
+ "class": "replay_bundle" if self.bundle else "audit_record",
85
+ "ts_mono_ns": 0,
86
+ "direction": "world",
87
+ "kind": "header",
88
+ "body": header,
89
+ },
90
+ )
91
+
92
+ def record(self, session_id: str, ts_mono_ns: int, direction: str, msg: Message) -> None:
93
+ if session_id not in self._files:
94
+ return
95
+ if msg.get("method") in ("obs.frame", "cmd.frame"):
96
+ params = msg["params"]
97
+ payload = base64.b64decode(params.get("payload_b64", "")) # the payload, not its text
98
+ keep = self.bundle # a bundle keeps what the agent saw; a record, only its hash
99
+ body: dict[str, Any] = {k: v for k, v in params.items() if keep or k != "payload_b64"}
100
+ body["method"] = msg["method"]
101
+ body["payload_sha256"] = hashlib.sha256(payload).hexdigest()
102
+ kind = "frame"
103
+ else:
104
+ body = redact(msg, self.redact_paths)
105
+ kind = "message"
106
+ self._write(
107
+ session_id,
108
+ {"ts_mono_ns": ts_mono_ns, "direction": direction, "kind": kind, "body": body},
109
+ )
110
+
111
+ def close(self, session_id: str) -> None:
112
+ f = self._files.pop(session_id, None)
113
+ self._prev.pop(session_id, None)
114
+ if f is not None:
115
+ f.close()
116
+
117
+ def close_all(self) -> None:
118
+ for session_id in list(self._files):
119
+ self.close(session_id)
120
+
121
+ def _write(self, session_id: str, record: dict[str, Any]) -> None:
122
+ record["prev_hash"] = self._prev.get(session_id)
123
+ line = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
124
+ self._prev[session_id] = hashlib.sha256(line.encode()).hexdigest()
125
+ f = self._files[session_id]
126
+ f.write(line + "\n")
127
+ f.flush()
128
+
129
+
130
+ def verify_chain(lines: Iterable[str]) -> bool:
131
+ """True if every record's prev_hash is the SHA-256 of the line before it (AWP-AUD-003)."""
132
+ prev: str | None = None
133
+ for line in lines:
134
+ record = json.loads(line)
135
+ if record.get("prev_hash") != prev:
136
+ return False
137
+ prev = hashlib.sha256(line.rstrip("\n").encode()).hexdigest()
138
+ return True
awp_sim/cli.py ADDED
@@ -0,0 +1,238 @@
1
+ """The `awp-sim` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import contextlib
8
+ import json
9
+ import logging
10
+ import os
11
+ import signal
12
+ import ssl
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from . import __version__, scenarios
17
+ from .audit import AuditLog
18
+ from .config import FEATURES, WorldConfig
19
+ from .server import Server
20
+ from .world import World
21
+
22
+
23
+ def _parser() -> argparse.ArgumentParser:
24
+ p = argparse.ArgumentParser(prog="awp-sim", description="Agent World Protocol reference world.")
25
+ p.add_argument("--version", action="version", version=f"awp-sim {__version__}")
26
+ sub = p.add_subparsers(dest="command", required=True)
27
+
28
+ s = sub.add_parser("serve", help="run the reference world")
29
+ s.add_argument("--mode", choices=["streaming", "lockstep"], default="streaming")
30
+ s.add_argument("--host", default="127.0.0.1")
31
+ s.add_argument("--port", type=int, default=8710)
32
+ s.add_argument(
33
+ "--token",
34
+ default=os.environ.get("AWP_SIM_TOKEN"),
35
+ help="require this bearer token (default: $AWP_SIM_TOKEN)",
36
+ )
37
+ s.add_argument("--tls-cert", type=Path)
38
+ s.add_argument("--tls-key", type=Path)
39
+ s.add_argument(
40
+ "--insecure", action="store_true", help="allow a non-loopback bind without a token or TLS"
41
+ )
42
+ s.add_argument(
43
+ "--audit-dir",
44
+ type=Path,
45
+ default=Path("awp-audit"),
46
+ help="per-session audit logs (AWP-AUD-001; default: ./awp-audit)",
47
+ )
48
+ s.add_argument(
49
+ "--no-audit",
50
+ action="store_true",
51
+ help="disable the audit log (not conformant; for development only)",
52
+ )
53
+ s.add_argument("--record-dir", type=Path, help="write per-session wire traces here")
54
+ s.add_argument(
55
+ "--replay-dir",
56
+ type=Path,
57
+ help="write replay bundles instead of audit records (lockstep, --features sim)",
58
+ )
59
+ s.add_argument(
60
+ "--features",
61
+ default="",
62
+ help=f"comma-separated features beyond Core: {', '.join(sorted(FEATURES))}",
63
+ )
64
+ s.add_argument(
65
+ "--approver-token",
66
+ default=os.environ.get("AWP_SIM_APPROVER_TOKEN"),
67
+ help="bearer token of approver connections (with --features approval)",
68
+ )
69
+ s.add_argument("--approval-timeout-ms", type=int, default=60000)
70
+ s.add_argument(
71
+ "--stream-binding",
72
+ choices=["inline", "ws"],
73
+ default="inline",
74
+ help="offer frames on a ws stream connection as well as inline (AWP-TRN-003)",
75
+ )
76
+ for name, default in (
77
+ ("watchdog-ms", 2000),
78
+ ("heartbeat-ms", 5000),
79
+ ("reconnect-window-ms", 30000),
80
+ ("tick-ms", 20),
81
+ ("max-duration-ms", 10000),
82
+ ):
83
+ s.add_argument(f"--{name}", type=int, default=default)
84
+ s.add_argument("--log-level", default="INFO")
85
+
86
+ sc = sub.add_parser("scenarios", help="run the scripted scenarios")
87
+ sc.add_argument("names", nargs="*", help="scenarios to run (default: all)")
88
+ sc.add_argument("--list", action="store_true", help="list scenarios and exit")
89
+ sc.add_argument("--out", type=Path, help="write traces and report.json here")
90
+
91
+ d = sub.add_parser("demo", help="drive a running world with a scripted agent")
92
+ d.add_argument("--url", default="ws://127.0.0.1:8710")
93
+ d.add_argument("--token", default=os.environ.get("AWP_SIM_TOKEN"))
94
+
95
+ r = sub.add_parser("replay", help="replay a replay bundle and compare (AWP-REP-003)")
96
+ r.add_argument("bundle", type=Path)
97
+
98
+ m = sub.add_parser("manifest", help="print the world manifest")
99
+ m.add_argument("--mode", choices=["streaming", "lockstep"], default="streaming")
100
+ m.add_argument("--features", default="")
101
+ return p
102
+
103
+
104
+ def main(argv: list[str] | None = None) -> int:
105
+ args = _parser().parse_args(argv)
106
+ if args.command == "manifest":
107
+ features = frozenset(f for f in args.features.split(",") if f)
108
+ print(json.dumps(WorldConfig(mode=args.mode, features=features).manifest(), indent=2))
109
+ return 0
110
+ if args.command == "scenarios":
111
+ return _scenarios(args)
112
+ if args.command == "replay":
113
+ from .replay import replay
114
+
115
+ outcome = replay(args.bundle)
116
+ if outcome.reproduced:
117
+ print(f"reproduced {outcome.frames} frames and {outcome.transitions} transitions")
118
+ return 0
119
+ print(f"not reproduced: {outcome.difference}", file=sys.stderr)
120
+ return 1
121
+ if args.command == "demo":
122
+ from websockets.exceptions import InvalidHandshake
123
+
124
+ from awp.errors import AwpError
125
+
126
+ from .demo import run_demo
127
+
128
+ try:
129
+ asyncio.run(run_demo(args.url, token=args.token))
130
+ except (OSError, InvalidHandshake, AwpError, TimeoutError) as err:
131
+ print(f"awp-sim demo: {err}", file=sys.stderr)
132
+ return 1
133
+ return 0
134
+ return _serve(args)
135
+
136
+
137
+ def _scenarios(args: argparse.Namespace) -> int:
138
+ if args.list:
139
+ for name, (description, _) in scenarios.SCENARIOS.items():
140
+ print(f"{name:24} {description}")
141
+ return 0
142
+ unknown = [n for n in args.names if n not in scenarios.SCENARIOS]
143
+ if unknown:
144
+ print(f"unknown scenario(s): {', '.join(unknown)}", file=sys.stderr)
145
+ return 2
146
+ results = scenarios.run_all(args.names or None)
147
+ for r in results:
148
+ metrics = " ".join(f"{k}={v}" for k, v in r.metrics.items())
149
+ print(f"{'PASS' if r.passed else 'FAIL'} {r.name:24} {metrics}")
150
+ for check, ok in r.checks:
151
+ if not ok:
152
+ print(f" ✗ {check}")
153
+ if r.error:
154
+ print(f" ✗ {r.error}")
155
+ if args.out:
156
+ scenarios.write(results, args.out)
157
+ print(f"traces and report written to {args.out}")
158
+ return 0 if all(r.passed for r in results) else 1
159
+
160
+
161
+ def _serve(args: argparse.Namespace) -> int:
162
+ logging.basicConfig(
163
+ level=args.log_level.upper(), format="%(asctime)s %(levelname)s %(message)s"
164
+ )
165
+ try:
166
+ config = _config(args)
167
+ except ValueError as err:
168
+ print(f"awp-sim: {err}", file=sys.stderr)
169
+ return 2
170
+ tls = None
171
+ if args.tls_cert:
172
+ tls = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
173
+ tls.load_cert_chain(args.tls_cert, args.tls_key)
174
+ return _run_server(args, config, tls)
175
+
176
+
177
+ def _config(args: argparse.Namespace) -> WorldConfig:
178
+ return WorldConfig(
179
+ mode=args.mode,
180
+ watchdog_ms=args.watchdog_ms,
181
+ heartbeat_interval_ms=args.heartbeat_ms,
182
+ reconnect_window_ms=args.reconnect_window_ms,
183
+ tick_ms=args.tick_ms,
184
+ max_duration_ms=args.max_duration_ms,
185
+ features=frozenset(f for f in args.features.split(",") if f),
186
+ approval_timeout_ms=args.approval_timeout_ms,
187
+ )
188
+
189
+
190
+ def _run_server(args: argparse.Namespace, config: WorldConfig, tls: ssl.SSLContext | None) -> int:
191
+ if args.replay_dir is not None:
192
+ if not config.has("sim"):
193
+ print("awp-sim: --replay-dir needs --mode lockstep --features sim", file=sys.stderr)
194
+ return 2
195
+ audit: AuditLog | None = AuditLog(args.replay_dir, bundle=True)
196
+ else:
197
+ audit = None if args.no_audit else AuditLog(args.audit_dir)
198
+ world = World(config, audit=audit)
199
+ try:
200
+ server = Server(
201
+ world,
202
+ host=args.host,
203
+ port=args.port,
204
+ token=args.token,
205
+ ssl_context=tls,
206
+ allow_insecure=args.insecure,
207
+ record_dir=args.record_dir,
208
+ stream_binding=args.stream_binding == "ws",
209
+ approver_token=args.approver_token,
210
+ )
211
+ except ValueError as err:
212
+ print(f"awp-sim: {err}", file=sys.stderr)
213
+ return 2
214
+
215
+ async def run() -> None:
216
+ stop = asyncio.Event()
217
+ loop = asyncio.get_running_loop()
218
+ for sig in (signal.SIGINT, signal.SIGTERM):
219
+ with contextlib.suppress(NotImplementedError):
220
+ loop.add_signal_handler(sig, stop.set)
221
+ # Operator e-stop: SIGUSR1 engages, SIGUSR2 releases (AWP-EVT-002).
222
+ for name, handler in (("SIGUSR1", server.engage_estop), ("SIGUSR2", server.release_estop)):
223
+ usr = getattr(signal, name, None)
224
+ if usr is not None:
225
+ loop.add_signal_handler(usr, handler)
226
+ async with server:
227
+ await stop.wait()
228
+
229
+ try:
230
+ asyncio.run(run())
231
+ finally:
232
+ if audit is not None:
233
+ audit.close_all()
234
+ return 0
235
+
236
+
237
+ if __name__ == "__main__":
238
+ sys.exit(main())
awp_sim/config.py ADDED
@@ -0,0 +1,225 @@
1
+ """Reference world configuration and the manifest it declares."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ from . import __version__
9
+
10
+ Mode = Literal["lockstep", "streaming"]
11
+
12
+ EMBODIMENT = "arm_01"
13
+ HOME: tuple[float, float, float] = (0.0, 0.0, 0.4)
14
+ PARK: tuple[float, float, float] = (0.0, 0.0, 0.25)
15
+ SERVO_CHANNEL = "servo_arm"
16
+
17
+ # Beyond Core, each off by default: task (AWP-TSK), approval of `park` (AWP-APR), blend preemption
18
+ # (AWP-PRE-004), embodiment transfer (AWP-EMB-003), sim-profile seeding, snapshots, and replay
19
+ # (AWP-REP, lockstep), and a servo command channel (AWP-CMD, streaming).
20
+ FEATURES = frozenset({"task", "approval", "blend", "transfer", "sim", "servo"})
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class WorldConfig:
25
+ mode: Mode = "streaming"
26
+ heartbeat_interval_ms: int = 5000
27
+ reconnect_window_ms: int = 30000
28
+ watchdog_ms: int = 2000
29
+ max_basis_age_ms: int = 500
30
+ max_abort_ms: int = 1500
31
+ max_duration_ms: int = 10000
32
+ tick_ms: int = 20
33
+ proprio_hz: float = 100.0
34
+ state_hz: float = 10.0
35
+ telemetry_interval_ms: int = 1000
36
+ progress_interval_ms: int = 200
37
+ aabb_m: tuple[tuple[float, float, float], tuple[float, float, float]] = (
38
+ (-0.5, -0.5, 0.0),
39
+ (0.5, 0.5, 0.8),
40
+ )
41
+ max_velocity_mps: float = 0.5
42
+ max_action_rate_hz: float = 20.0
43
+ accel_mps2: float = 2.0
44
+ features: frozenset[str] = frozenset()
45
+ approval_timeout_ms: int = 60000
46
+ servo_watchdog_ms: int = 200
47
+ servo_hz: float = 200.0
48
+ extensions: dict[str, Any] = field(default_factory=dict)
49
+
50
+ def __post_init__(self) -> None:
51
+ if self.watchdog_ms > self.reconnect_window_ms:
52
+ raise ValueError("watchdog_ms must be ≤ reconnect_window_ms (AWP-SAF-003)")
53
+ if self.heartbeat_interval_ms < 100:
54
+ raise ValueError("heartbeat_interval_ms must be ≥ 100")
55
+ unknown = set(self.features) - FEATURES
56
+ if unknown:
57
+ raise ValueError(f"unknown features {sorted(unknown)}; known: {sorted(FEATURES)}")
58
+ if "servo" in self.features and self.mode != "streaming":
59
+ raise ValueError("command channels are streaming-only (AWP-CMD-001)")
60
+ if "sim" in self.features and self.mode != "lockstep":
61
+ raise ValueError("the sim feature needs lockstep: its determinism is lockstep's")
62
+
63
+ @property
64
+ def envelope(self) -> dict[str, Any]:
65
+ return {
66
+ "embodiment": EMBODIMENT,
67
+ "spatial": {"frame": "base", "aabb_m": [list(self.aabb_m[0]), list(self.aabb_m[1])]},
68
+ "max_velocity_mps": self.max_velocity_mps,
69
+ "max_action_rate_hz": self.max_action_rate_hz,
70
+ "enforcement": "command_check",
71
+ "on_violation": "reject",
72
+ }
73
+
74
+ def has(self, feature: str) -> bool:
75
+ return feature in self.features
76
+
77
+ def manifest(self) -> dict[str, Any]:
78
+ lockstep = self.mode == "lockstep"
79
+ safety: dict[str, Any] = {"envelopes": [self.envelope]}
80
+ if self.has("approval"):
81
+ safety["approval_timeout_ms"] = self.approval_timeout_ms
82
+ capabilities: dict[str, Any] = {}
83
+ if self.has("task"):
84
+ capabilities["task"] = True
85
+ if self.has("sim"):
86
+ capabilities.update(seed=True, snapshot=True, replay=True)
87
+ if self.has("servo"):
88
+ capabilities["command_channels"] = True
89
+ action_types = ["move_to_pose", "stop"]
90
+ if self.has("approval"):
91
+ action_types.append("park")
92
+ if self.has("servo"):
93
+ action_types.append("servo")
94
+ channels = ["proprio", "arm_state"] + ([SERVO_CHANNEL] if self.has("servo") else [])
95
+ move_policies = ["replace", "queue", "reject"] + (["blend"] if self.has("blend") else [])
96
+ if not lockstep:
97
+ safety["safe_state"] = {"behavior": "safe_stop", "watchdog_ms": self.watchdog_ms}
98
+ safety["max_basis_age_ms"] = self.max_basis_age_ms
99
+ manifest: dict[str, Any] = {
100
+ "protocol_version": "0.1",
101
+ "world": {"name": "awp-sim", "version": __version__, "vendor": "hyperduality"},
102
+ "time_models": [self.mode],
103
+ "capabilities": capabilities,
104
+ "initial_states": ["home"],
105
+ "embodiments": [
106
+ {
107
+ "id": EMBODIMENT,
108
+ "kind": "manipulator",
109
+ "action_types": action_types,
110
+ "channels": channels,
111
+ }
112
+ ],
113
+ "observation_channels": [
114
+ {
115
+ "id": "proprio",
116
+ "modality": "proprio/json",
117
+ "rate_hz": None if lockstep else self.proprio_hz,
118
+ "loss_class": "latest-wins",
119
+ "stale_after_ms": max(1, round(5000 / self.proprio_hz)),
120
+ "schema": {"fields": ["p_m", "v_mps"], "frame": "base"},
121
+ },
122
+ {
123
+ "id": "arm_state",
124
+ "modality": "text/event+json",
125
+ "rate_hz": None if lockstep else self.state_hz,
126
+ "loss_class": "reliable",
127
+ "schema": {
128
+ "type": "object",
129
+ "properties": {
130
+ "phase": {"enum": ["idle", "moving", "stopping", "servo"]},
131
+ "target_m": {"type": ["array", "null"]},
132
+ "action_id": {"type": ["string", "null"]},
133
+ },
134
+ "required": ["phase", "target_m", "action_id"],
135
+ },
136
+ },
137
+ ],
138
+ "action_schemas": [
139
+ {
140
+ "type": "move_to_pose",
141
+ "params_schema": {"$ref": "#/$defs/move_to_pose_params"},
142
+ "duration": "extended",
143
+ "preemption": move_policies,
144
+ "concurrency_group": "arm_motion",
145
+ "max_queue": 4,
146
+ "max_abort_ms": self.max_abort_ms,
147
+ "max_duration_ms": self.max_duration_ms,
148
+ "description": "Move the end effector in a straight line to a position.",
149
+ },
150
+ {
151
+ "type": "stop",
152
+ "params_schema": {"type": "object", "additionalProperties": False},
153
+ "duration": "instant",
154
+ "preemption": "replace",
155
+ "concurrency_group": "arm_motion",
156
+ "description": "Decelerate to rest; replaces any motion.",
157
+ },
158
+ ],
159
+ "safety_policy": safety,
160
+ "$defs": {
161
+ "move_to_pose_params": {
162
+ "type": "object",
163
+ "properties": {
164
+ "pose": {
165
+ "$ref": "https://agentworldprotocol.com/schemas/v0.1/common.schema.json#/$defs/pose"
166
+ },
167
+ "max_velocity_mps": {"type": "number", "exclusiveMinimum": 0},
168
+ },
169
+ "required": ["pose"],
170
+ "additionalProperties": False,
171
+ }
172
+ },
173
+ }
174
+ if self.has("approval"):
175
+ manifest["action_schemas"].append(
176
+ {
177
+ "type": "park",
178
+ "params_schema": {"type": "object", "additionalProperties": False},
179
+ "duration": "extended",
180
+ "preemption": "queue",
181
+ "concurrency_group": "arm_motion",
182
+ "max_queue": 4,
183
+ "requires_approval": True,
184
+ "max_abort_ms": self.max_abort_ms,
185
+ "max_duration_ms": self.max_duration_ms,
186
+ "description": "Move to the park pose; needs an approver's decision.",
187
+ }
188
+ )
189
+ if self.has("servo"):
190
+ manifest["command_channels"] = [
191
+ {
192
+ "id": SERVO_CHANNEL,
193
+ "modality": "servo/json",
194
+ "rate_hz": self.servo_hz,
195
+ "loss_class": "latest-wins",
196
+ "schema": {"fields": ["v_mps"], "frame": "base"},
197
+ }
198
+ ]
199
+ manifest["action_schemas"].append(
200
+ {
201
+ "type": "servo",
202
+ "params_schema": {"type": "object", "additionalProperties": False},
203
+ "duration": "streaming",
204
+ "command_channel": SERVO_CHANNEL,
205
+ "watchdog_ms": self.servo_watchdog_ms,
206
+ "preemption": ["replace", "reject"],
207
+ "concurrency_group": "arm_motion",
208
+ "max_abort_ms": self.max_abort_ms,
209
+ "description": "Follow end-effector velocity setpoints on servo_arm.",
210
+ }
211
+ )
212
+ if lockstep:
213
+ manifest["tick_policy"] = "on_tick"
214
+ manifest["tick_authority"] = "any_session"
215
+ if self.extensions:
216
+ manifest["extensions"] = self.extensions
217
+ return manifest
218
+
219
+
220
+ FRAME_TREE: dict[str, Any] = {
221
+ "frames": [
222
+ {"id": "world", "parent": None},
223
+ {"id": "base", "parent": "world", "transform": {"p_m": [0, 0, 0], "q": [0, 0, 0, 1]}},
224
+ ]
225
+ }
awp_sim/demo.py ADDED
@@ -0,0 +1,85 @@
1
+ """A scripted agent that exercises a running world: moves, a cancel, and an envelope refusal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+ from awp.aio import AsyncClient
9
+ from awp.client import ClientConnection
10
+ from awp.errors import AwpError
11
+
12
+ from . import __version__
13
+
14
+ TARGETS = [(0.3, 0.2, 0.5), (-0.2, 0.1, 0.3), (0.0, -0.3, 0.45), (0.0, 0.0, 0.4)]
15
+
16
+
17
+ def _pose(p: tuple[float, float, float]) -> dict[str, Any]:
18
+ return {"pose": {"frame": "base", "p_m": list(p), "q": [0, 0, 0, 1]}}
19
+
20
+
21
+ def _pct(values: list[float], p: float) -> float:
22
+ ordered = sorted(values)
23
+ return ordered[min(len(ordered) - 1, round(p * (len(ordered) - 1)))]
24
+
25
+
26
+ async def run_demo(url: str, *, token: str | None = None, echo: bool = True) -> dict[str, float]:
27
+ say = print if echo else (lambda *_: None)
28
+ conn = ClientConnection(
29
+ {"name": "awp-sim-demo", "version": __version__, "vendor": "hyperduality"},
30
+ ["proprio/json", "text/event+json"],
31
+ )
32
+ admission_ms: list[float] = []
33
+ async with AsyncClient(conn, url, token=token) as client:
34
+ manifest = await client.initialize()
35
+ mode = manifest["time_models"][0]
36
+ ready = await client.open_session(
37
+ mode, embodiment="arm_01", subscribe=["proprio", "arm_state"]
38
+ )
39
+ say(f"session {ready['session_id']} ({mode}) on {manifest['world']['name']}")
40
+ lockstep = mode == "lockstep"
41
+ if "move_to_pose" not in conn.granted_action_types:
42
+ say(" move_to_pose is not granted; nothing to do")
43
+ await client.close_session()
44
+ return {}
45
+
46
+ async def move(target: tuple[float, float, float], **kw: Any) -> str:
47
+ basis = None if lockstep else client.latest["proprio"].frame
48
+ validity = None if lockstep else 200
49
+ started = time.perf_counter()
50
+ record = await client.submit(
51
+ "move_to_pose", _pose(target), basis=basis, valid_for_ms=validity, **kw
52
+ )
53
+ admission_ms.append((time.perf_counter() - started) * 1000)
54
+ return record.action_id
55
+
56
+ for target in TARGETS:
57
+ action = await move(target)
58
+ while lockstep and not conn.actions[action].terminal:
59
+ await client.advance(10)
60
+ record = await client.wait_terminal(action)
61
+ say(f" move to {target}: {record.state}")
62
+
63
+ if not lockstep:
64
+ action = await move(TARGETS[0])
65
+ await client.wait_for(lambda e: (conn.actions[action].progress or 0) > 0.2, 5)
66
+ await client.cancel(action)
67
+ record = await client.wait_terminal(action)
68
+ say(f" cancelled move: {record.state} at {record.status.get('aborted_at_progress')}")
69
+
70
+ try:
71
+ await move((0.9, 0.0, 0.4))
72
+ except AwpError as err:
73
+ say(f" outside the envelope: {err.message}")
74
+
75
+ await client.close_session()
76
+
77
+ metrics = {
78
+ "admission_p50_ms": _pct(admission_ms, 0.5),
79
+ "admission_p95_ms": _pct(admission_ms, 0.95),
80
+ }
81
+ say(
82
+ f"admission latency p50 {metrics['admission_p50_ms']:.2f} ms, "
83
+ f"p95 {metrics['admission_p95_ms']:.2f} ms"
84
+ )
85
+ return metrics