feat: 新增AgentRun终态感知能力与配套优化
本次提交实现了AgentRun全生命周期终态处理能力,覆盖以下核心变更: 1. 新增4种AgentRun终态错误类型与HTTP状态映射,透传上游错误上下文 2. 新增AgentRun终态信息查询接口与DTO,支持非事件流场景下的状态感知 3. 优化沙箱路径转义规则,兼容服务账号UID格式 4. 新增出站管道SKIP逻辑,针对取消/中断场景避免无效重试 5. 补充完整单元测试覆盖所有新功能与优化点
This commit is contained in:
parent
08617091dc
commit
59cc1daee1
@ -15,6 +15,9 @@ from yuxi.utils.paths import (
|
||||
)
|
||||
|
||||
_SAFE_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
# 与 docker/sandbox_provisioner/app.py 中的 UNSAFE_PATH_SEGMENT_CHAR_RE 保持一致,
|
||||
# 确保宿主机路径与容器挂载路径采用相同的转义规则。
|
||||
_UNSAFE_PATH_SEGMENT_CHAR_RE = re.compile(r"[^A-Za-z0-9_-]")
|
||||
|
||||
|
||||
def get_virtual_path_prefix() -> str:
|
||||
@ -35,18 +38,23 @@ def _thread_root_dir(thread_id: str) -> Path:
|
||||
return Path(conf.save_dir) / "threads" / safe_thread_id / "user-data"
|
||||
|
||||
|
||||
def _validate_uid(uid: str) -> str:
|
||||
value = str(uid or "").strip()
|
||||
if not value:
|
||||
def _sanitize_uid(uid: str) -> str:
|
||||
"""将 uid 转义为文件系统安全的路径组件。
|
||||
|
||||
服务账号 uid 格式 ``svc:channel:{channel_type}:{account_id}`` 含冒号,
|
||||
用作宿主机路径组件时需转义为 ``_``,与 sandbox_provisioner.app.
|
||||
LocalContainerProvisionerBackend._validate_uid 保持一致的转义规则,
|
||||
确保宿主机路径与容器挂载路径映射一致。
|
||||
"""
|
||||
candidate = str(uid or "").strip()
|
||||
if not candidate:
|
||||
raise ValueError("uid is required")
|
||||
if not _SAFE_ID_RE.match(value):
|
||||
raise ValueError("uid contains invalid characters")
|
||||
return value
|
||||
return _UNSAFE_PATH_SEGMENT_CHAR_RE.sub("_", candidate)
|
||||
|
||||
|
||||
def _global_user_data_dir(uid: str) -> Path:
|
||||
"""Return the shared host-side directory used for one user's workspace files."""
|
||||
safe_uid = _validate_uid(uid)
|
||||
safe_uid = _sanitize_uid(uid)
|
||||
return Path(conf.save_dir) / "threads" / "shared" / safe_uid
|
||||
|
||||
|
||||
|
||||
@ -31,7 +31,11 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from yuxi.channels.contract.dtos.messaging.common import MessageContent
|
||||
from yuxi.channels.contract.dtos.plugin.tools import ChannelTool
|
||||
from yuxi.channels.contract.dtos.shared.agent_run import AgentRunCmd, AgentRunId
|
||||
from yuxi.channels.contract.dtos.shared.agent_run import (
|
||||
AgentRunCmd,
|
||||
AgentRunId,
|
||||
AgentRunTerminalInfo,
|
||||
)
|
||||
from yuxi.channels.contract.dtos.shared.option import Nothing, Option, Some
|
||||
from yuxi.channels.contract.dtos.transport.stream_event import StreamEvent
|
||||
from yuxi.channels.contract.errors import (
|
||||
@ -556,3 +560,56 @@ class AgentRunAdapter(AgentRunPort):
|
||||
if text:
|
||||
return Some(text)
|
||||
return Nothing()
|
||||
|
||||
async def getAgentRunTerminalInfo(
|
||||
self,
|
||||
run_id: AgentRunId,
|
||||
) -> Option[AgentRunTerminalInfo]:
|
||||
"""获取 AgentRun 终态信息。
|
||||
|
||||
委托 ``execution_port.getRunTerminalInfo`` 查询 ``agent_runs`` 表当前
|
||||
状态,仅在终态(completed/failed/cancelled/interrupted)时返回
|
||||
``Some(AgentRunTerminalInfo)``,非终态或记录不存在时返回 ``Nothing``。
|
||||
|
||||
供 ``LoadBuildStage`` 在事件流为空时根据 AgentRun 终态决定后续处理
|
||||
(透传上游错误上下文 / SKIP 投递),避免下游
|
||||
``ValidationError("content", "must not be empty")`` 丢失上下文。
|
||||
|
||||
与 ``getAgentRunFinalOutput`` 的区别:本方法读取 ``agent_runs`` 表
|
||||
状态字段(DB),不读取 Redis Stream 内容文本;两者数据源不同、
|
||||
用途不同。
|
||||
|
||||
Args:
|
||||
run_id: Agent 运行 ID。
|
||||
|
||||
Returns:
|
||||
终态返回 ``Some(AgentRunTerminalInfo)``;非终态或记录不存在
|
||||
返回 ``Nothing``。
|
||||
|
||||
Raises:
|
||||
DependencyError: 依赖服务(DB)故障。
|
||||
"""
|
||||
try:
|
||||
async with self._session_scope(None) as session:
|
||||
info = await self._execution_port.getRunTerminalInfo(run_id.value, session)
|
||||
except (ValidationError, DependencyError, NotFoundError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
await self._logger.error(
|
||||
"agent_run_terminal_info_failed",
|
||||
resource="agent_runs",
|
||||
run_id=run_id.value,
|
||||
error=str(exc),
|
||||
)
|
||||
raise DependencyError("agent_runs", cause=Error(str(exc))) from exc
|
||||
|
||||
if info is None:
|
||||
return Nothing()
|
||||
return Some(
|
||||
AgentRunTerminalInfo(
|
||||
status=str(info.get("status") or ""),
|
||||
agent_id=str(info.get("agent_id") or ""),
|
||||
error_type=info.get("error_type"),
|
||||
error_message=info.get("error_message"),
|
||||
)
|
||||
)
|
||||
|
||||
@ -21,6 +21,7 @@ from yuxi.channels.contract.ports.driven.shared.agent_run_execution_port import
|
||||
)
|
||||
from yuxi.services.agent_run_service import (
|
||||
create_agent_run_view,
|
||||
get_run_terminal_info,
|
||||
get_run_uid,
|
||||
stream_agent_run_events,
|
||||
)
|
||||
@ -137,6 +138,18 @@ class AgentRunExecutionAdapter(AgentRunExecutionPort):
|
||||
"""委托 ``agent_run_service.get_run_uid`` 查询运行记录的 uid。"""
|
||||
return await get_run_uid(run_id, db)
|
||||
|
||||
async def getRunTerminalInfo(
|
||||
self,
|
||||
run_id: str,
|
||||
db: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
"""委托 ``agent_run_service.get_run_terminal_info`` 查询运行记录的终态信息。
|
||||
|
||||
返回 ``{status, agent_id, error_type, error_message}`` 字典;非终态
|
||||
或记录不存在时返回 ``None``。
|
||||
"""
|
||||
return await get_run_terminal_info(run_id, db)
|
||||
|
||||
def streamAgentRunEvents(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -192,6 +192,10 @@ class AgentRunEnqueueStage:
|
||||
self.logger = logger
|
||||
self.event_publisher = event_publisher
|
||||
self.masking_port = masking_port
|
||||
# message ops 适配器缺失告警去重(C-N1):按 channel_type 首次缺失时
|
||||
# 记录 WARNING 提示运维,后续同一 channel_type 的入站消息降级为 DEBUG,
|
||||
# 避免 wechat_woc 等不支持消息操作的渠道在每条消息上都输出 WARNING 噪声。
|
||||
self._message_ops_missing_warned: set[ChannelType] = set()
|
||||
|
||||
async def process(self, context: InboundContext) -> bool:
|
||||
"""创建 Agent 运行。
|
||||
@ -630,13 +634,27 @@ class AgentRunEnqueueStage:
|
||||
|
||||
adapter = self.message_ops_adapter_registry.get(context.channel_type)
|
||||
if adapter is None:
|
||||
# C-N1: 按 channel_type 去重 WARNING 告警。首次缺失时记录 WARNING
|
||||
# 提示运维(可能是配置遗漏),后续同一 channel_type 的入站消息降级
|
||||
# 为 DEBUG(属于"渠道不支持消息操作"的稳定状态,无需重复告警)。
|
||||
# 典型场景:wechat_woc 不支持消息操作,注册表无对应 adapter,
|
||||
# 首条消息 WARNING 后续 DEBUG,避免日志噪声。
|
||||
if self.logger is not None:
|
||||
await self.logger.warning(
|
||||
"message ops adapter not registered, skip message ops enrichment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
account_id=context.account_id,
|
||||
)
|
||||
if context.channel_type not in self._message_ops_missing_warned:
|
||||
self._message_ops_missing_warned.add(context.channel_type)
|
||||
await self.logger.warning(
|
||||
"message ops adapter not registered, skip message ops enrichment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
account_id=context.account_id,
|
||||
)
|
||||
else:
|
||||
await self.logger.debug(
|
||||
"message ops adapter not registered, skip message ops enrichment",
|
||||
trace_id=context.trace_id,
|
||||
channel_type=context.channel_type,
|
||||
account_id=context.account_id,
|
||||
)
|
||||
return run_context
|
||||
|
||||
try:
|
||||
|
||||
@ -15,7 +15,13 @@ from yuxi.channels.application.messaging.context.outbound_context import Outboun
|
||||
from yuxi.channels.contract.dtos.messaging.common import Attachment
|
||||
from yuxi.channels.contract.dtos.messaging.outbound import OutboundPayload, RichMessageFields
|
||||
from yuxi.channels.contract.dtos.shared.agent_run import AgentRunId
|
||||
from yuxi.channels.contract.dtos.shared.option import Some
|
||||
from yuxi.channels.contract.dtos.shared.option import Nothing, Some
|
||||
from yuxi.channels.contract.errors import (
|
||||
AgentRunCancelledError,
|
||||
AgentRunEmptyOutputError,
|
||||
AgentRunFailedError,
|
||||
AgentRunInterruptedError,
|
||||
)
|
||||
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
|
||||
from yuxi.channels.contract.ports.driven.shared.agent_run_port import AgentRunPort
|
||||
|
||||
@ -131,9 +137,35 @@ class LoadBuildStage:
|
||||
已有的 payload 提取逻辑(避免重复解析 StreamEvent payload
|
||||
嵌套结构)。
|
||||
|
||||
AgentRun 终态感知:当 ``getAgentRunFinalOutput`` 返回 ``Nothing``
|
||||
(事件流无 ``messages`` 事件)时,调用 ``getAgentRunTerminalInfo``
|
||||
查询 AgentRun 当前终态,按终态抛出对应的领域错误,透传真实失败
|
||||
原因到 outbound pipeline 调用方:
|
||||
|
||||
- ``failed`` → ``AgentRunFailedError``(携带 run_error_type /
|
||||
run_error_message,如 ``worker_error: uid contains invalid
|
||||
characters``),调用方按 delivery failure 处理;
|
||||
- ``cancelled`` → ``AgentRunCancelledError``,调用方应 SKIP 后续
|
||||
投递(用户主动取消,无需重试);
|
||||
- ``interrupted`` → ``AgentRunInterruptedError``,调用方应 SKIP
|
||||
后续投递(等待用户输入,有专门通知链路);
|
||||
- ``completed`` 但输出为空 → ``AgentRunEmptyOutputError``,表明
|
||||
Agent 配置异常或 LLM 返回空内容(如仅产出 ``reasoning_content``
|
||||
或全程只发起 tool_call),需排查 agent 配置与模型行为;
|
||||
- 非终态(``pending`` / ``running``)或记录不存在 → 返回
|
||||
``Nothing``,本方法不抛错,由下游 ``PrefixStage`` 按原有逻辑
|
||||
抛 ``ValidationError("content", "must not be empty")``。这是
|
||||
极端竞态(DB 状态滞后于事件流)的兜底路径,不影响主流程。
|
||||
|
||||
参数:
|
||||
context: 出站管道上下文,读取 ``agent_run_id``,写入
|
||||
``stream_chunks``。
|
||||
|
||||
抛出:
|
||||
AgentRunFailedError: AgentRun 终态为 ``failed``。
|
||||
AgentRunCancelledError: AgentRun 终态为 ``cancelled``。
|
||||
AgentRunInterruptedError: AgentRun 终态为 ``interrupted``。
|
||||
AgentRunEmptyOutputError: AgentRun 终态为 ``completed`` 但输出为空。
|
||||
"""
|
||||
run_id = AgentRunId(value=context.agent_run_id)
|
||||
# 步骤 1:阻塞消费至完成(不提取内容,仅等待 end 信号)
|
||||
@ -144,3 +176,57 @@ class LoadBuildStage:
|
||||
result = await self._agent_run_port.getAgentRunFinalOutput(run_id)
|
||||
if isinstance(result, Some):
|
||||
context.stream_chunks.append(result.value)
|
||||
return
|
||||
# 步骤 3:输出为空时查询 AgentRun 终态,按终态抛出领域错误透传上下文
|
||||
assert isinstance(result, Nothing)
|
||||
await self._raiseOnTerminalStatus(context, run_id)
|
||||
|
||||
async def _raiseOnTerminalStatus(
|
||||
self,
|
||||
context: OutboundContext,
|
||||
run_id: AgentRunId,
|
||||
) -> None:
|
||||
"""根据 AgentRun 终态抛出对应领域错误。
|
||||
|
||||
仅在 ``getAgentRunFinalOutput`` 返回 ``Nothing`` 时调用。查询
|
||||
``agent_runs`` 表当前状态,按终态抛出对应错误。非终态或记录不存在
|
||||
时不抛错(由下游 ``PrefixStage`` 兜底抛 ``ValidationError``)。
|
||||
"""
|
||||
info_option = await self._agent_run_port.getAgentRunTerminalInfo(run_id)
|
||||
if not isinstance(info_option, Some):
|
||||
# 非终态或记录不存在:不抛错,下游 PrefixStage 兜底
|
||||
return
|
||||
info = info_option.value
|
||||
trace_id = context.trace_id
|
||||
run_id_str = run_id.value
|
||||
agent_id = info.agent_id
|
||||
|
||||
if info.status == "failed":
|
||||
raise AgentRunFailedError(
|
||||
run_id=run_id_str,
|
||||
agent_id=agent_id,
|
||||
error_type=info.error_type or "unknown",
|
||||
error_message=info.error_message or "",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
if info.status == "cancelled":
|
||||
raise AgentRunCancelledError(
|
||||
run_id=run_id_str,
|
||||
agent_id=agent_id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
if info.status == "interrupted":
|
||||
raise AgentRunInterruptedError(
|
||||
run_id=run_id_str,
|
||||
agent_id=agent_id,
|
||||
error_message=info.error_message or "",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
if info.status == "completed":
|
||||
raise AgentRunEmptyOutputError(
|
||||
run_id=run_id_str,
|
||||
agent_id=agent_id,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
# 其他终态值(理论不存在,因 TERMINAL_RUN_STATUSES 仅含 4 种):
|
||||
# 不抛错,下游 PrefixStage 兜底
|
||||
|
||||
@ -49,6 +49,8 @@ from yuxi.channels.contract.dtos.messaging.inbound import (
|
||||
SignatureVerifyResult,
|
||||
)
|
||||
from yuxi.channels.contract.errors import (
|
||||
AgentRunCancelledError,
|
||||
AgentRunInterruptedError,
|
||||
ConflictError,
|
||||
Error,
|
||||
IdempotencyConflictError,
|
||||
@ -681,6 +683,28 @@ class InboundMessageService:
|
||||
await self._cache_port.releaseAdvisoryLock(lock_token)
|
||||
|
||||
if not ok:
|
||||
# SKIP 语义:AgentRun cancelled / interrupted 由 LoadBuildStage
|
||||
# 抛出对应领域错误。此类"失败"并非出站投递故障,而是用户主动
|
||||
# 取消(cancelled)或 AgentRun 等待用户输入(interrupted,有
|
||||
# 专门通知链路),不应触发 NACK / 幂等回退 / 重试,仅记录 INFO
|
||||
# 日志说明跳过原因并正常结束出站链路。其余错误(含
|
||||
# ``AgentRunFailedError`` / ``AgentRunEmptyOutputError``)
|
||||
# 仍按 delivery failure 处理,透传真实失败原因到错误日志。
|
||||
if isinstance(err, (AgentRunCancelledError, AgentRunInterruptedError)):
|
||||
await self._logger.info(
|
||||
"outbound pipeline skipped due to agent run terminal state",
|
||||
trace_id=ctx.trace_id,
|
||||
agent_run_id=ctx.agent_run_id,
|
||||
channel_type=ctx.channel_type,
|
||||
account_id=ctx.account_id,
|
||||
skip_reason=err.error_code,
|
||||
run_id=getattr(err, "run_id", ""),
|
||||
agent_id=getattr(err, "agent_id", ""),
|
||||
)
|
||||
# 跳过 _handleOutboundFailure:不设置 NACK、不回退幂等记录。
|
||||
# 出站投递异步化后 ack_decision 保持入站管道决策(ACK 互斥
|
||||
# 语义不变),allow transport 层继续接收下一条消息。
|
||||
return
|
||||
error_occurred = True
|
||||
err_msg = err.message if err is not None else "pipeline returned failure without error"
|
||||
await self._logger.error(
|
||||
|
||||
@ -34,6 +34,32 @@ class AgentRunId:
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentRunTerminalInfo:
|
||||
"""AgentRun 终态信息。
|
||||
|
||||
由 ``AgentRunPort.getAgentRunTerminalInfo`` 返回,携带 AgentRun 的最终
|
||||
状态与错误信息,供 ``LoadBuildStage`` 根据 AgentRun 终态决定后续处理
|
||||
(正常投递 / 抛出领域错误 / 跳过投递),避免 AgentRun 失败时下游
|
||||
抛出无上下文的 ``ValidationError("content", "must not be empty")``。
|
||||
|
||||
字段:
|
||||
status: AgentRun 终态(``completed`` / ``failed`` / ``cancelled``
|
||||
/ ``interrupted``)。由 ``agent_runs.status`` 字段读取,仅
|
||||
返回终态值。
|
||||
agent_id: Agent slug,用于错误信息上下文(默认空串)。
|
||||
error_type: 失败时的错误类型(仅 ``failed`` / ``interrupted``
|
||||
携带,默认 None)。
|
||||
error_message: 失败时的错误消息(仅 ``failed`` / ``interrupted``
|
||||
携带,默认 None)。
|
||||
"""
|
||||
|
||||
status: str
|
||||
agent_id: str = ""
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelFormatSpec:
|
||||
"""渠道格式规格。
|
||||
|
||||
@ -36,6 +36,10 @@ from yuxi.channels.contract.errors.domain import (
|
||||
AgentCollaborationError,
|
||||
AgentHandoffFailedError,
|
||||
AgentNotAvailableError,
|
||||
AgentRunCancelledError,
|
||||
AgentRunEmptyOutputError,
|
||||
AgentRunFailedError,
|
||||
AgentRunInterruptedError,
|
||||
BotLoopBudgetExceededError,
|
||||
CapabilityNotProvenError,
|
||||
ChannelDegradedError,
|
||||
@ -126,6 +130,11 @@ __all__ = [
|
||||
"AgentCollaborationError",
|
||||
"AgentNotAvailableError",
|
||||
"AgentHandoffFailedError",
|
||||
# AgentRun 终态感知相关错误(P0:AgentRun 失败时透传上游错误上下文)
|
||||
"AgentRunFailedError",
|
||||
"AgentRunEmptyOutputError",
|
||||
"AgentRunCancelledError",
|
||||
"AgentRunInterruptedError",
|
||||
"ContentReviewError",
|
||||
"ContentViolationError",
|
||||
"CredentialNotFoundError",
|
||||
|
||||
@ -61,4 +61,15 @@ CHANNEL_ERROR_STATUS_MAP: dict[str, int] = {
|
||||
"ILLEGAL_STATE": 409,
|
||||
# 媒体下载大小限制相关错误(N-M2)
|
||||
"PAYLOAD_TOO_LARGE": 413,
|
||||
# AgentRun 终态感知相关错误(P0:AgentRun 失败时透传上游错误上下文)
|
||||
# - AGENT_RUN_FAILED / AGENT_RUN_EMPTY_OUTPUT:上游 Agent 执行失败或产出
|
||||
# 空内容,502 Bad Gateway 语义(上游依赖故障)。
|
||||
# - AGENT_RUN_CANCELLED:AgentRun 已被取消(用户主动终止),410 Gone 语义。
|
||||
# 经 ``InboundMessageService`` SKIP 后通常不触达 HTTP 层,仅作完整性映射。
|
||||
# - AGENT_RUN_INTERRUPTED:AgentRun 被挂起等待用户输入(ask_user_question /
|
||||
# human_approval),409 Conflict 语义,需要客户端解决状态冲突。
|
||||
"AGENT_RUN_FAILED": 502,
|
||||
"AGENT_RUN_EMPTY_OUTPUT": 502,
|
||||
"AGENT_RUN_CANCELLED": 410,
|
||||
"AGENT_RUN_INTERRUPTED": 409,
|
||||
}
|
||||
|
||||
@ -23,6 +23,10 @@ from yuxi.channels.contract.errors.domain.agent import (
|
||||
AgentCollaborationError,
|
||||
AgentHandoffFailedError,
|
||||
AgentNotAvailableError,
|
||||
AgentRunCancelledError,
|
||||
AgentRunEmptyOutputError,
|
||||
AgentRunFailedError,
|
||||
AgentRunInterruptedError,
|
||||
)
|
||||
from yuxi.channels.contract.errors.domain.base import (
|
||||
BotLoopBudgetExceededError,
|
||||
@ -101,6 +105,10 @@ __all__ = [
|
||||
"AgentCollaborationError",
|
||||
"AgentNotAvailableError",
|
||||
"AgentHandoffFailedError",
|
||||
"AgentRunFailedError",
|
||||
"AgentRunEmptyOutputError",
|
||||
"AgentRunCancelledError",
|
||||
"AgentRunInterruptedError",
|
||||
# content
|
||||
"ContentReviewError",
|
||||
"ContentViolationError",
|
||||
|
||||
@ -2,10 +2,19 @@
|
||||
|
||||
定义 ``AgentCollaborationError`` 基类及其 2 个子类,覆盖多 Agent 协作场景
|
||||
中目标 Agent 不可用、移交失败等异常。所有错误继承 ``DomainError``。
|
||||
|
||||
另定义 ``AgentRunFailedError`` / ``AgentRunEmptyOutputError`` /
|
||||
``AgentRunCancelledError`` / ``AgentRunInterruptedError`` 四个 AgentRun
|
||||
终态相关错误,由 ``LoadBuildStage`` 在 AgentRun 进入非正常终态时抛出,
|
||||
透传真实失败原因(run_id / agent_id / error_type / error_message)到
|
||||
outbound pipeline 调用方,避免下游 ``PrefixStage`` 抛出无上下文的
|
||||
``ValidationError("content", "must not be empty")``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from yuxi.channels.contract.errors.domain.base import DomainError
|
||||
|
||||
|
||||
@ -35,3 +44,146 @@ class AgentHandoffFailedError(AgentCollaborationError):
|
||||
"""
|
||||
|
||||
error_code = "AGENT_HANDOFF_FAILED"
|
||||
|
||||
|
||||
class AgentRunFailedError(DomainError):
|
||||
"""AgentRun 执行失败错误。
|
||||
|
||||
AgentRun 终态为 ``failed`` 时由 ``LoadBuildStage`` 抛出,携带
|
||||
``run_id`` / ``agent_id`` / ``run_error_type`` / ``run_error_message``,
|
||||
透传 Agent 失败的真实原因(如 ``worker_error: uid contains invalid
|
||||
characters``)到 outbound pipeline 调用方,避免下游 ``PrefixStage``
|
||||
抛出无上下文的 ``ValidationError("content", "must not be empty")``。
|
||||
|
||||
调用方(``InboundMessageService._deliverAgentResponse``)应将此错误
|
||||
当作 delivery failure 处理(与原有 ``ok=False`` 路径一致),但错误
|
||||
日志会包含真实失败原因,便于运维排查。
|
||||
"""
|
||||
|
||||
error_code = "AGENT_RUN_FAILED"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
error_type: str,
|
||||
error_message: str,
|
||||
trace_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
f"AgentRun {run_id} failed (agent={agent_id}, "
|
||||
f"error_type={error_type}): {error_message}",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
self.run_id = run_id
|
||||
self.agent_id = agent_id
|
||||
self.run_error_type = error_type
|
||||
self.run_error_message = error_message
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = super().to_dict()
|
||||
data["run_id"] = self.run_id
|
||||
data["agent_id"] = self.agent_id
|
||||
data["run_error_type"] = self.run_error_type
|
||||
data["run_error_message"] = self.run_error_message
|
||||
return data
|
||||
|
||||
|
||||
class AgentRunEmptyOutputError(DomainError):
|
||||
"""AgentRun 完成但无输出错误。
|
||||
|
||||
AgentRun 终态为 ``completed`` 但 ``getAgentRunFinalOutput`` 返回
|
||||
``Nothing`` 时由 ``LoadBuildStage`` 抛出,表明 Agent 配置异常或 LLM
|
||||
返回空内容(如仅产出 ``reasoning_content`` 或全程只发起 tool_call),
|
||||
需要排查 agent 配置与模型行为。
|
||||
"""
|
||||
|
||||
error_code = "AGENT_RUN_EMPTY_OUTPUT"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
trace_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
f"AgentRun {run_id} completed but produced no output (agent={agent_id})",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
self.run_id = run_id
|
||||
self.agent_id = agent_id
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = super().to_dict()
|
||||
data["run_id"] = self.run_id
|
||||
data["agent_id"] = self.agent_id
|
||||
return data
|
||||
|
||||
|
||||
class AgentRunCancelledError(DomainError):
|
||||
"""AgentRun 被取消错误。
|
||||
|
||||
AgentRun 终态为 ``cancelled`` 时由 ``LoadBuildStage`` 抛出。调用方
|
||||
(``InboundMessageService._deliverAgentResponse``)应捕获此错误并
|
||||
跳过 delivery failure 处理(用户已主动取消,无需再发消息、无需重试),
|
||||
仅记录 INFO 级日志说明跳过原因。
|
||||
"""
|
||||
|
||||
error_code = "AGENT_RUN_CANCELLED"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
trace_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
f"AgentRun {run_id} cancelled (agent={agent_id})",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
self.run_id = run_id
|
||||
self.agent_id = agent_id
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = super().to_dict()
|
||||
data["run_id"] = self.run_id
|
||||
data["agent_id"] = self.agent_id
|
||||
return data
|
||||
|
||||
|
||||
class AgentRunInterruptedError(DomainError):
|
||||
"""AgentRun 被中断错误。
|
||||
|
||||
AgentRun 终态为 ``interrupted``(需要用户追问 / 人工审批)时由
|
||||
``LoadBuildStage`` 抛出。此类 AgentRun 不应走持久化 outbound 投递
|
||||
路径(追问/审批有专门的通知链路),调用方应捕获此错误并跳过 delivery
|
||||
failure 处理,仅记录 INFO 级日志说明跳过原因。
|
||||
"""
|
||||
|
||||
error_code = "AGENT_RUN_INTERRUPTED"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
run_id: str,
|
||||
agent_id: str,
|
||||
error_message: str = "",
|
||||
trace_id: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
f"AgentRun {run_id} interrupted (agent={agent_id}): {error_message}",
|
||||
trace_id=trace_id,
|
||||
)
|
||||
self.run_id = run_id
|
||||
self.agent_id = agent_id
|
||||
self.run_error_message = error_message
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = super().to_dict()
|
||||
data["run_id"] = self.run_id
|
||||
data["agent_id"] = self.agent_id
|
||||
data["run_error_message"] = self.run_error_message
|
||||
return data
|
||||
|
||||
@ -96,7 +96,7 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = (
|
||||
required=False,
|
||||
default=False,
|
||||
hot_reloadable=True,
|
||||
scope=ConfigScope.CHANNEL,
|
||||
scope=ConfigScope.GLOBAL,
|
||||
title="Agent 提示开关",
|
||||
description="是否在消息中附加 Agent 提示上下文。",
|
||||
category="功能开关",
|
||||
@ -107,7 +107,7 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = (
|
||||
required=False,
|
||||
default=False,
|
||||
hot_reloadable=True,
|
||||
scope=ConfigScope.CHANNEL,
|
||||
scope=ConfigScope.GLOBAL,
|
||||
title="渠道工具开关",
|
||||
description="是否允许 Agent 调用渠道相关工具。",
|
||||
category="功能开关",
|
||||
@ -119,7 +119,7 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = (
|
||||
required=False,
|
||||
default=True,
|
||||
hot_reloadable=True,
|
||||
scope=ConfigScope.CHANNEL,
|
||||
scope=ConfigScope.GLOBAL,
|
||||
title="消息操作开关",
|
||||
description="总开关:是否启用消息操作(反应、置顶、卡片更新等)。",
|
||||
category="功能开关",
|
||||
@ -130,7 +130,7 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = (
|
||||
required=False,
|
||||
default=False,
|
||||
hot_reloadable=True,
|
||||
scope=ConfigScope.CHANNEL,
|
||||
scope=ConfigScope.ACCOUNT,
|
||||
title="流式响应开关",
|
||||
description="是否启用流式(打字机效果)响应输出。",
|
||||
category="功能开关",
|
||||
@ -141,7 +141,7 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = (
|
||||
required=False,
|
||||
default=True,
|
||||
hot_reloadable=True,
|
||||
scope=ConfigScope.CHANNEL,
|
||||
scope=ConfigScope.ACCOUNT,
|
||||
title="输入中提示开关",
|
||||
description="是否向用户展示'正在输入'状态提示。",
|
||||
category="功能开关",
|
||||
@ -292,7 +292,7 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = (
|
||||
required=False,
|
||||
default=200,
|
||||
hot_reloadable=True,
|
||||
scope=ConfigScope.GLOBAL,
|
||||
scope=ConfigScope.ACCOUNT,
|
||||
title="流式最小间隔",
|
||||
description="流式响应相邻分块之间的最小间隔(毫秒)。",
|
||||
category="流式与传输体验",
|
||||
@ -303,7 +303,7 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = (
|
||||
required=False,
|
||||
default=60,
|
||||
hot_reloadable=True,
|
||||
scope=ConfigScope.GLOBAL,
|
||||
scope=ConfigScope.ACCOUNT,
|
||||
title="流式 TTL",
|
||||
description="流式响应状态的最大保留时间(秒)。",
|
||||
category="流式与传输体验",
|
||||
@ -314,7 +314,7 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = (
|
||||
required=False,
|
||||
default=10000,
|
||||
hot_reloadable=True,
|
||||
scope=ConfigScope.GLOBAL,
|
||||
scope=ConfigScope.ACCOUNT,
|
||||
title="输入提示 TTL",
|
||||
description="'正在输入'提示状态的最大持续时间(毫秒)。",
|
||||
category="流式与传输体验",
|
||||
@ -339,7 +339,7 @@ CONFIG_SCHEMA: tuple[ConfigField, ...] = (
|
||||
required=False,
|
||||
default="after_agent_dispatch",
|
||||
hot_reloadable=True,
|
||||
scope=ConfigScope.GLOBAL,
|
||||
scope=ConfigScope.ACCOUNT,
|
||||
title="ACK 策略",
|
||||
description="分阶段 ACK 策略:after_record / after_agent_dispatch / after_persist / manual。",
|
||||
category="可靠性",
|
||||
|
||||
@ -8,6 +8,7 @@ DB 技术访问逻辑,供 ``AgentRunAdapter`` 通过本端口调用,消除
|
||||
``yuxi.services.run_queue_service`` 中的实际签名保持一致:
|
||||
- ``createAgentRunView`` ← ``agent_run_service.create_agent_run_view``
|
||||
- ``getRunUid`` ← ``agent_run_service.get_run_uid``
|
||||
- ``getRunTerminalInfo`` ← ``agent_run_service.get_run_terminal_info``
|
||||
- ``streamAgentRunEvents`` ← ``agent_run_service.stream_agent_run_events``
|
||||
- ``listRunStreamEvents`` ← ``run_queue_service.list_run_stream_events``
|
||||
|
||||
@ -83,6 +84,33 @@ class AgentRunExecutionPort(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def getRunTerminalInfo(
|
||||
self,
|
||||
run_id: str,
|
||||
db: Any,
|
||||
) -> dict[str, Any] | None:
|
||||
"""查询 run 记录的终态信息。
|
||||
|
||||
委托 ``agent_run_service.get_run_terminal_info`` 实现,供
|
||||
``AgentRunAdapter.getAgentRunTerminalInfo`` 转换为
|
||||
``AgentRunTerminalInfo`` DTO。仅返回 status / agent_id / error_type /
|
||||
error_message 四个字段,避免泄露完整 ORM 记录。
|
||||
|
||||
非终态(pending/running/cancel_requested)或记录不存在时返回
|
||||
``None``;终态(completed/failed/cancelled/interrupted)返回字典。
|
||||
|
||||
@pre
|
||||
- run_id 非空
|
||||
|
||||
@post
|
||||
- 终态返回 ``{status, agent_id, error_type, error_message}``;
|
||||
- 非终态或记录不存在时返回 ``None``。
|
||||
|
||||
@failure
|
||||
- DependencyError:DB 故障
|
||||
"""
|
||||
...
|
||||
|
||||
def streamAgentRunEvents(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -10,7 +10,11 @@ from __future__ import annotations
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
||||
|
||||
from yuxi.channels.contract.dtos.shared.agent_run import AgentRunCmd, AgentRunId
|
||||
from yuxi.channels.contract.dtos.shared.agent_run import (
|
||||
AgentRunCmd,
|
||||
AgentRunId,
|
||||
AgentRunTerminalInfo,
|
||||
)
|
||||
from yuxi.channels.contract.dtos.shared.option import Option
|
||||
from yuxi.channels.contract.dtos.transport.stream_event import StreamEvent
|
||||
|
||||
@ -141,3 +145,43 @@ class AgentRunPort(Protocol):
|
||||
- 读取不修改持久化状态,仅读取
|
||||
"""
|
||||
...
|
||||
|
||||
async def getAgentRunTerminalInfo(self, run_id: AgentRunId) -> Option[AgentRunTerminalInfo]:
|
||||
"""获取 AgentRun 终态信息。
|
||||
|
||||
查询 ``agent_runs`` 表当前状态,返回终态信息(status / agent_id /
|
||||
error_type / error_message),供 ``LoadBuildStage`` 在事件流为空时
|
||||
根据 AgentRun 终态决定后续处理:
|
||||
|
||||
- ``completed`` + 空输出 → 抛 ``AgentRunEmptyOutputError``,透传上游
|
||||
产出缺陷(避免下游 ``ValidationError("content", "must not be empty")``
|
||||
丢失上下文);
|
||||
- ``failed`` → 抛 ``AgentRunFailedError``,透传上游执行错误
|
||||
(error_type / error_message);
|
||||
- ``cancelled`` → 抛 ``AgentRunCancelledError``,调用方应 SKIP 后续
|
||||
投递(用户主动终止,非投递失败);
|
||||
- ``interrupted`` → 抛 ``AgentRunInterruptedError``,调用方应 SKIP
|
||||
后续投递(等待用户输入,非投递失败);
|
||||
- ``pending`` / ``running`` → 返回 ``Nothing``(AgentRun 未终态,
|
||||
由调用方按既有逻辑处理)。
|
||||
|
||||
与 ``getAgentRunFinalOutput`` 的区别:本方法读取 ``agent_runs`` 表
|
||||
状态字段,不读取 Redis Stream 事件流;``getAgentRunFinalOutput``
|
||||
读取 Redis Stream 内容文本。两者数据源不同、用途不同。
|
||||
|
||||
@pre
|
||||
- run_id 非空且为已创建的 Agent 运行 ID
|
||||
|
||||
@post
|
||||
- 终态(completed/failed/cancelled/interrupted)返回
|
||||
``Some(AgentRunTerminalInfo)``;
|
||||
- 非终态(pending/running)或记录不存在时返回 ``Nothing``。
|
||||
|
||||
@failure
|
||||
- DependencyError:依赖服务(DB)故障
|
||||
|
||||
@consistency
|
||||
- 最终一致性:状态字段可能滞后于 Agent 实际执行状态
|
||||
- 读取不修改持久化状态,仅读取
|
||||
"""
|
||||
...
|
||||
|
||||
@ -7,6 +7,7 @@ import json
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
@ -398,6 +399,32 @@ async def get_run_uid(run_id: str, db: AsyncSession) -> str:
|
||||
return str(run.uid)
|
||||
|
||||
|
||||
async def get_run_terminal_info(run_id: str, db: AsyncSession) -> dict[str, Any] | None:
|
||||
"""查询 run 记录的终态信息。
|
||||
|
||||
仅返回 status / agent_id / error_type / error_message 四个字段。run 不存在
|
||||
或非终态(pending/running/cancel_requested)时返回 ``None``;终态
|
||||
(completed/failed/cancelled/interrupted)返回字典。
|
||||
|
||||
供 ``AgentRunAdapter.getAgentRunTerminalInfo`` 转换为
|
||||
``AgentRunTerminalInfo`` DTO,使 ``LoadBuildStage`` 能在事件流为空时
|
||||
根据 AgentRun 终态决定后续处理(透传上游错误上下文 / SKIP 投递),
|
||||
而不是抛出无上下文的 ``ValidationError("content", "must not be empty")``。
|
||||
"""
|
||||
repo = AgentRunRepository(db)
|
||||
run = await repo.get_run(run_id)
|
||||
if run is None:
|
||||
return None
|
||||
if run.status not in TERMINAL_RUN_STATUSES:
|
||||
return None
|
||||
return {
|
||||
"status": run.status,
|
||||
"agent_id": run.agent_id,
|
||||
"error_type": run.error_type,
|
||||
"error_message": run.error_message,
|
||||
}
|
||||
|
||||
|
||||
async def cancel_agent_run_view(*, run_id: str, current_uid: str, db: AsyncSession) -> dict:
|
||||
repo = AgentRunRepository(db)
|
||||
run = await repo.get_run_for_user(run_id, str(current_uid))
|
||||
|
||||
44
backend/test/unit/backends/test_sandbox_paths.py
Normal file
44
backend/test/unit/backends/test_sandbox_paths.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Tests for yuxi.agents.backends.sandbox.paths uid sanitize behavior.
|
||||
|
||||
服务账号 uid 格式 ``svc:channel:{channel_type}:{account_id}`` 含冒号,
|
||||
paths.py._sanitize_uid 必须将其转义为文件系统安全的路径组件,且与
|
||||
docker/sandbox_provisioner/app.py 中的转义规则保持一致,确保宿主机
|
||||
路径与容器挂载路径映射一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.agents.backends.sandbox.paths import _sanitize_uid
|
||||
|
||||
|
||||
def test_sanitize_uid_passes_through_safe_chars() -> None:
|
||||
assert _sanitize_uid("user-1_2") == "user-1_2"
|
||||
assert _sanitize_uid("abcDEF123") == "abcDEF123"
|
||||
|
||||
|
||||
def test_sanitize_uid_escapes_colons_in_service_account_uid() -> None:
|
||||
# 服务账号 uid: svc:channel:{channel_type}:{account_id}
|
||||
assert _sanitize_uid("svc:channel:wechat_woc:acc-1") == "svc_channel_wechat_woc_acc-1"
|
||||
assert _sanitize_uid("svc:channel:feishu:host_1_bridge_2") == "svc_channel_feishu_host_1_bridge_2"
|
||||
|
||||
|
||||
def test_sanitize_uid_escapes_unsafe_chars_to_underscore() -> None:
|
||||
assert _sanitize_uid("user/name") == "user_name"
|
||||
assert _sanitize_uid("user name") == "user_name"
|
||||
assert _sanitize_uid("user;rm") == "user_rm"
|
||||
assert _sanitize_uid("user.name") == "user_name"
|
||||
# 路径穿越尝试被转义后不再包含 ..(每个非法字符独立替换为 _)
|
||||
assert _sanitize_uid("../user") == "___user"
|
||||
assert _sanitize_uid("..") == "__"
|
||||
|
||||
|
||||
def test_sanitize_uid_rejects_empty_or_whitespace() -> None:
|
||||
for value in ["", " ", None]:
|
||||
with pytest.raises(ValueError):
|
||||
_sanitize_uid(value) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_sanitize_uid_strips_surrounding_whitespace() -> None:
|
||||
assert _sanitize_uid(" user-1 ") == "user-1"
|
||||
@ -78,7 +78,16 @@ def test_local_container_identity_validation_rejects_unsafe_path_segments(monkey
|
||||
with pytest.raises(ValueError):
|
||||
backend_cls._validate_thread_id(value)
|
||||
|
||||
for value in ["../user", "user/name", "user name", "user;rm", "user.name"]:
|
||||
# thread_id 仍为 strict 校验(拒绝非法字符);uid 改为 sanitize 风格,
|
||||
# 含冒号的服务账号 uid(svc:channel:{type}:{account_id})转义为 _ 后可用。
|
||||
assert backend_cls._validate_uid("svc:channel:wechat_woc:acc-1") == "svc_channel_wechat_woc_acc-1"
|
||||
assert backend_cls._validate_uid("../user") == "___user"
|
||||
assert backend_cls._validate_uid("user/name") == "user_name"
|
||||
assert backend_cls._validate_uid("user name") == "user_name"
|
||||
assert backend_cls._validate_uid("user;rm") == "user_rm"
|
||||
assert backend_cls._validate_uid("user.name") == "user_name"
|
||||
|
||||
for value in ["", " ", None]:
|
||||
with pytest.raises(ValueError):
|
||||
backend_cls._validate_uid(value)
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"""yuxi.channels.adapters.agent_run_adapter 单元测试。
|
||||
|
||||
覆盖 ``AgentRunAdapter`` 的 ``createAgentRun`` / ``streamAgentRun`` /
|
||||
``getAgentRunFinalOutput`` 方法,使用 ``AsyncMock`` 注入
|
||||
``AgentRunExecutionPort`` 与 ``ServiceAccountPort`` 桩,patch
|
||||
``pg_manager.get_async_session_context`` 返回 mock session,不连接真实
|
||||
Redis / DB。
|
||||
``getAgentRunFinalOutput`` / ``getAgentRunTerminalInfo`` 方法,使用
|
||||
``AsyncMock`` 注入 ``AgentRunExecutionPort`` 与 ``ServiceAccountPort`` 桩,
|
||||
patch ``pg_manager.get_async_session_context`` 返回 mock session,不连接
|
||||
真实 Redis / DB。
|
||||
|
||||
createAgentRun 通过 ServiceAccountPort 解析执行身份(服务账号 uid),
|
||||
并通过 pg_manager 获取真实 AsyncSession 注入下游;测试覆盖以下路径:
|
||||
@ -13,6 +13,13 @@ createAgentRun 通过 ServiceAccountPort 解析执行身份(服务账号 uid
|
||||
- 渠道来源完整:解析服务账号,current_uid 使用服务账号 uid
|
||||
- 服务账号缺失:抛 InternalError(INV-7 错误显式化,不回退)
|
||||
- 服务账号异常:契约错误放行,其余包装为 DependencyError(ADP-002)
|
||||
|
||||
getAgentRunTerminalInfo 委托 ``execution_port.getRunTerminalInfo`` 查询
|
||||
``agent_runs`` 表终态信息;测试覆盖以下路径:
|
||||
- 终态返回 Some(AgentRunTerminalInfo),含 status / agent_id / error_type / error_message
|
||||
- 非终态或记录不存在返回 Nothing
|
||||
- 契约错误(ValidationError / DependencyError / NotFoundError)放行
|
||||
- 其余异常包装为 DependencyError(ADP-002)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -28,12 +35,14 @@ from yuxi.channels.contract.dtos.shared.agent_run import (
|
||||
AgentRunCmd,
|
||||
AgentRunContext,
|
||||
AgentRunId,
|
||||
AgentRunTerminalInfo,
|
||||
)
|
||||
from yuxi.channels.contract.dtos.shared.option import Nothing, Some
|
||||
from yuxi.channels.contract.dtos.shared.service_account import ServiceAccount
|
||||
from yuxi.channels.contract.dtos.transport.stream_event import StreamEvent
|
||||
from yuxi.channels.contract.errors import (
|
||||
DependencyError,
|
||||
Error,
|
||||
InternalError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
@ -739,3 +748,262 @@ class TestAgentRunAdapterGetFinalOutput:
|
||||
assert isinstance(result, Some)
|
||||
assert result.value == "x" * 200
|
||||
assert execution_port.listRunStreamEvents.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestAgentRunAdapterGetTerminalInfo:
|
||||
"""``getAgentRunTerminalInfo`` 行为测试。
|
||||
|
||||
委托 ``execution_port.getRunTerminalInfo`` 查询 ``agent_runs`` 表当前
|
||||
状态。终态(completed/failed/cancelled/interrupted)返回
|
||||
``Some(AgentRunTerminalInfo)``,非终态或记录不存在返回 ``Nothing``。
|
||||
|
||||
注意:``getAgentRunTerminalInfo`` 通过 ``_session_scope(None)`` 调用
|
||||
``pg_manager.get_async_session_context``,测试需 patch pg_manager
|
||||
避免连接真实 DB。
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_some_when_run_in_terminal_status_failed(self):
|
||||
# Arrange — AgentRun failed 终态,含全部错误字段
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(
|
||||
return_value={
|
||||
"status": "failed",
|
||||
"agent_id": "default-chatbot",
|
||||
"error_type": "worker_error",
|
||||
"error_message": "uid contains invalid characters",
|
||||
}
|
||||
)
|
||||
logger = MagicMock()
|
||||
logger.error = AsyncMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act
|
||||
with _patch_pg_session():
|
||||
result = await adapter.getAgentRunTerminalInfo(AgentRunId("run-failed-1"))
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, Some)
|
||||
info = result.value
|
||||
assert isinstance(info, AgentRunTerminalInfo)
|
||||
assert info.status == "failed"
|
||||
assert info.agent_id == "default-chatbot"
|
||||
assert info.error_type == "worker_error"
|
||||
assert info.error_message == "uid contains invalid characters"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_some_when_run_in_terminal_status_cancelled(self):
|
||||
# Arrange — AgentRun cancelled 终态,无 error_type / error_message
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(
|
||||
return_value={
|
||||
"status": "cancelled",
|
||||
"agent_id": "default-chatbot",
|
||||
"error_type": None,
|
||||
"error_message": None,
|
||||
}
|
||||
)
|
||||
logger = MagicMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act
|
||||
with _patch_pg_session():
|
||||
result = await adapter.getAgentRunTerminalInfo(AgentRunId("run-cancelled-1"))
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, Some)
|
||||
info = result.value
|
||||
assert info.status == "cancelled"
|
||||
assert info.agent_id == "default-chatbot"
|
||||
assert info.error_type is None
|
||||
assert info.error_message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_some_when_run_in_terminal_status_interrupted(self):
|
||||
# Arrange — AgentRun interrupted 终态
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(
|
||||
return_value={
|
||||
"status": "interrupted",
|
||||
"agent_id": "default-chatbot",
|
||||
"error_type": None,
|
||||
"error_message": "waiting for user input",
|
||||
}
|
||||
)
|
||||
logger = MagicMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act
|
||||
with _patch_pg_session():
|
||||
result = await adapter.getAgentRunTerminalInfo(AgentRunId("run-interrupted-1"))
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, Some)
|
||||
info = result.value
|
||||
assert info.status == "interrupted"
|
||||
assert info.error_message == "waiting for user input"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_some_when_run_in_terminal_status_completed(self):
|
||||
# Arrange — AgentRun completed 终态
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(
|
||||
return_value={
|
||||
"status": "completed",
|
||||
"agent_id": "default-chatbot",
|
||||
"error_type": None,
|
||||
"error_message": None,
|
||||
}
|
||||
)
|
||||
logger = MagicMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act
|
||||
with _patch_pg_session():
|
||||
result = await adapter.getAgentRunTerminalInfo(AgentRunId("run-completed-1"))
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, Some)
|
||||
info = result.value
|
||||
assert info.status == "completed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_nothing_when_run_not_in_terminal_status(self):
|
||||
# Arrange — AgentRun 仍为 running / pending(非终态)
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(return_value=None)
|
||||
logger = MagicMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act
|
||||
with _patch_pg_session():
|
||||
result = await adapter.getAgentRunTerminalInfo(AgentRunId("run-running-1"))
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, Nothing)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_nothing_when_run_record_not_found(self):
|
||||
# Arrange — AgentRun 记录不存在(已删除或 ID 错误)
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(return_value=None)
|
||||
logger = MagicMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act
|
||||
with _patch_pg_session():
|
||||
result = await adapter.getAgentRunTerminalInfo(AgentRunId("run-not-exist"))
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, Nothing)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_some_with_empty_strings_when_db_fields_missing(self):
|
||||
# Arrange — DB 返回的 status / agent_id 为 None(理论不应发生,但
|
||||
# adapter 应兜底为空串而非 None,保证 AgentRunTerminalInfo 字段类型)
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(
|
||||
return_value={
|
||||
"status": None,
|
||||
"agent_id": None,
|
||||
"error_type": None,
|
||||
"error_message": None,
|
||||
}
|
||||
)
|
||||
logger = MagicMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act
|
||||
with _patch_pg_session():
|
||||
result = await adapter.getAgentRunTerminalInfo(AgentRunId("run-empty-fields"))
|
||||
|
||||
# Assert — status / agent_id 兜底为空串,error_type / error_message 保持 None
|
||||
assert isinstance(result, Some)
|
||||
info = result.value
|
||||
assert info.status == ""
|
||||
assert info.agent_id == ""
|
||||
assert info.error_type is None
|
||||
assert info.error_message is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_through_validation_error(self):
|
||||
# Arrange — 契约错误(ValidationError)放行,不包装为 DependencyError
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(
|
||||
side_effect=ValidationError("run_id", "invalid format")
|
||||
)
|
||||
logger = MagicMock()
|
||||
logger.error = AsyncMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act / Assert
|
||||
with _patch_pg_session(), pytest.raises(ValidationError):
|
||||
await adapter.getAgentRunTerminalInfo(AgentRunId("run-1"))
|
||||
# 不应记录 error 日志(契约错误直接放行)
|
||||
logger.error.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_through_dependency_error(self):
|
||||
# Arrange — 契约错误(DependencyError)放行
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(
|
||||
side_effect=DependencyError("agent_runs", cause=Error("db down"))
|
||||
)
|
||||
logger = MagicMock()
|
||||
logger.error = AsyncMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act / Assert
|
||||
with _patch_pg_session(), pytest.raises(DependencyError):
|
||||
await adapter.getAgentRunTerminalInfo(AgentRunId("run-1"))
|
||||
logger.error.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_passes_through_not_found_error(self):
|
||||
# Arrange — 契约错误(NotFoundError)放行
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(
|
||||
side_effect=NotFoundError("agent_runs", "run not found")
|
||||
)
|
||||
logger = MagicMock()
|
||||
logger.error = AsyncMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act / Assert
|
||||
with _patch_pg_session(), pytest.raises(NotFoundError):
|
||||
await adapter.getAgentRunTerminalInfo(AgentRunId("run-1"))
|
||||
logger.error.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_translates_generic_exception_to_dependency_error(self):
|
||||
# Arrange — 非契约错误(如 RuntimeError)包装为 DependencyError
|
||||
# (ADP-002),保留原始异常链(from exc)
|
||||
execution_port = MagicMock()
|
||||
execution_port.getRunTerminalInfo = AsyncMock(side_effect=RuntimeError("db connection lost"))
|
||||
logger = MagicMock()
|
||||
logger.error = AsyncMock()
|
||||
service_account_port = MagicMock()
|
||||
adapter = AgentRunAdapter(execution_port, logger, service_account_port)
|
||||
|
||||
# Act / Assert
|
||||
with _patch_pg_session(), pytest.raises(DependencyError) as exc_info:
|
||||
await adapter.getAgentRunTerminalInfo(AgentRunId("run-1"))
|
||||
# 原始异常链保留
|
||||
assert isinstance(exc_info.value.__cause__, RuntimeError)
|
||||
assert "db connection lost" in str(exc_info.value.__cause__)
|
||||
# 记录 error 日志(INV-10 可观测性)
|
||||
logger.error.assert_awaited_once()
|
||||
log_kwargs = logger.error.call_args.kwargs
|
||||
assert log_kwargs["resource"] == "agent_runs"
|
||||
assert log_kwargs["run_id"] == "run-1"
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
- ``process(context)``:正常路径(含富消息/附件)、无富消息、空分块、
|
||||
image_url/video_url 转换为附件
|
||||
- 持久化模式下 AgentRun 内容加载(streamAgentRun 阻塞消费 + getAgentRunFinalOutput)
|
||||
- AgentRun 终态感知:``failed`` / ``cancelled`` / ``interrupted`` / ``completed`` /
|
||||
非终态场景下 ``_loadAgentRunOutput`` 的行为契约
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -17,8 +19,17 @@ from yuxi.channels.application.messaging.pipeline.outbound.load_build_stage impo
|
||||
from yuxi.channels.contract.dtos.messaging.channel import ChannelType
|
||||
from yuxi.channels.contract.dtos.messaging.common import Attachment
|
||||
from yuxi.channels.contract.dtos.messaging.outbound import RichMessage
|
||||
from yuxi.channels.contract.dtos.shared.agent_run import AgentRunId
|
||||
from yuxi.channels.contract.dtos.shared.agent_run import (
|
||||
AgentRunId,
|
||||
AgentRunTerminalInfo,
|
||||
)
|
||||
from yuxi.channels.contract.dtos.shared.option import Nothing, Some
|
||||
from yuxi.channels.contract.errors import (
|
||||
AgentRunCancelledError,
|
||||
AgentRunEmptyOutputError,
|
||||
AgentRunFailedError,
|
||||
AgentRunInterruptedError,
|
||||
)
|
||||
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
@ -42,12 +53,15 @@ def _make_ctx(**overrides) -> OutboundContext:
|
||||
def _make_agent_run_port(
|
||||
*,
|
||||
stream_events: list | None = None,
|
||||
final_output: Some | Nothing = None,
|
||||
final_output: Some | Nothing | None = None,
|
||||
terminal_info: Some | Nothing | None = None,
|
||||
) -> MagicMock:
|
||||
"""构造 mock AgentRunPort。
|
||||
|
||||
stream_events:streamAgentRun 产出的事件列表(按序 yield 后结束迭代)。
|
||||
final_output:getAgentRunFinalOutput 返回的 Option。
|
||||
final_output:getAgentRunFinalOutput 返回的 Option,默认 Nothing()。
|
||||
terminal_info:getAgentRunTerminalInfo 返回的 Option,默认 Nothing()
|
||||
(非终态或记录不存在,由下游 PrefixStage 兜底)。
|
||||
"""
|
||||
port = MagicMock()
|
||||
|
||||
@ -57,6 +71,7 @@ def _make_agent_run_port(
|
||||
|
||||
port.streamAgentRun = MagicMock(side_effect=_stream)
|
||||
port.getAgentRunFinalOutput = AsyncMock(return_value=final_output or Nothing())
|
||||
port.getAgentRunTerminalInfo = AsyncMock(return_value=terminal_info or Nothing())
|
||||
return port
|
||||
|
||||
|
||||
@ -333,11 +348,16 @@ class TestLoadBuildStagePersistentContentLoad:
|
||||
assert ctx.outbound_payload.stream_chunks == ("full response text",)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistent_mode_final_output_nothing_keeps_empty(self):
|
||||
# Arrange — getAgentRunFinalOutput 返回 Nothing(AgentRun 无文本输出)
|
||||
async def test_persistent_mode_final_output_nothing_non_terminal_keeps_empty(self):
|
||||
# Arrange — getAgentRunFinalOutput 返回 Nothing(无 messages 事件),
|
||||
# 同时 getAgentRunTerminalInfo 返回 Nothing(AgentRun 仍为 pending /
|
||||
# running,或记录已删除)。此为极端竞态:事件流已结束但 DB 状态
|
||||
# 滞后未进入终态。LoadBuildStage 不抛错,交由下游 PrefixStage 兜底
|
||||
# 抛 ValidationError("content", "must not be empty")。
|
||||
port = _make_agent_run_port(
|
||||
stream_events=["evt-1"],
|
||||
final_output=Nothing(),
|
||||
terminal_info=Nothing(),
|
||||
)
|
||||
stage = LoadBuildStage(agent_run_port=port)
|
||||
ctx = _make_ctx(delivery_mode="persistent", agent_run_id="run-001", stream_chunks=[])
|
||||
@ -345,10 +365,13 @@ class TestLoadBuildStagePersistentContentLoad:
|
||||
# Act
|
||||
ok = await stage.process(ctx)
|
||||
|
||||
# Assert — stream_chunks 保持空,不抛异常
|
||||
# Assert — stream_chunks 保持空,process 返回 True(终态查询返回
|
||||
# Nothing,不抛领域错误;下游 PrefixStage 才会抛 ValidationError)
|
||||
assert ok is True
|
||||
assert ctx.stream_chunks == []
|
||||
assert ctx.outbound_payload.stream_chunks == ()
|
||||
# getAgentRunTerminalInfo 被调用一次
|
||||
port.getAgentRunTerminalInfo.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_mode_skips_agent_run_load(self):
|
||||
@ -399,3 +422,270 @@ class TestLoadBuildStagePersistentContentLoad:
|
||||
assert ok is True
|
||||
port.streamAgentRun.assert_not_called()
|
||||
port.getAgentRunFinalOutput.assert_not_called()
|
||||
|
||||
|
||||
# ─── AgentRun 终态感知 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLoadBuildStageTerminalStatusAwareness:
|
||||
"""AgentRun 终态感知:``getAgentRunFinalOutput`` 返回 ``Nothing`` 时按
|
||||
``AgentRunTerminalInfo.status`` 抛出对应领域错误,透传真实失败原因到
|
||||
outbound pipeline 调用方。
|
||||
|
||||
覆盖 5 个分支:
|
||||
- ``failed`` → ``AgentRunFailedError``(携带 run_error_type /
|
||||
run_error_message)
|
||||
- ``cancelled`` → ``AgentRunCancelledError``(SKIP 语义)
|
||||
- ``interrupted`` → ``AgentRunInterruptedError``(SKIP 语义)
|
||||
- ``completed`` 但输出为空 → ``AgentRunEmptyOutputError``
|
||||
- 非终态或记录不存在 → 不抛错,下游 PrefixStage 兜底
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_status_raises_agent_run_failed_error(self):
|
||||
# Arrange — AgentRun 终态为 failed,应透传 error_type / error_message
|
||||
port = _make_agent_run_port(
|
||||
stream_events=["evt-1"],
|
||||
final_output=Nothing(),
|
||||
terminal_info=Some(
|
||||
AgentRunTerminalInfo(
|
||||
status="failed",
|
||||
agent_id="default-chatbot",
|
||||
error_type="worker_error",
|
||||
error_message="uid contains invalid characters",
|
||||
),
|
||||
),
|
||||
)
|
||||
stage = LoadBuildStage(agent_run_port=port)
|
||||
ctx = _make_ctx(
|
||||
delivery_mode="persistent",
|
||||
agent_run_id="run-failed-001",
|
||||
stream_chunks=[],
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(AgentRunFailedError) as exc_info:
|
||||
await stage.process(ctx)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.run_id == "run-failed-001"
|
||||
assert err.agent_id == "default-chatbot"
|
||||
assert err.run_error_type == "worker_error"
|
||||
assert err.run_error_message == "uid contains invalid characters"
|
||||
assert err.error_code == "AGENT_RUN_FAILED"
|
||||
assert err.trace_id == "trace-001"
|
||||
# 错误信息包含真实失败原因,便于运维排查
|
||||
assert "worker_error" in err.message
|
||||
assert "uid contains invalid characters" in err.message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_status_raises_agent_run_cancelled_error(self):
|
||||
# Arrange — AgentRun 被取消(用户主动终止),调用方应 SKIP 后续投递
|
||||
port = _make_agent_run_port(
|
||||
stream_events=["evt-1"],
|
||||
final_output=Nothing(),
|
||||
terminal_info=Some(
|
||||
AgentRunTerminalInfo(
|
||||
status="cancelled",
|
||||
agent_id="default-chatbot",
|
||||
),
|
||||
),
|
||||
)
|
||||
stage = LoadBuildStage(agent_run_port=port)
|
||||
ctx = _make_ctx(
|
||||
delivery_mode="persistent",
|
||||
agent_run_id="run-cancelled-001",
|
||||
stream_chunks=[],
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(AgentRunCancelledError) as exc_info:
|
||||
await stage.process(ctx)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.run_id == "run-cancelled-001"
|
||||
assert err.agent_id == "default-chatbot"
|
||||
assert err.error_code == "AGENT_RUN_CANCELLED"
|
||||
assert err.trace_id == "trace-001"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupted_status_raises_agent_run_interrupted_error(self):
|
||||
# Arrange — AgentRun 被中断(等待用户输入 / 人工审批),调用方应
|
||||
# SKIP 后续投递(有专门通知链路)
|
||||
port = _make_agent_run_port(
|
||||
stream_events=["evt-1"],
|
||||
final_output=Nothing(),
|
||||
terminal_info=Some(
|
||||
AgentRunTerminalInfo(
|
||||
status="interrupted",
|
||||
agent_id="default-chatbot",
|
||||
error_message="waiting for user input",
|
||||
),
|
||||
),
|
||||
)
|
||||
stage = LoadBuildStage(agent_run_port=port)
|
||||
ctx = _make_ctx(
|
||||
delivery_mode="persistent",
|
||||
agent_run_id="run-interrupted-001",
|
||||
stream_chunks=[],
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(AgentRunInterruptedError) as exc_info:
|
||||
await stage.process(ctx)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.run_id == "run-interrupted-001"
|
||||
assert err.agent_id == "default-chatbot"
|
||||
assert err.run_error_message == "waiting for user input"
|
||||
assert err.error_code == "AGENT_RUN_INTERRUPTED"
|
||||
assert err.trace_id == "trace-001"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_with_empty_output_raises_empty_output_error(self):
|
||||
# Arrange — AgentRun 终态为 completed 但输出为空(Agent 配置异常或
|
||||
# LLM 仅产出 reasoning_content / 全程只发起 tool_call),需排查
|
||||
port = _make_agent_run_port(
|
||||
stream_events=["evt-1"],
|
||||
final_output=Nothing(),
|
||||
terminal_info=Some(
|
||||
AgentRunTerminalInfo(
|
||||
status="completed",
|
||||
agent_id="default-chatbot",
|
||||
),
|
||||
),
|
||||
)
|
||||
stage = LoadBuildStage(agent_run_port=port)
|
||||
ctx = _make_ctx(
|
||||
delivery_mode="persistent",
|
||||
agent_run_id="run-empty-001",
|
||||
stream_chunks=[],
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(AgentRunEmptyOutputError) as exc_info:
|
||||
await stage.process(ctx)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.run_id == "run-empty-001"
|
||||
assert err.agent_id == "default-chatbot"
|
||||
assert err.error_code == "AGENT_RUN_EMPTY_OUTPUT"
|
||||
assert err.trace_id == "trace-001"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_status_with_none_error_fields_uses_defaults(self):
|
||||
# Arrange — AgentRun failed 但 error_type / error_message 为 None
|
||||
# (历史数据或异常路径未填充),应使用 "unknown" / "" 兜底
|
||||
port = _make_agent_run_port(
|
||||
stream_events=["evt-1"],
|
||||
final_output=Nothing(),
|
||||
terminal_info=Some(
|
||||
AgentRunTerminalInfo(
|
||||
status="failed",
|
||||
agent_id="default-chatbot",
|
||||
error_type=None,
|
||||
error_message=None,
|
||||
),
|
||||
),
|
||||
)
|
||||
stage = LoadBuildStage(agent_run_port=port)
|
||||
ctx = _make_ctx(
|
||||
delivery_mode="persistent",
|
||||
agent_run_id="run-failed-002",
|
||||
stream_chunks=[],
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(AgentRunFailedError) as exc_info:
|
||||
await stage.process(ctx)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.run_error_type == "unknown"
|
||||
assert err.run_error_message == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupted_status_with_none_error_message_uses_empty(self):
|
||||
# Arrange — AgentRun interrupted 但 error_message 为 None,应使用空串
|
||||
port = _make_agent_run_port(
|
||||
stream_events=["evt-1"],
|
||||
final_output=Nothing(),
|
||||
terminal_info=Some(
|
||||
AgentRunTerminalInfo(
|
||||
status="interrupted",
|
||||
agent_id="default-chatbot",
|
||||
error_message=None,
|
||||
),
|
||||
),
|
||||
)
|
||||
stage = LoadBuildStage(agent_run_port=port)
|
||||
ctx = _make_ctx(
|
||||
delivery_mode="persistent",
|
||||
agent_run_id="run-interrupted-002",
|
||||
stream_chunks=[],
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(AgentRunInterruptedError) as exc_info:
|
||||
await stage.process(ctx)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.run_error_message == ""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_terminal_status_does_not_raise(self):
|
||||
# Arrange — 理论不存在的终态值(TERMINAL_RUN_STATUSES 仅含 4 种),
|
||||
# 兜底分支:不抛错,由下游 PrefixStage 处理。验证防御性兜底行为。
|
||||
port = _make_agent_run_port(
|
||||
stream_events=["evt-1"],
|
||||
final_output=Nothing(),
|
||||
terminal_info=Some(
|
||||
AgentRunTerminalInfo(
|
||||
status="unknown_status",
|
||||
agent_id="default-chatbot",
|
||||
),
|
||||
),
|
||||
)
|
||||
stage = LoadBuildStage(agent_run_port=port)
|
||||
ctx = _make_ctx(
|
||||
delivery_mode="persistent",
|
||||
agent_run_id="run-unknown-001",
|
||||
stream_chunks=[],
|
||||
)
|
||||
|
||||
# Act
|
||||
ok = await stage.process(ctx)
|
||||
|
||||
# Assert — 不抛错,process 返回 True(下游 PrefixStage 兜底)
|
||||
assert ok is True
|
||||
assert ctx.stream_chunks == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_info_not_called_when_final_output_present(self):
|
||||
# Arrange — getAgentRunFinalOutput 返回 Some(有内容)时不应调用
|
||||
# getAgentRunTerminalInfo(短路返回,性能优化)
|
||||
port = _make_agent_run_port(
|
||||
stream_events=["evt-1"],
|
||||
final_output=Some("response text"),
|
||||
terminal_info=Some(
|
||||
AgentRunTerminalInfo(
|
||||
status="completed",
|
||||
agent_id="default-chatbot",
|
||||
),
|
||||
),
|
||||
)
|
||||
stage = LoadBuildStage(agent_run_port=port)
|
||||
ctx = _make_ctx(
|
||||
delivery_mode="persistent",
|
||||
agent_run_id="run-ok-001",
|
||||
stream_chunks=[],
|
||||
)
|
||||
|
||||
# Act
|
||||
ok = await stage.process(ctx)
|
||||
|
||||
# Assert
|
||||
assert ok is True
|
||||
port.getAgentRunFinalOutput.assert_called_once()
|
||||
# 终态查询不应被调用(短路返回)
|
||||
port.getAgentRunTerminalInfo.assert_not_called()
|
||||
|
||||
@ -33,6 +33,10 @@ from yuxi.channels.contract.dtos.messaging.inbound import (
|
||||
)
|
||||
from yuxi.channels.contract.dtos.shared.command import CommandResponse, CommandResult
|
||||
from yuxi.channels.contract.errors import (
|
||||
AgentRunCancelledError,
|
||||
AgentRunEmptyOutputError,
|
||||
AgentRunFailedError,
|
||||
AgentRunInterruptedError,
|
||||
DependencyError,
|
||||
Error,
|
||||
IdempotencyConflictError,
|
||||
@ -853,6 +857,258 @@ class TestOutboundFailureIdempotencyRollback:
|
||||
assert result.ack_decision == "pending"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestOutboundSkipOnAgentRunTerminalState:
|
||||
"""SKIP 语义:AgentRun cancelled / interrupted 时跳过 delivery failure 处理。
|
||||
|
||||
覆盖 ``InboundMessageService._deliverAgentResponse`` 在出站管道返回
|
||||
``ok=False`` 且错误为 ``AgentRunCancelledError`` /
|
||||
``AgentRunInterruptedError`` 时,**不**触发 NACK / 幂等回退 / 重试,
|
||||
仅记录 INFO 日志说明跳过原因。
|
||||
|
||||
与 ``AgentRunFailedError`` / ``AgentRunEmptyOutputError`` 对比:后者仍
|
||||
按 delivery failure 处理(透传真实失败原因到错误日志)。
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_agent_run_skips_outbound_failure_handling(
|
||||
self,
|
||||
fake_logger,
|
||||
fake_config,
|
||||
fake_tracer,
|
||||
fake_transaction_port,
|
||||
fake_idempotency_repo,
|
||||
):
|
||||
# Arrange AgentRun cancelled → 出站返回 ok=False 携带
|
||||
# AgentRunCancelledError,应跳过 _handleOutboundFailure(不回退幂等)
|
||||
inbound_pipeline = AsyncMock()
|
||||
|
||||
async def _set_agent_run_with_idempotency(ctx):
|
||||
ctx.agent_run_id = "run-cancelled-1"
|
||||
ctx.idempotency_record_id = 42
|
||||
return (True, None)
|
||||
|
||||
inbound_pipeline.run.side_effect = _set_agent_run_with_idempotency
|
||||
outbound_pipeline = AsyncMock()
|
||||
err = AgentRunCancelledError(
|
||||
run_id="run-cancelled-1",
|
||||
agent_id="default-chatbot",
|
||||
trace_id="trace-1",
|
||||
)
|
||||
outbound_pipeline.run.return_value = (False, err)
|
||||
streaming_config = MagicMock()
|
||||
streaming_config.streaming_enabled = False
|
||||
fake_config.getStreamingConfig.return_value = streaming_config
|
||||
service = _make_service(
|
||||
fake_logger,
|
||||
fake_config,
|
||||
fake_tracer,
|
||||
fake_transaction_port,
|
||||
inbound_pipeline=inbound_pipeline,
|
||||
outbound_pipeline=outbound_pipeline,
|
||||
idempotency_repository=fake_idempotency_repo,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await service.receiveInbound(_make_inbound_cmd())
|
||||
await _drain_outbound_tasks(service)
|
||||
|
||||
# Assert 幂等记录不被回退为 failed(SKIP 语义)
|
||||
statuses = [call.args[1] for call in fake_idempotency_repo.updateIdempotencyStatus.call_args_list]
|
||||
assert "failed" not in statuses
|
||||
# INFO 日志记录跳过原因
|
||||
fake_logger.info.assert_awaited()
|
||||
skip_logs = [
|
||||
call for call in fake_logger.info.call_args_list
|
||||
if "outbound pipeline skipped" in str(call)
|
||||
]
|
||||
assert skip_logs, "应记录 'outbound pipeline skipped' INFO 日志"
|
||||
# 日志中包含 skip_reason / run_id / agent_id
|
||||
skip_call_kwargs = skip_logs[0].kwargs
|
||||
assert skip_call_kwargs["skip_reason"] == "AGENT_RUN_CANCELLED"
|
||||
assert skip_call_kwargs["run_id"] == "run-cancelled-1"
|
||||
assert skip_call_kwargs["agent_id"] == "default-chatbot"
|
||||
# 出站异步化后 ack_decision 保持 pending,无 error 透传
|
||||
assert result.ack_decision == "pending"
|
||||
assert result.error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interrupted_agent_run_skips_outbound_failure_handling(
|
||||
self,
|
||||
fake_logger,
|
||||
fake_config,
|
||||
fake_tracer,
|
||||
fake_transaction_port,
|
||||
fake_idempotency_repo,
|
||||
):
|
||||
# Arrange AgentRun interrupted(等待用户输入)→ 出站返回 ok=False
|
||||
# 携带 AgentRunInterruptedError,应跳过 _handleOutboundFailure
|
||||
inbound_pipeline = AsyncMock()
|
||||
|
||||
async def _set_agent_run_with_idempotency(ctx):
|
||||
ctx.agent_run_id = "run-interrupted-1"
|
||||
ctx.idempotency_record_id = 42
|
||||
return (True, None)
|
||||
|
||||
inbound_pipeline.run.side_effect = _set_agent_run_with_idempotency
|
||||
outbound_pipeline = AsyncMock()
|
||||
err = AgentRunInterruptedError(
|
||||
run_id="run-interrupted-1",
|
||||
agent_id="default-chatbot",
|
||||
error_message="waiting for user input",
|
||||
trace_id="trace-1",
|
||||
)
|
||||
outbound_pipeline.run.return_value = (False, err)
|
||||
streaming_config = MagicMock()
|
||||
streaming_config.streaming_enabled = False
|
||||
fake_config.getStreamingConfig.return_value = streaming_config
|
||||
service = _make_service(
|
||||
fake_logger,
|
||||
fake_config,
|
||||
fake_tracer,
|
||||
fake_transaction_port,
|
||||
inbound_pipeline=inbound_pipeline,
|
||||
outbound_pipeline=outbound_pipeline,
|
||||
idempotency_repository=fake_idempotency_repo,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await service.receiveInbound(_make_inbound_cmd())
|
||||
await _drain_outbound_tasks(service)
|
||||
|
||||
# Assert 幂等记录不被回退为 failed
|
||||
statuses = [call.args[1] for call in fake_idempotency_repo.updateIdempotencyStatus.call_args_list]
|
||||
assert "failed" not in statuses
|
||||
# INFO 日志记录跳过原因
|
||||
skip_logs = [
|
||||
call for call in fake_logger.info.call_args_list
|
||||
if "outbound pipeline skipped" in str(call)
|
||||
]
|
||||
assert skip_logs
|
||||
skip_call_kwargs = skip_logs[0].kwargs
|
||||
assert skip_call_kwargs["skip_reason"] == "AGENT_RUN_INTERRUPTED"
|
||||
assert skip_call_kwargs["run_id"] == "run-interrupted-1"
|
||||
assert result.ack_decision == "pending"
|
||||
assert result.error is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_agent_run_still_triggers_outbound_failure_handling(
|
||||
self,
|
||||
fake_logger,
|
||||
fake_config,
|
||||
fake_tracer,
|
||||
fake_transaction_port,
|
||||
fake_idempotency_repo,
|
||||
):
|
||||
# Arrange AgentRun failed(非 SKIP 类错误)→ 出站返回 ok=False
|
||||
# 携带 AgentRunFailedError,应仍按 delivery failure 处理(回退幂等
|
||||
# 为 failed,允许渠道重试),透传真实失败原因到错误日志
|
||||
inbound_pipeline = AsyncMock()
|
||||
|
||||
async def _set_agent_run_with_idempotency(ctx):
|
||||
ctx.agent_run_id = "run-failed-1"
|
||||
ctx.idempotency_record_id = 42
|
||||
return (True, None)
|
||||
|
||||
inbound_pipeline.run.side_effect = _set_agent_run_with_idempotency
|
||||
outbound_pipeline = AsyncMock()
|
||||
err = AgentRunFailedError(
|
||||
run_id="run-failed-1",
|
||||
agent_id="default-chatbot",
|
||||
error_type="worker_error",
|
||||
error_message="uid contains invalid characters",
|
||||
trace_id="trace-1",
|
||||
)
|
||||
outbound_pipeline.run.return_value = (False, err)
|
||||
streaming_config = MagicMock()
|
||||
streaming_config.streaming_enabled = False
|
||||
fake_config.getStreamingConfig.return_value = streaming_config
|
||||
service = _make_service(
|
||||
fake_logger,
|
||||
fake_config,
|
||||
fake_tracer,
|
||||
fake_transaction_port,
|
||||
inbound_pipeline=inbound_pipeline,
|
||||
outbound_pipeline=outbound_pipeline,
|
||||
idempotency_repository=fake_idempotency_repo,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await service.receiveInbound(_make_inbound_cmd())
|
||||
await _drain_outbound_tasks(service)
|
||||
|
||||
# Assert 幂等记录被回退为 failed(与 cancelled/interrupted 相反)
|
||||
statuses = [call.args[1] for call in fake_idempotency_repo.updateIdempotencyStatus.call_args_list]
|
||||
assert statuses[-1] == "failed"
|
||||
# ERROR 日志透传真实失败原因
|
||||
error_logs = [
|
||||
call for call in fake_logger.error.call_args_list
|
||||
if "outbound pipeline failed" in str(call)
|
||||
]
|
||||
assert error_logs
|
||||
error_call_args = error_logs[0].args
|
||||
# 错误消息含 worker_error / uid contains invalid characters
|
||||
error_msg = error_call_args[0] if error_call_args else ""
|
||||
assert "worker_error" in error_msg
|
||||
assert "uid contains invalid characters" in error_msg
|
||||
assert result.ack_decision == "pending"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_output_agent_run_still_triggers_outbound_failure_handling(
|
||||
self,
|
||||
fake_logger,
|
||||
fake_config,
|
||||
fake_tracer,
|
||||
fake_transaction_port,
|
||||
fake_idempotency_repo,
|
||||
):
|
||||
# Arrange AgentRun completed 但输出为空(非 SKIP 类错误)→ 出站
|
||||
# 返回 ok=False 携带 AgentRunEmptyOutputError,应仍按 delivery
|
||||
# failure 处理(与 AgentRunFailedError 语义一致)
|
||||
inbound_pipeline = AsyncMock()
|
||||
|
||||
async def _set_agent_run_with_idempotency(ctx):
|
||||
ctx.agent_run_id = "run-empty-1"
|
||||
ctx.idempotency_record_id = 42
|
||||
return (True, None)
|
||||
|
||||
inbound_pipeline.run.side_effect = _set_agent_run_with_idempotency
|
||||
outbound_pipeline = AsyncMock()
|
||||
err = AgentRunEmptyOutputError(
|
||||
run_id="run-empty-1",
|
||||
agent_id="default-chatbot",
|
||||
trace_id="trace-1",
|
||||
)
|
||||
outbound_pipeline.run.return_value = (False, err)
|
||||
streaming_config = MagicMock()
|
||||
streaming_config.streaming_enabled = False
|
||||
fake_config.getStreamingConfig.return_value = streaming_config
|
||||
service = _make_service(
|
||||
fake_logger,
|
||||
fake_config,
|
||||
fake_tracer,
|
||||
fake_transaction_port,
|
||||
inbound_pipeline=inbound_pipeline,
|
||||
outbound_pipeline=outbound_pipeline,
|
||||
idempotency_repository=fake_idempotency_repo,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = await service.receiveInbound(_make_inbound_cmd())
|
||||
await _drain_outbound_tasks(service)
|
||||
|
||||
# Assert 幂等记录被回退为 failed
|
||||
statuses = [call.args[1] for call in fake_idempotency_repo.updateIdempotencyStatus.call_args_list]
|
||||
assert statuses[-1] == "failed"
|
||||
# 不应有 SKIP 日志
|
||||
skip_logs = [
|
||||
call for call in fake_logger.info.call_args_list
|
||||
if "outbound pipeline skipped" in str(call)
|
||||
]
|
||||
assert not skip_logs
|
||||
assert result.ack_decision == "pending"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestReceiveWebhook:
|
||||
"""receiveWebhook 行为测试。"""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user