feat: 新增AgentRun终态感知能力与配套优化

本次提交实现了AgentRun全生命周期终态处理能力,覆盖以下核心变更:
1. 新增4种AgentRun终态错误类型与HTTP状态映射,透传上游错误上下文
2. 新增AgentRun终态信息查询接口与DTO,支持非事件流场景下的状态感知
3. 优化沙箱路径转义规则,兼容服务账号UID格式
4. 新增出站管道SKIP逻辑,针对取消/中断场景避免无效重试
5. 补充完整单元测试覆盖所有新功能与优化点
This commit is contained in:
Kris 2026-07-18 03:42:49 +08:00
parent 08617091dc
commit 59cc1daee1
20 changed files with 1414 additions and 36 deletions

View File

@ -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

View File

@ -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"),
)
)

View File

@ -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,
*,

View File

@ -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:

View File

@ -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 兜底

View File

@ -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(

View File

@ -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:
"""渠道格式规格。

View File

@ -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 终态感知相关错误P0AgentRun 失败时透传上游错误上下文)
"AgentRunFailedError",
"AgentRunEmptyOutputError",
"AgentRunCancelledError",
"AgentRunInterruptedError",
"ContentReviewError",
"ContentViolationError",
"CredentialNotFoundError",

View File

@ -61,4 +61,15 @@ CHANNEL_ERROR_STATUS_MAP: dict[str, int] = {
"ILLEGAL_STATE": 409,
# 媒体下载大小限制相关错误N-M2
"PAYLOAD_TOO_LARGE": 413,
# AgentRun 终态感知相关错误P0AgentRun 失败时透传上游错误上下文)
# - AGENT_RUN_FAILED / AGENT_RUN_EMPTY_OUTPUT上游 Agent 执行失败或产出
# 空内容502 Bad Gateway 语义(上游依赖故障)。
# - AGENT_RUN_CANCELLEDAgentRun 已被取消用户主动终止410 Gone 语义。
# 经 ``InboundMessageService`` SKIP 后通常不触达 HTTP 层,仅作完整性映射。
# - AGENT_RUN_INTERRUPTEDAgentRun 被挂起等待用户输入ask_user_question /
# human_approval409 Conflict 语义,需要客户端解决状态冲突。
"AGENT_RUN_FAILED": 502,
"AGENT_RUN_EMPTY_OUTPUT": 502,
"AGENT_RUN_CANCELLED": 410,
"AGENT_RUN_INTERRUPTED": 409,
}

View File

@ -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",

View File

@ -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

View File

@ -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="可靠性",

View File

@ -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
- DependencyErrorDB 故障
"""
...
def streamAgentRunEvents(
self,
*,

View File

@ -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 实际执行状态
- 读取不修改持久化状态仅读取
"""
...

View File

@ -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))

View 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"

View File

@ -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 风格,
# 含冒号的服务账号 uidsvc: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)

View File

@ -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
- 服务账号缺失 InternalErrorINV-7 错误显式化不回退
- 服务账号异常契约错误放行其余包装为 DependencyErrorADP-002
getAgentRunTerminalInfo 委托 ``execution_port.getRunTerminalInfo`` 查询
``agent_runs`` 表终态信息测试覆盖以下路径
- 终态返回 Some(AgentRunTerminalInfo) status / agent_id / error_type / error_message
- 非终态或记录不存在返回 Nothing
- 契约错误ValidationError / DependencyError / NotFoundError放行
- 其余异常包装为 DependencyErrorADP-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"

View File

@ -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_eventsstreamAgentRun 产出的事件列表按序 yield 后结束迭代
final_outputgetAgentRunFinalOutput 返回的 Option
final_outputgetAgentRunFinalOutput 返回的 Option默认 Nothing()
terminal_infogetAgentRunTerminalInfo 返回的 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 返回 NothingAgentRun 无文本输出)
async def test_persistent_mode_final_output_nothing_non_terminal_keeps_empty(self):
# Arrange — getAgentRunFinalOutput 返回 Nothing无 messages 事件),
# 同时 getAgentRunTerminalInfo 返回 NothingAgentRun 仍为 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()

View File

@ -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 幂等记录不被回退为 failedSKIP 语义)
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 行为测试。"""