feat: add workflow action outcomes and tracing
This commit is contained in:
@@ -83,6 +83,7 @@ class BrainRuntime:
|
||||
set_system_prompt: Callable[[str], None]
|
||||
set_tools: Callable[[list[FunctionSchema] | None], None]
|
||||
call_end: CallEndPort
|
||||
session_id: str = ""
|
||||
client_tools: ClientToolPort | None = None
|
||||
worker: Any = None
|
||||
context_aggregator: Any = None
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user