zero-agent-core-multi-agent 0.1.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_multi_agent/__init__.py +68 -0
- agent_core_multi_agent/adapters.py +139 -0
- agent_core_multi_agent/errors.py +39 -0
- agent_core_multi_agent/protocols.py +49 -0
- agent_core_multi_agent/py.typed +1 -0
- agent_core_multi_agent/registry.py +133 -0
- agent_core_multi_agent/routing.py +124 -0
- agent_core_multi_agent/storage.py +110 -0
- agent_core_multi_agent/supervisor.py +554 -0
- agent_core_multi_agent/types.py +450 -0
- zero_agent_core_multi_agent-0.1.0.dist-info/METADATA +72 -0
- zero_agent_core_multi_agent-0.1.0.dist-info/RECORD +14 -0
- zero_agent_core_multi_agent-0.1.0.dist-info/WHEEL +4 -0
- zero_agent_core_multi_agent-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""多 Agent Supervisor、handoff 循环和恢复控制。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
|
|
12
|
+
from agent_core import (
|
|
13
|
+
AccessContext,
|
|
14
|
+
CallbackHandler,
|
|
15
|
+
CallbackManager,
|
|
16
|
+
EventSink,
|
|
17
|
+
RunEvent,
|
|
18
|
+
RunLeaseStore,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from agent_core_multi_agent.errors import (
|
|
22
|
+
CoordinationBudgetExceededError,
|
|
23
|
+
CoordinationBusyError,
|
|
24
|
+
CoordinationLeaseLostError,
|
|
25
|
+
)
|
|
26
|
+
from agent_core_multi_agent.protocols import AgentRouter, CoordinationStore
|
|
27
|
+
from agent_core_multi_agent.registry import AgentRegistry
|
|
28
|
+
from agent_core_multi_agent.routing import CapabilityRouter
|
|
29
|
+
from agent_core_multi_agent.storage import WorkflowCoordinationStore
|
|
30
|
+
from agent_core_multi_agent.types import (
|
|
31
|
+
AgentResult,
|
|
32
|
+
AgentTask,
|
|
33
|
+
CoordinationExecution,
|
|
34
|
+
CoordinationPolicy,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SupervisorAgent:
|
|
41
|
+
"""路由任务、执行子 Agent、处理 handoff,并在边界保存快照。"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
registry: AgentRegistry,
|
|
46
|
+
*,
|
|
47
|
+
router: AgentRouter | None = None,
|
|
48
|
+
store: CoordinationStore | None = None,
|
|
49
|
+
policy: CoordinationPolicy | None = None,
|
|
50
|
+
event_sink: EventSink | None = None,
|
|
51
|
+
callbacks: Sequence[CallbackHandler] | None = None,
|
|
52
|
+
callback_manager: CallbackManager | None = None,
|
|
53
|
+
lease_store: RunLeaseStore | None = None,
|
|
54
|
+
worker_id: str | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
if callbacks is not None and callback_manager is not None:
|
|
57
|
+
raise ValueError("callbacks 和 callback_manager 不能同时传入")
|
|
58
|
+
self._registry = registry
|
|
59
|
+
self._router = router or CapabilityRouter()
|
|
60
|
+
self._policy = policy or CoordinationPolicy()
|
|
61
|
+
self._store = store or WorkflowCoordinationStore(require_access=self._policy.require_access)
|
|
62
|
+
self._event_sink = event_sink
|
|
63
|
+
self._callback_manager = callback_manager or CallbackManager(callbacks)
|
|
64
|
+
self._lease_store = lease_store
|
|
65
|
+
self._worker_id = worker_id or f"multi-agent-{uuid.uuid4()}"
|
|
66
|
+
self._create_lock = asyncio.Lock()
|
|
67
|
+
self._active_invocations: dict[str, asyncio.Task[AgentResult]] = {}
|
|
68
|
+
|
|
69
|
+
async def run(self, task: AgentTask) -> CoordinationExecution:
|
|
70
|
+
"""创建并同步执行一个协调实例;相同 task_id 会返回已有实例。"""
|
|
71
|
+
self._require_access(task.access)
|
|
72
|
+
async with self._create_lock:
|
|
73
|
+
existing = await self._store.load(task.task_id, access=task.access)
|
|
74
|
+
if existing is not None:
|
|
75
|
+
return existing
|
|
76
|
+
bound_task = task.bind_execution(task.task_id)
|
|
77
|
+
execution = CoordinationExecution(
|
|
78
|
+
execution_id=task.task_id,
|
|
79
|
+
root_task=bound_task,
|
|
80
|
+
current_task=bound_task,
|
|
81
|
+
)
|
|
82
|
+
event = execution.emit("coordination_queued", task_id=bound_task.task_id)
|
|
83
|
+
await self._save_and_publish(execution, event)
|
|
84
|
+
return await self._execute_with_lease(execution)
|
|
85
|
+
|
|
86
|
+
async def resume(
|
|
87
|
+
self,
|
|
88
|
+
execution_id: str,
|
|
89
|
+
*,
|
|
90
|
+
access: AccessContext | None = None,
|
|
91
|
+
) -> CoordinationExecution:
|
|
92
|
+
"""从最后保存的 Agent 边界继续执行。"""
|
|
93
|
+
execution = await self.get(execution_id, access=access)
|
|
94
|
+
if execution.status in {"completed", "cancelled"}:
|
|
95
|
+
return execution
|
|
96
|
+
if execution.current_task is None:
|
|
97
|
+
return await self._fail(execution, "协调实例没有可恢复的 current_task")
|
|
98
|
+
return await self._execute_with_lease(execution, resume=True)
|
|
99
|
+
|
|
100
|
+
async def get(
|
|
101
|
+
self,
|
|
102
|
+
execution_id: str,
|
|
103
|
+
*,
|
|
104
|
+
access: AccessContext | None = None,
|
|
105
|
+
) -> CoordinationExecution:
|
|
106
|
+
execution = await self._store.load(execution_id, access=access)
|
|
107
|
+
if execution is None:
|
|
108
|
+
raise KeyError(f"协调实例不存在:{execution_id}")
|
|
109
|
+
self._check_execution_access(execution, access)
|
|
110
|
+
return execution
|
|
111
|
+
|
|
112
|
+
async def cancel(
|
|
113
|
+
self,
|
|
114
|
+
execution_id: str,
|
|
115
|
+
*,
|
|
116
|
+
access: AccessContext | None = None,
|
|
117
|
+
) -> CoordinationExecution:
|
|
118
|
+
"""取消协调实例,并取消当前进程内正在等待的子 Agent。"""
|
|
119
|
+
execution = await self.get(execution_id, access=access)
|
|
120
|
+
if execution.status in {"completed", "failed", "cancelled"}:
|
|
121
|
+
return execution
|
|
122
|
+
execution.status = "cancelled"
|
|
123
|
+
event = execution.emit("coordination_cancelled", agent_id=execution.current_agent_id)
|
|
124
|
+
await self._save_and_publish(execution, event)
|
|
125
|
+
active = self._active_invocations.get(execution_id)
|
|
126
|
+
if active is not None:
|
|
127
|
+
active.cancel()
|
|
128
|
+
return execution
|
|
129
|
+
|
|
130
|
+
async def run_sequence(self, tasks: Sequence[AgentTask]) -> list[CoordinationExecution]:
|
|
131
|
+
"""按输入顺序执行多个独立协调任务。"""
|
|
132
|
+
return [await self.run(task) for task in tasks]
|
|
133
|
+
|
|
134
|
+
async def run_parallel(self, tasks: Sequence[AgentTask]) -> list[CoordinationExecution]:
|
|
135
|
+
"""在全局并行预算内执行多个独立协调任务,并保持结果顺序。"""
|
|
136
|
+
semaphore = asyncio.Semaphore(self._policy.max_parallelism)
|
|
137
|
+
|
|
138
|
+
async def execute(task: AgentTask) -> CoordinationExecution:
|
|
139
|
+
async with semaphore:
|
|
140
|
+
return await self.run(task)
|
|
141
|
+
|
|
142
|
+
return list(await asyncio.gather(*(execute(task) for task in tasks)))
|
|
143
|
+
|
|
144
|
+
async def _execute_with_lease(
|
|
145
|
+
self,
|
|
146
|
+
execution: CoordinationExecution,
|
|
147
|
+
*,
|
|
148
|
+
resume: bool = False,
|
|
149
|
+
) -> CoordinationExecution:
|
|
150
|
+
lease_lost = asyncio.Event()
|
|
151
|
+
heartbeat_stop = asyncio.Event()
|
|
152
|
+
heartbeat: asyncio.Task[None] | None = None
|
|
153
|
+
if self._lease_store is not None:
|
|
154
|
+
claimed = await self._lease_store.claim(
|
|
155
|
+
execution.root_task.thread_id,
|
|
156
|
+
execution.execution_id,
|
|
157
|
+
self._worker_id,
|
|
158
|
+
ttl_seconds=self._policy.lease_seconds,
|
|
159
|
+
)
|
|
160
|
+
if not claimed:
|
|
161
|
+
raise CoordinationBusyError(f"协调实例正由其他 Worker 执行:{execution.execution_id}")
|
|
162
|
+
heartbeat = asyncio.create_task(
|
|
163
|
+
self._heartbeat(execution, heartbeat_stop, lease_lost)
|
|
164
|
+
)
|
|
165
|
+
try:
|
|
166
|
+
if resume:
|
|
167
|
+
execution.status = "queued"
|
|
168
|
+
execution.error = None
|
|
169
|
+
assert execution.current_task is not None
|
|
170
|
+
execution.current_task = replace(
|
|
171
|
+
execution.current_task,
|
|
172
|
+
resume_requested=True,
|
|
173
|
+
)
|
|
174
|
+
event = execution.emit(
|
|
175
|
+
"coordination_resumed",
|
|
176
|
+
task_id=execution.current_task.task_id,
|
|
177
|
+
)
|
|
178
|
+
await self._save_and_publish(execution, event)
|
|
179
|
+
return await self._drive(execution, lease_lost)
|
|
180
|
+
finally:
|
|
181
|
+
heartbeat_stop.set()
|
|
182
|
+
if heartbeat is not None:
|
|
183
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
184
|
+
await heartbeat
|
|
185
|
+
if self._lease_store is not None:
|
|
186
|
+
try:
|
|
187
|
+
await self._lease_store.release(
|
|
188
|
+
execution.root_task.thread_id,
|
|
189
|
+
execution.execution_id,
|
|
190
|
+
self._worker_id,
|
|
191
|
+
)
|
|
192
|
+
except Exception:
|
|
193
|
+
logger.exception("释放多 Agent 协调租约失败:%s", execution.execution_id)
|
|
194
|
+
|
|
195
|
+
async def _heartbeat(
|
|
196
|
+
self,
|
|
197
|
+
execution: CoordinationExecution,
|
|
198
|
+
stop: asyncio.Event,
|
|
199
|
+
lease_lost: asyncio.Event,
|
|
200
|
+
) -> None:
|
|
201
|
+
assert self._lease_store is not None
|
|
202
|
+
while True:
|
|
203
|
+
try:
|
|
204
|
+
await asyncio.wait_for(stop.wait(), timeout=self._policy.heartbeat_seconds)
|
|
205
|
+
return
|
|
206
|
+
except TimeoutError:
|
|
207
|
+
try:
|
|
208
|
+
renewed = await self._lease_store.renew(
|
|
209
|
+
execution.root_task.thread_id,
|
|
210
|
+
execution.execution_id,
|
|
211
|
+
self._worker_id,
|
|
212
|
+
ttl_seconds=self._policy.lease_seconds,
|
|
213
|
+
)
|
|
214
|
+
except Exception:
|
|
215
|
+
logger.exception("续租多 Agent 协调实例失败:%s", execution.execution_id)
|
|
216
|
+
lease_lost.set()
|
|
217
|
+
return
|
|
218
|
+
if not renewed:
|
|
219
|
+
lease_lost.set()
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
async def _drive(
|
|
223
|
+
self,
|
|
224
|
+
execution: CoordinationExecution,
|
|
225
|
+
lease_lost: asyncio.Event,
|
|
226
|
+
) -> CoordinationExecution:
|
|
227
|
+
execution.status = "running"
|
|
228
|
+
event = execution.emit("coordination_started")
|
|
229
|
+
await self._save_and_publish(execution, event)
|
|
230
|
+
try:
|
|
231
|
+
while execution.current_task is not None:
|
|
232
|
+
if lease_lost.is_set():
|
|
233
|
+
raise CoordinationLeaseLostError("协调实例租约已丢失")
|
|
234
|
+
task = execution.current_task
|
|
235
|
+
agent_id = execution.current_agent_id
|
|
236
|
+
if agent_id is None:
|
|
237
|
+
decision = await self._router.route(task, self._registry)
|
|
238
|
+
agent_id = decision.agent_id
|
|
239
|
+
self._check_visit_budget(execution, agent_id)
|
|
240
|
+
execution.current_agent_id = agent_id
|
|
241
|
+
execution.route_history.append(agent_id)
|
|
242
|
+
event = execution.emit(
|
|
243
|
+
"agent_routed",
|
|
244
|
+
task_id=task.task_id,
|
|
245
|
+
agent_id=agent_id,
|
|
246
|
+
reason=decision.reason,
|
|
247
|
+
)
|
|
248
|
+
await self._save_and_publish(execution, event)
|
|
249
|
+
|
|
250
|
+
result = self._checkpointed_result(execution, task, agent_id)
|
|
251
|
+
if result is None:
|
|
252
|
+
result = await self._invoke_with_retries(
|
|
253
|
+
execution,
|
|
254
|
+
task,
|
|
255
|
+
agent_id,
|
|
256
|
+
lease_lost,
|
|
257
|
+
)
|
|
258
|
+
if result.status == "completed" and result.handoff is not None:
|
|
259
|
+
if execution.handoffs_completed >= self._policy.max_handoffs:
|
|
260
|
+
raise CoordinationBudgetExceededError("超过最大 handoff 次数")
|
|
261
|
+
child = task.handoff(
|
|
262
|
+
result.handoff,
|
|
263
|
+
current_agent_id=agent_id,
|
|
264
|
+
parent_run_id=result.run_id,
|
|
265
|
+
fallback_input=result.output or task.input,
|
|
266
|
+
)
|
|
267
|
+
execution.handoffs_completed += 1
|
|
268
|
+
execution.current_task = child
|
|
269
|
+
execution.current_agent_id = None
|
|
270
|
+
event = execution.emit(
|
|
271
|
+
"agent_handoff",
|
|
272
|
+
from_agent_id=agent_id,
|
|
273
|
+
target_agent_id=child.target_agent_id,
|
|
274
|
+
required_capability=child.required_capability,
|
|
275
|
+
parent_task_id=task.task_id,
|
|
276
|
+
child_task_id=child.task_id,
|
|
277
|
+
reason=result.handoff.reason,
|
|
278
|
+
)
|
|
279
|
+
await self._save_and_publish(execution, event)
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
if result.status == "completed":
|
|
283
|
+
execution.status = "completed"
|
|
284
|
+
execution.output = result.output
|
|
285
|
+
execution.current_task = None
|
|
286
|
+
execution.current_agent_id = None
|
|
287
|
+
event = execution.emit(
|
|
288
|
+
"coordination_completed",
|
|
289
|
+
agent_id=agent_id,
|
|
290
|
+
output=result.output,
|
|
291
|
+
)
|
|
292
|
+
await self._save_and_publish(execution, event)
|
|
293
|
+
return execution
|
|
294
|
+
|
|
295
|
+
if result.status == "interrupted":
|
|
296
|
+
execution.status = "interrupted"
|
|
297
|
+
execution.error = result.error
|
|
298
|
+
event = execution.emit(
|
|
299
|
+
"coordination_interrupted",
|
|
300
|
+
agent_id=agent_id,
|
|
301
|
+
error=result.error,
|
|
302
|
+
)
|
|
303
|
+
await self._save_and_publish(execution, event)
|
|
304
|
+
return execution
|
|
305
|
+
|
|
306
|
+
if result.status == "cancelled":
|
|
307
|
+
execution.status = "cancelled"
|
|
308
|
+
event = execution.emit("coordination_cancelled", agent_id=agent_id)
|
|
309
|
+
await self._save_and_publish(execution, event)
|
|
310
|
+
return execution
|
|
311
|
+
|
|
312
|
+
return await self._fail(execution, result.error or f"Agent {agent_id} 执行失败")
|
|
313
|
+
except CoordinationLeaseLostError as exc:
|
|
314
|
+
execution.status = "interrupted"
|
|
315
|
+
execution.error = str(exc)
|
|
316
|
+
event = execution.emit("coordination_interrupted", error=str(exc), reason="lease_lost")
|
|
317
|
+
await self._save_and_publish(execution, event)
|
|
318
|
+
return execution
|
|
319
|
+
except asyncio.CancelledError:
|
|
320
|
+
latest = await self._store.load(execution.execution_id, access=execution.access)
|
|
321
|
+
if latest is None or latest.status != "cancelled":
|
|
322
|
+
execution.status = "cancelled"
|
|
323
|
+
event = execution.emit("coordination_cancelled", reason="task_cancelled")
|
|
324
|
+
await self._save_and_publish(execution, event)
|
|
325
|
+
raise
|
|
326
|
+
except Exception as exc:
|
|
327
|
+
return await self._fail(execution, str(exc))
|
|
328
|
+
return execution
|
|
329
|
+
|
|
330
|
+
async def _invoke_with_retries(
|
|
331
|
+
self,
|
|
332
|
+
execution: CoordinationExecution,
|
|
333
|
+
task: AgentTask,
|
|
334
|
+
agent_id: str,
|
|
335
|
+
lease_lost: asyncio.Event,
|
|
336
|
+
) -> AgentResult:
|
|
337
|
+
previous_failures = [
|
|
338
|
+
item
|
|
339
|
+
for item in execution.results
|
|
340
|
+
if item.task_id == task.task_id
|
|
341
|
+
and item.agent_id == agent_id
|
|
342
|
+
and item.status == "failed"
|
|
343
|
+
]
|
|
344
|
+
result = previous_failures[-1] if previous_failures else None
|
|
345
|
+
if result is not None and (
|
|
346
|
+
not result.retryable
|
|
347
|
+
or len(previous_failures) >= self._policy.max_agent_attempts
|
|
348
|
+
):
|
|
349
|
+
return result
|
|
350
|
+
first_attempt = len(previous_failures) + 1
|
|
351
|
+
for attempt in range(first_attempt, self._policy.max_agent_attempts + 1):
|
|
352
|
+
if execution.agent_calls >= self._policy.max_agent_calls:
|
|
353
|
+
raise CoordinationBudgetExceededError("超过最大 Agent 调用次数")
|
|
354
|
+
execution.agent_calls += 1
|
|
355
|
+
event = execution.emit(
|
|
356
|
+
"agent_started",
|
|
357
|
+
task_id=task.task_id,
|
|
358
|
+
agent_id=agent_id,
|
|
359
|
+
attempt=attempt,
|
|
360
|
+
idempotency_key=task.idempotency_key,
|
|
361
|
+
)
|
|
362
|
+
await self._save_and_publish(execution, event)
|
|
363
|
+
try:
|
|
364
|
+
result = await self._invoke_once(execution, task, agent_id, lease_lost)
|
|
365
|
+
except TimeoutError:
|
|
366
|
+
result = AgentResult(
|
|
367
|
+
task_id=task.task_id,
|
|
368
|
+
agent_id=agent_id,
|
|
369
|
+
status="failed",
|
|
370
|
+
error=f"Agent 调用超过 {self._policy.agent_timeout_seconds:g} 秒",
|
|
371
|
+
retryable=True,
|
|
372
|
+
)
|
|
373
|
+
except asyncio.CancelledError:
|
|
374
|
+
raise
|
|
375
|
+
except CoordinationLeaseLostError:
|
|
376
|
+
raise
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
result = AgentResult(
|
|
379
|
+
task_id=task.task_id,
|
|
380
|
+
agent_id=agent_id,
|
|
381
|
+
status="failed",
|
|
382
|
+
error=str(exc),
|
|
383
|
+
retryable=True,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
execution.results.append(result)
|
|
387
|
+
self._merge_usage(execution, result)
|
|
388
|
+
event_type = "agent_completed" if result.status == "completed" else f"agent_{result.status}"
|
|
389
|
+
event = execution.emit(
|
|
390
|
+
event_type,
|
|
391
|
+
task_id=task.task_id,
|
|
392
|
+
agent_id=agent_id,
|
|
393
|
+
attempt=attempt,
|
|
394
|
+
run_id=result.run_id,
|
|
395
|
+
error=result.error,
|
|
396
|
+
retryable=result.retryable,
|
|
397
|
+
usage=result.usage,
|
|
398
|
+
)
|
|
399
|
+
await self._save_and_publish(execution, event)
|
|
400
|
+
self._check_usage_budget(execution)
|
|
401
|
+
|
|
402
|
+
should_retry = (
|
|
403
|
+
result.status == "failed"
|
|
404
|
+
and result.retryable
|
|
405
|
+
and attempt < self._policy.max_agent_attempts
|
|
406
|
+
)
|
|
407
|
+
if not should_retry:
|
|
408
|
+
return result
|
|
409
|
+
retry_event = execution.emit(
|
|
410
|
+
"agent_retry_scheduled",
|
|
411
|
+
task_id=task.task_id,
|
|
412
|
+
agent_id=agent_id,
|
|
413
|
+
next_attempt=attempt + 1,
|
|
414
|
+
)
|
|
415
|
+
await self._save_and_publish(execution, retry_event)
|
|
416
|
+
delay = self._policy.retry_base_seconds * (2 ** (attempt - 1))
|
|
417
|
+
if delay > 0:
|
|
418
|
+
await asyncio.sleep(delay)
|
|
419
|
+
assert result is not None
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
@staticmethod
|
|
423
|
+
def _checkpointed_result(
|
|
424
|
+
execution: CoordinationExecution,
|
|
425
|
+
task: AgentTask,
|
|
426
|
+
agent_id: str,
|
|
427
|
+
) -> AgentResult | None:
|
|
428
|
+
"""恢复时复用已落库的终态结果,避免重复调用已完成的子 Agent。"""
|
|
429
|
+
if not execution.results:
|
|
430
|
+
return None
|
|
431
|
+
result = execution.results[-1]
|
|
432
|
+
if (
|
|
433
|
+
result.task_id == task.task_id
|
|
434
|
+
and result.agent_id == agent_id
|
|
435
|
+
and result.status in {"completed", "cancelled"}
|
|
436
|
+
):
|
|
437
|
+
return result
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
async def _invoke_once(
|
|
441
|
+
self,
|
|
442
|
+
execution: CoordinationExecution,
|
|
443
|
+
task: AgentTask,
|
|
444
|
+
agent_id: str,
|
|
445
|
+
lease_lost: asyncio.Event,
|
|
446
|
+
) -> AgentResult:
|
|
447
|
+
invoke_task = asyncio.create_task(
|
|
448
|
+
asyncio.wait_for(
|
|
449
|
+
self._registry.invoke(agent_id, task),
|
|
450
|
+
timeout=self._policy.agent_timeout_seconds,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
self._active_invocations[execution.execution_id] = invoke_task
|
|
454
|
+
lease_task: asyncio.Task[bool] | None = None
|
|
455
|
+
try:
|
|
456
|
+
if self._lease_store is None:
|
|
457
|
+
return await invoke_task
|
|
458
|
+
lease_task = asyncio.create_task(lease_lost.wait())
|
|
459
|
+
done, _ = await asyncio.wait(
|
|
460
|
+
{invoke_task, lease_task},
|
|
461
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
462
|
+
)
|
|
463
|
+
if lease_task in done and lease_lost.is_set():
|
|
464
|
+
invoke_task.cancel()
|
|
465
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
466
|
+
await invoke_task
|
|
467
|
+
raise CoordinationLeaseLostError("协调实例租约已丢失")
|
|
468
|
+
return await invoke_task
|
|
469
|
+
finally:
|
|
470
|
+
if lease_task is not None:
|
|
471
|
+
lease_task.cancel()
|
|
472
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
473
|
+
await lease_task
|
|
474
|
+
self._active_invocations.pop(execution.execution_id, None)
|
|
475
|
+
|
|
476
|
+
async def _heartbeat_event(self, event: RunEvent) -> None:
|
|
477
|
+
await self._callback_manager.dispatch(event)
|
|
478
|
+
if self._event_sink is not None:
|
|
479
|
+
try:
|
|
480
|
+
await self._event_sink.publish(event)
|
|
481
|
+
except Exception:
|
|
482
|
+
logger.exception("发布多 Agent 事件失败:%s", event.event_type)
|
|
483
|
+
|
|
484
|
+
async def _save_and_publish(
|
|
485
|
+
self,
|
|
486
|
+
execution: CoordinationExecution,
|
|
487
|
+
event: RunEvent,
|
|
488
|
+
) -> None:
|
|
489
|
+
existing = await self._store.load(execution.execution_id, access=execution.access)
|
|
490
|
+
if (
|
|
491
|
+
existing is not None
|
|
492
|
+
and existing.status == "cancelled"
|
|
493
|
+
and execution.status != "cancelled"
|
|
494
|
+
):
|
|
495
|
+
raise asyncio.CancelledError
|
|
496
|
+
await self._store.save(execution)
|
|
497
|
+
await self._heartbeat_event(event)
|
|
498
|
+
|
|
499
|
+
async def _fail(
|
|
500
|
+
self,
|
|
501
|
+
execution: CoordinationExecution,
|
|
502
|
+
error: str,
|
|
503
|
+
) -> CoordinationExecution:
|
|
504
|
+
execution.status = "failed"
|
|
505
|
+
execution.error = error
|
|
506
|
+
event = execution.emit(
|
|
507
|
+
"coordination_failed",
|
|
508
|
+
agent_id=execution.current_agent_id,
|
|
509
|
+
error=error,
|
|
510
|
+
)
|
|
511
|
+
await self._save_and_publish(execution, event)
|
|
512
|
+
return execution
|
|
513
|
+
|
|
514
|
+
def _require_access(self, access: AccessContext | None) -> None:
|
|
515
|
+
if self._policy.require_access and access is None:
|
|
516
|
+
raise PermissionError("严格访问模式要求提供 AccessContext")
|
|
517
|
+
|
|
518
|
+
def _check_execution_access(
|
|
519
|
+
self,
|
|
520
|
+
execution: CoordinationExecution,
|
|
521
|
+
access: AccessContext | None,
|
|
522
|
+
) -> None:
|
|
523
|
+
owner = execution.access
|
|
524
|
+
self._require_access(access)
|
|
525
|
+
if self._policy.require_access and owner is None:
|
|
526
|
+
raise PermissionError("该协调实例尚未绑定用户和租户")
|
|
527
|
+
if owner is not None and access is not None and not access.can_access(
|
|
528
|
+
owner.user_id,
|
|
529
|
+
owner.tenant_id,
|
|
530
|
+
):
|
|
531
|
+
raise PermissionError("无权访问该协调实例")
|
|
532
|
+
|
|
533
|
+
def _check_visit_budget(self, execution: CoordinationExecution, agent_id: str) -> None:
|
|
534
|
+
if execution.route_history.count(agent_id) >= self._policy.max_visits_per_agent:
|
|
535
|
+
raise CoordinationBudgetExceededError(
|
|
536
|
+
f"Agent {agent_id} 超过最大访问次数 {self._policy.max_visits_per_agent}"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def _merge_usage(self, execution: CoordinationExecution, result: AgentResult) -> None:
|
|
540
|
+
for key, value in result.usage.items():
|
|
541
|
+
execution.usage[key] = execution.usage.get(key, 0.0) + value
|
|
542
|
+
|
|
543
|
+
def _check_usage_budget(self, execution: CoordinationExecution) -> None:
|
|
544
|
+
if self._policy.max_total_tokens is not None:
|
|
545
|
+
total_tokens = execution.usage.get("total_tokens", 0.0)
|
|
546
|
+
if total_tokens > self._policy.max_total_tokens:
|
|
547
|
+
raise CoordinationBudgetExceededError("超过最大 Token 预算")
|
|
548
|
+
if self._policy.max_total_cost is not None:
|
|
549
|
+
total_cost = execution.usage.get("cost", 0.0)
|
|
550
|
+
if total_cost > self._policy.max_total_cost:
|
|
551
|
+
raise CoordinationBudgetExceededError("超过最大费用预算")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
__all__ = ["SupervisorAgent"]
|