ForcePilot/backend/package/yuxi/channels/adapters/agent_run_adapter.py

464 lines
22 KiB
Python
Raw Normal View History

"""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-001createAgentRun 通过 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 解析服务账号(执行身份)。
# 渠道来源必须解析出服务账号,缺失则抛 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:
# 通过 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()