Coverage for agentos/concurrent/parallel.py: 0%

180 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 12:29 +0800

1""" 

2Async Parallel Execution Primitives — fan-out/fan-in for multi-agent tasks. 

3 

4Provides structured concurrency patterns for AgentOS: 

5- TaskGroup: structured async task grouping with collective result/error handling 

6- ParallelExecutor: fan-out/fan-in with timeout, cancellation, throttling 

7- parallel_gather: await multiple coroutines with timeout & partial results 

8- parallel_map: map a function over items concurrently with bounded parallelism 

9 

10Key features: 

11- Structured concurrency (all-or-nothing or partial results) 

12- Semaphore-based throttling (bounded parallelism) 

13- Per-task timeout + global timeout 

14- Result aggregation with success/failure tracking 

15- Graceful cancellation propagation 

16""" 

17 

18from __future__ import annotations 

19 

20import asyncio 

21import time 

22import uuid 

23from collections.abc import Callable, Coroutine 

24from dataclasses import dataclass, field 

25from enum import StrEnum 

26from typing import Any, TypeVar 

27 

28T = TypeVar("T") 

29R = TypeVar("R") 

30 

31 

32# ── Data Structures ────────────────────────────────────────────── 

33 

34 

35class TaskStatus(StrEnum): 

36 """Individual task execution status.""" 

37 

38 PENDING = "pending" 

39 RUNNING = "running" 

40 COMPLETED = "completed" 

41 FAILED = "failed" 

42 TIMEOUT = "timeout" 

43 CANCELLED = "cancelled" 

44 

45 

46@dataclass 

47class TaskResult: 

48 """Result of a single parallel task.""" 

49 

50 task_id: str 

51 status: TaskStatus = TaskStatus.PENDING 

52 result: Any = None 

53 error: Exception | None = None 

54 started_at: float = 0.0 

55 finished_at: float = 0.0 

56 duration_ms: float = 0.0 

57 retries: int = 0 

58 

59 

60@dataclass 

61class GatherResult: 

62 """Aggregated result from parallel_gather.""" 

63 

64 results: list[TaskResult] = field(default_factory=list) 

65 total: int = 0 

66 completed: int = 0 

67 failed: int = 0 

68 timed_out: int = 0 

69 cancelled: int = 0 

70 total_duration_ms: float = 0.0 

71 all_succeeded: bool = False 

72 

73 @property 

74 def success_rate(self) -> float: 

75 return self.completed / self.total if self.total > 0 else 0.0 

76 

77 def get_results(self) -> list[Any]: 

78 """Extract successful result values.""" 

79 return [r.result for r in self.results if r.status == TaskStatus.COMPLETED] 

80 

81 def get_errors(self) -> list[tuple[str, Exception]]: 

82 """Extract (task_id, error) pairs for failed tasks.""" 

83 return [(r.task_id, r.error) for r in self.results if r.error] 

84 

85 

86# ── Semaphore-based Task Throttler ─────────────────────────────── 

87 

88 

89class TaskThrottler: 

90 """ 

91 Bounded concurrency controller using asyncio.Semaphore. 

92 

93 Usage: 

94 throttler = TaskThrottler(max_concurrent=5) 

95 async with throttler: 

96 await do_work() 

97 """ 

98 

99 def __init__(self, max_concurrent: int = 10): 

100 if max_concurrent < 1: 

101 raise ValueError("max_concurrent must be >= 1") 

102 self.max_concurrent = max_concurrent 

103 self._semaphore = asyncio.Semaphore(max_concurrent) 

104 self._active = 0 

105 self._peak = 0 

106 

107 @property 

108 def active(self) -> int: 

109 return self._active 

110 

111 @property 

112 def peak(self) -> int: 

113 return self._peak 

114 

115 async def __aenter__(self): 

116 await self._semaphore.acquire() 

117 self._active += 1 

118 if self._active > self._peak: 

119 self._peak = self._active 

120 return self 

121 

122 async def __aexit__(self, *args): 

123 self._active -= 1 

124 self._semaphore.release() 

125 

126 

127# ── Parallel Executor ──────────────────────────────────────────── 

128 

129 

130class ParallelExecutor: 

131 """ 

132 Fan-out/fan-in executor for running multiple coroutines concurrently. 

133 

134 Supports structured concurrency: either wait for all (fail-fast or tolerant), 

135 or collect partial results on timeout. 

136 

137 Usage: 

138 executor = ParallelExecutor(max_concurrent=8, timeout=30.0) 

139 result = await executor.gather([ 

140 agent1.run(task_a), 

141 agent2.run(task_b), 

142 agent3.run(task_c), 

143 ]) 

144 print(f"{result.completed}/{result.total} succeeded") 

145 """ 

146 

147 def __init__( 

148 self, 

149 max_concurrent: int = 8, 

150 timeout: float = 60.0, 

151 fail_fast: bool = False, 

152 ): 

