"""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)。 """ from __future__ import annotations from collections.abc import AsyncIterator from typing import Any 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 __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) -> AgentRunId: """创建 Agent 运行。 渠道来源下先通过 ``ServiceAccountPort`` 按 channel_type + channel_account_id 解析服务账号,以服务账号 uid 作为 ``current_uid`` (执行身份)注入下游,解决使用渠道发送者 ID 导致会话归属校验失败的 问题;并通过 ``pg_manager`` 获取真实 AsyncSession 注入 ``createAgentRunView``,支撑下游用户存在性校验。随后将渠道上下文、 渠道工具列表、执行身份与渠道用户身份注入 ``meta``。 非渠道来源(ctx 为 None 或无 channel_type)不解析服务账号, ``current_uid`` 回退到 ``channel_sender_id``,``db`` 仍注入真实 session 以保持下游一致。 Args: cmd: Agent 运行命令,携带会话 ID、消息内容与渠道上下文。 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: # 通过 pg_manager 获取真实 AsyncSession 注入下游,支撑用户存在性校验 # 与会话归属查询(替代原 db=None)。session 在上下文退出时自动提交。 async with pg_manager.get_async_session_context() as session: result = await self._execution_port.createAgentRunView( query=cmd.message_content.text, agent_id=agent_id, thread_id=cmd.conversation_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 @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 时由 ``channel_sender_id`` 写入),确保 ``streamAgentRunEvents`` 内部的 权限校验通过。 Args: run_id: Agent 运行 ID。 Yields: ``StreamEvent`` 流式事件 DTO。 Raises: ValidationError: 契约校验失败。 DependencyError: 依赖服务(DB / Redis 流)故障。 NotFoundError: run 记录不存在。 """ try: # 通过 execution_port 查询 run 记录的 uid(创建时由 # channel_sender_id 写入),确保 streamAgentRunEvents 内部 # get_run_for_user 权限校验通过。current_uid="" 会导致查不到 # 记录而报错。 current_uid = await self._execution_port.getRunUid(run_id.value, None) 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()