movement: smooth animated moves (move duration, observable intermediate positions) + proactive cruise (glide while the LLM thinks, collision-safe, reported back); verified mission with gpt-5.6-luna
This commit is contained in:
@@ -64,6 +64,20 @@ taken); stop the old one with `fuser -k 8765/tcp` or Ctrl-C in its terminal.
|
|||||||
The demo prints a full transcript of tool calls/results to stdout and saves the
|
The demo prints a full transcript of tool calls/results to stdout and saves the
|
||||||
capsule's final first-person frame to `demo_final_frame.png`.
|
capsule's final first-person frame to `demo_final_frame.png`.
|
||||||
|
|
||||||
|
### Smooth & proactive movement
|
||||||
|
|
||||||
|
The capsule no longer teleports or stops-and-thinks:
|
||||||
|
|
||||||
|
- **Smooth**: `move(forward, duration)` animates the displacement over
|
||||||
|
`duration` seconds on the bridge, so the live viewer shows genuine gliding.
|
||||||
|
The scripted agent and auto-cruise use it by default; the LLM can too.
|
||||||
|
- **Proactive** (`--cruise N`, meters per think): while the model is
|
||||||
|
generating a response, the capsule keeps gliding forward (low-level
|
||||||
|
controller pattern, like a real robot). Collisions stop the drift safely
|
||||||
|
and are reported to the model, which re-plans. Verified: gpt-5.6-luna
|
||||||
|
completed the beacon mission with cruise on, bumping into and avoiding
|
||||||
|
crates while "thinking".
|
||||||
|
|
||||||
## Providers
|
## Providers
|
||||||
|
|
||||||
Provider presets resolve endpoint + API key + default model:
|
Provider presets resolve endpoint + API key + default model:
|
||||||
|
|||||||
+38
-3
@@ -85,6 +85,7 @@ class MoveOutput(BaseModel):
|
|||||||
collision_normal: Vec3 | None
|
collision_normal: Vec3 | None
|
||||||
position: Vec3
|
position: Vec3
|
||||||
tick: int
|
tick: int
|
||||||
|
duration: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class TurnOutput(BaseModel):
|
class TurnOutput(BaseModel):
|
||||||
@@ -165,6 +166,31 @@ class RoomBridge(Bridge):
|
|||||||
def _tick(self) -> int:
|
def _tick(self) -> int:
|
||||||
return self.world.advance_tick()
|
return self.world.advance_tick()
|
||||||
|
|
||||||
|
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:
|
def _vec3(self, x: float, y: float, z: float) -> Vec3:
|
||||||
return Vec3(x=round(x, 3), y=round(y, 3), z=round(z, 3))
|
return Vec3(x=round(x, 3), y=round(y, 3), z=round(z, 3))
|
||||||
|
|
||||||
@@ -263,13 +289,21 @@ class RoomBridge(Bridge):
|
|||||||
|
|
||||||
@self.tool(
|
@self.tool(
|
||||||
cls=ToolClass.ACTUATOR,
|
cls=ToolClass.ACTUATOR,
|
||||||
description="Move the capsule forward along its heading by the given distance in meters.",
|
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) -> MoveOutput:
|
async def move(forward: float = 1.0, duration: float = 0.0) -> MoveOutput:
|
||||||
tick = self._tick()
|
tick = self._tick()
|
||||||
if forward < 0.0 or forward > 5.0:
|
if forward < 0.0 or forward > 5.0:
|
||||||
raise ValueError(f"forward must be in [0, 5] m, got {forward}")
|
raise ValueError(f"forward must be in [0, 5] m, got {forward}")
|
||||||
moved, hit = world.move_forward(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
|
c = world.capsule
|
||||||
normal = None
|
normal = None
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
@@ -294,6 +328,7 @@ class RoomBridge(Bridge):
|
|||||||
collision_normal=normal,
|
collision_normal=normal,
|
||||||
position=self._vec3(c.x, 0.0, c.z),
|
position=self._vec3(c.x, 0.0, c.z),
|
||||||
tick=tick,
|
tick=tick,
|
||||||
|
duration=round(duration, 3),
|
||||||
).model_dump()
|
).model_dump()
|
||||||
|
|
||||||
@self.tool(
|
@self.tool(
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ async def chat_loop(client: AICCClient, manifest, args: argparse.Namespace) -> i
|
|||||||
log=mission_log,
|
log=mission_log,
|
||||||
nudge_limit=args.mission_nudges,
|
nudge_limit=args.mission_nudges,
|
||||||
look_every=args.look_every,
|
look_every=args.look_every,
|
||||||
|
cruise=args.cruise,
|
||||||
)
|
)
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
print(f" [mission] LLM error: {exc}")
|
print(f" [mission] LLM error: {exc}")
|
||||||
@@ -463,6 +464,12 @@ def main() -> int:
|
|||||||
default=3,
|
default=3,
|
||||||
help="attach a fresh camera frame every N turns (0 disables; default 3)",
|
help="attach a fresh camera frame every N turns (0 disables; default 3)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cruise",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
help="proactive motion in missions: glide forward (meters) while the LLM thinks (0 disables)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
args.base_url, args.api_key, args.model = resolve_provider(
|
args.base_url, args.api_key, args.model = resolve_provider(
|
||||||
|
|||||||
+13
-4
@@ -142,6 +142,7 @@ async def run_llm_agent(
|
|||||||
vision: bool | None = None,
|
vision: bool | None = None,
|
||||||
digest: bool | None = None,
|
digest: bool | None = None,
|
||||||
look_every: int = 0,
|
look_every: int = 0,
|
||||||
|
cruise: float = 0.0,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Autonomous LLM run: controller + nudge/correct loop (see llm_agent)."""
|
"""Autonomous LLM run: controller + nudge/correct loop (see llm_agent)."""
|
||||||
from testbed.llm_agent import LLMController, run_llm_agent_loop
|
from testbed.llm_agent import LLMController, run_llm_agent_loop
|
||||||
@@ -233,9 +234,9 @@ async def run_scripted_agent(
|
|||||||
|
|
||||||
if detour_steps > 0:
|
if detour_steps > 0:
|
||||||
# Escape the obstacle before re-aiming: keep the detour heading.
|
# Escape the obstacle before re-aiming: keep the detour heading.
|
||||||
mv = (await client.call_tool("move", {"forward": 0.8})).output
|
mv = (await client.call_tool("move", {"forward": 0.8, "duration": 0.6})).output
|
||||||
summary["tool_calls"] += 1
|
summary["tool_calls"] += 1
|
||||||
log("tool", 'move({"forward": 0.8}) [detour]')
|
log("tool", 'move({"forward": 0.8, "duration": 0.6}) [detour]')
|
||||||
log("bridge", f"ok {json.dumps(mv)}")
|
log("bridge", f"ok {json.dumps(mv)}")
|
||||||
if mv.get("moved", 0.0) < 0.2 and detour_turns < 6:
|
if mv.get("moved", 0.0) < 0.2 and detour_turns < 6:
|
||||||
detour_turns += 1
|
detour_turns += 1
|
||||||
@@ -267,9 +268,9 @@ async def run_scripted_agent(
|
|||||||
log("tool", f'turn({{"yaw_deg": {delta * 0.8:.1f}}})')
|
log("tool", f'turn({{"yaw_deg": {delta * 0.8:.1f}}})')
|
||||||
log("bridge", f"ok {json.dumps(turn)}")
|
log("bridge", f"ok {json.dumps(turn)}")
|
||||||
else:
|
else:
|
||||||
mv = (await client.call_tool("move", {"forward": 0.8})).output
|
mv = (await client.call_tool("move", {"forward": 0.8, "duration": 0.6})).output
|
||||||
summary["tool_calls"] += 1
|
summary["tool_calls"] += 1
|
||||||
log("tool", 'move({"forward": 0.8})')
|
log("tool", 'move({"forward": 0.8, "duration": 0.6})')
|
||||||
log("bridge", f"ok {json.dumps(mv)}")
|
log("bridge", f"ok {json.dumps(mv)}")
|
||||||
if mv.get("collision"):
|
if mv.get("collision"):
|
||||||
consecutive_collisions += 1
|
consecutive_collisions += 1
|
||||||
@@ -315,6 +316,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
vision=args.vision,
|
vision=args.vision,
|
||||||
digest=args.digest,
|
digest=args.digest,
|
||||||
look_every=args.look_every,
|
look_every=args.look_every,
|
||||||
|
cruise=args.cruise,
|
||||||
)
|
)
|
||||||
elif args.agent == "scripted":
|
elif args.agent == "scripted":
|
||||||
summary = await run_scripted_agent(
|
summary = await run_scripted_agent(
|
||||||
@@ -338,6 +340,7 @@ async def run_demo(args: argparse.Namespace) -> dict[str, Any]:
|
|||||||
vision=args.vision,
|
vision=args.vision,
|
||||||
digest=args.digest,
|
digest=args.digest,
|
||||||
look_every=args.look_every,
|
look_every=args.look_every,
|
||||||
|
cruise=args.cruise,
|
||||||
)
|
)
|
||||||
if summary.get("interacted"):
|
if summary.get("interacted"):
|
||||||
return summary
|
return summary
|
||||||
@@ -431,6 +434,12 @@ def main() -> int:
|
|||||||
default=3,
|
default=3,
|
||||||
help="attach a fresh camera frame every N agent steps (0 disables; default 3)",
|
help="attach a fresh camera frame every N agent steps (0 disables; default 3)",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--cruise",
|
||||||
|
type=float,
|
||||||
|
default=1.0,
|
||||||
|
help="proactive motion: glide forward (meters) while the LLM thinks (0 disables)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--frame",
|
"--frame",
|
||||||
default="demo_final_frame.png",
|
default="demo_final_frame.png",
|
||||||
|
|||||||
+68
-4
@@ -9,6 +9,7 @@ tool calls/results fed back as `tool` messages.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
@@ -463,10 +464,12 @@ class LLMController:
|
|||||||
async def send_user(self, text: str) -> None:
|
async def send_user(self, text: str) -> None:
|
||||||
self.messages.append({"role": "user", "content": text})
|
self.messages.append({"role": "user", "content": text})
|
||||||
|
|
||||||
async def invoke(self) -> TurnResult:
|
async def request(self) -> Any:
|
||||||
"""One model round-trip: get the response and execute its tool calls."""
|
"""Send the current conversation to the model and return the raw
|
||||||
|
response WITHOUT executing any tool calls. Lets a caller overlap the
|
||||||
|
model's thinking time with other work (e.g. proactive cruising)."""
|
||||||
try:
|
try:
|
||||||
resp = await self.ac.chat.completions.create(
|
return await self.ac.chat.completions.create(
|
||||||
model=self.model,
|
model=self.model,
|
||||||
messages=self.messages,
|
messages=self.messages,
|
||||||
tools=self.tools,
|
tools=self.tools,
|
||||||
@@ -476,6 +479,12 @@ class LLMController:
|
|||||||
self.log("agent", f"LLM error: {exc}")
|
self.log("agent", f"LLM error: {exc}")
|
||||||
raise RuntimeError(f"LLM error: {exc}") from exc
|
raise RuntimeError(f"LLM error: {exc}") from exc
|
||||||
|
|
||||||
|
async def invoke(self) -> TurnResult:
|
||||||
|
"""One model round-trip: get the response and execute its tool calls."""
|
||||||
|
return await self.execute(await self.request())
|
||||||
|
|
||||||
|
async def execute(self, resp: Any) -> TurnResult:
|
||||||
|
"""Execute the tool calls of a previously requested response."""
|
||||||
choice = resp.choices[0]
|
choice = resp.choices[0]
|
||||||
text = choice.message.content or ""
|
text = choice.message.content or ""
|
||||||
result = TurnResult(text=text)
|
result = TurnResult(text=text)
|
||||||
@@ -596,11 +605,15 @@ async def run_llm_agent_loop(
|
|||||||
recorder: Any | None = None,
|
recorder: Any | None = None,
|
||||||
nudge_limit: int = 1,
|
nudge_limit: int = 1,
|
||||||
look_every: int = 0,
|
look_every: int = 0,
|
||||||
|
cruise: float = 0.0,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Drive the controller until the mission is done or steps run out.
|
"""Drive the controller until the mission is done or steps run out.
|
||||||
|
|
||||||
``look_every``: attach a fresh camera frame every N steps so the model
|
``look_every``: attach a fresh camera frame every N steps so the model
|
||||||
always sees recent visual context without asking (0 disables).
|
always sees recent visual context without asking (0 disables).
|
||||||
|
``cruise``: proactive motion — while the model is thinking, the capsule
|
||||||
|
keeps gliding forward (meters per think, 0 disables). Collisions stop the
|
||||||
|
drift and are reported to the model.
|
||||||
"""
|
"""
|
||||||
summary: dict[str, Any] = {
|
summary: dict[str, Any] = {
|
||||||
"steps": 0,
|
"steps": 0,
|
||||||
@@ -613,6 +626,45 @@ async def run_llm_agent_loop(
|
|||||||
last_correct_step = -99
|
last_correct_step = -99
|
||||||
last_collision_step = -99
|
last_collision_step = -99
|
||||||
|
|
||||||
|
async def drift(amount: float, step: int) -> None:
|
||||||
|
"""Low-level controller: keep moving forward while the brain thinks."""
|
||||||
|
nonlocal last_collision_step
|
||||||
|
remaining = amount
|
||||||
|
total = 0.0
|
||||||
|
while remaining > 0.02:
|
||||||
|
mv = await controller.client.call_tool(
|
||||||
|
"move", {"forward": min(0.4, remaining), "duration": 0.5}
|
||||||
|
)
|
||||||
|
out = mv.output
|
||||||
|
controller.observe(out)
|
||||||
|
moved = out.get("moved", 0.0)
|
||||||
|
total += moved
|
||||||
|
remaining -= moved
|
||||||
|
if out.get("collision"):
|
||||||
|
last_collision_step = step
|
||||||
|
controller.messages.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
"While you were thinking, the capsule drifted forward and bumped "
|
||||||
|
f"into {out.get('collision_normal')}. It stopped. Navigate around it."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
log("agent", "(cruise: bumped while thinking — stopped)")
|
||||||
|
return
|
||||||
|
if total > 0.05:
|
||||||
|
controller.messages.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"While you were thinking, the capsule kept moving (auto-cruise, "
|
||||||
|
f"{total:.2f} m forward). {controller.state_hint()}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
log("agent", f"(cruise: drifted {total:.2f} m while thinking)")
|
||||||
|
|
||||||
def maybe_correct(step: int) -> None:
|
def maybe_correct(step: int) -> None:
|
||||||
nonlocal corrections, last_correct_step
|
nonlocal corrections, last_correct_step
|
||||||
if controller.pos is None or controller.beacon_pos is None:
|
if controller.pos is None or controller.beacon_pos is None:
|
||||||
@@ -652,6 +704,7 @@ async def run_llm_agent_loop(
|
|||||||
log("agent", "(correction: re-aim at the beacon)")
|
log("agent", "(correction: re-aim at the beacon)")
|
||||||
dist_history.clear()
|
dist_history.clear()
|
||||||
|
|
||||||
|
pending_resp: Any | None = None # LLM response requested while we cruised
|
||||||
for step in range(max_steps):
|
for step in range(max_steps):
|
||||||
summary["steps"] = step + 1
|
summary["steps"] = step + 1
|
||||||
if recorder is not None and controller.pos is not None:
|
if recorder is not None and controller.pos is not None:
|
||||||
@@ -660,7 +713,12 @@ async def run_llm_agent_loop(
|
|||||||
# sees what is happening without having to ask.
|
# sees what is happening without having to ask.
|
||||||
if look_every and step % look_every == 0:
|
if look_every and step % look_every == 0:
|
||||||
await controller.auto_frame()
|
await controller.auto_frame()
|
||||||
turn = await controller.invoke()
|
if pending_resp is not None:
|
||||||
|
resp = await pending_resp
|
||||||
|
pending_resp = None
|
||||||
|
turn = await controller.execute(resp)
|
||||||
|
else:
|
||||||
|
turn = await controller.invoke()
|
||||||
if turn.text:
|
if turn.text:
|
||||||
log("agent", turn.text[:400])
|
log("agent", turn.text[:400])
|
||||||
summary["tool_calls"] += len(turn.calls)
|
summary["tool_calls"] += len(turn.calls)
|
||||||
@@ -706,5 +764,11 @@ async def run_llm_agent_loop(
|
|||||||
return summary
|
return summary
|
||||||
controller.messages.append({"role": "user", "content": NUDGE})
|
controller.messages.append({"role": "user", "content": NUDGE})
|
||||||
log("agent", "(nudge: no tool call; continue the mission)")
|
log("agent", "(nudge: no tool call; continue the mission)")
|
||||||
|
continue
|
||||||
|
# Proactive motion: request the next response and, while the model
|
||||||
|
# thinks, keep the capsule gliding forward (collision-safe).
|
||||||
|
if cruise > 0.0 and step < max_steps - 1:
|
||||||
|
pending_resp = asyncio.create_task(controller.request())
|
||||||
|
await drift(cruise, step)
|
||||||
summary["result"] = f"exceeded {max_steps} steps"
|
summary["result"] = f"exceeded {max_steps} steps"
|
||||||
return summary
|
return summary
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Tests for the testbed bridge: tool behavior over the in-process transport."""
|
"""Tests for the testbed bridge: tool behavior over the in-process transport."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import io
|
import io
|
||||||
|
import math
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from aicc.client import AICCClient
|
from aicc.client import AICCClient
|
||||||
@@ -186,3 +188,62 @@ async def test_single_source_of_truth():
|
|||||||
world = dumped["world"]
|
world = dumped["world"]
|
||||||
assert set(world.keys()) == {"name", "kind"}
|
assert set(world.keys()) == {"name", "kind"}
|
||||||
assert "beacon" not in dumped
|
assert "beacon" not in dumped
|
||||||
|
|
||||||
|
|
||||||
|
async def test_animated_move_is_smooth_and_observable(bridge):
|
||||||
|
"""A move with duration glides: a concurrent client sees intermediate
|
||||||
|
positions, and the final displacement is exact."""
|
||||||
|
# A transport is a single session: one per client.
|
||||||
|
t1 = InProcessTransport.start(bridge)
|
||||||
|
t2 = InProcessTransport.start(bridge)
|
||||||
|
async with t1, t2:
|
||||||
|
mover = AICCClient(t1)
|
||||||
|
viewer = AICCClient(t2)
|
||||||
|
async with mover, viewer:
|
||||||
|
await mover.handshake()
|
||||||
|
await viewer.handshake()
|
||||||
|
start = (await mover.call_tool("proprioception", {})).output["position"]
|
||||||
|
assert start["x"] == 1.5 and start["z"] == 1.5
|
||||||
|
|
||||||
|
move_task = asyncio.create_task(
|
||||||
|
mover.call_tool("move", {"forward": 2.0, "duration": 0.8})
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
mid = (await viewer.call_tool("proprioception", {})).output["position"]
|
||||||
|
# Moving along the 45-degree diagonal: strictly between start and end.
|
||||||
|
assert 1.5 < mid["x"] < 3.5 and 1.5 < mid["z"] < 3.5
|
||||||
|
|
||||||
|
res = (await move_task).output
|
||||||
|
assert res["moved"] == pytest.approx(2.0, abs=1e-3)
|
||||||
|
assert res["position"]["x"] == pytest.approx(
|
||||||
|
1.5 + 2.0 * math.sin(math.radians(45)), abs=1e-3
|
||||||
|
)
|
||||||
|
assert res["duration"] == 0.8
|
||||||
|
|
||||||
|
|
||||||
|
async def test_animated_move_stops_on_collision(bridge):
|
||||||
|
t = InProcessTransport.start(bridge)
|
||||||
|
async with t:
|
||||||
|
client = AICCClient(t)
|
||||||
|
async with client:
|
||||||
|
await client.handshake()
|
||||||
|
bridge.world.capsule.yaw_deg = 180.0 # straight at the north wall
|
||||||
|
res = (
|
||||||
|
await client.call_tool("move", {"forward": 5.0, "duration": 0.6})
|
||||||
|
).output
|
||||||
|
assert res["collision"] is True
|
||||||
|
assert res["moved"] < 5.0
|
||||||
|
assert res["position"]["z"] == pytest.approx(
|
||||||
|
bridge.world.capsule.radius, abs=1e-3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_move_accepts_duration_zero_default(bridge):
|
||||||
|
t = InProcessTransport.start(bridge)
|
||||||
|
async with t:
|
||||||
|
client = AICCClient(t)
|
||||||
|
async with client:
|
||||||
|
await client.handshake()
|
||||||
|
res = (await client.call_tool("move", {"forward": 1.0})).output
|
||||||
|
assert res["moved"] == pytest.approx(1.0)
|
||||||
|
assert res["duration"] == 0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user