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 <task_dir>/model.txt at
session start and on every switch; TaskWorker reads it with the env
value as fallback.
This commit is contained in:
snowspeeder committed 2026-09-12 07:16:08 -04:00
1 parent 436b3fc8b1
commit b01ee6478d
3 files changed
+37 -1

No files matched your search

+1
View File
@@ -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. - **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. - **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. - **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 `<task_dir>/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. - **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 - **Hope Music stays external.** `hope-media-music` owns yt-dlp, ffmpeg, the
library volume, streaming endpoints, and the player UI. Do not add those library volume, streaming endpoints, and the player UI. Do not add those
+19
View File
@@ -750,6 +750,20 @@ def build_mcp_toolsets(user_profile: dict, task_dir: str) -> list[mcp.MCPToolset
return toolsets 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
<task_dir>/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): class VoiceAssistant(Agent):
"""The conversational agent. LLM is the brain; STT/TTS are senses.""" """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. # into it for live model switching.
voice_assistant = VoiceAssistant(user_profile, task_dir) 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. # Publish the chat-capable model list so the web UI can offer a picker.
async def _publish_models() -> None: async def _publish_models() -> None:
models = await list_available_models() models = await list_available_models()
@@ -1009,6 +1027,7 @@ async def handle_job(ctx: JobContext) -> None:
ack, reliable=True, topic="voice-control" ack, reliable=True, topic="voice-control"
) )
) )
_sync_task_model(task_dir, llm.model)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
logger.warning("Failed to update model: %s", e) logger.warning("Failed to update model: %s", e)
+17 -1
View File
@@ -45,6 +45,22 @@ def _html_to_text(html: str) -> str:
return text.strip() 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: class TaskWorker:
"""Runs a background research/lookup task using the LLM + tools.""" """Runs a background research/lookup task using the LLM + tools."""
@@ -65,7 +81,7 @@ class TaskWorker:
or os.environ.get("GEMMA_BASE_URL") or os.environ.get("GEMMA_BASE_URL")
or "http://192.168.86.2:3005/api" or "http://192.168.86.2:3005/api"
) )
model = ( model = _live_model(
os.environ.get("LLM_MODEL") os.environ.get("LLM_MODEL")
or os.environ.get("GEMMA_MODEL") or os.environ.get("GEMMA_MODEL")
or "qwen2.5-vl-3b" or "qwen2.5-vl-3b"