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,899 @@
1
+ """运行时状态存储。
2
+
3
+ 测试使用内存,开发使用 SQLite,生产使用 PostgreSQL。
4
+ RunContext 等领域对象不绑定具体数据库。
5
+
6
+ 存 agent 引擎的执行状态
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import copy
13
+ import json
14
+ import sqlite3
15
+ from abc import ABC, abstractmethod
16
+ from collections.abc import Generator
17
+ from contextlib import contextmanager
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from agent_core.protocol.runtime import (
22
+ ApprovalRecord,
23
+ RunContext,
24
+ )
25
+ from agent_core.storage._sqlite_utils import (
26
+ _run_in_thread,
27
+ run_sync_in_thread,
28
+ sqlite_connection,
29
+ )
30
+
31
+
32
+ # ————————————————————————————————————#
33
+ # 运行时并发错误
34
+ # ————————————————————————————————————#
35
+ class RuntimeConcurrencyError(RuntimeError):
36
+ """运行状态版本冲突,调用方应重新加载后再决定是否重试。"""
37
+
38
+
39
+ # ————————————————————————————————————#
40
+ # 运行时存储接口
41
+ # ————————————————————————————————————#
42
+ class RuntimeStore(ABC):
43
+ """运行状态、事件和审批记录的持久化抽象。"""
44
+
45
+ # ————————————————————————————————————#
46
+ # 运行状态存储
47
+ # ————————————————————————————————————#
48
+ @abstractmethod
49
+ async def save_run(self, context: RunContext) -> None: ...
50
+
51
+ # ————————————————————————————————————#
52
+ # 运行事件查询
53
+ # ————————————————————————————————————#
54
+ @abstractmethod
55
+ async def find_run_by_idempotency(
56
+ self, tenant_id: str | None, user_id: str | None, idempotency_key: str
57
+ ) -> RunContext | None: ...
58
+
59
+ # ————————————————————————————————————#
60
+ # 运行状态查询
61
+ # ————————————————————————————————————#
62
+ @abstractmethod
63
+ async def mark_stale_runs(self) -> list[RunContext]: ...
64
+
65
+ # ————————————————————————————————————#
66
+ # 运行状态查询
67
+ # ————————————————————————————————————#
68
+ @abstractmethod
69
+ async def save_thread_owner(self, thread_id: str, user_id: str, tenant_id: str) -> None: ...
70
+
71
+ # ————————————————————————————————————#
72
+ # 运行状态查询
73
+ # ————————————————————————————————————#
74
+ @abstractmethod
75
+ async def get_thread_owner(self, thread_id: str) -> tuple[str, str] | None: ...
76
+
77
+ # ————————————————————————————————————#
78
+ # 运行状态加载
79
+ # ————————————————————————————————————#
80
+ @abstractmethod
81
+ async def load_run(self, thread_id: str, run_id: str) -> RunContext | None: ...
82
+
83
+ @abstractmethod
84
+ async def list_runs(self, thread_id: str) -> list[RunContext]: ...
85
+
86
+ @abstractmethod
87
+ async def save_approval(self, approval: ApprovalRecord) -> None: ...
88
+
89
+ @abstractmethod
90
+ async def load_approval(self, approval_id: str) -> ApprovalRecord | None: ...
91
+
92
+ @abstractmethod
93
+ async def transition_approval(
94
+ self,
95
+ approval: ApprovalRecord,
96
+ *,
97
+ expected_status: str = "pending",
98
+ ) -> bool: ...
99
+
100
+ # ————————————————————————————————————#
101
+ # 运行审批记录加载
102
+ # ————————————————————————————————————#
103
+
104
+
105
+ def _approval_from_dict(value: dict[str, Any] | None) -> ApprovalRecord | None:
106
+ if not value:
107
+ return None
108
+ arguments = value.get("arguments") or {}
109
+ if isinstance(arguments, str): # asyncpg 默认把 JSONB 返回为字符串,统一转换后再重建领域对象。
110
+ arguments = json.loads(arguments) if arguments else {} # 确保 arguments 是字典类型
111
+ return ApprovalRecord.from_dict({**value, "arguments": dict(arguments)})
112
+
113
+
114
+ # ──────────────────────────────────────────────
115
+ # 数据库行 → RunContext
116
+ # ──────────────────────────────────────────────
117
+ def _context_from_dict(value: dict[str, Any]) -> RunContext:
118
+ value = copy.deepcopy(value)
119
+ # asyncpg 默认把 JSONB 返回为字符串,统一转换后再重建领域对象。
120
+ for key in (
121
+ "tags",
122
+ "pending_approval",
123
+ "pending_clarification",
124
+ "state",
125
+ "metadata",
126
+ "checkpoint",
127
+ "events",
128
+ ):
129
+ if isinstance(value.get(key), str):
130
+ value[key] = json.loads(value[key])
131
+ return RunContext.from_dict(value)
132
+
133
+
134
+ # ──────────────────────────────────────────────
135
+ # 2. 内存后端(测试环境)
136
+ # ──────────────────────────────────────────────
137
+ class MemoryRuntimeStore(RuntimeStore):
138
+ """测试用内存实现。"""
139
+
140
+ def __init__(self) -> None:
141
+ self._runs: dict[tuple[str, str], dict[str, Any]] = {}
142
+ self._approvals: dict[str, dict[str, Any]] = {}
143
+ self._threads: dict[str, tuple[str, str]] = {}
144
+ self._lock = asyncio.Lock()
145
+
146
+ async def save_run(self, context: RunContext) -> None:
147
+ key = (context.thread_id, context.run_id)
148
+ previous = self._runs.get(key)
149
+ if previous is not None and int(previous.get("version", 0)) != context.version:
150
+ raise RuntimeConcurrencyError(f"run {context.run_id} 版本冲突")
151
+ context.version = (int(previous.get("version", 0)) + 1) if previous else 1
152
+ self._runs[key] = copy.deepcopy(context.to_dict())
153
+ if context.pending_approval:
154
+ await self.save_approval(context.pending_approval)
155
+
156
+ async def load_run(self, thread_id: str, run_id: str) -> RunContext | None:
157
+ value = self._runs.get((thread_id, run_id))
158
+ return _context_from_dict(value) if value else None
159
+
160
+ async def list_runs(self, thread_id: str) -> list[RunContext]:
161
+ return [
162
+ _context_from_dict(value)
163
+ for (stored_thread, _), value in self._runs.items()
164
+ if stored_thread == thread_id
165
+ ]
166
+
167
+ async def find_run_by_idempotency(
168
+ self, tenant_id: str | None, user_id: str | None, idempotency_key: str
169
+ ) -> RunContext | None:
170
+ for value in self._runs.values():
171
+ if (
172
+ value.get("tenant_id") == tenant_id
173
+ and value.get("user_id") == user_id
174
+ and value.get("idempotency_key") == idempotency_key
175
+ ):
176
+ return _context_from_dict(value)
177
+ return None
178
+
179
+ async def mark_stale_runs(self) -> list[RunContext]:
180
+ stale: list[RunContext] = []
181
+ for value in list(self._runs.values()):
182
+ if value.get("status") not in {"queued", "running"}:
183
+ continue
184
+ context = _context_from_dict(value)
185
+ context.status = "interrupted"
186
+ context.emit("run_interrupted", reason="服务重启")
187
+ context.version = int(value.get("version", 0))
188
+ await self.save_run(context)
189
+ stale.append(context)
190
+ return stale
191
+
192
+ async def save_thread_owner(self, thread_id: str, user_id: str, tenant_id: str) -> None:
193
+ previous = self._threads.get(thread_id)
194
+ if previous and previous != (user_id, tenant_id):
195
+ raise RuntimeConcurrencyError("thread 归属冲突")
196
+ self._threads[thread_id] = (user_id, tenant_id)
197
+
198
+ async def get_thread_owner(self, thread_id: str) -> tuple[str, str] | None:
199
+ return self._threads.get(thread_id)
200
+
201
+ async def save_approval(self, approval: ApprovalRecord) -> None:
202
+ async with self._lock:
203
+ current = self._approvals.get(approval.approval_id)
204
+ if current is not None and current.get("status") != "pending":
205
+ return
206
+ self._approvals[approval.approval_id] = copy.deepcopy(approval.to_dict())
207
+
208
+ async def load_approval(self, approval_id: str) -> ApprovalRecord | None:
209
+ return _approval_from_dict(copy.deepcopy(self._approvals.get(approval_id)))
210
+
211
+ async def transition_approval(
212
+ self,
213
+ approval: ApprovalRecord,
214
+ *,
215
+ expected_status: str = "pending",
216
+ ) -> bool:
217
+ async with self._lock:
218
+ current = self._approvals.get(approval.approval_id)
219
+ if current is None or current.get("status") != expected_status:
220
+ return False
221
+ self._approvals[approval.approval_id] = copy.deepcopy(approval.to_dict())
222
+ return True
223
+
224
+
225
+ # ──────────────────────────────────────────────
226
+ # 3. SQLite 后端(开发环境)
227
+ # ──────────────────────────────────────────────
228
+ class SqliteRuntimeStore(RuntimeStore):
229
+ """开发环境 SQLite 实现,可和 sessions.db 共用数据库文件。"""
230
+
231
+ DDL = """
232
+ CREATE TABLE IF NOT EXISTS agent_runs (
233
+ thread_id TEXT NOT NULL,
234
+ run_id TEXT NOT NULL,
235
+ user_id TEXT,
236
+ tenant_id TEXT,
237
+ parent_run_id TEXT,
238
+ tags TEXT NOT NULL DEFAULT '[]',
239
+ status TEXT NOT NULL,
240
+ version INTEGER NOT NULL DEFAULT 0,
241
+ idempotency_key TEXT,
242
+ iteration INTEGER NOT NULL DEFAULT 0,
243
+ tool_calls_used INTEGER NOT NULL DEFAULT 0,
244
+ pending_approval TEXT,
245
+ pending_clarification TEXT,
246
+ state TEXT NOT NULL DEFAULT '{}',
247
+ metadata TEXT NOT NULL DEFAULT '{}',
248
+ checkpoint TEXT NOT NULL DEFAULT '{}',
249
+ events TEXT NOT NULL DEFAULT '[]',
250
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
251
+ PRIMARY KEY (thread_id, run_id)
252
+ );
253
+ CREATE TABLE IF NOT EXISTS agent_approvals (
254
+ approval_id TEXT PRIMARY KEY,
255
+ thread_id TEXT NOT NULL,
256
+ run_id TEXT NOT NULL,
257
+ user_id TEXT,
258
+ tenant_id TEXT,
259
+ action TEXT NOT NULL,
260
+ arguments TEXT NOT NULL DEFAULT '{}',
261
+ status TEXT NOT NULL,
262
+ reason TEXT,
263
+ created_at TEXT NOT NULL,
264
+ expires_at TEXT
265
+ );
266
+ CREATE TABLE IF NOT EXISTS agent_threads (
267
+ thread_id TEXT PRIMARY KEY,
268
+ user_id TEXT NOT NULL,
269
+ tenant_id TEXT NOT NULL,
270
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
271
+ );
272
+ """
273
+
274
+ def __init__(self, db_path: str | Path = "./sessions.db") -> None:
275
+ self._db_path = str(Path(db_path).resolve())
276
+ # 建表/补列/幂等索引 DDL 初始化同样放入工作线程执行,与 async 方法共用同一连接参数规范。
277
+ run_sync_in_thread(self._init_db)
278
+
279
+ def _init_db(self) -> None:
280
+ with self._connect() as conn:
281
+ conn.executescript(self.DDL)
282
+ self._ensure_columns(conn)
283
+
284
+ @staticmethod
285
+ def _ensure_columns(conn: sqlite3.Connection) -> None:
286
+ """为已有开发数据库补充新字段,避免升级时丢失运行记录。"""
287
+ columns = {row[1] for row in conn.execute("PRAGMA table_info(agent_runs)")}
288
+ for name, definition in {
289
+ "tenant_id": "TEXT",
290
+ "parent_run_id": "TEXT",
291
+ "tags": "TEXT NOT NULL DEFAULT '[]'",
292
+ "version": "INTEGER NOT NULL DEFAULT 0",
293
+ "idempotency_key": "TEXT",
294
+ "checkpoint": "TEXT NOT NULL DEFAULT '{}'",
295
+ }.items():
296
+ if name not in columns:
297
+ conn.execute(f"ALTER TABLE agent_runs ADD COLUMN {name} {definition}")
298
+ approval_columns = {row[1] for row in conn.execute("PRAGMA table_info(agent_approvals)")}
299
+ for name, definition in {"user_id": "TEXT", "tenant_id": "TEXT"}.items():
300
+ if name not in approval_columns:
301
+ conn.execute(f"ALTER TABLE agent_approvals ADD COLUMN {name} {definition}")
302
+ # SQLite 把 NULL 视为互不相等,表达式索引把匿名作用域归一化后才能真正幂等。
303
+ conn.execute("DROP INDEX IF EXISTS idx_agent_runs_idempotency")
304
+ duplicate = conn.execute(
305
+ "SELECT COALESCE(tenant_id, ''), COALESCE(user_id, ''), idempotency_key "
306
+ "FROM agent_runs WHERE idempotency_key IS NOT NULL "
307
+ "GROUP BY 1, 2, 3 HAVING COUNT(*) > 1 LIMIT 1"
308
+ ).fetchone()
309
+ if duplicate:
310
+ # 旧索引允许 (NULL, NULL, key) 重复;直接建唯一索引会让升级中断且难以定位。
311
+ raise RuntimeConcurrencyError(
312
+ f"agent_runs 存在归一化后重复的幂等键 {tuple(duplicate)},"
313
+ "无法创建唯一索引;请先清理历史重复行后再初始化"
314
+ )
315
+ conn.execute(
316
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_runs_idempotency_scope "
317
+ "ON agent_runs(COALESCE(tenant_id, ''), COALESCE(user_id, ''), idempotency_key) "
318
+ "WHERE idempotency_key IS NOT NULL"
319
+ )
320
+
321
+ @contextmanager
322
+ def _connect(self) -> Generator[sqlite3.Connection]:
323
+ """获取 SQLite 连接(同步,自动提交/回滚;统一 timeout 与 WAL 规范)。"""
324
+ with sqlite_connection(self._db_path) as conn:
325
+ yield conn
326
+
327
+ async def save_run(self, context: RunContext) -> None:
328
+ def _op() -> int:
329
+ with self._connect() as conn:
330
+ conn.execute("BEGIN IMMEDIATE")
331
+ row = conn.execute(
332
+ "SELECT version FROM agent_runs WHERE thread_id=? AND run_id=?",
333
+ (context.thread_id, context.run_id),
334
+ ).fetchone()
335
+ stored_version = int(row[0]) if row else 0
336
+ if row is not None and stored_version != context.version:
337
+ raise RuntimeConcurrencyError(f"run {context.run_id} 版本冲突")
338
+ next_version = stored_version + 1
339
+ value = context.to_dict()
340
+ cursor = conn.execute(
341
+ """INSERT INTO agent_runs
342
+ (thread_id, run_id, user_id, tenant_id, parent_run_id, tags,
343
+ status, version, idempotency_key,
344
+ iteration, tool_calls_used, pending_approval, pending_clarification,
345
+ state, metadata, checkpoint, events)
346
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
347
+ ON CONFLICT(thread_id, run_id) DO UPDATE SET
348
+ user_id=excluded.user_id, tenant_id=excluded.tenant_id,
349
+ parent_run_id=excluded.parent_run_id, tags=excluded.tags,
350
+ status=excluded.status, version=excluded.version,
351
+ idempotency_key=excluded.idempotency_key,
352
+ iteration=excluded.iteration, tool_calls_used=excluded.tool_calls_used,
353
+ pending_approval=excluded.pending_approval,
354
+ pending_clarification=excluded.pending_clarification,
355
+ state=excluded.state, metadata=excluded.metadata,
356
+ checkpoint=excluded.checkpoint,
357
+ events=excluded.events, updated_at=CURRENT_TIMESTAMP
358
+ WHERE agent_runs.version=?""",
359
+ (
360
+ context.thread_id,
361
+ context.run_id,
362
+ context.user_id,
363
+ context.tenant_id,
364
+ context.parent_run_id,
365
+ json.dumps(value["tags"], ensure_ascii=False),
366
+ context.status,
367
+ next_version,
368
+ context.idempotency_key,
369
+ context.iteration,
370
+ context.tool_calls_used,
371
+ json.dumps(value["pending_approval"], ensure_ascii=False),
372
+ json.dumps(value["pending_clarification"], ensure_ascii=False),
373
+ json.dumps(value["state"], ensure_ascii=False),
374
+ json.dumps(value["metadata"], ensure_ascii=False),
375
+ json.dumps(value["checkpoint"], ensure_ascii=False),
376
+ json.dumps(value["events"], ensure_ascii=False),
377
+ stored_version,
378
+ ),
379
+ )
380
+ if cursor.rowcount != 1:
381
+ raise RuntimeConcurrencyError(f"run {context.run_id} 保存时发生并发冲突")
382
+ return next_version
383
+
384
+ next_version = await _run_in_thread(_op)
385
+ context.version = next_version
386
+ if context.pending_approval:
387
+ await self.save_approval(context.pending_approval)
388
+
389
+ async def load_run(self, thread_id: str, run_id: str) -> RunContext | None:
390
+ def _op() -> Any:
391
+ with self._connect() as conn:
392
+ return conn.execute(
393
+ """SELECT thread_id, run_id, user_id, tenant_id, parent_run_id, tags,
394
+ status, version,
395
+ idempotency_key, iteration, tool_calls_used, pending_approval,
396
+ pending_clarification, state, metadata, checkpoint, events
397
+ FROM agent_runs WHERE thread_id = ? AND run_id = ?""",
398
+ (thread_id, run_id),
399
+ ).fetchone()
400
+
401
+ row = await _run_in_thread(_op)
402
+ if row is None:
403
+ return None
404
+ # 使用固定列名,避免依赖 sqlite Row 配置。
405
+ keys = [
406
+ "thread_id",
407
+ "run_id",
408
+ "user_id",
409
+ "tenant_id",
410
+ "parent_run_id",
411
+ "tags",
412
+ "status",
413
+ "version",
414
+ "idempotency_key",
415
+ "iteration",
416
+ "tool_calls_used",
417
+ "pending_approval",
418
+ "pending_clarification",
419
+ "state",
420
+ "metadata",
421
+ "checkpoint",
422
+ "events",
423
+ ]
424
+ value = dict(zip(keys, row, strict=True))
425
+ for key in (
426
+ "tags",
427
+ "pending_approval",
428
+ "pending_clarification",
429
+ "state",
430
+ "metadata",
431
+ "checkpoint",
432
+ "events",
433
+ ):
434
+ value[key] = json.loads(value[key]) if value[key] else (None if key.startswith("pending") else {})
435
+ return _context_from_dict(value)
436
+
437
+ async def list_runs(self, thread_id: str) -> list[RunContext]:
438
+ def _op() -> list[Any]:
439
+ with self._connect() as conn:
440
+ return conn.execute(
441
+ "SELECT run_id FROM agent_runs WHERE thread_id = ? ORDER BY updated_at DESC",
442
+ (thread_id,),
443
+ ).fetchall()
444
+
445
+ rows = await _run_in_thread(_op)
446
+ result: list[RunContext] = []
447
+ for (run_id,) in rows:
448
+ context = await self.load_run(thread_id, run_id)
449
+ if context:
450
+ result.append(context)
451
+ return result
452
+
453
+ async def find_run_by_idempotency(
454
+ self, tenant_id: str | None, user_id: str | None, idempotency_key: str
455
+ ) -> RunContext | None:
456
+ def _op() -> Any:
457
+ with self._connect() as conn:
458
+ return conn.execute(
459
+ "SELECT thread_id, run_id FROM agent_runs "
460
+ "WHERE tenant_id IS ? AND user_id IS ? AND idempotency_key = ? LIMIT 1",
461
+ (tenant_id, user_id, idempotency_key),
462
+ ).fetchone()
463
+
464
+ row = await _run_in_thread(_op)
465
+ return await self.load_run(row[0], row[1]) if row else None
466
+
467
+ async def mark_stale_runs(self) -> list[RunContext]:
468
+ def _op() -> list[Any]:
469
+ with self._connect() as conn:
470
+ return conn.execute(
471
+ "SELECT thread_id, run_id FROM agent_runs WHERE status IN ('queued', 'running')"
472
+ ).fetchall()
473
+
474
+ rows = await _run_in_thread(_op)
475
+ result: list[RunContext] = []
476
+ for thread_id, run_id in rows:
477
+ context = await self.load_run(thread_id, run_id)
478
+ if context is None:
479
+ continue
480
+ context.status = "interrupted"
481
+ context.emit("run_interrupted", reason="服务重启")
482
+ await self.save_run(context)
483
+ result.append(context)
484
+ return result
485
+
486
+ async def save_thread_owner(self, thread_id: str, user_id: str, tenant_id: str) -> None:
487
+ def _op() -> None:
488
+ with self._connect() as conn:
489
+ existing = conn.execute(
490
+ "SELECT user_id, tenant_id FROM agent_threads WHERE thread_id=?", (thread_id,)
491
+ ).fetchone()
492
+ if existing and tuple(existing) != (user_id, tenant_id):
493
+ raise RuntimeConcurrencyError("thread 归属冲突")
494
+ conn.execute(
495
+ "INSERT OR IGNORE INTO agent_threads(thread_id,user_id,tenant_id) VALUES (?,?,?)",
496
+ (thread_id, user_id, tenant_id),
497
+ )
498
+
499
+ await _run_in_thread(_op)
500
+
501
+ async def get_thread_owner(self, thread_id: str) -> tuple[str, str] | None:
502
+ def _op() -> Any:
503
+ with self._connect() as conn:
504
+ return conn.execute(
505
+ "SELECT user_id, tenant_id FROM agent_threads WHERE thread_id=?", (thread_id,)
506
+ ).fetchone()
507
+
508
+ row = await _run_in_thread(_op)
509
+ return tuple(row) if row else None
510
+
511
+ async def save_approval(self, approval: ApprovalRecord) -> None:
512
+ def _op() -> None:
513
+ with self._connect() as conn:
514
+ conn.execute(
515
+ """INSERT INTO agent_approvals
516
+ (approval_id, thread_id, run_id, user_id, tenant_id, action,
517
+ arguments, status, reason, created_at, expires_at)
518
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
519
+ ON CONFLICT(approval_id) DO UPDATE SET
520
+ status=excluded.status, reason=excluded.reason, expires_at=excluded.expires_at
521
+ WHERE agent_approvals.status='pending'""",
522
+ (
523
+ approval.approval_id,
524
+ approval.thread_id,
525
+ approval.run_id,
526
+ approval.user_id,
527
+ approval.tenant_id,
528
+ approval.action,
529
+ json.dumps(approval.arguments, ensure_ascii=False),
530
+ approval.status,
531
+ approval.reason,
532
+ approval.created_at,
533
+ approval.expires_at,
534
+ ),
535
+ )
536
+
537
+ await _run_in_thread(_op)
538
+
539
+ async def transition_approval(
540
+ self,
541
+ approval: ApprovalRecord,
542
+ *,
543
+ expected_status: str = "pending",
544
+ ) -> bool:
545
+ def _op() -> int:
546
+ with self._connect() as conn:
547
+ cursor = conn.execute(
548
+ """UPDATE agent_approvals
549
+ SET status=?, reason=?, expires_at=?
550
+ WHERE approval_id=? AND status=?""",
551
+ (
552
+ approval.status,
553
+ approval.reason,
554
+ approval.expires_at,
555
+ approval.approval_id,
556
+ expected_status,
557
+ ),
558
+ )
559
+ return cursor.rowcount
560
+
561
+ return await _run_in_thread(_op) == 1
562
+
563
+ async def load_approval(self, approval_id: str) -> ApprovalRecord | None:
564
+ def _op() -> Any:
565
+ with self._connect() as conn:
566
+ return conn.execute(
567
+ """SELECT approval_id, thread_id, run_id, user_id, tenant_id,
568
+ action, arguments, status, reason, created_at, expires_at
569
+ FROM agent_approvals WHERE approval_id = ?""",
570
+ (approval_id,),
571
+ ).fetchone()
572
+
573
+ row = await _run_in_thread(_op)
574
+ if row is None:
575
+ return None
576
+ return _approval_from_dict(
577
+ dict(
578
+ zip(
579
+ [
580
+ "approval_id",
581
+ "thread_id",
582
+ "run_id",
583
+ "user_id",
584
+ "tenant_id",
585
+ "action",
586
+ "arguments",
587
+ "status",
588
+ "reason",
589
+ "created_at",
590
+ "expires_at",
591
+ ],
592
+ row,
593
+ strict=True,
594
+ )
595
+ )
596
+ )
597
+
598
+
599
+ # ──────────────────────────────────────────────
600
+ # 4. PostgreSQL 后端(生产环境)
601
+ # ──────────────────────────────────────────────
602
+ class PostgresRuntimeStore(RuntimeStore):
603
+ """生产环境 PostgreSQL 实现。"""
604
+
605
+ def __init__(self, dsn: str) -> None:
606
+ self._dsn = dsn.replace("postgresql+asyncpg://", "postgresql://")
607
+ self._pool: Any = None
608
+
609
+ async def initialize(self) -> None:
610
+ try:
611
+ import asyncpg
612
+ except ImportError as exc:
613
+ raise ImportError("PostgresRuntimeStore 需要 asyncpg,请执行:uv add asyncpg") from exc
614
+ self._pool = await asyncpg.create_pool(self._dsn, min_size=2, max_size=10)
615
+ async with self._pool.acquire() as conn:
616
+ await conn.execute("""
617
+ CREATE TABLE IF NOT EXISTS agent_runs (
618
+ thread_id TEXT NOT NULL, run_id TEXT NOT NULL, user_id TEXT, tenant_id TEXT,
619
+ parent_run_id TEXT, tags JSONB NOT NULL DEFAULT '[]'::jsonb,
620
+ status TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 0,
621
+ idempotency_key TEXT,
622
+ iteration INTEGER NOT NULL DEFAULT 0,
623
+ tool_calls_used INTEGER NOT NULL DEFAULT 0,
624
+ state JSONB NOT NULL DEFAULT '{}'::jsonb,
625
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
626
+ checkpoint JSONB NOT NULL DEFAULT '{}'::jsonb,
627
+ pending_approval JSONB, pending_clarification JSONB,
628
+ events JSONB NOT NULL DEFAULT '[]'::jsonb,
629
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
630
+ PRIMARY KEY (thread_id, run_id)
631
+ );
632
+ CREATE TABLE IF NOT EXISTS agent_approvals (
633
+ approval_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, run_id TEXT NOT NULL,
634
+ user_id TEXT, tenant_id TEXT,
635
+ action TEXT NOT NULL, arguments JSONB NOT NULL DEFAULT '{}'::jsonb,
636
+ status TEXT NOT NULL, reason TEXT, created_at TEXT NOT NULL, expires_at TEXT
637
+ );
638
+ CREATE TABLE IF NOT EXISTS agent_threads (
639
+ thread_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
640
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
641
+ );
642
+ ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS tenant_id TEXT;
643
+ ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS parent_run_id TEXT;
644
+ ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS tags JSONB NOT NULL DEFAULT '[]'::jsonb;
645
+ ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 0;
646
+ ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS idempotency_key TEXT;
647
+ ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS checkpoint JSONB NOT NULL DEFAULT '{}'::jsonb;
648
+ ALTER TABLE agent_approvals ADD COLUMN IF NOT EXISTS user_id TEXT;
649
+ ALTER TABLE agent_approvals ADD COLUMN IF NOT EXISTS tenant_id TEXT;
650
+ DROP INDEX IF EXISTS idx_agent_runs_idempotency;
651
+ """)
652
+ # 旧索引允许 (NULL, NULL, key) 重复;先预检归一化后的重复行再建唯一索引。
653
+ duplicate = await conn.fetchrow(
654
+ "SELECT COALESCE(tenant_id, ''), COALESCE(user_id, ''), idempotency_key "
655
+ "FROM agent_runs WHERE idempotency_key IS NOT NULL "
656
+ "GROUP BY 1, 2, 3 HAVING COUNT(*) > 1 LIMIT 1"
657
+ )
658
+ if duplicate:
659
+ raise RuntimeConcurrencyError(
660
+ f"agent_runs 存在归一化后重复的幂等键 {tuple(duplicate)},"
661
+ "无法创建唯一索引;请先清理历史重复行后再初始化"
662
+ )
663
+ await conn.execute(
664
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_runs_idempotency_scope "
665
+ "ON agent_runs(COALESCE(tenant_id, ''), COALESCE(user_id, ''), idempotency_key) "
666
+ "WHERE idempotency_key IS NOT NULL"
667
+ )
668
+
669
+ async def close(self) -> None:
670
+ if self._pool:
671
+ await self._pool.close()
672
+
673
+ def _check(self) -> None:
674
+ if self._pool is None:
675
+ raise RuntimeError("PostgresRuntimeStore 未初始化")
676
+
677
+ async def save_run(self, context: RunContext) -> None:
678
+ self._check()
679
+ async with self._pool.acquire() as conn:
680
+ async with conn.transaction():
681
+ # 在同一事务中锁住当前 run,避免两个 API 实例同时通过版本检查。
682
+ row = await conn.fetchrow(
683
+ "SELECT version, thread_id, run_id FROM agent_runs "
684
+ "WHERE thread_id=$1 AND run_id=$2 FOR UPDATE",
685
+ context.thread_id,
686
+ context.run_id,
687
+ )
688
+ stored_version = int(row["version"]) if row is not None else None
689
+ if stored_version is not None and stored_version != context.version:
690
+ raise RuntimeConcurrencyError(f"run {context.run_id} 版本冲突")
691
+
692
+ # 幂等键的唯一索引负责最终约束,这里先给出可理解的领域错误,
693
+ # 避免把 asyncpg 的 UniqueViolation 泄漏到 HTTP 层。
694
+ if context.idempotency_key:
695
+ duplicate = await conn.fetchrow(
696
+ "SELECT thread_id, run_id FROM agent_runs "
697
+ "WHERE tenant_id IS NOT DISTINCT FROM $1 "
698
+ "AND user_id IS NOT DISTINCT FROM $2 "
699
+ "AND idempotency_key=$3 FOR SHARE",
700
+ context.tenant_id,
701
+ context.user_id,
702
+ context.idempotency_key,
703
+ )
704
+ if duplicate and (
705
+ duplicate["thread_id"] != context.thread_id or duplicate["run_id"] != context.run_id
706
+ ):
707
+ raise RuntimeConcurrencyError(f"幂等键已被 run {duplicate['run_id']} 使用")
708
+
709
+ next_version = (stored_version or 0) + 1
710
+ value = context.to_dict()
711
+ command = await conn.execute(
712
+ """INSERT INTO agent_runs
713
+ (thread_id, run_id, user_id, tenant_id, parent_run_id, tags,
714
+ status, version, idempotency_key,
715
+ iteration, tool_calls_used, state, metadata, checkpoint,
716
+ pending_approval, pending_clarification, events)
717
+ VALUES (
718
+ $1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10,$11,$12::jsonb,$13::jsonb,
719
+ $14::jsonb,$15::jsonb,$16::jsonb,$17::jsonb
720
+ )
721
+ ON CONFLICT (thread_id, run_id) DO UPDATE SET
722
+ user_id=$3, tenant_id=$4, parent_run_id=$5, tags=$6::jsonb,
723
+ status=$7, version=$8, idempotency_key=$9,
724
+ iteration=$10, tool_calls_used=$11,
725
+ state=$12::jsonb, metadata=$13::jsonb, checkpoint=$14::jsonb,
726
+ pending_approval=$15::jsonb, pending_clarification=$16::jsonb,
727
+ events=$17::jsonb, updated_at=NOW()
728
+ WHERE agent_runs.version=$18""",
729
+ context.thread_id,
730
+ context.run_id,
731
+ context.user_id,
732
+ context.tenant_id,
733
+ context.parent_run_id,
734
+ json.dumps(value["tags"], ensure_ascii=False),
735
+ context.status,
736
+ next_version,
737
+ context.idempotency_key,
738
+ context.iteration,
739
+ context.tool_calls_used,
740
+ json.dumps(value["state"], ensure_ascii=False),
741
+ json.dumps(value["metadata"], ensure_ascii=False),
742
+ json.dumps(value["checkpoint"], ensure_ascii=False),
743
+ json.dumps(value["pending_approval"], ensure_ascii=False),
744
+ json.dumps(value["pending_clarification"], ensure_ascii=False),
745
+ json.dumps(value["events"], ensure_ascii=False),
746
+ stored_version or 0,
747
+ )
748
+ expected_command = "INSERT 0 1" if stored_version is None else "UPDATE 1"
749
+ if command != expected_command:
750
+ raise RuntimeConcurrencyError(f"run {context.run_id} 保存时发生并发冲突")
751
+ context.version = next_version
752
+ if context.pending_approval:
753
+ await self.save_approval(context.pending_approval)
754
+
755
+ async def load_run(self, thread_id: str, run_id: str) -> RunContext | None:
756
+ self._check()
757
+ async with self._pool.acquire() as conn:
758
+ row = await conn.fetchrow(
759
+ "SELECT * FROM agent_runs WHERE thread_id=$1 AND run_id=$2", thread_id, run_id
760
+ )
761
+ if row is None:
762
+ return None
763
+ return _context_from_dict(dict(row))
764
+
765
+ async def list_runs(self, thread_id: str) -> list[RunContext]:
766
+ self._check()
767
+ async with self._pool.acquire() as conn:
768
+ rows = await conn.fetch(
769
+ "SELECT * FROM agent_runs WHERE thread_id=$1 ORDER BY updated_at DESC", thread_id
770
+ )
771
+ return [_context_from_dict(dict(row)) for row in rows]
772
+
773
+ async def find_run_by_idempotency(
774
+ self, tenant_id: str | None, user_id: str | None, idempotency_key: str
775
+ ) -> RunContext | None:
776
+ self._check()
777
+ async with self._pool.acquire() as conn:
778
+ row = await conn.fetchrow(
779
+ "SELECT thread_id, run_id FROM agent_runs "
780
+ "WHERE tenant_id IS NOT DISTINCT FROM $1 AND user_id IS NOT DISTINCT FROM $2 "
781
+ "AND idempotency_key=$3 LIMIT 1",
782
+ tenant_id,
783
+ user_id,
784
+ idempotency_key,
785
+ )
786
+ return await self.load_run(row["thread_id"], row["run_id"]) if row else None
787
+
788
+ async def mark_stale_runs(self) -> list[RunContext]:
789
+ self._check()
790
+ async with self._pool.acquire() as conn:
791
+ rows = await conn.fetch(
792
+ "SELECT thread_id, run_id FROM agent_runs WHERE status IN ('queued', 'running')"
793
+ )
794
+ result: list[RunContext] = []
795
+ for row in rows:
796
+ context = await self.load_run(row["thread_id"], row["run_id"])
797
+ if context is None:
798
+ continue
799
+ context.status = "interrupted"
800
+ context.emit("run_interrupted", reason="服务重启")
801
+ await self.save_run(context)
802
+ result.append(context)
803
+ return result
804
+
805
+ async def save_thread_owner(self, thread_id: str, user_id: str, tenant_id: str) -> None:
806
+ self._check()
807
+ async with self._pool.acquire() as conn:
808
+ await conn.execute(
809
+ """INSERT INTO agent_threads(thread_id,user_id,tenant_id) VALUES ($1,$2,$3)
810
+ ON CONFLICT(thread_id) DO UPDATE SET user_id=agent_threads.user_id,
811
+ tenant_id=agent_threads.tenant_id""",
812
+ thread_id,
813
+ user_id,
814
+ tenant_id,
815
+ )
816
+ owner = await conn.fetchrow(
817
+ "SELECT user_id, tenant_id FROM agent_threads WHERE thread_id=$1", thread_id
818
+ )
819
+ if owner["user_id"] != user_id or owner["tenant_id"] != tenant_id:
820
+ raise RuntimeConcurrencyError("thread 归属冲突")
821
+
822
+ async def get_thread_owner(self, thread_id: str) -> tuple[str, str] | None:
823
+ self._check()
824
+ async with self._pool.acquire() as conn:
825
+ row = await conn.fetchrow(
826
+ "SELECT user_id, tenant_id FROM agent_threads WHERE thread_id=$1", thread_id
827
+ )
828
+ return (row["user_id"], row["tenant_id"]) if row else None
829
+
830
+ async def save_approval(self, approval: ApprovalRecord) -> None:
831
+ self._check()
832
+ async with self._pool.acquire() as conn:
833
+ await conn.execute(
834
+ """INSERT INTO agent_approvals (
835
+ approval_id,thread_id,run_id,user_id,tenant_id,action,
836
+ arguments,status,reason,created_at,expires_at
837
+ )
838
+ VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,$11)
839
+ ON CONFLICT (approval_id) DO UPDATE SET
840
+ status=$8, reason=$9, expires_at=$11
841
+ WHERE agent_approvals.status='pending'""",
842
+ approval.approval_id,
843
+ approval.thread_id,
844
+ approval.run_id,
845
+ approval.user_id,
846
+ approval.tenant_id,
847
+ approval.action,
848
+ json.dumps(approval.arguments, ensure_ascii=False),
849
+ approval.status,
850
+ approval.reason,
851
+ approval.created_at,
852
+ approval.expires_at,
853
+ )
854
+
855
+ async def transition_approval(
856
+ self,
857
+ approval: ApprovalRecord,
858
+ *,
859
+ expected_status: str = "pending",
860
+ ) -> bool:
861
+ self._check()
862
+ async with self._pool.acquire() as conn:
863
+ command = await conn.execute(
864
+ """UPDATE agent_approvals
865
+ SET status=$2, reason=$3, expires_at=$4
866
+ WHERE approval_id=$1 AND status=$5""",
867
+ approval.approval_id,
868
+ approval.status,
869
+ approval.reason,
870
+ approval.expires_at,
871
+ expected_status,
872
+ )
873
+ return bool(command == "UPDATE 1")
874
+
875
+ async def load_approval(self, approval_id: str) -> ApprovalRecord | None:
876
+ self._check()
877
+ async with self._pool.acquire() as conn:
878
+ row = await conn.fetchrow("SELECT * FROM agent_approvals WHERE approval_id=$1", approval_id)
879
+ return _approval_from_dict(dict(row)) if row else None
880
+
881
+
882
+ # ──────────────────────────────────────────────
883
+ # 5. 创建运行时状态存储
884
+ # ──────────────────────────────────────────────
885
+ def create_runtime_store(
886
+ env: str = "development",
887
+ *,
888
+ sqlite_path: str = "./sessions.db",
889
+ postgres_url: str | None = None,
890
+ ) -> RuntimeStore:
891
+ """按环境选择运行时状态存储。"""
892
+ normalized = env.lower()
893
+ if normalized in {"test", "testing"}:
894
+ return MemoryRuntimeStore()
895
+ if normalized in {"production", "prod"}:
896
+ if not postgres_url:
897
+ raise ValueError("生产环境需要配置 PostgreSQL 连接地址")
898
+ return PostgresRuntimeStore(postgres_url)
899
+ return SqliteRuntimeStore(sqlite_path)