Coverage for agentlib/utils/plotting/dependency_graph.py: 0%

164 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2026-08-13 10:24 +0000

1import logging 

2import socket 

3import webbrowser 

4from collections import defaultdict 

5from typing import Dict, List, Tuple, TYPE_CHECKING 

6 

7from agentlib.core.datamodels import AgentVariable 

8from agentlib.core.errors import OptionalDependencyError 

9 

10try: 

11 import dash 

12 from dash import html, dcc, Input, Output 

13 import dash_cytoscape as cyto 

14except ImportError: 

15 raise OptionalDependencyError("mas_dependency_graph", "dash dash-cytoscape", "interactive") 

16 

17if TYPE_CHECKING: 

18 from agentlib.utils.multi_agent_system import LocalMASAgency 

19 

20 

21logger = logging.getLogger(__name__) 

22 

23 

24def get_port(): 

25 """Find a free port on localhost.""" 

26 port = 8050 

27 while True: 

28 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 

29 is_free = s.connect_ex(("localhost", port)) != 0 

30 if is_free: 

31 return port 

32 port += 1 

33 

34 

35def _get_var_mechanism(var: AgentVariable, cfg) -> str: 

36 """Determine which mechanism made a variable shared. 

37 

38 Returns 'svf' if the variable's field is in shared_variable_fields, 

39 'shared' otherwise (i.e. the variable itself was configured as shared). 

40 """ 

41 svf = set(cfg.shared_variable_fields) 

42 for field_name in cfg.model_fields: 

43 field_val = getattr(cfg, field_name, None) 

44 if isinstance(field_val, AgentVariable) and field_val is var: 

45 return "svf" if field_name in svf else "shared" 

46 elif isinstance(field_val, list): 

47 for item in field_val: 

48 if isinstance(item, AgentVariable) and item is var: 

49 return "svf" if field_name in svf else "shared" 

50 return "shared" 

51 

52 

53def _extract_consumers( 

54 mas: "LocalMASAgency", 

55 shared_vars: Dict[str, Dict[str, str]], 

56) -> Tuple[Dict[str, Dict[str, set]], set]: 

57 """Determine which agents consume the shared variables of each producer. 

58 

59 Consumers are derived from each agent's DataBroker callbacks matched against the 

60 producers' shared variables by alias, which also captures consumers that were not 

61 declared via ``subscriptions`` or a ``source``. 

62 

63 Returns ``(producer_agent -> {alias -> {consumer_agent, ...}}, ambiguous_aliases)`` 

64 where ``ambiguous_aliases`` are published by more than one producer, so an implicit 

65 consumer cannot be attributed to a single one. 

66 """ 

67 

68 alias_producer_scope: Dict[str, List[str]] = defaultdict(list) 

69 for producer, prod_vars in shared_vars.items(): 

70 for alias in prod_vars: 

71 alias_producer_scope[alias].append(producer) 

72 

73 consumers: Dict[str, Dict[str, set]] = defaultdict(dict) 

74 for agent_id, agent in mas._agents.items(): 

75 data_broker = agent.data_broker 

76 callbacks = list(getattr(data_broker, "_unmapped_callbacks", [])) 

77 for callback_list in getattr(data_broker, "_mapped_callbacks", {}).values(): 

78 callbacks.extend(callback_list) 

79 for cb in callbacks: 

80 alias = cb.alias 

81 if alias is None or alias not in alias_producer_scope: 

82 continue 

83 # Link the consumer to every producer of this alias. The edge is flagged 

84 # ambiguous if more than one producer publishes the alias. 

85 for producer in alias_producer_scope[alias]: 

86 if producer != agent_id: 

87 consumers[producer].setdefault(alias, set()).add(agent_id) 

88 

89 ambiguous_aliases = { 

90 alias for alias, producers in alias_producer_scope.items() if len(producers) > 1 

91 } 

92 return consumers, ambiguous_aliases 

93 

94 

95def _extract_dependencies( 

96 mas: "LocalMASAgency", 

97) -> List[Tuple[str, str, str, str, bool]]: 

98 """Extract individual variable dependencies between agents. 

99 

100 Returns a list of tuples 

101 ``(producer_agent, subscriber_agent, variable_label, mechanism_tag, is_ambiguous)`` 

102 where mechanism_tag is one of "shared+sub", "svf+sub", "source", "shared+consumer" or 

103 "svf+consumer", and is_ambiguous marks edges whose source could not be pinned to a 

104 single producer (multiple agents publish the same alias). 

105 """ 

106 shared_vars: Dict[str, Dict[str, str]] = defaultdict(dict) 

107 for agent_id, agent in mas._agents.items(): 

108 for module in agent.modules: 

