diff --git a/engine/core/session.py b/engine/core/session.py index dba4ca3..98b8aba 100644 --- a/engine/core/session.py +++ b/engine/core/session.py @@ -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], diff --git a/engine/docs/ws_v1_schema.md b/engine/docs/ws_v1_schema.md index 1d35e98..732c8cb 100644 --- a/engine/docs/ws_v1_schema.md +++ b/engine/docs/ws_v1_schema.md @@ -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"`. diff --git a/engine/docs/ws_v1_schema_zh.md b/engine/docs/ws_v1_schema_zh.md index 25c5ad9..5095d08 100644 --- a/engine/docs/ws_v1_schema_zh.md +++ b/engine/docs/ws_v1_schema_zh.md @@ -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`。 +- 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 ``` - diff --git a/engine/tests/test_dynamic_variables.py b/engine/tests/test_dynamic_variables.py new file mode 100644 index 0000000..78a270e --- /dev/null +++ b/engine/tests/test_dynamic_variables.py @@ -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 diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index bc302ca..dac4bf7 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -1528,6 +1528,29 @@ const TOOL_PARAMETER_HINTS: Record = { const getDefaultToolParameters = (toolId: string) => TOOL_PARAMETER_HINTS[toolId] || { type: 'object', properties: {} }; +type DynamicVariableEntry = { + id: string; + key: string; + value: string; +}; + +const DYNAMIC_VARIABLE_KEY_RE = /^[a-zA-Z_][a-zA-Z0-9_]{0,63}$/; +const DYNAMIC_VARIABLE_PLACEHOLDER_RE = /\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g; +const DYNAMIC_VARIABLE_MAX_ITEMS = 30; +const DYNAMIC_VARIABLE_MAX_VALUE_LENGTH = 1000; + +const extractDynamicTemplateKeys = (text: string): string[] => { + if (!text) return []; + const keys = new Set(); + const pattern = new RegExp(DYNAMIC_VARIABLE_PLACEHOLDER_RE); + for (const match of text.matchAll(pattern)) { + if (match[1]) { + keys.add(match[1]); + } + } + return Array.from(keys); +}; + // Stable transcription log so the scroll container is not recreated on every render (avoids scroll jumping) const TranscriptionLog: React.FC<{ scrollRef: React.RefObject; @@ -1619,12 +1642,30 @@ export const DebugDrawer: React.FC<{ const [captureConfigOpen, setCaptureConfigOpen] = useState(false); const [captureConfigView, setCaptureConfigView] = useState(''); const [settingsDrawerOpen, setSettingsDrawerOpen] = useState(false); + const [dynamicVariables, setDynamicVariables] = useState([]); + const dynamicVariableSeqRef = useRef(0); const [wsUrl, setWsUrl] = useState(() => { const fromStorage = localStorage.getItem('debug_ws_url'); if (fromStorage) return fromStorage; const defaultHost = window.location.hostname || 'localhost'; return `ws://${defaultHost}:8000/ws`; }); + const nextDynamicVariableId = () => { + dynamicVariableSeqRef.current += 1; + return `var_${dynamicVariableSeqRef.current}`; + }; + const isDynamicVariablesLocked = + wsStatus === 'connecting' + || wsStatus === 'ready' + || callStatus === 'calling' + || callStatus === 'active' + || textSessionStarted; + const requiredTemplateVariableKeys = useMemo(() => { + const keys = new Set(); + extractDynamicTemplateKeys(String(assistant.prompt || '')).forEach((key) => keys.add(key)); + extractDynamicTemplateKeys(String(assistant.opener || '')).forEach((key) => keys.add(key)); + return Array.from(keys).sort(); + }, [assistant.opener, assistant.prompt]); // Media State const videoRef = useRef(null); @@ -1714,6 +1755,11 @@ export const DebugDrawer: React.FC<{ wsStatusRef.current = wsStatus; }, [wsStatus]); + useEffect(() => { + dynamicVariableSeqRef.current = 0; + setDynamicVariables([]); + }, [assistant.id, isOpen]); + useEffect(() => { localStorage.setItem('debug_audio_aec', aecEnabled ? '1' : '0'); }, [aecEnabled]); @@ -2068,7 +2114,9 @@ export const DebugDrawer: React.FC<{ } } catch (e) { console.error(e); - setMessages(prev => [...prev, { role: 'model', text: "Error: Failed to connect to AI service." }]); + const errMessage = (e as Error)?.message || 'Failed to connect to AI service.'; + setMessages(prev => [...prev, { role: 'model', text: `Error: ${errMessage}` }]); + setWsError(errMessage); setIsLoading(false); } finally { if (mode !== 'text') setIsLoading(false); @@ -2096,6 +2144,82 @@ export const DebugDrawer: React.FC<{ } }; + const addDynamicVariableRow = () => { + if (isDynamicVariablesLocked) return; + setDynamicVariables((prev) => { + if (prev.length >= DYNAMIC_VARIABLE_MAX_ITEMS) return prev; + return [...prev, { id: nextDynamicVariableId(), key: '', value: '' }]; + }); + }; + + const updateDynamicVariableRow = (rowId: string, field: 'key' | 'value', value: string) => { + if (isDynamicVariablesLocked) return; + setDynamicVariables((prev) => prev.map((item) => (item.id === rowId ? { ...item, [field]: value } : item))); + }; + + const removeDynamicVariableRow = (rowId: string) => { + if (isDynamicVariablesLocked) return; + setDynamicVariables((prev) => prev.filter((item) => item.id !== rowId)); + }; + + const buildDynamicVariablesPayload = (): { variables: Record; error?: string } => { + const variables: Record = {}; + const nonEmptyRows = dynamicVariables + .map((row, index) => ({ ...row, index, key: row.key.trim() })) + .filter((row) => row.key !== '' || row.value !== ''); + + if (nonEmptyRows.length > DYNAMIC_VARIABLE_MAX_ITEMS) { + return { + variables, + error: `Dynamic variable count cannot exceed ${DYNAMIC_VARIABLE_MAX_ITEMS}.`, + }; + } + + for (const row of nonEmptyRows) { + if (!row.key) { + return { + variables, + error: `Dynamic variable row ${row.index + 1} is missing key.`, + }; + } + if (!DYNAMIC_VARIABLE_KEY_RE.test(row.key)) { + return { + variables, + error: `Invalid dynamic variable key "${row.key}". Use letters, digits, underscore and start with a letter/underscore.`, + }; + } + if (row.value === '') { + return { + variables, + error: `Dynamic variable "${row.key}" is missing value.`, + }; + } + if (row.value.length > DYNAMIC_VARIABLE_MAX_VALUE_LENGTH) { + return { + variables, + error: `Dynamic variable "${row.key}" exceeds ${DYNAMIC_VARIABLE_MAX_VALUE_LENGTH} characters.`, + }; + } + if (Object.prototype.hasOwnProperty.call(variables, row.key)) { + return { + variables, + error: `Duplicate dynamic variable key "${row.key}".`, + }; + } + variables[row.key] = row.value; + } + + const missingTemplateKeys = requiredTemplateVariableKeys.filter((key) => !Object.prototype.hasOwnProperty.call(variables, key)); + if (missingTemplateKeys.length > 0) { + return { + variables, + error: `Missing required dynamic variables: ${missingTemplateKeys.join(', ')}`, + }; + } + + return { variables }; + }; + const buildLocalResolvedRuntime = () => { const warnings: string[] = []; const services: Record = {}; @@ -2203,11 +2327,18 @@ export const DebugDrawer: React.FC<{ }; const fetchRuntimeMetadata = async (): Promise> => { + const dynamicVariablesResult = buildDynamicVariablesPayload(); + if (dynamicVariablesResult.error) { + throw new Error(dynamicVariablesResult.error); + } const localResolved = buildLocalResolvedRuntime(); const mergedMetadata: Record = { ...localResolved.sessionStartMetadata, ...(sessionMetadataExtras || {}), }; + if (Object.keys(dynamicVariablesResult.variables).length > 0) { + mergedMetadata.dynamicVariables = dynamicVariablesResult.variables; + } // Engine resolves trusted runtime config by top-level assistant/app ID. // Keep these IDs at metadata root so backend /assistants/{id}/config is reachable. if (!mergedMetadata.assistantId && assistant.id) { @@ -2654,6 +2785,70 @@ export const DebugDrawer: React.FC<{ Auto Gain Control (AGC) +
+
+

Dynamic Variables

+ +
+

+ Use placeholders like {'{{customer_name}}'} in prompt/opener. +

+ {requiredTemplateVariableKeys.length > 0 && ( +

+ Required: {requiredTemplateVariableKeys.join(', ')} +

+ )} + {dynamicVariables.length === 0 ? ( +
+ No variables added. +
+ ) : ( +
+ {dynamicVariables.map((row, index) => ( +
+ updateDynamicVariableRow(row.id, 'key', e.target.value)} + placeholder={`key_${index + 1}`} + disabled={isDynamicVariablesLocked} + className="h-8 text-xs" + /> + updateDynamicVariableRow(row.id, 'value', e.target.value)} + placeholder="value" + disabled={isDynamicVariablesLocked} + className="h-8 text-xs" + /> + +
+ ))} +
+ )} + {isDynamicVariablesLocked && ( +

+ Editing is locked while conversation is starting/active. +

+ )} +