awp-python 0.1.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- awp/__init__.py +64 -0
- awp/_spec/lifecycle.json +190 -0
- awp/_spec/schemas/action-cancel-result.schema.json +13 -0
- awp/_spec/schemas/action-ref.schema.json +11 -0
- awp/_spec/schemas/action-schema.schema.json +37 -0
- awp/_spec/schemas/action-status.schema.json +39 -0
- awp/_spec/schemas/action-submit-result.schema.json +16 -0
- awp/_spec/schemas/action-submit.schema.json +19 -0
- awp/_spec/schemas/agent-manifest.schema.json +27 -0
- awp/_spec/schemas/approval-requested.schema.json +25 -0
- awp/_spec/schemas/approval-respond.schema.json +31 -0
- awp/_spec/schemas/common.schema.json +150 -0
- awp/_spec/schemas/embodiment.schema.json +22 -0
- awp/_spec/schemas/empty-result.schema.json +8 -0
- awp/_spec/schemas/error.schema.json +25 -0
- awp/_spec/schemas/frame-inline.schema.json +20 -0
- awp/_spec/schemas/frame-tree.schema.json +26 -0
- awp/_spec/schemas/obs-report.schema.json +40 -0
- awp/_spec/schemas/observation-channel.schema.json +28 -0
- awp/_spec/schemas/ping-result.schema.json +13 -0
- awp/_spec/schemas/ping.schema.json +12 -0
- awp/_spec/schemas/profiles/gui-actions.schema.json +57 -0
- awp/_spec/schemas/reset-result.schema.json +11 -0
- awp/_spec/schemas/reset.schema.json +12 -0
- awp/_spec/schemas/restore.schema.json +11 -0
- awp/_spec/schemas/safety-policy.schema.json +81 -0
- awp/_spec/schemas/session-open.schema.json +41 -0
- awp/_spec/schemas/session-ready.schema.json +50 -0
- awp/_spec/schemas/session-resume.schema.json +12 -0
- awp/_spec/schemas/session-state.schema.json +14 -0
- awp/_spec/schemas/session-telemetry.schema.json +22 -0
- awp/_spec/schemas/session-transfer-result.schema.json +12 -0
- awp/_spec/schemas/session-transfer.schema.json +11 -0
- awp/_spec/schemas/snapshot-result.schema.json +11 -0
- awp/_spec/schemas/subscribe-result.schema.json +11 -0
- awp/_spec/schemas/subscribe.schema.json +11 -0
- awp/_spec/schemas/task-update.schema.json +11 -0
- awp/_spec/schemas/tick-result.schema.json +9 -0
- awp/_spec/schemas/tick.schema.json +12 -0
- awp/_spec/schemas/unsubscribe.schema.json +11 -0
- awp/_spec/schemas/world-event.schema.json +45 -0
- awp/_spec/schemas/world-manifest.schema.json +61 -0
- awp/aio.py +380 -0
- awp/client.py +781 -0
- awp/clock.py +61 -0
- awp/errors.py +112 -0
- awp/frames.py +174 -0
- awp/jsonrpc.py +84 -0
- awp/lifecycle.py +78 -0
- awp/py.typed +0 -0
- awp/schema.py +158 -0
- awp_python-0.1.0a1.dist-info/METADATA +127 -0
- awp_python-0.1.0a1.dist-info/RECORD +71 -0
- awp_python-0.1.0a1.dist-info/WHEEL +4 -0
- awp_python-0.1.0a1.dist-info/entry_points.txt +2 -0
- awp_python-0.1.0a1.dist-info/licenses/LICENSE +201 -0
- awp_sim/__init__.py +8 -0
- awp_sim/__main__.py +5 -0
- awp_sim/arm.py +163 -0
- awp_sim/audit.py +138 -0
- awp_sim/cli.py +238 -0
- awp_sim/config.py +225 -0
- awp_sim/demo.py +85 -0
- awp_sim/loopback.py +222 -0
- awp_sim/py.typed +0 -0
- awp_sim/recorder.py +72 -0
- awp_sim/replay.py +134 -0
- awp_sim/scenarios.py +427 -0
- awp_sim/server.py +333 -0
- awp_sim/session.py +153 -0
- awp_sim/world.py +1612 -0
awp_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
|