Skip to Content
BonicBot A2DevelopmentArtificial IntelligenceLesson 16: BonicBot Listens, Researches, and Talks Back

Lesson 16: BonicBot Listens, Researches, and Talks Back

Learning Objective

Enhance BonicBot’s listen-think-speak voice assistant with a new “think” step: give the local model a tool it can decide to call — a Wikipedia lookup — so BonicBot can answer questions its small local model doesn’t reliably know, using its own voice through a Bluetooth speaker.


Introduction

BonicBot uses a fully local voice loop:

microphone -> SpeechRecognition (STT) -> Ollama local LLM -> speak the reply

That local model — a small language model (SLM) — is fast and private, but it’s frozen at whatever it learned during training and has no way to check a fact it’s unsure about. This lesson adds exactly one capability to fix that: a lookup_topic tool backed by Wikipedia’s free REST API, using Ollama’s native tool-calling (tools=[...]).

The model itself decides, per question, whether it needs the tool:

microphone -> STT -> SLM (decides: answer directly, or call lookup_topic?) -> speak the reply

Ask “say hi” and it answers immediately. Ask “what is the tallest mountain in the world?” and it calls lookup_topic, reads the result, and then answers — you’ll see the difference printed to the console either way.

No BonicBot Bridge connection is needed for this lesson — audio plays through whatever speaker your computer is using, including a Bluetooth speaker paired directly to it, same as BonicBot’s own speaker setup.


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 ollama requests speechrecognition piper-tts faster-whisper pyaudio

What each package / tool does

Package / ToolPurpose
requestsHTTP library. Used by lookup_topic() to query Wikipedia’s free REST search and summary APIs.
ollamaPython client for local LLMs. Uses qwen3:0.6b with native function tool-calling (tools=[LOOKUP_TOOL]).
faster-whisperFast, local Speech-to-Text (STT) model runner. Used by listen() to transcribe microphone audio offline.
piper-ttsFast, local neural Text-to-Speech (TTS) engine. Used by tts_worker() for asynchronous live speech streaming.
speechrecognitionImported as sr. Used for microphone recording and noise threshold adjustments.
pyaudioAudio I/O library. Streams synthesized PCM audio chunks directly to your computer or Bluetooth speakers.
numpyImported as np. Handles raw audio buffer conversions for Whisper.

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 ollama requests speechrecognition piper-tts faster-whisper pyaudio

How to run the program

  1. Install Ollama (one-time setup):
    • Windows / Mac: Download and run the installer from ollama.com/download .
    • Linux: Run curl -fsSL https://ollama.com/install.sh | sh in your terminal.
  2. Install PortAudio (system dependency for pyaudio):
    • Linux: sudo apt install portaudio19-dev
    • macOS: brew install portaudio
  3. Pull the LLM model:
    ollama pull qwen3:0.6b
  4. Ensure Internet Connection: Wikipedia tool queries require active internet access.
  5. Save the code into a file, e.g., lesson16_web_research.py.
  6. Run the script:
    python lesson16_web_research.py
  7. Stay quiet for 2 seconds while the microphone calibrates for ambient noise.
  8. Ask BonicBot any question into the microphone (e.g. “What is the tallest mountain in the world?” or “Say hi”).
  9. Watch terminal output: BonicBot will answer simple questions immediately, or call lookup_topic to research factual topics on Wikipedia before responding aloud!
  10. Say “goodbye” 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:

  1. Launch the BonicBot simulation:

    ros2 launch my_bot robot_system.launch.py use_sim_time:=true world:=obsworld.sdf use_real_camera:=True
  2. No physical robot connection is required for this lesson; audio plays through your computer or Bluetooth speaker while running the live tool-calling voice research loop.


Code

Click to view the complete program

