feat: add workflow action outcomes and tracing

This commit is contained in:
Xin Wang
2026-08-01 11:21:31 +08:00
parent ad5ff061bb
commit b747144ff1
13 changed files with 558 additions and 32 deletions

View File

@@ -5,7 +5,9 @@ from __future__ import annotations
import asyncio
from copy import deepcopy
from dataclasses import replace
from time import monotonic
from typing import Any
from uuid import uuid4
from loguru import logger
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.workflow.agent import WorkflowAgentStage
from services.workflow.models import (
ActionError,
ActionOutcome,
ActionStatus,
RouteStatus,
WorkflowRuntimeState,
WorkflowStatus,
@@ -53,6 +58,23 @@ from services.workflow_router import WorkflowLLMRouter
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):
"""Preserve Flow transitions while suppressing late async-tool replies."""
@@ -257,7 +279,7 @@ class WorkflowBrain(BaseBrain):
)
if current_node != self._state.current_node_id:
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(
reason="client_ready",
node_id=current_node,
@@ -643,7 +665,7 @@ class WorkflowBrain(BaseBrain):
*,
triggering_user_text: str = "",
) -> NodeConfig:
self._state.begin_transition()
await self._begin_edge_transition(edge)
leading_messages: list[dict[str, str]] = []
speech = self._engine.edge_transition_speech(edge)
if speech:
@@ -690,7 +712,9 @@ class WorkflowBrain(BaseBrain):
await self._enter_end(node_id)
return self._passive_node_config(node_id, context_messages)
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":
await self._enter_handoff(node_id)
elif node_type == "start":
@@ -703,7 +727,7 @@ class WorkflowBrain(BaseBrain):
edge = await self._select_edge(node_id)
if not edge:
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)
if speech:
content = self._store.render(speech).strip()
@@ -720,11 +744,13 @@ class WorkflowBrain(BaseBrain):
node_id = str(edge.get("target") or "")
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)
await self._emit_node_active(node_id)
data = self._engine.data(node_id)
runtime = self._require_runtime()
invocation_id = f"act_{uuid4().hex[:20]}"
started_at = monotonic()
block_user_input = data.get("userInputPolicy") == "block"
if block_user_input and runtime.set_input_enabled:
# Blocking only suppresses new audio/text input while the Action
@@ -734,9 +760,20 @@ class WorkflowBrain(BaseBrain):
runtime.set_input_enabled(False)
tool_id = str(data.get("toolId") or "")
tool = self._tool_by_id.get(tool_id)
result: dict[str, Any] | None = None
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:
raise ToolExecutionError(f"工具不存在:{tool_id}")
raise _ActionFailure(
f"工具不存在:{tool_id}",
code="tool_not_found",
)
arguments = self._store.render_data(data.get("arguments") or {})
result = await self._tools.execute(
tool,
@@ -744,8 +781,12 @@ class WorkflowBrain(BaseBrain):
result_assignments=self._action_result_assignments(data),
)
if result.get("status") != "ok":
raise ToolExecutionError(
str(result.get("message") or "工具返回执行失败状态")
returned_status = str(result.get("status") or "error")
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 [])
if updated_variables:
@@ -754,14 +795,114 @@ class WorkflowBrain(BaseBrain):
node_id=node_id,
changed=updated_variables,
)
self._store.values["system__last_action_status"] = "ok"
self._store.values["system__last_action_error"] = ""
except (ToolExecutionError, ValueError) as exc:
self._store.values["system__last_action_status"] = "error"
self._store.values["system__last_action_error"] = str(exc)[:2048]
outcome = ActionOutcome(
invocation_id=invocation_id,
status=ActionStatus.SUCCESS,
duration_ms=self._elapsed_ms(started_at),
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:
if block_user_input and runtime.set_input_enabled:
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
def _action_result_assignments(
@@ -815,6 +956,12 @@ class WorkflowBrain(BaseBrain):
data = self._engine.data(node_id)
message = self._store.render(str(data.get("message") or ""))
scope = str(data.get("scope") or "session")
await self._emit_trace(
"workflow_ended",
nodeId=node_id,
scope=scope,
outcome="success",
)
if scope == "flow":
await runtime.queue_frame(
OutputTransportMessageUrgentFrame(
@@ -835,8 +982,46 @@ class WorkflowBrain(BaseBrain):
else:
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)
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(
self,
@@ -851,6 +1036,19 @@ class WorkflowBrain(BaseBrain):
node_id=node_id,
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:
if self._runtime is None: