Coverage for agentos/orchestration/graph_executor.py: 32%

185 statements  

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

1""" 

2Agent Graph — DAG-based multi-agent execution engine. 

3 

4Build complex agent pipelines as directed acyclic graphs where each node 

5is an agent invocation and edges define data flow dependencies. 

6 

7v1.16.1: Integrated MetricsCollector for per-node execution tracking. 

8""" 

9 

10from __future__ import annotations 

11 

12import time 

13from collections import deque 

14from collections.abc import Callable 

15from dataclasses import dataclass, field 

16from enum import Enum 

17from typing import Any 

18 

19from agentos.tools.metrics import MetricsCollector 

20 

21 

22class GraphNodeState(Enum): 

23 """Execution state of a graph orchestrator node.""" 

24 

25 PENDING = "pending" 

26 RUNNING = "running" 

27 COMPLETED = "completed" 

28 FAILED = "failed" 

29 SKIPPED = "skipped" 

30 

31 

32@dataclass 

33class GraphNode: 

34 """A single node in the agent execution graph.""" 

35 

36 name: str 

37 agent_type: str 

38 task_template: str 

39 """Template string with {input} or {node_name.output} placeholders.""" 

40 

41 depends_on: list[str] = field(default_factory=list) 

42 """Node names this node depends on.""" 

43 

44 timeout_seconds: float = 120.0 

45 retry_count: int = 0 

46 on_failure: str = "abort" 

47 """Action on failure: 'abort', 'skip', 'continue'.""" 

48 

49 state: GraphNodeState = GraphNodeState.PENDING 

50 output: Any = None 

51 error: str | None = None 

52 latency_ms: float = 0.0 

53 

54 def resolve_task(self, node_outputs: dict[str, Any]) -> str: 

55 """Resolve template placeholders using outputs from completed nodes.""" 

56 task = self.task_template 

57 task = task.replace("{input}", str(node_outputs.get("__input__", ""))) 

58 for name, output in node_outputs.items(): 

59 placeholder = f"{{{name}.output}}" 

60 if placeholder in task: 

61 task = task.replace(placeholder, str(output)) 

62 return task 

63 

64 

65@dataclass 

66class GraphResult: 

67 """Result of graph execution.""" 

68 

69 node_results: dict[str, GraphNode] = field(default_factory=dict) 

70 execution_order: list[str] = field(default_factory=list) 

71 total_latency_ms: float = 0.0 

72 success: bool = True 

73 error: str | None = None 

74 

75 

76class AgentGraph: 

77 """ 

78 DAG-based multi-agent execution engine. 

79 

80 Define execution graphs declaratively, resolve dependencies automatically, 

81 execute nodes in topological order with parallelism for independent nodes. 

82 

83 Example:: 

84 

85 graph = AgentGraph() 

86 graph.add_node(GraphNode( 

87 name="research", 

88 agent_type="researcher", 

89 task_template="Research: {input}" 

90 )) 

91 graph.add_node(GraphNode( 

92 name="summarize", 

93 agent_type="summarizer", 

94 task_template="Summarize: {research.output}", 

95 depends_on=["research"] 

96 )) 

97 result = graph.execute("quantum computing advances") 

98 """ 

99 

100 def __init__( 

101 self, 

102 executor: Callable[[str, str], Any] | None = None, 

103 metrics: MetricsCollector | None = None, 

104 ): 

105 """ 

106 Args: 

107 executor: Callable(agent_type, task) -> output. If not provided, 

108 subclasses must override _execute_node. 

109 metrics: Optional MetricsCollector for per-node execution tracking. 

110 """ 

111 self._nodes: dict[str, GraphNode] = {} 

112 self._executor = executor 

113 self._metrics = metrics 

114 

115 def add_node(self, node: GraphNode) -> None: 

116 """Add a node to the graph. Raises ValueError on duplicate name.""" 

117 if node.name in self._nodes: 

