Add dynamic variable support to Assistant model and related components

- Introduce dynamic variable definitions in AssistantConfig and Assistant models, allowing for flexible prompt customization.
- Implement validation for dynamic variable names and types in the schema.
- Update backend services and routes to handle dynamic variables in assistant configurations and runtime processing.
- Enhance frontend components to support dynamic variable definitions, including a new editor for managing variables.
- Add tests to ensure proper functionality and validation of dynamic variables in various scenarios.
This commit is contained in:
Xin Wang
2026-07-12 23:42:56 +08:00
parent 7c9a18c806
commit deaf3d7730
21 changed files with 1396 additions and 12 deletions

View File

@@ -2,8 +2,11 @@
from __future__ import annotations
from copy import deepcopy
from urllib.parse import quote
from uuid import uuid4
import httpx
from models import AssistantConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
@@ -16,6 +19,11 @@ 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 (
DynamicVariableError,
DynamicVariableStore,
value_at_path,
)
class PromptBrain(BaseBrain):
@@ -25,21 +33,184 @@ class PromptBrain(BaseBrain):
owns_context=True,
)
def __init__(self, cfg: AssistantConfig):
self._cfg = cfg
self._dynamic_enabled = True
self._store = DynamicVariableStore.from_config(cfg)
self._runtime: BrainRuntime | None = None
async def greeting(self, cfg: AssistantConfig) -> str:
return self._store.render(cfg.greeting) if self._dynamic_enabled else cfg.greeting
def system_prompt(self, cfg: AssistantConfig) -> str:
return self._store.render(cfg.prompt) if self._dynamic_enabled else cfg.prompt
def build_llm(self, cfg: AssistantConfig, context: LLMContext) -> FrameProcessor:
from services.pipecat.service_factory import create_llm
return create_llm(cfg)
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
self._runtime = runtime
schemas: list[FunctionSchema] = []
for tool in cfg.tools:
if tool.type != "end_call":
if tool.type == "end_call":
schema, handler = self._make_end_call_tool(tool, runtime)
elif tool.type == "http":
schema, handler = self._make_http_tool(tool, runtime)
else:
continue
schema, handler = self._make_end_call_tool(tool, runtime)
schemas.append(schema)
runtime.llm.register_function(tool.function_name, handler)
runtime.set_tools(schemas)
def record_user_message(self, content: str) -> None:
if not self._dynamic_enabled:
return
self._store.record("user", content)
self._refresh_prompt()
async def on_assistant_text_end(
self,
_turn_id: str,
content: str,
interrupted: bool,
) -> None:
if content and not interrupted:
self._store.record("agent", content, completed_agent_turn=True)
self._refresh_prompt()
def _refresh_prompt(self) -> None:
if self._dynamic_enabled and self._runtime is not None:
self._runtime.set_system_prompt(self._store.render(self._cfg.prompt))
def _make_http_tool(self, tool, runtime: BrainRuntime):
config = (tool.definition or {}).get("config") or {}
parameters = list(config.get("parameters") or [])
properties = {
str(parameter.get("name")): {
"type": str(parameter.get("type") or "string"),
"description": str(parameter.get("description") or ""),
}
for parameter in parameters
if parameter.get("name")
}
required = [
str(parameter["name"])
for parameter in parameters
if parameter.get("name") and parameter.get("required", True)
]
tool_secrets = tool.secrets or {}
dynamic_secrets = tool_secrets.get("dynamic_variables") or {}
for name, value in dynamic_secrets.items():
if not str(name).startswith("secret__"):
raise DynamicVariableError(f"工具密钥变量必须以 secret__ 开头: {name}")
self._store.secrets[str(name)] = str(value)
async def call_http(params: FunctionCallParams) -> None:
arguments = params.arguments or {}
url = self._store.render(str(config.get("url") or ""))
configured_headers = self._store.render_data(
deepcopy(config.get("headers") or {}), allow_secrets=True
)
secret_headers = self._store.render_data(
deepcopy(tool_secrets.get("headers") or {}), allow_secrets=True
)
headers: dict[str, str] = {}
query: dict[str, object] = {}
body = self._store.render_data(deepcopy(config.get("body") or {}))
for parameter in parameters:
name = str(parameter.get("name") or "")
if not name or name not in arguments:
continue
value = arguments[name]
location = str(parameter.get("location") or "body")
if location == "path":
encoded = quote(str(value), safe="")
url = url.replace(f"{{{name}}}", encoded)
elif location == "query":
query[name] = value
elif location == "header":
headers[name] = str(value)
else:
body[name] = value
# Admin-configured headers win over model-provided arguments; secret
# headers are applied last so an LLM can never replace credentials.
headers.update(
{str(key): str(value) for key, value in configured_headers.items()}
)
headers.update({str(key): str(value) for key, value in secret_headers.items()})
try:
async with httpx.AsyncClient(
timeout=float(config.get("timeout_seconds") or 15),
follow_redirects=False,
) as client:
response = await client.request(
str(config.get("method") or "GET"),
url,
headers=headers,
params=query,
json=body if body else None,
)
response.raise_for_status()
if len(response.content) > 1_000_000:
raise DynamicVariableError("HTTP 工具响应超过 1 MB 限制")
try:
payload = response.json()
except ValueError:
payload = {"text": response.text[:8000]}
updated: list[str] = []
assignments = config.get("dynamic_variable_assignments") or {}
for variable_name, path in assignments.items():
try:
value = value_at_path(payload, str(path))
except KeyError:
try:
value = value_at_path({"response": payload}, str(path))
except KeyError:
continue
self._store.assign(str(variable_name), value)
updated.append(str(variable_name))
if updated:
self._refresh_prompt()
await params.result_callback(
{
"status": "ok",
"status_code": response.status_code,
"data": payload,
"updated_variables": updated,
}
)
except httpx.TimeoutException:
await params.result_callback(
{"status": "error", "message": "HTTP 工具调用超时"}
)
except httpx.HTTPStatusError as exc:
await params.result_callback(
{
"status": "error",
"status_code": exc.response.status_code,
"message": "HTTP 工具返回错误状态",
}
)
except (httpx.RequestError, DynamicVariableError) as exc:
await params.result_callback(
{"status": "error", "message": f"HTTP 工具调用失败: {exc}"}
)
schema = FunctionSchema(
name=tool.function_name,
description=tool.description or f"调用 {tool.name}",
properties=properties,
required=required,
)
return schema, call_http
@staticmethod
def _make_end_call_tool(tool, runtime: BrainRuntime):
config = (tool.definition or {}).get("config") or {}

