Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion pepper_wizard/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -777,15 +777,22 @@ def _on_escape(event):

def llm_talk_session(robot_client, config, verbose=False):
"""LLM dialogue via always-on VAD on Pepper's front microphone."""
from pathlib import Path
from .logger import get_logger
from .stt_client import STTClient
from .llm.client import LLMClient, LLMUnavailable
from .llm.config_watcher import LLMConfigWatcher

logger = get_logger("LLMTalk")
stt_config = config.stt_config
llm_config_path = Path(__file__).parent / "config" / "llm.json"

try:
llm = LLMClient(config.llm_config)
watcher = LLMConfigWatcher(
path=llm_config_path,
on_change=lambda old, new: _announce_llm_reload(old, new, logger),
)
llm = LLMClient(watcher)
except LLMUnavailable as e:
print_formatted_text(
HTML("<ansired>LLM unavailable: {}</ansired>").format(str(e))
Expand Down Expand Up @@ -937,8 +944,32 @@ def _handle_vad_event(evt, *, review_mode, llm, stt, robot_client, logger, sessi
robot_client=robot_client, logger=logger)


def _announce_llm_reload(old_config: dict, new_config: dict, logger):
"""Print and log a one-line summary of which llm.json fields changed."""
from html import escape

keys = set(old_config) | set(new_config)
changed = []
for key in sorted(keys):
if old_config.get(key) != new_config.get(key):
if key == "system_prompt":
changed.append("system_prompt")
else:
changed.append(f"{key} {old_config.get(key)!r}→{new_config.get(key)!r}")
if not changed:
return
summary = "config reloaded: " + ", ".join(changed)
print_formatted_text(HTML(f"<ansigray>{escape(summary)}</ansigray>"))
logger.info("LLMConfigReload", {
"changed": changed,
"old": old_config,
"new": new_config,
})


def _dispatch_to_llm(user_text, *, source, llm, stt, robot_client, logger):
# Mute while Pepper is speaking so stt-service ignores self-hearing.
# TODO - for the person interrupting pepper this needs some neance. Outside the scope of current PR.
stt.mute()
logger.info("MuteStart", {})
try:
Expand Down
1 change: 0 additions & 1 deletion pepper_wizard/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ def __init__(self):
self.keyboard_config = load_keyboard_config(CONFIG_DIR / "keyboard.json")
self.temperature_config = load_temperature_config(CONFIG_DIR / "temperature.json")
self.stt_config = load_stt_config(CONFIG_DIR / "stt.json")
self.llm_config = load_llm_config(CONFIG_DIR / "llm.json")
self.recording_config = load_recording_config(CONFIG_DIR / "recording.json")

def load_config():
Expand Down
52 changes: 33 additions & 19 deletions pepper_wizard/llm/client.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
import os
from collections import deque
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .config_watcher import LLMConfigWatcher


class LLMUnavailable(Exception):
pass


class LLMClient:
"""Anthropic-backed dialogue client with rolling history.
"""Anthropic-backed dialogue client backed by a hot-swappable config watcher.

Lazy-imports `anthropic` so that the rest of PepperWizard keeps importing
even when the SDK isn't installed (matches the perception/tracking
convention).
The watcher is the ground truth for `model`, `system_prompt`, `max_tokens`,
`temperature`, and `history_turns`. Each `reply()` call reads the current
config from the watcher, so edits to `llm.json` take effect on the next turn.
"""

def __init__(self, config):
self.model = config.get("model", "claude-haiku-4-5")
self.system_prompt = config.get(
"system_prompt",
"You are Pepper, a humanoid robot. Keep replies brief and conversational.",
)
self.max_tokens = config.get("max_tokens", 256)
self.temperature = config.get("temperature", 0.7)
def __init__(self, watcher: "LLMConfigWatcher"):
self._watcher = watcher

history_turns = config.get("history_turns", 10)
self._history = deque(maxlen=history_turns * 2)
# Initialise with the default maxlen; reply() will resize on first call
# if the watcher's history_turns differs. We do not call watcher.current()
# here so that the watcher call-count seen by callers reflects only
# active turns, not construction overhead.
self._history = deque(maxlen=10 * 2)

api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
Expand All @@ -43,15 +44,28 @@ def __init__(self, config):

self._client = anthropic.Anthropic(api_key=api_key)

def reply(self, user_text):
@property
def model(self) -> str:
return self._watcher.current().get("model", "claude-haiku-4-5")

def reply(self, user_text: str) -> str:
"""Send `user_text` with the rolling history and return the reply."""
config = self._watcher.current()

desired_maxlen = config.get("history_turns", 10) * 2
if self._history.maxlen != desired_maxlen:
self._history = deque(self._history, maxlen=desired_maxlen)

self._history.append({"role": "user", "content": user_text})

response = self._client.messages.create(
model=self.model,
system=self.system_prompt,
max_tokens=self.max_tokens,
temperature=self.temperature,
model=config.get("model", "claude-haiku-4-5"),
system=config.get(
"system_prompt",
"You are Pepper, a humanoid robot. Keep replies brief and conversational.",
),
max_tokens=config.get("max_tokens", 256),
temperature=config.get("temperature", 0.7),
messages=list(self._history),
)

Expand Down
62 changes: 62 additions & 0 deletions pepper_wizard/llm/config_watcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import json
from pathlib import Path
from typing import Callable, Optional

from ..logger import get_logger
from .client import LLMUnavailable

_logger = get_logger("pepper_wizard.llm.config_watcher")

OnChange = Callable[[dict, dict], None]


class LLMConfigWatcher:
"""File-backed config source that re-reads `llm.json` on mtime change.

