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
agent_core/hitl/core.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""通用人机协同审批队列。
|
|
2
|
+
|
|
3
|
+
Core 只负责审批状态机和异步等待,不负责 HTTP 路由、认证或具体业务动作。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import UTC, datetime, timedelta
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from agent_core.access import AccessContext
|
|
17
|
+
from agent_core.middleware.base import (
|
|
18
|
+
Middleware,
|
|
19
|
+
MiddlewareAction,
|
|
20
|
+
MiddlewareContext,
|
|
21
|
+
MiddlewareResult,
|
|
22
|
+
)
|
|
23
|
+
from agent_core.ports import ApprovalStore
|
|
24
|
+
from agent_core.protocol.runtime import ApprovalRecord
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApprovalStatus(Enum):
|
|
30
|
+
PENDING = "pending"
|
|
31
|
+
APPROVED = "approved"
|
|
32
|
+
REJECTED = "rejected"
|
|
33
|
+
TIMEOUT = "timeout"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ApprovalRequest:
|
|
38
|
+
"""一个等待外部决定的动作请求。"""
|
|
39
|
+
|
|
40
|
+
request_id: str
|
|
41
|
+
thread_id: str
|
|
42
|
+
tool_name: str
|
|
43
|
+
tool_args: dict[str, Any]
|
|
44
|
+
run_id: str = ""
|
|
45
|
+
user_id: str | None = None
|
|
46
|
+
tenant_id: str | None = None
|
|
47
|
+
status: ApprovalStatus = ApprovalStatus.PENDING
|
|
48
|
+
reason: str | None = None
|
|
49
|
+
expires_at: str | None = None
|
|
50
|
+
_event: asyncio.Event = field(default_factory=asyncio.Event, repr=False)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def approval_id(self) -> str:
|
|
54
|
+
return self.request_id
|
|
55
|
+
|
|
56
|
+
def to_record(self) -> ApprovalRecord:
|
|
57
|
+
status_by_request: dict[
|
|
58
|
+
ApprovalStatus,
|
|
59
|
+
Literal["pending", "approved", "rejected", "expired"],
|
|
60
|
+
] = {
|
|
61
|
+
ApprovalStatus.PENDING: "pending",
|
|
62
|
+
ApprovalStatus.APPROVED: "approved",
|
|
63
|
+
ApprovalStatus.REJECTED: "rejected",
|
|
64
|
+
ApprovalStatus.TIMEOUT: "expired",
|
|
65
|
+
}
|
|
66
|
+
return ApprovalRecord(
|
|
67
|
+
approval_id=self.request_id,
|
|
68
|
+
thread_id=self.thread_id,
|
|
69
|
+
run_id=self.run_id,
|
|
70
|
+
action=self.tool_name,
|
|
71
|
+
arguments=dict(self.tool_args),
|
|
72
|
+
user_id=self.user_id,
|
|
73
|
+
tenant_id=self.tenant_id,
|
|
74
|
+
status=status_by_request[self.status],
|
|
75
|
+
reason=self.reason,
|
|
76
|
+
expires_at=self.expires_at,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"request_id": self.request_id,
|
|
82
|
+
"thread_id": self.thread_id,
|
|
83
|
+
"run_id": self.run_id,
|
|
84
|
+
"user_id": self.user_id,
|
|
85
|
+
"tenant_id": self.tenant_id,
|
|
86
|
+
"tool_name": self.tool_name,
|
|
87
|
+
"tool_args": self.tool_args,
|
|
88
|
+
"status": self.status.value,
|
|
89
|
+
"reason": self.reason,
|
|
90
|
+
"expires_at": self.expires_at,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_record(cls, record: ApprovalRecord) -> ApprovalRequest:
|
|
95
|
+
"""从持久化记录恢复审批请求。"""
|
|
96
|
+
request = cls(
|
|
97
|
+
request_id=record.approval_id,
|
|
98
|
+
thread_id=record.thread_id,
|
|
99
|
+
run_id=record.run_id,
|
|
100
|
+
tool_name=record.action,
|
|
101
|
+
tool_args=dict(record.arguments),
|
|
102
|
+
user_id=record.user_id,
|
|
103
|
+
tenant_id=record.tenant_id,
|
|
104
|
+
status={
|
|
105
|
+
"pending": ApprovalStatus.PENDING,
|
|
106
|
+
"approved": ApprovalStatus.APPROVED,
|
|
107
|
+
"rejected": ApprovalStatus.REJECTED,
|
|
108
|
+
"expired": ApprovalStatus.TIMEOUT,
|
|
109
|
+
}.get(record.status, ApprovalStatus.PENDING),
|
|
110
|
+
reason=record.reason,
|
|
111
|
+
expires_at=record.expires_at,
|
|
112
|
+
)
|
|
113
|
+
if request.status != ApprovalStatus.PENDING:
|
|
114
|
+
request._event.set()
|
|
115
|
+
return request
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class ApprovalQueue:
|
|
119
|
+
"""进程内等待器,可选地把状态写入 ``ApprovalStore``。"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
approval_store: ApprovalStore | None = None,
|
|
124
|
+
*,
|
|
125
|
+
runtime_store: ApprovalStore | None = None,
|
|
126
|
+
poll_interval_seconds: float = 0.5,
|
|
127
|
+
require_access: bool = False,
|
|
128
|
+
) -> None:
|
|
129
|
+
# runtime_store 是旧 Vanilla 命名,保留它避免升级时破坏调用方。
|
|
130
|
+
if approval_store is not None and runtime_store is not None:
|
|
131
|
+
raise ValueError("approval_store 和 runtime_store 只能配置一个")
|
|
132
|
+
self._requests: dict[str, ApprovalRequest] = {}
|
|
133
|
+
self._approval_store = approval_store or runtime_store
|
|
134
|
+
self._require_access = require_access
|
|
135
|
+
if poll_interval_seconds <= 0:
|
|
136
|
+
raise ValueError("poll_interval_seconds 必须大于 0")
|
|
137
|
+
self._poll_interval = poll_interval_seconds
|
|
138
|
+
|
|
139
|
+
def create_request(
|
|
140
|
+
self,
|
|
141
|
+
thread_id: str,
|
|
142
|
+
tool_name: str,
|
|
143
|
+
tool_args: dict[str, Any],
|
|
144
|
+
*,
|
|
145
|
+
run_id: str = "",
|
|
146
|
+
user_id: str | None = None,
|
|
147
|
+
tenant_id: str | None = None,
|
|
148
|
+
timeout_seconds: float | None = None,
|
|
149
|
+
) -> ApprovalRequest:
|
|
150
|
+
"""创建仅驻留当前进程的审批请求。
|
|
151
|
+
|
|
152
|
+
配置持久化存储后必须使用 ``create_request_async``,确保方法返回时初始审批
|
|
153
|
+
已写入存储,避免进程退出或后台任务失败造成记录丢失。
|
|
154
|
+
"""
|
|
155
|
+
self._require_owner(user_id, tenant_id)
|
|
156
|
+
if self._approval_store is not None:
|
|
157
|
+
raise RuntimeError("配置审批存储后必须使用 await create_request_async()")
|
|
158
|
+
request = ApprovalRequest(
|
|
159
|
+
request_id=str(uuid.uuid4()),
|
|
160
|
+
thread_id=thread_id,
|
|
161
|
+
tool_name=tool_name,
|
|
162
|
+
tool_args=dict(tool_args),
|
|
163
|
+
run_id=run_id,
|
|
164
|
+
user_id=user_id,
|
|
165
|
+
tenant_id=tenant_id,
|
|
166
|
+
expires_at=self._expires_at(timeout_seconds),
|
|
167
|
+
)
|
|
168
|
+
self._requests[request.request_id] = request
|
|
169
|
+
return request
|
|
170
|
+
|
|
171
|
+
async def create_request_async(
|
|
172
|
+
self,
|
|
173
|
+
thread_id: str,
|
|
174
|
+
tool_name: str,
|
|
175
|
+
tool_args: dict[str, Any],
|
|
176
|
+
*,
|
|
177
|
+
run_id: str = "",
|
|
178
|
+
user_id: str | None = None,
|
|
179
|
+
tenant_id: str | None = None,
|
|
180
|
+
timeout_seconds: float | None = None,
|
|
181
|
+
) -> ApprovalRequest:
|
|
182
|
+
"""创建请求并等待持久化完成,适合生产执行路径。"""
|
|
183
|
+
self._require_owner(user_id, tenant_id)
|
|
184
|
+
request = ApprovalRequest(
|
|
185
|
+
request_id=str(uuid.uuid4()),
|
|
186
|
+
thread_id=thread_id,
|
|
187
|
+
tool_name=tool_name,
|
|
188
|
+
tool_args=dict(tool_args),
|
|
189
|
+
run_id=run_id,
|
|
190
|
+
user_id=user_id,
|
|
191
|
+
tenant_id=tenant_id,
|
|
192
|
+
expires_at=self._expires_at(timeout_seconds),
|
|
193
|
+
)
|
|
194
|
+
self._requests[request.request_id] = request
|
|
195
|
+
if self._approval_store:
|
|
196
|
+
await self._approval_store.save_approval(request.to_record())
|
|
197
|
+
return request
|
|
198
|
+
|
|
199
|
+
async def wait_for_decision(
|
|
200
|
+
self,
|
|
201
|
+
request: ApprovalRequest,
|
|
202
|
+
timeout_seconds: float = 300.0,
|
|
203
|
+
) -> ApprovalStatus:
|
|
204
|
+
if timeout_seconds <= 0:
|
|
205
|
+
raise ValueError("timeout_seconds 必须大于 0")
|
|
206
|
+
loop = asyncio.get_running_loop()
|
|
207
|
+
if request.expires_at is None:
|
|
208
|
+
request.expires_at = self._expires_at(timeout_seconds)
|
|
209
|
+
remaining_lifetime = self._remaining_seconds(request.expires_at)
|
|
210
|
+
deadline = loop.time() + min(timeout_seconds, max(0.0, remaining_lifetime))
|
|
211
|
+
if self._approval_store is not None:
|
|
212
|
+
# pending -> pending 也是条件更新,用于持久化 deadline,不能覆盖已经完成的决定。
|
|
213
|
+
updated = await self._transition(request.to_record())
|
|
214
|
+
if not updated:
|
|
215
|
+
stored = await self._approval_store.load_approval(request.request_id)
|
|
216
|
+
if stored is not None:
|
|
217
|
+
self._apply_record(request, stored)
|
|
218
|
+
try:
|
|
219
|
+
while request.status == ApprovalStatus.PENDING:
|
|
220
|
+
if self._approval_store is not None:
|
|
221
|
+
stored = await self._approval_store.load_approval(request.request_id)
|
|
222
|
+
if stored is not None and stored.status != "pending":
|
|
223
|
+
restored = ApprovalRequest.from_record(stored)
|
|
224
|
+
request.status = restored.status
|
|
225
|
+
request.reason = restored.reason
|
|
226
|
+
request._event.set()
|
|
227
|
+
break
|
|
228
|
+
remaining = deadline - loop.time()
|
|
229
|
+
if remaining <= 0:
|
|
230
|
+
break
|
|
231
|
+
try:
|
|
232
|
+
await asyncio.wait_for(
|
|
233
|
+
request._event.wait(),
|
|
234
|
+
timeout=min(self._poll_interval, remaining),
|
|
235
|
+
)
|
|
236
|
+
except TimeoutError:
|
|
237
|
+
continue
|
|
238
|
+
if request.status != ApprovalStatus.PENDING:
|
|
239
|
+
return request.status
|
|
240
|
+
if self._approval_store is None:
|
|
241
|
+
request.status = ApprovalStatus.TIMEOUT
|
|
242
|
+
return request.status
|
|
243
|
+
|
|
244
|
+
expired = request.to_record()
|
|
245
|
+
expired.status = "expired"
|
|
246
|
+
if await self._transition(expired):
|
|
247
|
+
request.status = ApprovalStatus.TIMEOUT
|
|
248
|
+
request._event.set()
|
|
249
|
+
return request.status
|
|
250
|
+
|
|
251
|
+
# 决定可能刚好在 deadline 到达时提交;以持久化终态为准。
|
|
252
|
+
stored = await self._approval_store.load_approval(request.request_id)
|
|
253
|
+
if stored is not None:
|
|
254
|
+
self._apply_record(request, stored)
|
|
255
|
+
return request.status
|
|
256
|
+
# 记录不存在(幻影请求):不可能再有决定提交,按超时收口而不是返回 PENDING。
|
|
257
|
+
request.status = ApprovalStatus.TIMEOUT
|
|
258
|
+
request._event.set()
|
|
259
|
+
return request.status
|
|
260
|
+
finally:
|
|
261
|
+
self._requests.pop(request.request_id, None)
|
|
262
|
+
|
|
263
|
+
async def approve(
|
|
264
|
+
self,
|
|
265
|
+
request_id: str,
|
|
266
|
+
*,
|
|
267
|
+
access: AccessContext | None = None,
|
|
268
|
+
) -> bool:
|
|
269
|
+
request = await self.load_request(request_id, access=access)
|
|
270
|
+
if request is None or request.status != ApprovalStatus.PENDING:
|
|
271
|
+
return False
|
|
272
|
+
if self._approval_store:
|
|
273
|
+
approved = request.to_record()
|
|
274
|
+
approved.status = "approved"
|
|
275
|
+
if not await self._transition(approved):
|
|
276
|
+
return False
|
|
277
|
+
request.status = ApprovalStatus.APPROVED
|
|
278
|
+
request._event.set()
|
|
279
|
+
return True
|
|
280
|
+
|
|
281
|
+
async def reject(
|
|
282
|
+
self,
|
|
283
|
+
request_id: str,
|
|
284
|
+
reason: str = "用户拒绝",
|
|
285
|
+
*,
|
|
286
|
+
access: AccessContext | None = None,
|
|
287
|
+
) -> bool:
|
|
288
|
+
request = await self.load_request(request_id, access=access)
|
|
289
|
+
if request is None or request.status != ApprovalStatus.PENDING:
|
|
290
|
+
return False
|
|
291
|
+
if self._approval_store:
|
|
292
|
+
rejected = request.to_record()
|
|
293
|
+
rejected.status = "rejected"
|
|
294
|
+
rejected.reason = reason
|
|
295
|
+
if not await self._transition(rejected):
|
|
296
|
+
return False
|
|
297
|
+
request.status = ApprovalStatus.REJECTED
|
|
298
|
+
request.reason = reason
|
|
299
|
+
request._event.set()
|
|
300
|
+
return True
|
|
301
|
+
|
|
302
|
+
def list_pending(
|
|
303
|
+
self,
|
|
304
|
+
*,
|
|
305
|
+
access: AccessContext | None = None,
|
|
306
|
+
) -> list[dict[str, Any]]:
|
|
307
|
+
if self._require_access and access is None:
|
|
308
|
+
raise PermissionError("该 ApprovalQueue 要求提供 AccessContext")
|
|
309
|
+
return [
|
|
310
|
+
request.to_dict()
|
|
311
|
+
for request in self._requests.values()
|
|
312
|
+
if request.status == ApprovalStatus.PENDING
|
|
313
|
+
and not (request.expires_at and self._is_past(request.expires_at))
|
|
314
|
+
and (access is None or access.can_access(request.user_id, request.tenant_id))
|
|
315
|
+
]
|
|
316
|
+
|
|
317
|
+
def get_request(
|
|
318
|
+
self,
|
|
319
|
+
request_id: str,
|
|
320
|
+
*,
|
|
321
|
+
access: AccessContext | None = None,
|
|
322
|
+
) -> ApprovalRequest | None:
|
|
323
|
+
request = self._requests.get(request_id)
|
|
324
|
+
if request is not None:
|
|
325
|
+
self._check_access(request, access)
|
|
326
|
+
elif self._require_access and access is None:
|
|
327
|
+
raise PermissionError("该 ApprovalQueue 要求提供 AccessContext")
|
|
328
|
+
return request
|
|
329
|
+
|
|
330
|
+
async def load_request(
|
|
331
|
+
self,
|
|
332
|
+
request_id: str,
|
|
333
|
+
*,
|
|
334
|
+
access: AccessContext | None = None,
|
|
335
|
+
) -> ApprovalRequest | None:
|
|
336
|
+
"""优先读取进程内请求,否则从持久化记录恢复。"""
|
|
337
|
+
request = self._requests.get(request_id)
|
|
338
|
+
if request is not None:
|
|
339
|
+
await self._expire_if_due(request)
|
|
340
|
+
self._check_access(request, access)
|
|
341
|
+
return request
|
|
342
|
+
if self._require_access and access is None:
|
|
343
|
+
raise PermissionError("该 ApprovalQueue 要求提供 AccessContext")
|
|
344
|
+
if self._approval_store is None:
|
|
345
|
+
return None
|
|
346
|
+
record = await self._approval_store.load_approval(request_id)
|
|
347
|
+
if record is None:
|
|
348
|
+
return None
|
|
349
|
+
request = ApprovalRequest.from_record(record)
|
|
350
|
+
await self._expire_if_due(request)
|
|
351
|
+
self._check_access(request, access)
|
|
352
|
+
self._requests[request_id] = request
|
|
353
|
+
return request
|
|
354
|
+
|
|
355
|
+
async def _expire_if_due(self, request: ApprovalRequest) -> None:
|
|
356
|
+
"""对已过期的 pending 请求执行过期收口;配置存储时以 CAS 结果为准。"""
|
|
357
|
+
if request.status is not ApprovalStatus.PENDING or not request.expires_at:
|
|
358
|
+
return
|
|
359
|
+
if not self._is_past(request.expires_at):
|
|
360
|
+
return
|
|
361
|
+
if self._approval_store is None:
|
|
362
|
+
request.status = ApprovalStatus.TIMEOUT
|
|
363
|
+
request._event.set()
|
|
364
|
+
return
|
|
365
|
+
expired = request.to_record()
|
|
366
|
+
expired.status = "expired"
|
|
367
|
+
if await self._transition(expired):
|
|
368
|
+
request.status = ApprovalStatus.TIMEOUT
|
|
369
|
+
request._event.set()
|
|
370
|
+
else:
|
|
371
|
+
latest = await self._approval_store.load_approval(request.request_id)
|
|
372
|
+
if latest is not None:
|
|
373
|
+
self._apply_record(request, latest)
|
|
374
|
+
|
|
375
|
+
async def _transition(self, record: ApprovalRecord) -> bool:
|
|
376
|
+
"""执行审批 CAS;兼容尚未升级条件更新接口的外部 Store。
|
|
377
|
+
|
|
378
|
+
内置 Memory/SQLite/PostgreSQL Store 的 ``transition_approval`` 是原子条件更新。
|
|
379
|
+
未实现该接口的外部 Store 会退化为 load→check→save,跨进程并发下存在
|
|
380
|
+
last-writer-wins 窗口;生产环境应让外部 Store 实现 ``transition_approval``。
|
|
381
|
+
"""
|
|
382
|
+
if self._approval_store is None:
|
|
383
|
+
return False
|
|
384
|
+
transition = getattr(self._approval_store, "transition_approval", None)
|
|
385
|
+
if transition is not None:
|
|
386
|
+
return bool(await transition(record, expected_status="pending"))
|
|
387
|
+
current = await self._approval_store.load_approval(record.approval_id)
|
|
388
|
+
if current is None or current.status != "pending":
|
|
389
|
+
return False
|
|
390
|
+
await self._approval_store.save_approval(record)
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
@staticmethod
|
|
394
|
+
def _apply_record(request: ApprovalRequest, record: ApprovalRecord) -> None:
|
|
395
|
+
restored = ApprovalRequest.from_record(record)
|
|
396
|
+
request.status = restored.status
|
|
397
|
+
request.reason = restored.reason
|
|
398
|
+
request.expires_at = restored.expires_at
|
|
399
|
+
if request.status != ApprovalStatus.PENDING:
|
|
400
|
+
request._event.set()
|
|
401
|
+
|
|
402
|
+
@staticmethod
|
|
403
|
+
def _record_expired(record: ApprovalRecord) -> bool:
|
|
404
|
+
return ApprovalQueue._is_past(record.expires_at)
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def _is_past(expires_at: str | None) -> bool:
|
|
408
|
+
if not expires_at:
|
|
409
|
+
return False
|
|
410
|
+
expires = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
411
|
+
if expires.tzinfo is None:
|
|
412
|
+
expires = expires.replace(tzinfo=UTC)
|
|
413
|
+
return expires <= datetime.now(UTC)
|
|
414
|
+
|
|
415
|
+
@staticmethod
|
|
416
|
+
def _expires_at(timeout_seconds: float | None) -> str | None:
|
|
417
|
+
if timeout_seconds is None:
|
|
418
|
+
return None
|
|
419
|
+
if timeout_seconds <= 0:
|
|
420
|
+
raise ValueError("timeout_seconds 必须大于 0")
|
|
421
|
+
return (datetime.now(UTC) + timedelta(seconds=timeout_seconds)).isoformat()
|
|
422
|
+
|
|
423
|
+
@staticmethod
|
|
424
|
+
def _remaining_seconds(expires_at: str | None) -> float:
|
|
425
|
+
if expires_at is None:
|
|
426
|
+
return float("inf")
|
|
427
|
+
parsed = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
428
|
+
if parsed.tzinfo is None:
|
|
429
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
430
|
+
return (parsed - datetime.now(UTC)).total_seconds()
|
|
431
|
+
|
|
432
|
+
def _check_access(
|
|
433
|
+
self,
|
|
434
|
+
request: ApprovalRequest,
|
|
435
|
+
access: AccessContext | None,
|
|
436
|
+
) -> None:
|
|
437
|
+
if self._require_access and access is None:
|
|
438
|
+
raise PermissionError("该 ApprovalQueue 要求提供 AccessContext")
|
|
439
|
+
if self._require_access and (request.user_id is None or request.tenant_id is None):
|
|
440
|
+
raise PermissionError("该审批请求尚未绑定用户和租户")
|
|
441
|
+
if access is not None and not access.can_access(request.user_id, request.tenant_id):
|
|
442
|
+
raise PermissionError("无权处理该审批请求")
|
|
443
|
+
|
|
444
|
+
def _require_owner(
|
|
445
|
+
self,
|
|
446
|
+
user_id: str | None,
|
|
447
|
+
tenant_id: str | None,
|
|
448
|
+
) -> None:
|
|
449
|
+
if self._require_access and (user_id is None or tenant_id is None):
|
|
450
|
+
raise PermissionError("严格访问模式要求审批绑定 user_id 和 tenant_id")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class HumanInTheLoopMiddleware(Middleware):
|
|
454
|
+
"""在工具执行前暂停,等待审批队列返回决定。"""
|
|
455
|
+
|
|
456
|
+
def __init__(
|
|
457
|
+
self,
|
|
458
|
+
queue: ApprovalQueue | None = None,
|
|
459
|
+
approval_needed: list[str] | None = None,
|
|
460
|
+
thread_id: str = "default",
|
|
461
|
+
auto_approve: bool = False,
|
|
462
|
+
approval_callback: Any | None = None,
|
|
463
|
+
timeout_seconds: float = 300.0,
|
|
464
|
+
) -> None:
|
|
465
|
+
self._queue = queue or ApprovalQueue()
|
|
466
|
+
self._needed = set(approval_needed or ())
|
|
467
|
+
self._thread = thread_id
|
|
468
|
+
self._auto = auto_approve
|
|
469
|
+
self._callback = approval_callback
|
|
470
|
+
self._timeout = timeout_seconds
|
|
471
|
+
|
|
472
|
+
async def before_tool(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
473
|
+
if ctx.tool_name not in self._needed or self._auto:
|
|
474
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
475
|
+
if self._callback is not None:
|
|
476
|
+
try:
|
|
477
|
+
approved = await asyncio.wait_for(
|
|
478
|
+
self._callback(ctx.tool_name, ctx.tool_args),
|
|
479
|
+
timeout=self._timeout,
|
|
480
|
+
)
|
|
481
|
+
except TimeoutError:
|
|
482
|
+
return MiddlewareResult(
|
|
483
|
+
action=MiddlewareAction.STOP,
|
|
484
|
+
data={"reason": "approval_timeout", "tool": ctx.tool_name},
|
|
485
|
+
)
|
|
486
|
+
if approved:
|
|
487
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
488
|
+
return MiddlewareResult(
|
|
489
|
+
action=MiddlewareAction.STOP,
|
|
490
|
+
data={"reason": "approval_denied", "tool": ctx.tool_name},
|
|
491
|
+
)
|
|
492
|
+
request = await self._queue.create_request_async(
|
|
493
|
+
thread_id=str(ctx.metadata.get("thread_id") or self._thread),
|
|
494
|
+
tool_name=ctx.tool_name or "unknown",
|
|
495
|
+
tool_args=dict(ctx.tool_args),
|
|
496
|
+
run_id=str(ctx.metadata.get("run_id") or ""),
|
|
497
|
+
user_id=ctx.metadata.get("user_id"),
|
|
498
|
+
tenant_id=ctx.metadata.get("tenant_id"),
|
|
499
|
+
timeout_seconds=self._timeout,
|
|
500
|
+
)
|
|
501
|
+
ctx.metadata["approval_request_id"] = request.request_id
|
|
502
|
+
status = await self._queue.wait_for_decision(request, timeout_seconds=self._timeout)
|
|
503
|
+
if status == ApprovalStatus.APPROVED:
|
|
504
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
505
|
+
reason = request.reason or status.value
|
|
506
|
+
return MiddlewareResult(
|
|
507
|
+
action=MiddlewareAction.STOP,
|
|
508
|
+
data={"reason": reason, "tool": ctx.tool_name, "status": status.value},
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
__all__ = [
|
|
513
|
+
"ApprovalQueue",
|
|
514
|
+
"ApprovalRequest",
|
|
515
|
+
"ApprovalStatus",
|
|
516
|
+
"HumanInTheLoopMiddleware",
|
|
517
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""MCP 客户端协议、stdio 适配器和通用错误。"""
|
|
2
|
+
|
|
3
|
+
from agent_core.mcp.client import JsonObject, McpToolCaller, McpToolClient
|
|
4
|
+
from agent_core.mcp.errors import McpConnectionError, McpError, McpTimeoutError, McpToolError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"JsonObject",
|
|
8
|
+
"McpConnectionError",
|
|
9
|
+
"McpError",
|
|
10
|
+
"McpTimeoutError",
|
|
11
|
+
"McpToolCaller",
|
|
12
|
+
"McpToolClient",
|
|
13
|
+
"McpToolError",
|
|
14
|
+
]
|