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:
Xin Wang
2026-02-27 11:21:37 +08:00
parent f1b60bef22
commit 3272a7a68a
5 changed files with 461 additions and 4 deletions

View File

@@ -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],