From b01ee6478d97f6845ab64e2bcf157d60dee6949c Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 12 Sep 2026 07:16:08 -0400 Subject: [PATCH] fix: background tasks follow the live model selection TaskWorker resolved its model from LLM_MODEL env (frozen at MCP spawn), so after a live set_model switch in the UI, dispatched background tasks still asked for the old model. On local-vl-models (llama-swap --models-max 1) that forces a swap against the loaded conversation model; a force-kill mid-load surfaces as 400 'model failed to load'. The agent now writes the selected model to /model.txt at session start and on every switch; TaskWorker reads it with the env value as fallback. --- AGENTS.md | 1 + agent/agent.py | 19 +++++++++++++++++++ agent/task_worker.py | 18 +++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index d79f8a8..4cec31c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,6 +216,7 @@ curl -s "https://eastus.tts.speech.microsoft.com/cognitiveservices/v1" \ - **Azure TTS uses SSML, not JSON.** The REST endpoint requires `Content-Type: application/ssml+xml`. The LiveKit Azure plugin handles this internally. - **Voice changes are live.** The web UI sends a data message to the agent; the agent calls `tts.update_options(voice=...)` without restarting. The agent filters data messages by topic ("voice-control") before processing. - **Model changes are live too.** The agent fetches the chat-capable model list (`HopeLLM` filters out embedding/reranker ids) and publishes `{type: "models", models, selected}`; the UI's settings sheet renders a picker and sends `{type: "set_model", model}`, which calls `HopeLLM.update_options(model=...)` (mutates `self._opts.model`) and acks with `{type: "model_changed", model}`. The choice persists in the browser's localStorage. +- **Background tasks follow the live model.** The dispatch MCP subprocess inherits its env at spawn, so it cannot see later `set_model` switches. The agent writes the selected model to `/model.txt` at session start and on every switch; `TaskWorker` reads that file (falling back to `LLM_MODEL` env) when starting a task. Without this, a switched session (e.g. gemma-4-e4b) dispatches tasks against the stale env model (qwen2.5-vl-3b), which llama-swap on `local-vl-models` (`--models-max 1`) cannot load without killing the conversation model mid-swap. - **All supervisord programs log to /dev/stdout** so `docker compose logs -f voice` shows everything. Individual process logs are no longer written to files. - **Hope Music stays external.** `hope-media-music` owns yt-dlp, ffmpeg, the library volume, streaming endpoints, and the player UI. Do not add those diff --git a/agent/agent.py b/agent/agent.py index 4c715f9..f423c93 100644 --- a/agent/agent.py +++ b/agent/agent.py @@ -750,6 +750,20 @@ def build_mcp_toolsets(user_profile: dict, task_dir: str) -> list[mcp.MCPToolset return toolsets +def _sync_task_model(task_dir: str, model: str) -> None: + """Record the live model choice where background task workers can read it. + + The dispatch MCP subprocess inherits its environment at spawn time, so it + can never see later UI model switches. The worker instead reads + /model.txt (falling back to LLM_MODEL env) when it starts a task. + """ + try: + os.makedirs(task_dir, exist_ok=True) + with open(os.path.join(task_dir, "model.txt"), "w") as f: + f.write((model or "").strip()) + except OSError as e: + logger.warning("failed to record selected model for background tasks: %s", e) + class VoiceAssistant(Agent): """The conversational agent. LLM is the brain; STT/TTS are senses.""" @@ -824,6 +838,10 @@ async def handle_job(ctx: JobContext) -> None: # into it for live model switching. voice_assistant = VoiceAssistant(user_profile, task_dir) + # Background task workers (dispatch MCP subprocess) read this file so + # they use the same model as the conversation, even after live switches. + _sync_task_model(task_dir, voice_assistant.llm.model) + # Publish the chat-capable model list so the web UI can offer a picker. async def _publish_models() -> None: models = await list_available_models() @@ -1009,6 +1027,7 @@ async def handle_job(ctx: JobContext) -> None: ack, reliable=True, topic="voice-control" ) ) + _sync_task_model(task_dir, llm.model) except Exception as e: # noqa: BLE001 logger.warning("Failed to update model: %s", e) diff --git a/agent/task_worker.py b/agent/task_worker.py index c592edb..5051e96 100644 --- a/agent/task_worker.py +++ b/agent/task_worker.py @@ -45,6 +45,22 @@ def _html_to_text(html: str) -> str: return text.strip() +def _live_model(default: str) -> str: + """Follow the conversation's live model selection when it is fresher. + + The main agent writes the UI-selected model to the session's task dir + (shared with this MCP subprocess) on every set_model. Env is the + fallback for tasks dispatched before any switch happened. + """ + env = os.environ.get("TASKS_DIR", "/tmp/tasks") + try: + with open(os.path.join(env, "model.txt")) as f: + chosen = f.read().strip() + except OSError: + return default + return chosen or default + + class TaskWorker: """Runs a background research/lookup task using the LLM + tools.""" @@ -65,7 +81,7 @@ class TaskWorker: or os.environ.get("GEMMA_BASE_URL") or "http://192.168.86.2:3005/api" ) - model = ( + model = _live_model( os.environ.get("LLM_MODEL") or os.environ.get("GEMMA_MODEL") or "qwen2.5-vl-3b"