diff --git a/pepper_wizard/cli.py b/pepper_wizard/cli.py
index 4af1520..202f4a4 100644
--- a/pepper_wizard/cli.py
+++ b/pepper_wizard/cli.py
@@ -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("LLM unavailable: {}").format(str(e))
@@ -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"{escape(summary)}"))
+ 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:
diff --git a/pepper_wizard/config.py b/pepper_wizard/config.py
index 6d8bdc2..78d24c9 100644
--- a/pepper_wizard/config.py
+++ b/pepper_wizard/config.py
@@ -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():
diff --git a/pepper_wizard/llm/client.py b/pepper_wizard/llm/client.py
index 947d49b..eceaa69 100644
--- a/pepper_wizard/llm/client.py
+++ b/pepper_wizard/llm/client.py
@@ -1,5 +1,9 @@
import os
from collections import deque
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from .config_watcher import LLMConfigWatcher
class LLMUnavailable(Exception):
@@ -7,24 +11,21 @@ class LLMUnavailable(Exception):
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:
@@ -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),
)
diff --git a/pepper_wizard/llm/config_watcher.py b/pepper_wizard/llm/config_watcher.py
new file mode 100644
index 0000000..452c852
--- /dev/null
+++ b/pepper_wizard/llm/config_watcher.py
@@ -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
diff --git a/tests/test_llm_client.py b/tests/test_llm_client.py
new file mode 100644
index 0000000..48df202
--- /dev/null
+++ b/tests/test_llm_client.py
@@ -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()
diff --git a/tests/test_llm_config_watcher.py b/tests/test_llm_config_watcher.py
new file mode 100644
index 0000000..d3ace29
--- /dev/null
+++ b/tests/test_llm_config_watcher.py
@@ -0,0 +1,119 @@
+import json
+import os
+import tempfile
+import unittest
+from pathlib import Path
+
+from pepper_wizard.llm.client import LLMUnavailable
+from pepper_wizard.llm.config_watcher import LLMConfigWatcher
+
+
+def _bump_mtime(path: Path):
+ """Force a visible mtime change even on filesystems with coarse timestamps."""
+ stat = path.stat()
+ new_time = stat.st_mtime + 1
+ os.utime(path, (new_time, new_time))
+
+
+class LLMConfigWatcherTests(unittest.TestCase):
+ def setUp(self):
+ fd, name = tempfile.mkstemp(suffix=".json")
+ os.close(fd)
+ self.path = Path(name)
+ self.path.write_text(json.dumps({"model": "claude-haiku-4-5", "temperature": 0.7}))
+
+ def tearDown(self):
+ if self.path.exists():
+ self.path.unlink()
+
+ def test_initial_load_returns_parsed_dict(self):
+ watcher = LLMConfigWatcher(self.path)
+ cfg = watcher.current()
+ self.assertEqual(cfg["model"], "claude-haiku-4-5")
+ self.assertEqual(cfg["temperature"], 0.7)
+
+ def test_repeated_current_returns_same_object_when_unchanged(self):
+ watcher = LLMConfigWatcher(self.path)
+ first = watcher.current()
+ second = watcher.current()
+ self.assertIs(first, second)
+
+ def test_mtime_bump_triggers_reload(self):
+ watcher = LLMConfigWatcher(self.path)
+ _ = watcher.current()
+ self.path.write_text(json.dumps({"model": "claude-sonnet-4-6", "temperature": 0.2}))
+ _bump_mtime(self.path)
+ cfg = watcher.current()
+ self.assertEqual(cfg["model"], "claude-sonnet-4-6")
+ self.assertEqual(cfg["temperature"], 0.2)
+
+ def test_on_change_callback_fires_with_old_and_new(self):
+ events = []
+ watcher = LLMConfigWatcher(
+ self.path,
+ on_change=lambda old, new: events.append((old, new)),
+ )
+ _ = watcher.current()
+ self.path.write_text(json.dumps({"model": "claude-sonnet-4-6"}))
+ _bump_mtime(self.path)
+ _ = watcher.current()
+ self.assertEqual(len(events), 1)
+ old, new = events[0]
+ self.assertEqual(old["model"], "claude-haiku-4-5")
+ self.assertEqual(new["model"], "claude-sonnet-4-6")
+
+ def test_malformed_json_keeps_cached_value_and_logs(self):
+ watcher = LLMConfigWatcher(self.path)
+ good = watcher.current()
+ self.path.write_text("{ this is not json")
+ _bump_mtime(self.path)
+ with self.assertLogs("pepper_wizard.llm.config_watcher", level="WARNING"):
+ cfg = watcher.current()
+ self.assertIs(cfg, good)
+
+ def test_recovers_after_malformed_then_valid(self):
+ events = []
+ watcher = LLMConfigWatcher(
+ self.path,
+ on_change=lambda old, new: events.append((old, new)),
+ )
+ _ = watcher.current()
+ self.path.write_text("{ broken")
+ _bump_mtime(self.path)
+ with self.assertLogs("pepper_wizard.llm.config_watcher", level="WARNING"):
+ _ = watcher.current()
+ self.assertEqual(events, [])
+ self.path.write_text(json.dumps({"model": "claude-opus-4-7"}))
+ _bump_mtime(self.path)
+ cfg = watcher.current()
+ self.assertEqual(cfg["model"], "claude-opus-4-7")
+ self.assertEqual(len(events), 1)
+
+ def test_missing_file_keeps_cached_value(self):
+ watcher = LLMConfigWatcher(self.path)
+ good = watcher.current()
+ self.path.unlink()
+ with self.assertLogs("pepper_wizard.llm.config_watcher", level="WARNING"):
+ cfg = watcher.current()
+ self.assertIs(cfg, good)
+
+ def test_initial_load_failure_raises_llm_unavailable(self):
+ bogus = self.path.with_suffix(".does-not-exist")
+ with self.assertRaises(LLMUnavailable):
+ LLMConfigWatcher(bogus)
+
+ def test_callback_exception_does_not_propagate(self):
+ def boom(*_):
+ raise RuntimeError("callback should not crash watcher")
+
+ watcher = LLMConfigWatcher(self.path, on_change=boom)
+ _ = watcher.current()
+ self.path.write_text(json.dumps({"model": "claude-sonnet-4-6"}))
+ _bump_mtime(self.path)
+ with self.assertLogs("pepper_wizard.llm.config_watcher", level="WARNING"):
+ cfg = watcher.current()
+ self.assertEqual(cfg["model"], "claude-sonnet-4-6")
+
+
+if __name__ == "__main__":
+ unittest.main()