118 raise ValueError(f"Duplicate node name: {node.name}") 

119 self._nodes[node.name] = node 

120 

121 def remove_node(self, name: str) -> None: 

122 """Remove a node and all edges referencing it.""" 

123 if name not in self._nodes: 

124 raise KeyError(f"Node not found: {name}") 

125 del self._nodes[name] 

126 for node in self._nodes.values(): 

127 node.depends_on = [d for d in node.depends_on if d != name] 

128 

129 def validate(self) -> list[str]: 

130 """ 

131 Validate graph integrity. 

132 

133 Returns: 

134 List of error messages (empty if valid). 

135 """ 

136 errors: list[str] = [] 

137 

138 for name, node in self._nodes.items(): 

139 for dep in node.depends_on: 

140 if dep not in self._nodes: 

141 errors.append(f"Node '{name}' depends on unknown node '{dep}'") 

142 if dep == name: 

143 errors.append(f"Node '{name}' cannot depend on itself") 

144 

145 # Check for cycles using topological sort 

146 if not errors: 

147 try: 

148 self._topological_order() 

149 except ValueError as e: 

150 errors.append(str(e)) 

151 

152 return errors 

153 

154 def _topological_order(self) -> list[str]: 

155 """Return nodes in topological order. Raises ValueError on cycle.""" 

156 in_degree: dict[str, int] = {name: 0 for name in self._nodes} 

157 adjacency: dict[str, list[str]] = {name: [] for name in self._nodes} 

158 

159 for name, node in self._nodes.items(): 

160 for dep in node.depends_on: 

161 adjacency[dep].append(name) 

162 in_degree[name] += 1 

163 

164 queue = deque([name for name, deg in in_degree.items() if deg == 0]) 

165 order: list[str] = [] 

166 

167 while queue: 

168 current = queue.popleft() 

169 order.append(current) 

170 for neighbor in adjacency[current]: 

171 in_degree[neighbor] -= 1 

172 if in_degree[neighbor] == 0: 

173 queue.append(neighbor) 

174 

175 if len(order) != len(self._nodes): 

176 remaining = set(self._nodes) - set(order) 

177 raise ValueError(f"Cycle detected involving nodes: {remaining}") 

178 

179 return order 

180 

181 def execute(self, input_data: str) -> GraphResult: 

182 """ 

183 Execute the graph with given input. 

184 

185 Args: 

186 input_data: Initial task input, accessible as {input} in templates. 

187 

188 Returns: 

189 GraphResult with per-node outputs and execution metadata. 

190 """ 

191 errors = self.validate() 

192 if errors: 

193 return GraphResult(success=False, error="; ".join(errors)) 

194 

195 t0 = time.perf_counter() 

196 node_outputs: dict[str, Any] = {"__input__": input_data} 

197 results: dict[str, GraphNode] = {} 

198 order: list[str] = [] 

199 

200 # Reset all nodes 

201 for node in self._nodes.values(): 

202 node.state = GraphNodeState.PENDING 

203 node.output = None 

204 node.error = None 

205 node.latency_ms = 0.0 

206 

207 try: 

208 topo = self._topological_order() 

209 except ValueError as e: 

210 return GraphResult(success=False, error=str(e)) 

211 

212 abort = False 

213 for name in topo: 

214 if abort: 

215 self._nodes[name].state = GraphNodeState.SKIPPED 

216 results[name] = self._nodes[name] 

217 continue 

218 

219 node = self._nodes[name] 

220 results[name] = node 

221 order.append(name) 

222 

223 # Check dependencies 

224 deps_failed = False 

225 for dep in node.depends_on: 

226 if results[dep].state == GraphNodeState.FAILED: 

227 deps_failed = True 

228 break 

229 

230 if deps_failed: 

231 node.state = GraphNodeState.SKIPPED 

232 continue 

233 

234 task = node.resolve_task(node_outputs) 

