1. 移除所有适配器文件中多余的空导入行 2. 调整ValidationError继承,移除不必要的ValueError继承 3. 修正多处ChannelType使用方式,从.value改为直接使用枚举实例 4. 优化飞书插件部分硬编码渠道类型为枚举实例 5. 更新wechat_ilink插件清单与适配器配置 6. 新增飞书目录适配器缓存清理支持判断与iLink生命周期适配器凭据轮换支持判断 7. 优化配置处理器历史查询逻辑,区分键不存在与无历史记录场景
464 lines
22 KiB
Python
464 lines
22 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)。
|
||
"""
|
||
|
||
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()
|