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,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):

View 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()