Files
aicc-capsule/testbed/bridge.py
T

519 lines
17 KiB
Python

"""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 copy
import io
import math
from typing import Any
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
duration: float = 0.0
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 ReportOutput(BaseModel):
discovery: str
success: bool
verified: bool
distance: float
message: str
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()
async def _animate_turn(
self, yaw_deg: float, pitch_deg: float, duration: float
) -> None:
"""Rotate smoothly over ``duration`` seconds (observable mid-turn)."""
n = max(1, min(int(duration / 0.1), 100))
dt = duration / n
for i in range(n):
self.world.turn(yaw_deg / n, pitch_deg / n)
if i < n - 1:
await asyncio.sleep(dt)
async def _animate_move(
self, forward: float, duration: float
) -> tuple[float, Any | None]:
"""Smoothly displace the capsule over ``duration`` seconds.
The position advances in small timed steps, so concurrent clients
(e.g. the live viewer) observe genuine gliding motion instead of a
teleport. Collision resolution is unchanged.
"""
world = self.world
n = max(1, min(int(duration / 0.1), 200))
step = forward / n
dt = duration / n
total = 0.0
hit: Any | None = None
for i in range(n):
moved, h = world.move_forward(step)
total += moved
if h is not None or moved < step * 0.01:
hit = h
break
if i < n - 1:
await asyncio.sleep(dt)
return total, hit
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()
# Render off the event loop so frames never stall the bridge under
# load; deepcopy keeps the snapshot consistent with this tool call.
snapshot = copy.deepcopy(world)
img, _ = await asyncio.to_thread(renderer.render, snapshot)
def _encode() -> str:
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
return VisionOutput(
png_b64=await asyncio.to_thread(_encode),
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()
snapshot = copy.deepcopy(world)
_, grid = await asyncio.to_thread(renderer.render, snapshot, 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. "
"duration (seconds) animates the motion smoothly instead of teleporting."
),
)
async def move(forward: float = 1.0, duration: float = 0.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}")
if duration < 0.0 or duration > 10.0:
raise ValueError(f"duration must be in [0, 10] s, got {duration}")
if duration > 0.0:
moved, hit = await self._animate_move(forward, duration)
else:
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,
duration=round(duration, 3),
).model_dump()
@self.tool(
cls=ToolClass.ACTUATOR,
description=(
"Rotate the camera: yaw_deg turns left/right, pitch_deg tilts up/down. "
"duration (seconds) animates the rotation smoothly."
),
)
async def turn(
yaw_deg: float = 0.0, pitch_deg: float = 0.0, duration: float = 0.0
) -> TurnOutput:
tick = self._tick()
if duration < 0.0 or duration > 10.0:
raise ValueError(f"duration must be in [0, 10] s, got {duration}")
if duration > 0.0:
await self._animate_turn(yaw_deg, pitch_deg, duration)
else:
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: 'beacon' or a crate "
"(crate_red, crate_blue, crate_olive)."
),
)
async def look_at(target: str) -> LookAtOutput:
tick = self._tick()
box = next((b for b in world.boxes if b.id == target), None)
if target == "beacon":
yaw, pitch = world.look_at_beacon()
elif box is not None:
dx = box.cx - world.capsule.x
dz = box.cz - world.capsule.z
dist = math.hypot(dx, dz) or 1.0
yaw = math.degrees(math.atan2(dx, dz))
pitch = math.degrees(
math.atan2(box.height / 2 - world.capsule.eye_height, dist)
)
world.face(yaw, pitch)
else:
raise ValueError(
f"unknown target {target!r}; available: beacon, crate_red, crate_blue, crate_olive"
)
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()
@self.tool(
cls=ToolClass.ACTUATOR,
description=(
"Report a discovery to the operator (used in search missions): "
"say what you found. The report is verified against your position."
),
)
async def report(discovery: str = "triangle") -> ReportOutput:
tick = self._tick()
dist = world.distance_to_marker()
verified = world.marker is not None and dist <= 6.0
if verified:
message = f"report verified: {discovery} sighted {dist:.1f} m away"
else:
message = (
f"not verified: nothing matching {discovery!r} within 6 m "
f"(closest check {dist:.1f} m) — keep exploring"
)
return ReportOutput(
discovery=discovery,
success=True,
verified=verified,
distance=round(dist, 2),
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