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
« 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
7from agentlib.core.datamodels import AgentVariable
8from agentlib.core.errors import OptionalDependencyError
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")
17if TYPE_CHECKING:
18 from agentlib.utils.multi_agent_system import LocalMASAgency
21logger = logging.getLogger(__name__)
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
35def _get_var_mechanism(var: AgentVariable, cfg) -> str:
36 """Determine which mechanism made a variable shared.
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"
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.
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``.
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 """
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)
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)
89 ambiguous_aliases = {
90 alias for alias, producers in alias_producer_scope.items() if len(producers) > 1
91 }
92 return consumers, ambiguous_aliases
95def _extract_dependencies(
96 mas: "LocalMASAgency",
97) -> List[Tuple[str, str, str, str, bool]]:
98 """Extract individual variable dependencies between agents.
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)
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)
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))
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 )
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))
164 return deps
167def run_dashboard(deps: List[Tuple[str, str, str, str, bool]], agent_ids: List[str]):
169 log = logging.getLogger("werkzeug")
170 log.setLevel(logging.ERROR)
172 """Bootstraps the Dash application."""
173 app = dash.Dash(__name__)
175 unique_vars = sorted(list(set(label for _, _, label, _, _ in deps)))
176 dropdown_options = [{"label": v, "value": v} for v in unique_vars]
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 ]
270 app.layout = html.Div([
271 html.H2("AgentLib MAS Dependency Graph", style={"fontFamily": "sans-serif"}),
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'}),
282 html.Div([
284 html.Div(
285 id='hover-info-box',
286 children="",
287 style={'opacity': '0'}
288 ),
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 ),
297 ], style={'position': 'relative', 'border': '1px solid #eee', 'borderRadius': '5px'}),
299 html.Div([
301 html.Div("Legend:", style={'fontWeight': 'bold', 'marginBottom': '4px'}),
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'}),
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'}),
313 html.Div([
314 html.Span("[source]", style={'marginRight': '5px', 'fontFamily': 'monospace'}),
315 html.Span("variable source.agent_id points to another agent"),
316 ]),
318 html.Div([
319 html.Span("[shared+consumer]", style={'marginRight': '5px', 'fontFamily': 'monospace'}),
320 html.Span("shared variable consumed via DataBroker callbacks"),
321 ]),
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'}),
333 ], style={
335 'fontFamily': 'sans-serif', 'fontSize': '13px',
337 'padding': '10px', 'marginTop': '8px',
339 'backgroundColor': '#f9f9f9', 'border': '1px solid #eee', 'borderRadius': '5px'
341 }),
343 ])
345 @app.callback(
346 Output('mas-dependency-graph', 'elements'),
347 Input('variable-dropdown', 'value')
348 )
349 def update_elements(selected_variable):
350 elements = []
352 for agent_id in agent_ids:
353 elements.append({
354 "data": {"id": str(agent_id), "label": str(agent_id)},
355 "classes": "agent"
356 })
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))
362 edge_id_counter = 0
364 for (src, tgt), label_tags in edges_by_pair.items():
365 remaining = list(label_tags)
366 dim_class = " dimmed" if selected_variable else ""
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))
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
416 return elements
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 }
443 hidden_style = base_hud_style.copy()
444 hidden_style['opacity'] = '0'
446 visible_style = base_hud_style.copy()
447 visible_style['opacity'] = '1'
449 if not selected_edges:
450 return hidden_style, ""
452 edge_data = selected_edges[-1]
454 details = edge_data.get('hover_details', 'Unknown')
455 var_list = [v.strip() for v in details.split(",")]
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 ])
462 return visible_style, content
464 port = get_port()
465 webbrowser.open_new_tab(f"http://localhost:{port}")
466 app.run(debug=False, port=port, use_reloader=False)
469import multiprocessing
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())
477 # 2. Spawn the process with simple, picklable data
478 process = multiprocessing.Process(
479 target=run_dashboard,
480 args=(deps, agent_ids)
481 )
483 # 3. Start and return the process
484 process.start()
485 return process