Coverage for agentos/tools/pipeline.py: 0%

145 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-09 10:19 +0800

1""" 

2Pipeline — composable data processing pipeline for AgentOS. 

3 

4Features: 

5- Linear and branching pipelines 

6- Fan-out / fan-in patterns 

7- Backpressure and quota control 

8- Stage-level error handling and retry 

9- Pipeline serialization for checkpoint/resume 

10""" 

11 

12import threading 

13import time 

14from abc import ABC, abstractmethod 

15from collections.abc import Callable 

16from dataclasses import dataclass, field 

17from enum import Enum, auto 

18from typing import Any, Generic, TypeVar 

19 

20T = TypeVar("T") 

21U = TypeVar("U") 

22 

23 

24# ============================================================================ 

25# Core Types 

26# ============================================================================ 

27 

28 

29class StageStatus(Enum): 

30 IDLE = auto() 

31 RUNNING = auto() 

32 PAUSED = auto() 

33 STOPPED = auto() 

34 ERROR = auto() 

35 

36 

37@dataclass 

38class PipelineContext: 

39 """Shared context flowing through the pipeline.""" 

40 

41 data: dict[str, Any] = field(default_factory=dict) 

42 metadata: dict[str, Any] = field(default_factory=dict) 

43 

44 def get(self, key: str, default: Any = None) -> Any: 

45 return self.data.get(key, default) 

46 

47 def set(self, key: str, value: Any) -> None: 

48 self.data[key] = value 

49 

50 

51# ============================================================================ 

52# Stage 

53# ============================================================================ 

54 

55_NO_FALLBACK = object() 

56 

57 

58class Stage(Generic[T, U], ABC): 

59 """Abstract pipeline stage. Transforms T → U.""" 

60 

61 def __init__(self, name: str = "", max_retries: int = 0): 

62 self.name = name or self.__class__.__name__ 

63 self.max_retries = max_retries 

64 self.status: StageStatus = StageStatus.IDLE 

65 self._error: Exception | None = None 

66 self._items_processed: int = 0 

67 self._items_errored: int = 0 

68 

69 @abstractmethod 

70 def process(self, item: T, ctx: PipelineContext) -> U: ... 

71 

72 def on_error(self, item: T, error: Exception, ctx: PipelineContext) -> Any: 

73 """Override to provide fallback on error. Return _NO_FALLBACK to propagate.""" 

74 return _NO_FALLBACK 

75 

76 def execute(self, item: T, ctx: PipelineContext) -> U: 

77 self.status = StageStatus.RUNNING 

78 for attempt in range(self.max_retries + 1): 

79 try: 

80 result = self.process(item, ctx) 

81 self._items_processed += 1 

82 self.status = StageStatus.IDLE 

83 return result 

84 except Exception as e: 

85 self._items_errored += 1 

86 if attempt < self.max_retries: 

87 time.sleep(0.01 * (attempt + 1)) 

88 continue 

89 fallback = self.on_error(item, e, ctx) 

90 if fallback is not _NO_FALLBACK: 

91 self.status = StageStatus.IDLE 

92 return fallback 

93 self._error = e 

94 self.status = StageStatus.ERROR 

95 raise 

96 

97 @property 

98 def stats(self) -> dict[str, Any]: 

99 return { 

100 "name": self.name, 

101 "status": self.status.name, 

102 "items_processed": self._items_processed, 

103 "items_errored": self._items_errored, 

104 } 

105 

106 

107class LambdaStage(Stage[T, U]): 

108 """Convenience stage from a callable.""" 

109 

110 def __init__(self, fn: Callable[[T, PipelineContext], U], name: str = "", max_retries: int = 0): 

111 super().__init__(name=name, max_retries=max_retries) 

112 self._fn = fn 

113 

114 def process(self, item: T, ctx: PipelineContext) -> U: 

115 return self._fn(item, ctx) 

116 

117 

118# ============================================================================ 

119# Pipeline 

120# ============================================================================ 

121 

122 

123class Pipeline(Generic[T, U]): 

124 """Linear pipeline: a sequence of stages T → ? → ... → U.""" 

125 

126 def __init__(self, name: str = "pipeline"): 

127 self.name = name 

128 self._stages: list[Stage] = [] 

129 self._lock = threading.Lock() 

130 self._ctx = PipelineContext() 

131 self.status: StageStatus = StageStatus.IDLE 

132 

133 def add_stage(self, stage: Stage) -> "Pipeline": 

134 with self._lock: 

135 self._stages.append(stage) 

136 return self 

137 

