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()