fix(agent): keep chat reads on native async path

This commit is contained in:
InfinityPacer
2026-08-23 11:43:07 +08:00
parent 21ce70fbfc
commit f560c19d65
9 changed files with 106 additions and 66 deletions
+8 -5
View File
@@ -10,7 +10,10 @@ from app.runtime.settings import RuntimeSettingsCompat
settings = RuntimeSettingsCompat() settings = RuntimeSettingsCompat()
from app.application.agentdata import AgentChatPort as AgentChatOper from app.application.agentdata import AgentChatPort as AgentChatOper
from app.application.messaging.chat import get_configured_agent_chat_persistence from app.application.messaging.chat import (
get_configured_agent_chat_persistence,
get_configured_agent_chat_service,
)
from app.runtime.log import logger from app.runtime.log import logger
from app.schemas.agent import ConversationMemory from app.schemas.agent import ConversationMemory
@@ -109,19 +112,19 @@ class MemoryManager:
async def async_get_agent_messages( async def async_get_agent_messages(
self, session_id: str, user_id: str self, session_id: str, user_id: str
) -> List[BaseMessage]: ) -> List[BaseMessage]:
"""异步恢复 Agent 消息,持久化读取经有界数据库 worker 承接""" """异步恢复 Agent 消息,查询与会话应用服务保持同一异步端口"""
memory = self.get_memory(session_id, user_id) memory = self.get_memory(session_id, user_id)
if memory: if memory:
return memory.messages return memory.messages
try: try:
persistence = get_configured_agent_chat_persistence() service = get_configured_agent_chat_service()
chat = await persistence.async_get( chat = await service.get(
session_id=session_id, session_id=session_id,
user_id=user_id, user_id=user_id,
) )
if not chat: if not chat:
chat = await persistence.async_get(session_id=session_id) chat = await service.get(session_id=session_id)
except Exception as e: except Exception as e:
logger.debug(f"读取持久化Agent会话失败: {e}") logger.debug(f"读取持久化Agent会话失败: {e}")
return [] return []
+2 -1
View File
@@ -82,6 +82,7 @@ def _get_plugin_tools_revision() -> int:
from app.application.agentdata import AgentTaskPort as AgentTaskOper from app.application.agentdata import AgentTaskPort as AgentTaskOper
from app.application.agentdata import UserPort as UserOper from app.application.agentdata import UserPort as UserOper
from app.application.messaging.chat import ( from app.application.messaging.chat import (
get_configured_agent_chat_service,
get_configured_agent_chat_persistence, get_configured_agent_chat_persistence,
has_custom_agent_chat_title, has_custom_agent_chat_title,
) )
@@ -579,7 +580,7 @@ class MoviePilotAgent:
return return
self._tool_context["chat_title_prepared"] = True self._tool_context["chat_title_prepared"] = True
try: try:
chat = await get_configured_agent_chat_persistence().async_get( chat = await get_configured_agent_chat_service().get(
session_id=self.session_id, session_id=self.session_id,
user_id=self.user_id, user_id=self.user_id,
) )
+3 -5
View File
@@ -518,12 +518,12 @@ async def _build_web_agent_session_id_async(
user: ApiPrincipal, user: ApiPrincipal,
session_id: Optional[str], session_id: Optional[str],
) -> str: ) -> str:
"""异步解析 Web Agent 会话 ID历史查询经有界数据库 worker 承接""" """异步解析 Web Agent 会话 ID并复用异步会话查询端口"""
seed = str(session_id or "").strip() or uuid.uuid4().hex seed = str(session_id or "").strip() or uuid.uuid4().hex
if seed.startswith(WEB_AGENT_SESSION_PREFIX): if seed.startswith(WEB_AGENT_SESSION_PREFIX):
return seed return seed
try: try:
existing_chat = await get_configured_agent_chat_persistence().async_get(seed) existing_chat = await get_configured_agent_chat_service().get(seed)
if existing_chat and AgentChatService.can_access(existing_chat, user): if existing_chat and AgentChatService.can_access(existing_chat, user):
return seed return seed
except Exception as e: except Exception as e:
@@ -663,9 +663,7 @@ async def _save_web_agent_display_snapshot(
保存 WebAgent 当前展示消息快照。 保存 WebAgent 当前展示消息快照。
""" """
try: try:
existing_chat = await get_configured_agent_chat_persistence().async_get( existing_chat = await get_configured_agent_chat_service().get(session_id)
session_id
)
await get_configured_agent_chat_persistence().async_save_display_messages( await get_configured_agent_chat_persistence().async_save_display_messages(
session_id=session_id, session_id=session_id,
user_id=(existing_chat.user_id if existing_chat else str(current_user.id)), user_id=(existing_chat.user_id if existing_chat else str(current_user.id)),
+19 -29
View File
@@ -85,14 +85,6 @@ class AsyncAgentChatRepository(Protocol):
class SyncAgentChatRepository(Protocol): class SyncAgentChatRepository(Protocol):
"""仅包含 Agent 编排所需同步持久化方法的适配器端口。""" """仅包含 Agent 编排所需同步持久化方法的适配器端口。"""
def get(
self,
session_id: str,
user_id: Optional[str] = None,
) -> Optional[Any]:
"""读取服务端会话。"""
...
def append_display_messages( def append_display_messages(
self, self,
session_id: str, session_id: str,
@@ -165,6 +157,7 @@ class AgentChatRecord:
created_at: Any created_at: Any
updated_at: Any updated_at: Any
messages: list[dict] messages: list[dict]
agent_messages: list[dict]
class AsyncUnitOfWork(Protocol): class AsyncUnitOfWork(Protocol):
@@ -222,9 +215,16 @@ class AgentChatService:
return None return None
return projected return projected
async def get(self, session_id: str) -> Optional[AgentChatRecord]: async def get(
self,
session_id: str,
user_id: Optional[str] = None,
) -> Optional[AgentChatRecord]:
"""读取不附带授权判断的会话投影。""" """读取不附带授权判断的会话投影。"""
record = await self._repository.async_get(session_id=session_id) record = await self._repository.async_get(
session_id=session_id,
user_id=user_id,
)
if record is None: if record is None:
return None return None
return self._project(record) return self._project(record)
@@ -338,11 +338,12 @@ class AgentChatService:
created_at=record.created_at, created_at=record.created_at,
updated_at=record.updated_at, updated_at=record.updated_at,
messages=list(record.display_messages or []), messages=list(record.display_messages or []),
agent_messages=list(record.agent_messages or []),
) )
class AgentChatPersistenceService: class AgentChatPersistenceService:
"""把 Agent 编排所需的同步短事务委托给有界数据库 worker。""" """把 Agent 编排所需的同步持久化操作委托给有界数据库 worker。"""
def __init__( def __init__(
self, self,
@@ -354,24 +355,11 @@ class AgentChatPersistenceService:
self._async_executor = async_executor self._async_executor = async_executor
async def _run(self, operation: Callable[[SyncAgentChatRepository], T]) -> T: async def _run(self, operation: Callable[[SyncAgentChatRepository], T]) -> T:
"""在线程 worker 中执行一个完整的同步 AgentChat 短事务""" """在线程 worker 中执行一个同步 AgentChat 持久化操作"""
return await self._async_executor.run( return await self._async_executor.run(
lambda: operation(self._repository()) lambda: operation(self._repository())
) )
async def async_get(
self,
session_id: str,
user_id: Optional[str] = None,
) -> Optional[Any]:
"""异步读取会话,实际查询由有界 worker 承接。"""
return await self._run(
lambda repository: repository.get(
session_id=session_id,
user_id=user_id,
)
)
async def async_append_display_messages( async def async_append_display_messages(
self, self,
*, *,
@@ -383,9 +371,9 @@ class AgentChatPersistenceService:
source: Optional[str] = None, source: Optional[str] = None,
original_chat_id: Optional[str] = None, original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None, client_session_id: Optional[str] = None,
) -> Optional[Any]: ) -> None:
"""异步追加展示消息,等待同步事务取得确定终态。""" """异步追加展示消息,等待同步事务取得确定终态。"""
return await self._run( await self._run(
lambda repository: repository.append_display_messages( lambda repository: repository.append_display_messages(
session_id=session_id, session_id=session_id,
user_id=user_id, user_id=user_id,
@@ -397,6 +385,7 @@ class AgentChatPersistenceService:
client_session_id=client_session_id, client_session_id=client_session_id,
) )
) )
return None
async def async_save_display_messages( async def async_save_display_messages(
self, self,
@@ -409,9 +398,9 @@ class AgentChatPersistenceService:
source: Optional[str] = None, source: Optional[str] = None,
original_chat_id: Optional[str] = None, original_chat_id: Optional[str] = None,
client_session_id: Optional[str] = None, client_session_id: Optional[str] = None,
) -> Optional[Any]: ) -> None:
"""异步保存展示消息快照,实际写入由有界 worker 承接。""" """异步保存展示消息快照,实际写入由有界 worker 承接。"""
return await self._run( await self._run(
lambda repository: repository.save_display_messages( lambda repository: repository.save_display_messages(
session_id=session_id, session_id=session_id,
user_id=user_id, user_id=user_id,
@@ -423,6 +412,7 @@ class AgentChatPersistenceService:
client_session_id=client_session_id, client_session_id=client_session_id,
) )
) )
return None
async def async_save_agent_messages( async def async_save_agent_messages(
self, self,
+3
View File
@@ -119,7 +119,9 @@ def configure_plugin_system_services():
) )
from app.application.messaging.message import MessageHelper, MessageQueueManager from app.application.messaging.message import MessageHelper, MessageQueueManager
from app.application.messaging.chat import ( from app.application.messaging.chat import (
AgentChatService,
AgentChatPersistenceService, AgentChatPersistenceService,
configure_agent_chat_service,
configure_agent_chat_persistence, configure_agent_chat_persistence,
) )
from app.runtime.cache import AsyncFileCache, FileCache from app.runtime.cache import AsyncFileCache, FileCache
@@ -265,6 +267,7 @@ def configure_plugin_system_services():
async_executor=database_executor, async_executor=database_executor,
) )
) )
configure_agent_chat_service(AgentChatService(repository=AgentChatOper()))
from app.adapters.external.market import ( from app.adapters.external.market import (
PluginHelper, PluginHelper,
VERSION_BACKWARD_COMPATIBLE_FLAGS, VERSION_BACKWARD_COMPATIBLE_FLAGS,
+46
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import json import json
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock
from langchain_core.messages import AIMessage, HumanMessage from langchain_core.messages import AIMessage, HumanMessage
@@ -278,3 +279,48 @@ def test_memory_manager_restores_agent_messages_from_database():
assert len(messages) == 1 assert len(messages) == 1
assert isinstance(messages[0], HumanMessage) assert isinstance(messages[0], HumanMessage)
assert messages[0].content == "继续之前的话题" assert messages[0].content == "继续之前的话题"
def test_async_memory_manager_restores_through_native_async_service(monkeypatch):
"""异步记忆恢复只能通过会话应用服务的异步查询端口。"""
session_id = "session-memory-async"
user_id = "3"
memory_manager.clear_memory(session_id, user_id)
service = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
agent_messages=[
{
"type": "human",
"data": {
"content": "异步恢复",
"additional_kwargs": {},
"response_metadata": {},
"type": "human",
"name": None,
"id": None,
"example": False,
},
}
]
)
)
)
monkeypatch.setattr(
"app.agent.memory.get_configured_agent_chat_service",
lambda: service,
)
messages = asyncio.run(
memory_manager.async_get_agent_messages(
session_id=session_id,
user_id=user_id,
)
)
assert len(messages) == 1
assert messages[0].content == "异步恢复"
service.get.assert_awaited_once_with(
session_id=session_id,
user_id=user_id,
)
+16 -19
View File
@@ -5,13 +5,11 @@ from __future__ import annotations
import asyncio import asyncio
import threading import threading
from uuid import uuid4 from uuid import uuid4
from types import SimpleNamespace
import pytest import pytest
from app.application.messaging.chat import AgentChatPersistenceService from app.application.messaging.chat import AgentChatPersistenceService, AgentChatService
from app.db.oper.agentchat import AgentChatOper from app.db.oper.agentchat import AgentChatOper
from app.db.models.agentchat import AgentChat
from app.db.worker import DatabaseWorker from app.db.worker import DatabaseWorker
@@ -39,10 +37,6 @@ class _Repository:
def __init__(self) -> None: def __init__(self) -> None:
self.calls: list[tuple[str, dict]] = [] self.calls: list[tuple[str, dict]] = []
def get(self, **kwargs):
self.calls.append(("get", kwargs))
return SimpleNamespace(agent_messages=[])
def append_display_messages(self, **kwargs): def append_display_messages(self, **kwargs):
self.calls.append(("append_display_messages", kwargs)) self.calls.append(("append_display_messages", kwargs))
return None return None
@@ -60,7 +54,7 @@ class _Repository:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_agent_chat_persistence_runs_sync_repository_inside_worker() -> None: async def test_agent_chat_persistence_runs_sync_repository_inside_worker() -> None:
"""同步查询和写入必须经过一次 worker admission。""" """同步 AgentChat 写入必须经过一次 worker admission。"""
executor = _Executor() executor = _Executor()
repository = _Repository() repository = _Repository()
service = AgentChatPersistenceService( service = AgentChatPersistenceService(
@@ -69,7 +63,6 @@ async def test_agent_chat_persistence_runs_sync_repository_inside_worker() -> No
) )
caller_thread_id = threading.get_ident() caller_thread_id = threading.get_ident()
await service.async_get("session-1", user_id="1")
await service.async_append_display_messages( await service.async_append_display_messages(
session_id="session-1", session_id="session-1",
user_id="1", user_id="1",
@@ -91,10 +84,9 @@ async def test_agent_chat_persistence_runs_sync_repository_inside_worker() -> No
title="标题", title="标题",
) )
assert executor.calls == 5 assert executor.calls == 4
assert executor.worker_thread_id != caller_thread_id assert executor.worker_thread_id != caller_thread_id
assert [name for name, _kwargs in repository.calls] == [ assert [name for name, _kwargs in repository.calls] == [
"get",
"append_display_messages", "append_display_messages",
"save_display_messages", "save_display_messages",
"save_agent_messages", "save_agent_messages",
@@ -125,17 +117,18 @@ async def test_agent_chat_persistence_propagates_worker_failure() -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_agent_chat_persistence_uses_real_worker_and_sqlite_transaction() -> None: async def test_agent_chat_persistence_uses_real_worker_and_sqlite_transaction() -> None:
"""真实 AgentChat Oper 经 worker 写入后可被后续 worker 查询恢复。""" """真实 AgentChat Oper 经 worker 写入后可被 native async 查询恢复。"""
worker = DatabaseWorker(max_workers=1, capacity=4) worker = DatabaseWorker(max_workers=1, capacity=4)
await worker.start() await worker.start()
session_id = f"worker-{uuid4().hex}" session_id = f"worker-{uuid4().hex}"
service = AgentChatPersistenceService( persistence = AgentChatPersistenceService(
repository=AgentChatOper, repository=AgentChatOper,
async_executor=worker, async_executor=worker,
) )
query = AgentChatService(repository=AgentChatOper())
try: try:
await service.async_save_display_messages( await persistence.async_save_display_messages(
session_id=session_id, session_id=session_id,
user_id="worker-user", user_id="worker-user",
username="worker-user", username="worker-user",
@@ -143,12 +136,16 @@ async def test_agent_chat_persistence_uses_real_worker_and_sqlite_transaction()
source="worker-test", source="worker-test",
messages=[{"role": "user", "content": "worker"}], messages=[{"role": "user", "content": "worker"}],
) )
chat = await service.async_get(session_id, user_id="worker-user") chat = await query.get(
session_id,
user_id="worker-user",
)
assert chat is not None assert chat is not None
assert chat.message_count == 1 assert chat.message_count == 1
assert chat.display_messages[0]["content"] == "worker" assert chat.messages[0]["content"] == "worker"
finally: finally:
chat = AgentChatOper().get(session_id=session_id, user_id="worker-user") await AgentChatOper().async_delete(
if chat is not None: session_id=session_id,
AgentChat.delete(rid=chat.id) user_id="worker-user",
)
await worker.shutdown() await worker.shutdown()
+1
View File
@@ -29,6 +29,7 @@ def _chat() -> SimpleNamespace:
created_at=None, created_at=None,
updated_at=None, updated_at=None,
display_messages=[], display_messages=[],
agent_messages=[],
) )
+8 -7
View File
@@ -175,28 +175,29 @@ def test_build_web_agent_session_id_reuses_accessible_history():
assert _build_web_agent_session_id(user, "telegram-session") == "telegram-session" assert _build_web_agent_session_id(user, "telegram-session") == "telegram-session"
def test_build_web_agent_session_id_async_uses_worker_persistence(): def test_build_web_agent_session_id_async_uses_native_async_persistence():
"""异步 Web 会话解析应通过 AgentChat worker 端口读取历史。""" """异步 Web 会话解析应通过 native async 会话服务读取历史。"""
user = SimpleNamespace(id=1, name="admin", is_superuser=True) user = SimpleNamespace(id=1, name="admin", is_superuser=True)
persistence = SimpleNamespace( service = SimpleNamespace(
async_get=AsyncMock( get=AsyncMock(
return_value=SimpleNamespace( return_value=SimpleNamespace(
user_id="telegram-user", user_id="telegram-user",
username="tester", username="tester",
agent_messages=[],
) )
) )
) )
with patch( with patch(
"app.api.endpoints.agent.get_configured_agent_chat_persistence", "app.api.endpoints.agent.get_configured_agent_chat_service",
return_value=persistence, return_value=service,
): ):
session_id = asyncio.run( session_id = asyncio.run(
_build_web_agent_session_id_async(user, "telegram-session") _build_web_agent_session_id_async(user, "telegram-session")
) )
assert session_id == "telegram-session" assert session_id == "telegram-session"
persistence.async_get.assert_awaited_once_with("telegram-session") service.get.assert_awaited_once_with("telegram-session")
def test_apply_web_agent_display_event_updates_snapshot(): def test_apply_web_agent_display_event_updates_snapshot():