feat: add session updates and message dialogs
This commit is contained in:
@@ -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: ...
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user