ForcePilot/backend/package/yuxi/channels/adapters/agent_run_adapter.py
Kris b88c0ae29e feat(channels): 批量新增多渠道网关限界上下文基础代码与契约
新增完整的 channels 限界上下文模块,包含契约层、领域核心层、应用服务、管道编排、插件体系、基础设施组合根等全层级代码,新增飞书与微信 iLink 渠道插件基础结构,补充各类 DTO、端口协议与领域服务实现。
2026-07-02 03:22:12 +08:00

388 lines
17 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
"""
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, 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
# 单次分页读取的事件上限,与 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 事件获取完整
输出文本,用于截断补全候选。
异常放行ADP-002 / INV-7``streamAgentRun`` 与
``getAgentRunFinalOutput`` 在 ``except Exception`` 兜底前显式放行
``ValidationError`` / ``DependencyError`` / ``NotFoundError`` 契约错误,
避免契约错误被包装为 ``DependencyError`` 丢失语义。
"""
def __init__(
self,
execution_port: AgentRunExecutionPort,
logger: LoggerPort,
) -> None:
"""初始化适配器。
Args:
execution_port: Agent 运行执行被驱动端口,封装
``agent_run_service`` 与 ``run_queue_service`` 的技术操作,
由外部 factory 注入并管理生命周期。
logger: 日志被驱动端口,用于记录兜底异常翻译路径上的故障
INV-10 可观测性)。强制注入,禁止使用全局 logger。
"""
self._execution_port = execution_port
self._logger = logger
async def createAgentRun(self, cmd: AgentRunCmd) -> AgentRunId:
"""创建 Agent 运行。
校验命令字段后委托 ``execution_port.createAgentRunView`` 创建
运行,将渠道上下文注入 ``meta``,渠道工具列表注入
``meta["channel_tools"]``,发送者 ID 由服务端从
``cmd.context.channel_sender_id`` 强制注入。
Args:
cmd: Agent 运行命令,携带会话 ID、消息内容与渠道上下文。
Returns:
AgentRunId 标识已创建的运行。
Raises:
ValidationError: 由 ``AgentRunCmd`` 构造时校验抛出
``conversation_id`` 或 ``message_content`` 为空)。
DependencyError: 依赖服务DB / agent_run_service故障。
"""
ctx = cmd.context
meta: dict[str, Any] = {}
current_uid = ""
if ctx is not None:
if ctx.channel_type is not None:
meta["channel_type"] = ctx.channel_type.value
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
# channel_sender_id 可能为 None使用空串占位以满足服务签名要求
current_uid = 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:
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=None,
)
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()