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:
@@ -158,6 +158,27 @@ docker compose --profile remote up -d
|
||||
当前后台处理使用 FastAPI 进程内任务,适合 MVP。服务重启时未完成任务会转为失败,
|
||||
可在页面重试;需要多实例或高吞吐时再迁移到 Redis/ARQ worker。
|
||||
|
||||
## Prompt 动态变量
|
||||
|
||||
提示词助手的 Pipeline 与 Realtime 模式都支持在开场白和系统提示词中使用
|
||||
`{{ variable_name }}`。Pipeline 模式还支持在 HTTP 工具的 URL、Header 和 Body
|
||||
中引用变量。助手页维护变量类型、默认值和必填规则;调试面板在每次通话开始前
|
||||
传入会话值。Realtime 会把渲染后的提示词作为模型 instructions。
|
||||
|
||||
内置系统变量包括 `system__conversation_id`、`system__time`、
|
||||
`system__agent_turns` 和 `system__conversation_history`。客户端不能提交
|
||||
`system__*` 或 `secret__*`。HTTP 工具的密钥变量必须在工具的服务端密钥配置中
|
||||
以 `secret__` 开头声明,而且只能用于 Header。
|
||||
|
||||
HTTP 工具可通过“响应变量赋值”把 JSON 路径写回普通会话变量,例如:
|
||||
|
||||
```json
|
||||
{"order_status": "response.order.status"}
|
||||
```
|
||||
|
||||
后续轮次会使用新值重新渲染系统提示词。上线前执行 `make db-migrate`,应用
|
||||
`20260712_0007` 迁移。
|
||||
|
||||
## 待联调 / TODO
|
||||
|
||||
- [ ] 联调 Pipecat 1.3.0 语音链路与各 OpenAI 兼容服务
|
||||
|
||||
@@ -171,6 +171,7 @@ class Assistant(Base):
|
||||
|
||||
# ---- 瘦类型专属字段(真列,稀疏:按 type 用其中几列) ----
|
||||
prompt: Mapped[str] = mapped_column(String(8192), default="") # prompt / opencode
|
||||
dynamic_variable_definitions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
api_url: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode
|
||||
api_key: Mapped[str] = mapped_column(String(512), default="") # dify / fastgpt / opencode(打码/哨兵)
|
||||
app_id: Mapped[str] = mapped_column(String(128), default="") # fastgpt
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""add assistant dynamic variable definitions
|
||||
|
||||
Revision ID: 20260712_0007
|
||||
Revises: 20260712_0006
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "20260712_0007"
|
||||
down_revision: str | Sequence[str] | None = "20260712_0006"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"assistants",
|
||||
sa.Column(
|
||||
"dynamic_variable_definitions",
|
||||
sa.JSON(),
|
||||
server_default=sa.text("'{}'"),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("assistants", "dynamic_variable_definitions")
|
||||
@@ -24,6 +24,7 @@ class RuntimeTool(BaseModel):
|
||||
type: str
|
||||
description: str = ""
|
||||
definition: dict = Field(default_factory=dict)
|
||||
secrets: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AssistantConfig(BaseModel):
|
||||
@@ -34,6 +35,10 @@ class AssistantConfig(BaseModel):
|
||||
type: str = "prompt"
|
||||
greeting: str = "您好,我是 AI 视频助手,请问有什么可以帮您?"
|
||||
prompt: str = "你是一个有帮助的助手。"
|
||||
dynamic_variable_definitions: dict = Field(default_factory=dict)
|
||||
dynamic_variables: dict = Field(default_factory=dict)
|
||||
secret_dynamic_variables: dict = Field(default_factory=dict)
|
||||
conversation_id: str = ""
|
||||
runtimeMode: RuntimeMode = "pipeline"
|
||||
|
||||
# 模型/音色选项
|
||||
@@ -121,3 +126,4 @@ class SignalingOffer(BaseModel):
|
||||
assistant_id: str | None = None
|
||||
inline_config: AssistantConfig | None = None
|
||||
vision_enabled: bool = False
|
||||
dynamic_variables: dict = Field(default_factory=dict)
|
||||
|
||||
@@ -154,6 +154,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
||||
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
|
||||
tool_ids=await _tool_ids(session, assistant.id),
|
||||
prompt=assistant.prompt,
|
||||
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
||||
api_url=assistant.api_url,
|
||||
api_key=mask(assistant.api_key),
|
||||
app_id=assistant.app_id,
|
||||
@@ -220,6 +221,7 @@ async def duplicate_assistant(
|
||||
knowledge_base_id=source.knowledge_base_id,
|
||||
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
|
||||
prompt=source.prompt,
|
||||
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
|
||||
api_url=source.api_url,
|
||||
api_key=source.api_key,
|
||||
app_id=source.app_id,
|
||||
|
||||
@@ -16,6 +16,7 @@ from loguru import logger
|
||||
from models import AssistantConfig, SignalingOffer
|
||||
from services.auth import require_admin, require_admin_websocket
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from services.runtime_variables import DynamicVariableError, prepare_dynamic_config
|
||||
from starlette.websockets import WebSocketDisconnect, WebSocketState
|
||||
|
||||
from services.webrtc_ice import aiortc_ice_servers, client_ice_servers
|
||||
@@ -52,7 +53,11 @@ async def voice_signaling(websocket: WebSocket):
|
||||
{
|
||||
"type": "error",
|
||||
"payload": {
|
||||
"message": f"语音会话启动失败: {type(e).__name__}"
|
||||
"message": (
|
||||
str(e)
|
||||
if isinstance(e, DynamicVariableError)
|
||||
else f"语音会话启动失败: {type(e).__name__}"
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -71,9 +76,18 @@ async def _resolve_config(offer: SignalingOffer) -> AssistantConfig:
|
||||
"""优先用 assistant_id 从 DB 解析(含真 key);否则用调试内联配置。"""
|
||||
if offer.assistant_id:
|
||||
async with SessionLocal() as session:
|
||||
return await resolve_runtime_config(session, offer.assistant_id)
|
||||
cfg = await resolve_runtime_config(session, offer.assistant_id)
|
||||
return prepare_dynamic_config(
|
||||
cfg,
|
||||
offer.dynamic_variables,
|
||||
assistant_id=offer.assistant_id,
|
||||
)
|
||||
if offer.inline_config:
|
||||
return offer.inline_config
|
||||
return prepare_dynamic_config(
|
||||
offer.inline_config,
|
||||
offer.dynamic_variables,
|
||||
assistant_id=None,
|
||||
)
|
||||
raise ValueError("offer 缺少 assistant_id 或 inline_config")
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from loguru import logger
|
||||
from models import AssistantConfig
|
||||
from services.auth import require_admin_websocket
|
||||
from services.config_resolver import resolve_runtime_config
|
||||
from services.runtime_variables import prepare_dynamic_config
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
# pipecat 重依赖,惰性导入(见 voice_webrtc.py 说明)
|
||||
@@ -28,12 +29,24 @@ async def _resolve_start_config(raw: str) -> tuple[AssistantConfig, str | None]:
|
||||
data = json.loads(raw)
|
||||
if data.get("assistant_id"):
|
||||
async with SessionLocal() as session:
|
||||
cfg = await resolve_runtime_config(session, data["assistant_id"])
|
||||
return (
|
||||
await resolve_runtime_config(session, data["assistant_id"]),
|
||||
prepare_dynamic_config(
|
||||
cfg,
|
||||
data.get("dynamic_variables"),
|
||||
assistant_id=data["assistant_id"],
|
||||
),
|
||||
data["assistant_id"],
|
||||
)
|
||||
if data.get("inline_config"):
|
||||
return AssistantConfig(**data["inline_config"]), None
|
||||
return (
|
||||
prepare_dynamic_config(
|
||||
AssistantConfig(**data["inline_config"]),
|
||||
data.get("dynamic_variables"),
|
||||
assistant_id=None,
|
||||
),
|
||||
None,
|
||||
)
|
||||
raise ValueError("启动参数缺少 assistant_id 或 inline_config")
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ JSON 用 camelCase(modelId/interfaceType/apiUrl/apiKey),Python 内部用 snake_c
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
from typing import Annotated, Any, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
@@ -22,6 +23,7 @@ ToolType = Literal["end_call", "http"]
|
||||
ToolStatus = Literal["active", "archived", "draft"]
|
||||
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
|
||||
ToolParameterLocation = Literal["path", "query", "body", "header"]
|
||||
DynamicVariableType = Literal["string", "number", "boolean"]
|
||||
|
||||
# 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵
|
||||
EXTERNAL_TYPES = {"dify", "fastgpt", "opencode"}
|
||||
@@ -106,12 +108,28 @@ class AssistantUpsert(CamelModel):
|
||||
|
||||
# 瘦类型专属(真列);按 type 取用,无关字段写入时清零
|
||||
prompt: str = ""
|
||||
dynamic_variable_definitions: dict[str, "DynamicVariableDefinition"] = Field(
|
||||
default_factory=dict
|
||||
)
|
||||
api_url: str = ""
|
||||
api_key: str = "" # 写时:占位符/空 → 保留旧(哨兵)
|
||||
app_id: str = ""
|
||||
# workflow 专属:图
|
||||
graph: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("dynamic_variable_definitions")
|
||||
@classmethod
|
||||
def validate_dynamic_variable_names(cls, value):
|
||||
if len(value) > 50:
|
||||
raise ValueError("动态变量最多允许 50 个")
|
||||
for name in value:
|
||||
if (
|
||||
not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", name)
|
||||
or name.startswith(("system__", "secret__"))
|
||||
):
|
||||
raise ValueError(f"动态变量名称不合法: {name}")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _strip_irrelevant_fields(self):
|
||||
allowed = ALLOWED_FIELDS[self.type]
|
||||
@@ -122,6 +140,7 @@ class AssistantUpsert(CamelModel):
|
||||
self.graph = {}
|
||||
if self.type != "prompt":
|
||||
self.tool_ids = []
|
||||
self.dynamic_variable_definitions = {}
|
||||
# 外部托管大脑只能 cascade,拦住不兼容的 realtime
|
||||
if self.runtime_mode == "realtime" and self.type not in REALTIME_CAPABLE_TYPES:
|
||||
raise ValueError(f"类型 {self.type} 不支持 realtime 运行模式")
|
||||
@@ -133,6 +152,29 @@ class AssistantOut(AssistantUpsert):
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class DynamicVariableDefinition(CamelModel):
|
||||
type: DynamicVariableType = "string"
|
||||
required: bool = False
|
||||
default: str | int | float | bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_default_type(self):
|
||||
if self.default is None:
|
||||
return self
|
||||
valid = (
|
||||
(self.type == "string" and isinstance(self.default, str))
|
||||
or (
|
||||
self.type == "number"
|
||||
and isinstance(self.default, (int, float))
|
||||
and not isinstance(self.default, bool)
|
||||
)
|
||||
or (self.type == "boolean" and isinstance(self.default, bool))
|
||||
)
|
||||
if not valid:
|
||||
raise ValueError(f"默认值类型应为 {self.type}")
|
||||
return self
|
||||
|
||||
|
||||
# ---------- 可复用工具 ----------
|
||||
class ToolParameter(CamelModel):
|
||||
name: str
|
||||
@@ -155,6 +197,7 @@ class HttpToolConfig(CamelModel):
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
parameters: list[ToolParameter] = Field(default_factory=list)
|
||||
body: dict[str, Any] = Field(default_factory=dict)
|
||||
dynamic_variable_assignments: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
233
backend/services/runtime_variables.py
Normal file
233
backend/services/runtime_variables.py
Normal 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
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from models import AssistantConfig, RuntimeTool
|
||||
from pipecat.frames.frames import (
|
||||
@@ -20,6 +21,7 @@ from services.brains.dify_llm import (
|
||||
normalize_api_base,
|
||||
)
|
||||
from services.brains.workflow_brain import WorkflowBrain
|
||||
from services.runtime_variables import prepare_dynamic_config
|
||||
|
||||
|
||||
class FakeLLM:
|
||||
@@ -94,6 +96,21 @@ class BrainRegistryTests(unittest.TestCase):
|
||||
runtimeMode="realtime",
|
||||
)
|
||||
|
||||
def test_prompt_realtime_keeps_dynamic_variable_definitions(self):
|
||||
assistant = AssistantUpsert(
|
||||
name="realtime prompt",
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
dynamicVariableDefinitions={
|
||||
"user_name": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"default": None,
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertIn("user_name", assistant.dynamic_variable_definitions)
|
||||
|
||||
|
||||
class DifyHelpersTests(unittest.TestCase):
|
||||
def test_normalize_api_base(self):
|
||||
@@ -176,6 +193,24 @@ class DifyLLMServiceTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_realtime_prompt_brain_renders_dynamic_variables(self):
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
prompt="服务用户 {{user_name}}",
|
||||
greeting="您好,{{user_name}}",
|
||||
dynamic_variable_definitions={
|
||||
"user_name": {"type": "string", "required": True}
|
||||
},
|
||||
),
|
||||
{"user_name": "王先生"},
|
||||
assistant_id="asst_realtime",
|
||||
)
|
||||
brain = build_brain(cfg)
|
||||
self.assertEqual(brain.system_prompt(cfg), "服务用户 王先生")
|
||||
self.assertEqual(await brain.greeting(cfg), "您好,王先生")
|
||||
|
||||
async def test_end_call_tool_is_owned_by_prompt_brain(self):
|
||||
brain = build_brain(
|
||||
AssistantConfig(
|
||||
@@ -233,6 +268,111 @@ class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertTrue(call_end.finished)
|
||||
self.assertEqual(params.result["action"], "ending_call")
|
||||
|
||||
async def test_http_tool_renders_secrets_and_updates_prompt_variable(self):
|
||||
requests = []
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
content = b'{"order":{"status":"paid"}}'
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return {"order": {"status": "paid"}}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_args):
|
||||
return None
|
||||
|
||||
async def request(self, method, url, **kwargs):
|
||||
requests.append((method, url, kwargs))
|
||||
return FakeResponse()
|
||||
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="pipeline",
|
||||
prompt="订单状态:{{order_status}}",
|
||||
dynamic_variable_definitions={
|
||||
"order_status": {"type": "string", "default": "unknown"}
|
||||
},
|
||||
tools=[
|
||||
RuntimeTool(
|
||||
id="lookup",
|
||||
name="查询订单",
|
||||
function_name="lookup_order",
|
||||
type="http",
|
||||
description="查询订单状态",
|
||||
definition={
|
||||
"config": {
|
||||
"method": "GET",
|
||||
"url": "https://example.test/orders/{order_id}",
|
||||
"headers": {"Authorization": "Bearer {{secret__token}}"},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "order_id",
|
||||
"type": "string",
|
||||
"location": "path",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"type": "string",
|
||||
"location": "header",
|
||||
"required": False,
|
||||
},
|
||||
],
|
||||
"dynamic_variable_assignments": {
|
||||
"order_status": "response.order.status"
|
||||
},
|
||||
}
|
||||
},
|
||||
secrets={"dynamic_variables": {"secret__token": "server-token"}},
|
||||
)
|
||||
],
|
||||
),
|
||||
{},
|
||||
assistant_id="asst_1",
|
||||
)
|
||||
brain = build_brain(cfg)
|
||||
llm = FakeLLM()
|
||||
prompts = []
|
||||
visible_tools = []
|
||||
|
||||
async def queue_frame(_frame):
|
||||
pass
|
||||
|
||||
await brain.setup(
|
||||
cfg,
|
||||
BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=llm,
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=prompts.append,
|
||||
set_tools=lambda tools: visible_tools.extend(tools or []),
|
||||
call_end=FakeCallEnd(),
|
||||
),
|
||||
)
|
||||
params = FakeFunctionParams(
|
||||
{"order_id": "A/1", "Authorization": "attacker-value"}
|
||||
)
|
||||
with patch("services.brains.prompt_brain.httpx.AsyncClient", FakeClient):
|
||||
await llm.functions["lookup_order"](params)
|
||||
|
||||
self.assertEqual(requests[0][1], "https://example.test/orders/A%2F1")
|
||||
self.assertEqual(
|
||||
requests[0][2]["headers"]["Authorization"], "Bearer server-token"
|
||||
)
|
||||
self.assertEqual(params.result["updated_variables"], ["order_status"])
|
||||
self.assertEqual(prompts[-1], "订单状态:paid")
|
||||
|
||||
|
||||
class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_transition_and_end_are_owned_by_workflow_brain(self):
|
||||
|
||||
99
backend/tests/test_runtime_variables.py
Normal file
99
backend/tests/test_runtime_variables.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from models import AssistantConfig
|
||||
from services.runtime_variables import (
|
||||
DynamicVariableError,
|
||||
DynamicVariableStore,
|
||||
prepare_dynamic_config,
|
||||
value_at_path,
|
||||
)
|
||||
|
||||
|
||||
class DynamicVariableTests(unittest.TestCase):
|
||||
def test_defaults_client_values_and_system_values_are_merged(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="pipeline",
|
||||
prompt="服务 {{user_name}} / {{tier}} / {{system__conversation_id}}",
|
||||
greeting="您好 {{user_name}}",
|
||||
dynamic_variable_definitions={
|
||||
"user_name": {"type": "string", "required": True},
|
||||
"tier": {"type": "string", "default": "普通"},
|
||||
},
|
||||
)
|
||||
prepared = prepare_dynamic_config(
|
||||
cfg, {"user_name": "王先生"}, assistant_id="asst_1"
|
||||
)
|
||||
store = DynamicVariableStore.from_config(prepared)
|
||||
self.assertEqual(store.render(prepared.greeting), "您好 王先生")
|
||||
self.assertIn("普通", store.render(prepared.prompt))
|
||||
self.assertTrue(prepared.conversation_id.startswith("conv_"))
|
||||
self.assertEqual(
|
||||
prepared.dynamic_variables["system__conversation_id"],
|
||||
prepared.conversation_id,
|
||||
)
|
||||
|
||||
def test_realtime_prompt_supports_dynamic_variables(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
prompt="请称呼用户为 {{user_name}}",
|
||||
greeting="您好,{{user_name}}",
|
||||
dynamic_variable_definitions={
|
||||
"user_name": {"type": "string", "required": True}
|
||||
},
|
||||
)
|
||||
prepared = prepare_dynamic_config(
|
||||
cfg, {"user_name": "王先生"}, assistant_id="asst_realtime"
|
||||
)
|
||||
store = DynamicVariableStore.from_config(prepared)
|
||||
self.assertEqual(store.render(prepared.greeting), "您好,王先生")
|
||||
self.assertEqual(store.render(prepared.prompt), "请称呼用户为 王先生")
|
||||
|
||||
def test_client_cannot_set_reserved_or_wrong_typed_values(self):
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
runtimeMode="pipeline",
|
||||
dynamic_variable_definitions={
|
||||
"count": {"type": "number", "required": True}
|
||||
},
|
||||
)
|
||||
with self.assertRaisesRegex(DynamicVariableError, "保留变量"):
|
||||
prepare_dynamic_config(
|
||||
cfg, {"system__agent_turns": 99}, assistant_id="asst_1"
|
||||
)
|
||||
with self.assertRaisesRegex(DynamicVariableError, "类型"):
|
||||
prepare_dynamic_config(cfg, {"count": "1"}, assistant_id="asst_1")
|
||||
|
||||
def test_secret_is_header_only_and_expansion_is_single_pass(self):
|
||||
store = DynamicVariableStore(
|
||||
{"name": "{{secret__token}}"}, {"secret__token": "private"}
|
||||
)
|
||||
self.assertEqual(store.render("{{name}}"), "{{secret__token}}")
|
||||
with self.assertRaisesRegex(DynamicVariableError, "只能用于 HTTP Header"):
|
||||
store.render("{{secret__token}}")
|
||||
self.assertEqual(
|
||||
store.render("Bearer {{secret__token}}", allow_secrets=True),
|
||||
"Bearer private",
|
||||
)
|
||||
|
||||
def test_tool_assignment_and_system_history(self):
|
||||
store = DynamicVariableStore(
|
||||
{
|
||||
"order_status": "unknown",
|
||||
"system__agent_turns": 0,
|
||||
"system__conversation_history": "",
|
||||
}
|
||||
)
|
||||
store.assign("order_status", value_at_path({"order": {"status": "paid"}}, "order.status"))
|
||||
store.record("user", "查订单")
|
||||
store.record("agent", "已付款", completed_agent_turn=True)
|
||||
self.assertEqual(store.values["order_status"], "paid")
|
||||
self.assertEqual(store.values["system__agent_turns"], 1)
|
||||
self.assertIn("查订单", store.values["system__conversation_history"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user