235 node_t0 = time.perf_counter() 

236 

237 try: 

238 node.state = GraphNodeState.RUNNING 

239 output = self._execute_node(node.agent_type, task) 

240 node.output = output 

241 node.state = GraphNodeState.COMPLETED 

242 node_outputs[name] = output 

243 except Exception as exc: 

244 node.state = GraphNodeState.FAILED 

245 node.error = f"{type(exc).__name__}: {exc}" 

246 node_outputs[name] = None 

247 if node.on_failure == "abort": 

248 abort = True 

249 

250 node.latency_ms = (time.perf_counter() - node_t0) * 1000 

251 

252 # Metrics: track per-node execution 

253 if self._metrics is not None: 

254 self._metrics.get_counter("graph_nodes_total").inc(node.agent_type) 

255 self._metrics.get_counter(f"graph_node_{node.state.value}").inc(node.agent_type) 

256 self._metrics.get_timer("graph_node_latency_ms").record(node.latency_ms) 

257 

258 success = all( 

259 n.state in (GraphNodeState.COMPLETED, GraphNodeState.SKIPPED) for n in results.values() 

260 ) 

261 total_latency = (time.perf_counter() - t0) * 1000 

262 

263 return GraphResult( 

264 node_results=results, 

265 execution_order=order, 

266 total_latency_ms=total_latency, 

267 success=success, 

268 ) 

269 

270 def _execute_node(self, agent_type: str, task: str) -> Any: 

271 """Execute a single node. Override or provide executor callback.""" 

272 if self._executor: 

273 return self._executor(agent_type, task) 

274 raise NotImplementedError( 

275 "No executor provided. Pass executor to __init__ or override _execute_node." 

276 ) 

277 

278 def to_mermaid(self) -> str: 

279 """Export graph as Mermaid flowchart.""" 

280 lines = ["graph TD"] 

281 for name, node in self._nodes.items(): 

282 safe = name.replace("-", "_").replace(" ", "_") 

283 lines.append(f' {safe}["{name}\\n({node.agent_type})"]') 

284 for name, node in self._nodes.items(): 

285 safe = name.replace("-", "_").replace(" ", "_") 

286 for dep in node.depends_on: 

287 safe_dep = dep.replace("-", "_").replace(" ", "_") 

288 lines.append(f" {safe_dep} --> {safe}") 

289 return "\n".join(lines) 

290 

291 @property 

292 def node_count(self) -> int: 

293 return len(self._nodes) 

294 

295 @property 

296 def edge_count(self) -> int: 

297 return sum(len(n.depends_on) for n in self._nodes.values()) 

298 

299 

300@dataclass 

301class GraphRecipe: 

302 """Declarative graph definition (YAML-friendly).""" 

303 

304 name: str 

305 description: str = "" 

306 nodes: list[dict[str, Any]] = field(default_factory=list) 

307 """List of node dicts with keys: name, agent_type, task_template, depends_on, timeout_seconds, on_failure.""" 

308 

309 @classmethod 

310 def from_dict(cls, data: dict) -> GraphRecipe: 

311 return cls( 

312 name=data.get("name", "unnamed"), 

313 description=data.get("description", ""), 

314 nodes=data.get("nodes", []), 

315 ) 

316 

317 def build(self, executor: Callable | None = None) -> AgentGraph: 

318 """Build an AgentGraph from this recipe.""" 

319 graph = AgentGraph(executor=executor) 

320 for spec in self.nodes: 

321 graph.add_node( 

322 GraphNode( 

323 name=spec["name"], 

324 agent_type=spec.get("agent_type", "default"), 

325 task_template=spec["task_template"], 

326 depends_on=spec.get("depends_on", []), 

327 timeout_seconds=spec.get("timeout_seconds", 120.0), 

328 retry_count=spec.get("retry_count", 0), 

329 on_failure=spec.get("on_failure", "abort"), 

330 ) 

331 ) 

332 return graph