Lesson 19: Connecting BonicBot with an LLM
Learning Objective
Control BonicBot in real time using OpenRouter and live tool calling (with NVIDIA’s Nemotron 3 Ultra model). Implement OpenAI-compatible tool calls (neck_gesture, arm_gesture, move_gesture, speak) to execute actions dynamically turn-by-turn as the LLM decides.
Introduction
Generating a full script upfront is great for a rehearsed performance, but a bit heavy for “just turn left” — the model would have to produce a title, an array of steps, and satisfy a whole schema before a single motor moved.
This lesson gives Nemotron direct tools instead of a schema to fill in:
TOOLS = [
{"type": "function", "function": {"name": "neck_gesture", ...}},
{"type": "function", "function": {"name": "arm_gesture", ...}},
{"type": "function", "function": {"name": "move_gesture", ...}},
{"type": "function", "function": {"name": "speak", ...}},
]Each tool’s JSON Schema is just the field-level constraints from Lesson 18
(the same Literal enums, the same duration_seconds bounds) reshaped into
the format a tool-calling API expects. Nemotron reads the user’s instruction,
decides which tool(s) apply, and returns a tool_calls list. We execute each
one on the real robot, send the result back (“ok” or an error string), and
loop — so Nemotron can chain “look left, wave, then say hi” as three back-to-back
decisions instead of one upfront plan, and can course-correct if a call
comes back with an error.
Ollama is gone; OpenRouter is the external LLM gateway now, reached
through the openai Python package pointed at https://openrouter.ai/api/v1
(OpenRouter’s API is OpenAI-compatible, so the standard client works — no
OpenAI account needed). The model doing the reasoning is NVIDIA’s
Nemotron 3 Ultra, one of OpenRouter’s free (:free) models. Piper and
PyAudio are unchanged from Lesson 18.
On model choice: this lesson uses a config constant,
MODEL_NAME, rather than hard-coding a specific model forever. OpenRouter’s free-model lineup rotates fairly often — check https://openrouter.ai/models?fmt=free (or the Free Models collection) for whatever is currently the best free, tool-calling-capable model, and drop its slug in. Also note free (:free) models on OpenRouter are rate-limited rather than token-priced: 20 requests/minute always, and 50 requests/day until you’ve ever bought $10+ in credits (after which it’s 1,000/day).
Setup: Installing Packages
Before running the code, make sure your computer has the required Python packages and system libraries installed. Open a terminal and run:
pip install bonicbot-bridge opencv-python numpy openai pydantic piper-tts pyaudioWhat each package / tool does
| Package / Tool | Purpose |
|---|---|
openai | OpenAI Python client. Connects to OpenRouter’s OpenAI-compatible REST API endpoint (https://openrouter.ai/api/v1) to run NVIDIA’s Nemotron 3 Ultra (nvidia/nemotron-3-ultra-550b-a55b:free) for real-time tool calling. |
bonicbot-bridge | The official BonicBot SDK. Connects to BonicBot to execute physical neck poses (bot.set_neck()), arm gestures (bot.move_left_arm()), and movement routines. |
pydantic | Data validation library. Validates tool call parameters (NeckArgs, ArmArgs, MoveArgs, SpeakArgs) dynamically before sending instructions to the robot SDK. |
piper-tts | Fast, local neural Text-to-Speech (TTS) engine. Synthesizes voice audio for speak tool calls. |
pyaudio | Audio I/O library. Streams synthesized PCM audio chunks directly to your computer speakers. |
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 bonicbot-bridge opencv-python numpy openai pydantic piper-tts pyaudioHow to run the program
- Get an OpenRouter API Key (one-time setup):
- Create a free account at openrouter.ai and get an API key at openrouter.ai/keys .
- Set
OPENROUTER_API_KEY = "your-key-here"near the top of the script.
- Install PortAudio (system dependency for
pyaudio):- Linux:
sudo apt install portaudio19-dev - macOS:
brew install portaudio
- Linux:
- Update IP address: Find your BonicBot’s IP address and set
HOST = '[IP_ADDRESS]'(or'localhost'for simulation). - Save the code into a file, e.g.,
lesson19_improviser.py. - Run the script:
python lesson19_improviser.py - Type natural language instructions into the console (e.g. “glance to the right and say hi”, “turn left, wave, then speak”).
- Nemotron 3 Ultra on OpenRouter will evaluate your prompt, invoke tool calls dynamically turn-by-turn, and BonicBot will execute speech and motor actions in real time!
- Type “quit” or press Ctrl+C to exit.
Don’t have a physical BonicBot? Try it in simulation (optional)
If you don’t have physical access to a BonicBot, you can still work through this lesson using the ROS 2 simulation environment:
-
Launch the BonicBot simulation:
ros2 launch my_bot robot_system.launch.py use_sim_time:=true world:=obsworld.sdf use_real_camera:=True -
When
HOST = 'localhost',BonicBotconnects to the simulated robot and executes neck turns, arm gestures, and movement tool calls in real time.
Code
Click to view the complete program
import json
import sys
import time
from pathlib import Path
from typing import List, 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 = 'localhost' # replace with your BonicBot's IP address
# OpenRouter's API is OpenAI-compatible, so the same client library works —
# just point base_url at OpenRouter instead of OpenAI's servers.
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# Optional. Lets this app show up (attributed) in OpenRouter's own
# dashboards/rankings — neither header is required for requests to work.
APP_URL = "https://example.com/bonicbot-lessons" # replace with your own
APP_TITLE = "BonicBot the Improviser"
# 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"
# NVIDIA's Nemotron 3 Ultra, free on OpenRouter (the `:free` variant).
# Free models rotate on OpenRouter fairly often — check
# https://openrouter.ai/models?fmt=free if this slug ever stops resolving.
MODEL_NAME = "nvidia/nemotron-3-ultra-550b-a55b:free"
PIPER_VOICE_NAME = "en_US-lessac-medium"
PIPER_VOICES_DIR = Path(__file__).resolve().parent / "piper_voices"
# How many rounds of tool-calling we allow for a single typed instruction
# before giving up. A simple instruction resolves in 1-2 rounds; this is
# just a safety cap against a runaway back-and-forth.
MAX_TOOL_ROUNDS = 6
# --- Neck: yaw only, a continuous angle rather than named positions -------
# -90 = fully left, 0 = center, 90 = fully right (the SDK's documented
# neck yaw joint limit).
NECK_YAW_RANGE = (-90.0, 90.0)
# --- Arm: named presets, pre-checked against the SDK's documented joint
# limits (shoulder pitch -45..180 deg, elbow 0..50 deg) so Nemotron can only
# request a pose that's physically safe to send straight to move_*_arm().
# Identical table to Lesson 18 on purpose. ----------------------------------
ArmPose = Literal[
"rest", "wave", "reach_forward", "shrug",
"droop", "celebrate", "thinking", "hug_self",
]
ARM_PRESETS = {
# name (shoulder_deg, elbow_deg) -- meaning
"rest": (0.0, 0.0), # neutral, arm relaxed at the side
"wave": (90.0, 30.0), # greeting / hello / goodbye
"reach_forward": (90.0, 0.0), # offering, pointing, handing something over
"shrug": (45.0, 50.0), # unsure, confused, "I don't know"
"droop": (-40.0, 15.0), # sad, tired, deflated
"celebrate": (170.0, 10.0), # excited, proud, cheering
"thinking": (60.0, 50.0), # curious, pondering, hand drawn in
"hug_self": (30.0, 50.0), # shy, nervous, self-comforting
}
SHOULDER_RANGE = (0, 180) # from the SDK's documented joint limits
ELBOW_RANGE = (0, 50)
# --- Move: precise motion, exact distance/angle instead of speed+duration --
# bot.drive_distance's own guard rejects anything past 10.0 m (raises
# PreciseMotionError), so that's the hard bound here too. Rotation has no
# documented guard, so two full turns either way is a generous sanity bound.
DRIVE_DISTANCE_RANGE = (-10.0, 10.0) # meters, negative = backward
ROTATE_ANGLE_RANGE = (-720.0, 720.0) # degrees, negative = left
PRECISE_ENGINE = "internal" # no mapping/nav2 stack needed for open-loop moves
DEFAULT_DRIVE_SPEED = 0.2 # m/s
DEFAULT_ROTATE_SPEED = 30.0 # deg/s
def estimate_timeout(magnitude, speed, min_timeout=5.0, buffer_factor=1.5, buffer_seconds=5.0):
"""A blocking precise-motion call needs a timeout long enough to actually
finish the move, or the SDK will cut it off early -- exactly the kind of
'moved partway, then stopped' the fixed-duration approach ran into.
Estimating from distance/angle and speed instead of guessing a constant
avoids that."""
if speed <= 0:
return min_timeout
return max(min_timeout, abs(magnitude) / speed * buffer_factor + buffer_seconds)
SYSTEM_PROMPT = (
"You are the onboard controller for a small robot named BonicBot. You "
"can ONLY affect the robot by calling the tools you've been given — "
"there is no other way to move it or make it speak. Given a short "
"instruction, call whichever tools it needs, in the order the actions "
"should happen — you may call more than one tool in the same turn. "
"The neck moves to a yaw angle in degrees, from -90 (fully left) to 90 "
"(fully right), with 0 as straight ahead — a small glance is closer to "
"20-30 degrees, a full look to one side is closer to 60-90. BonicBot "
"cannot look up or down, cannot use grippers, and cannot strafe "
"sideways — only turn in place. Keep duration_seconds between "
"0.5 and 5.0. Use the speak tool at whatever point in the sequence a "
"spoken line actually belongs, not just at the very end. Once the "
"instruction has been fully carried out, reply with a short plain-text "
"line and no further tool calls."
)
# ============================================================================
# TOOL DEFINITIONS — these are Nemotron's only way to affect the real world.
# Each one's JSON Schema mirrors the same field constraints Lesson 18
# expressed as a Pydantic schema (the enums and duration bounds are
# identical), just reshaped for tool calling.
# ============================================================================
TOOLS = [
{
"type": "function",
"function": {
"name": "neck_gesture",
"description": (
"Turn BonicBot's neck to a specific yaw angle in degrees, "
"hold it briefly, then it returns to center (0) on its own. "
"Negative degrees turn left, positive degrees turn right, "
"0 is straight ahead. Use for attention, curiosity, or "
"acknowledging someone off to one side."
),
"parameters": {
"type": "object",
"properties": {
"neck_yaw_deg": {
"type": "number",
"minimum": -90,
"maximum": 90,
"description": "Yaw angle in degrees: negative = left, positive = right, 0 = center.",
},
"duration_seconds": {
"type": "number",
"minimum": 0.5,
"maximum": 5.0,
"description": "How long to hold the angle before returning to center.",
},
},
"required": ["neck_yaw_deg"],
},
},
},
{
"type": "function",
"function": {
"name": "arm_gesture",
"description": (
"Move one arm into a named emotional pose, hold it, then "
"return it to rest. Poses: rest (neutral), wave (greeting), "
"reach_forward (offering/pointing), shrug (unsure), droop "
"(sad/tired), celebrate (excited/proud), thinking "
"(curious/pondering), hug_self (shy/nervous)."
),
"parameters": {
"type": "object",
"properties": {
"arm_side": {"type": "string", "enum": ["left", "right"]},
"arm_pose": {"type": "string", "enum": list(ARM_PRESETS.keys())},
"duration_seconds": {
"type": "number",
"minimum": 0.5,
"maximum": 5.0,
},
},
"required": ["arm_side", "arm_pose"],
},
},
},
{
"type": "function",
"function": {
"name": "move_gesture",
"description": (
"Drive the body forward/backward or turn it left/right in "
"place, for a fixed duration. BonicBot cannot strafe "
"sideways — only drive straight or turn in place."
),
"parameters": {
"type": "object",
"properties": {
"move_action": {
"type": "string",
"enum": ["move_forward", "move_backward", "turn_left", "turn_right"],
},
"duration_seconds": {
"type": "number",
"minimum": 0.5,
"maximum": 5.0,
},
},
"required": ["move_action"],
},
},
},
{
"type": "function",
"function": {
"name": "speak",
"description": "Say one short, friendly sentence out loud through BonicBot's speaker.",
"parameters": {
"type": "object",
"properties": {
"speech_text": {"type": "string", "maxLength": 200},
},
"required": ["speech_text"],
},
},
},
]
# ============================================================================
# PYDANTIC ARG MODELS — a JSON Schema enum keeps Nemotron *mostly* honest,
# but arguments are still just a string it generated. These give the same
# defense-in-depth validation Lesson 18 did before anything reaches the SDK.
# ============================================================================
class NeckArgs(BaseModel):
neck_yaw_deg: float = Field(ge=NECK_YAW_RANGE[0], le=NECK_YAW_RANGE[1])
duration_seconds: float = Field(default=1.5, ge=0.5, le=5.0)
class ArmArgs(BaseModel):
arm_side: Literal["left", "right"]
arm_pose: ArmPose
duration_seconds: float = Field(default=1.5, ge=0.5, le=5.0)
MoveAction = Literal["move_forward", "move_backward", "turn_left", "turn_right"]
class MoveArgs(BaseModel):
move_action: MoveAction
duration_seconds: float = Field(default=1.5, ge=0.5, le=5.0)
class SpeakArgs(BaseModel):
speech_text: str = Field(max_length=200)
TOOL_ARG_MODELS = {
"neck_gesture": NeckArgs,
"arm_gesture": ArmArgs,
"move_gesture": MoveArgs,
"speak": SpeakArgs,
}
# ============================================================================
# GESTURE FUNCTIONS — identical to Lesson 18, SDK controls only, every
# angle clamped to safe ranges regardless of what already passed validation.
# ============================================================================
def clamp(value, min_val, max_val):
return max(min_val, min(max_val, value))
def neck_gesture(bot, neck_yaw_deg, duration_seconds):
neck_yaw_deg = clamp(neck_yaw_deg, *NECK_YAW_RANGE)
bot.set_neck(neck_yaw_deg)
time.sleep(duration_seconds)
if neck_yaw_deg != 0.0:
bot.set_neck(0.0)
def arm_gesture(bot, arm_side, arm_pose, duration_seconds):
shoulder_deg, elbow_deg = ARM_PRESETS[arm_pose]
shoulder_deg = clamp(shoulder_deg, *SHOULDER_RANGE)
elbow_deg = clamp(elbow_deg, *ELBOW_RANGE)
move = bot.move_left_arm if arm_side == "left" else bot.move_right_arm
move(shoulder_deg, elbow_deg, wait=True)
time.sleep(duration_seconds)
if arm_pose != "rest":
move(0.0, 0.0, wait=True)
def move_gesture(bot, move_action, duration_seconds):
if move_action == "move_forward":
bot.move_forward(speed=DEFAULT_DRIVE_SPEED, duration=duration_seconds)
elif move_action == "move_backward":
bot.move_backward(speed=DEFAULT_DRIVE_SPEED, duration=duration_seconds)
elif move_action == "turn_left":
bot.turn_left(speed=DEFAULT_ROTATE_SPEED, duration=duration_seconds)
elif move_action == "turn_right":
bot.turn_right(speed=DEFAULT_ROTATE_SPEED, duration=duration_seconds)
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()
# ============================================================================
# EXECUTING A SINGLE TOOL CALL — validate, then perform, then report back.
# Whatever this returns becomes the "tool" message Nemotron sees next, so a
# validation failure or a BonicBotError becomes something it can react
# to on its very next turn, instead of silently vanishing.
# ============================================================================
def execute_tool_call(bot, piper_voice, pyaudio_instance, name, raw_arguments):
model_cls = TOOL_ARG_MODELS.get(name)
if model_cls is None:
return f"error: unknown tool '{name}'"
try:
args = model_cls.model_validate_json(raw_arguments)
except ValidationError as e:
return f"error: invalid arguments for {name}: {e}"
try:
if name == "neck_gesture":
print(f" 🤖 bot.set_neck({args.neck_yaw_deg})")
neck_gesture(bot, args.neck_yaw_deg, args.duration_seconds)
elif name == "arm_gesture":
method = "move_left_arm" if args.arm_side == "left" else "move_right_arm"
print(f" 🤖 bot.{method}(...) # {args.arm_pose}")
arm_gesture(bot, args.arm_side, args.arm_pose, args.duration_seconds)
elif name == "move_gesture":
print(f" 🤖 bot.{args.move_action}(...)")
move_gesture(bot, args.move_action, args.duration_seconds)
elif name == "speak":
speak_step(piper_voice, pyaudio_instance, args.speech_text)
return "ok"
except BonicBotError as e:
return f"error: robot rejected this action: {e}"
# How many times we'll retry a single model call if OpenRouter hands back a
# 200 OK with no usable choice at all -- a known hiccup with free, shared
# models under load. This is separate from MAX_TOOL_ROUNDS below: it's not
# about the conversation going on too long, it's about one specific call
# coming back empty.
MAX_EMPTY_RESPONSE_RETRIES = 2
EMPTY_RESPONSE_RETRY_DELAY = 1.5 # seconds
def call_model(client, messages):
"""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. Returns None if every
retry comes back empty, so the caller can bail out of this instruction
cleanly instead of crashing on response.choices[0]."""
for attempt in range(1, MAX_EMPTY_RESPONSE_RETRIES + 2):
response = client.chat.completions.create(
model=MODEL_NAME,
messages=messages,
tools=TOOLS,
tool_choice="auto",
parallel_tool_calls=True,
temperature=0.3,
)
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
# ============================================================================
# THE AGENT LOOP — Nemotron picks tools, we run them, it sees the results and
# decides whether to keep going or wrap up. This replaces Lesson 18's
# "generate the whole plan, then perform it" flow with something closer to
# a live back-and-forth.
# ============================================================================
def run_agent_turn(client, bot, piper_voice, pyaudio_instance, instruction):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": instruction},
]
for round_num in range(1, MAX_TOOL_ROUNDS + 1):
response = call_model(client, messages)
if response is None:
print(" ⚠️ OpenRouter/Nemotron kept returning empty responses for this "
"instruction -- a known hiccup with free, shared models under load. "
"Try again in a moment, or check openrouter.ai/models?fmt=free for a "
"less-loaded free model to swap into MODEL_NAME.")
return
message = response.choices[0].message
messages.append(message.model_dump(exclude_none=True))
if not message.tool_calls:
if message.content:
print(f" 💬 Nemotron: {message.content}")
return
# Tool calls in one round may be requested "in parallel" by the API,
# but a physical robot can't actually do two blocking motions at
# once — they're executed one at a time, in the order Nemotron listed
# them, and each result goes back before the next round starts.
for tool_call in message.tool_calls:
result = execute_tool_call(
bot, piper_voice, pyaudio_instance,
tool_call.function.name,
tool_call.function.arguments,
)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
print(" ⚠️ Hit the tool-round limit — stopping this instruction.")
# ============================================================================
# SETUP HELPERS — OpenRouter client + Piper voice, external to the BonicBot SDK
# ============================================================================
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
# ============================================================================
# MAIN
# ============================================================================
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()
try:
with BonicBot(host=HOST, port=9090, timeout=10) as bot:
print("✅ Connected. Type an instruction, or 'quit' to stop.")
print(' Example: "turn left, then wave and say hi"\n')
while True:
instruction = input("Instruction> ").strip()
if not instruction:
continue
if instruction.lower() in {"quit", "exit"}:
break
try:
run_agent_turn(client, bot, piper_voice, pyaudio_instance, instruction)
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.")
except APIError as e:
print(f"⚠️ OpenRouter API error: {e}")
except BonicBotError as e:
print(f"⚠️ Robot error: {e}")
finally:
pyaudio_instance.terminate()
if __name__ == "__main__":
main()Replace HOST = 'localhost' with your BonicBot’s actual IP address, and paste your real key into OPENROUTER_API_KEY in the CONFIG section, before running.
Code Walkthrough
Line-by-line explanation
- OpenRouter & Nemotron Configuration (
lines 77–107) — Configures OpenRouter base URL (https://openrouter.ai/api/v1), model slug (nvidia/nemotron-3-ultra-550b-a55b:free), maximum tool rounds (MAX_TOOL_ROUNDS = 6), and arm gesture lookup table (ARM_PRESETS). - OpenAI Tool Schemas (
lines 181–279) — DefinesTOOLSarray formatted for OpenAI function calling (neck_gesture,arm_gesture,move_gesture,speak). - Pydantic Argument Validation (
lines 287–313) — Defines Pydantic validation models (NeckArgs,ArmArgs,MoveArgs,SpeakArgs) to re-validate tool arguments sent back from Nemotron before passing them to the hardware. - Hardware Gesture Functions (
lines 324–373) —neck_gesture(),arm_gesture(),move_gesture(), andspeak_step()execute physical SDK calls on BonicBot and play synthesized Piper voice streams. - Tool Execution & Error Recovery (
lines 381–441) —execute_tool_call()validates arguments via Pydantic and returns"ok"or error messages back to the LLM.call_model()retries transient empty responses from OpenRouter (MAX_EMPTY_RESPONSE_RETRIES). - Turn-by-Turn Agent Loop (
lines 449–490) —run_agent_turn()maintains multi-turn message history, dispatches Nemotron tool calls sequentially, sends execution results back to the LLM, and stops when the task is complete.
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. Type an instruction, or 'quit' to stop.
Example: "turn left, then wave and say hi"
Instruction> glance to the right and say hi
🤖 bot.set_neck(45.0)
🗣️ BonicBot says: "Hi there!"
💬 Nemotron: Done — glanced right and said hello.Each 🤖 line is a real Bridge SDK call, executed the moment Nemotron’s tool
call for it comes back — no upfront plan, just one decision at a time.
🔧 Under the Hood
Why tools instead of one big schema like Lesson 18?
Lesson 18’s Choreography schema is great for a rehearsed, multi-step
performance, but it means every instruction pays the cost of producing a
full title + steps array, even “turn left.” Tool calling lets Nemotron reach for
exactly as much as the instruction needs — one tool call for a one-step
instruction, several chained together for something bigger — and it can see
the outcome of each call (“ok” or an error) before deciding what to do next,
rather than committing to a whole plan blind.
Why validate arguments with Pydantic if the JSON Schema already has enums?
A tool’s JSON Schema tells Nemotron what a valid call looks like, but
nothing guarantees the string it actually sends back matches it — the enum is a
strong hint to the model, not a hard constraint the API enforces before
handing it to you. execute_tool_call() re-validates every call with the
same kind of Pydantic model Lesson 18 used for schema fields, so a malformed
or out-of-range value is caught before it reaches move_left_arm() — and
the error string gets sent back as the tool result, so Nemotron can see what
went wrong and correct it on its next turn.
Why does call_model() check response.choices instead of just trusting the API call?
Normally a broken request either works or raises an exception the openai
client understands (AuthenticationError, RateLimitError, APIError) —
run_agent_turn()’s caller already handles those. But free, community-hosted
models on OpenRouter occasionally do something in between: the HTTP call
still returns 200 OK, yet the response has no usable choice in it at all,
usually because the underlying free model was overloaded for a moment.
Nothing raises in that case — response.choices is just empty — so
response.choices[0].message crashes with a bare TypeError if nothing
checks for it first. call_model() retries a couple of times with a short
delay, since this is often transient, and gives up cleanly if it isn’t.
The API says “parallel” tool calls — so why does execute_tool_call() run them one at a time?
parallel_tool_calls=True means Nemotron is allowed to return more than one
tool call in a single response — e.g. deciding in one shot that a turn, a
wave, and a spoken line should all happen for this instruction. It doesn’t
mean the robot can actually do them at once. move_left_arm(..., wait=True)
blocks until the arm gets there, and move_forward(speed, duration) blocks
for the full duration — running two of those concurrently on one physical
robot would mean two motor commands fighting each other. The loop executes
Nemotron’s tool calls in the order it listed them, one finishing before the next
starts, and only sends all their results back once every call in that round
is done.
Student Challenge
Right now, a “bow” would need Nemotron to correctly sequence two separate
arm_gesture calls (left then right, both reach_forward) — and there’s
nothing stopping it from doing just one arm, or doing them in a way that
looks lopsided. Add a new bow tool (with a matching BowArgs Pydantic
model and a branch in execute_tool_call()) that moves both arms into
reach_forward together in one atomic call, holds, then returns both to
rest — so a bow is guaranteed to look like a real bow no matter what
Nemotron decides to chain around it.
Reflection Question
Lesson 18 gave the model a limited number of attempts to fix a validation
error before giving up on the whole instruction (MAX_VALIDATION_ATTEMPTS).
This lesson’s MAX_TOOL_ROUNDS looks similar, but it’s capping something
different. What is MAX_TOOL_ROUNDS actually protecting against here, and
why does an agent loop like this one need that cap in a way a single
one-shot schema request didn’t?