109 for var in module.config.get_variables(): 

110 if var.shared: 

111 label = var.alias or var.name 

112 if label not in shared_vars[agent_id]: 

113 shared_vars[agent_id][label] = _get_var_mechanism(var, module.config) 

114 

115 # 1) Explicit subscription configuration. 

116 subs: Dict[str, List[str]] = defaultdict(list) 

117 for agent_id, agent in mas._agents.items(): 

118 for module in agent.modules: 

119 subscriptions = getattr(module.config, "subscriptions", None) 

120 if subscriptions: 

121 for sub_agent_id in subscriptions: 

122 if sub_agent_id != agent_id and sub_agent_id not in subs[agent_id]: 

123 subs[agent_id].append(sub_agent_id) 

124 

125 deps: List[Tuple[str, str, str, str, bool]] = [] 

126 seen: set = set() 

127 for subscriber, producers in subs.items(): 

128 for producer in producers: 

129 for var_label, mech in shared_vars.get(producer, {}).items(): 

130 key = (producer, subscriber, var_label) 

131 if key not in seen: 

132 seen.add(key) 

133 deps.append((producer, subscriber, var_label, f"{mech}+sub", False)) 

134 

135 # 2) Variables consumed via the DataBroker. This covers subscription/transport-free 

136 # dependencies and is the mechanism that makes shared variables show up even when 

137 # no "subscriptions" block is declared. 

138 consumers, ambiguous_aliases = _extract_consumers(mas, shared_vars) 

139 for producer, vars_consumed in consumers.items(): 

140 for alias, consumer_set in vars_consumed.items(): 

141 for consumer_agent in consumer_set: 

142 key = (producer, consumer_agent, alias) 

143 if key in seen: 

144 continue 

145 seen.add(key) 

146 mech = shared_vars.get(producer, {}).get(alias, "shared") 

147 is_ambiguous = alias in ambiguous_aliases 

148 deps.append( 

149 (producer, consumer_agent, alias, f"{mech}+consumer", is_ambiguous) 

150 ) 

151 

152 # 3) Variables with a source pointing to a producer. 

153 for agent_id, agent in mas._agents.items(): 

154 for module in agent.modules: 

155 for var in module.config.get_variables(): 

156 src_agent = var.source.agent_id 

157 if src_agent is not None and src_agent != agent_id: 

158 label = var.alias or var.name 

159 key = (src_agent, agent_id, label) 

160 if key not in seen: 

161 seen.add(key) 

162 deps.append((src_agent, agent_id, label, "source", False)) 

163 

164 return deps 

165 

166 

167def run_dashboard(deps: List[Tuple[str, str, str, str, bool]], agent_ids: List[str]): 

168 

169 log = logging.getLogger("werkzeug") 

170 log.setLevel(logging.ERROR) 

171 

172 """Bootstraps the Dash application.""" 

173 app = dash.Dash(__name__) 

174 

175 unique_vars = sorted(list(set(label for _, _, label, _, _ in deps))) 

176 dropdown_options = [{"label": v, "value": v} for v in unique_vars] 

177 

178 # Define base stylesheet 

179 BASE_STYLESHEET = [ 

180 { 

181 'selector': '.agent', 

182 'style': { 

183 'content': 'data(label)', 

184 'text-valign': 'center', 

185 'text-halign': 'center', 

186 'background-color': "#0F4877", 

187 'color': '#FFFFFF', 

188 'shape': 'round-rectangle', 

189 'width': '150px', 

190 'height': '50px', 

191 'font-weight': 'bold', 

192 }, 

193 }, 

194 { 

195 'selector': '.dependency', 

196 'style': { 

197 'curve-style': 'bezier', 

198 'control-point-distance': 35, 

199 'target-arrow-shape': 'triangle', 

200 'font-size': '12px', 

201 'text-rotation': 'autorotate', 

202 'text-margin-y': '-15px', 

203 'text-background-opacity': 0.7, 

204 'text-background-color': '#FFFFFF', 

205 'text-background-padding': '2px', 

206 'text-background-shape': 'roundrectangle', 

207 'transition-property': 'opacity, line-color, target-arrow-color, width', 

208 'transition-duration': '0.15s' 

209 }, 

210 }, 

211 { 

212 'selector': '.standard-dependency', 

213 'style': { 

214 'content': 'data(label)', 

215 'width': 3, 

216 'line-color': "#4E4E4E", 

217 'target-arrow-color': "#4E4E4E", 

218 } 

219 }, 

220 { 

221 'selector': '.summary-dependency', 

222 'style': { 

223 'content': 'data(label)', 

224 'width': 3, 

225 'line-color': "#0F4877", 

226 'target-arrow-color': "#0F4877", 

227 'line-style': 'dashed', 

228 } 

229 }, 

230 { 

231 'selector': '.ambiguous-dependency', 

232 'style': { 

233 'content': 'data(label)', 

234 'width': 2, 

235 'line-color': "#B0B0B0", 

236 'target-arrow-color': "#B0B0B0", 

237 'line-style': 'dotted', 

238 'opacity': 0.45, 

239 } 

240 }, 

241 { 

242 'selector': '.highlighted-dependency', 

243 'style': { 

244 'content': 'data(label)', 

245 'line-color': "#D88A30", 

246 'target-arrow-color': '#D88A30', 

247 'width': 6, 

248 'opacity': 1, 

249 'z-index': 9000 

250 } 

251 }, 

252 { 

253 'selector': '.dimmed', 

254 'style': { 

255 'opacity': 0.15 

256 } 

257 }, 

258 { 

259 'selector': 'edge:selected', 

260 'style': { 

261 'width': 8, 

262 'line-color': '#D88A30', 

263 'target-arrow-color': '#D88A30', 

264 'opacity': 1, 

265 'z-index': 9999 

266 } 

267 } 

268 ] 

