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.
@@ -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"]