import difflib import inspect import json import queue import re import shutil import subprocess import sys import threading import time from pathlib import Path from urllib.parse import quote import numpy as np import ollama import pyaudio import requests import speech_recognition as sr from faster_whisper import WhisperModel from piper import PiperVoice, SynthesisConfig from piper.download_voices import download_voice # ============================================================================ # CONFIG # ============================================================================ OLLAMA_MODEL = "qwen3:0.6b" # supports native tool-calling + a think toggle, still light enough for CPU PIPER_VOICE_NAME = "en_US-lessac-medium" PIPER_VOICES_DIR = Path(__file__).resolve().parent / "piper_voices" PIPER_LENGTH_SCALE = 0.8 # > 1 = slower speech, < 1 = faster (1.0 = voice's own normal pace) PIPER_VOLUME = 1.0 # > 1 = louder, < 1 = quieter WHISPER_MODEL_SIZE = "small.en" # tiny.en (fastest) / base.en (good balance) / small.en (most accurate, slower) SERVER_START_TIMEOUT = 15.0 SERVER_POLL_INTERVAL = 0.5 LISTEN_TIMEOUT = 60.0 # seconds to wait for the person to start speaking PHRASE_TIME_LIMIT = 15.0 # max seconds for a single thing they say MAX_HISTORY_MESSAGES = 13 # system message + ~6 back-and-forth exchanges REPEAT_SIMILARITY_THRESHOLD = 0.85 LOOKUP_TIMEOUT = 6.0 NO_SPEECH_PROB_THRESHOLD = 0.6 # above this, the segment is probably not speech AVG_LOGPROB_THRESHOLD = -1.0 # below this, the model itself was unsure EXIT_WORDS = {"goodbye", "bye", "stop", "exit", "quit"} # Concise, punchy intro message INTRO_MESSAGE = "Hi, I'm BonicBot! Ask me about any person, place, or event, and I'll look it up. Say goodbye to quit." SYSTEM_PROMPT = ( "You are BonicBot, a friendly, patient voice assistant helping students " "learn about NLP and AI. Keep answers short -- one to three sentences, " "spoken out loud. Use simple words. " "For every turn, think it through and choose exactly ONE of these three " "actions:\n" "1. ANSWER DIRECTLY -- only for greetings, small talk, opinions, simple " "arithmetic, or questions about yourself. Never answer a real-world " "factual question this way -- your own memory of people, places, " "dates, organizations, and events is not reliable.\n" "2. USE THE lookup_topic TOOL -- for any question about a specific " "real-world person, place, date, organization, event, or thing, even " "if you think you already know the answer. Call it BEFORE answering, " "not after.\n" "3. ASK ONE SHORT CLARIFYING QUESTION -- if the input is incomplete, " "cut off mid-sentence, ambiguous, or you genuinely can't tell what's " "being asked. Ask specifically about what's missing (for example: " "'Which country's prime minister did you mean?'). Do not guess, and " "do not call the tool with a vague or incomplete query -- ask first, " "then use the tool once you know the real topic.\n" "Never respond with a dismissive or quiz-style line like 'None of the " "above' or a flat 'I don't know' -- one of the three actions above " "always applies. If the tool returns nothing useful, say so honestly " "and ask a clarifying question about what they meant, instead of " "guessing.\n" "Your input comes from speech-to-text and is sometimes messy -- filler " "words, or a stray unrelated fragment mixed in with the real question " "from background noise. Find the one clear question or statement " "inside it and act on that, ignoring unrelated fragments; if nothing " "clear survives that filtering, that's when you ask a clarifying " "question. When a tool result names a specific office or role (e.g. " "President vs. Prime Minister), use the tool's wording exactly -- " "don't swap it for a different title from memory. Never pretend to be " "a human, and never give unsafe, harmful, or inappropriate advice." ) EMOJI_PATTERN = re.compile( "[" "\U0001F300-\U0001FAFF" "\U00002600-\U000027BF" "\U0001F1E6-\U0001F1FF" "\U0001F900-\U0001F9FF" "\U00002190-\U000021FF" "\U00002B00-\U00002BFF" "]+", flags=re.UNICODE, ) def clean_for_speech(text): """Strips markdown formatting and emoji before handing text to Piper — the model doesn't know it's writing for a voice, so it still reaches for **bold**, `code`, and emoji out of habit.""" # Strip <think> blocks so BonicBot doesn't speak its internal monologue out loud text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE) text = EMOJI_PATTERN.sub("", text) text = re.sub(r"\*\*(.*?)\*\*", r"\1", text) # **bold** text = re.sub(r"\*(.*?)\*", r"\1", text) # *italic* text = re.sub(r"`(.*?)`", r"\1", text) # `code` text = re.sub(r"#+\s*", "", text) # headers text = re.sub(r"[*_~]", "", text) # stray leftover symbols text = re.sub(r"\s+", " ", text).strip() return text # ============================================================================ # EXTERNAL LIBRARY — encyclopedia lookup, not part of the BonicBot Bridge SDK # ============================================================================ def lookup_topic(query): """Resolves `query` to the best-matching Wikipedia page via the search API, then fetches its summary. Resolving via search instead of treating `query` as an exact title matters because the model is tiny and often passes slightly-off or multi-word phrasings -- an exact-title lookup silently fails on those and feeds 'No information was found' back to the model for no good reason.""" query = query.strip() if not query: return None try: search_resp = requests.get( "https://en.wikipedia.org/w/rest.php/v1/search/page", params={"q": query, "limit": 1}, timeout=LOOKUP_TIMEOUT, headers={"User-Agent": "BonicBot-Lesson16"}, ) if search_resp.status_code != 200: return None results = search_resp.json().get("pages", []) if not results: return None title = results[0]["key"] except (requests.RequestException, KeyError, ValueError, IndexError): return None try: summary_resp = requests.get( f"https://en.wikipedia.org/api/rest_v1/page/summary/{quote(title)}", timeout=LOOKUP_TIMEOUT, headers={"User-Agent": "BonicBot-Lesson16"}, ) if summary_resp.status_code != 200: return None return summary_resp.json().get("extract") except requests.RequestException: return None # The tool description Ollama uses to decide WHEN to call lookup_topic. LOOKUP_TOOL = { "type": "function", "function": { "name": "lookup_topic", "description": ( "Look up a short, accurate factual summary of a topic. Use " "this by default for any specific person, place, date, " "organization, event, or thing -- don't rely on your own " "memory for facts like these." ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "The topic to look up, e.g. 'Mount Everest'", } }, "required": ["query"], }, }, } # Last-resort cleanup only: if the model still produces a flat dismissive # line despite the system prompt telling it not to, rewrite it into an # actual clarifying question instead of leaving a dead end. This does NOT # force a tool call -- it's just turning a bad phrasing into a good one. PUNT_PATTERN = re.compile( r"none of the above|not (?:applicable|available)|no (?:correct )?answer", re.IGNORECASE, ) # ============================================================================ # OLLAMA SETUP — all handled from Python, no manual `ollama` commands # ============================================================================ def ensure_ollama_installed(): if shutil.which("ollama") is None: print("⚠️ The Ollama app isn't installed on this computer yet.") print("This is a one-time setup step, done once per computer:") print(" 1. Go to https://ollama.com/download") print(" 2. Download and run the installer for your OS") print(" 3. Re-run this script afterwards") sys.exit(1) def ensure_ollama_running(): try: ollama.list() return except Exception: pass print("Ollama server isn't running yet — starting it now...") subprocess.Popen(["ollama", "serve"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) start = time.time() while time.time() - start < SERVER_START_TIMEOUT: try: ollama.list() print("✅ Ollama server is up.") return except Exception: time.sleep(SERVER_POLL_INTERVAL) print("⚠️ Couldn't confirm the server started in time — continuing anyway.") def is_model_available(model_name): try: ollama.show(model_name) return True except Exception: return False def ensure_model_pulled(model_name): if is_model_available(model_name): print(f"✅ Model '{model_name}' is already downloaded.") return print(f"Model '{model_name}' not found locally — downloading now " f"(one-time, a few hundred MB)...") last_status = None for progress in ollama.pull(model_name, stream=True): status = progress.get("status", "") if status and status != last_status: print(f" {status}") last_status = status print(f"✅ Model '{model_name}' ready.") 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(): print(f"✅ Voice '{voice_name}' is already downloaded.") return model_path, config_path print(f"Voice '{voice_name}' not found locally — downloading now " f"(one-time, roughly 60MB)...") try: download_voice(voice_name, voices_dir) except Exception as e: print(f"⚠️ Couldn't download the Piper voice: {e}") print("Check your internet connection and the voice name, then try again.") sys.exit(1) print(f"✅ Voice '{voice_name}' ready.") return model_path, config_path # ============================================================================ # BACKGROUND STREAMING TEXT-TO-SPEECH (Sentence Queue & Interrupts) # ============================================================================ tts_queue = queue.Queue() interrupt_event = threading.Event() def tts_worker(piper_voice, pyaudio_instance): """Monitors the tts_queue and plays synthesized audio sentence-by-sentence. Allows for immediate audio interruption if interrupt_event is set.""" while True: item = tts_queue.get() if item is None: break sentence, syn_config = item interrupt_event.clear() stream = None try: for chunk in piper_voice.synthesize(sentence, syn_config=syn_config): if interrupt_event.is_set(): break 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) except Exception: pass finally: if stream is not None: try: stream.stop_stream() stream.close() except Exception: pass tts_queue.task_done() def stop_tts(): """Instantly stops any ongoing synthesis and purges the speech queue.""" interrupt_event.set() while not tts_queue.empty(): try: tts_queue.get_nowait() tts_queue.task_done() except queue.Empty: break def wait_for_tts(): """Blocks the main thread loop until all queued sentences have finished being spoken out loud by the background TTS worker thread.""" tts_queue.join() def speak_async(text, syn_config): """Splits a full block of text into sentences and queues them asynchronously.""" sentences = re.split(r'(?<=[.!?])\s+', text) for s in sentences: s = s.strip() if s: cleaned = clean_for_speech(s) if cleaned: tts_queue.put((cleaned, syn_config)) def print_and_speak(text, syn_config): """Prints and queues hardcoded platform messages (like intro and goodbye).""" print(f"BonicBot: {text}") speak_async(text, syn_config) # ============================================================================ # SPEECH-TO-TEXT — faster-whisper, fully offline # ============================================================================ _TRANSCRIBE_PARAMS = set(inspect.signature(WhisperModel.transcribe).parameters) def _build_transcribe_kwargs(): kwargs = {"language": "en", "beam_size": 5} if "temperature" in _TRANSCRIBE_PARAMS: kwargs["temperature"] = 0.0 # deterministic, less prone to hallucinating text if "condition_on_previous_text" in _TRANSCRIBE_PARAMS: kwargs["condition_on_previous_text"] = False # stops hallucination loops feeding on themselves if "vad_filter" in _TRANSCRIBE_PARAMS: kwargs["vad_filter"] = True # skip silent/non-speech stretches entirely if "vad_parameters" in _TRANSCRIBE_PARAMS: kwargs["vad_parameters"] = dict(min_silence_duration_ms=500) return kwargs def listen(recognizer, mic, whisper_model): """Records one utterance from the microphone and transcribes it locally.""" with mic as source: print("\n🎤 Listening... (say 'goodbye' to quit)") try: audio = recognizer.listen( source, timeout=LISTEN_TIMEOUT, phrase_time_limit=PHRASE_TIME_LIMIT ) except sr.WaitTimeoutError: return None audio_np = np.frombuffer(audio.get_raw_data(), dtype=np.int16).astype(np.float32) / 32768.0 segments, _ = whisper_model.transcribe(audio_np, **_build_transcribe_kwargs()) kept = [] for seg in segments: no_speech_prob = getattr(seg, "no_speech_prob", 0.0) avg_logprob = getattr(seg, "avg_logprob", 0.0) if no_speech_prob > NO_SPEECH_PROB_THRESHOLD or avg_logprob < AVG_LOGPROB_THRESHOLD: print(f" (dropped low-confidence audio: {seg.text.strip()!r})") continue kept.append(seg.text) text = " ".join(kept).strip() if not text: return None print(f"You said: {text}") return text # ============================================================================ # LOCAL LLM WITH NATIVE TOOL-CALLING (via Ollama) # ============================================================================ def _chat(history, syn_config): """Thin wrapper around ollama.chat. Streams the response live to the terminal, dynamically parsing out sentences as they complete to queue them synchronously into the background speech pipeline.""" try: stream = ollama.chat( model=OLLAMA_MODEL, messages=history, tools=[LOOKUP_TOOL], think=True, options={"temperature": 0}, stream=True ) except ollama.ResponseError as e: if e.status_code == 404: ensure_model_pulled(OLLAMA_MODEL) stream = ollama.chat( model=OLLAMA_MODEL, messages=history, tools=[LOOKUP_TOOL], think=True, options={"temperature": 0}, stream=True ) else: raise assembled_content = "" final_tool_calls = None # Instantly cut off any leftover audio from prior responses stop_tts() # Print in gray so the entire generation (thinking block included) is distinct sys.stdout.write("\n \033[90m[Generating...] ") sys.stdout.flush() buffer = "" speaking_buffer = "" parse_ptr = 0 in_think = False for chunk in stream: msg = chunk.get("message", {}) content_chunk = msg.get("content", "") if content_chunk: assembled_content += content_chunk # Print the chunk to console immediately sys.stdout.write(content_chunk) sys.stdout.flush() # Feed characters into speech pipeline buffer buffer += content_chunk # Real-time state machine parsing out `<think>...</think>` boundaries # to keep internal monologues strictly out of BonicBot's voice while parse_ptr < len(buffer): if not in_think: think_start = buffer.lower().find("<think>", parse_ptr) if think_start != -1: speaking_buffer += buffer[parse_ptr:think_start] parse_ptr = think_start + len("<think>") in_think = True else: potential_start = buffer.lower().rfind("<", parse_ptr) if potential_start != -1 and len(buffer) - potential_start < len("<think>"): speaking_buffer += buffer[parse_ptr:potential_start] parse_ptr = potential_start break else: speaking_buffer += buffer[parse_ptr:] parse_ptr = len(buffer) else: think_end = buffer.lower().find("</think>", parse_ptr) if think_end != -1: parse_ptr = think_end + len("</think>") in_think = False else: potential_end_start = buffer.lower().rfind("<", parse_ptr) if potential_end_start != -1 and len(buffer) - potential_end_start < len("</think>"): parse_ptr = potential_end_start break else: parse_ptr = len(buffer) # Extract and queue completed sentences from the filtered speaking buffer while True: match = re.search(r'([.!?])(\s+|$)', speaking_buffer) if match: end_idx = match.end() sentence = speaking_buffer[:end_idx].strip() speaking_buffer = speaking_buffer[end_idx:] if sentence: cleaned = clean_for_speech(sentence) if cleaned: tts_queue.put((cleaned, syn_config)) else: if '\n' in speaking_buffer: parts = speaking_buffer.split('\n', 1) sentence = parts[0].strip() speaking_buffer = parts[1] if sentence: cleaned = clean_for_speech(sentence) if cleaned: tts_queue.put((cleaned, syn_config)) else: break if msg.get("tool_calls"): final_tool_calls = msg["tool_calls"] # Flush any remaining unpunctuated text at the end of generation if speaking_buffer.strip(): cleaned = clean_for_speech(speaking_buffer.strip()) if cleaned: tts_queue.put((cleaned, syn_config)) # Reset terminal color back to default sys.stdout.write("\033[0m\n") sys.stdout.flush() final_message = {"role": "assistant", "content": assembled_content} if final_tool_calls: final_message["tool_calls"] = final_tool_calls return {"message": final_message} def _extract_query(call): args = call.get("function", {}).get("arguments", {}) if isinstance(args, str): try: args = json.loads(args) except (json.JSONDecodeError, TypeError): args = {} return args.get("query", "") if isinstance(args, dict) else "" def ask_llm(history, syn_config): """Sends the conversation to the local model with the lookup_topic tool available and thinking enabled.""" response = _chat(history, syn_config) message = response["message"] tool_calls = message.get("tool_calls") if tool_calls: history.append({ "role": "assistant", "content": message.get("content", ""), "tool_calls": tool_calls, }) for call in tool_calls: query = _extract_query(call) print(f" 🔎 Looking up: {query}") result = lookup_topic(query) if result is None: print(f" ⚠️ No summary found for '{query}'.") result = f"No information was found for '{query}'." history.append({"role": "tool", "content": result}) response = _chat(history, syn_config) message = response["message"] final_text = message["content"].strip() if PUNT_PATTERN.search(final_text): final_text = "Sorry, could you tell me a bit more about who or what you mean?" stop_tts() speak_async(final_text, syn_config) return final_text def trim_history(history): if len(history) > MAX_HISTORY_MESSAGES: history = [history[0]] + history[-(MAX_HISTORY_MESSAGES - 1):] return history def is_repeat_question(current_text, previous_text): if not previous_text: return False a, b = current_text.strip().lower(), previous_text.strip().lower() return difflib.SequenceMatcher(None, a, b).ratio() >= REPEAT_SIMILARITY_THRESHOLD # ============================================================================ # MAIN LOOP — listen, think (decide: answer / tool / clarify), speak # ============================================================================ def main(): ensure_ollama_installed() ensure_ollama_running() ensure_model_pulled(OLLAMA_MODEL) model_path, config_path = ensure_piper_voice_ready(PIPER_VOICE_NAME, PIPER_VOICES_DIR) print("Loading Piper voice into memory...") piper_voice = PiperVoice.load(model_path, config_path=config_path) pyaudio_instance = pyaudio.PyAudio() # Start the daemon background TTS speaker thread t = threading.Thread(target=tts_worker, args=(piper_voice, pyaudio_instance), daemon=True) t.start() print("Loading Whisper model into memory...") whisper_model = WhisperModel(WHISPER_MODEL_SIZE, device="cpu", compute_type="int8") recognizer = sr.Recognizer() recognizer.pause_threshold = 1.5 recognizer.non_speaking_duration = 0.5 recognizer.dynamic_energy_threshold = True mic = sr.Microphone(sample_rate=16000) # 16kHz is what Whisper expects syn_config = SynthesisConfig(length_scale=PIPER_LENGTH_SCALE, volume=PIPER_VOLUME) history = [{"role": "system", "content": SYSTEM_PROMPT}] last_user_text = None print("Calibrating microphone for background noise, please stay quiet...") with mic as source: recognizer.adjust_for_ambient_noise(source, duration=2.0) try: # Speak the concise intro message print_and_speak(INTRO_MESSAGE, syn_config) while True: # Wait until BonicBot is completely finished speaking before opening the microphone. # This elegantly solves the feedback loop! wait_for_tts() user_text = listen(recognizer, mic, whisper_model) if user_text is None: continue # Halt any playing text immediately when the user finishes speaking a command stop_tts() if user_text.strip().lower() in EXIT_WORDS: print_and_speak("Goodbye! It was nice talking with you.", syn_config) # Wait until the goodbye message finishes speaking before exiting the process wait_for_tts() break if is_repeat_question(user_text, last_user_text): turn_content = ( f"{user_text}\n\n" "[The user just asked this again — your previous answer was " "likely wrong, unclear, or not what they needed. Do not repeat " "it. Either try the lookup_topic tool with a more specific " "query, or ask a clarifying question about what they meant.]" ) else: turn_content = user_text history.append({"role": "user", "content": turn_content}) last_user_text = user_text reply = ask_llm(history, syn_config) history.append({"role": "assistant", "content": reply}) history = trim_history(history) except KeyboardInterrupt: print("\nShutting down...") finally: pyaudio_instance.terminate() if __name__ == "__main__": main()