269 

270 app.layout = html.Div([ 

271 html.H2("AgentLib MAS Dependency Graph", style={"fontFamily": "sans-serif"}), 

272 

273 html.Div([ 

274 dcc.Dropdown( 

275 id='variable-dropdown', 

276 options=dropdown_options, 

277 clearable=True, 

278 placeholder="Select a variable to highlight..." 

279 ), 

280 ], style={'width': '350px', 'marginBottom': '10px', 'fontFamily': 'sans-serif'}), 

281 

282 html.Div([ 

283 

284 html.Div( 

285 id='hover-info-box', 

286 children="", 

287 style={'opacity': '0'} 

288 ), 

289 

290 cyto.Cytoscape( 

291 id='mas-dependency-graph', 

292 layout={'name': 'breadthfirst', 'directed': True}, 

293 style={'width': '100%', 'height': '800px'}, 

294 stylesheet=BASE_STYLESHEET 

295 ), 

296 

297 ], style={'position': 'relative', 'border': '1px solid #eee', 'borderRadius': '5px'}), 

298 

299 html.Div([ 

300 

301 html.Div("Legend:", style={'fontWeight': 'bold', 'marginBottom': '4px'}), 

302 

303 html.Div([ 

304 html.Span("[shared+sub]", style={'marginRight': '5px', 'fontFamily': 'monospace'}), 

305 html.Span("variable marked shared: true, received via subscription"), 

306 ], style={'marginBottom': '4px'}), 

307 

308 html.Div([ 

309 html.Span("[svf+sub]", style={'marginRight': '5px', 'fontFamily': 'monospace'}), 

310 html.Span("field in shared_variable_fields, received via subscription"), 

311 ], style={'marginBottom': '4px'}), 

312 

313 html.Div([ 

314 html.Span("[source]", style={'marginRight': '5px', 'fontFamily': 'monospace'}), 

315 html.Span("variable source.agent_id points to another agent"), 

316 ]), 

317 

318 html.Div([ 

319 html.Span("[shared+consumer]", style={'marginRight': '5px', 'fontFamily': 'monospace'}), 

320 html.Span("shared variable consumed via DataBroker callbacks"), 

321 ]), 

322 

323 html.Div([ 

324 html.Span("[ambiguous]", style={ 

325 'marginRight': '5px', 'fontFamily': 'monospace', 

326 'color': '#B0B0B0', 'textDecoration': 'underline', 

327 'textDecorationStyle': 'dotted' 

328 }), 

329 html.Span("same alias published by multiple agents; " 

330 "consumer may receive from any of them (last-write-wins)"), 

331 ], style={'marginBottom': '4px'}), 

332 

333 ], style={ 

334 

335 'fontFamily': 'sans-serif', 'fontSize': '13px', 

336 

337 'padding': '10px', 'marginTop': '8px', 

338 

339 'backgroundColor': '#f9f9f9', 'border': '1px solid #eee', 'borderRadius': '5px' 

340 

341 }), 

342 

343 ]) 

344 

345 @app.callback( 

346 Output('mas-dependency-graph', 'elements'), 

347 Input('variable-dropdown', 'value') 

348 ) 

349 def update_elements(selected_variable): 

350 elements = [] 

351 

352 for agent_id in agent_ids: 

353 elements.append({ 

354 "data": {"id": str(agent_id), "label": str(agent_id)}, 

355 "classes": "agent" 

356 }) 

357 

358 edges_by_pair = defaultdict(list) 

359 for src, tgt, label, tag, is_ambiguous in deps: 

