testbed: bridge with world tools (proprioception, vision, depth, hear, world_query, move, turn, look_at, interact) + conformance tools; 9/9 scenarios green
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
"""The testbed bridge: connects the AICC protocol to the room world.
|
||||
|
||||
Registers the world tools (proprioception, vision, depth, hear, world_query,
|
||||
move, turn, look_at, interact) plus the conformance tools the scenario suite
|
||||
expects (echo, boom, bump). Subclasses ``aicc.Bridge`` only to learn which
|
||||
session is handling the current tool call, so tools can push async events
|
||||
(collision, audio) via ``emit_event``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import math
|
||||
from typing import Any, Literal
|
||||
|
||||
from aicc.bridge import Bridge
|
||||
from aicc.protocol import TickMode, ToolClass
|
||||
from pydantic import BaseModel
|
||||
|
||||
from testbed.room.render import Raycaster
|
||||
from testbed.room.world import Room
|
||||
|
||||
VISION_WIDTH = 160
|
||||
VISION_HEIGHT = 120
|
||||
DEPTH_WIDTH = VISION_WIDTH // 4
|
||||
DEPTH_HEIGHT = VISION_HEIGHT // 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Typed tool outputs (advertised as output_schema in the manifest)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Vec3(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
|
||||
class Rotation(BaseModel):
|
||||
yaw_deg: float
|
||||
pitch_deg: float
|
||||
|
||||
|
||||
class ProprioceptionOutput(BaseModel):
|
||||
position: Vec3
|
||||
rotation: Rotation
|
||||
velocity: Vec3
|
||||
health: float
|
||||
tick: int
|
||||
|
||||
|
||||
class VisionOutput(BaseModel):
|
||||
png_b64: str
|
||||
width: int
|
||||
height: int
|
||||
tick: int
|
||||
|
||||
|
||||
class DepthOutput(BaseModel):
|
||||
width: int
|
||||
height: int
|
||||
max_depth: float
|
||||
depth: list[list[float]]
|
||||
tick: int
|
||||
|
||||
|
||||
class Sound(BaseModel):
|
||||
kind: str
|
||||
direction_deg: float
|
||||
intensity: float
|
||||
|
||||
|
||||
class HearOutput(BaseModel):
|
||||
sounds: list[Sound]
|
||||
tick: int
|
||||
|
||||
|
||||
class MoveOutput(BaseModel):
|
||||
moved: float
|
||||
collision: bool
|
||||
collision_normal: Vec3 | None
|
||||
position: Vec3
|
||||
tick: int
|
||||
|
||||
|
||||
class TurnOutput(BaseModel):
|
||||
rotation: Rotation
|
||||
tick: int
|
||||
|
||||
|
||||
class LookAtOutput(BaseModel):
|
||||
target: str
|
||||
rotation: Rotation
|
||||
tick: int
|
||||
|
||||
|
||||
class InteractOutput(BaseModel):
|
||||
target: str
|
||||
success: bool
|
||||
message: str
|
||||
tick: int
|
||||
|
||||
|
||||
class WorldQueryOutput(BaseModel):
|
||||
room: dict[str, Any]
|
||||
obstacles: list[dict[str, Any]]
|
||||
beacon: dict[str, Any]
|
||||
tick: int
|
||||
|
||||
|
||||
class EchoOutput(BaseModel):
|
||||
value: str
|
||||
|
||||
|
||||
class BumpOutput(BaseModel):
|
||||
bumped: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RoomBridge(Bridge):
|
||||
"""AICC bridge over one Room. Event-driven ticks: the world advances
|
||||
only on tool calls (protocol tick_mode = "event")."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
world: Room | None = None,
|
||||
name: str = "testbed_room_01",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.world = world or Room()
|
||||
self.renderer = Raycaster(VISION_WIDTH, VISION_HEIGHT)
|
||||
self._session_id: str | None = None
|
||||
kwargs.setdefault("tick_mode", TickMode.EVENT)
|
||||
kwargs.setdefault("tick_rate_hz", 10.0)
|
||||
super().__init__(name=name, kind="3d", **kwargs)
|
||||
self._register_tools()
|
||||
|
||||
# -- session plumbing for event emission from tool handlers ------------
|
||||
|
||||
async def _handle_tool_call(self, session_id: str, call) -> dict[str, Any]:
|
||||
self._session_id = session_id
|
||||
return await super()._handle_tool_call(session_id, call)
|
||||
|
||||
def emit(
|
||||
self,
|
||||
topic: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
tick: int | None = None,
|
||||
) -> None:
|
||||
"""Push an async event to the session currently handling a tool call."""
|
||||
if self._session_id is not None:
|
||||
self.emit_event(self._session_id, topic, payload, tick=tick)
|
||||
|
||||
# -- world helpers ------------------------------------------------------
|
||||
|
||||
def _tick(self) -> int:
|
||||
return self.world.advance_tick()
|
||||
|
||||
def _vec3(self, x: float, y: float, z: float) -> Vec3:
|
||||
return Vec3(x=round(x, 3), y=round(y, 3), z=round(z, 3))
|
||||
|
||||
def _rotation(self) -> Rotation:
|
||||
c = self.world.capsule
|
||||
return Rotation(
|
||||
yaw_deg=round(c.yaw_deg % 360.0, 2), pitch_deg=round(c.pitch_deg, 2)
|
||||
)
|
||||
|
||||
def _bearing_to(self, dx: float, dz: float) -> float:
|
||||
"""Compass bearing of (dx, dz) relative to the capsule's heading."""
|
||||
abs_bearing = math.degrees(math.atan2(dx, dz))
|
||||
return (abs_bearing - self.world.capsule.yaw_deg) % 360.0
|
||||
|
||||
# -- tool registration --------------------------------------------------
|
||||
|
||||
def _register_tools(self) -> None:
|
||||
world = self.world
|
||||
renderer = self.renderer
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.SENSOR,
|
||||
description="Return the capsule's own state: position, rotation, velocity, health.",
|
||||
)
|
||||
async def proprioception() -> ProprioceptionOutput:
|
||||
tick = self._tick()
|
||||
c = world.capsule
|
||||
return ProprioceptionOutput(
|
||||
position=self._vec3(c.x, 0.0, c.z),
|
||||
rotation=self._rotation(),
|
||||
velocity=self._vec3(0.0, 0.0, c.speed),
|
||||
health=round(c.health, 1),
|
||||
tick=tick,
|
||||
).model_dump()
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.SENSOR,
|
||||
description="Return the current first-person RGB frame as base64 PNG (160x120).",
|
||||
limits={"calls_per_minute": 60},
|
||||
)
|
||||
async def vision() -> VisionOutput:
|
||||
tick = self._tick()
|
||||
img, _ = renderer.render(world)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return VisionOutput(
|
||||
png_b64=base64.b64encode(buf.getvalue()).decode("ascii"),
|
||||
width=VISION_WIDTH,
|
||||
height=VISION_HEIGHT,
|
||||
tick=tick,
|
||||
).model_dump()
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.SENSOR,
|
||||
description="Return a depth map (meters, 40x30) aligned to the vision frame.",
|
||||
)
|
||||
async def depth(max_depth: float = 10.0) -> DepthOutput:
|
||||
tick = self._tick()
|
||||
_, grid = renderer.render(world, max_depth=max_depth)
|
||||
return DepthOutput(
|
||||
width=DEPTH_WIDTH,
|
||||
height=DEPTH_HEIGHT,
|
||||
max_depth=round(max_depth, 2),
|
||||
depth=grid,
|
||||
tick=tick,
|
||||
).model_dump()
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.SENSOR,
|
||||
description="Return audio events since the last call: beacon hum when near, collision thuds.",
|
||||
)
|
||||
async def hear() -> HearOutput:
|
||||
tick = self._tick()
|
||||
sounds = world.hear_now()
|
||||
return HearOutput(
|
||||
sounds=[Sound(**s) for s in sounds],
|
||||
tick=tick,
|
||||
).model_dump()
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.SENSOR,
|
||||
description="Return static room layout: bounds, obstacle positions, beacon location.",
|
||||
)
|
||||
async def world_query() -> WorldQueryOutput:
|
||||
tick = self._tick()
|
||||
d = world.describe()
|
||||
return WorldQueryOutput(**d, tick=tick).model_dump()
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.ACTUATOR,
|
||||
description="Move the capsule forward along its heading by the given distance in meters.",
|
||||
)
|
||||
async def move(forward: float = 1.0) -> MoveOutput:
|
||||
tick = self._tick()
|
||||
if forward < 0.0 or forward > 5.0:
|
||||
raise ValueError(f"forward must be in [0, 5] m, got {forward}")
|
||||
moved, hit = world.move_forward(forward)
|
||||
c = world.capsule
|
||||
normal = None
|
||||
if hit is not None:
|
||||
normal = self._vec3(hit.normal_x, 0.0, hit.normal_z)
|
||||
self.emit(
|
||||
"collision",
|
||||
{
|
||||
"other": hit.other,
|
||||
"normal": normal.model_dump(),
|
||||
"impulse": round(hit.impulse, 3),
|
||||
},
|
||||
tick=tick,
|
||||
)
|
||||
world.queue_audio(
|
||||
"thud",
|
||||
self._bearing_to(-hit.normal_x, -hit.normal_z),
|
||||
min(1.0, 0.4 + hit.impulse * 0.15),
|
||||
)
|
||||
return MoveOutput(
|
||||
moved=round(moved, 3),
|
||||
collision=hit is not None,
|
||||
collision_normal=normal,
|
||||
position=self._vec3(c.x, 0.0, c.z),
|
||||
tick=tick,
|
||||
).model_dump()
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.ACTUATOR,
|
||||
description="Rotate the camera: yaw_deg turns left/right, pitch_deg tilts up/down.",
|
||||
)
|
||||
async def turn(yaw_deg: float = 0.0, pitch_deg: float = 0.0) -> TurnOutput:
|
||||
tick = self._tick()
|
||||
world.turn(yaw_deg, pitch_deg)
|
||||
return TurnOutput(rotation=self._rotation(), tick=tick).model_dump()
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.ACTUATOR,
|
||||
description="Orient the camera toward a named target (e.g. 'beacon').",
|
||||
)
|
||||
async def look_at(target: str) -> LookAtOutput:
|
||||
tick = self._tick()
|
||||
if target != "beacon":
|
||||
raise ValueError(
|
||||
f"unknown target {target!r}; available targets: beacon"
|
||||
)
|
||||
yaw, pitch = world.look_at_beacon()
|
||||
return LookAtOutput(
|
||||
target=target,
|
||||
rotation=Rotation(
|
||||
yaw_deg=round(yaw % 360.0, 2), pitch_deg=round(pitch, 2)
|
||||
),
|
||||
tick=tick,
|
||||
).model_dump()
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.ACTUATOR,
|
||||
description="Use the named interactable object (e.g. 'beacon'). Must be within reach.",
|
||||
)
|
||||
async def interact(target: str = "beacon") -> InteractOutput:
|
||||
tick = self._tick()
|
||||
if target != "beacon":
|
||||
raise ValueError(
|
||||
f"unknown target {target!r}; available targets: beacon"
|
||||
)
|
||||
success, message = world.interact_beacon()
|
||||
if success and world.beacon.active:
|
||||
world.queue_audio(
|
||||
"beacon_chime",
|
||||
self._bearing_to(
|
||||
world.beacon.x - world.capsule.x,
|
||||
world.beacon.z - world.capsule.z,
|
||||
),
|
||||
1.0,
|
||||
)
|
||||
return InteractOutput(
|
||||
target=target, success=success, message=message, tick=tick
|
||||
).model_dump()
|
||||
|
||||
# -- conformance tools (design doc: register alongside world tools) --
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.SENSOR,
|
||||
description="Echo a value back to the caller.",
|
||||
)
|
||||
async def echo(value: str = "") -> EchoOutput:
|
||||
return EchoOutput(value=value).model_dump()
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.ACTUATOR,
|
||||
description="Always raises an exception.",
|
||||
)
|
||||
async def boom() -> dict[str, Any]:
|
||||
raise RuntimeError("kaboom")
|
||||
|
||||
@self.tool(
|
||||
cls=ToolClass.ACTUATOR,
|
||||
description="Returns ok and emits a collision event.",
|
||||
)
|
||||
async def bump() -> BumpOutput:
|
||||
tick = self._tick()
|
||||
self.emit(
|
||||
"collision",
|
||||
{
|
||||
"other": "wall",
|
||||
"normal": {"x": 0.0, "y": 0.0, "z": 1.0},
|
||||
"impulse": 1.0,
|
||||
},
|
||||
tick=tick,
|
||||
)
|
||||
return BumpOutput(bumped=True).model_dump()
|
||||
|
||||
|
||||
def build_bridge(world: Room | None = None, **kwargs: Any) -> RoomBridge:
|
||||
"""Construct a fully-wired testbed bridge."""
|
||||
return RoomBridge(world=world, **kwargs)
|
||||
|
||||
|
||||
async def serve(host: str = "127.0.0.1", port: int = 8765) -> None:
|
||||
"""Run the bridge over WebSocket until interrupted."""
|
||||
from aicc.transport.websocket import WebSocketServer
|
||||
|
||||
bridge = build_bridge()
|
||||
async with WebSocketServer(bridge, host=host, port=port):
|
||||
print(f"AICC capsule testbed listening on ws://{host}:{port}")
|
||||
await bridge.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run the AICC capsule testbed bridge over WebSocket."
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
asyncio.run(serve(args.host, args.port))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Run the AICC conformance scenarios against the testbed bridge.
|
||||
|
||||
Usage:
|
||||
python -m testbed.conformance <scenarios-dir>
|
||||
|
||||
The same bridge used by the server and demo is exercised here, so a green
|
||||
suite proves the shipped bridge is protocol-conformant.
|
||||
|
||||
Exit code 0 when all scenarios pass, 1 otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aicc.conformance import run_scenarios, summarize
|
||||
|
||||
from testbed.bridge import build_bridge
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: python -m testbed.conformance <scenarios-dir>")
|
||||
return 2
|
||||
scenario_dir = Path(sys.argv[1]).resolve()
|
||||
bridge = build_bridge()
|
||||
results = asyncio.run(run_scenarios(bridge, scenario_dir))
|
||||
print(summarize(results))
|
||||
return 0 if all(r.passed for r in results) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user