Before running, make sure Ollama is installed (one-time setup):

  • Windows / Mac: installer from ollama.com/download .
  • Linux: curl -fsSL https://ollama.com/install.sh | sh

Install the Python packages:

pip install ollama SpeechRecognition piper-tts pyaudio requests --break-system-packages

🔊 No BonicBot Bridge connection is needed — pair your Bluetooth speaker directly to the computer running this script (or use its built-in speakers/mic).


Code Walkthrough

Line-by-line explanation

  • Configuration & System Prompts (lines 65–160) — Configures Ollama model (qwen3:0.6b), Piper voice, Whisper model size (small.en), and SYSTEM_PROMPT instructing the agent on when to answer directly vs. when to call lookup_topic.
  • Wikipedia Tool & Tool Schema (lines 165–237)lookup_topic() queries Wikipedia’s REST search API and page summary API. LOOKUP_TOOL defines the function schema for Ollama native tool calling.
  • Ollama & Piper Preloading (lines 242–310)ensure_ollama_running() starts the local daemon; ensure_model_pulled() fetches qwen3:0.6b; ensure_piper_voice_ready() downloads the ONNX voice model.
  • Asynchronous Live TTS Queue (lines 316–386)tts_worker() runs on a background daemon thread with a sentence queue (tts_queue). stop_tts(), wait_for_tts(), and speak_async() manage live sentence-by-sentence speech output and prevent microphone feedback loops.
  • Whisper Speech-to-Text (lines 391–434)listen() records microphone audio and transcribes it locally using faster_whisper with deterministic temperature and VAD filtering.
  • Native Tool Calling Execution (lines 439–604)_chat() streams LLM tokens, filtering out <think> internal monologue blocks. ask_llm() detects tool call requests (tool_calls), invokes lookup_topic(), appends tool output to history with role "tool", and queries Ollama again for the final researched response.
  • Main Voice Loop (lines 622–702) — Initializes background TTS speaker thread, calibrates microphone noise, waits for TTS completion before listening (wait_for_tts()), processes input, triggers LLM research, and streams response sentences aloud.

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:

