ForcePilot/backend/package/yuxi/channels/adapters/agent_run_adapter.py
Kris 977724d7c3 refactor(wechat-woc,transport): 批量代码优化与功能增强
1.  修复代码格式与缩进问题,统一代码风格
2.  调整凭证缓存TTL为不过期,显式管理缓存失效
3.  重构wechat-woc出站请求,添加可重入锁保证同账号串行调用
4.  新增wechat-woc向导适配器配置端口支持,兼容开发环境localhost
5.  实现入站消息出站投递异步化,添加账号级并发限制
6.  新增transport启动前凭证缓存预热逻辑
7.  调整导入顺序与依赖位置,优化代码结构
2026-07-11 05:42:26 +08:00

559 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 解析服务账号(执行身份)。
# 渠道来源必须解析出服务账号,缺失则抛 InternalErrorINV-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:
# 契约错误已放行,其余异常包装为 DependencyErrorADP-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: 统一身份序列化到 metaNone 时写入 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: 渠道用户身份序列化到 metaNone 时写入 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_idNone 时用空串占位以满足服务签名要求。
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-I1tx 非空时复用主事务共享 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_idUUID 字符串)查询会话。此处将数字主键转换为
# 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_idUUID
渠道管道全程用 ``Conversation.id``(数字主键)作为 ``conversation_id``
(由 ``resolveConversation`` 返回 ``str(id)``),而
``create_agent_run_view`` 期望 ``thread_id``UUID 字符串)查询会话。
此方法消除两个系统的 ID 语义错位。
参数:
session: SQLAlchemy AsyncSession。
conversation_id: 渠道管道的数字主键 conversation_id。
返回:
conversation 的 thread_idUUID 字符串)。
抛出:
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()