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,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)
|