360 edges_by_pair[(src, tgt)].append((label, tag, is_ambiguous)) 

361 

362 edge_id_counter = 0 

363 

364 for (src, tgt), label_tags in edges_by_pair.items(): 

365 remaining = list(label_tags) 

366 dim_class = " dimmed" if selected_variable else "" 

367 

368 if selected_variable: 

369 matching = [(l, t, a) for l, t, a in remaining if l == selected_variable] 

370 if matching: 

371 l, t, a = matching[0] 

372 elements.append({ 

373 "data": { 

374 "id": f"e{edge_id_counter}", 

375 "source": str(src), 

376 "target": str(tgt), 

377 "label": f"{l} [{t}]", 

378 "hover_details": f"{l} [{t}]" 

379 }, 

380 "classes": "dependency highlighted-dependency" 

381 }) 

382 edge_id_counter += 1 

383 remaining.remove((l, t, a)) 

384 

385 if len(remaining) > 3: 

386 summary_text = f"{len(remaining)} variables...(click for details)" 

387 elements.append({ 

388 "data": { 

389 "id": f"e{edge_id_counter}", 

390 "source": str(src), 

391 "target": str(tgt), 

392 "label": summary_text, 

393 "hover_details": ", ".join(f"{l} [{t}]" for l, t, _ in remaining) 

394 }, 

395 "classes": f"dependency summary-dependency{dim_class}" 

396 }) 

397 edge_id_counter += 1 

398 else: 

399 for lbl, tag, is_ambiguous in remaining: 

400 if is_ambiguous: 

401 edge_class = "ambiguous-dependency" 

402 else: 

403 edge_class = "standard-dependency" 

404 elements.append({ 

405 "data": { 

406 "id": f"e{edge_id_counter}", 

407 "source": str(src), 

408 "target": str(tgt), 

409 "label": f"{lbl} [{tag}]", 

410 "hover_details": f"{lbl} [{tag}]" 

411 }, 

412 "classes": f"dependency {edge_class}{dim_class}" 

413 }) 

414 edge_id_counter += 1 

415 

416 return elements 

417 

418 @app.callback( 

419 Output('hover-info-box', 'style'), 

420 Output('hover-info-box', 'children'), 

421 Input('mas-dependency-graph', 'selectedEdgeData') 

422 ) 

423 def display_selected_data(selected_edges): 

424 base_hud_style = { 

425 'position': 'absolute', 

426 'top': '20px', 

427 'right': '20px', 

428 'zIndex': 1000, 

429 'backgroundColor': 'rgba(255, 255, 255, 0.95)', 

430 'border': '1px solid #ccc', 

431 'borderRadius': '8px', 

432 'padding': '15px', 

433 'boxShadow': '0px 4px 10px rgba(0,0,0,0.1)', 

434 'width': '250px', 

435 'maxHeight': '400px', 

436 'overflowY': 'auto', 

437 'fontFamily': 'sans-serif', 

438 'fontSize': '14px', 

439 'pointerEvents': 'none', 

440 'transition': 'opacity 0.2s ease-in-out' 

441 } 

442 

443 hidden_style = base_hud_style.copy() 

444 hidden_style['opacity'] = '0' 

445 

446 visible_style = base_hud_style.copy() 

447 visible_style['opacity'] = '1' 

448 

449 if not selected_edges: 

450 return hidden_style, "" 

451 

452 edge_data = selected_edges[-1] 

453 

454 details = edge_data.get('hover_details', 'Unknown') 

455 var_list = [v.strip() for v in details.split(",")] 

456 

457 content = html.Div([ 

458 html.Strong("Variables in this link:", style={'display': 'block', 'marginBottom': '10px'}), 

459 html.Ul([html.Li(var) for var in var_list], style={'margin': '0', 'paddingLeft': '20px', 'color': '#333'}) 

460 ]) 

461 

462 return visible_style, content 

463 

464 port = get_port() 

465 webbrowser.open_new_tab(f"http://localhost:{port}") 

466 app.run(debug=False, port=port, use_reloader=False) 

467 

468 

469import multiprocessing 

470 

471def show_dependency_graph(mas: "LocalMASAgency") -> multiprocessing.Process: 

472 """Starts the visualizer in a background process and returns the process.""" 

473 # 1. Extract data BEFORE creating the process to avoid Pickling errors 

474 deps = _extract_dependencies(mas) 

475 agent_ids = list(mas._agents.keys()) 

476 

477 # 2. Spawn the process with simple, picklable data 

478 process = multiprocessing.Process( 

479 target=run_dashboard, 

480 args=(deps, agent_ids) 

481 ) 

482 

483 # 3. Start and return the process 

484 process.start() 

485 return process