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.
Files changed (104) hide show
  1. agent_core/__init__.py +525 -0
  2. agent_core/access/__init__.py +6 -0
  3. agent_core/access/context.py +45 -0
  4. agent_core/access/guard.py +40 -0
  5. agent_core/acp/README.md +236 -0
  6. agent_core/acp/__init__.py +20 -0
  7. agent_core/acp/server.py +351 -0
  8. agent_core/acp/types.py +86 -0
  9. agent_core/callbacks/__init__.py +17 -0
  10. agent_core/callbacks/manager.py +105 -0
  11. agent_core/checkpoint/__init__.py +19 -0
  12. agent_core/checkpoint/naming.py +93 -0
  13. agent_core/checkpoint/store.py +186 -0
  14. agent_core/checkpoint/travel.py +448 -0
  15. agent_core/compaction/__init__.py +57 -0
  16. agent_core/compaction/middleware.py +428 -0
  17. agent_core/compaction/prompts.py +32 -0
  18. agent_core/compaction/pruner.py +121 -0
  19. agent_core/compaction/region.py +145 -0
  20. agent_core/compaction/spill.py +81 -0
  21. agent_core/compaction/spill_tool.py +42 -0
  22. agent_core/compaction/summarizer.py +95 -0
  23. agent_core/compaction/token_limit.py +74 -0
  24. agent_core/compaction/types.py +93 -0
  25. agent_core/docs.py +15 -0
  26. agent_core/documents/__init__.py +23 -0
  27. agent_core/documents/loaders.py +79 -0
  28. agent_core/documents/splitters.py +94 -0
  29. agent_core/documents/types.py +197 -0
  30. agent_core/errors.py +81 -0
  31. agent_core/guardrails/__init__.py +19 -0
  32. agent_core/guardrails/core.py +186 -0
  33. agent_core/hitl/__init__.py +15 -0
  34. agent_core/hitl/core.py +517 -0
  35. agent_core/mcp/__init__.py +14 -0
  36. agent_core/mcp/client.py +212 -0
  37. agent_core/mcp/errors.py +26 -0
  38. agent_core/middleware/__init__.py +26 -0
  39. agent_core/middleware/base.py +283 -0
  40. agent_core/model/__init__.py +73 -0
  41. agent_core/model/catalog.py +73 -0
  42. agent_core/model/core.py +179 -0
  43. agent_core/model/governance.py +317 -0
  44. agent_core/model/providers/__init__.py +43 -0
  45. agent_core/model/providers/anthropic.py +294 -0
  46. agent_core/model/providers/common.py +28 -0
  47. agent_core/model/providers/factory.py +107 -0
  48. agent_core/model/providers/gemini.py +273 -0
  49. agent_core/model/providers/ollama.py +80 -0
  50. agent_core/model/providers/openai_compatible.py +526 -0
  51. agent_core/model/structured.py +200 -0
  52. agent_core/observability/__init__.py +21 -0
  53. agent_core/observability/core.py +311 -0
  54. agent_core/ports/__init__.py +31 -0
  55. agent_core/ports/memory.py +130 -0
  56. agent_core/ports/model_selection.py +64 -0
  57. agent_core/ports/storage.py +114 -0
  58. agent_core/prompts/__init__.py +19 -0
  59. agent_core/prompts/registry.py +236 -0
  60. agent_core/prompts/templates.py +166 -0
  61. agent_core/protocol/__init__.py +31 -0
  62. agent_core/protocol/capabilities.py +49 -0
  63. agent_core/protocol/messages.py +100 -0
  64. agent_core/protocol/runtime.py +293 -0
  65. agent_core/py.typed +1 -0
  66. agent_core/retrieval/__init__.py +40 -0
  67. agent_core/retrieval/_common.py +121 -0
  68. agent_core/retrieval/chroma.py +269 -0
  69. agent_core/retrieval/core.py +63 -0
  70. agent_core/retrieval/factory.py +72 -0
  71. agent_core/retrieval/memory.py +232 -0
  72. agent_core/retrieval/pgvector.py +239 -0
  73. agent_core/retrieval/types.py +95 -0
  74. agent_core/runtime/__init__.py +28 -0
  75. agent_core/runtime/compat.py +98 -0
  76. agent_core/runtime/react.py +888 -0
  77. agent_core/runtime/service.py +557 -0
  78. agent_core/runtime/store.py +55 -0
  79. agent_core/serialization/__init__.py +21 -0
  80. agent_core/serialization/registry.py +318 -0
  81. agent_core/skills/__init__.py +24 -0
  82. agent_core/skills/errors.py +27 -0
  83. agent_core/skills/loader.py +258 -0
  84. agent_core/skills/registry.py +205 -0
  85. agent_core/skills/types.py +56 -0
  86. agent_core/storage/__init__.py +107 -0
  87. agent_core/storage/_sqlite_utils.py +92 -0
  88. agent_core/storage/context.py +382 -0
  89. agent_core/storage/lease.py +435 -0
  90. agent_core/storage/memory.py +604 -0
  91. agent_core/storage/model_selection.py +360 -0
  92. agent_core/storage/runtime.py +899 -0
  93. agent_core/storage/session.py +904 -0
  94. agent_core/storage/workflow.py +411 -0
  95. agent_core/tools/__init__.py +5 -0
  96. agent_core/tools/executor.py +306 -0
  97. agent_core/workflow/__init__.py +25 -0
  98. agent_core/workflow/durable.py +175 -0
  99. agent_core/workflow/state_machine.py +427 -0
  100. zero_agent_core-0.2.0.dist-info/METADATA +237 -0
  101. zero_agent_core-0.2.0.dist-info/RECORD +104 -0
  102. zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
  103. zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
  104. 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