Skip to Content
BonicBot A2DevelopmentArtificial IntelligenceLesson 20: BonicBot's Reverse Gear

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:

IngredientPlain-English meaningIn this lesson
GoalWhat is the agent trying to achieve?Carry out each movement instruction exactly, and when asked, return to precisely where it started
State / memoryWhat does it remember about progress so far?action_log — a stack (list) of every move actually performed, in order
DecideGiven the state, what should it do next?Classify one instruction as a new move or a reverse request — via a forced tool call
ActActually do the thing.bot.drive_distance() / bot.rotate_angle() — real precise motion, no continuous velocity
Loop + stopRepeat 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-packages

What each package does

PackagePurpose
bonicbot-bridgeThe 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().
openaiProvides 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.
pydanticProvides 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-ttsProvides 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.
pyaudioProvides 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-packages

How to run the program

  1. Find your BonicBot’s IP address — via the robot’s on-device display, its companion app, or your router’s connected-devices list.
  2. 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.
  3. Save the code below into a file, e.g. lesson20_reverse_gear.py.
  4. Replace HOST = '[IP_ADDRESS]' with your BonicBot’s actual IP address, e.g. HOST = '172.20.10.2'.
  5. Paste your real key into OPENROUTER_API_KEY near the top of the script, and update MODEL_NAME if the default slug no longer resolves.
  6. 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.
  7. Run the script:
python lesson20_reverse_gear.py
  1. On first run, Piper will download the en_US-lessac-medium voice model automatically. Once connected, you’ll see a prompt (Instruction>) where you can type movement instructions like drive forward 1 meter, turn right 90 degrees, or reverse.
  2. Type quit or exit at 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.sdf

Since 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

  • Importssys and time support exits and retry delays; Path from pathlib locates the Piper voice folder; Literal/Optional from typing type the Pydantic models; pyaudio plays synthesized speech; OpenAI/APIError/AuthenticationError/RateLimitError from openai drive the OpenRouter connection; BaseModel/Field/ValidationError from pydantic validate tool-call arguments; PiperVoice/SynthesisConfig/download_voice from piper handle text-to-speech; BonicBot/BonicBotError manage the robot connection.
  • CONFIG constantsHOST is the robot’s IP placeholder; OPENROUTER_BASE_URL, APP_URL, APP_TITLE, and OPENROUTER_API_KEY configure the OpenRouter client; MODEL_NAME selects the free tool-calling model; PIPER_VOICE_NAME/PIPER_VOICES_DIR locate the TTS voice; DRIVE_RANGE/ROTATE_RANGE clamp move amounts client-side as a safety net; DEFAULT_DRIVE_SPEED/DEFAULT_ROTATE_SPEED fill in speed when the model omits it; RETURN_TOLERANCE_M sets how much dead-reckoning drift counts as “still home”; MAX_EMPTY_RESPONSE_RETRIES/EMPTY_RESPONSE_RETRY_DELAY handle occasional empty responses from free-tier models.
  • SYSTEM_PROMPT — Instructs the model to call exactly one tool per instruction, explains the sign convention for amount (meters for drive, degrees for rotate), sets a soft cap on single-move size, and clarifies when steps should be set on reverse_moves versus left unset (undo everything).
  • MOVE_TOOL / REVERSE_TOOL — The two tool schemas passed to the model. do_move requires move_kind ("drive" or "rotate") and amount, with optional speed. reverse_moves takes an optional steps (how many recent moves to undo; omitted/null means undo everything). Together with tool_choice="required" in call_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: DoMoveArgs enforces move_kind is one of the two literal strings and amount/speed are numeric; ReverseArgs enforces steps is None or an integer >= 1. TOOL_ARG_MODELS maps each tool name to its validator for lookup in decide_next_action().
  • clamp(value, min_val, max_val) — Small helper that clips a value into a [min_val, max_val] range; used to enforce DRIVE_RANGE/ROTATE_RANGE client-side even if the model requests something out of bounds.
  • ensure_openrouter_client() — Exits with a clear message if OPENROUTER_API_KEY was never replaced from its placeholder; otherwise constructs and returns an OpenAI client pointed at OPENROUTER_BASE_URL with OpenRouter’s recommended attribution headers.
  • ensure_piper_voice_ready(voice_name, voices_dir) — Checks whether the .onnx model and config files already exist locally; if not, calls download_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 via piper_voice.synthesize(...), opens a pyaudio output stream sized to the first chunk’s format, and writes each chunk’s audio bytes to it — closing the stream in a finally block regardless of errors.
  • call_model(client, messages, tools, tool_choice="auto") — Wraps client.chat.completions.create(...) with a retry loop: if the response comes back with no choices at all (a known quirk of some free, community-hosted models under load), it waits EMPTY_RESPONSE_RETRY_DELAY seconds and retries up to MAX_EMPTY_RESPONSE_RETRIES times before giving up and returning None.
  • decide_next_action(client, instruction) — The Decide step. Sends only the system prompt plus the single new instruction (no growing chat history, since action_log — not the model — is the source of truth for past moves). Calls call_model(...) with both tools and tool_choice="required", extracts the first tool call, looks up the matching Pydantic model in TOOL_ARG_MODELS, and validates the arguments with model_validate_json(...), printing a warning and returning None if validation fails.
  • perform_and_log(bot, action_log, args, piper_voice, pyaudio_instance) — The Act step for new moves. Clamps amount into range, fills in a default speed if none was given, speaks a description of the move, then calls bot.drive_distance(...) or bot.rotate_angle(...) depending on args.move_kind. Afterward, it appends a {"kind", "amount", "speed"} dict onto action_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 if steps is None), returns early with a spoken message if the stack is empty, then loops that many times: each iteration calls action_log.pop() to remove and return the most recently performed move (LIFO), negates its amount to get the exact inverse, and replays it with bot.drive_distance() or bot.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 via bot.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 exceeds RETURN_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:, records start_pos = bot.get_position() as the reference point for every future reversal, then enters the Loop: reads an instruction from input, breaks on "quit"/"exit", otherwise calls decide_next_action() wrapped in try/except blocks for AuthenticationError (fatal, breaks the loop), RateLimitError (recoverable, continues), and APIError (recoverable, continues).
    • If a decision was returned, dispatches to perform_and_log() for "do_move" or reverse_moves() for "reverse_moves", catching BonicBotError around each so a single failed movement doesn’t crash the whole session. After a reversal empties action_log, it calls check_return_to_start().
    • The outer try/except BonicBotError catches connection-level robot errors, and the finally block always calls pyaudio_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> quit

If 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

IngredientWhere it lives in the code
GoalImplicit 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 / memoryaction_log — a plain Python list used as a stack, holding the exact kind/amount/speed of every move performed
Decidedecide_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
Actperform_and_log() and reverse_moves() — real bot.drive_distance() / bot.rotate_angle() calls, forward for new moves, negated and LIFO-ordered for reversal
Loop + stopThe 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?

Last updated on