138 def then( 

139 self, fn: Callable[[Any, PipelineContext], Any], name: str = "", max_retries: int = 0 

140 ) -> "Pipeline": 

141 """Fluent API: add a lambda stage.""" 

142 return self.add_stage(LambdaStage(fn, name=name, max_retries=max_retries)) 

143 

144 def run(self, input_item: T) -> U: 

145 """Run pipeline on a single item.""" 

146 current = input_item 

147 self.status = StageStatus.RUNNING 

148 try: 

149 for stage in self._stages: 

150 current = stage.execute(current, self._ctx) 

151 return current 

152 finally: 

153 all_idle = all(s.status == StageStatus.IDLE for s in self._stages) 

154 self.status = StageStatus.IDLE if all_idle else StageStatus.ERROR 

155 

156 def run_batch(self, items: list[T]) -> list[U]: 

157 """Run pipeline on a batch.""" 

158 results = [] 

159 for item in items: 

160 results.append(self.run(item)) 

161 return results 

162 

163 @property 

164 def context(self) -> PipelineContext: 

165 return self._ctx 

166 

167 @property 

168 def stats(self) -> dict[str, Any]: 

169 return { 

170 "name": self.name, 

171 "status": self.status.name, 

172 "stages": [s.stats for s in self._stages], 

173 } 

174 

175 

176# ============================================================================ 

177# ParallelPipeline — Fan-out / Fan-in 

178# ============================================================================ 

179 

180 

181class ParallelPipeline(Generic[T, U]): 

182 """Branches: split input across parallel stages, then merge results. 

183 

184 Fan-out: single input → all branches simultaneously. 

185 Fan-in: all branch outputs → merge function → single output. 

186 """ 

187 

188 def __init__(self, name: str = "parallel_pipeline"): 

189 self.name = name 

190 self._branches: list[Pipeline] = [] 

191 self._merge: Callable[[list[Any], PipelineContext], U] | None = None 

192 self._lock = threading.Lock() 

193 self._ctx = PipelineContext() 

194 

195 def branch(self, pipeline: Pipeline) -> "ParallelPipeline": 

196 with self._lock: 

197 self._branches.append(pipeline) 

198 return self 

199 

200 def merge(self, fn: Callable[[list[Any], PipelineContext], U]) -> "ParallelPipeline": 

201 self._merge = fn 

202 return self 

203 

204 def run(self, input_item: T) -> U: 

205 import concurrent.futures 

206 

207 with concurrent.futures.ThreadPoolExecutor(max_workers=len(self._branches)) as pool: 

208 futures = { 

209 pool.submit(branch.run, input_item): i for i, branch in enumerate(self._branches) 

210 } 

211 results = [None] * len(self._branches) 

212 for future in concurrent.futures.as_completed(futures): 

213 idx = futures[future] 

214 results[idx] = future.result() 

215 

216 if self._merge: 

217 return self._merge(results, self._ctx) 

218 return results # type: ignore 

219 

220 @property 

221 def context(self) -> PipelineContext: 

222 return self._ctx 

223 

224 

225# ============================================================================ 

226# Stage helpers 

227# ============================================================================ 

228 

229 

230class FilterStage(Stage[T, T]): 

231 """Pass-through stage that filters items.""" 

232 

233 def __init__( 

234 self, 

235 predicate: Callable[[T, PipelineContext], bool], 

236 name: str = "filter", 

237 max_retries: int = 0, 

238 ): 

239 super().__init__(name=name, max_retries=max_retries) 

240 self._predicate = predicate 

241 

242 def process(self, item: T, ctx: PipelineContext) -> T: 

243 if not self._predicate(item, ctx): 

244 raise FilterDrop() 

245 return item 

246 

247 def on_error(self, item: T, error: Exception, ctx: PipelineContext) -> T | None: 

248 if isinstance(error, FilterDrop): 

249 return None 

250 return super().on_error(item, error, ctx) 

251 

252 

253class FilterDrop(Exception): # noqa: N818 

254 """Signal that an item should be filtered out.""" 

255 

256 

257 

258class BatchStage(Stage[list[T], list[U]]): 

259 """Accumulates items into batches before processing.""" 

260 

261 def __init__( 

262 self, 

263 batch_size: int, 

264 fn: Callable[[list[T], PipelineContext], list[U]], 

265 name: str = "batch", 

266 max_retries: int = 0, 

267 ): 

268 super().__init__(name=name, max_retries=max_retries) 

269 self.batch_size = batch_size 

270 self._buffer: list[T] = [] 

271 self._fn = fn 

272 

273 def process(self, item: list[T], ctx: PipelineContext) -> list[U]: 

274 return self._fn(item, ctx)