zero-agent-core 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_core/__init__.py +525 -0
- agent_core/access/__init__.py +6 -0
- agent_core/access/context.py +45 -0
- agent_core/access/guard.py +40 -0
- agent_core/acp/README.md +236 -0
- agent_core/acp/__init__.py +20 -0
- agent_core/acp/server.py +351 -0
- agent_core/acp/types.py +86 -0
- agent_core/callbacks/__init__.py +17 -0
- agent_core/callbacks/manager.py +105 -0
- agent_core/checkpoint/__init__.py +19 -0
- agent_core/checkpoint/naming.py +93 -0
- agent_core/checkpoint/store.py +186 -0
- agent_core/checkpoint/travel.py +448 -0
- agent_core/compaction/__init__.py +57 -0
- agent_core/compaction/middleware.py +428 -0
- agent_core/compaction/prompts.py +32 -0
- agent_core/compaction/pruner.py +121 -0
- agent_core/compaction/region.py +145 -0
- agent_core/compaction/spill.py +81 -0
- agent_core/compaction/spill_tool.py +42 -0
- agent_core/compaction/summarizer.py +95 -0
- agent_core/compaction/token_limit.py +74 -0
- agent_core/compaction/types.py +93 -0
- agent_core/docs.py +15 -0
- agent_core/documents/__init__.py +23 -0
- agent_core/documents/loaders.py +79 -0
- agent_core/documents/splitters.py +94 -0
- agent_core/documents/types.py +197 -0
- agent_core/errors.py +81 -0
- agent_core/guardrails/__init__.py +19 -0
- agent_core/guardrails/core.py +186 -0
- agent_core/hitl/__init__.py +15 -0
- agent_core/hitl/core.py +517 -0
- agent_core/mcp/__init__.py +14 -0
- agent_core/mcp/client.py +212 -0
- agent_core/mcp/errors.py +26 -0
- agent_core/middleware/__init__.py +26 -0
- agent_core/middleware/base.py +283 -0
- agent_core/model/__init__.py +73 -0
- agent_core/model/catalog.py +73 -0
- agent_core/model/core.py +179 -0
- agent_core/model/governance.py +317 -0
- agent_core/model/providers/__init__.py +43 -0
- agent_core/model/providers/anthropic.py +294 -0
- agent_core/model/providers/common.py +28 -0
- agent_core/model/providers/factory.py +107 -0
- agent_core/model/providers/gemini.py +273 -0
- agent_core/model/providers/ollama.py +80 -0
- agent_core/model/providers/openai_compatible.py +526 -0
- agent_core/model/structured.py +200 -0
- agent_core/observability/__init__.py +21 -0
- agent_core/observability/core.py +311 -0
- agent_core/ports/__init__.py +31 -0
- agent_core/ports/memory.py +130 -0
- agent_core/ports/model_selection.py +64 -0
- agent_core/ports/storage.py +114 -0
- agent_core/prompts/__init__.py +19 -0
- agent_core/prompts/registry.py +236 -0
- agent_core/prompts/templates.py +166 -0
- agent_core/protocol/__init__.py +31 -0
- agent_core/protocol/capabilities.py +49 -0
- agent_core/protocol/messages.py +100 -0
- agent_core/protocol/runtime.py +293 -0
- agent_core/py.typed +1 -0
- agent_core/retrieval/__init__.py +40 -0
- agent_core/retrieval/_common.py +121 -0
- agent_core/retrieval/chroma.py +269 -0
- agent_core/retrieval/core.py +63 -0
- agent_core/retrieval/factory.py +72 -0
- agent_core/retrieval/memory.py +232 -0
- agent_core/retrieval/pgvector.py +239 -0
- agent_core/retrieval/types.py +95 -0
- agent_core/runtime/__init__.py +28 -0
- agent_core/runtime/compat.py +98 -0
- agent_core/runtime/react.py +888 -0
- agent_core/runtime/service.py +557 -0
- agent_core/runtime/store.py +55 -0
- agent_core/serialization/__init__.py +21 -0
- agent_core/serialization/registry.py +318 -0
- agent_core/skills/__init__.py +24 -0
- agent_core/skills/errors.py +27 -0
- agent_core/skills/loader.py +258 -0
- agent_core/skills/registry.py +205 -0
- agent_core/skills/types.py +56 -0
- agent_core/storage/__init__.py +107 -0
- agent_core/storage/_sqlite_utils.py +92 -0
- agent_core/storage/context.py +382 -0
- agent_core/storage/lease.py +435 -0
- agent_core/storage/memory.py +604 -0
- agent_core/storage/model_selection.py +360 -0
- agent_core/storage/runtime.py +899 -0
- agent_core/storage/session.py +904 -0
- agent_core/storage/workflow.py +411 -0
- agent_core/tools/__init__.py +5 -0
- agent_core/tools/executor.py +306 -0
- agent_core/workflow/__init__.py +25 -0
- agent_core/workflow/durable.py +175 -0
- agent_core/workflow/state_machine.py +427 -0
- zero_agent_core-0.2.0.dist-info/METADATA +237 -0
- zero_agent_core-0.2.0.dist-info/RECORD +104 -0
- zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
- zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
- zero_agent_core-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
"""会话存储:多轮对话历史持久化。
|
|
2
|
+
|
|
3
|
+
存储后端(通过 create_session_store() 工厂自动选择):
|
|
4
|
+
- MemorySessionStore : 进程内存,重启丢失(单元测试用)
|
|
5
|
+
- SqliteSessionStore : SQLite 本地文件,开发环境首选
|
|
6
|
+
- PostgresSessionStore : PostgreSQL,生产环境首选
|
|
7
|
+
|
|
8
|
+
选择逻辑(由 create_session_store() 控制):
|
|
9
|
+
AGENT_ENV=development(默认)→ SqliteSessionStore (./sessions.db)
|
|
10
|
+
AGENT_ENV=production → PostgresSessionStore (需配置 AGENT_SESSION_POSTGRES_URL)
|
|
11
|
+
|
|
12
|
+
对应关系:
|
|
13
|
+
MemorySessionStore ≈ MemorySaver (测试)
|
|
14
|
+
SqliteSessionStore ≈ SqliteSaver (开发)
|
|
15
|
+
PostgresSessionStore ≈ PostgresSaver (生产)
|
|
16
|
+
|
|
17
|
+
表结构(SQLite / PostgreSQL 通用):
|
|
18
|
+
sessions
|
|
19
|
+
├── id INTEGER PRIMARY KEY AUTOINCREMENT
|
|
20
|
+
├── thread_id TEXT NOT NULL
|
|
21
|
+
├── role TEXT NOT NULL (user/assistant/tool/system)
|
|
22
|
+
├── content TEXT NOT NULL
|
|
23
|
+
├── name TEXT
|
|
24
|
+
├── tool_call_id TEXT
|
|
25
|
+
├── tool_calls TEXT (JSON 序列化)
|
|
26
|
+
└── created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import sqlite3
|
|
35
|
+
from abc import ABC, abstractmethod
|
|
36
|
+
from collections.abc import Generator
|
|
37
|
+
from contextlib import contextmanager
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, cast
|
|
40
|
+
|
|
41
|
+
from agent_core.access import AccessContext
|
|
42
|
+
from agent_core.model import ContextUsage
|
|
43
|
+
from agent_core.protocol.messages import Message, assistant_message, user_message
|
|
44
|
+
from agent_core.protocol.runtime import RunContext
|
|
45
|
+
from agent_core.storage._sqlite_utils import (
|
|
46
|
+
_run_in_thread,
|
|
47
|
+
run_sync_in_thread,
|
|
48
|
+
sqlite_connection,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ──────────────────────────────────────────────
|
|
55
|
+
# 消息序列化 / 反序列化
|
|
56
|
+
# ──────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _msg_to_row(msg: Message) -> tuple[str, str, str | None, str | None, str]:
|
|
60
|
+
"""Message → (role, content, name, tool_call_id, tool_calls_json)。"""
|
|
61
|
+
return (
|
|
62
|
+
msg.role,
|
|
63
|
+
msg.content,
|
|
64
|
+
msg.name,
|
|
65
|
+
msg.tool_call_id,
|
|
66
|
+
json.dumps(msg.tool_calls, ensure_ascii=False) if msg.tool_calls else "[]",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ──────────────────────────────────────────────
|
|
71
|
+
# 数据库行 → Message
|
|
72
|
+
# ──────────────────────────────────────────────
|
|
73
|
+
def _row_to_msg(row: tuple[Any, ...] | dict[str, Any]) -> Message:
|
|
74
|
+
"""数据库行 → Message。
|
|
75
|
+
|
|
76
|
+
兼容 sqlite3.Row(可按列名访问)和普通 tuple。
|
|
77
|
+
"""
|
|
78
|
+
if isinstance(row, dict):
|
|
79
|
+
role, content, name, tool_call_id, tool_calls_json = (
|
|
80
|
+
row["role"],
|
|
81
|
+
row["content"],
|
|
82
|
+
row.get("name"),
|
|
83
|
+
row.get("tool_call_id"),
|
|
84
|
+
row.get("tool_calls", "[]"),
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
# tuple: (role, content, name, tool_call_id, tool_calls)
|
|
88
|
+
role, content, name, tool_call_id, tool_calls_json = row
|
|
89
|
+
|
|
90
|
+
tool_calls: list[dict[str, Any]] = []
|
|
91
|
+
if tool_calls_json:
|
|
92
|
+
try:
|
|
93
|
+
tool_calls = json.loads(tool_calls_json)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
return Message(
|
|
98
|
+
role=role,
|
|
99
|
+
content=content,
|
|
100
|
+
name=name,
|
|
101
|
+
tool_call_id=tool_call_id,
|
|
102
|
+
tool_calls=tool_calls,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ──────────────────────────────────────────────
|
|
107
|
+
# 抽象接口
|
|
108
|
+
# ──────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class BaseSessionStore(ABC):
|
|
112
|
+
"""会话存储抽象基类。"""
|
|
113
|
+
|
|
114
|
+
@abstractmethod
|
|
115
|
+
async def load(self, thread_id: str, *, access: AccessContext | None = None) -> list[Message]:
|
|
116
|
+
"""加载指定 thread 的消息历史。"""
|
|
117
|
+
|
|
118
|
+
@abstractmethod
|
|
119
|
+
async def save(
|
|
120
|
+
self,
|
|
121
|
+
thread_id: str,
|
|
122
|
+
messages: list[Message],
|
|
123
|
+
*,
|
|
124
|
+
access: AccessContext | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""覆盖保存指定 thread 的消息历史。"""
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
async def append(
|
|
130
|
+
self,
|
|
131
|
+
thread_id: str,
|
|
132
|
+
messages: list[Message],
|
|
133
|
+
*,
|
|
134
|
+
access: AccessContext | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""追加消息到指定 thread(比 save 更高效,不需要先 load)。"""
|
|
137
|
+
|
|
138
|
+
@abstractmethod
|
|
139
|
+
async def delete(self, thread_id: str, *, access: AccessContext | None = None) -> None:
|
|
140
|
+
"""删除指定 thread 的所有消息。"""
|
|
141
|
+
|
|
142
|
+
@abstractmethod
|
|
143
|
+
async def list_threads(self, *, access: AccessContext | None = None) -> list[str]:
|
|
144
|
+
"""列出所有 thread_id。"""
|
|
145
|
+
|
|
146
|
+
@abstractmethod
|
|
147
|
+
async def count(self, thread_id: str, *, access: AccessContext | None = None) -> int:
|
|
148
|
+
"""返回指定 thread 的消息数量。"""
|
|
149
|
+
|
|
150
|
+
# 统一端口名称,同时保留早期实现中的短方法名。
|
|
151
|
+
async def load_messages(self, thread_id: str, *, access: AccessContext | None = None) -> list[Message]:
|
|
152
|
+
return await self.load(thread_id, access=access)
|
|
153
|
+
|
|
154
|
+
async def append_messages(
|
|
155
|
+
self,
|
|
156
|
+
thread_id: str,
|
|
157
|
+
messages: list[Message],
|
|
158
|
+
*,
|
|
159
|
+
access: AccessContext | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
await self.append(thread_id, messages, access=access)
|
|
162
|
+
|
|
163
|
+
async def replace_messages(
|
|
164
|
+
self,
|
|
165
|
+
thread_id: str,
|
|
166
|
+
messages: list[Message],
|
|
167
|
+
*,
|
|
168
|
+
access: AccessContext | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
await self.save(thread_id, messages, access=access)
|
|
171
|
+
|
|
172
|
+
async def delete_thread(self, thread_id: str, *, access: AccessContext | None = None) -> None:
|
|
173
|
+
await self.delete(thread_id, access=access)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# ──────────────────────────────────────────────
|
|
177
|
+
# 1. 内存后端(测试用)
|
|
178
|
+
# ──────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class MemorySessionStore(BaseSessionStore):
|
|
182
|
+
"""进程内存,重启丢失。仅用于单元测试。"""
|
|
183
|
+
|
|
184
|
+
def __init__(self, *, require_access: bool = False) -> None:
|
|
185
|
+
# thread_id → list[Message]
|
|
186
|
+
self._data: dict[str, list[Message]] = {}
|
|
187
|
+
self._owners: dict[str, tuple[str, str]] = {}
|
|
188
|
+
self._require_access = require_access
|
|
189
|
+
|
|
190
|
+
def _check(self, thread_id: str, access: AccessContext | None, *, claim: bool) -> None:
|
|
191
|
+
if self._require_access and access is None:
|
|
192
|
+
raise PermissionError("该 SessionStore 要求提供 AccessContext")
|
|
193
|
+
owner = self._owners.get(thread_id)
|
|
194
|
+
if owner is None and access is not None and claim:
|
|
195
|
+
self._owners[thread_id] = (access.user_id, access.tenant_id)
|
|
196
|
+
return
|
|
197
|
+
if owner is None and self._require_access and thread_id in self._data:
|
|
198
|
+
raise PermissionError("该 thread 的历史数据尚未绑定所有者")
|
|
199
|
+
if owner is not None and access is not None and not access.can_access(*owner):
|
|
200
|
+
raise PermissionError("无权访问该 thread 的会话历史")
|
|
201
|
+
|
|
202
|
+
async def load(self, thread_id: str, *, access: AccessContext | None = None) -> list[Message]:
|
|
203
|
+
self._check(thread_id, access, claim=False)
|
|
204
|
+
return list(self._data.get(thread_id, []))
|
|
205
|
+
|
|
206
|
+
async def save(
|
|
207
|
+
self,
|
|
208
|
+
thread_id: str,
|
|
209
|
+
messages: list[Message],
|
|
210
|
+
*,
|
|
211
|
+
access: AccessContext | None = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
self._check(thread_id, access, claim=True)
|
|
214
|
+
self._data[thread_id] = list(messages)
|
|
215
|
+
|
|
216
|
+
async def append(
|
|
217
|
+
self,
|
|
218
|
+
thread_id: str,
|
|
219
|
+
messages: list[Message],
|
|
220
|
+
*,
|
|
221
|
+
access: AccessContext | None = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
self._check(thread_id, access, claim=True)
|
|
224
|
+
self._data.setdefault(thread_id, []).extend(messages)
|
|
225
|
+
|
|
226
|
+
async def delete(self, thread_id: str, *, access: AccessContext | None = None) -> None:
|
|
227
|
+
self._check(thread_id, access, claim=False)
|
|
228
|
+
self._data.pop(thread_id, None)
|
|
229
|
+
self._owners.pop(thread_id, None)
|
|
230
|
+
|
|
231
|
+
async def list_threads(self, *, access: AccessContext | None = None) -> list[str]:
|
|
232
|
+
if self._require_access and access is None:
|
|
233
|
+
raise PermissionError("该 SessionStore 要求提供 AccessContext")
|
|
234
|
+
if access is None:
|
|
235
|
+
return list(self._data.keys())
|
|
236
|
+
return [
|
|
237
|
+
thread_id
|
|
238
|
+
for thread_id in self._data
|
|
239
|
+
if (owner := self._owners.get(thread_id)) is not None and access.can_access(*owner)
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
async def count(self, thread_id: str, *, access: AccessContext | None = None) -> int:
|
|
243
|
+
self._check(thread_id, access, claim=False)
|
|
244
|
+
return len(self._data.get(thread_id, []))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ──────────────────────────────────────────────
|
|
248
|
+
# 2. SQLite 后端(开发环境)
|
|
249
|
+
# ──────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
_SCHEMA_SQL = """
|
|
252
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
253
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
254
|
+
thread_id TEXT NOT NULL,
|
|
255
|
+
role TEXT NOT NULL,
|
|
256
|
+
content TEXT NOT NULL,
|
|
257
|
+
name TEXT,
|
|
258
|
+
tool_call_id TEXT,
|
|
259
|
+
tool_calls TEXT NOT NULL DEFAULT '[]',
|
|
260
|
+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
261
|
+
);
|
|
262
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_thread_id ON sessions (thread_id);
|
|
263
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions (thread_id, created_at);
|
|
264
|
+
CREATE TABLE IF NOT EXISTS session_threads (
|
|
265
|
+
thread_id TEXT PRIMARY KEY,
|
|
266
|
+
user_id TEXT NOT NULL,
|
|
267
|
+
tenant_id TEXT NOT NULL,
|
|
268
|
+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
269
|
+
);
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ──────────────────────────────────────────────
|
|
274
|
+
# 3. SQLite 后端(开发环境)
|
|
275
|
+
# ──────────────────────────────────────────────
|
|
276
|
+
class SqliteSessionStore(BaseSessionStore):
|
|
277
|
+
"""SQLite 本地文件存储。
|
|
278
|
+
|
|
279
|
+
- 开发环境默认使用
|
|
280
|
+
- 零额外依赖(Python 内置 sqlite3)
|
|
281
|
+
- 重启后数据不丢失
|
|
282
|
+
- 不适合多进程并发写(多实例部署请用 PostgreSQL)
|
|
283
|
+
|
|
284
|
+
文件位置:由 db_path 参数指定,默认 ./sessions.db
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
DDL = _SCHEMA_SQL
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
db_path: str | Path = "./sessions.db",
|
|
292
|
+
*,
|
|
293
|
+
require_access: bool = False,
|
|
294
|
+
) -> None:
|
|
295
|
+
self._db_path = str(Path(db_path).resolve())
|
|
296
|
+
self._require_access = require_access
|
|
297
|
+
# 建表 DDL/PRAGMA 初始化同样放入工作线程执行,与 async 方法共用同一连接参数规范。
|
|
298
|
+
run_sync_in_thread(self._init_db)
|
|
299
|
+
logger.info("SqliteSessionStore: %s", self._db_path)
|
|
300
|
+
|
|
301
|
+
def _init_db(self) -> None:
|
|
302
|
+
"""建表(幂等)。"""
|
|
303
|
+
with self._connect() as conn:
|
|
304
|
+
conn.executescript(self.DDL)
|
|
305
|
+
|
|
306
|
+
@contextmanager
|
|
307
|
+
def _connect(self) -> Generator[sqlite3.Connection]:
|
|
308
|
+
"""获取 SQLite 连接(同步,自动提交/回滚;统一 timeout 与 WAL 规范)。"""
|
|
309
|
+
with sqlite_connection(self._db_path, row_factory=sqlite3.Row) as conn:
|
|
310
|
+
yield conn
|
|
311
|
+
|
|
312
|
+
def _check_access(
|
|
313
|
+
self,
|
|
314
|
+
conn: sqlite3.Connection,
|
|
315
|
+
thread_id: str,
|
|
316
|
+
access: AccessContext | None,
|
|
317
|
+
*,
|
|
318
|
+
claim: bool,
|
|
319
|
+
) -> None:
|
|
320
|
+
if self._require_access and access is None:
|
|
321
|
+
raise PermissionError("该 SessionStore 要求提供 AccessContext")
|
|
322
|
+
row = conn.execute(
|
|
323
|
+
"SELECT user_id,tenant_id FROM session_threads WHERE thread_id=?",
|
|
324
|
+
(thread_id,),
|
|
325
|
+
).fetchone()
|
|
326
|
+
if row is None and access is not None and claim:
|
|
327
|
+
conn.execute(
|
|
328
|
+
"INSERT INTO session_threads(thread_id,user_id,tenant_id) VALUES (?,?,?)",
|
|
329
|
+
(thread_id, access.user_id, access.tenant_id),
|
|
330
|
+
)
|
|
331
|
+
return
|
|
332
|
+
if row is None and self._require_access:
|
|
333
|
+
has_messages = conn.execute(
|
|
334
|
+
"SELECT 1 FROM sessions WHERE thread_id=? LIMIT 1",
|
|
335
|
+
(thread_id,),
|
|
336
|
+
).fetchone()
|
|
337
|
+
if has_messages is not None:
|
|
338
|
+
raise PermissionError("该 thread 的历史数据尚未绑定所有者")
|
|
339
|
+
if row is not None and access is not None and not access.can_access(row[0], row[1]):
|
|
340
|
+
raise PermissionError("无权访问该 thread 的会话历史")
|
|
341
|
+
|
|
342
|
+
async def load(self, thread_id: str, *, access: AccessContext | None = None) -> list[Message]:
|
|
343
|
+
def _op() -> list[tuple[Any, ...] | dict[str, Any]]:
|
|
344
|
+
with self._connect() as conn:
|
|
345
|
+
self._check_access(conn, thread_id, access, claim=False)
|
|
346
|
+
return conn.execute(
|
|
347
|
+
"SELECT role, content, name, tool_call_id, tool_calls "
|
|
348
|
+
"FROM sessions WHERE thread_id = ? ORDER BY id ASC",
|
|
349
|
+
(thread_id,),
|
|
350
|
+
).fetchall()
|
|
351
|
+
|
|
352
|
+
rows = await _run_in_thread(_op)
|
|
353
|
+
result = [_row_to_msg(dict(row)) for row in rows]
|
|
354
|
+
logger.debug("SQLite load: thread=%r, messages=%d", thread_id, len(result))
|
|
355
|
+
return result
|
|
356
|
+
|
|
357
|
+
async def save(
|
|
358
|
+
self,
|
|
359
|
+
thread_id: str,
|
|
360
|
+
messages: list[Message],
|
|
361
|
+
*,
|
|
362
|
+
access: AccessContext | None = None,
|
|
363
|
+
) -> None:
|
|
364
|
+
"""覆盖保存(先删再插)。"""
|
|
365
|
+
|
|
366
|
+
def _op() -> None:
|
|
367
|
+
with self._connect() as conn:
|
|
368
|
+
self._check_access(conn, thread_id, access, claim=True)
|
|
369
|
+
conn.execute("DELETE FROM sessions WHERE thread_id = ?", (thread_id,))
|
|
370
|
+
conn.executemany(
|
|
371
|
+
"INSERT INTO sessions (thread_id, role, content, name, tool_call_id, tool_calls) "
|
|
372
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
373
|
+
[(thread_id, *_msg_to_row(m)) for m in messages],
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
await _run_in_thread(_op)
|
|
377
|
+
logger.debug("SQLite save: thread=%r, messages=%d", thread_id, len(messages))
|
|
378
|
+
|
|
379
|
+
async def append(
|
|
380
|
+
self,
|
|
381
|
+
thread_id: str,
|
|
382
|
+
messages: list[Message],
|
|
383
|
+
*,
|
|
384
|
+
access: AccessContext | None = None,
|
|
385
|
+
) -> None:
|
|
386
|
+
"""追加消息(不需要先 load,效率更高)。"""
|
|
387
|
+
|
|
388
|
+
def _op() -> None:
|
|
389
|
+
with self._connect() as conn:
|
|
390
|
+
self._check_access(conn, thread_id, access, claim=True)
|
|
391
|
+
conn.executemany(
|
|
392
|
+
"INSERT INTO sessions (thread_id, role, content, name, tool_call_id, tool_calls) "
|
|
393
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
394
|
+
[(thread_id, *_msg_to_row(m)) for m in messages],
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
await _run_in_thread(_op)
|
|
398
|
+
logger.debug("SQLite append: thread=%r, +%d messages", thread_id, len(messages))
|
|
399
|
+
|
|
400
|
+
async def delete(self, thread_id: str, *, access: AccessContext | None = None) -> None:
|
|
401
|
+
def _op() -> None:
|
|
402
|
+
with self._connect() as conn:
|
|
403
|
+
self._check_access(conn, thread_id, access, claim=False)
|
|
404
|
+
conn.execute("DELETE FROM sessions WHERE thread_id = ?", (thread_id,))
|
|
405
|
+
conn.execute("DELETE FROM session_threads WHERE thread_id = ?", (thread_id,))
|
|
406
|
+
|
|
407
|
+
await _run_in_thread(_op)
|
|
408
|
+
logger.debug("SQLite delete: thread=%r", thread_id)
|
|
409
|
+
|
|
410
|
+
async def list_threads(self, *, access: AccessContext | None = None) -> list[str]:
|
|
411
|
+
if self._require_access and access is None:
|
|
412
|
+
raise PermissionError("该 SessionStore 要求提供 AccessContext")
|
|
413
|
+
|
|
414
|
+
def _op() -> list[sqlite3.Row]:
|
|
415
|
+
with self._connect() as conn:
|
|
416
|
+
if access is None:
|
|
417
|
+
return conn.execute(
|
|
418
|
+
"SELECT DISTINCT thread_id FROM sessions ORDER BY thread_id"
|
|
419
|
+
).fetchall()
|
|
420
|
+
if access.is_admin:
|
|
421
|
+
return conn.execute(
|
|
422
|
+
"SELECT thread_id FROM session_threads WHERE tenant_id=? ORDER BY thread_id",
|
|
423
|
+
(access.tenant_id,),
|
|
424
|
+
).fetchall()
|
|
425
|
+
return conn.execute(
|
|
426
|
+
"""SELECT thread_id FROM session_threads
|
|
427
|
+
WHERE tenant_id=? AND user_id=? ORDER BY thread_id""",
|
|
428
|
+
(access.tenant_id, access.user_id),
|
|
429
|
+
).fetchall()
|
|
430
|
+
|
|
431
|
+
rows = await _run_in_thread(_op)
|
|
432
|
+
return [row["thread_id"] for row in rows]
|
|
433
|
+
|
|
434
|
+
async def count(self, thread_id: str, *, access: AccessContext | None = None) -> int:
|
|
435
|
+
def _op() -> Any:
|
|
436
|
+
with self._connect() as conn:
|
|
437
|
+
self._check_access(conn, thread_id, access, claim=False)
|
|
438
|
+
return conn.execute(
|
|
439
|
+
"SELECT COUNT(*) AS cnt FROM sessions WHERE thread_id = ?",
|
|
440
|
+
(thread_id,),
|
|
441
|
+
).fetchone()
|
|
442
|
+
|
|
443
|
+
row = await _run_in_thread(_op)
|
|
444
|
+
return int(row["cnt"]) if row else 0
|
|
445
|
+
|
|
446
|
+
def db_path(self) -> str:
|
|
447
|
+
"""返回数据库文件路径(调试用)。"""
|
|
448
|
+
return self._db_path
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# ──────────────────────────────────────────────
|
|
452
|
+
# 3. PostgreSQL 后端(生产环境)
|
|
453
|
+
# ──────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class PostgresSessionStore(BaseSessionStore):
|
|
457
|
+
"""PostgreSQL 异步存储。
|
|
458
|
+
|
|
459
|
+
- 生产环境使用
|
|
460
|
+
- 支持多实例并发写
|
|
461
|
+
- 需要安装 asyncpg:uv add asyncpg
|
|
462
|
+
|
|
463
|
+
连接 URL 格式:
|
|
464
|
+
postgresql://user:password@host:5432/dbname
|
|
465
|
+
postgresql+asyncpg://user:password@host:5432/dbname
|
|
466
|
+
|
|
467
|
+
使用方式:
|
|
468
|
+
store = PostgresSessionStore("postgresql://user:pass@localhost/mydb")
|
|
469
|
+
await store.initialize() # 建表(幂等,启动时调用一次)
|
|
470
|
+
"""
|
|
471
|
+
|
|
472
|
+
DDL = """
|
|
473
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
474
|
+
id SERIAL PRIMARY KEY,
|
|
475
|
+
thread_id TEXT NOT NULL,
|
|
476
|
+
role TEXT NOT NULL,
|
|
477
|
+
content TEXT NOT NULL,
|
|
478
|
+
name TEXT,
|
|
479
|
+
tool_call_id TEXT,
|
|
480
|
+
tool_calls TEXT NOT NULL DEFAULT '[]',
|
|
481
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
482
|
+
);
|
|
483
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_thread_id ON sessions (thread_id);
|
|
484
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions (thread_id, created_at);
|
|
485
|
+
CREATE TABLE IF NOT EXISTS session_threads (
|
|
486
|
+
thread_id TEXT PRIMARY KEY,
|
|
487
|
+
user_id TEXT NOT NULL,
|
|
488
|
+
tenant_id TEXT NOT NULL,
|
|
489
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
490
|
+
);
|
|
491
|
+
"""
|
|
492
|
+
|
|
493
|
+
def __init__(self, dsn: str, *, require_access: bool = False) -> None:
|
|
494
|
+
# 统一转成 asyncpg 格式(去掉 +asyncpg 前缀)
|
|
495
|
+
self._dsn = dsn.replace("postgresql+asyncpg://", "postgresql://")
|
|
496
|
+
self._pool: Any = None # asyncpg.Pool
|
|
497
|
+
self._require_access = require_access
|
|
498
|
+
|
|
499
|
+
async def initialize(self) -> None:
|
|
500
|
+
"""创建连接池并建表(启动时调用一次)。"""
|
|
501
|
+
try:
|
|
502
|
+
import asyncpg
|
|
503
|
+
except ImportError as exc:
|
|
504
|
+
raise ImportError("PostgresSessionStore 需要 asyncpg,请执行:uv add asyncpg") from exc
|
|
505
|
+
|
|
506
|
+
self._pool = await asyncpg.create_pool(self._dsn, min_size=2, max_size=10)
|
|
507
|
+
async with self._pool.acquire() as conn:
|
|
508
|
+
await conn.execute(self.DDL)
|
|
509
|
+
logger.info("PostgresSessionStore initialized: %s", self._dsn.split("@")[-1])
|
|
510
|
+
|
|
511
|
+
async def close(self) -> None:
|
|
512
|
+
"""关闭连接池(服务停止时调用)。"""
|
|
513
|
+
if self._pool:
|
|
514
|
+
await self._pool.close()
|
|
515
|
+
|
|
516
|
+
def _check_pool(self) -> None:
|
|
517
|
+
if self._pool is None:
|
|
518
|
+
raise RuntimeError("PostgresSessionStore 未初始化,请先调用 await store.initialize()")
|
|
519
|
+
|
|
520
|
+
async def _check_access(
|
|
521
|
+
self,
|
|
522
|
+
connection: Any,
|
|
523
|
+
thread_id: str,
|
|
524
|
+
access: AccessContext | None,
|
|
525
|
+
*,
|
|
526
|
+
claim: bool,
|
|
527
|
+
) -> None:
|
|
528
|
+
if self._require_access and access is None:
|
|
529
|
+
raise PermissionError("该 SessionStore 要求提供 AccessContext")
|
|
530
|
+
row = await connection.fetchrow(
|
|
531
|
+
"SELECT user_id,tenant_id FROM session_threads WHERE thread_id=$1",
|
|
532
|
+
thread_id,
|
|
533
|
+
)
|
|
534
|
+
if row is None and access is not None and claim:
|
|
535
|
+
await connection.execute(
|
|
536
|
+
"""INSERT INTO session_threads(thread_id,user_id,tenant_id)
|
|
537
|
+
VALUES ($1,$2,$3) ON CONFLICT(thread_id) DO NOTHING""",
|
|
538
|
+
thread_id,
|
|
539
|
+
access.user_id,
|
|
540
|
+
access.tenant_id,
|
|
541
|
+
)
|
|
542
|
+
row = await connection.fetchrow(
|
|
543
|
+
"SELECT user_id,tenant_id FROM session_threads WHERE thread_id=$1",
|
|
544
|
+
thread_id,
|
|
545
|
+
)
|
|
546
|
+
if row is None and self._require_access:
|
|
547
|
+
has_messages = await connection.fetchval(
|
|
548
|
+
"SELECT EXISTS(SELECT 1 FROM sessions WHERE thread_id=$1)",
|
|
549
|
+
thread_id,
|
|
550
|
+
)
|
|
551
|
+
if has_messages:
|
|
552
|
+
raise PermissionError("该 thread 的历史数据尚未绑定所有者")
|
|
553
|
+
if row is not None and access is not None and not access.can_access(row["user_id"], row["tenant_id"]):
|
|
554
|
+
raise PermissionError("无权访问该 thread 的会话历史")
|
|
555
|
+
|
|
556
|
+
async def load(self, thread_id: str, *, access: AccessContext | None = None) -> list[Message]:
|
|
557
|
+
self._check_pool()
|
|
558
|
+
async with self._pool.acquire() as conn:
|
|
559
|
+
await self._check_access(conn, thread_id, access, claim=False)
|
|
560
|
+
rows = await conn.fetch(
|
|
561
|
+
"SELECT role, content, name, tool_call_id, tool_calls "
|
|
562
|
+
"FROM sessions WHERE thread_id = $1 ORDER BY id ASC",
|
|
563
|
+
thread_id,
|
|
564
|
+
)
|
|
565
|
+
result = [_row_to_msg(dict(row)) for row in rows]
|
|
566
|
+
logger.debug("Postgres load: thread=%r, messages=%d", thread_id, len(result))
|
|
567
|
+
return result
|
|
568
|
+
|
|
569
|
+
async def save(
|
|
570
|
+
self,
|
|
571
|
+
thread_id: str,
|
|
572
|
+
messages: list[Message],
|
|
573
|
+
*,
|
|
574
|
+
access: AccessContext | None = None,
|
|
575
|
+
) -> None:
|
|
576
|
+
self._check_pool()
|
|
577
|
+
async with self._pool.acquire() as conn:
|
|
578
|
+
async with conn.transaction():
|
|
579
|
+
await self._check_access(conn, thread_id, access, claim=True)
|
|
580
|
+
await conn.execute("DELETE FROM sessions WHERE thread_id = $1", thread_id)
|
|
581
|
+
if messages:
|
|
582
|
+
await conn.executemany(
|
|
583
|
+
"INSERT INTO sessions "
|
|
584
|
+
"(thread_id, role, content, name, tool_call_id, tool_calls) "
|
|
585
|
+
"VALUES ($1, $2, $3, $4, $5, $6)",
|
|
586
|
+
[(thread_id, *_msg_to_row(m)) for m in messages],
|
|
587
|
+
)
|
|
588
|
+
logger.debug("Postgres save: thread=%r, messages=%d", thread_id, len(messages))
|
|
589
|
+
|
|
590
|
+
async def append(
|
|
591
|
+
self,
|
|
592
|
+
thread_id: str,
|
|
593
|
+
messages: list[Message],
|
|
594
|
+
*,
|
|
595
|
+
access: AccessContext | None = None,
|
|
596
|
+
) -> None:
|
|
597
|
+
self._check_pool()
|
|
598
|
+
async with self._pool.acquire() as conn:
|
|
599
|
+
await self._check_access(conn, thread_id, access, claim=True)
|
|
600
|
+
await conn.executemany(
|
|
601
|
+
"INSERT INTO sessions "
|
|
602
|
+
"(thread_id, role, content, name, tool_call_id, tool_calls) "
|
|
603
|
+
"VALUES ($1, $2, $3, $4, $5, $6)",
|
|
604
|
+
[(thread_id, *_msg_to_row(m)) for m in messages],
|
|
605
|
+
)
|
|
606
|
+
logger.debug("Postgres append: thread=%r, +%d messages", thread_id, len(messages))
|
|
607
|
+
|
|
608
|
+
async def delete(self, thread_id: str, *, access: AccessContext | None = None) -> None:
|
|
609
|
+
self._check_pool()
|
|
610
|
+
async with self._pool.acquire() as conn:
|
|
611
|
+
async with conn.transaction():
|
|
612
|
+
await self._check_access(conn, thread_id, access, claim=False)
|
|
613
|
+
await conn.execute("DELETE FROM sessions WHERE thread_id = $1", thread_id)
|
|
614
|
+
await conn.execute("DELETE FROM session_threads WHERE thread_id = $1", thread_id)
|
|
615
|
+
|
|
616
|
+
async def list_threads(self, *, access: AccessContext | None = None) -> list[str]:
|
|
617
|
+
if self._require_access and access is None:
|
|
618
|
+
raise PermissionError("该 SessionStore 要求提供 AccessContext")
|
|
619
|
+
self._check_pool()
|
|
620
|
+
async with self._pool.acquire() as conn:
|
|
621
|
+
if access is None:
|
|
622
|
+
rows = await conn.fetch("SELECT DISTINCT thread_id FROM sessions ORDER BY thread_id")
|
|
623
|
+
elif access.is_admin:
|
|
624
|
+
rows = await conn.fetch(
|
|
625
|
+
"SELECT thread_id FROM session_threads WHERE tenant_id=$1 ORDER BY thread_id",
|
|
626
|
+
access.tenant_id,
|
|
627
|
+
)
|
|
628
|
+
else:
|
|
629
|
+
rows = await conn.fetch(
|
|
630
|
+
"""SELECT thread_id FROM session_threads
|
|
631
|
+
WHERE tenant_id=$1 AND user_id=$2 ORDER BY thread_id""",
|
|
632
|
+
access.tenant_id,
|
|
633
|
+
access.user_id,
|
|
634
|
+
)
|
|
635
|
+
return [row["thread_id"] for row in rows]
|
|
636
|
+
|
|
637
|
+
async def count(self, thread_id: str, *, access: AccessContext | None = None) -> int:
|
|
638
|
+
self._check_pool()
|
|
639
|
+
async with self._pool.acquire() as conn:
|
|
640
|
+
await self._check_access(conn, thread_id, access, claim=False)
|
|
641
|
+
row = await conn.fetchrow(
|
|
642
|
+
"SELECT COUNT(*) AS cnt FROM sessions WHERE thread_id = $1",
|
|
643
|
+
thread_id,
|
|
644
|
+
)
|
|
645
|
+
return row["cnt"] if row else 0
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# ──────────────────────────────────────────────
|
|
649
|
+
# 4. 工厂函数(环境感知自动切换)
|
|
650
|
+
# ──────────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def create_session_store(
|
|
654
|
+
env: str = "development",
|
|
655
|
+
*,
|
|
656
|
+
sqlite_path: str = "./sessions.db",
|
|
657
|
+
postgres_url: str | None = None,
|
|
658
|
+
require_access: bool = False,
|
|
659
|
+
) -> BaseSessionStore:
|
|
660
|
+
"""根据运行环境创建合适的会话存储后端。
|
|
661
|
+
|
|
662
|
+
对应关系:
|
|
663
|
+
development → SqliteSessionStore ≈ SqliteSaver
|
|
664
|
+
production → PostgresSessionStore ≈ PostgresSaver
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
env: 运行环境,"development"(默认)或 "production"。
|
|
668
|
+
sqlite_path: SQLite 数据库文件路径。
|
|
669
|
+
postgres_url: PostgreSQL 连接 URL(生产环境必填)。
|
|
670
|
+
require_access: 是否强制所有调用携带 ``AccessContext``。
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
对应的存储后端实例。
|
|
674
|
+
|
|
675
|
+
Raises:
|
|
676
|
+
ValueError: 生产环境未配置 postgres_url。
|
|
677
|
+
"""
|
|
678
|
+
normalized = env.lower()
|
|
679
|
+
if normalized in ("test", "testing"):
|
|
680
|
+
logger.info("Session store: memory (test)")
|
|
681
|
+
return MemorySessionStore(require_access=require_access)
|
|
682
|
+
|
|
683
|
+
is_prod = normalized in ("production", "prod")
|
|
684
|
+
|
|
685
|
+
if is_prod:
|
|
686
|
+
if not postgres_url:
|
|
687
|
+
raise ValueError(
|
|
688
|
+
"生产环境需要配置 AGENT_SESSION_POSTGRES_URL\n"
|
|
689
|
+
"例如:postgresql://user:pass@localhost:5432/agent_db"
|
|
690
|
+
)
|
|
691
|
+
logger.info("Session store: PostgreSQL (production)")
|
|
692
|
+
return PostgresSessionStore(postgres_url, require_access=require_access)
|
|
693
|
+
|
|
694
|
+
logger.info("Session store: SQLite (development) → %s", sqlite_path)
|
|
695
|
+
return SqliteSessionStore(sqlite_path, require_access=require_access)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
# ──────────────────────────────────────────────
|
|
699
|
+
# 5. 会话管理器(组合 Agent + Store)
|
|
700
|
+
# ──────────────────────────────────────────────
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
class SessionManager:
|
|
704
|
+
"""带多轮记忆的对话管理器。
|
|
705
|
+
|
|
706
|
+
使用示例:
|
|
707
|
+
# 开发环境(自动用 SQLite)
|
|
708
|
+
mgr = SessionManager.from_config(env="development")
|
|
709
|
+
|
|
710
|
+
# 生产环境(自动用 PostgreSQL)
|
|
711
|
+
mgr = SessionManager.from_config(
|
|
712
|
+
env="production",
|
|
713
|
+
postgres_url="postgresql://user:pass@host/db",
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# 每轮对话
|
|
717
|
+
answer = await mgr.chat(agent, thread_id="user-123", user_input="你好")
|
|
718
|
+
answer = await mgr.chat(agent, thread_id="user-123", user_input="我叫什么")
|
|
719
|
+
"""
|
|
720
|
+
|
|
721
|
+
def __init__(
|
|
722
|
+
self,
|
|
723
|
+
store: BaseSessionStore,
|
|
724
|
+
*,
|
|
725
|
+
max_history_messages: int = 50,
|
|
726
|
+
) -> None:
|
|
727
|
+
self._store = store
|
|
728
|
+
self._max_history = max_history_messages
|
|
729
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
730
|
+
|
|
731
|
+
@classmethod
|
|
732
|
+
def from_config(
|
|
733
|
+
cls,
|
|
734
|
+
env: str = "development",
|
|
735
|
+
*,
|
|
736
|
+
sqlite_path: str = "./sessions.db",
|
|
737
|
+
postgres_url: str | None = None,
|
|
738
|
+
max_history_messages: int = 50,
|
|
739
|
+
require_access: bool = False,
|
|
740
|
+
) -> SessionManager:
|
|
741
|
+
"""从配置创建 SessionManager(自动选择存储后端)。"""
|
|
742
|
+
store = create_session_store(
|
|
743
|
+
env,
|
|
744
|
+
sqlite_path=sqlite_path,
|
|
745
|
+
postgres_url=postgres_url,
|
|
746
|
+
require_access=require_access,
|
|
747
|
+
)
|
|
748
|
+
return cls(store, max_history_messages=max_history_messages)
|
|
749
|
+
|
|
750
|
+
async def setup(self) -> None:
|
|
751
|
+
"""初始化存储(PostgreSQL 需要调用建连接池和建表)。"""
|
|
752
|
+
if isinstance(self._store, PostgresSessionStore):
|
|
753
|
+
await self._store.initialize()
|
|
754
|
+
|
|
755
|
+
async def teardown(self) -> None:
|
|
756
|
+
"""关闭存储(服务停止时调用)。"""
|
|
757
|
+
if isinstance(self._store, PostgresSessionStore):
|
|
758
|
+
await self._store.close()
|
|
759
|
+
|
|
760
|
+
async def chat(
|
|
761
|
+
self,
|
|
762
|
+
agent: Any,
|
|
763
|
+
thread_id: str,
|
|
764
|
+
user_input: str,
|
|
765
|
+
*,
|
|
766
|
+
append_user: bool = True,
|
|
767
|
+
run_context: RunContext | None = None,
|
|
768
|
+
checkpoint_state: dict[str, Any] | None = None,
|
|
769
|
+
access: AccessContext | None = None,
|
|
770
|
+
) -> str:
|
|
771
|
+
"""串行化同一 thread 的对话,避免并发请求覆盖历史。"""
|
|
772
|
+
lock = self._locks.setdefault(thread_id, asyncio.Lock())
|
|
773
|
+
async with lock:
|
|
774
|
+
return await self._chat_unlocked(
|
|
775
|
+
agent,
|
|
776
|
+
thread_id,
|
|
777
|
+
user_input,
|
|
778
|
+
append_user=append_user,
|
|
779
|
+
run_context=run_context,
|
|
780
|
+
checkpoint_state=checkpoint_state,
|
|
781
|
+
access=access,
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
async def _chat_unlocked(
|
|
785
|
+
self,
|
|
786
|
+
agent: Any,
|
|
787
|
+
thread_id: str,
|
|
788
|
+
user_input: str,
|
|
789
|
+
*,
|
|
790
|
+
append_user: bool = True,
|
|
791
|
+
run_context: RunContext | None = None,
|
|
792
|
+
checkpoint_state: dict[str, Any] | None = None,
|
|
793
|
+
access: AccessContext | None = None,
|
|
794
|
+
) -> str:
|
|
795
|
+
"""带记忆的多轮对话。
|
|
796
|
+
|
|
797
|
+
Args:
|
|
798
|
+
agent: ReActAgent 实例。
|
|
799
|
+
thread_id: 会话 ID。
|
|
800
|
+
user_input: 用户输入。
|
|
801
|
+
|
|
802
|
+
Returns:
|
|
803
|
+
Agent 回答文本。
|
|
804
|
+
"""
|
|
805
|
+
if access is None and run_context is not None:
|
|
806
|
+
if run_context.user_id is not None and run_context.tenant_id is not None:
|
|
807
|
+
access = AccessContext(
|
|
808
|
+
user_id=run_context.user_id,
|
|
809
|
+
tenant_id=run_context.tenant_id,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
# 1. 加载历史,构建 memory_context
|
|
813
|
+
history = await self._store.load(thread_id, access=access)
|
|
814
|
+
memory_context = self._build_context(history) if history else None
|
|
815
|
+
|
|
816
|
+
# 2. 裁剪过长历史(仅在内存中裁剪,下一步 append 写入的是新消息)
|
|
817
|
+
if len(history) > self._max_history:
|
|
818
|
+
history = history[-self._max_history :]
|
|
819
|
+
await self._store.save(thread_id, history, access=access)
|
|
820
|
+
|
|
821
|
+
# 3. 调用 Agent
|
|
822
|
+
answer = cast(
|
|
823
|
+
str,
|
|
824
|
+
await agent.run(
|
|
825
|
+
user_input,
|
|
826
|
+
memory_context=memory_context,
|
|
827
|
+
thread_id=thread_id,
|
|
828
|
+
run_context=run_context,
|
|
829
|
+
checkpoint_state=checkpoint_state,
|
|
830
|
+
),
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
# 4. 追加本轮对话(append 比 save 更高效,只写新增的行)。
|
|
834
|
+
# 暂停的 run 在等待浏览器审批前已经保存过用户消息,因此恢复时不重复追加。
|
|
835
|
+
messages_to_append = [assistant_message(answer)]
|
|
836
|
+
if append_user:
|
|
837
|
+
messages_to_append.insert(0, user_message(user_input))
|
|
838
|
+
await self._store.append(thread_id, messages_to_append, access=access)
|
|
839
|
+
|
|
840
|
+
return answer
|
|
841
|
+
|
|
842
|
+
def _build_context(self, history: list[Message]) -> str:
|
|
843
|
+
"""把历史消息转成字符串摘要注入 system prompt。"""
|
|
844
|
+
lines: list[str] = []
|
|
845
|
+
for msg in history:
|
|
846
|
+
if msg.role == "user":
|
|
847
|
+
lines.append(f"用户: {msg.content}")
|
|
848
|
+
elif msg.role == "assistant":
|
|
849
|
+
# 限制每条摘要长度,避免 context 过长
|
|
850
|
+
lines.append(f"助手: {msg.content[:300]}")
|
|
851
|
+
return "\n".join(lines)
|
|
852
|
+
|
|
853
|
+
async def get_history(
|
|
854
|
+
self,
|
|
855
|
+
thread_id: str,
|
|
856
|
+
*,
|
|
857
|
+
access: AccessContext | None = None,
|
|
858
|
+
) -> list[Message]:
|
|
859
|
+
"""获取会话历史(用于查询接口)。"""
|
|
860
|
+
return await self._store.load(thread_id, access=access)
|
|
861
|
+
|
|
862
|
+
# ──────────────────────────────────────────────
|
|
863
|
+
# 上下文使用
|
|
864
|
+
# ──────────────────────────────────────────────
|
|
865
|
+
async def context_usage(
|
|
866
|
+
self,
|
|
867
|
+
agent: Any,
|
|
868
|
+
thread_id: str | None,
|
|
869
|
+
user_input: str,
|
|
870
|
+
*,
|
|
871
|
+
access: AccessContext | None = None,
|
|
872
|
+
) -> ContextUsage:
|
|
873
|
+
"""按真实会话记忆构造下一次模型输入并统计 token。"""
|
|
874
|
+
history = await self._store.load(thread_id, access=access) if thread_id else []
|
|
875
|
+
memory_context = self._build_context(history) if history else None
|
|
876
|
+
return cast(
|
|
877
|
+
ContextUsage,
|
|
878
|
+
await agent.context_usage(user_input, memory_context=memory_context),
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
async def clear(
|
|
882
|
+
self,
|
|
883
|
+
thread_id: str,
|
|
884
|
+
*,
|
|
885
|
+
access: AccessContext | None = None,
|
|
886
|
+
) -> None:
|
|
887
|
+
"""清空会话历史。"""
|
|
888
|
+
await self._store.delete(thread_id, access=access)
|
|
889
|
+
lock = self._locks.get(thread_id)
|
|
890
|
+
if lock is not None and not lock.locked():
|
|
891
|
+
self._locks.pop(thread_id, None)
|
|
892
|
+
|
|
893
|
+
async def list_threads(
|
|
894
|
+
self,
|
|
895
|
+
*,
|
|
896
|
+
access: AccessContext | None = None,
|
|
897
|
+
) -> list[str]:
|
|
898
|
+
"""列出所有会话 ID。"""
|
|
899
|
+
return await self._store.list_threads(access=access)
|
|
900
|
+
|
|
901
|
+
@property
|
|
902
|
+
def store(self) -> BaseSessionStore:
|
|
903
|
+
"""暴露底层存储(测试用)。"""
|
|
904
|
+
return self._store
|