Add dynamic variables support in session management and UI components. Implement validation rules for dynamic variables in metadata, including key format and value constraints. Enhance session start handling to manage dynamic variable errors. Update documentation and tests to reflect new functionality.
This commit is contained in:
@@ -28,6 +28,11 @@ from models.ws_v1 import (
|
||||
ToolCallResultsMessage,
|
||||
)
|
||||
|
||||
_DYNAMIC_VARIABLE_KEY_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]{0,63}$")
|
||||
_DYNAMIC_VARIABLE_PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")
|
||||
_DYNAMIC_VARIABLE_MAX_ITEMS = 30
|
||||
_DYNAMIC_VARIABLE_VALUE_MAX_CHARS = 1000
|
||||
|
||||
|
||||
class WsSessionState(str, Enum):
|
||||
"""Protocol state machine for WS sessions."""
|
||||
@@ -60,6 +65,7 @@ class Session:
|
||||
"knowledge",
|
||||
"knowledgeBaseId",
|
||||
"openerAudio",
|
||||
"dynamicVariables",
|
||||
"history",
|
||||
"userId",
|
||||
"assistantId",
|
||||
@@ -332,6 +338,16 @@ class Session:
|
||||
)
|
||||
metadata = self._merge_runtime_metadata(server_runtime, self._sanitize_untrusted_runtime_metadata(workflow_runtime))
|
||||
metadata = self._merge_runtime_metadata(metadata, client_runtime)
|
||||
metadata, dynamic_var_error = self._apply_dynamic_variables(metadata, raw_metadata)
|
||||
if dynamic_var_error:
|
||||
await self._send_error(
|
||||
"client",
|
||||
dynamic_var_error["message"],
|
||||
dynamic_var_error["code"],
|
||||
stage="protocol",
|
||||
retryable=False,
|
||||
)
|
||||
return
|
||||
|
||||
# Create history call record early so later turn callbacks can append transcripts.
|
||||
await self._start_history_bridge(metadata)
|
||||
@@ -798,6 +814,132 @@ class Session:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
def _extract_dynamic_template_keys(self, text: Any) -> List[str]:
|
||||
"""Collect placeholder keys from a template string."""
|
||||
if text is None:
|
||||
return []
|
||||
keys: List[str] = []
|
||||
seen = set()
|
||||
for match in _DYNAMIC_VARIABLE_PLACEHOLDER_RE.finditer(str(text)):
|
||||
key = str(match.group(1) or "")
|
||||
if key and key not in seen:
|
||||
seen.add(key)
|
||||
keys.append(key)
|
||||
return keys
|
||||
|
||||
def _render_dynamic_template(
|
||||
self,
|
||||
template: str,
|
||||
dynamic_vars: Dict[str, str],
|
||||
) -> tuple[str, List[str]]:
|
||||
"""Render one template text and return missing keys if any."""
|
||||
missing = set()
|
||||
|
||||
def _replace(match: re.Match) -> str:
|
||||
key = str(match.group(1) or "")
|
||||
if key not in dynamic_vars:
|
||||
missing.add(key)
|
||||
return match.group(0)
|
||||
return dynamic_vars[key]
|
||||
|
||||
rendered = _DYNAMIC_VARIABLE_PLACEHOLDER_RE.sub(_replace, str(template or ""))
|
||||
return rendered, sorted(missing)
|
||||
|
||||
def _apply_dynamic_variables(
|
||||
self,
|
||||
metadata: Dict[str, Any],
|
||||
client_metadata: Dict[str, Any],
|
||||
) -> tuple[Dict[str, Any], Optional[Dict[str, str]]]:
|
||||
"""
|
||||
Apply session.start metadata.dynamicVariables to prompt/greeting templates.
|
||||
|
||||
Returns:
|
||||
tuple(merged_metadata, error_payload)
|
||||
error_payload shape: {"code": "...", "message": "..."}
|
||||
"""
|
||||
merged = dict(metadata or {})
|
||||
raw_dynamic_vars = client_metadata.get("dynamicVariables")
|
||||
dynamic_vars: Dict[str, str] = {}
|
||||
|
||||
if raw_dynamic_vars is not None:
|
||||
if not isinstance(raw_dynamic_vars, dict):
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_invalid",
|
||||
"message": "metadata.dynamicVariables must be an object with string key/value pairs",
|
||||
}
|
||||
if len(raw_dynamic_vars) > _DYNAMIC_VARIABLE_MAX_ITEMS:
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_invalid",
|
||||
"message": f"metadata.dynamicVariables cannot exceed {_DYNAMIC_VARIABLE_MAX_ITEMS} entries",
|
||||
}
|
||||
|
||||
for raw_key, raw_value in raw_dynamic_vars.items():
|
||||
if not isinstance(raw_key, str):
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_invalid",
|
||||
"message": "metadata.dynamicVariables keys must be strings",
|
||||
}
|
||||
key = raw_key.strip()
|
||||
if not _DYNAMIC_VARIABLE_KEY_RE.match(key):
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_invalid",
|
||||
"message": (
|
||||
"Invalid dynamic variable key "
|
||||
f"'{raw_key}'. Expected ^[a-zA-Z_][a-zA-Z0-9_]{{0,63}}$"
|
||||
),
|
||||
}
|
||||
if key in dynamic_vars:
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_invalid",
|
||||
"message": f"Duplicate dynamic variable key '{key}'",
|
||||
}
|
||||
if not isinstance(raw_value, str):
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_invalid",
|
||||
"message": f"Dynamic variable '{key}' value must be a string",
|
||||
}
|
||||
if len(raw_value) > _DYNAMIC_VARIABLE_VALUE_MAX_CHARS:
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_invalid",
|
||||
"message": (
|
||||
f"Dynamic variable '{key}' exceeds "
|
||||
f"{_DYNAMIC_VARIABLE_VALUE_MAX_CHARS} characters"
|
||||
),
|
||||
}
|
||||
dynamic_vars[key] = raw_value
|
||||
|
||||
template_keys = set(self._extract_dynamic_template_keys(merged.get("systemPrompt")))
|
||||
template_keys.update(self._extract_dynamic_template_keys(merged.get("greeting")))
|
||||
if not template_keys:
|
||||
return merged, None
|
||||
if raw_dynamic_vars is None:
|
||||
missing = ", ".join(sorted(template_keys))
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_missing",
|
||||
"message": f"Missing dynamic variables for placeholders: {missing}",
|
||||
}
|
||||
|
||||
missing_keys = sorted([key for key in template_keys if key not in dynamic_vars])
|
||||
if missing_keys:
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_missing",
|
||||
"message": f"Missing dynamic variables for placeholders: {', '.join(missing_keys)}",
|
||||
}
|
||||
|
||||
for field in ("systemPrompt", "greeting"):
|
||||
value = merged.get(field)
|
||||
if value is None:
|
||||
continue
|
||||
rendered, unresolved = self._render_dynamic_template(str(value), dynamic_vars)
|
||||
if unresolved:
|
||||
return merged, {
|
||||
"code": "protocol.dynamic_variables_missing",
|
||||
"message": f"Missing dynamic variables for placeholders: {', '.join(unresolved)}",
|
||||
}
|
||||
merged[field] = rendered
|
||||
|
||||
return merged, None
|
||||
|
||||
async def _load_server_runtime_metadata(
|
||||
self,
|
||||
client_metadata: Dict[str, Any],
|
||||
|
||||
@@ -65,7 +65,11 @@ Rules:
|
||||
"mode": "audio"
|
||||
},
|
||||
"systemPrompt": "You are concise.",
|
||||
"greeting": "Hi, how can I help?"
|
||||
"greeting": "Hi, how can I help?",
|
||||
"dynamicVariables": {
|
||||
"customer_name": "Alice",
|
||||
"plan_tier": "Pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -74,6 +78,13 @@ Rules:
|
||||
- Client-side `metadata.services` is ignored.
|
||||
- Service config (including secrets) is resolved server-side (env/backend).
|
||||
- Client should pass stable IDs (`appId`, `channel`, `configVersionId`) plus small runtime overrides (e.g. `output`, `bargeIn`, greeting/prompt style hints).
|
||||
- `metadata.dynamicVariables` is optional and must be an object of string key/value pairs.
|
||||
- Key pattern: `^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`
|
||||
- Max entries: 30
|
||||
- Max value length: 1000 chars
|
||||
- Placeholder format in `systemPrompt` and `greeting`: `{{variable_name}}`.
|
||||
- Missing referenced placeholders reject `session.start` with `protocol.dynamic_variables_missing`.
|
||||
- Invalid `dynamicVariables` payload rejects `session.start` with `protocol.dynamic_variables_invalid`.
|
||||
|
||||
Text-only mode:
|
||||
- Set `metadata.output.mode = "text"`.
|
||||
|
||||
@@ -105,7 +105,11 @@
|
||||
"mode": "audio"
|
||||
},
|
||||
"systemPrompt": "你是简洁助手",
|
||||
"greeting": "你好,我能帮你什么?"
|
||||
"greeting": "你好,我能帮你什么?",
|
||||
"dynamicVariables": {
|
||||
"customer_name": "Alice",
|
||||
"plan_tier": "Pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -135,12 +139,22 @@
|
||||
- `bargeIn`
|
||||
- `knowledge`
|
||||
- `knowledgeBaseId`
|
||||
- `openerAudio`
|
||||
- `history`
|
||||
- `userId`
|
||||
- `assistantId`
|
||||
- `source`
|
||||
- `dynamicVariables`
|
||||
- 客户端传入 `metadata.services` 会被忽略(服务端会记录 warning),服务配置由后端/环境变量决定。
|
||||
|
||||
`metadata.dynamicVariables` 规则:
|
||||
- 可选;必须是 `object<string,string>`。
|
||||
- key 正则:`^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`
|
||||
- 最多 30 个变量,单个 value 最长 1000 字符。
|
||||
- `systemPrompt` / `greeting` 中支持占位符语法:`{{variable_name}}`。
|
||||
- 若模板引用了缺失变量,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_missing`。
|
||||
- 若 `dynamicVariables` 结构/内容非法,`session.start` 会被拒绝,错误码 `protocol.dynamic_variables_invalid`。
|
||||
|
||||
`output.mode` 用法:
|
||||
- `"audio"`(默认语音输出)
|
||||
- `"text"`(纯文本输出)
|
||||
@@ -517,4 +531,3 @@ Server <- output.audio.end
|
||||
Client -> session.stop
|
||||
Server <- session.stopped
|
||||
```
|
||||
|
||||
|
||||
96
engine/tests/test_dynamic_variables.py
Normal file
96
engine/tests/test_dynamic_variables.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from core.session import Session
|
||||
|
||||
|
||||
def _session() -> Session:
|
||||
# Skip heavyweight runtime initialization (VAD model, services, transport).
|
||||
return Session.__new__(Session)
|
||||
|
||||
|
||||
def test_apply_dynamic_variables_substitutes_prompt_and_greeting():
|
||||
session = _session()
|
||||
metadata = {
|
||||
"systemPrompt": "Hello {{customer_name}}, plan={{plan_tier}}.",
|
||||
"greeting": "Hi {{customer_name}}",
|
||||
}
|
||||
client_metadata = {
|
||||
"dynamicVariables": {
|
||||
"customer_name": "Alice",
|
||||
"plan_tier": "Pro",
|
||||
}
|
||||
}
|
||||
|
||||
resolved, error = session._apply_dynamic_variables(metadata, client_metadata)
|
||||
|
||||
assert error is None
|
||||
assert resolved["systemPrompt"] == "Hello Alice, plan=Pro."
|
||||
assert resolved["greeting"] == "Hi Alice"
|
||||
|
||||
|
||||
def test_apply_dynamic_variables_missing_value_rejects_session_start():
|
||||
session = _session()
|
||||
metadata = {
|
||||
"systemPrompt": "Hello {{customer_name}}",
|
||||
"greeting": "Plan {{plan_tier}}",
|
||||
}
|
||||
client_metadata = {
|
||||
"dynamicVariables": {
|
||||
"customer_name": "Alice",
|
||||
}
|
||||
}
|
||||
|
||||
_, error = session._apply_dynamic_variables(metadata, client_metadata)
|
||||
|
||||
assert error is not None
|
||||
assert error["code"] == "protocol.dynamic_variables_missing"
|
||||
assert "plan_tier" in error["message"]
|
||||
|
||||
|
||||
def test_apply_dynamic_variables_requires_string_values():
|
||||
session = _session()
|
||||
metadata = {
|
||||
"systemPrompt": "Hello {{customer_name}}",
|
||||
"greeting": "Hi",
|
||||
}
|
||||
client_metadata = {
|
||||
"dynamicVariables": {
|
||||
"customer_name": 123,
|
||||
}
|
||||
}
|
||||
|
||||
_, error = session._apply_dynamic_variables(metadata, client_metadata)
|
||||
|
||||
assert error is not None
|
||||
assert error["code"] == "protocol.dynamic_variables_invalid"
|
||||
assert "must be a string" in error["message"]
|
||||
|
||||
|
||||
def test_apply_dynamic_variables_missing_object_rejects_when_template_uses_placeholders():
|
||||
session = _session()
|
||||
metadata = {
|
||||
"systemPrompt": "Hello {{customer_name}}",
|
||||
"greeting": "Hi",
|
||||
}
|
||||
|
||||
_, error = session._apply_dynamic_variables(metadata, {})
|
||||
|
||||
assert error is not None
|
||||
assert error["code"] == "protocol.dynamic_variables_missing"
|
||||
assert "customer_name" in error["message"]
|
||||
|
||||
|
||||
def test_apply_dynamic_variables_no_placeholder_keeps_metadata():
|
||||
session = _session()
|
||||
metadata = {
|
||||
"systemPrompt": "Hello there",
|
||||
"greeting": "Hi",
|
||||
}
|
||||
client_metadata = {
|
||||
"dynamicVariables": {
|
||||
"customer_name": "Alice",
|
||||
}
|
||||
}
|
||||
|
||||
resolved, error = session._apply_dynamic_variables(metadata, client_metadata)
|
||||
|
||||
assert error is None
|
||||
assert resolved == metadata
|
||||
Reference in New Issue
Block a user