Lesson 20: BonicBot’s Reverse Gear
Learning Objective
Build an action-stack agent that maintains physical history in a LIFO stack. The LLM classifies user commands as new moves or reverse requests, allowing the robot to execute actions and accurately retrace its path back to the starting point.
Introduction
A growing message list is often used as an agent’s memory — every turn, the model reread the conversation to decide what to do next. That works well when the memory is conversational.
Here the memory is different in kind: it’s physical history — an ordered record of real actions with exact numbers attached (drive 1.0m at 0.3 m/s; rotate 90.0° at 45°/s). Trusting an LLM to re-read a transcript and correctly recall exact numbers is asking for a mistake. So this lesson keeps that record as a plain Python list — action_log — and the model’s job shrinks to exactly one classification per instruction:
| Ingredient | Plain-English meaning | In this lesson |
|---|---|---|
| Goal | What is the agent trying to achieve? | Carry out each movement instruction exactly, and when asked, return to precisely where it started |
| State / memory | What does it remember about progress so far? | action_log — a stack (list) of every move actually performed, in order |
| Decide | Given the state, what should it do next? | Classify one instruction as a new move or a reverse request — via a forced tool call |
| Act | Actually do the thing. | bot.drive_distance() / bot.rotate_angle() — real precise motion, no continuous velocity |
| Loop + stop | Repeat until the goal is reached or a limit is hit. | while True until the student types “quit”; each reverse is its own mini-loop that pops the stack |
🧠 Why LIFO (last-in, first-out) matters: if you drive forward, then turn, then drive again, retracing your steps means undoing the most recent move first — turn back, then walk back — not undoing them in the order you did them. Popping a stack (instead of walking a list front-to-back) is what makes the reversal actually retrace the physical path instead of producing nonsense. This is the same reason “undo” buttons in every piece of software you’ve ever used work last-action-first.
Only precise motion is used here — no camera, no detection, no continuous move()/turn_left() velocity commands. drive_distance() and rotate_angle() take an exact signed amount, which is what makes an exact, reliable inverse possible: negate the amount, and you get the exact opposite move.
Setup: Installing Packages
Before running the code, make sure your computer has the required Python packages installed. Open a terminal and run:
pip install openai pydantic piper-tts pyaudio bonicbot-bridge --break-system-packagesWhat each package does
| Package | Purpose |
|---|---|
bonicbot-bridge | The official BonicBot SDK. Provides the BonicBot class and BonicBotError exception used to connect via with BonicBot(host=HOST, port=9090, timeout=10) as bot:, plus the precise-motion calls bot.drive_distance(), bot.rotate_angle(), bot.get_position(), and bot.get_distance_traveled() used throughout perform_and_log(), reverse_moves(), and check_return_to_start(). |
openai | Provides the OpenAI client class (used against OpenRouter’s OpenAI-compatible endpoint) plus the APIError, AuthenticationError, and RateLimitError exception types. ensure_openrouter_client() constructs the client, and call_model() calls client.chat.completions.create(...) with forced tool-calling (tool_choice="required") to classify each instruction. |
pydantic | Provides BaseModel, Field, and the ValidationError exception. Used to define DoMoveArgs and ReverseArgs, which validate the model’s tool-call arguments (model_cls.model_validate_json(...)) before anything reaches the BonicBot SDK — defense-in-depth against a malformed or out-of-range LLM response. |
piper-tts | Provides the PiperVoice class and SynthesisConfig, plus download_voice() from piper.download_voices. Used in ensure_piper_voice_ready() to download the voice model if missing, and in speak_step() to synthesize and stream each spoken announcement. |
pyaudio | Provides the PyAudio class used to open an audio output stream in speak_step(), so the synthesized Piper audio chunks can actually be played through your speakers. |
This lesson also uses sys, time, pathlib.Path, and typing (all part of the standard library, so no separate install is needed for those).
If you already installed bonicbot-bridge, openai, pydantic, piper-tts, and pyaudio in an earlier lesson, you don’t need to reinstall anything for this lesson.
If pip install fails, try pip3 install ... instead, or use a virtual environment:
python3 -m venv bonicbot-env
source bonicbot-env/bin/activate # On Windows: bonicbot-env\Scripts\activate
pip install openai pydantic piper-tts pyaudio bonicbot-bridge --break-system-packagesHow to run the program
- Find your BonicBot’s IP address — via the robot’s on-device display, its companion app, or your router’s connected-devices list.
- Create an OpenRouter account and API key at openrouter.ai/keys , and check openrouter.ai/models?fmt=free for the current best free, tool-calling-capable model slug.
- Save the code below into a file, e.g.
lesson20_reverse_gear.py. - Replace
HOST = '[IP_ADDRESS]'with your BonicBot’s actual IP address, e.g.HOST = '172.20.10.2'. - Paste your real key into
OPENROUTER_API_KEYnear the top of the script, and updateMODEL_NAMEif the default slug no longer resolves. - Make sure your BonicBot is powered on and network-connected, and that it has clear open floor space around it to drive and rotate freely — this lesson moves the robot’s base, not just its camera or arms.
- Run the script:
python lesson20_reverse_gear.py- On first run, Piper will download the
en_US-lessac-mediumvoice model automatically. Once connected, you’ll see a prompt (Instruction>) where you can type movement instructions likedrive forward 1 meter,turn right 90 degrees, orreverse. - Type
quitorexitat the prompt to stop the script cleanly.
Don’t have a physical BonicBot? Try it in simulation (optional)
This lesson relies on precise, exact-distance motion (bot.drive_distance(), bot.rotate_angle()) and real odometry feedback (bot.get_position(), bot.get_distance_traveled()) to verify the robot actually returns to its starting point. If you’re running against the ROS 2 simulation instead of a real BonicBot, use the same host substitution pattern as earlier lessons — set HOST = 'localhost' and launch the simulation:
ros2 launch my_bot robot_system.launch.py use_sim_time:=true world:=obsworld.sdfSince this lesson doesn’t use the camera at all, use_real_camera doesn’t affect it either way — leave it at its default.
Test a small, easy-to-watch example first (e.g. drive forward 0.3 meters then reverse) to confirm the simulated robot’s signed drive/rotate behavior matches what the lesson assumes, before trusting a longer sequence. If you have a real BonicBot, using its actual IP address is still the recommended way to go through this lesson, since precise dead-reckoning drift is part of what the lesson is teaching you to notice.
Code
Click to view the complete program
"""
BonicBot's Reverse Gear — An Action-Stack Agent (OpenRouter)
=====================================================
Every agent in this lesson series is built from the same five ingredients:
GOAL -> carry out each instruction exactly, and get back to the
exact starting position/heading when asked to reverse
STATE / MEMORY -> `action_log`: a LIFO stack of every move actually
performed (NOT a growing chat transcript)
DECIDE -> ONE classification per instruction, as a forced tool
call: new move (do_move) or reverse request
(reverse_moves) [tool_choice="required"]
ACT -> bot.drive_distance() / bot.rotate_angle() -- real,
exact, signed precise motion
LOOP + STOP -> keep taking instructions until the student quits;
each reversal is its own inner loop popping the stack
The key design choice: the LLM only ever decides INTENT (new move, or
reverse?). It never has to recall or recompute exact distances/angles from
earlier turns -- those live in `action_log`, a plain Python list the code
trusts completely. That's why reversal is exact instead of approximate.
OpenRouter (via the OpenAI-compatible `openai` client) and Piper are
external libraries, not part of the BonicBot Bridge SDK.
Setup:
1. Create an OpenRouter account and API key at https://openrouter.ai/keys
2. pip install openai pydantic piper-tts pyaudio bonicbot-bridge --break-system-packages
3. Paste your key into OPENROUTER_API_KEY in the CONFIG section below.
4. Replace [IP_ADDRESS] with your BonicBot's IP.
5. Check https://openrouter.ai/models?fmt=free for the current best
free, tool-calling-capable model and set MODEL_NAME to its slug.
Free models are rate-limited (20/min, 50-1000/day), not billed.
"""
import sys
import time
from pathlib import Path
from typing import Literal, Optional
import pyaudio
from openai import OpenAI, APIError, AuthenticationError, RateLimitError
from pydantic import BaseModel, Field, ValidationError
from piper import PiperVoice, SynthesisConfig
from piper.download_voices import download_voice
from bonicbot_bridge import BonicBot
from bonicbot_bridge.exceptions import BonicBotError
# ============================================================================
# CONFIG
# ============================================================================
HOST = '[IP_ADDRESS]'
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
APP_URL = "https://example.com/bonicbot-lessons" # replace with your own
APP_TITLE = "BonicBot's Reverse Gear"
# Paste your key from https://openrouter.ai/keys here. Keep this file out
# of any public repo or shared folder if you hardcode it this way.
OPENROUTER_API_KEY = "your-key-here"
# A free, tool-calling-capable model on OpenRouter. Free models rotate
# fairly often -- check https://openrouter.ai/models?fmt=free if this slug
# ever stops resolving.
MODEL_NAME = "openrouter/free"
PIPER_VOICE_NAME = "en_US-lessac-medium"
PIPER_VOICES_DIR = Path(__file__).resolve().parent / "piper_voices"
# Same guard bot.drive_distance() enforces itself (raises PreciseMotionError
# past this) -- clamped client-side too so a bad model output never reaches
# the SDK as an out-of-range call.
DRIVE_RANGE = (-10.0, 10.0) # meters, negative = backward
ROTATE_RANGE = (-720.0, 720.0) # degrees, negative = the opposite turn direction
DEFAULT_DRIVE_SPEED = 0.3 # m/s
DEFAULT_ROTATE_SPEED = 45.0 # deg/s
# How far (in meters) BonicBot is allowed to end up from its starting
# position after a full reversal before we call it "drift" instead of
# "back home". Real open-loop dead reckoning always accumulates a little
# error -- this is what makes the check honest instead of always claiming
# success.
RETURN_TOLERANCE_M = 0.08
MAX_EMPTY_RESPONSE_RETRIES = 2
EMPTY_RESPONSE_RETRY_DELAY = 1.5 # seconds
SYSTEM_PROMPT = (
"You control BonicBot's movement only: driving straight and rotating "
"in place. For every instruction, call exactly one tool. Call do_move "
"for a new drive or rotate action. Call reverse_moves whenever the "
"student asks to reverse, undo, go back, retrace their steps, or "
"return to where they started. For do_move, 'amount' is in meters for "
"a drive (positive = forward, negative = backward) or degrees for a "
"rotate (pick a consistent sign for a given direction and stick to "
"it). Keep a single move reasonable -- under 3 meters or 360 degrees "
"-- unless the student clearly asks for more. For reverse_moves, only "
"set 'steps' if the student named a specific number of moves to undo "
"('undo my last two moves'); leave it unset to undo everything back "
"to the start."
)
# ============================================================================
# TOOL DEFINITIONS -- exactly one of these is called per instruction.
# tool_choice="required" forces a call, but leaves WHICH tool to the model.
# ============================================================================
MOVE_TOOL = {
"type": "function",
"function": {
"name": "do_move",
"description": "Perform ONE new precise movement: drive straight, or rotate in place, by an exact amount.",
"parameters": {
"type": "object",
"properties": {
"move_kind": {"type": "string", "enum": ["drive", "rotate"]},
"amount": {
"type": "number",
"description": "Meters for drive (positive=forward, negative=backward), or degrees for rotate.",
},
"speed": {
"type": "number",
"description": "Optional. m/s for drive, deg/s for rotate. Omit to use a sensible default.",
},
},
"required": ["move_kind", "amount"],
},
},
}
REVERSE_TOOL = {
"type": "function",
"function": {
"name": "reverse_moves",
"description": (
"Undo previously performed moves by replaying them in reverse "
"order with each amount inverted -- retracing the physical "
"path back the way it came."
),
"parameters": {
"type": "object",
"properties": {
"steps": {
"type": ["integer", "null"],
"description": "How many of the most recent moves to undo. Omit or null to undo everything back to the start.",
},
},
"required": [],
},
},
}
# ============================================================================
# ARG MODELS -- defense-in-depth validation before anything reaches the SDK
# ============================================================================
class DoMoveArgs(BaseModel):
move_kind: Literal["drive", "rotate"]
amount: float
speed: Optional[float] = None
class ReverseArgs(BaseModel):
steps: Optional[int] = Field(default=None, ge=1)
TOOL_ARG_MODELS = {"do_move": DoMoveArgs, "reverse_moves": ReverseArgs}
def clamp(value, min_val, max_val):
return max(min_val, min(max_val, value))
# ============================================================================
# OPENROUTER + PIPER SETUP
# ============================================================================
def ensure_openrouter_client():
if OPENROUTER_API_KEY == "your-key-here":
print("⚠️ Paste your real key into OPENROUTER_API_KEY near the top of the script.")
print(" Get one from https://openrouter.ai/keys")
sys.exit(1)
return OpenAI(
base_url=OPENROUTER_BASE_URL,
api_key=OPENROUTER_API_KEY,
default_headers={"HTTP-Referer": APP_URL, "X-Title": APP_TITLE},
)
def ensure_piper_voice_ready(voice_name, voices_dir):
voices_dir.mkdir(parents=True, exist_ok=True)
model_path = voices_dir / f"{voice_name}.onnx"
config_path = voices_dir / f"{voice_name}.onnx.json"
if model_path.exists() and config_path.exists():
return model_path, config_path
print(f"Downloading voice '{voice_name}'...")
download_voice(voice_name, voices_dir)
return model_path, config_path
def speak_step(piper_voice, pyaudio_instance, text):
print(f" 🗣️ BonicBot says: \"{text}\"")
syn_config = SynthesisConfig(length_scale=1.0, volume=1.0)
stream = None
try:
for chunk in piper_voice.synthesize(text, syn_config=syn_config):
if stream is None:
stream = pyaudio_instance.open(
format=pyaudio_instance.get_format_from_width(chunk.sample_width),
channels=chunk.sample_channels,
rate=chunk.sample_rate,
output=True,
)
stream.write(chunk.audio_int16_bytes)
finally:
if stream is not None:
stream.stop_stream()
stream.close()
def call_model(client, messages, tools, tool_choice="auto"):
"""Calls the model and guards against a 200 OK response that still has
no usable choice in it -- free, community-hosted models occasionally do
this under load instead of raising a proper error."""
for attempt in range(1, MAX_EMPTY_RESPONSE_RETRIES + 2):
response = client.chat.completions.create(
model=MODEL_NAME,
messages=messages,
tools=tools,
tool_choice=tool_choice,
temperature=0.2,
)
if response.choices:
return response
if attempt <= MAX_EMPTY_RESPONSE_RETRIES:
print(f" ⚠️ Empty response from OpenRouter (attempt {attempt}) -- retrying...")
time.sleep(EMPTY_RESPONSE_RETRY_DELAY)
return None
# ============================================================================
# DECIDE -- exactly one classification per instruction: new move, or reverse?
# Each instruction is handled fresh (system + this one line) -- the model
# doesn't need conversational memory, because the memory that actually
# matters (action_log) isn't the model's job to hold.
# ============================================================================
def decide_next_action(client, instruction):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": instruction},
]
response = call_model(client, messages, tools=[MOVE_TOOL, REVERSE_TOOL], tool_choice="required")
if response is None or not response.choices[0].message.tool_calls:
return None
call = response.choices[0].message.tool_calls[0]
model_cls = TOOL_ARG_MODELS.get(call.function.name)
if model_cls is None:
return None
try:
args = model_cls.model_validate_json(call.function.arguments)
except ValidationError as e:
print(f"⚠️ BonicBot's move didn't pass validation: {e}")
return None
return call.function.name, args
# ============================================================================
# ACT -- do_move: perform a new move and push it onto the stack
# ============================================================================
def perform_and_log(bot, action_log, args, piper_voice, pyaudio_instance):
speed = args.speed
if args.move_kind == "drive":
amount = clamp(args.amount, *DRIVE_RANGE)
speed = speed or DEFAULT_DRIVE_SPEED
direction = "forward" if amount >= 0 else "backward"
speak_step(piper_voice, pyaudio_instance, f"Driving {direction} {abs(amount):.2f} meters.")
bot.drive_distance(amount, speed=speed, engine="internal", timeout=30)
else:
amount = clamp(args.amount, *ROTATE_RANGE)
speed = speed or DEFAULT_ROTATE_SPEED
direction = "one way" if amount >= 0 else "the other way"
speak_step(piper_voice, pyaudio_instance, f"Rotating {abs(amount):.1f} degrees, {direction}.")
bot.rotate_angle(amount, speed=speed, engine="internal", timeout=30)
action_log.append({"kind": args.move_kind, "amount": amount, "speed": speed}) # STATE update
print(f" 📚 action_log now has {len(action_log)} move(s) on the stack.")
# ============================================================================
# ACT -- reverse_moves: pop the stack LIFO, execute the exact inverse of
# each move. This is the whole trick: negate the amount, same speed, and
# undo most-recent-first so a turn-then-drive correctly becomes
# drive-back-then-turn-back, not the other way around.
# ============================================================================
def reverse_moves(bot, action_log, steps, piper_voice, pyaudio_instance):
count = len(action_log) if steps is None else min(steps, len(action_log))
if count == 0:
speak_step(piper_voice, pyaudio_instance, "There's nothing to reverse yet.")
return 0
speak_step(piper_voice, pyaudio_instance, f"Reversing my last {count} move{'s' if count != 1 else ''}.")
for _ in range(count):
move = action_log.pop() # LIFO -- undo the most recent move first
inverse_amount = -move["amount"] # exact physical inverse
if move["kind"] == "drive":
bot.drive_distance(inverse_amount, speed=move["speed"], engine="internal", timeout=30)
else:
bot.rotate_angle(inverse_amount, speed=move["speed"], engine="internal", timeout=30)
print(f" 📚 action_log now has {len(action_log)} move(s) left on the stack.")
return count
# ============================================================================
# The agent's stopping condition, checked against reality: only claim
# "back at the start" if real odometry says so, not because the stack is
# empty. Open-loop dead reckoning drifts a little -- this is honest about it.
# ============================================================================
def check_return_to_start(bot, start_pos, piper_voice, pyaudio_instance):
drift = bot.get_distance_traveled(start_pos)
if drift <= RETURN_TOLERANCE_M:
speak_step(piper_voice, pyaudio_instance, "Back exactly where I started!")
else:
speak_step(piper_voice, pyaudio_instance, f"Close — I'm about {drift:.2f} meters off from where I started.")
# ============================================================================
# MAIN -- GOAL + STATE + the LOOP that ties every ingredient together
# ============================================================================
def main():
client = ensure_openrouter_client()
model_path, config_path = ensure_piper_voice_ready(PIPER_VOICE_NAME, PIPER_VOICES_DIR)
piper_voice = PiperVoice.load(model_path, config_path=config_path)
pyaudio_instance = pyaudio.PyAudio()
action_log = [] # STATE -- a stack of every move actually performed
try:
with BonicBot(host=HOST, port=9090, timeout=10) as bot:
start_pos = bot.get_position() # the point every reversal is measured against
print("✅ Connected. Give BonicBot movement instructions, e.g.:")
print(" 'drive forward 1 meter' 'turn right 90 degrees' 'reverse' 'quit'\n")
while True: # LOOP
instruction = input("Instruction> ").strip()
if not instruction:
continue
if instruction.lower() in {"quit", "exit"}:
break
try:
decision = decide_next_action(client, instruction) # DECIDE
except AuthenticationError:
print("⚠️ OpenRouter rejected the API key — check OPENROUTER_API_KEY near the top of the script.")
break
except RateLimitError:
print("⚠️ Hit OpenRouter's free-tier rate limit (20/min, 50-1000/day) — wait a bit and try again.")
continue
except APIError as e:
print(f"⚠️ OpenRouter API error: {e}")
continue
if decision is None:
print("⚠️ Couldn't turn that into a move — try rephrasing.")
continue
name, args = decision
try:
if name == "do_move":
perform_and_log(bot, action_log, args, piper_voice, pyaudio_instance) # ACT
elif name == "reverse_moves":
reverse_moves(bot, action_log, args.steps, piper_voice, pyaudio_instance) # ACT
if not action_log: # fully back to the start
check_return_to_start(bot, start_pos, piper_voice, pyaudio_instance)
except BonicBotError as e:
print(f"⚠️ Robot error: {e}")
except BonicBotError as e:
print(f"⚠️ Robot error: {e}")
finally:
pyaudio_instance.terminate()
if __name__ == "__main__":
main()Replace [IP_ADDRESS] with your BonicBot’s IP address, and paste a real key into OPENROUTER_API_KEY, before running.
⚠️ This lesson assumes bot.rotate_angle(-x, ...) rotates the exact opposite direction of bot.rotate_angle(x, ...) by the same amount, and bot.drive_distance(-d, ...) drives the same distance backward. That’s a very safe assumption for a “rotate/drive by a signed amount” API, but test it on a small, easy-to-watch example (e.g. drive forward 0.3 meters then reverse) before trusting a longer sequence — by checking against the real installed package instead of the docs alone.
Code Walkthrough
Line-by-line explanation
- Imports —
sysandtimesupport exits and retry delays;Pathfrompathliblocates the Piper voice folder;Literal/Optionalfromtypingtype the Pydantic models;pyaudioplays synthesized speech;OpenAI/APIError/AuthenticationError/RateLimitErrorfromopenaidrive the OpenRouter connection;BaseModel/Field/ValidationErrorfrompydanticvalidate tool-call arguments;PiperVoice/SynthesisConfig/download_voicefrompiperhandle text-to-speech;BonicBot/BonicBotErrormanage the robot connection. - CONFIG constants —
HOSTis the robot’s IP placeholder;OPENROUTER_BASE_URL,APP_URL,APP_TITLE, andOPENROUTER_API_KEYconfigure the OpenRouter client;MODEL_NAMEselects the free tool-calling model;PIPER_VOICE_NAME/PIPER_VOICES_DIRlocate the TTS voice;DRIVE_RANGE/ROTATE_RANGEclamp move amounts client-side as a safety net;DEFAULT_DRIVE_SPEED/DEFAULT_ROTATE_SPEEDfill in speed when the model omits it;RETURN_TOLERANCE_Msets how much dead-reckoning drift counts as “still home”;MAX_EMPTY_RESPONSE_RETRIES/EMPTY_RESPONSE_RETRY_DELAYhandle occasional empty responses from free-tier models. SYSTEM_PROMPT— Instructs the model to call exactly one tool per instruction, explains the sign convention foramount(meters for drive, degrees for rotate), sets a soft cap on single-move size, and clarifies whenstepsshould be set onreverse_movesversus left unset (undo everything).MOVE_TOOL/REVERSE_TOOL— The two tool schemas passed to the model.do_moverequiresmove_kind("drive"or"rotate") andamount, with optionalspeed.reverse_movestakes an optionalsteps(how many recent moves to undo; omitted/null means undo everything). Together withtool_choice="required"incall_model(), this forces the model to always classify the instruction as one or the other.DoMoveArgs/ReverseArgs/TOOL_ARG_MODELS— Pydantic models that validate the model’s raw JSON tool-call arguments before they’re trusted:DoMoveArgsenforcesmove_kindis one of the two literal strings andamount/speedare numeric;ReverseArgsenforcesstepsisNoneor an integer>= 1.TOOL_ARG_MODELSmaps each tool name to its validator for lookup indecide_next_action().clamp(value, min_val, max_val)— Small helper that clips a value into a[min_val, max_val]range; used to enforceDRIVE_RANGE/ROTATE_RANGEclient-side even if the model requests something out of bounds.ensure_openrouter_client()— Exits with a clear message ifOPENROUTER_API_KEYwas never replaced from its placeholder; otherwise constructs and returns anOpenAIclient pointed atOPENROUTER_BASE_URLwith OpenRouter’s recommended attribution headers.ensure_piper_voice_ready(voice_name, voices_dir)— Checks whether the.onnxmodel and config files already exist locally; if not, callsdownload_voice(...)to fetch them, then returns both paths.speak_step(piper_voice, pyaudio_instance, text)— Prints the spoken line for visibility, synthesizes it chunk-by-chunk viapiper_voice.synthesize(...), opens apyaudiooutput stream sized to the first chunk’s format, and writes each chunk’s audio bytes to it — closing the stream in afinallyblock regardless of errors.call_model(client, messages, tools, tool_choice="auto")— Wrapsclient.chat.completions.create(...)with a retry loop: if the response comes back with nochoicesat all (a known quirk of some free, community-hosted models under load), it waitsEMPTY_RESPONSE_RETRY_DELAYseconds and retries up toMAX_EMPTY_RESPONSE_RETRIEStimes before giving up and returningNone.decide_next_action(client, instruction)— The Decide step. Sends only the system prompt plus the single newinstruction(no growing chat history, sinceaction_log— not the model — is the source of truth for past moves). Callscall_model(...)with both tools andtool_choice="required", extracts the first tool call, looks up the matching Pydantic model inTOOL_ARG_MODELS, and validates the arguments withmodel_validate_json(...), printing a warning and returningNoneif validation fails.perform_and_log(bot, action_log, args, piper_voice, pyaudio_instance)— The Act step for new moves. Clampsamountinto range, fills in a defaultspeedif none was given, speaks a description of the move, then callsbot.drive_distance(...)orbot.rotate_angle(...)depending onargs.move_kind. Afterward, it appends a{"kind", "amount", "speed"}dict ontoaction_log— this is the State update — and prints the current stack size.reverse_moves(bot, action_log, steps, piper_voice, pyaudio_instance)— The Act step for reversal. Computes how many moves to undo (steps, or the whole stack ifsteps is None), returns early with a spoken message if the stack is empty, then loops that many times: each iteration callsaction_log.pop()to remove and return the most recently performed move (LIFO), negates itsamountto get the exact inverse, and replays it withbot.drive_distance()orbot.rotate_angle(). This is what makes a turn-then-drive sequence reverse as drive-back-then-turn-back rather than the wrong order.check_return_to_start(bot, start_pos, piper_voice, pyaudio_instance)— After a full reversal (stack empty), this checks real odometry viabot.get_distance_traveled(start_pos)rather than trusting the empty stack alone, and speaks either “Back exactly where I started!” or an honest drift distance if it exceedsRETURN_TOLERANCE_M.main()— Ties every ingredient together:- Sets up the OpenRouter client, Piper voice, and PyAudio instance.
- Initializes
action_log = []— the State. - Inside
with BonicBot(...) as bot:, recordsstart_pos = bot.get_position()as the reference point for every future reversal, then enters the Loop: reads aninstructionfrom input, breaks on"quit"/"exit", otherwise callsdecide_next_action()wrapped intry/exceptblocks forAuthenticationError(fatal, breaks the loop),RateLimitError(recoverable, continues), andAPIError(recoverable, continues). - If a decision was returned, dispatches to
perform_and_log()for"do_move"orreverse_moves()for"reverse_moves", catchingBonicBotErroraround each so a single failed movement doesn’t crash the whole session. After a reversal emptiesaction_log, it callscheck_return_to_start(). - The outer
try/except BonicBotErrorcatches connection-level robot errors, and thefinallyblock always callspyaudio_instance.terminate()to clean up audio resources.
if __name__ == "__main__": main()— Standard entry point guard that starts the whole agent when the script is run directly.
Expected Output
Click to see expected output
Visual Output:
📌 Note: This visual demonstration is using the ROS 2 simulation with use_real_camera:=True.
Terminal Output:
✅ Connected. Give BonicBot movement instructions, e.g.:
'drive forward 1 meter' 'turn right 90 degrees' 'reverse' 'quit'
Instruction> drive forward 1 meter
🗣️ BonicBot says: "Driving forward 1.00 meters."
📚 action_log now has 1 move(s) on the stack.
Instruction> turn right 90 degrees
🗣️ BonicBot says: "Rotating 90.0 degrees, one way."
📚 action_log now has 2 move(s) on the stack.
Instruction> drive forward half a meter
🗣️ BonicBot says: "Driving forward 0.50 meters."
📚 action_log now has 3 move(s) on the stack.
Instruction> reverse
🗣️ BonicBot says: "Reversing my last 3 moves."
📚 action_log now has 0 move(s) left on the stack.
🗣️ BonicBot says: "Back exactly where I started!"
Instruction> quitIf only some of the moves should undo:
Instruction> undo my last move
🗣️ BonicBot says: "Reversing my last 1 move."
📚 action_log now has 2 move(s) left on the stack.🧠 This Is Agentic AI — Mapped to the Code
Click to see every ingredient traced through the program
| Ingredient | Where it lives in the code |
|---|---|
| Goal | Implicit in every call: carry out the instruction exactly; check_return_to_start() verifies the “get back to start” version of the goal against real odometry |
| State / memory | action_log — a plain Python list used as a stack, holding the exact kind/amount/speed of every move performed |
| Decide | decide_next_action() — one forced tool call (tool_choice="required") per instruction picks between do_move and reverse_moves; the model never touches the actual numbers stored in action_log |
| Act | perform_and_log() and reverse_moves() — real bot.drive_distance() / bot.rotate_angle() calls, forward for new moves, negated and LIFO-ordered for reversal |
| Loop + stop | The outer while True runs until “quit”; reverse_moves() is its own inner loop that runs until the requested number of moves (or the whole stack) is undone |
Notice that check_return_to_start() doesn’t trust the stack being empty as proof BonicBot is home — it asks the robot’s own odometry (get_distance_traveled(start_pos)) and reports honestly if there’s drift. The agent’s success is checked against reality, not against its own bookkeeping.
Click to see why the model doesn’t need the conversation history
Often, history (the full message list) was the agent’s memory — every decision looked back over everything said so far. Here, each call to decide_next_action() sends only the system prompt plus the one new instruction. That’s not an oversight — it’s the point. The thing that needs remembering (exact distances and angles) isn’t something you want a language model reconstructing from a transcript; it’s something you want a data structure holding exactly. The model’s only real job is a one-shot classification: new move, or reverse? Recognizing when an agent’s state belongs in code rather than in the prompt is a design skill, not just an implementation detail.
Student Challenge
Add a third tool, list_moves, that the model can call when the student asks “what have you done so far?” — have it read action_log back in plain English (e.g., “1. drove forward 1.00m, 2. rotated 90.0°…”) without performing any motion. Then try asking BonicBot to reverse before giving it any moves at all — confirm reverse_moves() handles an empty stack gracefully instead of erroring.
Reflection Question
action_log lives in a Python variable, not in the LLM’s context. If you restarted the script mid-sequence (moves performed, but not yet reversed), the stack would be gone and BonicBot would have no way to know it wasn’t home. Which of the five ingredients does that expose as fragile — and would saving action_log to a file between runs fix it, or does the deeper problem lie somewhere else?