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

@@ -44,6 +44,14 @@ class BrainSpec:
owns_context: bool
@dataclass(frozen=True)
class SessionVariableUpdate:
"""Result of one silent update to declared session variables."""
changed: list[str]
dynamic_variables: dict[str, str | int | float | bool]
class CallEndPort(Protocol):
"""Small call-lifecycle surface available to a brain."""
@@ -144,6 +152,13 @@ class BaseBrain:
async def on_client_ready(self) -> None:
"""Replay client-visible state after its app message channel is ready."""
async def on_session_update(
self,
dynamic_variables: dict[str, Any],
) -> SessionVariableUpdate:
"""Apply a silent client state update without starting an LLM turn."""
raise ValueError(f"助手类型 {self.spec.type} 不支持会话动态变量更新")
def record_user_message(self, content: str) -> None:
"""Observe a committed user message for brain-owned routing state."""
@@ -194,6 +209,11 @@ class Brain(Protocol):
async def on_client_ready(self) -> None: ...
async def on_session_update(
self,
dynamic_variables: dict[str, Any],
) -> SessionVariableUpdate: ...
def record_user_message(self, content: str) -> None: ...
async def on_user_turn_end(self, content: str) -> bool: ...

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from typing import Any
from uuid import uuid4
from models import AssistantConfig
@@ -15,10 +16,13 @@ from pipecat.services.llm_service import (
)
from pipecat.utils.time import time_now_iso8601
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
from services.runtime_variables import (
DynamicVariableStore,
from services.brains.base import (
BaseBrain,
BrainRuntime,
BrainSpec,
SessionVariableUpdate,
)
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tool_policy import policy_for_tool
@@ -76,6 +80,18 @@ class PromptBrain(BaseBrain):
self._store.record("user", content)
self._refresh_prompt()
async def on_session_update(
self,
dynamic_variables: dict[str, Any],
) -> SessionVariableUpdate:
changed = self._store.assign_declared_many(dynamic_variables)
if changed:
self._refresh_prompt()
return SessionVariableUpdate(
changed=changed,
dynamic_variables=self._store.public_values(),
)
async def on_assistant_text_start(self, _turn_id: str) -> None:
if self._runtime is not None:
self._runtime.call_end.begin_response()

View File

@@ -28,7 +28,12 @@ from pipecat.services.llm_service import (
FunctionCallParams,
FunctionCallResultProperties,
)
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
from services.brains.base import (
BaseBrain,
BrainRuntime,
BrainSpec,
SessionVariableUpdate,
)
from services.knowledge import search as search_knowledge
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor
@@ -258,6 +263,27 @@ class WorkflowBrain(BaseBrain):
node_id=current_node,
)
async def on_session_update(
self,
dynamic_variables: dict[str, Any],
) -> SessionVariableUpdate:
if self._ended:
raise ValueError("工作流会话已经结束")
changed = self._store.assign_declared_many(dynamic_variables)
current = self._state.current_node_id
if changed and current and self._engine.node_type(current) == "agent":
await self._refresh_agent_prompt(current)
if changed:
await self._emit_variables(
reason="session_update",
node_id=current or None,
changed=changed,
)
return SessionVariableUpdate(
changed=changed,
dynamic_variables=self._store.public_values(),
)
def record_user_message(self, content: str) -> None:
if content and not self._ended:
self._store.record("user", content)

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."""

View File

@@ -210,6 +210,39 @@ class DynamicVariableStore:
raise DynamicVariableError(f"动态变量 {name} 类型应为 {expected}")
self.values[name] = primitive
def assign_declared_many(self, values: dict[str, Any]) -> list[str]:
"""Atomically update public variables declared by the assistant."""
staged: dict[str, Primitive] = {}
for name, value in values.items():
if (
name.startswith(("system__", "secret__"))
or not VARIABLE_NAME.fullmatch(name)
):
raise DynamicVariableError(f"会话不能更新保留变量: {name}")
if name not in self.variable_types:
raise DynamicVariableError(f"动态变量未声明: {name}")
primitive = _primitive(value, name)
expected = self.variable_types[name]
if not _type_matches(primitive, expected):
raise DynamicVariableError(f"动态变量 {name} 类型应为 {expected}")
staged[name] = primitive
changed = [
name
for name, value in staged.items()
if name not in self.values or self.values[name] != value
]
self.values.update(staged)
return changed
def public_values(self) -> dict[str, Primitive]:
"""Return the client-visible dynamic-variable snapshot."""
return {
name: value
for name, value in self.values.items()
if not name.startswith(("system__", "secret__"))
}
def prepare_dynamic_config(
cfg: AssistantConfig,