Files
jxxghp 6d4ce83837 fix(types): clear host mypy debt and prevent typing regressions
严格检查全部宿主源码,清零类型错误预算并移除按模块类型豁免。
按原因、所属方法、错误码及语法指纹锁定必要兼容例外和站点类型桩,
CI 阻断新增忽略、检查范围退化、本地桩替代真实接口及新的类型错误。

修复空值、异步和供应方类型边界,保持插件与 API 兼容合同,
并补充存储、订阅、通知、Agent 和工作流边界的回归覆盖。
2026-10-21 22:03:18 +08:00

782 lines
27 KiB
Python
Raw Permalink 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.
"""
Agent 会话历史的查询、授权与删除应用服务。
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, List, Optional, Protocol
from weakref import WeakValueDictionary
from app.application.database import (
AsyncDatabaseExecutor,
)
from app.application.messaging.recall import RecallService
from app.runtime.observability import record_metric
from app.runtime.priority import INTERACTIVE_DATABASE_CAPACITY, is_interactive
from app.schemas.agent import AgentChatSessionDetail, AgentChatSessionSummary
from app.schemas.exception import AgentChatPersistenceUnavailableError
DEFAULT_AGENT_CHAT_WRITE_CAPACITY = 32
DEFAULT_AGENT_CHAT_SESSION_CAPACITY = 4
def has_custom_agent_chat_title(value: Optional[str]) -> bool:
"""
判断会话标题是否已经脱离默认占位标题。
"""
return bool(value and value.strip() and value.strip() != "未命名会话")
class AgentChatPrincipal(Protocol):
"""
会话访问控制所需的最小用户身份。
"""
@property
def id(self) -> Any:
"""
返回稳定用户标识。
:return: 用于会话归属校验的稳定用户标识
"""
...
@property
def name(self) -> Optional[str]:
"""
返回可选用户显示名。
:return: 用于展示及旧会话兼容查询的用户名称,未提供时为 None
"""
...
@property
def is_superuser(self) -> bool:
"""
返回是否具备超级管理员权限。
:return: 用户具备全局会话管理权限时为 True
"""
...
class AsyncAgentChatRepository(Protocol):
"""
Agent 会话用例需要的最小异步持久化端口。
"""
async def async_list_by_page(
self,
page: int = 1,
count: int = 30,
user_id: Optional[str] = None,
username: Optional[str] = None,
) -> list[Any]:
"""
分页读取用户可见的会话。
:param page: 页码,从 1 开始
:param count: 每页最多返回的会话数,默认 30
:param user_id: 按用户身份过滤;与 username 同时提供时还兼容同名且未绑定身份的旧记录
:param username: 旧记录兼容名称;单独提供时按名称筛选,两项均为 None 时不限定用户
:return: 按最近更新时间降序排列的会话记录
"""
...
async def async_get(
self,
session_id: str,
user_id: Optional[str] = None,
) -> Optional[Any]:
"""
按服务端会话 ID 读取记录。
:param session_id: 服务端会话标识
:param user_id: 限定记录归属的用户标识;None 表示不按用户过滤,授权由上层完成
:return: 匹配的持久化记录,不存在时为 None
"""
...
async def async_delete(
self,
session_id: str,
user_id: Optional[str] = None,
) -> bool:
"""
删除指定服务端会话。
:param session_id: 服务端会话标识
:param user_id: 限定记录归属的用户标识;None 表示不按用户过滤,授权由上层完成
:return: 找到并删除记录时为 True,不存在时为 False
"""
...
async def async_stage_delete(
self,
session_id: str,
user_id: Optional[str] = None,
) -> bool:
"""
暂存删除指定服务端会话,不提交调用方事务。
:param session_id: 服务端会话标识
:param user_id: 限定记录归属的用户标识;None 表示不按用户过滤,授权由上层完成
:return: 找到并删除记录时为 True,不存在时为 False
"""
...
def get(self, session_id: str, user_id: Optional[str] = None) -> Optional[Any]:
"""
同步读取服务端会话。
:param session_id: 服务端会话标识
:param user_id: 限定记录归属的用户标识;None 表示不按用户过滤,授权由上层完成
:return: 匹配的持久化记录,不存在时为 None
"""
...
class SyncAgentChatRepository(Protocol):
"""
仅包含 Agent 编排所需同步持久化方法的适配器端口。
"""
def append_display_messages(
self,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict[str, object]]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[Any]:
"""
追加用户可见消息。
暂存展示消息及会话元数据,事务由调用方提交。
:param session_id: 服务端会话标识
:param user_id: 会话归属的可信用户标识,None 表示不更新已有归属
:param messages: 本批追加的展示消息,None 或空列表不增加消息
:param username: 会话用户显示名称,None 表示不更新已有名称
:param channel: 会话通知渠道,None 表示不更新已有渠道
:param source: 渠道实例标识,None 表示不更新已有来源
:param original_chat_id: 外部渠道聊天标识,None 表示不更新已有标识
:param client_session_id: 客户端会话标识,None 表示不更新已有标识
:return: 更新后的会话记录,无法建立会话时为 None
"""
...
def save_display_messages(
self,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict[str, object]]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> Optional[Any]:
"""
保存用户可见消息快照。
暂存展示消息及会话元数据,事务由调用方提交。
:param session_id: 服务端会话标识
:param user_id: 会话归属的可信用户标识,None 表示不更新已有归属
:param messages: 替换当前快照的全部展示消息,None 表示清空快照
:param username: 会话用户显示名称,None 表示不更新已有名称
:param channel: 会话通知渠道,None 表示不更新已有渠道
:param source: 渠道实例标识,None 表示不更新已有来源
:param original_chat_id: 外部渠道聊天标识,None 表示不更新已有标识
:param client_session_id: 客户端会话标识,None 表示不更新已有标识
:return: 更新后的会话记录,无法建立会话时为 None
"""
...
def save_agent_messages(
self,
session_id: str,
user_id: Optional[str],
messages: list[dict[str, object]],
) -> None:
"""
保存可恢复的原始 Agent 消息。
暂存原始消息快照,事务由调用方提交;不替代用户展示消息。
:param session_id: 服务端会话标识
:param user_id: 会话所属用户标识,None 表示未提供身份
:param messages: 用于恢复 Agent 上下文的完整原始消息快照,空列表清空快照
"""
...
def update_title_if_empty(
self,
session_id: str,
user_id: Optional[str],
title: Optional[str],
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> None:
"""
在会话尚无标题时写入标题。
仅更新未设置自定义标题的会话;修改暂存于调用方事务。
:param session_id: 服务端会话标识
:param user_id: 会话归属的可信用户标识,None 表示不更新已有归属
:param title: 候选标题;空值或默认占位标题不覆盖现有标题
:param username: 会话用户显示名称,None 表示不更新已有名称
:param channel: 会话通知渠道,None 表示不更新已有渠道
:param source: 渠道实例标识,None 表示不更新已有来源
:param original_chat_id: 外部渠道聊天标识,None 表示不更新已有标识
:param client_session_id: 客户端会话标识,None 表示不更新已有标识
"""
...
SyncAgentChatRepositoryFactory = Callable[[object], SyncAgentChatRepository]
SyncAgentChatTransaction = Callable[[Callable[[object], object]], object]
@dataclass(frozen=True, slots=True)
class AgentChatRecord:
"""
脱离 ORM 会话的 Agent 会话持久化投影。
:param id: 业务数据库记录主键,尚无主键时为 None
:param session_id: 宿主分配的服务端会话标识
:param client_session_id: 客户端持有的会话标识,旧记录可能为 None
:param title: 会话展示标题,尚未生成时可为 None
:param channel: 会话所属通知渠道,未记录时为 None
:param source: 会话所属渠道实例,未记录时为 None
:param user_id: 持久化的用户身份标识,旧记录可能缺失
:param username: 用户显示名称,用于展示及兼容未绑定身份的旧会话
:param original_chat_id: 外部消息平台的原始聊天标识,无渠道上下文时为 None
:param message_count: 用户可见消息的条数
:param created_at: 存储层提供的会话创建时间
:param updated_at: 存储层提供的会话最近更新时间
:param messages: 供界面展示的消息快照
:param agent_messages: 供 Agent 恢复上下文的原始消息快照
"""
id: Optional[int]
session_id: str
client_session_id: Optional[str]
title: Optional[str]
channel: Optional[str]
source: Optional[str]
user_id: Optional[str]
username: Optional[str]
original_chat_id: Optional[str]
message_count: int
created_at: Any
updated_at: Any
messages: list[dict[str, object]]
agent_messages: list[dict[str, object]]
class AsyncUnitOfWork(Protocol):
"""
Agent 会话异步写用例所需的最小事务端口。
"""
async def commit(self) -> None:
"""
提交当前请求事务。
"""
...
async def rollback(self) -> None:
"""
回滚当前请求事务。
"""
...
class AgentChatService:
"""
统一执行 Agent 会话查询、访问控制和删除。
"""
def __init__(
self,
repository: AsyncAgentChatRepository,
unit_of_work: Optional[AsyncUnitOfWork] = None,
recall: RecallService | None = None,
) -> None:
"""
保存会话持久化端口和可选请求级事务。
"""
self._repository = repository
self._unit_of_work = unit_of_work
self._recall = recall
async def list(
self,
principal: AgentChatPrincipal,
*,
page: int = 1,
count: int = 30,
) -> list[AgentChatSessionSummary]:
"""
分页返回当前用户可见的会话摘要。
"""
user_id = None if principal.is_superuser else str(principal.id)
username = None if principal.is_superuser else principal.name
records = await self._repository.async_list_by_page(
page=page,
count=count,
user_id=user_id,
username=username,
)
return [self.to_summary(self._project(record)) for record in records]
async def get_accessible(
self,
session_id: str,
principal: AgentChatPrincipal,
) -> Optional[AgentChatRecord]:
"""
读取会话并在应用边界执行访问控制。
"""
projected = await self.get(session_id)
if projected is None:
return None
if not self.can_access(projected, principal):
return None
return projected
async def get(
self,
session_id: str,
user_id: Optional[str] = None,
) -> Optional[AgentChatRecord]:
"""
读取不附带授权判断的会话投影。
"""
record = await self._repository.async_get(
session_id=session_id,
user_id=user_id,
)
if record is None:
return None
return self._project(record)
async def delete(
self,
session_id: str,
principal: AgentChatPrincipal,
) -> bool:
"""
仅在当前用户可访问时删除会话。
"""
record = await self.get_accessible(session_id, principal)
if record is None:
return False
# 两个数据库没有共享事务:先收回证据并落删除墓碑,主库失败可安全重试。
if self._recall is not None and record.user_id:
await self._recall.delete(record.user_id, session_id)
if self._unit_of_work is None:
return await self._repository.async_delete(session_id=session_id)
try:
deleted = await self._repository.async_stage_delete(
session_id=session_id
)
if deleted:
await self._unit_of_work.commit()
return deleted
except Exception:
await self._unit_of_work.rollback()
raise
def get_sync(
self,
session_id: str,
user_id: Optional[str] = None,
) -> Optional[AgentChatRecord]:
"""
同步读取会话投影,供同步 Agent 编排路径使用。
"""
record = self._repository.get(session_id=session_id, user_id=user_id)
return self._project(record) if record is not None else None
def save_agent_messages(
self,
session_id: str,
user_id: str,
messages: List[dict[str, Any]],
) -> None:
"""
同步保存可恢复消息,兼容非协程 Agent 调用路径。
"""
sync_repository = self._repository
save = getattr(sync_repository, "save_agent_messages", None)
if not callable(save):
raise RuntimeError("Agent 会话仓储不支持同步消息保存")
save(session_id=session_id, user_id=user_id, messages=messages)
@staticmethod
def can_access(
record: AgentChatRecord,
principal: AgentChatPrincipal,
) -> bool:
"""
优先按稳定用户 ID 授权,仅无 ID 旧记录回退到用户名。
"""
if getattr(principal, "is_superuser", False):
return True
user_id = str(principal.id)
username = str(principal.name or "")
if record.user_id:
return str(record.user_id) == user_id
return bool(username) and record.username == username
@staticmethod
def to_summary(record: AgentChatRecord) -> AgentChatSessionSummary:
"""
把持久化投影转换为会话摘要 DTO。
"""
return AgentChatSessionSummary(
id=record.id,
session_id=record.session_id,
client_session_id=record.client_session_id,
title=record.title,
channel=record.channel,
source=record.source,
user_id=record.user_id,
username=record.username,
original_chat_id=record.original_chat_id,
message_count=record.message_count,
created_at=record.created_at,
updated_at=record.updated_at,
)
@classmethod
def to_detail(cls, record: AgentChatRecord) -> AgentChatSessionDetail:
"""
把持久化投影转换为会话详情 DTO。
"""
return AgentChatSessionDetail.model_validate({
**cls.to_summary(record).model_dump(),
"messages": record.messages,
})
@staticmethod
def _project(record: Any) -> AgentChatRecord:
"""
立即复制 ORM 字段,避免对象越过请求级会话边界。
"""
return AgentChatRecord(
id=record.id,
session_id=record.session_id,
client_session_id=record.client_session_id,
title=record.title,
channel=record.channel,
source=record.source,
user_id=record.user_id,
username=record.username,
original_chat_id=record.original_chat_id,
message_count=record.message_count or 0,
created_at=record.created_at,
updated_at=record.updated_at,
messages=list(record.display_messages or []),
agent_messages=list(record.agent_messages or []),
)
class AgentChatPersistenceService:
"""
把 Agent 编排所需的同步持久化操作委托给有界数据库 worker。
"""
def __init__(
self,
repository: SyncAgentChatRepositoryFactory,
async_executor: AsyncDatabaseExecutor,
sync_transaction: SyncAgentChatTransaction,
capacity: int = DEFAULT_AGENT_CHAT_WRITE_CAPACITY,
session_capacity: int = DEFAULT_AGENT_CHAT_SESSION_CAPACITY,
) -> None:
"""
保存同步仓储工厂、事务端口和两级写入容量。
"""
if capacity < 1:
raise ValueError("AgentChat 写入容量必须大于 0")
if session_capacity < 1:
raise ValueError("AgentChat 单会话写入容量必须大于 0")
self._repository = repository
self._async_executor = async_executor
self._sync_transaction = sync_transaction
self._capacity = capacity
self._session_capacity = session_capacity
self._pending_writes = 0
self._pending_interactive_writes = 0
self._pending_by_session: dict[str, int] = {}
self._active_tasks: set[asyncio.Task[object]] = set()
self._closing = False
# append_display_messages 属于读取旧快照后整列写回的复合操作;按会话串行化,
# 才能在 worker 并发下保持首次建行和既有会话追加的完整性。弱引用避免长期运行
# 中为一次性会话永久保留锁对象,不限制不同会话之间的 worker 并行度。
self._session_locks: WeakValueDictionary[str, asyncio.Lock] = WeakValueDictionary()
def _session_lock(self, session_id: str) -> asyncio.Lock:
"""
返回当前进程内指定会话的写锁。
"""
lock = self._session_locks.get(session_id)
if lock is None:
lock = asyncio.Lock()
self._session_locks[session_id] = lock
return lock
def begin_shutdown(self) -> None:
"""
停止接受新的 AgentChat 持久化任务。
"""
self._closing = True
async def _run_write(
self,
session_id: str,
operation: Callable[[SyncAgentChatRepository], object],
) -> None:
"""
在线程 worker 内完成同步写入并丢弃仓储对象返回值。
"""
# 同时限制全局和单会话等待量,避免一个热点会话占满总 admission 后饿死其他会话。
if self._closing:
raise AgentChatPersistenceUnavailableError(
"AgentChat 持久化服务当前不可接收任务"
)
session_pending = self._pending_by_session.get(session_id, 0)
interactive = is_interactive()
pending = self._pending_interactive_writes if interactive else self._pending_writes - self._pending_interactive_writes
capacity = INTERACTIVE_DATABASE_CAPACITY if interactive else self._capacity
if (
pending >= capacity
or session_pending >= self._session_capacity
):
record_metric("agent.chat.persistence.rejected")
raise AgentChatPersistenceUnavailableError(
f"AgentChat 写入容量已用尽(当前执行组上限 {capacity},"
f"单会话上限 {self._session_capacity})"
)
self._pending_writes += 1
if interactive:
self._pending_interactive_writes += 1
self._pending_by_session[session_id] = session_pending + 1
current = asyncio.current_task()
if current is not None:
self._active_tasks.add(current)
record_metric("agent.chat.persistence.pending", 1)
try:
async with self._session_lock(session_id):
def execute() -> None:
"""
在单一同步事务中执行写入,不让 ORM 对象越过 worker 边界。
"""
self._sync_transaction(
lambda session: operation(self._repository(session))
)
await self._async_executor.run(execute)
finally:
self._pending_writes -= 1
if interactive:
self._pending_interactive_writes -= 1
remaining = self._pending_by_session.get(session_id, 1) - 1
if remaining:
self._pending_by_session[session_id] = remaining
else:
self._pending_by_session.pop(session_id, None)
if current is not None:
self._active_tasks.discard(current)
record_metric("agent.chat.persistence.pending", -1)
async def shutdown(self) -> None:
"""
拒绝新写入并等待当前会话锁和 worker 操作取得终态。
"""
self.begin_shutdown()
current = asyncio.current_task()
tasks = tuple(task for task in self._active_tasks if task is not current)
if tasks:
# wait 不会在生命周期超时时取消实际写入;外层可及时返回并保留
# 数据库 worker owner,已开始的事务继续由 worker 收口。
await asyncio.wait(tasks)
async def async_append_display_messages(
self,
*,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict[str, object]]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> None:
"""
异步追加展示消息,等待同步事务取得确定终态。
"""
await self._run_write(
session_id,
lambda repository: repository.append_display_messages(
session_id=session_id,
user_id=user_id,
messages=messages,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
)
return None
async def async_save_display_messages(
self,
*,
session_id: str,
user_id: Optional[str] = None,
messages: Optional[list[dict[str, object]]] = None,
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> None:
"""
异步保存展示消息快照,实际写入由有界 worker 承接。
"""
await self._run_write(
session_id,
lambda repository: repository.save_display_messages(
session_id=session_id,
user_id=user_id,
messages=messages,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
)
return None
async def async_save_agent_messages(
self,
*,
session_id: str,
user_id: str,
messages: list[dict[str, object]],
) -> None:
"""
异步保存可恢复的原始消息。
"""
await self._run_write(
session_id,
lambda repository: repository.save_agent_messages(
session_id=session_id,
user_id=user_id,
messages=messages,
)
)
async def async_update_title_if_empty(
self,
*,
session_id: str,
user_id: Optional[str],
title: Optional[str],
username: Optional[str] = None,
channel: Optional[Any] = None,
source: Optional[str] = None,
original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None,
) -> None:
"""
异步写入首次生成的会话标题。
"""
await self._run_write(
session_id,
lambda repository: repository.update_title_if_empty(
session_id=session_id,
user_id=user_id,
title=title,
username=username,
channel=channel,
source=source,
original_chat_id=original_chat_id,
client_session_id=client_session_id,
)
)
_configured_agent_chat_service: AgentChatService | None = None
_configured_agent_chat_persistence: AgentChatPersistenceService | None = None
def configure_agent_chat_service(service: AgentChatService) -> None:
"""
由启动组合根登记同步 Agent 会话服务。
"""
global _configured_agent_chat_service
_configured_agent_chat_service = service
def get_configured_agent_chat_service() -> AgentChatService:
"""
返回启动阶段登记的 Agent 会话服务。
"""
if _configured_agent_chat_service is None:
raise RuntimeError("Agent 会话服务尚未配置")
return _configured_agent_chat_service
def configure_agent_chat_persistence(
service: AgentChatPersistenceService,
) -> None:
"""
由启动组合根登记 Agent 编排所需的同步持久化端口。
"""
global _configured_agent_chat_persistence
_configured_agent_chat_persistence = service
def reset_agent_chat_service() -> None:
"""
清除当前 lifespan 的 Agent 会话查询服务。
"""
global _configured_agent_chat_service
_configured_agent_chat_service = None
def reset_agent_chat_persistence() -> None:
"""
清除当前 lifespan 的 Agent 会话持久化服务。
"""
global _configured_agent_chat_persistence
_configured_agent_chat_persistence = None
def get_configured_agent_chat_persistence() -> AgentChatPersistenceService:
"""
返回由启动组合根登记的 AgentChat worker 端口。
"""
if _configured_agent_chat_persistence is None:
raise RuntimeError("Agent 会话持久化服务尚未配置")
return _configured_agent_chat_persistence