Calibrating microphone for background noise, please stay quiet... BonicBot: Hello! I'm BonicBot. Ask me anything, and I'll look things up if I need to. Say goodbye to stop. 🎤 Listening... (say 'goodbye' to quit) You said: what is the tallest mountain in the world Thinking... 🔎 Looking up: Mount Everest BonicBot: The tallest mountain in the world is Mount Everest, standing over 8,800 meters tall! 🎤 Listening... (say 'goodbye' to quit) You said: say hi Thinking... BonicBot: Hi there! Great to see you. 🎤 Listening... (say 'goodbye' to quit) You said: goodbye BonicBot: Goodbye! It was nice talking with you.

Notice the second question never prints a 🔎 Looking up: line — the model decided the tool wasn’t needed.


🔧 Under the Hood

How does the model “decide” to use a tool?

response = ollama.chat(model=OLLAMA_MODEL, messages=history, tools=[LOOKUP_TOOL]) message = response["message"] if message.get("tool_calls"): ...

This is Ollama’s native tool-calling — distinct from a format=schema pattern, which shapes a final answer, not a decision to act. Here, the model’s first response either contains normal text, or a tool_calls list naming a function and its arguments. If it chose to call one, that assistant message and the tool’s result both get appended to history with roles "assistant" and "tool" — then the model is asked again, now able to see the looked-up fact, for its real answer. Two API calls happen only when the tool is actually used.


Student Challenge

ask_llm() currently loops over every entry in tool_calls but always calls the same tool. Add a print statement that reports how many tool calls the model made in a single turn, then deliberately ask a question likely to trigger two lookups at once (e.g. “compare Mount Everest and K2”) and see what actually happens.


Reflection Question

The Wikipedia result is added to history with {"role": "tool", "content": result} instead of just being appended to the user’s question as extra text. Why might keeping it as its own message, with its own role, matter for how the model treats that information?

Last updated on