fix: reliably dispatch and return background tasks
Rebuild & Deploy / rebuild (push) Successful in 47s
Rebuild & Deploy / rebuild (push) Successful in 47s
This commit is contained in:
1 parent
746ca8dca9
commit
321421e37f
7 files changed
+310
-27
No files matched your search
+105
-15
@@ -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
|
||||
|
||||
Reference in new issue
Block a user