Files
VoiceAgent/dual_engine.py
T

263 lines
10 KiB
Python

import asyncio
import time
from typing import Callable, Optional
from loguru import logger
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
Frame,
InterruptionFrame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMFullResponseStartFrame,
LLMTextFrame,
StartFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
class DualEngineProcessor(FrameProcessor):
"""Dual-Engine Orchestrator.
Combines a fast local engine (macOS Foundation Model / Apple MLX) for instant
sub-400ms voice feedback with a deep engine (Hermes Agent / Luna / Gemma) for
deep reasoning, tool execution, and workspace memory.
"""
def __init__(
self,
*,
fast_llm: Optional[FrameProcessor] = None,
deep_llm: FrameProcessor,
observer: Optional[Callable[[str], None]] = None,
**kwargs,
):
super().__init__(**kwargs)
self._fast_llm = fast_llm
self._deep_llm = deep_llm
self._observer = observer
self._current_user_text: str = ""
self._fast_task: Optional[asyncio.Task] = None
self._deep_task: Optional[asyncio.Task] = None
self._fast_spoken: bool = False
self._deep_spoken: bool = False
self._last_tool_phrase: str = ""
if hasattr(self._deep_llm, "_on_tool_event"):
self._deep_llm._on_tool_event = self.handle_tool_signal
def handle_tool_signal(self, detail: str):
if not detail or self._deep_spoken:
return
detail_lower = detail.lower()
if "read" in detail_lower or "view" in detail_lower or "cat" in detail_lower:
phrase = "Inspecting project files."
elif "search" in detail_lower or "grep" in detail_lower or "find" in detail_lower:
phrase = "Searching the codebase."
elif "exec" in detail_lower or "run" in detail_lower or "command" in detail_lower:
phrase = "Running command."
else:
phrase = "Working on that."
if phrase == self._last_tool_phrase:
return
self._last_tool_phrase = phrase
logger.info(f"🗣 [DualEngine Voice Signal]: {phrase!r} (from tool event: {detail[:60]!r})")
asyncio.create_task(self._speak_tool_update(phrase))
async def _speak_tool_update(self, phrase: str):
try:
await self.push_frame(LLMFullResponseStartFrame())
await self.push_frame(LLMTextFrame(phrase))
await self.push_frame(LLMFullResponseEndFrame())
except Exception as e:
logger.debug(f"Tool voice update error: {e}")
async def setup(self, task_manager):
await super().setup(task_manager)
if self._fast_llm and hasattr(self._fast_llm, "setup"):
await self._fast_llm.setup(task_manager)
if self._deep_llm and hasattr(self._deep_llm, "setup"):
await self._deep_llm.setup(task_manager)
def set_task_manager(self, task_manager):
super().set_task_manager(task_manager)
if self._fast_llm and hasattr(self._fast_llm, "set_task_manager"):
self._fast_llm.set_task_manager(task_manager)
if self._deep_llm and hasattr(self._deep_llm, "set_task_manager"):
self._deep_llm.set_task_manager(task_manager)
def link(self, processor: "FrameProcessor"):
super().link(processor)
if self._fast_llm and hasattr(self._fast_llm, "link"):
self._fast_llm.link(processor)
if self._deep_llm and hasattr(self._deep_llm, "link"):
self._deep_llm.link(processor)
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, StartFrame):
if self._fast_llm:
await self._fast_llm.process_frame(frame, direction)
await self._deep_llm.process_frame(frame, direction)
await self.push_frame(frame, direction)
elif isinstance(frame, (EndFrame, CancelFrame)):
await self._cancel_active_tasks()
if self._fast_llm:
await self._fast_llm.process_frame(frame, direction)
await self._deep_llm.process_frame(frame, direction)
await self.push_frame(frame, direction)
elif isinstance(frame, InterruptionFrame):
await self._cancel_active_tasks()
if self._fast_llm:
await self._fast_llm.process_frame(frame, direction)
await self._deep_llm.process_frame(frame, direction)
await self.push_frame(frame, direction)
elif isinstance(frame, LLMContextFrame):
text = self._extract_user_text(frame.context)
if text:
await self.start_dual_turn(text)
else:
await self.push_frame(frame, direction)
else:
await self.push_frame(frame, direction)
def start_turn_direct(self, text: str):
utterance = text.strip()
if not utterance:
return
asyncio.create_task(self.start_dual_turn(utterance))
async def start_dual_turn(self, text: str):
utterance = text.strip()
if not utterance:
return
await self._cancel_active_tasks()
self._current_user_text = utterance
self._fast_spoken = False
self._deep_spoken = False
self._suppress_deep = False
logger.info(f"⚡ [DualEngine] Starting turn for prompt: {utterance!r}")
t0 = time.perf_counter()
# Start deep Hermes processing in background
self._deep_task = asyncio.create_task(self._run_deep_path(utterance, t0))
# Dispatch fast-path acknowledgment concurrently
if self._fast_llm and hasattr(self._fast_llm, "_run_turn"):
self._fast_task = asyncio.create_task(self._run_fast_path(utterance, t0))
async def _run_fast_path(self, utterance: str, t0: float):
try:
fast_prompt = (
"You are a fast voice assistant.\n"
"Rules:\n"
"1. If the prompt is a simple greeting or fully answered by a short sentence, "
"end your answer with [COMPLETE].\n"
"2. If it requires deep search/code/tools, use a soft natural human filler "
'(e.g., "Ah, let me check that...", "Hmm, let me look into that.") and end with [NEEDS_DEEP].\n'
"3. Keep output under 15 words.\n\n"
f"User prompt: {utterance!r}"
)
chunks: list[str] = []
if hasattr(self._fast_llm, "_run_turn_cli"):
await self._fast_llm._run_turn_cli(fast_prompt, chunks)
elif hasattr(self._fast_llm, "_run_turn"):
await self._fast_llm._run_turn(fast_prompt)
t1 = time.perf_counter()
raw_text = " ".join(chunks).strip()
is_complete = "[COMPLETE]" in raw_text
cleaned_text = raw_text.replace("[COMPLETE]", "").replace("[NEEDS_DEEP]", "").strip()
if cleaned_text and not self._deep_spoken:
self._fast_spoken = True
if is_complete:
self._suppress_deep = True
logger.info(f"⚡ [DualEngine Speculative Routing]: Query marked COMPLETE by fast model. Suppressing redundant deep response.")
logger.info(f"⏱ [DualEngine Fast-Path ({int((t1-t0)*1000)}ms)]: {cleaned_text!r} (Complete: {is_complete})")
try:
import web_server
web_server.broadcast_event("fast_reply", {
"text": cleaned_text,
"is_complete": is_complete,
})
except Exception:
pass
await self.push_frame(LLMFullResponseStartFrame())
await self.push_frame(LLMTextFrame(cleaned_text))
await self.push_frame(LLMFullResponseEndFrame())
except asyncio.CancelledError:
pass
except Exception as e:
logger.debug(f"DualEngine fast-path error: {e}")
async def _run_deep_path(self, utterance: str, t0: float):
try:
# If fast model marked turn COMPLETE, run deep Hermes in background history mode
if self._suppress_deep:
logger.info("Hermes deep path running silently in background history sync mode...")
if hasattr(self._deep_llm, "_run_turn"):
try:
await self._deep_llm._run_turn(utterance, suppress_output=self._suppress_deep)
except TypeError:
await self._deep_llm._run_turn(utterance)
t1 = time.perf_counter()
self._deep_spoken = True
logger.info(f"⏱ [DualEngine Deep-Path ({int((t1-t0)*1000)}ms)] turn complete.")
try:
import web_server
web_server.broadcast_event("profiling", {
"mode": "Dual-Engine (Fast + Deep)",
"total_ms": int((t1 - t0) * 1000),
})
except Exception:
pass
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"DualEngine deep-path error: {e}")
async def _cancel_active_tasks(self):
for task in (self._fast_task, self._deep_task):
if task and not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._fast_task = None
self._deep_task = None
def _extract_user_text(self, context) -> str:
if not context or not hasattr(context, "messages"):
return ""
for msg in reversed(context.messages):
if isinstance(msg, dict) and msg.get("role") == "user":
content = msg.get("content", "")
if isinstance(content, str):
return content
elif isinstance(content, list):
return " ".join([c.get("text", "") for c in content if isinstance(c, dict)])
return ""