Coverage for agentos/agent/pipeline.py: 33%

122 statements  

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

1""" 

2多Agent编排管道 — Conditional / Parallel / Router。 

3 

4v1.5.1: 支持条件路由(ConditionalPipeline)、并行扇出(ParallelPipeline)、 

5 动态路由(RouterAgent) 三种生产级编排拓扑。 

6""" 

7 

8from __future__ import annotations 

9 

10import concurrent.futures 

11from dataclasses import dataclass, field 

12from typing import Callable 

13 

14from agentos.agent.tool_agent import ToolAgent, AgentConfig, AgentResult 

15 

16 

17@dataclass 

18class PipelineAgent: 

19 """管道中的单个 Agent 节点。""" 

20 name: str 

21 agent: ToolAgent 

22 config: AgentConfig | None = None 

23 

24 

25@dataclass 

26class PipelineResult: 

27 """管道执行结果。""" 

28 success: bool = True 

29 steps: list[dict] = field(default_factory=list) 

30 final_output: str = "" 

31 total_tokens: int = 0 

32 total_cost_usd: float = 0.0 

33 total_duration_ms: float = 0.0 

34 error: str = "" 

35 

36 @property 

37 def output(self) -> str: 

38 return self.final_output 

39 

40 

41@dataclass 

42class StepResult: 

43 """单个步骤的结果包装。""" 

44 agent_name: str 

45 result: AgentResult 

46 output_key: str | None = None 

47 

48 

49# ── ConditionalPipeline — 条件路由 ────────────────────────────────── 

50 

51ConditionFn = Callable[[str], str] # 输入 → 下一个 agent 名称 

52 

53 

54class ConditionalPipeline: 

55 """基于条件路由的多 Agent 管道。 

56 

57 每个 Agent 执行完成后,通过条件函数决定下一个调用的 Agent。 

58 支持 if-else / switch-case 风格的决策路由。 

59 

60 Usage:: 

61 

62 cp = ConditionalPipeline() 

63 cp.add("classifier", classifier_agent) 

64 cp.add("legal", legal_agent) 

65 cp.add("tech", tech_agent) 

66 

67 # 根据分类器输出决定下一跳 

68 def route(output: str) -> str: 

69 if "法律" in output: return "legal" 

70 if "技术" in output: return "tech" 

71 return "__END__" 

72 

73 result = cp.run("这份合同有问题吗?", router=route) 

74 """ 

75 

76 def __init__(self, max_hops: int = 5): 

77 self._agents: dict[str, PipelineAgent] = {} 

78 self._max_hops = max_hops 

79 

80 def add(self, name: str, agent: ToolAgent, config: AgentConfig | None = None): 

81 self._agents[name] = PipelineAgent(name=name, agent=agent, config=config) 

82 

83 def run(self, task: str, start_agent: str | None = None, router: ConditionFn | None = None) -> PipelineResult: 

84 if start_agent is None and self._agents: 

85 start_agent = next(iter(self._agents)) 

86 if start_agent not in self._agents: 

87 return PipelineResult(success=False, error=f"Unknown start agent: {start_agent}") 

88 

89 current = start_agent 

90 pipeline_output = "" 

91 steps: list[dict] = [] 

92 total_tokens = 0 

93 total_cost = 0.0 

94 total_ms = 0.0 

95 

96 for hop in range(self._max_hops): 

97 pa = self._agents[current] 

98 result = pa.agent.run(task) 

99 

100 steps.append({ 

101 "hop": hop, 

102 "agent": current, 

103 "output": result.final_answer, 

104 "tokens": result.total_tokens, 

105 "cost": result.total_cost_usd, 

106 "duration_ms": result.total_duration_ms, 

107 }) 

108 total_tokens += result.total_tokens 

109 total_cost += result.total_cost_usd 

110 total_ms += result.total_duration_ms 

111 pipeline_output = result.final_answer 

112 

113 if not result.success: 

114 return PipelineResult( 

115 success=False, steps=steps, final_output=pipeline_output, 

116 total_tokens=total_tokens, total_cost_usd=total_cost, 

117 total_duration_ms=total_ms, error=result.error, 

118 ) 

119 

120 if router is None: 

121 break 

122 

123 next_agent = router(result.final_answer) 

124 if next_agent == "__END__" or next_agent not in self._agents: 

125 break 

126 

127 task = result.final_answer # 下一跳的输入是当前输出 

128 current = next_agent 

129 

130 return PipelineResult( 

131 success=True, steps=steps, final_output=pipeline_output, 

132 total_tokens=total_tokens, total_cost_usd=total_cost, 

133 total_duration_ms=total_ms, 

134 ) 

135 

136 

137# ── ParallelPipeline — 并行扇出 ────────────────────────────────── 

138 

139class ParallelPipeline: 

140 """并行执行多个 Agent,聚合结果。 

141 

142 所有 Agent 同时接收同一个 task,各自独立运行,最后合并输出。 

143 

144 Usage:: 

145 

146 pp = ParallelPipeline() 

147 pp.add("analyst_1", agent_1) 

148 pp.add("analyst_2", agent_2) 

149 pp.add("analyst_3", agent_3) 

150 

151 result = pp.run("分析 Q3 财报",  

152 aggregator=lambda results: "\\n---\\n".join(results.values())) 

153 """ 