View File

@@ -18,7 +18,7 @@ def _workflow(cfg: AssistantConfig) -> Brain:
BRAIN_FACTORIES: dict[str, Callable[[AssistantConfig], Brain]] = {
"prompt": lambda _cfg: PromptBrain(),
"prompt": lambda cfg: PromptBrain(cfg),
"workflow": _workflow,
"dify": lambda _cfg: DifyBrain(),
"fastgpt": lambda _cfg: FastGPTBrain(),

View File

@@ -104,6 +104,7 @@ async def _tools_for(session: AsyncSession, assistant: Assistant) -> list[Runtim
type=tool.type,
description=tool.description,
definition=tool.definition or {},
secrets=tool.secrets or {},
)
for tool in tools
]
@@ -139,6 +140,7 @@ async def resolve_runtime_config(
greeting=assistant.greeting,
# prompt 现在是真列;外部类型由其平台编排,这里给个兜底
prompt=assistant.prompt or "你是一个有帮助的助手。",
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
runtimeMode=assistant.runtime_mode, # type: ignore[arg-type]
enableInterrupt=assistant.enable_interrupt,
turnConfig=assistant.turn_config or {},

View File

@@ -40,8 +40,9 @@ class ConversationRecorder:
assistant_name: str,
channel: str,
runtime_mode: str,
session_id: str | None = None,
) -> "ConversationRecorder | None":
session_id = f"conv_{uuid4().hex[:20]}"
session_id = session_id or f"conv_{uuid4().hex[:20]}"
try:
async with SessionLocal() as db:
db.add(

View File

@@ -308,6 +308,41 @@ class VisionCaptureProcessor(FrameProcessor):
await self.push_frame(frame, direction)
class RealtimeDynamicVariableProcessor(FrameProcessor):
"""Keep realtime system turn/history variables current between responses."""
def __init__(self, brain: Brain, cfg: AssistantConfig, realtime):
super().__init__()
self._brain = brain
self._cfg = cfg
self._realtime = realtime
async def _refresh_instructions(self) -> None:
update = getattr(self._realtime, "update_instructions", None)
if callable(update):
await update(self._brain.system_prompt(self._cfg))
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, OutputTransportMessageUrgentFrame):
message = frame.message
if isinstance(message, dict):
event_type = message.get("type")
if event_type == "transcript" and message.get("role") == "user":
content = str(message.get("content") or "").strip()
if content:
self._brain.record_user_message(content)
await self._refresh_instructions()
elif event_type == "assistant-text-end":
await self._brain.on_assistant_text_end(
str(message.get("turn_id") or ""),
str(message.get("content") or ""),
bool(message.get("interrupted", False)),
)
await self._refresh_instructions()
await self.push_frame(frame, direction)
class RealtimeTextInputProcessor(FrameProcessor):
"""Route text input directly to a realtime service without cascade semantics."""
@@ -709,6 +744,7 @@ async def run_pipeline(
assistant_name=cfg.name,
channel=channel,
runtime_mode=cfg.runtimeMode,
session_id=cfg.conversation_id or None,
)
pipeline = Pipeline(
[
@@ -911,6 +947,7 @@ async def run_realtime_pipeline(
instructions=brain.system_prompt(cfg),
)
text_input = RealtimeTextInputProcessor()
dynamic_variables = RealtimeDynamicVariableProcessor(brain, cfg, realtime)
greeting = await brain.greeting(cfg)
recorder = await ConversationRecorder.start(
@@ -918,12 +955,14 @@ async def run_realtime_pipeline(
assistant_name=cfg.name,
channel=channel,
runtime_mode=cfg.runtimeMode,
session_id=cfg.conversation_id or None,
)
pipeline = Pipeline(
[
transport.input(),
text_input,
realtime,
dynamic_variables,
ConversationHistoryProcessor(recorder),
transport.output(),
]

View File

@@ -289,6 +289,12 @@ class StepFunRealtimeService(AIService):
wait_until_ready=False,
)
async def update_instructions(self, instructions: str) -> None:
"""Refresh model instructions without rebuilding the realtime session."""
self._instructions = instructions
if self._session_ready.is_set():
await self._send_session_update()
async def _send_event(
self, payload: dict[str, Any], *, wait_until_ready: bool = True
) -> None:

View File

@@ -0,0 +1,233 @@
"""Conversation-scoped dynamic variables for prompt pipeline assistants.
The renderer is deliberately small: it only understands ``{{ name }}``
placeholders and never evaluates expressions. A value is substituted once,
so user input cannot introduce a second template expansion.
"""
from __future__ import annotations
import json
import re
from copy import deepcopy
from datetime import datetime
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
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*}}")
MAX_VARIABLES = 50
MAX_VALUE_LENGTH = 2048
MAX_HISTORY_ENTRIES = 50
MAX_HISTORY_JSON_LENGTH = 32_000
class DynamicVariableError(ValueError):
pass
def _primitive(value: Any, name: str) -> Primitive:
if not isinstance(value, (str, int, float, bool)) or value is None:
raise DynamicVariableError(f"动态变量 {name} 仅支持字符串、数字或布尔值")
if len(str(value)) > MAX_VALUE_LENGTH:
raise DynamicVariableError(f"动态变量 {name} 超过 {MAX_VALUE_LENGTH} 字符")
return value
def _validate_public(values: dict[str, Any] | None) -> dict[str, Primitive]:
values = values or {}
if len(values) > MAX_VARIABLES:
raise DynamicVariableError(f"动态变量最多允许 {MAX_VARIABLES}")
result: dict[str, Primitive] = {}
for name, value in values.items():
if not VARIABLE_NAME.fullmatch(name):
raise DynamicVariableError(f"动态变量名称不合法: {name}")
if name.startswith(("system__", "secret__")):
raise DynamicVariableError(f"客户端不能设置保留变量: {name}")
result[name] = _primitive(value, name)
return result
def _type_matches(value: Primitive, expected: str) -> bool:
if expected == "string":
return isinstance(value, str)
if expected == "boolean":
return isinstance(value, bool)
if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
return False
def _system_values(
*, assistant_id: str | None, conversation_id: str, timezone: str
) -> dict[str, Primitive]:
try:
zone = ZoneInfo(timezone)
except ZoneInfoNotFoundError as exc:
raise DynamicVariableError(f"无效时区: {timezone}") from exc
now = datetime.now(zone)
return {
"system__agent_id": assistant_id or "",
"system__conversation_id": conversation_id,
"system__time": now.strftime("%A, %H:%M %d %B %Y"),
"system__time_utc": now.astimezone(ZoneInfo("UTC")).isoformat(),
"system__timezone": timezone,
"system__agent_turns": 0,
"system__conversation_history": json.dumps(
{"entries": []}, ensure_ascii=False, separators=(",", ":")
),
}
class DynamicVariableStore:
"""Mutable state owned by exactly one conversation."""
def __init__(
self,
values: dict[str, Primitive],
secrets: dict[str, str] | None = None,
):
self.values = dict(values)
self.secrets = dict(secrets or {})
self.history: list[dict[str, str]] = []
@classmethod
def from_config(cls, cfg: AssistantConfig) -> "DynamicVariableStore":
return cls(cfg.dynamic_variables, cfg.secret_dynamic_variables)
def render(self, template: str, *, allow_secrets: bool = False) -> str:
if not template:
return template
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()
except ZoneInfoNotFoundError:
pass
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]
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
return PLACEHOLDER.sub(replace, template)
def render_data(self, value: Any, *, allow_secrets: bool = False) -> Any:
if isinstance(value, str):
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]
if isinstance(value, dict):
return {
key: self.render_data(item, allow_secrets=allow_secrets)
for key, item in value.items()
}
return deepcopy(value)
def record(self, role: str, content: str, *, completed_agent_turn: bool = False) -> None:
if content:
self.history.append({"role": role, "message": content})
self.history = self.history[-MAX_HISTORY_ENTRIES:]
if completed_agent_turn:
self.values["system__agent_turns"] = int(
self.values.get("system__agent_turns", 0)
) + 1
serialized = json.dumps(
{"entries": self.history}, ensure_ascii=False, separators=(",", ":")
)
while len(serialized) > MAX_HISTORY_JSON_LENGTH and len(self.history) > 1:
self.history.pop(0)
serialized = json.dumps(
{"entries": self.history},
ensure_ascii=False,
separators=(",", ":"),
)
self.values["system__conversation_history"] = serialized
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)
def prepare_dynamic_config(
cfg: AssistantConfig,
client_values: dict[str, Any] | None,
*,
assistant_id: str | None,
timezone: str = "Asia/Shanghai",
trusted_values: dict[str, Any] | None = None,
) -> AssistantConfig:
"""Validate and merge one call's values without mutating stored config."""
if cfg.type != "prompt":
if client_values:
raise DynamicVariableError("动态变量目前仅支持 prompt 助手")
return cfg
supplied = _validate_public(client_values)
# Server-side CRM/order/auth providers can pass trusted_values. They have
# precedence over browser input but still cannot claim reserved prefixes.
supplied.update(_validate_public(trusted_values))
definitions = cfg.dynamic_variable_definitions or {}
merged: dict[str, Primitive] = {}
for name, definition in definitions.items():
if name.startswith(("system__", "secret__")) or not VARIABLE_NAME.fullmatch(name):
raise DynamicVariableError(f"动态变量定义名称不合法: {name}")
if name in supplied:
value = supplied.pop(name)
elif "default" in definition and definition.get("default") is not None:
value = _primitive(definition["default"], name)
elif definition.get("required", False):
raise DynamicVariableError(f"缺少必填动态变量: {name}")
else:
continue
expected = str(definition.get("type") or "string")
if not _type_matches(value, expected):
raise DynamicVariableError(f"动态变量 {name} 类型应为 {expected}")
merged[name] = value
merged.update(supplied)
conversation_id = f"conv_{uuid4().hex[:20]}"
merged.update(
_system_values(
assistant_id=assistant_id,
conversation_id=conversation_id,
timezone=timezone,
)
)
prepared = cfg.model_copy(deep=True)
prepared.dynamic_variables = merged
prepared.conversation_id = conversation_id
# Validate prompt and greeting before media resources are allocated.
store = DynamicVariableStore.from_config(prepared)
store.render(prepared.prompt)
store.render(prepared.greeting)
return prepared
def value_at_path(payload: Any, path: str) -> Any:
current = payload
for part in path.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
elif isinstance(current, list) and part.isdigit() and int(part) < len(current):
current = current[int(part)]
else:
raise KeyError(path)
return current