153 self.max_concurrent = max_concurrent 

154 self.timeout = timeout 

155 self.fail_fast = fail_fast 

156 self.throttler = TaskThrottler(max_concurrent) 

157 

158 async def gather( 

159 self, 

160 coros: list[Coroutine], 

161 timeout: float | None = None, 

162 return_partial: bool = True, 

163 ) -> GatherResult: 

164 """ 

165 Execute multiple coroutines in parallel with bounded concurrency. 

166 

167 Args: 

168 coros: List of coroutines to execute 

169 timeout: Global timeout (overrides executor default) 

170 return_partial: If True, return partial results on timeout; if False, raise 

171 

172 Returns: 

173 GatherResult with aggregated status 

174 """ 

175 effective_timeout = timeout if timeout is not None else self.timeout 

176 total_start = time.monotonic() 

177 

178 results: list[TaskResult] = [] 

179 tasks: dict[str, asyncio.Task] = {} 

180 

181 async def _run_one(coro: Coroutine, task_id: str) -> None: 

182 tr = TaskResult(task_id=task_id, status=TaskStatus.RUNNING, started_at=time.monotonic()) 

183 results.append(tr) 

184 

185 async with self.throttler: 

186 try: 

187 tr.result = await coro 

188 tr.status = TaskStatus.COMPLETED 

189 except asyncio.CancelledError: 

190 tr.status = TaskStatus.CANCELLED 

191 if self.fail_fast: 

192 raise 

193 except TimeoutError: 

194 tr.status = TaskStatus.TIMEOUT 

195 tr.error = TimeoutError(f"Task {task_id} timed out") 

196 if self.fail_fast: 

197 raise 

198 except Exception as e: 

199 tr.status = TaskStatus.FAILED 

200 tr.error = e 

201 if self.fail_fast: 

202 raise 

203 finally: 

204 tr.finished_at = time.monotonic() 

205 tr.duration_ms = (tr.finished_at - tr.started_at) * 1000 

206 

207 # Launch all tasks 

208 for i, coro in enumerate(coros): 

209 task_id = uuid.uuid4().hex[:10] 

210 t = asyncio.create_task(_run_one(coro, task_id)) 

211 tasks[task_id] = t 

212 

213 # Wait with global timeout 

214 try: 

215 done, pending = await asyncio.wait( 

216 tasks.values(), 

217 timeout=effective_timeout, 

218 return_when=( 

219 asyncio.ALL_COMPLETED if not self.fail_fast else asyncio.FIRST_EXCEPTION 

220 ), 

221 ) 

222 

223 # Cancel remaining on fail-fast 

224 if pending and self.fail_fast: 

225 for t in pending: 

226 t.cancel() 

227 

228 # Handle timeout: cancel remaining if not return_partial 

229 if pending and not return_partial: 

230 for t in pending: 

231 t.cancel() 

232 raise TimeoutError(f"Gather timed out after {effective_timeout}s") 

233 

234 # Mark timed-out tasks 

235 for t in pending: 

236 t.cancel() 

237 for tr in results: 

238 if tr.status == TaskStatus.RUNNING: 

239 tr.status = TaskStatus.TIMEOUT 

240 tr.finished_at = time.monotonic() 

241 tr.duration_ms = (tr.finished_at - tr.started_at) * 1000 

242 

243 except Exception: 

244 for t in tasks.values(): 

245 if not t.done(): 

246 t.cancel() 

247 raise 

248 

249 # Build aggregate result 

250 total_duration = (time.monotonic() - total_start) * 1000 

251 completed = sum(1 for r in results if r.status == TaskStatus.COMPLETED) 

252 failed = sum(1 for r in results if r.status == TaskStatus.FAILED) 

253 timed_out = sum(1 for r in results if r.status == TaskStatus.TIMEOUT) 

254 cancelled = sum(1 for r in results if r.status == TaskStatus.CANCELLED) 

255 

256 return GatherResult( 

257 results=results, 

258 total=len(results), 

259 completed=completed, 

260 failed=failed, 

261 timed_out=timed_out, 

262 cancelled=cancelled, 

263 total_duration_ms=total_duration, 

264 all_succeeded=(completed == len(results)), 

265 ) 

266 

267 async def map( 

268 self, 

269 func: Callable[[T], Coroutine], 

270 items: list[T], 

271 timeout: float | None = None, 

272 ) -> GatherResult: 

273 """ 

274 Map an async function over a list of items with bounded concurrency. 

275 

276 Args: 

277 func: Async function taking one item and returning a value 

278 items: List of input items 

279 timeout: Global timeout 

280 

281 Returns: 

282 GatherResult with results 

283 """ 

284 coros = [func(item) for item in items] 

285 return await self.gather(coros, timeout=timeout) 

286 

287 

288# ── Convenience Functions ──────────────────────────────────────── 

289 

290 

