Enhance conversation history and runtime variable management

- Update ConversationRecorder to include source and nodeId metadata in transcripts for better context tracking.
- Introduce optional variable handling in DynamicVariableStore, allowing for unset variables to be rendered as empty without raising errors.
- Refactor WorkflowBrain to apply turn configurations and manage interaction policies dynamically, improving agent responsiveness.
- Implement tests to ensure proper handling of updated session variables and workflow metadata in various scenarios.
This commit is contained in:
Xin Wang
2026-07-14 11:08:11 +08:00
parent 665f727796
commit f74040adf3
18 changed files with 848 additions and 194 deletions

View File

@@ -1,4 +1,4 @@
"""Conversation-scoped dynamic variables for prompt pipeline assistants.
"""Conversation-scoped dynamic variables shared by Prompt and Workflow.
The renderer is deliberately small: it only understands ``{{ name }}``
placeholders and never evaluates expressions. A value is substituted once,
@@ -21,6 +21,7 @@ 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*}}")
FULL_PLACEHOLDER = re.compile(r"^{{\s*([A-Za-z][A-Za-z0-9_]*)\s*}}$")
MAX_VARIABLES = 50
MAX_VALUE_LENGTH = 2048
MAX_HISTORY_ENTRIES = 50
@@ -91,37 +92,71 @@ class DynamicVariableStore:
self,
values: dict[str, Primitive],
secrets: dict[str, str] | None = None,
*,
optional_names: set[str] | None = None,
variable_types: dict[str, str] | None = None,
):
self.values = dict(values)
self.secrets = dict(secrets or {})
self.optional_names = set(optional_names or set())
self.variable_types = dict(variable_types or {})
self.history: list[dict[str, str]] = []
@classmethod
def from_config(cls, cfg: AssistantConfig) -> "DynamicVariableStore":
return cls(cfg.dynamic_variables, cfg.secret_dynamic_variables)
definitions = cfg.dynamic_variable_definitions or {}
optional_names = {
name
for name, definition in definitions.items()
if not definition.get("required", False)
and definition.get("default") is None
}
variable_types = {
name: str(definition.get("type") or "string")
for name, definition in definitions.items()
}
return cls(
cfg.dynamic_variables,
cfg.secret_dynamic_variables,
optional_names=optional_names,
variable_types=variable_types,
)
def render(self, template: str, *, allow_secrets: bool = False) -> str:
if not template:
return template
def _refresh_time(self) -> None:
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()
self.values["system__time_utc"] = now.astimezone(
ZoneInfo("UTC")
).isoformat()
except ZoneInfoNotFoundError:
pass
def _resolve(self, name: str, *, allow_secrets: bool) -> Primitive:
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 in self.values:
return self.values[name]
# Optional variables intentionally remain absent from ``values`` so an
# ``exists`` expression can distinguish unset from an explicit value.
# Text templates still render predictably instead of failing the call.
if name in self.optional_names:
return ""
raise DynamicVariableError(f"缺少动态变量: {name}")
def render(self, template: str, *, allow_secrets: bool = False) -> str:
if not template:
return template
self._refresh_time()
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]
value = self._resolve(name, allow_secrets=allow_secrets)
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
@@ -130,6 +165,12 @@ class DynamicVariableStore:
def render_data(self, value: Any, *, allow_secrets: bool = False) -> Any:
if isinstance(value, str):
exact = FULL_PLACEHOLDER.fullmatch(value)
if exact:
self._refresh_time()
return deepcopy(
self._resolve(exact.group(1), allow_secrets=allow_secrets)
)
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]
@@ -163,7 +204,11 @@ class DynamicVariableStore:
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)
primitive = _primitive(value, name)
expected = self.variable_types.get(name)
if expected and not _type_matches(primitive, expected):
raise DynamicVariableError(f"动态变量 {name} 类型应为 {expected}")
self.values[name] = primitive
def prepare_dynamic_config(