223 lines
7.2 KiB
Python
223 lines
7.2 KiB
Python
"""Deterministic Action execution shared by Prompt startup and Workflow."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from time import monotonic
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from models import RuntimeTool
|
|
from services.tool_executor import ToolExecutionError, ToolExecutor
|
|
|
|
|
|
class ActionStatus(StrEnum):
|
|
SUCCESS = "success"
|
|
FAILURE = "failure"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ActionError:
|
|
code: str
|
|
message: str
|
|
retryable: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ActionOutcome:
|
|
"""One completed Action invocation.
|
|
|
|
Raw tool results stay in memory. Persisted or client-visible events should
|
|
use ``trace_payload`` so private business data is not copied accidentally.
|
|
"""
|
|
|
|
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:
|
|
return self.status != ActionStatus.CANCELLED
|
|
|
|
def trace_payload(self) -> dict[str, Any]:
|
|
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
|
|
|
|
|
|
class ActionInvocationCancelled(asyncio.CancelledError):
|
|
"""Carry a structured outcome while preserving task cancellation."""
|
|
|
|
def __init__(self, outcome: ActionOutcome) -> None:
|
|
super().__init__(outcome.error.message if outcome.error else "Action cancelled")
|
|
self.outcome = outcome
|
|
|
|
|
|
class ActionRunner:
|
|
"""Normalize ToolExecutor's heterogeneous responses into ActionOutcome."""
|
|
|
|
_CANCELLED_MARKERS = (
|
|
"会话已结束",
|
|
"会话已取消",
|
|
"管线已停止",
|
|
"通道已关闭",
|
|
"连接已断开",
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
executor: ToolExecutor,
|
|
*,
|
|
is_session_ending: Callable[[], bool] | None = None,
|
|
) -> None:
|
|
self._executor = executor
|
|
self._is_session_ending = is_session_ending or (lambda: False)
|
|
|
|
@staticmethod
|
|
def new_invocation_id() -> str:
|
|
return f"act_{uuid4().hex[:20]}"
|
|
|
|
async def execute(
|
|
self,
|
|
tool: RuntimeTool | None,
|
|
arguments: dict[str, Any] | None = None,
|
|
*,
|
|
result_assignments: dict[str, str] | None = None,
|
|
invocation_id: str | None = None,
|
|
) -> ActionOutcome:
|
|
invocation_id = invocation_id or self.new_invocation_id()
|
|
started_at = monotonic()
|
|
result: dict[str, Any] | None = None
|
|
if tool is None:
|
|
return self._failure(
|
|
invocation_id,
|
|
started_at,
|
|
code="tool_not_found",
|
|
message="Action 引用的工具不存在",
|
|
)
|
|
|
|
try:
|
|
rendered_arguments = self._executor.store.render_data(arguments or {})
|
|
result = await self._executor.execute(
|
|
tool,
|
|
rendered_arguments,
|
|
result_assignments=result_assignments,
|
|
)
|
|
if result.get("status") != "ok":
|
|
returned_status = str(result.get("status") or "error")
|
|
message = str(result.get("message") or "工具返回执行失败状态")
|
|
if self._is_cancelled(message):
|
|
return ActionOutcome(
|
|
invocation_id=invocation_id,
|
|
status=ActionStatus.CANCELLED,
|
|
duration_ms=self._elapsed_ms(started_at),
|
|
result=result,
|
|
error=ActionError(
|
|
code="session_ended",
|
|
message=message[:2048],
|
|
),
|
|
)
|
|
return self._failure(
|
|
invocation_id,
|
|
started_at,
|
|
code=str(result.get("code") or f"tool_{returned_status}"),
|
|
message=message,
|
|
retryable=bool(result.get("retryable", False)),
|
|
result=result,
|
|
)
|
|
return 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 result.get("updated_variables") or []
|
|
),
|
|
)
|
|
except asyncio.CancelledError as exc:
|
|
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 随当前任务取消",
|
|
),
|
|
)
|
|
raise ActionInvocationCancelled(outcome) from exc
|
|
except (ToolExecutionError, ValueError) as exc:
|
|
cancelled = self._is_cancelled(str(exc))
|
|
if cancelled:
|
|
return ActionOutcome(
|
|
invocation_id=invocation_id,
|
|
status=ActionStatus.CANCELLED,
|
|
duration_ms=self._elapsed_ms(started_at),
|
|
result=result,
|
|
error=ActionError(
|
|
code="session_ended",
|
|
message=str(exc)[:2048],
|
|
),
|
|
)
|
|
return self._failure(
|
|
invocation_id,
|
|
started_at,
|
|
code=(
|
|
"invalid_action_configuration"
|
|
if isinstance(exc, ValueError)
|
|
else "tool_execution_error"
|
|
),
|
|
message=str(exc),
|
|
result=result,
|
|
)
|
|
|
|
def _is_cancelled(self, message: str) -> bool:
|
|
return self._is_session_ending() or any(
|
|
marker in message for marker in self._CANCELLED_MARKERS
|
|
)
|
|
|
|
def _failure(
|
|
self,
|
|
invocation_id: str,
|
|
started_at: float,
|
|
*,
|
|
code: str,
|
|
message: str,
|
|
retryable: bool = False,
|
|
result: dict[str, Any] | None = None,
|
|
) -> ActionOutcome:
|
|
return ActionOutcome(
|
|
invocation_id=invocation_id,
|
|
status=ActionStatus.FAILURE,
|
|
duration_ms=self._elapsed_ms(started_at),
|
|
result=result,
|
|
error=ActionError(
|
|
code=code,
|
|
message=message[:2048],
|
|
retryable=retryable,
|
|
),
|
|
)
|
|
|
|
@staticmethod
|
|
def _elapsed_ms(started_at: float) -> int:
|
|
return max(0, round((monotonic() - started_at) * 1000))
|