Enhance conversation history and runtime variable management
- Update ConversationRecorder to include source and nodeId metadata in transcripts for better context tracking. - Introduce optional variable handling in DynamicVariableStore, allowing for unset variables to be rendered as empty without raising errors. - Refactor WorkflowBrain to apply turn configurations and manage interaction policies dynamically, improving agent responsiveness. - Implement tests to ensure proper handling of updated session variables and workflow metadata in various scenarios.
This commit is contained in:
@@ -60,6 +60,9 @@ class BrainRuntime:
|
||||
) = None
|
||||
set_knowledge_scope: Callable[[dict[str, Any]], None] | None = None
|
||||
set_input_enabled: Callable[[bool], None] | None = None
|
||||
apply_turn_config: (
|
||||
Callable[[bool, dict[str, Any]], Awaitable[None]] | None
|
||||
) = None
|
||||
flow_global_functions: list[Any] = field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -49,7 +50,13 @@ class WorkflowBrain(BaseBrain):
|
||||
|
||||
def __init__(self, cfg_or_graph: AssistantConfig | dict[str, Any]):
|
||||
cfg = cfg_or_graph if isinstance(cfg_or_graph, AssistantConfig) else None
|
||||
graph = cfg.graph if cfg is not None else cfg_or_graph
|
||||
graph = deepcopy(cfg.graph if cfg is not None else cfg_or_graph)
|
||||
if cfg is not None:
|
||||
# Graph v3 owns Workflow defaults. Keep older saved graphs compatible
|
||||
# by filling the new interaction settings from the assistant row.
|
||||
settings = graph.setdefault("settings", {})
|
||||
settings.setdefault("enableInterrupt", cfg.enableInterrupt)
|
||||
settings.setdefault("turnConfig", deepcopy(cfg.turnConfig))
|
||||
self._engine = WorkflowEngine(graph or {})
|
||||
if not self._engine.has_graph() or not self._engine.start_id:
|
||||
raise ValueError("WorkflowBrain 缺少有效的 Start 节点")
|
||||
@@ -95,6 +102,10 @@ class WorkflowBrain(BaseBrain):
|
||||
|
||||
async def on_connected(self) -> None:
|
||||
await self._emit_node_active(self._engine.start_id)
|
||||
await self._emit_variables(
|
||||
reason="initialized",
|
||||
node_id=self._engine.start_id,
|
||||
)
|
||||
edge = self._engine.deterministic_edge(
|
||||
self._engine.start_id,
|
||||
self._store,
|
||||
@@ -228,6 +239,11 @@ class WorkflowBrain(BaseBrain):
|
||||
if self._runtime and self._runtime.set_input_enabled:
|
||||
self._runtime.set_input_enabled(True)
|
||||
runtime = self._require_runtime()
|
||||
if runtime.apply_turn_config:
|
||||
await runtime.apply_turn_config(
|
||||
stage.enable_interrupt,
|
||||
stage.turn_config,
|
||||
)
|
||||
if runtime.switch_services:
|
||||
await runtime.switch_services(
|
||||
stage.llm_resource_id or None,
|
||||
@@ -248,6 +264,11 @@ class WorkflowBrain(BaseBrain):
|
||||
data = self._engine.data(node_id)
|
||||
entry_mode = str(data.get("entryMode") or "wait_user")
|
||||
entry_speech = self._store.render(str(data.get("entrySpeech") or ""))
|
||||
fixed_reply_messages = (
|
||||
[{"role": "assistant", "content": entry_speech}]
|
||||
if entry_mode == "fixed_speech" and entry_speech
|
||||
else []
|
||||
)
|
||||
strategy = (
|
||||
ContextStrategy.RESET
|
||||
if data.get("contextPolicy") == "fresh"
|
||||
@@ -265,11 +286,10 @@ class WorkflowBrain(BaseBrain):
|
||||
config: NodeConfig = {
|
||||
"name": node_id,
|
||||
"role_message": self._agent_role_message(node_id),
|
||||
"task_messages": (
|
||||
[{"role": "assistant", "content": entry_speech}]
|
||||
if entry_mode == "fixed_speech"
|
||||
else []
|
||||
),
|
||||
# Flows writes task_messages into the Pipecat LLM context. The
|
||||
# pre-action below is responsible only for display, persistence,
|
||||
# dynamic conversation history, and TTS playback.
|
||||
"task_messages": fixed_reply_messages,
|
||||
"functions": functions,
|
||||
"context_strategy": ContextStrategyConfig(strategy=strategy),
|
||||
"respond_immediately": entry_mode == "generate",
|
||||
@@ -279,6 +299,7 @@ class WorkflowBrain(BaseBrain):
|
||||
{
|
||||
"type": "workflow_fixed_speech",
|
||||
"text": entry_speech,
|
||||
"node_id": node_id,
|
||||
"handler": self._play_fixed_speech,
|
||||
}
|
||||
]
|
||||
@@ -286,9 +307,19 @@ class WorkflowBrain(BaseBrain):
|
||||
|
||||
async def _play_fixed_speech(self, action: dict, _flow_manager: FlowManager) -> None:
|
||||
"""Play and persist Agent entry speech without creating an LLM turn."""
|
||||
await self._queue_visible_speech(str(action.get("text") or ""))
|
||||
await self._queue_visible_speech(
|
||||
str(action.get("text") or ""),
|
||||
source="workflow-fixed-reply",
|
||||
node_id=str(action.get("node_id") or "") or None,
|
||||
)
|
||||
|
||||
async def _queue_visible_speech(self, text: str) -> None:
|
||||
async def _queue_visible_speech(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
source: str = "workflow-speech",
|
||||
node_id: str | None = None,
|
||||
) -> None:
|
||||
"""Show and persist fixed workflow speech before sending it to TTS."""
|
||||
content = text.strip()
|
||||
if not content:
|
||||
@@ -302,6 +333,8 @@ class WorkflowBrain(BaseBrain):
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"timestamp": time_now_iso8601(),
|
||||
"source": source,
|
||||
**({"nodeId": node_id} if node_id else {}),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -327,7 +360,13 @@ class WorkflowBrain(BaseBrain):
|
||||
result = await self._tools.execute(tool, dict(args or {}))
|
||||
except ToolExecutionError as exc:
|
||||
return {"status": "error", "message": str(exc)}
|
||||
if result.get("updated_variables"):
|
||||
updated_variables = list(result.get("updated_variables") or [])
|
||||
if updated_variables:
|
||||
await self._emit_variables(
|
||||
reason="tool",
|
||||
node_id=node_id,
|
||||
changed=updated_variables,
|
||||
)
|
||||
await self._refresh_agent_prompt(node_id)
|
||||
edge = self._engine.deterministic_edge(
|
||||
node_id,
|
||||
@@ -436,11 +475,18 @@ class WorkflowBrain(BaseBrain):
|
||||
return
|
||||
try:
|
||||
arguments = self._store.render_data(data.get("arguments") or {})
|
||||
await self._tools.execute(
|
||||
result = await self._tools.execute(
|
||||
tool,
|
||||
arguments,
|
||||
result_assignments=data.get("resultAssignments") or {},
|
||||
)
|
||||
updated_variables = list(result.get("updated_variables") or [])
|
||||
if updated_variables:
|
||||
await self._emit_variables(
|
||||
reason="action",
|
||||
node_id=node_id,
|
||||
changed=updated_variables,
|
||||
)
|
||||
self._store.values["system__last_action_status"] = "ok"
|
||||
self._store.values["system__last_action_error"] = ""
|
||||
except (ToolExecutionError, ValueError) as exc:
|
||||
@@ -501,6 +547,40 @@ class WorkflowBrain(BaseBrain):
|
||||
)
|
||||
)
|
||||
|
||||
def _public_variables(self) -> dict[str, str | int | float | bool]:
|
||||
"""Return the browser-safe part of this session's variable state."""
|
||||
return {
|
||||
name: value
|
||||
for name, value in self._store.values.items()
|
||||
if not name.startswith(("system__", "secret__"))
|
||||
and isinstance(value, (str, int, float, bool))
|
||||
}
|
||||
|
||||
async def _emit_variables(
|
||||
self,
|
||||
*,
|
||||
reason: str,
|
||||
node_id: str | None,
|
||||
changed: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Publish a safe snapshot so Workflow debug mirrors runtime state."""
|
||||
message: dict[str, Any] = {
|
||||
"type": "workflow-variables",
|
||||
"reason": reason,
|
||||
"variables": self._public_variables(),
|
||||
}
|
||||
if node_id:
|
||||
message["nodeId"] = node_id
|
||||
if changed:
|
||||
message["changed"] = [
|
||||
name
|
||||
for name in changed
|
||||
if not name.startswith(("system__", "secret__"))
|
||||
]
|
||||
await self._require_runtime().queue_frame(
|
||||
OutputTransportMessageUrgentFrame(message=message)
|
||||
)
|
||||
|
||||
def _require_runtime(self) -> BrainRuntime:
|
||||
if self._runtime is None:
|
||||
raise RuntimeError("WorkflowBrain 尚未绑定 pipeline runtime")
|
||||
|
||||
@@ -77,6 +77,10 @@ class ConversationRecorder:
|
||||
role = str(message.get("role") or "")
|
||||
content = str(message.get("content") or "").strip()
|
||||
event_key = f"transcript:{role}:{timestamp}:{content}"
|
||||
if message.get("source"):
|
||||
extra["source"] = str(message["source"])
|
||||
if message.get("nodeId"):
|
||||
extra["node_id"] = str(message["nodeId"])
|
||||
elif event_type == "assistant-text-end":
|
||||
role = "assistant"
|
||||
content = str(message.get("content") or "").strip()
|
||||
|
||||
@@ -162,6 +162,8 @@ def _normalize_settings(settings: dict[str, Any], *, global_prompt: str = "") ->
|
||||
settings.setdefault("knowledgeMode", "automatic")
|
||||
settings.setdefault("knowledgeTopN", 5)
|
||||
settings.setdefault("knowledgeScoreThreshold", 0.0)
|
||||
settings.setdefault("enableInterrupt", True)
|
||||
settings.setdefault("turnConfig", {})
|
||||
|
||||
|
||||
def normalize_graph(graph: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
@@ -10,6 +10,7 @@ import asyncio
|
||||
import base64
|
||||
from collections.abc import Callable
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
@@ -50,6 +51,7 @@ from pipecat.frames.frames import (
|
||||
TTSSpeakFrame,
|
||||
UserImageRawFrame,
|
||||
UserImageRequestFrame,
|
||||
VADParamsUpdateFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.llm_switcher import LLMSwitcher
|
||||
@@ -58,7 +60,6 @@ from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMAssistantAggregator,
|
||||
LLMUserAggregator,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
@@ -72,8 +73,10 @@ from pipecat.turns.user_mute.function_call_user_mute_strategy import (
|
||||
FunctionCallUserMuteStrategy,
|
||||
)
|
||||
from services.pipecat.turn_config import (
|
||||
ConfigurableLLMUserAggregator,
|
||||
create_user_turn_strategies,
|
||||
create_vad_analyzer,
|
||||
create_vad_params,
|
||||
)
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
from pipecat.workers.runner import WorkerRunner
|
||||
@@ -794,7 +797,7 @@ async def run_pipeline(
|
||||
current_llm_service = llm
|
||||
if cfg.type == "workflow":
|
||||
llm, llm_services, current_llm_service = _workflow_llm_switcher(cfg, llm)
|
||||
user_aggregator = LLMUserAggregator(
|
||||
user_aggregator = ConfigurableLLMUserAggregator(
|
||||
context,
|
||||
params=LLMUserAggregatorParams(
|
||||
vad_analyzer=create_vad_analyzer(cfg.turnConfig),
|
||||
@@ -1063,6 +1066,31 @@ async def run_pipeline(
|
||||
)
|
||||
)
|
||||
|
||||
current_enable_interrupt = cfg.enableInterrupt
|
||||
current_turn_config = dict(cfg.turnConfig)
|
||||
|
||||
async def apply_workflow_turn_config(
|
||||
enable_interrupt: bool,
|
||||
turn_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Apply one Agent's interaction policy before its next user turn."""
|
||||
nonlocal current_enable_interrupt, current_turn_config
|
||||
normalized = dict(turn_config or {})
|
||||
if (
|
||||
current_enable_interrupt == enable_interrupt
|
||||
and current_turn_config == normalized
|
||||
):
|
||||
return
|
||||
await user_aggregator.apply_turn_strategies(
|
||||
normalized,
|
||||
enable_interruptions=enable_interrupt,
|
||||
)
|
||||
await worker.queue_frame(
|
||||
VADParamsUpdateFrame(params=create_vad_params(normalized))
|
||||
)
|
||||
current_enable_interrupt = enable_interrupt
|
||||
current_turn_config = normalized
|
||||
|
||||
async def queue_transcript(role: str, content: str, timestamp: str) -> None:
|
||||
if content:
|
||||
await worker.queue_frame(
|
||||
@@ -1107,6 +1135,7 @@ async def run_pipeline(
|
||||
switch_services=switch_workflow_services,
|
||||
set_knowledge_scope=knowledge_retrieval.set_scope,
|
||||
set_input_enabled=lambda enabled: input_state.__setitem__("enabled", enabled),
|
||||
apply_turn_config=apply_workflow_turn_config,
|
||||
flow_global_functions=flow_global_functions,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,11 @@ from typing import Any
|
||||
from pipecat.audio.turn.smart_turn.local_smart_turn_v3 import LocalSmartTurnAnalyzerV3
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
from pipecat.processors.aggregators.llm_response_universal import (
|
||||
LLMUserAggregator,
|
||||
LLMUserAggregatorParams,
|
||||
)
|
||||
from pipecat.turns.user_start import (
|
||||
TranscriptionUserTurnStartStrategy,
|
||||
VADUserTurnStartStrategy,
|
||||
@@ -39,18 +44,21 @@ def _value(config: dict[str, Any], snake: str, camel: str, default: Any) -> Any:
|
||||
return config.get(snake, config.get(camel, default))
|
||||
|
||||
|
||||
def create_vad_analyzer(turn_config: dict[str, Any]) -> SileroVADAnalyzer:
|
||||
def create_vad_params(turn_config: dict[str, Any]) -> VADParams:
|
||||
"""Translate product settings into Pipecat's runtime VAD parameters."""
|
||||
vad = _section(turn_config, "vad", "vad")
|
||||
return SileroVADAnalyzer(
|
||||
params=VADParams(
|
||||
confidence=float(vad.get("confidence", DEFAULT_VAD["confidence"])),
|
||||
start_secs=float(_value(vad, "start_secs", "startSecs", 0.2)),
|
||||
stop_secs=float(_value(vad, "stop_secs", "stopSecs", 0.2)),
|
||||
min_volume=float(_value(vad, "min_volume", "minVolume", 0.6)),
|
||||
)
|
||||
return VADParams(
|
||||
confidence=float(vad.get("confidence", DEFAULT_VAD["confidence"])),
|
||||
start_secs=float(_value(vad, "start_secs", "startSecs", 0.2)),
|
||||
stop_secs=float(_value(vad, "stop_secs", "stopSecs", 0.2)),
|
||||
min_volume=float(_value(vad, "min_volume", "minVolume", 0.6)),
|
||||
)
|
||||
|
||||
|
||||
def create_vad_analyzer(turn_config: dict[str, Any]) -> SileroVADAnalyzer:
|
||||
return SileroVADAnalyzer(params=create_vad_params(turn_config))
|
||||
|
||||
|
||||
def create_user_turn_strategies(
|
||||
turn_config: dict[str, Any], *, enable_interruptions: bool
|
||||
) -> UserTurnStrategies:
|
||||
@@ -87,3 +95,34 @@ def create_user_turn_strategies(
|
||||
)
|
||||
]
|
||||
return UserTurnStrategies(start=start, stop=stop)
|
||||
|
||||
|
||||
class ConfigurableLLMUserAggregator(LLMUserAggregator):
|
||||
"""LLM user aggregator with one stable project-level runtime update API.
|
||||
|
||||
Pipecat 1.5 exposes ``UserTurnController.update_strategies`` but does not
|
||||
surface it on ``LLMUserAggregator``. Keeping that version-specific bridge
|
||||
here prevents Workflow orchestration from depending on Pipecat internals.
|
||||
VAD threshold updates still use Pipecat's public ``VADParamsUpdateFrame``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: LLMContext,
|
||||
*,
|
||||
params: LLMUserAggregatorParams | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(context, params=params, **kwargs)
|
||||
|
||||
async def apply_turn_strategies(
|
||||
self,
|
||||
turn_config: dict[str, Any],
|
||||
*,
|
||||
enable_interruptions: bool,
|
||||
) -> None:
|
||||
strategies = create_user_turn_strategies(
|
||||
turn_config,
|
||||
enable_interruptions=enable_interruptions,
|
||||
)
|
||||
await self._user_turn_controller.update_strategies(strategies)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Conversation-scoped dynamic variables for prompt pipeline assistants.
|
||||
"""Conversation-scoped dynamic variables shared by Prompt and Workflow.
|
||||
|
||||
The renderer is deliberately small: it only understands ``{{ name }}``
|
||||
placeholders and never evaluates expressions. A value is substituted once,
|
||||
@@ -21,6 +21,7 @@ from models import AssistantConfig
|
||||
Primitive = str | int | float | bool
|
||||
VARIABLE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
|
||||
PLACEHOLDER = re.compile(r"{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}")
|
||||
FULL_PLACEHOLDER = re.compile(r"^{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}$")
|
||||
MAX_VARIABLES = 50
|
||||
MAX_VALUE_LENGTH = 2048
|
||||
MAX_HISTORY_ENTRIES = 50
|
||||
@@ -91,37 +92,71 @@ class DynamicVariableStore:
|
||||
self,
|
||||
values: dict[str, Primitive],
|
||||
secrets: dict[str, str] | None = None,
|
||||
*,
|
||||
optional_names: set[str] | None = None,
|
||||
variable_types: dict[str, str] | None = None,
|
||||
):
|
||||
self.values = dict(values)
|
||||
self.secrets = dict(secrets or {})
|
||||
self.optional_names = set(optional_names or set())
|
||||
self.variable_types = dict(variable_types or {})
|
||||
self.history: list[dict[str, str]] = []
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, cfg: AssistantConfig) -> "DynamicVariableStore":
|
||||
return cls(cfg.dynamic_variables, cfg.secret_dynamic_variables)
|
||||
definitions = cfg.dynamic_variable_definitions or {}
|
||||
optional_names = {
|
||||
name
|
||||
for name, definition in definitions.items()
|
||||
if not definition.get("required", False)
|
||||
and definition.get("default") is None
|
||||
}
|
||||
variable_types = {
|
||||
name: str(definition.get("type") or "string")
|
||||
for name, definition in definitions.items()
|
||||
}
|
||||
return cls(
|
||||
cfg.dynamic_variables,
|
||||
cfg.secret_dynamic_variables,
|
||||
optional_names=optional_names,
|
||||
variable_types=variable_types,
|
||||
)
|
||||
|
||||
def render(self, template: str, *, allow_secrets: bool = False) -> str:
|
||||
if not template:
|
||||
return template
|
||||
def _refresh_time(self) -> None:
|
||||
timezone = str(self.values.get("system__timezone") or "Asia/Shanghai")
|
||||
try:
|
||||
now = datetime.now(ZoneInfo(timezone))
|
||||
self.values["system__time"] = now.strftime("%A, %H:%M %d %B %Y")
|
||||
self.values["system__time_utc"] = now.astimezone(ZoneInfo("UTC")).isoformat()
|
||||
self.values["system__time_utc"] = now.astimezone(
|
||||
ZoneInfo("UTC")
|
||||
).isoformat()
|
||||
except ZoneInfoNotFoundError:
|
||||
pass
|
||||
|
||||
def _resolve(self, name: str, *, allow_secrets: bool) -> Primitive:
|
||||
if name.startswith("secret__"):
|
||||
if not allow_secrets:
|
||||
raise DynamicVariableError(f"密钥变量 {name} 只能用于 HTTP Header")
|
||||
if name not in self.secrets:
|
||||
raise DynamicVariableError(f"缺少密钥变量: {name}")
|
||||
return self.secrets[name]
|
||||
if name in self.values:
|
||||
return self.values[name]
|
||||
# Optional variables intentionally remain absent from ``values`` so an
|
||||
# ``exists`` expression can distinguish unset from an explicit value.
|
||||
# Text templates still render predictably instead of failing the call.
|
||||
if name in self.optional_names:
|
||||
return ""
|
||||
raise DynamicVariableError(f"缺少动态变量: {name}")
|
||||
|
||||
def render(self, template: str, *, allow_secrets: bool = False) -> str:
|
||||
if not template:
|
||||
return template
|
||||
self._refresh_time()
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
if name.startswith("secret__"):
|
||||
if not allow_secrets:
|
||||
raise DynamicVariableError(f"密钥变量 {name} 只能用于 HTTP Header")
|
||||
if name not in self.secrets:
|
||||
raise DynamicVariableError(f"缺少密钥变量: {name}")
|
||||
return self.secrets[name]
|
||||
if name not in self.values:
|
||||
raise DynamicVariableError(f"缺少动态变量: {name}")
|
||||
value = self.values[name]
|
||||
value = self._resolve(name, allow_secrets=allow_secrets)
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
@@ -130,6 +165,12 @@ class DynamicVariableStore:
|
||||
|
||||
def render_data(self, value: Any, *, allow_secrets: bool = False) -> Any:
|
||||
if isinstance(value, str):
|
||||
exact = FULL_PLACEHOLDER.fullmatch(value)
|
||||
if exact:
|
||||
self._refresh_time()
|
||||
return deepcopy(
|
||||
self._resolve(exact.group(1), allow_secrets=allow_secrets)
|
||||
)
|
||||
return self.render(value, allow_secrets=allow_secrets)
|
||||
if isinstance(value, list):
|
||||
return [self.render_data(item, allow_secrets=allow_secrets) for item in value]
|
||||
@@ -163,7 +204,11 @@ class DynamicVariableStore:
|
||||
def assign(self, name: str, value: Any) -> None:
|
||||
if name.startswith(("system__", "secret__")) or not VARIABLE_NAME.fullmatch(name):
|
||||
raise DynamicVariableError(f"工具不能更新保留变量: {name}")
|
||||
self.values[name] = _primitive(value, name)
|
||||
primitive = _primitive(value, name)
|
||||
expected = self.variable_types.get(name)
|
||||
if expected and not _type_matches(primitive, expected):
|
||||
raise DynamicVariableError(f"动态变量 {name} 类型应为 {expected}")
|
||||
self.values[name] = primitive
|
||||
|
||||
|
||||
def prepare_dynamic_config(
|
||||
|
||||
@@ -23,6 +23,8 @@ class AgentStageConfig:
|
||||
knowledge_mode: str
|
||||
knowledge_top_n: int
|
||||
knowledge_score_threshold: float
|
||||
enable_interrupt: bool
|
||||
turn_config: dict[str, Any]
|
||||
|
||||
|
||||
class WorkflowEngine:
|
||||
@@ -106,6 +108,12 @@ class WorkflowEngine:
|
||||
asr_key = "defaultAsrResourceId" if inherits_global else "asrResourceId"
|
||||
tts_key = "defaultTtsResourceId" if inherits_global else "ttsResourceId"
|
||||
knowledge_base_id = str(source.get("knowledgeBaseId") or "")
|
||||
global_turn_config = self.settings.get("turnConfig")
|
||||
if not isinstance(global_turn_config, dict):
|
||||
global_turn_config = {}
|
||||
turn_config = source.get("turnConfig", global_turn_config)
|
||||
if not isinstance(turn_config, dict):
|
||||
turn_config = global_turn_config
|
||||
return AgentStageConfig(
|
||||
inherits_global=inherits_global,
|
||||
llm_resource_id=str(source.get(llm_key) or ""),
|
||||
@@ -122,6 +130,13 @@ class WorkflowEngine:
|
||||
knowledge_score_threshold=float(
|
||||
source.get("knowledgeScoreThreshold") or 0.0
|
||||
),
|
||||
enable_interrupt=bool(
|
||||
source.get(
|
||||
"enableInterrupt",
|
||||
self.settings.get("enableInterrupt", True),
|
||||
)
|
||||
),
|
||||
turn_config=dict(turn_config),
|
||||
)
|
||||
|
||||
def prompt_for(self, node_id: str, store: DynamicVariableStore) -> str:
|
||||
|
||||
@@ -9,6 +9,7 @@ from pipecat.frames.frames import (
|
||||
LLMContextFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
LLMMessagesUpdateFrame,
|
||||
LLMRunFrame,
|
||||
LLMTextFrame,
|
||||
OutputTransportMessageUrgentFrame,
|
||||
@@ -391,6 +392,78 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_action_publishes_updated_session_variables(self):
|
||||
tool = RuntimeTool(
|
||||
id="lookup",
|
||||
name="查询订单",
|
||||
function_name="lookup_order",
|
||||
type="http",
|
||||
)
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="workflow",
|
||||
graph={
|
||||
"specVersion": 3,
|
||||
"settings": {},
|
||||
"nodes": [
|
||||
{"id": "start", "type": "start", "data": {}},
|
||||
{
|
||||
"id": "lookup_action",
|
||||
"type": "action",
|
||||
"data": {
|
||||
"toolId": "lookup",
|
||||
"resultAssignments": {
|
||||
"order_status": "order.status"
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
dynamic_variable_definitions={
|
||||
"order_status": {"type": "string", "default": "pending"}
|
||||
},
|
||||
tools=[tool],
|
||||
),
|
||||
{},
|
||||
assistant_id="asst_workflow_action",
|
||||
)
|
||||
brain = WorkflowBrain(cfg)
|
||||
queued = []
|
||||
|
||||
async def queue_frame(frame):
|
||||
queued.append(frame)
|
||||
|
||||
async def execute(_tool, _arguments, *, result_assignments=None):
|
||||
self.assertEqual(result_assignments, {"order_status": "order.status"})
|
||||
brain._store.assign("order_status", "paid")
|
||||
return {
|
||||
"status": "ok",
|
||||
"updated_variables": ["order_status"],
|
||||
}
|
||||
|
||||
brain._runtime = BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=FakeLLM(),
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=FakeCallEnd(),
|
||||
)
|
||||
brain._tools.execute = execute
|
||||
|
||||
await brain._enter_action("lookup_action")
|
||||
|
||||
variable_events = [
|
||||
frame.message
|
||||
for frame in queued
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "workflow-variables"
|
||||
]
|
||||
self.assertEqual(variable_events[-1]["reason"], "action")
|
||||
self.assertEqual(variable_events[-1]["changed"], ["order_status"])
|
||||
self.assertEqual(variable_events[-1]["variables"], {"order_status": "paid"})
|
||||
|
||||
async def test_nodes_without_outgoing_edges_remain_active(self):
|
||||
queued = []
|
||||
|
||||
@@ -492,6 +565,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
"defaultTtsResourceId": "tts_global",
|
||||
"knowledgeBaseId": "kb_global",
|
||||
"knowledgeMode": "automatic",
|
||||
"enableInterrupt": False,
|
||||
"turnConfig": {
|
||||
"bargeIn": {"strategy": "transcription"},
|
||||
"vad": {"confidence": 0.55},
|
||||
},
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
@@ -551,6 +629,7 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
queued = []
|
||||
service_switches = []
|
||||
knowledge_scopes = []
|
||||
turn_configs = []
|
||||
call_end = FakeCallEnd()
|
||||
|
||||
class FakeWorker:
|
||||
@@ -585,6 +664,9 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def switch_services(llm_id, asr_id, tts_id):
|
||||
service_switches.append((llm_id, asr_id, tts_id))
|
||||
|
||||
async def apply_turn_config(enable_interrupt, turn_config):
|
||||
turn_configs.append((enable_interrupt, turn_config))
|
||||
|
||||
runtime = BrainRuntime(
|
||||
context=context,
|
||||
llm=llm,
|
||||
@@ -596,15 +678,27 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
context_aggregator=pair,
|
||||
switch_services=switch_services,
|
||||
set_knowledge_scope=knowledge_scopes.append,
|
||||
apply_turn_config=apply_turn_config,
|
||||
)
|
||||
await brain.setup(cfg, runtime)
|
||||
await brain.on_connected()
|
||||
self.assertEqual(brain._manager.current_node, "agent")
|
||||
variable_events = [
|
||||
frame.message
|
||||
for frame in queued
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "workflow-variables"
|
||||
]
|
||||
self.assertEqual(variable_events[0]["reason"], "initialized")
|
||||
self.assertEqual(variable_events[0]["variables"], {"user_name": "王先生"})
|
||||
self.assertNotIn("system__conversation_id", variable_events[0]["variables"])
|
||||
self.assertEqual(
|
||||
service_switches,
|
||||
[("llm_global", "asr_global", "tts_global")],
|
||||
)
|
||||
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_global")
|
||||
self.assertEqual(turn_configs[-1][0], False)
|
||||
self.assertEqual(turn_configs[-1][1]["vad"]["confidence"], 0.55)
|
||||
|
||||
brain._engine.data("agent").update(
|
||||
{
|
||||
@@ -614,6 +708,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
"ttsResourceId": "tts_agent",
|
||||
"knowledgeBaseId": "kb_agent",
|
||||
"knowledgeMode": "on_demand",
|
||||
"enableInterrupt": True,
|
||||
"turnConfig": {
|
||||
"bargeIn": {"strategy": "vad"},
|
||||
"turnDetection": {"strategy": "smart_turn"},
|
||||
},
|
||||
}
|
||||
)
|
||||
await brain._apply_agent_stage("agent")
|
||||
@@ -622,6 +721,11 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
("llm_agent", "asr_agent", "tts_agent"),
|
||||
)
|
||||
self.assertEqual(knowledge_scopes[-1]["knowledge_base_id"], "kb_agent")
|
||||
self.assertEqual(turn_configs[-1][0], True)
|
||||
self.assertEqual(
|
||||
turn_configs[-1][1]["turnDetection"]["strategy"],
|
||||
"smart_turn",
|
||||
)
|
||||
agent_config = brain._agent_config("agent")
|
||||
self.assertIn("王先生", agent_config["role_message"])
|
||||
self.assertIn("工作流路由已在用户一轮输入结束时完成", agent_config["role_message"])
|
||||
@@ -654,11 +758,30 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
fixed_config["task_messages"],
|
||||
[{"role": "assistant", "content": "您好,王先生"}],
|
||||
)
|
||||
self.assertEqual(fixed_config["pre_actions"][0]["node_id"], "agent")
|
||||
worker.frames.clear()
|
||||
queued.clear()
|
||||
await brain._manager.set_node_from_config(fixed_config)
|
||||
self.assertTrue(any(isinstance(frame, TTSSpeakFrame) for frame in queued))
|
||||
self.assertFalse(any(isinstance(frame, LLMRunFrame) for frame in worker.frames))
|
||||
context_updates = [
|
||||
frame
|
||||
for frame in worker.frames
|
||||
if isinstance(frame, LLMMessagesUpdateFrame)
|
||||
]
|
||||
self.assertEqual(
|
||||
context_updates[-1].messages,
|
||||
[{"role": "assistant", "content": "您好,王先生"}],
|
||||
)
|
||||
fixed_reply_events = [
|
||||
frame.message
|
||||
for frame in queued
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("source") == "workflow-fixed-reply"
|
||||
]
|
||||
self.assertEqual(fixed_reply_events[0]["content"], "您好,王先生")
|
||||
self.assertEqual(fixed_reply_events[0]["nodeId"], "agent")
|
||||
self.assertIn("您好,王先生", brain._store.values["system__conversation_history"])
|
||||
|
||||
self.assertFalse(
|
||||
any(
|
||||
|
||||
37
backend/tests/test_conversation_history.py
Normal file
37
backend/tests/test_conversation_history.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from services.conversation_history import ConversationRecorder
|
||||
|
||||
|
||||
class ConversationRecorderTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_fixed_reply_transcript_keeps_workflow_metadata(self):
|
||||
recorder = ConversationRecorder("conv_test")
|
||||
recorder._append = AsyncMock()
|
||||
|
||||
await recorder.record_transport_message(
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": "assistant",
|
||||
"content": "请稍等,我正在处理。",
|
||||
"timestamp": "2026-07-14T10:00:00+08:00",
|
||||
"source": "workflow-fixed-reply",
|
||||
"nodeId": "agent_service",
|
||||
}
|
||||
)
|
||||
|
||||
recorder._append.assert_awaited_once_with(
|
||||
"assistant",
|
||||
"请稍等,我正在处理。",
|
||||
"2026-07-14T10:00:00+08:00",
|
||||
{
|
||||
"source": "workflow-fixed-reply",
|
||||
"node_id": "agent_service",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -79,6 +79,58 @@ class DynamicVariableTests(unittest.TestCase):
|
||||
"Bearer private",
|
||||
)
|
||||
|
||||
def test_optional_workflow_variable_renders_empty_but_remains_unset(self):
|
||||
cfg = AssistantConfig(
|
||||
type="workflow",
|
||||
graph={
|
||||
"specVersion": 3,
|
||||
"settings": {"globalPrompt": "称呼 {{nickname}}"},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "start",
|
||||
"data": {"greeting": "您好 {{nickname}}"},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
},
|
||||
greeting="",
|
||||
dynamic_variable_definitions={
|
||||
"nickname": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"default": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
prepared = prepare_dynamic_config(cfg, {}, assistant_id="asst_workflow")
|
||||
store = DynamicVariableStore.from_config(prepared)
|
||||
|
||||
self.assertEqual(store.render("您好 {{nickname}}"), "您好 ")
|
||||
self.assertNotIn("nickname", store.values)
|
||||
with self.assertRaisesRegex(DynamicVariableError, "缺少动态变量"):
|
||||
store.render("{{not_defined}}")
|
||||
|
||||
def test_exact_placeholder_keeps_action_argument_type(self):
|
||||
store = DynamicVariableStore(
|
||||
{"count": 3, "confirmed": True},
|
||||
variable_types={"count": "number", "confirmed": "boolean"},
|
||||
)
|
||||
|
||||
rendered = store.render_data(
|
||||
{
|
||||
"count": "{{count}}",
|
||||
"confirmed": "{{confirmed}}",
|
||||
"label": "数量 {{count}}",
|
||||
}
|
||||
)
|
||||
self.assertEqual(rendered["count"], 3)
|
||||
self.assertIs(rendered["confirmed"], True)
|
||||
self.assertEqual(rendered["label"], "数量 3")
|
||||
|
||||
with self.assertRaisesRegex(DynamicVariableError, "类型应为 number"):
|
||||
store.assign("count", "four")
|
||||
|
||||
def test_tool_assignment_and_system_history(self):
|
||||
store = DynamicVariableStore(
|
||||
{
|
||||
|
||||
@@ -170,6 +170,11 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
"knowledgeMode": "on_demand",
|
||||
"knowledgeTopN": 8,
|
||||
"knowledgeScoreThreshold": 0.4,
|
||||
"enableInterrupt": False,
|
||||
"turnConfig": {
|
||||
"bargeIn": {"strategy": "transcription"},
|
||||
"turnDetection": {"strategy": "silence", "silenceTimeoutSecs": 1.2},
|
||||
},
|
||||
}
|
||||
)
|
||||
engine = WorkflowEngine(graph)
|
||||
@@ -177,6 +182,11 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
self.assertEqual(inherited.llm_resource_id, "llm_global")
|
||||
self.assertEqual(inherited.tool_ids, ("tool_global",))
|
||||
self.assertEqual(inherited.knowledge_mode, "on_demand")
|
||||
self.assertFalse(inherited.enable_interrupt)
|
||||
self.assertEqual(
|
||||
inherited.turn_config["bargeIn"]["strategy"],
|
||||
"transcription",
|
||||
)
|
||||
|
||||
engine.data("agent").update(
|
||||
{
|
||||
@@ -184,12 +194,22 @@ class WorkflowGraphTests(unittest.TestCase):
|
||||
"llmResourceId": "llm_agent",
|
||||
"toolIds": ["tool_agent"],
|
||||
"knowledgeBaseId": "",
|
||||
"enableInterrupt": True,
|
||||
"turnConfig": {
|
||||
"bargeIn": {"strategy": "vad"},
|
||||
"turnDetection": {"strategy": "smart_turn"},
|
||||
},
|
||||
}
|
||||
)
|
||||
custom = engine.agent_stage_config("agent")
|
||||
self.assertEqual(custom.llm_resource_id, "llm_agent")
|
||||
self.assertEqual(custom.tool_ids, ("tool_agent",))
|
||||
self.assertEqual(custom.knowledge_mode, "disabled")
|
||||
self.assertTrue(custom.enable_interrupt)
|
||||
self.assertEqual(
|
||||
custom.turn_config["turnDetection"]["strategy"],
|
||||
"smart_turn",
|
||||
)
|
||||
|
||||
def test_start_agent_and_handoff_may_have_no_outgoing_edge(self):
|
||||
terminal_graphs = [
|
||||
|
||||
Reference in New Issue
Block a user