feat: add session updates and message dialogs

This commit is contained in:
Xin Wang
2026-07-31 00:01:01 +08:00
parent 913435785e
commit c2f0f5eb04
14 changed files with 850 additions and 8 deletions

View File

@@ -74,6 +74,7 @@ from services.pipecat.processors import (
PassthroughLLMAssistantAggregator,
RealtimeDynamicVariableProcessor,
RealtimeUserInputProcessor,
SessionUpdateProcessor,
UserInput,
UserInputProcessor,
UserTurnRoutingProcessor,
@@ -365,6 +366,10 @@ async def run_pipeline(
or tool_interruption_strategy.is_muted
)
)
session_update = SessionUpdateProcessor(
brain.on_session_update,
should_ignore_update=lambda: call_end.ending,
)
client_tools = ClientToolBroker()
vision_capture = VisionCaptureProcessor()
knowledge_retrieval = KnowledgeRetrievalProcessor(
@@ -578,6 +583,7 @@ async def run_pipeline(
[
transport.input(),
client_tools,
session_update,
vision_capture,
user_input,
stt_processor,
@@ -778,6 +784,16 @@ async def run_realtime_pipeline(
input_sample_rate, output_sample_rate = realtime_audio_sample_rates(cfg)
user_input = RealtimeUserInputProcessor()
dynamic_variables = RealtimeDynamicVariableProcessor(brain, cfg, realtime)
async def refresh_realtime_instructions() -> None:
update = getattr(realtime, "update_instructions", None)
if callable(update):
await update(brain.system_prompt(cfg))
session_update = SessionUpdateProcessor(
brain.on_session_update,
after_update=refresh_realtime_instructions,
)
greeting = await brain.greeting(cfg)
recorder = await ConversationRecorder.start(
@@ -790,6 +806,7 @@ async def run_realtime_pipeline(
pipeline = Pipeline(
[
transport.input(),
session_update,
user_input,
realtime,
dynamic_variables,

View File

@@ -1,13 +1,14 @@
"""Reusable frame processors shared by cascade and realtime pipelines."""
import asyncio
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from uuid import uuid4
from loguru import logger
from models import AssistantConfig
from services.brains import Brain
from services.brains.base import SessionVariableUpdate
from services.conversation_history import ConversationRecorder
from services.knowledge import search as search_knowledge
from db.session import SessionLocal
@@ -38,6 +39,7 @@ from pipecat.utils.time import time_now_iso8601
KNOWLEDGE_CONTEXT_MARKER = "<!-- knowledge-context -->"
MAX_SESSION_UPDATE_VARIABLES = 20
@dataclass(frozen=True)
@@ -69,6 +71,69 @@ class UserInputError(ValueError):
self.input_id = input_id
@dataclass(frozen=True)
class SessionUpdate:
"""Validated silent update to declared session variables."""
update_id: str
dynamic_variables: dict[str, str | int | float | bool]
class SessionUpdateError(ValueError):
def __init__(self, message: str, *, update_id: str = "") -> None:
super().__init__(message)
self.update_id = update_id
def parse_session_update(message) -> SessionUpdate | None:
"""Parse the silent session-update wire format."""
if not isinstance(message, dict) or message.get("type") != "session-update":
return None
update_id = str(message.get("update_id") or "").strip()
if not update_id or len(update_id) > 128:
raise SessionUpdateError(
"session-update 缺少有效的 update_id",
update_id=update_id,
)
if message.get("schema_version") != 1:
raise SessionUpdateError(
"不支持的 session-update schema_version",
update_id=update_id,
)
raw_variables = message.get("dynamic_variables")
if not isinstance(raw_variables, dict) or not raw_variables:
raise SessionUpdateError(
"session-update dynamic_variables 不能为空",
update_id=update_id,
)
if len(raw_variables) > MAX_SESSION_UPDATE_VARIABLES:
raise SessionUpdateError(
f"session-update 一次最多更新 {MAX_SESSION_UPDATE_VARIABLES} 个变量",
update_id=update_id,
)
dynamic_variables: dict[str, str | int | float | bool] = {}
for raw_name, value in raw_variables.items():
if not isinstance(raw_name, str):
raise SessionUpdateError(
"session-update 动态变量名必须是字符串",
update_id=update_id,
)
if not isinstance(value, (str, int, float, bool)) or value is None:
raise SessionUpdateError(
f"动态变量 {raw_name} 仅支持字符串、数字或布尔值",
update_id=update_id,
)
dynamic_variables[raw_name] = value
return SessionUpdate(
update_id=update_id,
dynamic_variables=dynamic_variables,
)
def parse_user_input(message) -> UserInput | None:
"""Parse the sole public user-input wire format."""
if not isinstance(message, dict):
@@ -198,6 +263,94 @@ class UserInputProcessor(FrameProcessor):
)
class SessionUpdateProcessor(FrameProcessor):
"""Apply silent dynamic-variable updates without creating a user turn."""
def __init__(
self,
apply_update: Callable[
[dict[str, str | int | float | bool]],
Awaitable[SessionVariableUpdate],
],
*,
after_update: Callable[[], Awaitable[None]] | None = None,
should_ignore_update: Callable[[], bool] | None = None,
):
super().__init__()
self._apply_update = apply_update
self._after_update = after_update
self._should_ignore_update = should_ignore_update or (lambda: False)
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if not isinstance(frame, InputTransportMessageFrame):
await self.push_frame(frame, direction)
return
try:
update = parse_session_update(frame.message)
except SessionUpdateError as exc:
await self._emit_result(exc.update_id, "error", message=str(exc))
return
if update is None:
await self.push_frame(frame, direction)
return
if self._should_ignore_update():
await self._emit_result(
update.update_id,
"error",
message="当前不能更新会话状态",
)
return
try:
result = await self._apply_update(update.dynamic_variables)
if result.changed and self._after_update is not None:
await self._after_update()
except Exception as exc: # noqa: BLE001 - protocol failures need a reply
logger.warning(f"会话状态更新失败: {exc}")
await self._emit_result(
update.update_id,
"error",
message=str(exc),
)
return
await self._emit_result(
update.update_id,
"accepted",
changed=result.changed,
dynamic_variables=result.dynamic_variables,
)
async def _emit_result(
self,
update_id: str,
status: str,
*,
message: str = "",
changed: list[str] | None = None,
dynamic_variables: dict[str, str | int | float | bool] | None = None,
) -> None:
await self.push_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "session-update-result",
"update_id": update_id,
"status": status,
**({"message": message} if message else {}),
**({"changed": changed} if changed is not None else {}),
**(
{"dynamic_variables": dynamic_variables}
if dynamic_variables is not None
else {}
),
}
)
)
class CallEndingUserMuteStrategy(BaseUserMuteStrategy):
"""Keep user media muted after an end-call tool starts terminating a call."""