1. 修复代码格式与缩进问题,统一代码风格 2. 调整凭证缓存TTL为不过期,显式管理缓存失效 3. 重构wechat-woc出站请求,添加可重入锁保证同账号串行调用 4. 新增wechat-woc向导适配器配置端口支持,兼容开发环境localhost 5. 实现入站消息出站投递异步化,添加账号级并发限制 6. 新增transport启动前凭证缓存预热逻辑 7. 调整导入顺序与依赖位置,优化代码结构
559 lines
26 KiB
Python
559 lines
26 KiB
Python
"""AgentRunAdapter:实现 AgentRunPort,通过 AgentRunExecutionPort 委托执行。
|
||
|
||
- createAgentRun 委托 AgentRunExecutionPort.createAgentRunView 创建运行
|
||
- streamAgentRun 委托 AgentRunExecutionPort.streamAgentRunEvents 输出流式事件
|
||
- getAgentRunFinalOutput 委托 AgentRunExecutionPort.listRunStreamEvents 重放事件流
|
||
- 必须支持 Agent 渠道上下文注入(由 AgentRunCmd.context 携带)
|
||
- 必须支持渠道工具注册(由 AgentRunCmd.context.channel_tools 携带)
|
||
- 必须支持可信注入边界(服务端强制注入发送者 ID)
|
||
|
||
依赖边界:主要依赖 yuxi.channels.contract(端口 + DTO + 错误),
|
||
通过构造函数注入 AgentRunExecutionPort 消除对 yuxi.services 的直接
|
||
跨层依赖(ADP-001)。createAgentRun 通过 ServiceAccountPort 解析执行
|
||
身份(服务账号 uid),并通过 pg_manager 获取 AsyncSession 注入下游,
|
||
以支撑会话归属与用户存在性校验(Task 12)。
|
||
|
||
事务透传(C-I1):``createAgentRun`` 接受 ``tx`` 参数,非空时复用应用层
|
||
主事务的共享 session(``tx.get_session()``),``createAgentRunView`` 的
|
||
写操作加入主事务,由应用层统一提交/回滚,避免独立提交产生孤儿 AgentRun
|
||
记录(主事务回滚时 AgentRun 一并回滚)。``tx`` 为 ``None`` 时回退到
|
||
``pg_manager`` 自主获取 session 并独立提交(向后兼容)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import AsyncIterator
|
||
from contextlib import asynccontextmanager
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.exc import SQLAlchemyError
|
||
|
||
from yuxi.channels.contract.dtos.agent_run import AgentRunCmd, AgentRunId
|
||
from yuxi.channels.contract.dtos.common import MessageContent
|
||
from yuxi.channels.contract.dtos.option import Nothing, Option, Some
|
||
from yuxi.channels.contract.dtos.stream_event import StreamEvent
|
||
from yuxi.channels.contract.dtos.tools import ChannelTool
|
||
from yuxi.channels.contract.errors import (
|
||
DependencyError,
|
||
InternalError,
|
||
NotFoundError,
|
||
ValidationError,
|
||
)
|
||
from yuxi.channels.contract.errors.base import Error
|
||
from yuxi.channels.contract.ports.driven.agent_run_execution_port import AgentRunExecutionPort
|
||
from yuxi.channels.contract.ports.driven.agent_run_port import AgentRunPort
|
||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||
from yuxi.channels.contract.ports.driven.service_account_port import ServiceAccountPort
|
||
from yuxi.storage.postgres.manager import pg_manager
|
||
from yuxi.storage.postgres.models_business import Conversation
|
||
|
||
if TYPE_CHECKING:
|
||
from yuxi.channels.contract.ports.driven.transaction_port import TransactionContext
|
||
|
||
__all__ = ["AgentRunAdapter"]
|
||
|
||
# 单次分页读取的事件上限,与 stream_agent_run_events 保持一致
|
||
_EVENTS_PAGE_LIMIT = 200
|
||
|
||
|
||
def _serialize_channel_tool(tool: ChannelTool) -> dict[str, Any]:
|
||
"""将 ``ChannelTool`` 序列化为可存入 meta 的字典。
|
||
|
||
``ChannelTool`` 为 frozen dataclass,无法直接序列化到 meta 字典。
|
||
本函数把工具名称、描述、handler_id 与参数列表转换为纯字典结构,
|
||
供下游 ``resolve_configured_runtime_tools`` 重建为 LangChain 工具。
|
||
|
||
参数:
|
||
tool: 渠道工具实例。
|
||
|
||
返回:
|
||
可序列化的工具字典,含 name / description / handler_id / parameters / scope。
|
||
"""
|
||
return {
|
||
"name": tool.name,
|
||
"description": tool.description,
|
||
"handler_id": tool.handler_id,
|
||
"parameters": [
|
||
{
|
||
"name": p.name,
|
||
"type": p.type,
|
||
"description": p.description,
|
||
"required": p.required,
|
||
"default": p.default,
|
||
}
|
||
for p in tool.parameters
|
||
],
|
||
"scope": tool.scope,
|
||
}
|
||
|
||
|
||
class AgentRunAdapter(AgentRunPort):
|
||
"""Agent 运行被驱动适配器。
|
||
|
||
通过构造函数注入 ``AgentRunExecutionPort`` 委托实现 ``AgentRunPort``
|
||
契约,不直接依赖 ``yuxi.services``(ADP-001)。
|
||
``createAgentRun`` 委托 ``execution_port.createAgentRunView`` 创建运行
|
||
并注入渠道上下文、渠道工具列表与可信发送者;``streamAgentRun`` 委托
|
||
``execution_port.streamAgentRunEvents`` 输出流式事件并转换为
|
||
``StreamEvent`` DTO;``getAgentRunFinalOutput`` 委托
|
||
``execution_port.listRunStreamEvents`` 重放 Redis Stream 事件获取完整
|
||
输出文本,用于截断补全候选。
|
||
|
||
执行身份解析(Task 12):``createAgentRun`` 通过
|
||
``ServiceAccountPort`` 按 channel_type + channel_account_id 解析服务
|
||
账号,以服务账号 uid 作为 ``current_uid`` 注入下游,解决使用渠道发送者
|
||
ID 导致会话归属校验失败的问题;同时通过 ``pg_manager`` 获取真实
|
||
AsyncSession 注入 ``createAgentRunView``,支撑下游用户存在性校验。
|
||
|
||
异常放行(ADP-002 / INV-7):``streamAgentRun`` 与
|
||
``getAgentRunFinalOutput`` 在 ``except Exception`` 兜底前显式放行
|
||
``ValidationError`` / ``DependencyError`` / ``NotFoundError`` 契约错误,
|
||
避免契约错误被包装为 ``DependencyError`` 丢失语义。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
execution_port: AgentRunExecutionPort,
|
||
logger: LoggerPort,
|
||
service_account_port: ServiceAccountPort,
|
||
) -> None:
|
||
"""初始化适配器。
|
||
|
||
Args:
|
||
execution_port: Agent 运行执行被驱动端口,封装
|
||
``agent_run_service`` 与 ``run_queue_service`` 的技术操作,
|
||
由外部 factory 注入并管理生命周期。
|
||
logger: 日志被驱动端口,用于记录兜底异常翻译路径上的故障
|
||
(INV-10 可观测性)。强制注入,禁止使用全局 logger。
|
||
service_account_port: 服务账号领域服务被驱动端口,用于按渠道
|
||
账户解析服务账号(执行身份)。渠道来源必须能解析出服务账号,
|
||
缺失时抛 ``InternalError``(INV-7 错误显式化,不回退)。
|
||
"""
|
||
self._execution_port = execution_port
|
||
self._logger = logger
|
||
self._service_account_port = service_account_port
|
||
|
||
async def createAgentRun(
|
||
self,
|
||
cmd: AgentRunCmd,
|
||
*,
|
||
tx: TransactionContext | None = None,
|
||
) -> AgentRunId:
|
||
"""创建 Agent 运行。
|
||
|
||
渠道来源下先通过 ``ServiceAccountPort`` 按 channel_type +
|
||
channel_account_id 解析服务账号,以服务账号 uid 作为 ``current_uid``
|
||
(执行身份)注入下游,解决使用渠道发送者 ID 导致会话归属校验失败的
|
||
问题;并获取 AsyncSession 注入 ``createAgentRunView``,支撑下游用户
|
||
存在性校验。随后将渠道上下文、渠道工具列表、执行身份与渠道用户身份
|
||
注入 ``meta``。
|
||
|
||
非渠道来源(ctx 为 None 或无 channel_type)不解析服务账号,
|
||
``current_uid`` 回退到 ``channel_sender_id``,``db`` 仍注入真实
|
||
session 以保持下游一致。
|
||
|
||
事务透传(C-I1):``tx`` 非空时通过 ``tx.get_session()`` 复用应用层
|
||
主事务的共享 session,``createAgentRunView`` 的写操作加入主事务,
|
||
由应用层统一提交/回滚(适配器不自主提交),避免独立提交产生孤儿
|
||
AgentRun 记录。``tx`` 为 ``None`` 时回退到
|
||
``pg_manager.get_async_session_context()`` 自主获取 session 并独立
|
||
提交(向后兼容)。``commit`` 策略对齐
|
||
``ChannelPersistenceAdapter._should_commit(tx)``(``return tx is None``)。
|
||
|
||
Args:
|
||
cmd: Agent 运行命令,携带会话 ID、消息内容与渠道上下文。
|
||
tx: 事务上下文,非空时复用主事务共享 session(不自主提交);
|
||
为 None 时自主获取 session 并独立提交(向后兼容)。
|
||
|
||
Returns:
|
||
AgentRunId 标识已创建的运行。
|
||
|
||
Raises:
|
||
ValidationError: 由 ``AgentRunCmd`` 构造时校验抛出
|
||
(``conversation_id`` 或 ``message_content`` 为空)。
|
||
InternalError: 渠道来源下服务账号缺失(INV-7 错误显式化,不回退)。
|
||
DependencyError: 依赖服务(DB / agent_run_service / service_account)故障。
|
||
"""
|
||
ctx = cmd.context
|
||
|
||
# 通过 ServiceAccountPort 解析服务账号(执行身份)。
|
||
# 渠道来源必须解析出服务账号,缺失则抛 InternalError(INV-7 错误显式化,
|
||
# 不回退);非渠道来源(ctx 为 None 或无 channel_type)跳过解析。
|
||
if ctx is not None and ctx.channel_type is not None and ctx.channel_account_id:
|
||
try:
|
||
service_account = await self._service_account_port.getServiceAccountByChannelAccount(
|
||
ctx.channel_type, ctx.channel_account_id
|
||
)
|
||
except (ValidationError, DependencyError, NotFoundError):
|
||
raise
|
||
except Exception as exc:
|
||
# 契约错误已放行,其余异常包装为 DependencyError(ADP-002)
|
||
await self._logger.error(
|
||
"service_account_resolve_failed",
|
||
resource="service_account",
|
||
channel_type=ctx.channel_type,
|
||
account_id=ctx.channel_account_id,
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("service_account", exc) from exc
|
||
if service_account is None:
|
||
raise InternalError(message="Service account not found for channel account")
|
||
else:
|
||
service_account = None
|
||
|
||
meta: dict[str, Any] = {}
|
||
current_uid = ""
|
||
if ctx is not None:
|
||
if ctx.channel_type is not None:
|
||
meta["channel_type"] = ctx.channel_type
|
||
meta["channel_account_id"] = ctx.channel_account_id
|
||
meta["channel_peer_id"] = ctx.channel_peer_id
|
||
meta["channel_group_id"] = ctx.channel_group_id
|
||
meta["channel_topic_id"] = ctx.channel_topic_id
|
||
meta["channel_sender_id"] = ctx.channel_sender_id
|
||
if ctx.channel_format_spec is not None:
|
||
meta["channel_format_description"] = ctx.channel_format_spec.format_description
|
||
# BP-6: supported_formats 序列化为字符串列表,供下游格式渲染。
|
||
meta["channel_supported_formats"] = [f.value for f in ctx.channel_format_spec.supported_formats]
|
||
else:
|
||
# BP-6: format_spec 为 None 时写入空列表,便于下游统一处理。
|
||
meta["channel_supported_formats"] = []
|
||
if ctx.channel_context_note is not None:
|
||
meta["system_prompt_appendix"] = ctx.channel_context_note.system_prompt_appendix
|
||
meta["context_remark"] = ctx.channel_context_note.context_remark
|
||
# FR-11: 渠道工具列表序列化到 meta,供下游 Agent 运行时合并。
|
||
# 非渠道来源或开关关闭时 channel_tools 为空 tuple,不写入 meta。
|
||
if ctx.channel_tools:
|
||
meta["channel_tools"] = [_serialize_channel_tool(tool) for tool in ctx.channel_tools]
|
||
# BP-1: 统一身份序列化到 meta,None 时写入 None 保持键存在,
|
||
# 便于下游统一处理。identity_type 已为 str,直接使用。
|
||
meta["unified_identity"] = (
|
||
{
|
||
"identity_id": ctx.unified_identity.identity_id,
|
||
"user_id": ctx.unified_identity.user_id,
|
||
"identity_type": ctx.unified_identity.identity_type,
|
||
"identity_value": ctx.unified_identity.identity_value,
|
||
"source": ctx.unified_identity.source,
|
||
}
|
||
if ctx.unified_identity is not None
|
||
else None
|
||
)
|
||
# BP-2: DM 安全决策取 .value,配对 ID 直接透传;None 时写入 None。
|
||
meta["dm_decision"] = ctx.dm_decision.value if ctx.dm_decision is not None else None
|
||
meta["pairing_id"] = ctx.pairing_id
|
||
# 执行身份:服务账号 uid(已解析),非渠道来源为空串。
|
||
meta["service_user_uid"] = service_account.uid if service_account else ""
|
||
# BP-1: 渠道用户身份序列化到 meta,None 时写入 None 保持键存在,
|
||
# 便于下游统一处理。
|
||
meta["channel_user_identity"] = (
|
||
{
|
||
"identity_id": ctx.channel_user_identity.identity_id,
|
||
"user_id": ctx.channel_user_identity.user_id,
|
||
"identity_type": ctx.channel_user_identity.identity_type,
|
||
"identity_value": ctx.channel_user_identity.identity_value,
|
||
"source": ctx.channel_user_identity.source,
|
||
}
|
||
if ctx.channel_user_identity is not None
|
||
else None
|
||
)
|
||
# 会话来源标识:渠道来源为 True,供下游区分处理。
|
||
meta["is_channel_session"] = ctx.channel_type is not None
|
||
# current_uid 使用服务账号 uid 作为执行身份(已解析);
|
||
# 非渠道来源回退到 channel_sender_id,None 时用空串占位以满足服务签名要求。
|
||
current_uid = service_account.uid if service_account else (ctx.channel_sender_id or "")
|
||
if cmd.source:
|
||
meta["source"] = cmd.source
|
||
# BP-3: trace_id 透传到 meta,供下游链路追踪。
|
||
if cmd.trace_id:
|
||
meta["trace_id"] = cmd.trace_id
|
||
# FR-15: 非静默命令结果注入 Agent 上下文。command_result 为 None 时
|
||
# 不写入 meta,下游非渠道路径不受影响。
|
||
if cmd.command_result is not None:
|
||
result = cmd.command_result
|
||
meta["command_result"] = {
|
||
"command": result.command,
|
||
"is_silent": result.is_silent,
|
||
"response": {
|
||
"content": result.response.content,
|
||
"content_type": result.response.content_type,
|
||
}
|
||
if result.response is not None
|
||
else None,
|
||
}
|
||
|
||
# agent_id 由 AgentRunEnqueueStage 从 route_binding.agent_binding 提取并
|
||
# 写入 AgentRunContext,此处直接使用。route_binding 未命中时为空串,
|
||
# 由下游 create_agent_run_view 校验并返回 404。
|
||
agent_id = ctx.agent_id if ctx is not None else ""
|
||
|
||
# FR-47: 从 attachments 提取首张图片的 base64_content
|
||
image_content = self._extractFirstImageBase64(cmd.message_content)
|
||
# FR-47: 序列化全部附件元数据,供 Agent 感知用户发送的附件内容
|
||
# (非图片附件与多图场景)。首图 base64 仍通过 image_content 透传。
|
||
meta["attachments"] = self._extractAttachmentsMeta(cmd.message_content)
|
||
try:
|
||
# C-I1:tx 非空时复用主事务共享 session(不自主提交,由应用层统一
|
||
# 提交/回滚);tx 为 None 时通过 pg_manager 自主获取 session 并独立
|
||
# 提交(向后兼容)。commit 策略对齐 _should_commit(tx)(return tx is None)。
|
||
async with self._session_scope(tx) as session:
|
||
# 渠道管道的 conversation_id 是 Conversation 表数字主键(由
|
||
# resolveConversation 返回 str(id)),而 create_agent_run_view
|
||
# 期望 thread_id(UUID 字符串)查询会话。此处将数字主键转换为
|
||
# thread_id,消除两个系统的 ID 语义错位。
|
||
thread_id = await self._resolveThreadId(session, cmd.conversation_id)
|
||
result = await self._execution_port.createAgentRunView(
|
||
query=cmd.message_content.text,
|
||
agent_id=agent_id,
|
||
thread_id=thread_id,
|
||
meta=meta,
|
||
image_content=image_content,
|
||
current_uid=current_uid,
|
||
db=session,
|
||
)
|
||
return AgentRunId(result["run_id"])
|
||
except (ValidationError, DependencyError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"agent_run_create_failed",
|
||
resource="agent_run_service",
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("agent_run_service", exc) from exc
|
||
|
||
@asynccontextmanager
|
||
async def _session_scope(
|
||
self,
|
||
tx: TransactionContext | None,
|
||
) -> AsyncIterator[Any]:
|
||
"""获取数据库会话的范围上下文(C-I1 事务透传)。
|
||
|
||
``tx`` 非空时复用应用层主事务的共享 session(``tx.get_session()``),
|
||
适配器不自主提交(由应用层统一提交/回滚);``tx`` 为 ``None`` 时通过
|
||
``pg_manager`` 自主获取 session 并独立提交(向后兼容)。
|
||
|
||
参数:
|
||
tx: 事务上下文,``None`` 表示无应用层事务。
|
||
|
||
生成:
|
||
数据库会话(``AsyncSession`` 或 mock)。
|
||
"""
|
||
if tx is not None:
|
||
yield tx.get_session()
|
||
else:
|
||
async with pg_manager.get_async_session_context() as session:
|
||
yield session
|
||
|
||
@staticmethod
|
||
async def _resolveThreadId(session: Any, conversation_id: str) -> str:
|
||
"""将渠道管道的数字主键 conversation_id 转换为 thread_id(UUID)。
|
||
|
||
渠道管道全程用 ``Conversation.id``(数字主键)作为 ``conversation_id``
|
||
(由 ``resolveConversation`` 返回 ``str(id)``),而
|
||
``create_agent_run_view`` 期望 ``thread_id``(UUID 字符串)查询会话。
|
||
此方法消除两个系统的 ID 语义错位。
|
||
|
||
参数:
|
||
session: SQLAlchemy AsyncSession。
|
||
conversation_id: 渠道管道的数字主键 conversation_id。
|
||
|
||
返回:
|
||
conversation 的 thread_id(UUID 字符串)。
|
||
|
||
抛出:
|
||
NotFoundError: conversation 不存在。
|
||
DependencyError: 数据库查询故障。
|
||
"""
|
||
try:
|
||
cid = int(conversation_id)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValidationError("conversation_id", f"invalid conversation_id: {conversation_id}") from exc
|
||
try:
|
||
stmt = select(Conversation.thread_id).where(Conversation.id == cid)
|
||
result = await session.execute(stmt)
|
||
thread_id = result.scalar_one_or_none()
|
||
except SQLAlchemyError as exc:
|
||
raise DependencyError("conversation", Error(str(exc))) from exc
|
||
if thread_id is None:
|
||
raise NotFoundError("conversation", f"conversation not found: id={cid}")
|
||
return thread_id
|
||
|
||
@staticmethod
|
||
def _extractFirstImageBase64(message_content: MessageContent) -> str | None:
|
||
"""从消息内容提取首张图片的 base64 内容。
|
||
|
||
FR-47: 遍历 attachments,取首个 type="image" 且 base64_content 非空的附件。
|
||
多图场景仅取首张(v1 限制,多图演进列入 v2)。
|
||
|
||
参数:
|
||
message_content: 消息内容,含附件列表。
|
||
|
||
返回:
|
||
首张图片的 base64 内容;无图片时返回 None。
|
||
"""
|
||
if not message_content.attachments:
|
||
return None
|
||
for attachment in message_content.attachments:
|
||
if attachment.type == "image" and attachment.base64_content:
|
||
return attachment.base64_content
|
||
return None
|
||
|
||
@staticmethod
|
||
def _extractAttachmentsMeta(message_content: MessageContent) -> list[dict[str, Any]]:
|
||
"""提取消息中所有附件的元数据(不含二进制内容)。
|
||
|
||
FR-47: 序列化附件元数据(type / filename / mime_type / size / url),
|
||
供 Agent 感知用户发送的附件内容。图片附件的 base64_content 不包含
|
||
在元数据中,首图仍通过 ``image_content`` 路径透传(v1 单图限制)。
|
||
非图片附件(视频/文件/音频)的元数据透传到 Agent,弥补 v1 仅支持
|
||
单图 base64 的限制。
|
||
|
||
参数:
|
||
message_content: 消息内容,含附件列表。
|
||
|
||
返回:
|
||
附件元数据列表,每项含 type / filename / mime_type / size / url。
|
||
"""
|
||
if not message_content.attachments:
|
||
return []
|
||
attachments_meta: list[dict[str, Any]] = []
|
||
for attachment in message_content.attachments:
|
||
attachments_meta.append(
|
||
{
|
||
"type": attachment.type,
|
||
"filename": attachment.filename,
|
||
"mime_type": attachment.mime_type,
|
||
"size": attachment.size,
|
||
"url": attachment.url,
|
||
}
|
||
)
|
||
return attachments_meta
|
||
|
||
async def streamAgentRun(self, run_id: AgentRunId) -> AsyncIterator[StreamEvent]:
|
||
"""流式消费 Agent 运行事件。
|
||
|
||
委托 ``execution_port.streamAgentRunEvents`` 输出流式事件并转换为
|
||
``StreamEvent`` DTO。``current_uid`` 通过
|
||
``execution_port.getRunUid`` 从 run 记录查询获取(创建 run 时由
|
||
``service_account.uid`` 写入),确保 ``streamAgentRunEvents`` 内部的
|
||
权限校验通过。
|
||
|
||
Args:
|
||
run_id: Agent 运行 ID。
|
||
|
||
Yields:
|
||
``StreamEvent`` 流式事件 DTO。
|
||
|
||
Raises:
|
||
ValidationError: 契约校验失败。
|
||
DependencyError: 依赖服务(DB / Redis 流)故障。
|
||
NotFoundError: run 记录不存在。
|
||
"""
|
||
try:
|
||
# 通过 execution_port 查询 run 记录的 uid(创建时由
|
||
# service_account.uid 写入),确保 streamAgentRunEvents 内部
|
||
# get_run_for_user 权限校验通过。current_uid="" 会导致查不到
|
||
# 记录而报错。需通过 _session_scope 获取独立会话,不能传 None
|
||
# 否则 AgentRunRepository(None).get_run() 会在 None.execute()
|
||
# 处抛 AttributeError。
|
||
async with self._session_scope(None) as session:
|
||
current_uid = await self._execution_port.getRunUid(run_id.value, session)
|
||
|
||
async for event in self._execution_port.streamAgentRunEvents(
|
||
run_id=run_id.value,
|
||
after_seq="",
|
||
current_uid=current_uid,
|
||
verbose=True,
|
||
):
|
||
yield StreamEvent(
|
||
event_type=event.get("event_type", ""),
|
||
payload=event.get("payload") or {},
|
||
seq=event.get("seq", ""),
|
||
)
|
||
except (ValidationError, DependencyError, NotFoundError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"agent_run_stream_failed",
|
||
resource="redis_stream",
|
||
run_id=run_id.value,
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("redis_stream", Error(str(exc))) from exc
|
||
|
||
async def getAgentRunFinalOutput(self, run_id: AgentRunId) -> Option[str]:
|
||
"""获取 Agent 运行的最终输出文本。
|
||
|
||
委托 ``execution_port.listRunStreamEvents`` 分页读取 Redis Stream
|
||
中的全部事件,过滤 ``messages`` 事件并拼接其 ``items`` 中各 chunk
|
||
的可见内容(``stream_event.content`` 优先,回退 ``response``),
|
||
返回完整输出文本。用于截断补全:当流式投递因 TTL 超时或异常中断
|
||
导致 ``stream_chunks`` 不完整时,通过重放事件流获取更完整的文本
|
||
作为截断补全候选。
|
||
|
||
与 ``streamAgentRun`` 的区别:``streamAgentRun`` 是实时流式消费
|
||
(逐块 yield ``StreamEvent``),本方法一次性读取全部事件并拼接为
|
||
纯文本,用于事后补全而非实时投递。
|
||
|
||
Args:
|
||
run_id: Agent 运行 ID。
|
||
|
||
Returns:
|
||
``Some(完整输出文本)``;事件流为空或已 trim 时返回 ``Nothing``。
|
||
|
||
Raises:
|
||
ValidationError: 契约校验失败。
|
||
DependencyError: 依赖服务(Redis 流)故障。
|
||
NotFoundError: run 记录不存在。
|
||
"""
|
||
parts: list[str] = []
|
||
after_seq = "0-0"
|
||
try:
|
||
while True:
|
||
events = await self._execution_port.listRunStreamEvents(
|
||
run_id=run_id.value,
|
||
after_seq=after_seq,
|
||
limit=_EVENTS_PAGE_LIMIT,
|
||
)
|
||
if not events:
|
||
break
|
||
for event in events:
|
||
if event.get("event_type") != "messages":
|
||
continue
|
||
envelope = event.get("payload") or {}
|
||
inner_payload = envelope.get("payload") or {}
|
||
items = inner_payload.get("items") or []
|
||
for chunk in items:
|
||
if not isinstance(chunk, dict):
|
||
continue
|
||
# 优先使用 stream_event.content(语义增量),
|
||
# 回退到 response(直接文本),避免重复拼接
|
||
stream_event = chunk.get("stream_event")
|
||
if isinstance(stream_event, dict):
|
||
content = stream_event.get("content")
|
||
if isinstance(content, str) and content:
|
||
parts.append(content)
|
||
continue
|
||
response = chunk.get("response")
|
||
if isinstance(response, str) and response:
|
||
parts.append(response)
|
||
after_seq = events[-1].get("seq", after_seq)
|
||
if len(events) < _EVENTS_PAGE_LIMIT:
|
||
break
|
||
except (ValidationError, DependencyError, NotFoundError):
|
||
raise
|
||
except Exception as exc:
|
||
await self._logger.error(
|
||
"agent_run_final_output_failed",
|
||
resource="redis_stream",
|
||
run_id=run_id.value,
|
||
error=str(exc),
|
||
)
|
||
raise DependencyError("redis_stream", Error(str(exc))) from exc
|
||
|
||
text = "".join(parts)
|
||
if text:
|
||
return Some(text)
|
||
return Nothing()
|