Designed for hot-swapping the LLM dialogue config mid-session. The watcher
is the only thing that touches the filesystem; consumers call `current()`
each turn and treat the returned dict as authoritative for that turn.
"""

def __init__(self, path: Path, on_change: Optional[OnChange] = None):
self._path = Path(path)
self._on_change = on_change
try:
stat = self._path.stat()
with self._path.open() as f:
self._cached = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
raise LLMUnavailable(
f"Could not load LLM config from {self._path}: {exc}"
) from exc
self._mtime_ns = stat.st_mtime_ns

def current(self) -> dict:
"""Return the current config, re-reading from disk if the file changed."""
try:
stat = self._path.stat()
except OSError as exc:
_logger.warning("Could not stat %s: %s", self._path, exc)
return self._cached

if stat.st_mtime_ns == self._mtime_ns:
return self._cached

try:
with self._path.open() as f:
new_cfg = json.load(f)
except (OSError, json.JSONDecodeError) as exc:
_logger.warning("Could not reload %s: %s", self._path, exc)
return self._cached

old_cfg = self._cached
self._cached = new_cfg
self._mtime_ns = stat.st_mtime_ns

if self._on_change is not None:
try:
self._on_change(old_cfg, new_cfg)
except Exception as exc:
_logger.warning("on_change callback raised: %s", exc)

return new_cfg
164 changes: 164 additions & 0 deletions tests/test_llm_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import os
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch


class FakeWatcher:
"""Stand-in for LLMConfigWatcher with mutable state for tests."""

def __init__(self, config):
self._config = dict(config)

def current(self):
return self._config

def update(self, **changes):
self._config = {**self._config, **changes}


def _make_anthropic_response(text):
block = SimpleNamespace(type="text", text=text)
return SimpleNamespace(content=[block])


class LLMClientTests(unittest.TestCase):
def setUp(self):
os.environ["ANTHROPIC_API_KEY"] = "test-key"
self._anthropic_patch = patch("anthropic.Anthropic")
self._mock_anthropic = self._anthropic_patch.start()
self._mock_messages = MagicMock()
self._mock_messages.create.return_value = _make_anthropic_response("hello back")
self._mock_anthropic.return_value.messages = self._mock_messages

def tearDown(self):
self._anthropic_patch.stop()

def test_reply_calls_watcher_each_turn(self):
from pepper_wizard.llm.client import LLMClient

watcher = FakeWatcher({
"model": "claude-haiku-4-5",
"system_prompt": "be brief",
"max_tokens": 100,
"temperature": 0.5,
"history_turns": 4,
})
watcher.current = MagicMock(wraps=watcher.current)
client = LLMClient(watcher)
client.reply("hi")
client.reply("again")
self.assertEqual(watcher.current.call_count, 2)

def test_api_call_uses_current_config_values(self):
from pepper_wizard.llm.client import LLMClient

watcher = FakeWatcher({
"model": "claude-haiku-4-5",
"system_prompt": "be brief",
"max_tokens": 50,
"temperature": 0.3,
"history_turns": 4,
})
client = LLMClient(watcher)
client.reply("hi")
kwargs = self._mock_messages.create.call_args.kwargs
self.assertEqual(kwargs["model"], "claude-haiku-4-5")
self.assertEqual(kwargs["system"], "be brief")
self.assertEqual(kwargs["max_tokens"], 50)
self.assertEqual(kwargs["temperature"], 0.3)

def test_history_preserved_across_system_prompt_swap(self):
from pepper_wizard.llm.client import LLMClient

watcher = FakeWatcher({
"system_prompt": "old prompt",
"history_turns": 4,
})
client = LLMClient(watcher)
client.reply("first")
watcher.update(system_prompt="new prompt")
client.reply("second")
kwargs = self._mock_messages.create.call_args.kwargs
self.assertEqual(kwargs["system"], "new prompt")
# Two prior turns + the new user message = 3 messages in the second call
self.assertEqual(len(kwargs["messages"]), 3)
self.assertEqual(kwargs["messages"][0]["content"], "first")
self.assertEqual(kwargs["messages"][-1]["content"], "second")

def test_history_turns_shrink_truncates_oldest(self):
from pepper_wizard.llm.client import LLMClient

watcher = FakeWatcher({"history_turns": 5})
client = LLMClient(watcher)
for i in range(5):
client.reply(f"turn-{i}")
# 5 user + 5 assistant = 10 messages in the deque, maxlen 10
self.assertEqual(client._history.maxlen, 10)
self.assertEqual(len(client._history), 10)

watcher.update(history_turns=2)
client.reply("after-shrink")
self.assertEqual(client._history.maxlen, 4)
# Rebuild discards anything beyond the new maxlen (4): only the most
# recent prior entry plus the new user/assistant pair survive.
contents = [m["content"] for m in client._history]
self.assertEqual(contents[-2], "after-shrink")
self.assertNotIn("turn-0", contents)
self.assertNotIn("turn-1", contents)
self.assertNotIn("turn-2", contents)
self.assertNotIn("turn-3", contents)

def test_history_turns_grow_preserves_existing(self):
from pepper_wizard.llm.client import LLMClient

watcher = FakeWatcher({"history_turns": 2})
client = LLMClient(watcher)
client.reply("a")
client.reply("b")
self.assertEqual(client._history.maxlen, 4)
watcher.update(history_turns=10)
client.reply("c")
self.assertEqual(client._history.maxlen, 20)
contents = [m["content"] for m in client._history]
self.assertEqual(contents[0], "a")
self.assertEqual(contents[-2], "c")

def test_reply_appends_user_and_assistant_turns(self):
from pepper_wizard.llm.client import LLMClient

watcher = FakeWatcher({"history_turns": 4})
client = LLMClient(watcher)
self._mock_messages.create.return_value = _make_anthropic_response("the reply")
result = client.reply("the question")
self.assertEqual(result, "the reply")
self.assertEqual(list(client._history), [
{"role": "user", "content": "the question"},
{"role": "assistant", "content": "the reply"},
])

def test_reset_clears_history_without_touching_watcher(self):
from pepper_wizard.llm.client import LLMClient

watcher = FakeWatcher({"history_turns": 4})
watcher.current = MagicMock(wraps=watcher.current)
client = LLMClient(watcher)
client.reply("hi")
self.assertEqual(len(client._history), 2)
before = watcher.current.call_count
client.reset()
self.assertEqual(len(client._history), 0)
self.assertEqual(watcher.current.call_count, before)

def test_model_property_reflects_watcher(self):
from pepper_wizard.llm.client import LLMClient

watcher = FakeWatcher({"model": "claude-haiku-4-5"})
client = LLMClient(watcher)
self.assertEqual(client.model, "claude-haiku-4-5")
watcher.update(model="claude-sonnet-4-6")
self.assertEqual(client.model, "claude-sonnet-4-6")


if __name__ == "__main__":
unittest.main()
Loading
Loading