fix: reliably dispatch and return background tasks
Rebuild & Deploy / rebuild (push) Successful in 47s

This commit is contained in:
snowspeeder committed 2026-08-29 07:29:38 -04:00
1 parent 746ca8dca9
commit 321421e37f
7 files changed
+310 -27

No files matched your search

+105 -15
View File
@@ -11,6 +11,7 @@ import logging
import os
import re
import textwrap
import uuid
from dotenv import load_dotenv
from livekit.agents import (
@@ -157,6 +158,14 @@ SYSTEM_PROMPT = textwrap.dedent("""\
""")
# ── Background-task routing ─────────────────────────────────────────────────
try:
from agent.task_router import should_force_background_task
except ImportError:
from task_router import should_force_background_task
# ── Gemma LLM with reasoning_content fallback ───────────────────────────────
@@ -222,6 +231,31 @@ class _ReasoningFallbackWrapper:
return await self._inner.collect()
class _ForcedToolCallStream(lk_llm.LLMStream):
"""Emit a dispatch_task call without asking the small LLM to route it."""
def __init__(self, llm, *, chat_ctx, tools, conn_options, description: str):
self._description = description
super().__init__(llm, chat_ctx=chat_ctx, tools=tools, conn_options=conn_options)
async def _run(self) -> None:
self._event_ch.send_nowait(
lk_llm.ChatChunk(
id=f"forced-dispatch-{uuid.uuid4().hex}",
delta=lk_llm.ChoiceDelta(
role="assistant",
tool_calls=[
lk_llm.FunctionToolCall(
name="dispatch_task",
arguments=json.dumps({"description": self._description}),
call_id=f"dispatch-{uuid.uuid4().hex}",
)
],
),
)
)
class GemmaLLM(openai.LLM):
"""OpenAI-compatible LLM (llama.cpp Gemma 4) with thinking disabled."""
@@ -233,6 +267,10 @@ class GemmaLLM(openai.LLM):
IDLE_COMPACTION_SECONDS = 300 # also compact after 5 min of silence
def __init__(self, *args, **kwargs):
# llama.cpp's Gemma tool template ignores named tool_choice when OpenAI
# strict schemas are enabled. Non-strict schemas make forced dispatch
# (and ordinary MCP calls) work reliably.
kwargs.setdefault("_strict_tool_schema", False)
super().__init__(*args, **kwargs)
import time
self._last_call_time: float = time.monotonic()
@@ -263,6 +301,25 @@ class GemmaLLM(openai.LLM):
)
chat_ctx = self._compact_context(chat_ctx, conn_options)
# Force dispatch for clear background-work requests. Without this,
# Gemma often says "I'll get on that" but emits no tool call at all.
items = list(chat_ctx.items)
latest = items[-1] if items else None
latest_role = getattr(latest, "role", None)
latest_text = self._msg_content_text(latest) if latest_role == "user" else ""
dispatch_available = any(
getattr(tool, "id", None) == "dispatch_task" for tool in (tools or [])
)
if dispatch_available and should_force_background_task(latest_text):
logger.info("Routing background request to dispatch_task: %.120s", latest_text)
return _ForcedToolCallStream(
self,
chat_ctx=chat_ctx,
tools=tools or [],
conn_options=conn_options,
description=latest_text,
)
stream = super().chat(
chat_ctx=chat_ctx,
tools=tools,
@@ -386,8 +443,8 @@ class GemmaLLM(openai.LLM):
# ── MCP toolsets (web access + any extra configured servers) ────────────────
def build_mcp_toolsets(user_profile: dict) -> list[mcp.MCPToolset]:
"""Build isolated MCP toolsets for one authenticated voice user."""
def build_mcp_toolsets(user_profile: dict, task_dir: str) -> list[mcp.MCPToolset]:
"""Build isolated MCP toolsets for one authenticated voice user/session."""
toolsets: list[mcp.MCPToolset] = []
python_bin = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".venv", "bin", "python")
@@ -459,12 +516,12 @@ def build_mcp_toolsets(user_profile: dict) -> list[mcp.MCPToolset]:
mcp_server=mcp.MCPServerStdio(
command=python_bin,
args=[dispatch_mcp_script],
env={**os.environ},
env={**os.environ, "TASKS_DIR": task_dir},
client_session_timeout_seconds=60,
),
)
)
logger.info("Dispatch MCP toolset enabled")
logger.info("Dispatch MCP toolset enabled (%s)", task_dir)
extra = os.environ.get("EXTRA_MCP_SERVERS", "")
if extra:
@@ -492,7 +549,7 @@ def build_mcp_toolsets(user_profile: dict) -> list[mcp.MCPToolset]:
class VoiceAssistant(Agent):
"""The conversational agent. LLM is the brain; STT/TTS are senses."""
def __init__(self, user_profile: dict) -> None:
def __init__(self, user_profile: dict, task_dir: str) -> None:
display_name = user_profile.get("display_name") or user_profile["user_id"]
instructions = SYSTEM_PROMPT.replace("{user_display_name}", display_name)
super().__init__(
@@ -504,7 +561,7 @@ class VoiceAssistant(Agent):
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
),
instructions=instructions,
tools=build_mcp_toolsets(user_profile),
tools=build_mcp_toolsets(user_profile, task_dir),
)
@@ -614,11 +671,15 @@ async def handle_job(ctx: JobContext) -> None:
# audio is arriving from the participant.
logger.info("user state -> %s", ev.new_state)
# ── Background task events → room data channel ─────────────────────────
# ── Background task events → UI and spoken result ──────────────────────
# Every room gets a private event directory. This prevents concurrent or
# reconnecting sessions from consuming and announcing each other's tasks.
task_dir = f"/tmp/tasks/{ctx.room.name}"
try:
from agent.task_registry import registry as task_registry
from agent.task_registry import TaskRegistry
except ImportError:
from task_registry import registry as task_registry
from task_registry import TaskRegistry
task_registry = TaskRegistry(task_dir)
async def _publish_task_event(task_id: str, event: dict) -> None:
payload = json.dumps({"type": "task_event", "task_id": task_id, **event})
@@ -629,17 +690,46 @@ async def handle_job(ctx: JobContext) -> None:
except Exception as e: # noqa: BLE001
logger.warning("failed to publish task event: %s", e)
def _announce_task_result(task_id: str, event: dict) -> None:
event_type = event.get("event")
if event_type == "completed":
result = str(event.get("result") or "").strip()
if result:
logger.info("Announcing completed task %s: %.160s", task_id, result)
session.say(result, allow_interruptions=True, add_to_chat_ctx=True)
elif event_type == "failed":
logger.warning("Background task %s failed: %s", task_id, event.get("error"))
session.say(
"I couldn't finish that background task. You can ask me to try again.",
allow_interruptions=True,
add_to_chat_ctx=True,
)
async def _poll_tasks() -> None:
while True:
await asyncio.sleep(1.0)
events = await task_registry.poll_events()
for task_id, event in events:
await _publish_task_event(task_id, event)
try:
await asyncio.sleep(0.5)
events = await task_registry.poll_events()
for task_id, event in events:
await _publish_task_event(task_id, event)
_announce_task_result(task_id, event)
except asyncio.CancelledError:
raise
except Exception: # noqa: BLE001
# A transient room or speech error must not permanently kill
# result delivery for every later task.
logger.exception("Background task event poller failed")
asyncio.create_task(_poll_tasks())
poll_task = asyncio.create_task(_poll_tasks(), name=f"task-poller-{ctx.room.name}")
async def _stop_task_poller(*_args) -> None:
poll_task.cancel()
await asyncio.gather(poll_task, return_exceptions=True)
ctx.add_shutdown_callback(_stop_task_poller)
await session.start(
agent=VoiceAssistant(user_profile),
agent=VoiceAssistant(user_profile, task_dir),
room=ctx.room,
room_options=room_io.RoomOptions(
# Keep the agent in the room when a participant leaves; it must