291async def parallel_gather( 

292 *coros: Coroutine, 

293 max_concurrent: int = 8, 

294 timeout: float = 60.0, 

295 return_partial: bool = True, 

296) -> GatherResult: 

297 """ 

298 Convenience function: await multiple coroutines in parallel. 

299 

300 Usage: 

301 result = await parallel_gather( 

302 fetch_url(url1), 

303 fetch_url(url2), 

304 fetch_url(url3), 

305 max_concurrent=5, timeout=30.0, 

306 ) 

307 for r in result.get_results(): 

308 print(r) 

309 """ 

310 executor = ParallelExecutor(max_concurrent=max_concurrent, timeout=timeout) 

311 return await executor.gather(list(coros), return_partial=return_partial) 

312 

313 

314async def parallel_map( 

315 func: Callable[[T], Coroutine], 

316 items: list[T], 

317 max_concurrent: int = 8, 

318 timeout: float = 60.0, 

319) -> GatherResult: 

320 """ 

321 Convenience function: map async function over items concurrently. 

322 

323 Usage: 

324 result = await parallel_map(process_document, documents, max_concurrent=4) 

325 print(f"Processed {result.completed}/{result.total} docs") 

326 """ 

327 executor = ParallelExecutor(max_concurrent=max_concurrent, timeout=timeout) 

328 return await executor.map(func, items) 

329 

330 

331# ── Fan-Out / Fan-In with Aggregation ──────────────────────────── 

332 

333 

334@dataclass 

335class FanOutConfig: 

336 """Configuration for fan-out pattern.""" 

337 

338 max_concurrent: int = 8 

339 timeout: float = 60.0 

340 aggregation: str = "all" # "all" | "first" | "merge" 

341 retry_failed: bool = False 

342 max_retries: int = 2 

343 

344 

345class FanOutExecutor: 

346 """ 

347 Fan-out pattern: dispatch tasks to N workers, collect results. 

348 

349 Supports aggregation modes: 

350 - "all": Wait for all, return list of results 

351 - "first": Return first successful result (race) 

352 - "merge": Run all, merge results with a merge function 

353 """ 

354 

355 def __init__(self, config: FanOutConfig | None = None, max_concurrent: int = 0): 

356 if config is None and max_concurrent > 0: 

357 config = FanOutConfig(max_concurrent=max_concurrent) 

358 self.config = config or FanOutConfig() 

359 self.executor = ParallelExecutor( 

360 max_concurrent=self.config.max_concurrent, 

361 timeout=self.config.timeout, 

362 ) 

363 

364 async def fan_out( 

365 self, 

366 worker_coros: list[Coroutine], 

367 merge_fn: Callable[[list[Any]], Any] | None = None, 

368 ) -> list[Any] | Any | GatherResult: 

369 """ 

370 Fan out tasks to workers and collect results. 

371 

372 Args: 

373 worker_coros: List of worker coroutines 

374 merge_fn: Merge function for "merge" mode (list of results -> merged value) 

375 

376 Returns: 

377 Depends on aggregation mode: 

378 - "all": list of results 

379 - "first": first successful result 

380 - "merge": merged result via merge_fn 

381 """ 

382 mode = self.config.aggregation 

383 

384 if mode == "first": 

385 # Race: return first successful 

386 gather_result = await self.executor.gather( 

387 worker_coros, 

388 return_partial=True, 

389 ) 

390 successes = gather_result.get_results() 

391 if successes: 

392 return successes[0] 

393 # All failed — raise first error 

394 errors = gather_result.get_errors() 

395 if errors: 

396 raise errors[0][1] 

397 raise RuntimeError("All workers failed with no result") 

398 

399 elif mode == "merge": 

400 gather_result = await self.executor.gather(worker_coros) 

401 if not merge_fn: 

402 raise ValueError("merge_fn required for 'merge' mode") 

403 return merge_fn(gather_result.get_results()) 

404 

405 else: # "all" 

406 gather_result = await self.executor.gather(worker_coros) 

407 if self.config.retry_failed and gather_result.failed > 0: 

408 # Retry failed tasks 

409 for r in gather_result.results: 

410 if r.status == TaskStatus.FAILED: 

411 # Note: retry requires caller to provide a way to rebuild the coro 

412 pass 

413 return gather_result 

414 

415 

416# ── Agent Loop Integration ──────────────────────────────────────── 

417 

418 

419def create_parallel_agent_gather( 

420 max_concurrent: int = 8, 

421 timeout: float = 60.0, 

422) -> Callable: 

423 """ 

424 Create a gather function for use in Agent tool definitions. 

425 

426 Usage: 

427 agent_tools["parallel_gather"] = create_parallel_agent_gather(max_concurrent=5) 

428 """ 

429 

430 async def agent_gather(*coros: Coroutine) -> GatherResult: 

431 return await parallel_gather(*coros, max_concurrent=max_concurrent, timeout=timeout) 

432 

433 return agent_gather