feat: add workflow action outcomes and tracing
This commit is contained in:
@@ -102,6 +102,7 @@ async def get_conversation(
|
|||||||
).scalars().all()
|
).scalars().all()
|
||||||
return ConversationDetailOut(
|
return ConversationDetailOut(
|
||||||
**_session_out(conversation).model_dump(),
|
**_session_out(conversation).model_dump(),
|
||||||
|
extra=conversation.extra or {},
|
||||||
messages=[
|
messages=[
|
||||||
ConversationMessageOut(
|
ConversationMessageOut(
|
||||||
id=message.id,
|
id=message.id,
|
||||||
|
|||||||
@@ -422,6 +422,7 @@ class ConversationOut(CamelModel):
|
|||||||
|
|
||||||
|
|
||||||
class ConversationDetailOut(ConversationOut):
|
class ConversationDetailOut(ConversationOut):
|
||||||
|
extra: dict[str, Any] = Field(default_factory=dict)
|
||||||
messages: list[ConversationMessageOut] = Field(default_factory=list)
|
messages: list[ConversationMessageOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class BrainRuntime:
|
|||||||
set_system_prompt: Callable[[str], None]
|
set_system_prompt: Callable[[str], None]
|
||||||
set_tools: Callable[[list[FunctionSchema] | None], None]
|
set_tools: Callable[[list[FunctionSchema] | None], None]
|
||||||
call_end: CallEndPort
|
call_end: CallEndPort
|
||||||
|
session_id: str = ""
|
||||||
client_tools: ClientToolPort | None = None
|
client_tools: ClientToolPort | None = None
|
||||||
worker: Any = None
|
worker: Any = None
|
||||||
context_aggregator: Any = None
|
context_aggregator: Any = None
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
|
from time import monotonic
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from models import AssistantConfig, RuntimeTool
|
from models import AssistantConfig, RuntimeTool
|
||||||
@@ -40,6 +42,9 @@ from services.tool_executor import ToolExecutionError, ToolExecutor
|
|||||||
from services.tool_policy import policy_for_tool
|
from services.tool_policy import policy_for_tool
|
||||||
from services.workflow.agent import WorkflowAgentStage
|
from services.workflow.agent import WorkflowAgentStage
|
||||||
from services.workflow.models import (
|
from services.workflow.models import (
|
||||||
|
ActionError,
|
||||||
|
ActionOutcome,
|
||||||
|
ActionStatus,
|
||||||
RouteStatus,
|
RouteStatus,
|
||||||
WorkflowRuntimeState,
|
WorkflowRuntimeState,
|
||||||
WorkflowStatus,
|
WorkflowStatus,
|
||||||
@@ -53,6 +58,23 @@ from services.workflow_router import WorkflowLLMRouter
|
|||||||
MAX_AUTOMATIC_HOPS = 50
|
MAX_AUTOMATIC_HOPS = 50
|
||||||
|
|
||||||
|
|
||||||
|
class _ActionFailure(RuntimeError):
|
||||||
|
"""Internal adapter from heterogeneous tool failures to ActionOutcome."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
code: str,
|
||||||
|
retryable: bool = False,
|
||||||
|
result: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.retryable = retryable
|
||||||
|
self.result = result
|
||||||
|
|
||||||
|
|
||||||
class ConfiguredFlowManager(FlowManager):
|
class ConfiguredFlowManager(FlowManager):
|
||||||
"""Preserve Flow transitions while suppressing late async-tool replies."""
|
"""Preserve Flow transitions while suppressing late async-tool replies."""
|
||||||
|
|
||||||
@@ -257,7 +279,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
)
|
)
|
||||||
if current_node != self._state.current_node_id:
|
if current_node != self._state.current_node_id:
|
||||||
self._state.current_node_id = current_node
|
self._state.current_node_id = current_node
|
||||||
await self._emit_node_active(current_node)
|
await self._emit_node_active(current_node, trace=False)
|
||||||
await self._emit_variables(
|
await self._emit_variables(
|
||||||
reason="client_ready",
|
reason="client_ready",
|
||||||
node_id=current_node,
|
node_id=current_node,
|
||||||
@@ -643,7 +665,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
*,
|
*,
|
||||||
triggering_user_text: str = "",
|
triggering_user_text: str = "",
|
||||||
) -> NodeConfig:
|
) -> NodeConfig:
|
||||||
self._state.begin_transition()
|
await self._begin_edge_transition(edge)
|
||||||
leading_messages: list[dict[str, str]] = []
|
leading_messages: list[dict[str, str]] = []
|
||||||
speech = self._engine.edge_transition_speech(edge)
|
speech = self._engine.edge_transition_speech(edge)
|
||||||
if speech:
|
if speech:
|
||||||
@@ -690,7 +712,9 @@ class WorkflowBrain(BaseBrain):
|
|||||||
await self._enter_end(node_id)
|
await self._enter_end(node_id)
|
||||||
return self._passive_node_config(node_id, context_messages)
|
return self._passive_node_config(node_id, context_messages)
|
||||||
if node_type == "action":
|
if node_type == "action":
|
||||||
await self._enter_action(node_id)
|
outcome = await self._enter_action(node_id)
|
||||||
|
if not outcome.should_route:
|
||||||
|
return self._passive_node_config(node_id, context_messages)
|
||||||
elif node_type == "handoff":
|
elif node_type == "handoff":
|
||||||
await self._enter_handoff(node_id)
|
await self._enter_handoff(node_id)
|
||||||
elif node_type == "start":
|
elif node_type == "start":
|
||||||
@@ -703,7 +727,7 @@ class WorkflowBrain(BaseBrain):
|
|||||||
edge = await self._select_edge(node_id)
|
edge = await self._select_edge(node_id)
|
||||||
if not edge:
|
if not edge:
|
||||||
return self._passive_node_config(node_id, context_messages)
|
return self._passive_node_config(node_id, context_messages)
|
||||||
self._state.begin_transition()
|
await self._begin_edge_transition(edge)
|
||||||
speech = self._engine.edge_transition_speech(edge)
|
speech = self._engine.edge_transition_speech(edge)
|
||||||
if speech:
|
if speech:
|
||||||
content = self._store.render(speech).strip()
|
content = self._store.render(speech).strip()
|
||||||
@@ -720,11 +744,13 @@ class WorkflowBrain(BaseBrain):
|
|||||||
node_id = str(edge.get("target") or "")
|
node_id = str(edge.get("target") or "")
|
||||||
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
raise RuntimeError("工作流连续自动跳转超过安全上限")
|
||||||
|
|
||||||
async def _enter_action(self, node_id: str) -> None:
|
async def _enter_action(self, node_id: str) -> ActionOutcome:
|
||||||
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
self._state.enter(node_id, WorkflowStatus.RUNNING_ACTION)
|
||||||
await self._emit_node_active(node_id)
|
await self._emit_node_active(node_id)
|
||||||
data = self._engine.data(node_id)
|
data = self._engine.data(node_id)
|
||||||
runtime = self._require_runtime()
|
runtime = self._require_runtime()
|
||||||
|
invocation_id = f"act_{uuid4().hex[:20]}"
|
||||||
|
started_at = monotonic()
|
||||||
block_user_input = data.get("userInputPolicy") == "block"
|
block_user_input = data.get("userInputPolicy") == "block"
|
||||||
if block_user_input and runtime.set_input_enabled:
|
if block_user_input and runtime.set_input_enabled:
|
||||||
# Blocking only suppresses new audio/text input while the Action
|
# Blocking only suppresses new audio/text input while the Action
|
||||||
@@ -734,9 +760,20 @@ class WorkflowBrain(BaseBrain):
|
|||||||
runtime.set_input_enabled(False)
|
runtime.set_input_enabled(False)
|
||||||
tool_id = str(data.get("toolId") or "")
|
tool_id = str(data.get("toolId") or "")
|
||||||
tool = self._tool_by_id.get(tool_id)
|
tool = self._tool_by_id.get(tool_id)
|
||||||
|
result: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
|
await self._emit_trace(
|
||||||
|
"action_started",
|
||||||
|
nodeId=node_id,
|
||||||
|
invocationId=invocation_id,
|
||||||
|
toolId=tool_id,
|
||||||
|
toolType=tool.type if tool else None,
|
||||||
|
)
|
||||||
if not tool:
|
if not tool:
|
||||||
raise ToolExecutionError(f"工具不存在:{tool_id}")
|
raise _ActionFailure(
|
||||||
|
f"工具不存在:{tool_id}",
|
||||||
|
code="tool_not_found",
|
||||||
|
)
|
||||||
arguments = self._store.render_data(data.get("arguments") or {})
|
arguments = self._store.render_data(data.get("arguments") or {})
|
||||||
result = await self._tools.execute(
|
result = await self._tools.execute(
|
||||||
tool,
|
tool,
|
||||||
@@ -744,8 +781,12 @@ class WorkflowBrain(BaseBrain):
|
|||||||
result_assignments=self._action_result_assignments(data),
|
result_assignments=self._action_result_assignments(data),
|
||||||
)
|
)
|
||||||
if result.get("status") != "ok":
|
if result.get("status") != "ok":
|
||||||
raise ToolExecutionError(
|
returned_status = str(result.get("status") or "error")
|
||||||
str(result.get("message") or "工具返回执行失败状态")
|
raise _ActionFailure(
|
||||||
|
str(result.get("message") or "工具返回执行失败状态"),
|
||||||
|
code=str(result.get("code") or f"tool_{returned_status}"),
|
||||||
|
retryable=bool(result.get("retryable", False)),
|
||||||
|
result=result,
|
||||||
)
|
)
|
||||||
updated_variables = list(result.get("updated_variables") or [])
|
updated_variables = list(result.get("updated_variables") or [])
|
||||||
if updated_variables:
|
if updated_variables:
|
||||||
@@ -754,14 +795,114 @@ class WorkflowBrain(BaseBrain):
|
|||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
changed=updated_variables,
|
changed=updated_variables,
|
||||||
)
|
)
|
||||||
self._store.values["system__last_action_status"] = "ok"
|
outcome = ActionOutcome(
|
||||||
self._store.values["system__last_action_error"] = ""
|
invocation_id=invocation_id,
|
||||||
except (ToolExecutionError, ValueError) as exc:
|
status=ActionStatus.SUCCESS,
|
||||||
self._store.values["system__last_action_status"] = "error"
|
duration_ms=self._elapsed_ms(started_at),
|
||||||
self._store.values["system__last_action_error"] = str(exc)[:2048]
|
result=result,
|
||||||
|
updated_variables=tuple(str(name) for name in updated_variables),
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
outcome = ActionOutcome(
|
||||||
|
invocation_id=invocation_id,
|
||||||
|
status=ActionStatus.CANCELLED,
|
||||||
|
duration_ms=self._elapsed_ms(started_at),
|
||||||
|
result=result,
|
||||||
|
error=ActionError(
|
||||||
|
code="action_cancelled",
|
||||||
|
message="Action 随当前任务取消",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._set_last_action(outcome)
|
||||||
|
await self._emit_action_outcome(node_id, outcome)
|
||||||
|
raise
|
||||||
|
except (_ActionFailure, ToolExecutionError, ValueError) as exc:
|
||||||
|
cancelled = self._action_was_cancelled(exc, runtime)
|
||||||
|
outcome = ActionOutcome(
|
||||||
|
invocation_id=invocation_id,
|
||||||
|
status=(
|
||||||
|
ActionStatus.CANCELLED if cancelled else ActionStatus.FAILURE
|
||||||
|
),
|
||||||
|
duration_ms=self._elapsed_ms(started_at),
|
||||||
|
result=(exc.result if isinstance(exc, _ActionFailure) else result),
|
||||||
|
error=ActionError(
|
||||||
|
code=(
|
||||||
|
"session_ended"
|
||||||
|
if cancelled
|
||||||
|
else (
|
||||||
|
exc.code
|
||||||
|
if isinstance(exc, _ActionFailure)
|
||||||
|
else (
|
||||||
|
"invalid_action_configuration"
|
||||||
|
if isinstance(exc, ValueError)
|
||||||
|
else "tool_execution_error"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
message=str(exc)[:2048],
|
||||||
|
retryable=(
|
||||||
|
exc.retryable if isinstance(exc, _ActionFailure) else False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if block_user_input and runtime.set_input_enabled:
|
if block_user_input and runtime.set_input_enabled:
|
||||||
runtime.set_input_enabled(True)
|
runtime.set_input_enabled(True)
|
||||||
|
self._set_last_action(outcome)
|
||||||
|
await self._emit_action_outcome(node_id, outcome)
|
||||||
|
return outcome
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _elapsed_ms(started_at: float) -> int:
|
||||||
|
return max(0, round((monotonic() - started_at) * 1000))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _action_was_cancelled(exc: Exception, runtime: BrainRuntime) -> bool:
|
||||||
|
if getattr(runtime.call_end, "ending", False):
|
||||||
|
return True
|
||||||
|
message = str(exc)
|
||||||
|
return any(
|
||||||
|
marker in message
|
||||||
|
for marker in (
|
||||||
|
"会话已结束",
|
||||||
|
"会话已取消",
|
||||||
|
"管线已停止",
|
||||||
|
"通道已关闭",
|
||||||
|
"连接已断开",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_last_action(self, outcome: ActionOutcome) -> None:
|
||||||
|
legacy_status = {
|
||||||
|
ActionStatus.SUCCESS: "ok",
|
||||||
|
ActionStatus.FAILURE: "error",
|
||||||
|
ActionStatus.CANCELLED: "cancelled",
|
||||||
|
}[outcome.status]
|
||||||
|
self._store.values.update(
|
||||||
|
{
|
||||||
|
"system__last_action_status": legacy_status,
|
||||||
|
"system__last_action_invocation_id": outcome.invocation_id,
|
||||||
|
"system__last_action_duration_ms": outcome.duration_ms,
|
||||||
|
"system__last_action_error_code": (
|
||||||
|
outcome.error.code if outcome.error else ""
|
||||||
|
),
|
||||||
|
"system__last_action_error": (
|
||||||
|
outcome.error.message if outcome.error else ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _emit_action_outcome(
|
||||||
|
self,
|
||||||
|
node_id: str,
|
||||||
|
outcome: ActionOutcome,
|
||||||
|
) -> None:
|
||||||
|
event = {
|
||||||
|
ActionStatus.SUCCESS: "action_completed",
|
||||||
|
ActionStatus.FAILURE: "action_failed",
|
||||||
|
ActionStatus.CANCELLED: "action_cancelled",
|
||||||
|
}[outcome.status]
|
||||||
|
await self._emit_trace(event, nodeId=node_id, outcome=outcome.trace_payload())
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _action_result_assignments(
|
def _action_result_assignments(
|
||||||
@@ -815,6 +956,12 @@ class WorkflowBrain(BaseBrain):
|
|||||||
data = self._engine.data(node_id)
|
data = self._engine.data(node_id)
|
||||||
message = self._store.render(str(data.get("message") or ""))
|
message = self._store.render(str(data.get("message") or ""))
|
||||||
scope = str(data.get("scope") or "session")
|
scope = str(data.get("scope") or "session")
|
||||||
|
await self._emit_trace(
|
||||||
|
"workflow_ended",
|
||||||
|
nodeId=node_id,
|
||||||
|
scope=scope,
|
||||||
|
outcome="success",
|
||||||
|
)
|
||||||
if scope == "flow":
|
if scope == "flow":
|
||||||
await runtime.queue_frame(
|
await runtime.queue_frame(
|
||||||
OutputTransportMessageUrgentFrame(
|
OutputTransportMessageUrgentFrame(
|
||||||
@@ -835,8 +982,46 @@ class WorkflowBrain(BaseBrain):
|
|||||||
else:
|
else:
|
||||||
await runtime.call_end.finish()
|
await runtime.call_end.finish()
|
||||||
|
|
||||||
async def _emit_node_active(self, node_id: str | None) -> None:
|
async def _emit_node_active(
|
||||||
|
self,
|
||||||
|
node_id: str | None,
|
||||||
|
*,
|
||||||
|
trace: bool = True,
|
||||||
|
) -> None:
|
||||||
await self._require_output().emit_node_active(node_id)
|
await self._require_output().emit_node_active(node_id)
|
||||||
|
if trace and node_id:
|
||||||
|
await self._emit_trace(
|
||||||
|
"node_entered",
|
||||||
|
nodeId=node_id,
|
||||||
|
nodeType=self._engine.node_type(node_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _begin_edge_transition(self, edge: dict) -> None:
|
||||||
|
transition_id = self._state.begin_transition()
|
||||||
|
source_id = str(edge.get("source") or self._state.current_node_id or "")
|
||||||
|
target_id = str(edge.get("target") or "")
|
||||||
|
if source_id:
|
||||||
|
await self._emit_trace("node_exited", nodeId=source_id)
|
||||||
|
await self._emit_trace(
|
||||||
|
"edge_selected",
|
||||||
|
edgeId=str(edge.get("id") or ""),
|
||||||
|
sourceNodeId=source_id,
|
||||||
|
targetNodeId=target_id,
|
||||||
|
edgeMode=self._engine.edge_mode(edge),
|
||||||
|
transitionId=transition_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _emit_trace(self, event: str, **details: Any) -> None:
|
||||||
|
transition_id = int(details.pop("transitionId", self._state.transition_id))
|
||||||
|
try:
|
||||||
|
await self._require_output().emit_trace(
|
||||||
|
event,
|
||||||
|
revision=self._engine.revision,
|
||||||
|
transition_id=transition_id,
|
||||||
|
**details,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - trace must not alter execution
|
||||||
|
logger.warning(f"发送 Workflow 轨迹失败,不影响当前流程: {exc}")
|
||||||
|
|
||||||
async def _emit_variables(
|
async def _emit_variables(
|
||||||
self,
|
self,
|
||||||
@@ -851,6 +1036,19 @@ class WorkflowBrain(BaseBrain):
|
|||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
changed=changed,
|
changed=changed,
|
||||||
)
|
)
|
||||||
|
public_variables = self._require_output().public_variables()
|
||||||
|
public_names = [
|
||||||
|
name
|
||||||
|
for name in (changed or public_variables.keys())
|
||||||
|
if not name.startswith(("system__", "secret__"))
|
||||||
|
]
|
||||||
|
await self._emit_trace(
|
||||||
|
"variables_updated" if changed else "variables_snapshot",
|
||||||
|
nodeId=node_id,
|
||||||
|
reason=reason,
|
||||||
|
variableNames=public_names,
|
||||||
|
variables=public_variables,
|
||||||
|
)
|
||||||
|
|
||||||
def _require_runtime(self) -> BrainRuntime:
|
def _require_runtime(self) -> BrainRuntime:
|
||||||
if self._runtime is None:
|
if self._runtime is None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ conversation_artifacts,并把对象存储地址关联到会话或消息。
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from copy import deepcopy
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ from db.session import SessionLocal
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
MAX_WORKFLOW_TRACE_EVENTS = 1000
|
||||||
|
|
||||||
|
|
||||||
def _parse_timestamp(value: object) -> datetime:
|
def _parse_timestamp(value: object) -> datetime:
|
||||||
if not isinstance(value, str) or not value:
|
if not isinstance(value, str) or not value:
|
||||||
return datetime.now(UTC)
|
return datetime.now(UTC)
|
||||||
@@ -29,6 +33,7 @@ class ConversationRecorder:
|
|||||||
def __init__(self, session_id: str):
|
def __init__(self, session_id: str):
|
||||||
self.session_id = session_id
|
self.session_id = session_id
|
||||||
self._sequence = 0
|
self._sequence = 0
|
||||||
|
self._trace_sequence = 0
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._seen_events: set[str] = set()
|
self._seen_events: set[str] = set()
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ class ConversationRecorder:
|
|||||||
channel: str,
|
channel: str,
|
||||||
runtime_mode: str,
|
runtime_mode: str,
|
||||||
session_id: str | None = None,
|
session_id: str | None = None,
|
||||||
|
extra: dict | None = None,
|
||||||
) -> "ConversationRecorder | None":
|
) -> "ConversationRecorder | None":
|
||||||
session_id = session_id or f"conv_{uuid4().hex[:20]}"
|
session_id = session_id or f"conv_{uuid4().hex[:20]}"
|
||||||
try:
|
try:
|
||||||
@@ -54,7 +60,7 @@ class ConversationRecorder:
|
|||||||
runtime_mode=runtime_mode,
|
runtime_mode=runtime_mode,
|
||||||
status="active",
|
status="active",
|
||||||
message_count=0,
|
message_count=0,
|
||||||
extra={},
|
extra=deepcopy(extra or {}),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -67,6 +73,9 @@ class ConversationRecorder:
|
|||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
return
|
return
|
||||||
event_type = message.get("type")
|
event_type = message.get("type")
|
||||||
|
if event_type == "workflow-event":
|
||||||
|
await self._append_workflow_event(message)
|
||||||
|
return
|
||||||
role = ""
|
role = ""
|
||||||
content = ""
|
content = ""
|
||||||
extra: dict = {}
|
extra: dict = {}
|
||||||
@@ -98,6 +107,36 @@ class ConversationRecorder:
|
|||||||
self._seen_events.add(event_key)
|
self._seen_events.add(event_key)
|
||||||
await self._append(role, content, timestamp, extra)
|
await self._append(role, content, timestamp, extra)
|
||||||
|
|
||||||
|
async def _append_workflow_event(self, message: dict) -> None:
|
||||||
|
"""Persist bounded control-plane history without creating chat rows."""
|
||||||
|
|
||||||
|
event_id = str(message.get("eventId") or "")
|
||||||
|
event_name = str(message.get("event") or "")
|
||||||
|
event_key = f"workflow:{event_id}"
|
||||||
|
if not event_id or not event_name or event_key in self._seen_events:
|
||||||
|
return
|
||||||
|
self._seen_events.add(event_key)
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
next_sequence = self._trace_sequence + 1
|
||||||
|
payload = deepcopy(message)
|
||||||
|
payload["sessionId"] = self.session_id
|
||||||
|
payload["sequence"] = next_sequence
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
conversation = await db.get(ConversationSession, self.session_id)
|
||||||
|
if not conversation:
|
||||||
|
return
|
||||||
|
extra = dict(conversation.extra or {})
|
||||||
|
trace = list(extra.get("workflowTrace") or [])
|
||||||
|
trace.append(payload)
|
||||||
|
extra["workflowTrace"] = trace[-MAX_WORKFLOW_TRACE_EVENTS:]
|
||||||
|
conversation.extra = extra
|
||||||
|
await db.commit()
|
||||||
|
self._trace_sequence = next_sequence
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"保存 Workflow 轨迹失败,不影响本次通话: {exc}")
|
||||||
|
|
||||||
async def _append(
|
async def _append(
|
||||||
self,
|
self,
|
||||||
role: str,
|
role: str,
|
||||||
@@ -130,13 +169,14 @@ class ConversationRecorder:
|
|||||||
logger.error(f"保存对话文本失败,不影响本次通话: {exc}")
|
logger.error(f"保存对话文本失败,不影响本次通话: {exc}")
|
||||||
|
|
||||||
async def finish(self, *, status: str = "completed") -> None:
|
async def finish(self, *, status: str = "completed") -> None:
|
||||||
try:
|
async with self._lock:
|
||||||
async with SessionLocal() as db:
|
try:
|
||||||
conversation = await db.get(ConversationSession, self.session_id)
|
async with SessionLocal() as db:
|
||||||
if conversation:
|
conversation = await db.get(ConversationSession, self.session_id)
|
||||||
conversation.status = status
|
if conversation:
|
||||||
conversation.ended_at = datetime.now(UTC)
|
conversation.status = status
|
||||||
conversation.message_count = self._sequence
|
conversation.ended_at = datetime.now(UTC)
|
||||||
await db.commit()
|
conversation.message_count = self._sequence
|
||||||
except Exception as exc:
|
await db.commit()
|
||||||
logger.error(f"结束对话历史会话失败: {exc}")
|
except Exception as exc:
|
||||||
|
logger.error(f"结束对话历史会话失败: {exc}")
|
||||||
|
|||||||
@@ -208,8 +208,9 @@ async def run_pipeline(
|
|||||||
只要有 .input() / .output() / event_handler 即可。
|
只要有 .input() / .output() / event_handler 即可。
|
||||||
cfg: 助手配置(随请求内联传入)。
|
cfg: 助手配置(随请求内联传入)。
|
||||||
"""
|
"""
|
||||||
if cfg.type == "workflow":
|
workflow_engine = WorkflowEngine(cfg.graph) if cfg.type == "workflow" else None
|
||||||
vision_enabled = WorkflowEngine(cfg.graph).uses_vision()
|
if workflow_engine:
|
||||||
|
vision_enabled = workflow_engine.uses_vision()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"启动管线: assistant={cfg.name} type={cfg.type} "
|
f"启动管线: assistant={cfg.name} type={cfg.type} "
|
||||||
@@ -578,6 +579,7 @@ async def run_pipeline(
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
runtime_mode=cfg.runtimeMode,
|
runtime_mode=cfg.runtimeMode,
|
||||||
session_id=cfg.conversation_id or None,
|
session_id=cfg.conversation_id or None,
|
||||||
|
extra=(workflow_engine.session_metadata() if workflow_engine else None),
|
||||||
)
|
)
|
||||||
pipeline = Pipeline(
|
pipeline = Pipeline(
|
||||||
[
|
[
|
||||||
@@ -664,6 +666,7 @@ async def run_pipeline(
|
|||||||
set_system_prompt=set_system_prompt,
|
set_system_prompt=set_system_prompt,
|
||||||
set_tools=set_visible_tools,
|
set_tools=set_visible_tools,
|
||||||
call_end=call_end,
|
call_end=call_end,
|
||||||
|
session_id=recorder.session_id if recorder else "",
|
||||||
client_tools=client_tools,
|
client_tools=client_tools,
|
||||||
worker=worker,
|
worker=worker,
|
||||||
context_aggregator=WorkflowAggregatorPair(
|
context_aggregator=WorkflowAggregatorPair(
|
||||||
|
|||||||
@@ -27,6 +27,65 @@ class RouteStatus(StrEnum):
|
|||||||
ERROR = "error"
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class ActionStatus(StrEnum):
|
||||||
|
"""Stable Action outcomes used by routing and future debug tooling."""
|
||||||
|
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILURE = "failure"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActionError:
|
||||||
|
"""Machine-readable failure details without losing the operator message."""
|
||||||
|
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
retryable: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ActionOutcome:
|
||||||
|
"""One completed Action invocation.
|
||||||
|
|
||||||
|
``result`` remains an in-memory value because a tool response may contain
|
||||||
|
private business data. Trace events publish only its shape and variable
|
||||||
|
names, never the raw response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
invocation_id: str
|
||||||
|
status: ActionStatus
|
||||||
|
duration_ms: int
|
||||||
|
result: dict[str, Any] | None = None
|
||||||
|
updated_variables: tuple[str, ...] = ()
|
||||||
|
error: ActionError | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_route(self) -> bool:
|
||||||
|
"""Cancellation is a lifecycle outcome, not a failure branch."""
|
||||||
|
|
||||||
|
return self.status != ActionStatus.CANCELLED
|
||||||
|
|
||||||
|
def trace_payload(self) -> dict[str, Any]:
|
||||||
|
"""Return a persistence-safe summary of the execution result."""
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"invocationId": self.invocation_id,
|
||||||
|
"status": self.status.value,
|
||||||
|
"durationMs": self.duration_ms,
|
||||||
|
"updatedVariables": list(self.updated_variables),
|
||||||
|
}
|
||||||
|
if self.result is not None:
|
||||||
|
payload["resultKeys"] = sorted(str(key) for key in self.result)
|
||||||
|
if self.error is not None:
|
||||||
|
payload["error"] = {
|
||||||
|
"code": self.error.code,
|
||||||
|
"message": self.error.message,
|
||||||
|
"retryable": self.error.retryable,
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UserTurn:
|
class UserTurn:
|
||||||
"""One committed user turn that may cross automatic Workflow nodes."""
|
"""One committed user turn that may cross automatic Workflow nodes."""
|
||||||
@@ -89,4 +148,3 @@ class EdgeEvaluation:
|
|||||||
status: RouteStatus
|
status: RouteStatus
|
||||||
edge: dict[str, Any] | None = None
|
edge: dict[str, Any] | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
from pipecat.frames.frames import OutputTransportMessageUrgentFrame, TTSSpeakFrame
|
||||||
from pipecat.utils.time import time_now_iso8601
|
from pipecat.utils.time import time_now_iso8601
|
||||||
@@ -67,6 +68,29 @@ class WorkflowOutput:
|
|||||||
if node_id:
|
if node_id:
|
||||||
await self.emit({"type": "node-active", "nodeId": node_id})
|
await self.emit({"type": "node-active", "nodeId": node_id})
|
||||||
|
|
||||||
|
async def emit_trace(
|
||||||
|
self,
|
||||||
|
event: str,
|
||||||
|
*,
|
||||||
|
revision: str,
|
||||||
|
transition_id: int,
|
||||||
|
**details: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Publish one ordered, machine-readable Workflow runtime event."""
|
||||||
|
|
||||||
|
await self.emit(
|
||||||
|
{
|
||||||
|
"type": "workflow-event",
|
||||||
|
"eventId": f"wfe_{uuid4().hex[:20]}",
|
||||||
|
"event": event,
|
||||||
|
"timestamp": time_now_iso8601(),
|
||||||
|
"sessionId": self._runtime.session_id,
|
||||||
|
"workflowRevision": revision,
|
||||||
|
"transitionId": transition_id,
|
||||||
|
**details,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def emit_variables(
|
async def emit_variables(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from services.node_specs import normalize_graph
|
from services.node_specs import normalize_graph
|
||||||
@@ -32,6 +35,7 @@ class AgentStageConfig:
|
|||||||
class WorkflowEngine:
|
class WorkflowEngine:
|
||||||
def __init__(self, graph: dict[str, Any]):
|
def __init__(self, graph: dict[str, Any]):
|
||||||
self.graph = normalize_graph(graph)
|
self.graph = normalize_graph(graph)
|
||||||
|
self.revision = self._revision_for(self.graph)
|
||||||
self.settings = self.graph.get("settings") or {}
|
self.settings = self.graph.get("settings") or {}
|
||||||
self.nodes: dict[str, dict] = {
|
self.nodes: dict[str, dict] = {
|
||||||
str(node["id"]): node
|
str(node["id"]): node
|
||||||
@@ -48,6 +52,29 @@ class WorkflowEngine:
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _revision_for(normalized_graph: dict[str, Any]) -> str:
|
||||||
|
"""Hash the exact normalized graph used by this runtime."""
|
||||||
|
|
||||||
|
canonical = json.dumps(
|
||||||
|
normalized_graph,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
return f"sha256:{sha256(canonical).hexdigest()}"
|
||||||
|
|
||||||
|
def session_metadata(self) -> dict[str, Any]:
|
||||||
|
"""Pin a conversation to an immutable, inspectable graph snapshot."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"workflow": {
|
||||||
|
"revision": self.revision,
|
||||||
|
"specVersion": self.graph.get("specVersion"),
|
||||||
|
"snapshot": deepcopy(self.graph),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
def has_graph(self) -> bool:
|
def has_graph(self) -> bool:
|
||||||
return bool(self.start_id)
|
return bool(self.start_id)
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ from services.brains.dify_llm import (
|
|||||||
)
|
)
|
||||||
from services.brains.workflow_brain import WorkflowBrain
|
from services.brains.workflow_brain import WorkflowBrain
|
||||||
from services.runtime_variables import prepare_dynamic_config
|
from services.runtime_variables import prepare_dynamic_config
|
||||||
from services.workflow.models import LLMRouteResult, RouteStatus, WorkflowStatus
|
from services.workflow.models import (
|
||||||
|
ActionStatus,
|
||||||
|
LLMRouteResult,
|
||||||
|
RouteStatus,
|
||||||
|
WorkflowStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FakeLLM:
|
class FakeLLM:
|
||||||
@@ -831,11 +836,18 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
set_system_prompt=lambda _prompt: None,
|
set_system_prompt=lambda _prompt: None,
|
||||||
set_tools=lambda _tools: None,
|
set_tools=lambda _tools: None,
|
||||||
call_end=FakeCallEnd(),
|
call_end=FakeCallEnd(),
|
||||||
|
session_id="conv_action",
|
||||||
)
|
)
|
||||||
brain._tools.execute = execute
|
brain._tools.execute = execute
|
||||||
|
|
||||||
await brain._enter_action("lookup_action")
|
outcome = await brain._enter_action("lookup_action")
|
||||||
|
|
||||||
|
self.assertEqual(outcome.status, ActionStatus.SUCCESS)
|
||||||
|
self.assertEqual(outcome.updated_variables, ("order_status",))
|
||||||
|
self.assertEqual(
|
||||||
|
brain._store.values["system__last_action_invocation_id"],
|
||||||
|
outcome.invocation_id,
|
||||||
|
)
|
||||||
variable_events = [
|
variable_events = [
|
||||||
frame.message
|
frame.message
|
||||||
for frame in queued
|
for frame in queued
|
||||||
@@ -845,6 +857,17 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(variable_events[-1]["reason"], "action")
|
self.assertEqual(variable_events[-1]["reason"], "action")
|
||||||
self.assertEqual(variable_events[-1]["changed"], ["order_status"])
|
self.assertEqual(variable_events[-1]["changed"], ["order_status"])
|
||||||
self.assertEqual(variable_events[-1]["variables"], {"order_status": "paid"})
|
self.assertEqual(variable_events[-1]["variables"], {"order_status": "paid"})
|
||||||
|
completed_event = next(
|
||||||
|
frame.message
|
||||||
|
for frame in queued
|
||||||
|
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("event") == "action_completed"
|
||||||
|
)
|
||||||
|
self.assertEqual(completed_event["outcome"]["status"], "success")
|
||||||
|
self.assertEqual(completed_event["outcome"]["updatedVariables"], ["order_status"])
|
||||||
|
self.assertEqual(completed_event["sessionId"], "conv_action")
|
||||||
|
self.assertEqual(completed_event["workflowRevision"], brain._engine.revision)
|
||||||
|
self.assertNotIn("result", completed_event["outcome"])
|
||||||
|
|
||||||
async def test_action_result_assignment_modes_reach_tool_executor(self):
|
async def test_action_result_assignment_modes_reach_tool_executor(self):
|
||||||
tool = RuntimeTool(
|
tool = RuntimeTool(
|
||||||
@@ -958,14 +981,87 @@ class WorkflowBrainTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
brain._tools.execute = execute
|
brain._tools.execute = execute
|
||||||
|
|
||||||
await brain._enter_action("action")
|
outcome = await brain._enter_action("action")
|
||||||
|
|
||||||
|
self.assertEqual(outcome.status, ActionStatus.FAILURE)
|
||||||
|
self.assertEqual(outcome.error.code, "tool_error")
|
||||||
self.assertEqual(brain._store.values["system__last_action_status"], "error")
|
self.assertEqual(brain._store.values["system__last_action_status"], "error")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
brain._store.values["system__last_action_error"],
|
brain._store.values["system__last_action_error"],
|
||||||
"用户关闭了确认弹窗",
|
"用户关闭了确认弹窗",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_cancelled_action_does_not_follow_failure_or_default_edge(self):
|
||||||
|
tool = RuntimeTool(
|
||||||
|
id="client_action",
|
||||||
|
name="客户端操作",
|
||||||
|
function_name="show_message",
|
||||||
|
type="client",
|
||||||
|
)
|
||||||
|
brain = WorkflowBrain(
|
||||||
|
AssistantConfig(
|
||||||
|
type="workflow",
|
||||||
|
graph={
|
||||||
|
"specVersion": 3,
|
||||||
|
"settings": {},
|
||||||
|
"nodes": [
|
||||||
|
{"id": "start", "type": "start", "data": {}},
|
||||||
|
{
|
||||||
|
"id": "action",
|
||||||
|
"type": "action",
|
||||||
|
"data": {"toolId": "client_action"},
|
||||||
|
},
|
||||||
|
{"id": "end", "type": "end", "data": {}},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"id": "after_action",
|
||||||
|
"source": "action",
|
||||||
|
"target": "end",
|
||||||
|
"data": {"mode": "always"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tools=[tool],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued = []
|
||||||
|
|
||||||
|
async def queue_frame(frame):
|
||||||
|
queued.append(frame)
|
||||||
|
|
||||||
|
async def execute(_tool, _arguments, *, result_assignments=None):
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"message": "会话已结束",
|
||||||
|
"updated_variables": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
brain._runtime = BrainRuntime(
|
||||||
|
context=LLMContext(messages=[]),
|
||||||
|
llm=FakeLLM(),
|
||||||
|
queue_frame=queue_frame,
|
||||||
|
set_system_prompt=lambda _prompt: None,
|
||||||
|
set_tools=lambda _tools: None,
|
||||||
|
call_end=FakeCallEnd(),
|
||||||
|
)
|
||||||
|
brain._tools.execute = execute
|
||||||
|
|
||||||
|
config = await brain._resolve_path("action")
|
||||||
|
|
||||||
|
self.assertEqual(config["name"], "action")
|
||||||
|
self.assertEqual(
|
||||||
|
brain._store.values["system__last_action_status"],
|
||||||
|
"cancelled",
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||||
|
and frame.message.get("event") == "edge_selected"
|
||||||
|
for frame in queued
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def test_action_block_policy_only_suppresses_input_while_running(self):
|
async def test_action_block_policy_only_suppresses_input_while_running(self):
|
||||||
tool = RuntimeTool(
|
tool = RuntimeTool(
|
||||||
id="client_action",
|
id="client_action",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import AsyncMock
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from services.conversation_history import ConversationRecorder
|
from services.conversation_history import ConversationRecorder
|
||||||
|
|
||||||
@@ -32,6 +33,53 @@ class ConversationRecorderTest(unittest.IsolatedAsyncioTestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def test_workflow_trace_is_persisted_without_counting_as_message(self):
|
||||||
|
conversation = SimpleNamespace(
|
||||||
|
extra={"workflow": {"revision": "sha256:test"}},
|
||||||
|
message_count=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
committed = False
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_args):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get(self, _model, _session_id):
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
async def commit(self):
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
session = FakeSession()
|
||||||
|
recorder = ConversationRecorder("conv_test")
|
||||||
|
event = {
|
||||||
|
"type": "workflow-event",
|
||||||
|
"eventId": "wfe_test",
|
||||||
|
"event": "node_entered",
|
||||||
|
"timestamp": "2026-08-01T10:00:00+08:00",
|
||||||
|
"workflowRevision": "sha256:test",
|
||||||
|
"transitionId": 0,
|
||||||
|
"nodeId": "start",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"services.conversation_history.SessionLocal",
|
||||||
|
return_value=session,
|
||||||
|
):
|
||||||
|
await recorder.record_transport_message(event)
|
||||||
|
await recorder.record_transport_message(event)
|
||||||
|
|
||||||
|
self.assertTrue(session.committed)
|
||||||
|
self.assertEqual(conversation.message_count, 3)
|
||||||
|
self.assertEqual(len(conversation.extra["workflowTrace"]), 1)
|
||||||
|
saved = conversation.extra["workflowTrace"][0]
|
||||||
|
self.assertEqual(saved["sessionId"], "conv_test")
|
||||||
|
self.assertEqual(saved["sequence"], 1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from copy import deepcopy
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from models import AssistantConfig, RuntimeModelResource
|
from models import AssistantConfig, RuntimeModelResource
|
||||||
@@ -54,6 +55,26 @@ def valid_graph():
|
|||||||
|
|
||||||
|
|
||||||
class WorkflowGraphTests(unittest.TestCase):
|
class WorkflowGraphTests(unittest.TestCase):
|
||||||
|
def test_revision_pins_the_normalized_workflow_snapshot(self):
|
||||||
|
first = WorkflowEngine(valid_graph())
|
||||||
|
same_graph = deepcopy(valid_graph())
|
||||||
|
same_graph["settings"] = dict(reversed(list(same_graph["settings"].items())))
|
||||||
|
second = WorkflowEngine(same_graph)
|
||||||
|
|
||||||
|
self.assertEqual(first.revision, second.revision)
|
||||||
|
self.assertTrue(first.revision.startswith("sha256:"))
|
||||||
|
|
||||||
|
metadata = first.session_metadata()
|
||||||
|
first.graph["settings"]["globalPrompt"] = "运行时外部修改"
|
||||||
|
self.assertEqual(
|
||||||
|
metadata["workflow"]["snapshot"]["settings"]["globalPrompt"],
|
||||||
|
"服务 {{customer}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = valid_graph()
|
||||||
|
changed["settings"]["globalPrompt"] = "另一份配置"
|
||||||
|
self.assertNotEqual(first.revision, WorkflowEngine(changed).revision)
|
||||||
|
|
||||||
def test_workflow_graph_owns_flat_vision_session_flag(self):
|
def test_workflow_graph_owns_flat_vision_session_flag(self):
|
||||||
graph = valid_graph()
|
graph = valid_graph()
|
||||||
graph["settings"].update(
|
graph["settings"].update(
|
||||||
|
|||||||
@@ -278,6 +278,14 @@ export type ConversationMessage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ConversationDetail = Conversation & {
|
export type ConversationDetail = Conversation & {
|
||||||
|
extra: {
|
||||||
|
workflow?: {
|
||||||
|
revision: string;
|
||||||
|
specVersion?: number;
|
||||||
|
snapshot: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
workflowTrace?: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
messages: ConversationMessage[];
|
messages: ConversationMessage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user