154 

155 def __init__(self, max_workers: int = 5): 

156 self._agents: dict[str, PipelineAgent] = {} 

157 self._max_workers = max_workers 

158 

159 def add(self, name: str, agent: ToolAgent, config: AgentConfig | None = None): 

160 self._agents[name] = PipelineAgent(name=name, agent=agent, config=config) 

161 

162 def run(self, task: str, aggregator: Callable[[dict[str, str]], str] | None = None) -> PipelineResult: 

163 if not self._agents: 

164 return PipelineResult(success=False, error="No agents registered") 

165 

166 def _run_one(pa: PipelineAgent) -> tuple[str, AgentResult]: 

167 return pa.name, pa.agent.run(task) 

168 

169 results: dict[str, AgentResult] = {} 

170 total_tokens = 0 

171 total_cost = 0.0 

172 total_ms = 0.0 

173 errors: list[str] = [] 

174 

175 with concurrent.futures.ThreadPoolExecutor(max_workers=self._max_workers) as pool: 

176 futures = {pool.submit(_run_one, pa): pa.name for pa in self._agents.values()} 

177 for fut in concurrent.futures.as_completed(futures): 

178 try: 

179 name, result = fut.result() 

180 results[name] = result 

181 total_tokens += result.total_tokens 

182 total_cost += result.total_cost_usd 

183 total_ms = max(total_ms, result.total_duration_ms) 

184 except Exception as e: 

185 errors.append(f"{futures[fut]}: {e}") 

186 

187 if errors and not results: 

188 return PipelineResult(success=False, error="; ".join(errors)) 

189 

190 raw_outputs = {name: r.final_answer for name, r in results.items()} 

191 if aggregator: 

192 final = aggregator(raw_outputs) 

193 else: 

194 parts = [f"## {name}\n{out}" for name, out in raw_outputs.items()] 

195 final = "\n\n".join(parts) 

196 

197 return PipelineResult( 

198 success=len(errors) == 0, 

199 steps=[ 

200 {"agent": name, "output": r.final_answer, "tokens": r.total_tokens} 

201 for name, r in results.items() 

202 ], 

203 final_output=final, 

204 total_tokens=total_tokens, 

205 total_cost_usd=total_cost, 

206 total_duration_ms=total_ms, 

207 error="; ".join(errors) if errors else "", 

208 ) 

209 

210 

211# ── RouterAgent — 动态路由 ────────────────────────────────────── 

212 

213RouterFn = Callable[[str], tuple[str, str]] # (task, str) → (next_agent_name, rewritten_task) 

214 

215 

216class RouterAgent: 

217 """动态路由编排器 — 根据初始化内容选择合适的 Agent。 

218 

219 使用一个分类器 Agent 先分析任务,再动态路由到目标 Agent。 

220 

221 Usage:: 

222 

223 ra = RouterAgent(classifier_agent) 

224 ra.register("code", code_agent, description="代码生成/调试任务") 

225 ra.register("writing", writer_agent, description="写作/翻译/总结任务") 

226 ra.register("research", research_agent, description="调研/搜索/分析任务") 

227 

228 result = ra.run("帮我写一个 Python 快速排序") 

229 """ 

230 

231 def __init__(self, classifier: ToolAgent): 

232 self._classifier = classifier 

233 self._routes: dict[str, tuple[ToolAgent, str]] = {} 

234 

235 def register(self, name: str, agent: ToolAgent, description: str = ""): 

236 self._routes[name] = (agent, description) 

237 

238 def run(self, task: str) -> PipelineResult: 

239 if not self._routes: 

240 return PipelineResult(success=False, error="No routes registered") 

241 

242 # 构建分类提示 

243 route_desc = "\n".join( 

244 f"- {name}: {desc}" for name, (_, desc) in self._routes.items() 

245 ) 

246 classify_task = ( 

247 f"Analyze the following task and output ONLY the best matching route name " 

248 f"from the list below. Output just the name, nothing else.\n\n" 

249 f"Available routes:\n{route_desc}\n\n" 

250 f"Task: {task}\n\n" 

251 f"Route:" 

252 ) 

253 

254 class_result = self._classifier.run(classify_task) 

255 target = class_result.final_answer.strip().lower() 

256 

257 # 模糊匹配 

258 matched = None 

259 for name in self._routes: 

260 if name.lower() in target: 

261 matched = name 

262 break 

263 

264 if matched is None: 

265 # 回退到第一个 

266 matched = next(iter(self._routes)) 

267 

268 agent, _ = self._routes[matched] 

269 result = agent.run(task) 

270 

271 return PipelineResult( 

272 success=result.success, 

273 steps=[ 

274 {"agent": "router", "output": f"Classified as: {matched}"}, 

275 {"agent": matched, "output": result.final_answer, "tokens": result.total_tokens}, 

276 ], 

277 final_output=result.final_answer, 

278 total_tokens=result.total_tokens, 

279 total_cost_usd=result.total_cost_usd, 

280 total_duration_ms=result.total_duration_ms, 

281 )