graphviagent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """GraphVIAgent — local viewer for LangGraph pipelines."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
graphviagent/cli.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from graphviagent.discover import discover_pipelines
8
+ from graphviagent.load import load_pipeline
9
+ from graphviagent.server import serve
10
+
11
+
12
+ def main(argv: list[str] | None = None) -> None:
13
+ argv = list(sys.argv[1:] if argv is None else argv)
14
+ serve_mode = False
15
+ if argv and argv[0] == "serve":
16
+ serve_mode = True
17
+ argv = argv[1:]
18
+
19
+ parser = argparse.ArgumentParser(
20
+ prog="graphviagent",
21
+ description="GraphVIAgent — inspect and replay LangGraph *_pipeline.py files",
22
+ )
23
+ parser.add_argument("path", nargs="?", default=".", help="folder to scan")
24
+ parser.add_argument("--host", default="127.0.0.1")
25
+ parser.add_argument("--port", type=int, default=8765)
26
+ args = parser.parse_args(argv)
27
+ root = Path(args.path).resolve()
28
+ if not root.exists():
29
+ raise SystemExit(f"path not found: {root}")
30
+
31
+ if serve_mode:
32
+ serve(root, host=args.host, port=args.port)
33
+ return
34
+
35
+ found = discover_pipelines(root)
36
+ if not found:
37
+ print(f"no *_pipeline.py under {root}")
38
+ return
39
+ for path in found:
40
+ loaded = load_pipeline(path)
41
+ rel = path.relative_to(root)
42
+ if loaded.error:
43
+ print(f"{rel} ERROR {loaded.error}")
44
+ else:
45
+ print(f"{rel} ok examples={len(loaded.examples)}")
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ SKIP_DIRS = {
6
+ ".venv",
7
+ "venv",
8
+ ".graphviagent",
9
+ ".git",
10
+ "__pycache__",
11
+ "node_modules",
12
+ ".mypy_cache",
13
+ ".pytest_cache",
14
+ "dist",
15
+ "build",
16
+ }
17
+
18
+
19
+ def discover_pipelines(root: Path) -> list[Path]:
20
+ root = root.resolve()
21
+ found: list[Path] = []
22
+ for path in sorted(root.rglob("*_pipeline.py")):
23
+ if any(part in SKIP_DIRS or part.endswith(".egg-info") for part in path.parts):
24
+ continue
25
+ found.append(path)
26
+ return found
graphviagent/load.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import importlib.util
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ FACTORY_NAMES = ("build_graph", "get_graph", "create_graph")
11
+
12
+
13
+ @dataclass
14
+ class LoadedPipeline:
15
+ path: Path
16
+ stem: str
17
+ app: Any = None
18
+ examples: list[dict] = field(default_factory=list)
19
+ error: str | None = None
20
+ has_checkpointer: bool = False
21
+ module: Any = None
22
+
23
+
24
+ def _is_runnable(obj: Any) -> bool:
25
+ return hasattr(obj, "stream") and hasattr(obj, "invoke")
26
+
27
+
28
+ def _compile_if_needed(obj: Any) -> tuple[Any, bool]:
29
+ if _is_runnable(obj):
30
+ checkpointer = getattr(obj, "checkpointer", None)
31
+ return obj, checkpointer not in (None, False)
32
+ compile_fn = getattr(obj, "compile", None)
33
+ if callable(compile_fn):
34
+ try:
35
+ from langgraph.checkpoint.memory import MemorySaver
36
+
37
+ app = compile_fn(checkpointer=MemorySaver())
38
+ return app, True
39
+ except TypeError:
40
+ return compile_fn(), False
41
+ raise TypeError("object is not a compiled graph or StateGraph")
42
+
43
+
44
+ def load_module(path: Path) -> Any:
45
+ path = path.resolve()
46
+ key = hashlib.md5(str(path).encode(), usedforsecurity=False).hexdigest()
47
+ name = f"graphviagent_pipe_{key}"
48
+ spec = importlib.util.spec_from_file_location(name, path)
49
+ if spec is None or spec.loader is None:
50
+ raise ImportError(f"cannot import {path}")
51
+ module = importlib.util.module_from_spec(spec)
52
+ spec.loader.exec_module(module)
53
+ return module
54
+
55
+
56
+ def load_pipeline(path: Path) -> LoadedPipeline:
57
+ path = path.resolve()
58
+ loaded = LoadedPipeline(path=path, stem=path.stem)
59
+ try:
60
+ module = load_module(path)
61
+ loaded.module = module
62
+ examples = getattr(module, "EXAMPLES", None)
63
+ if isinstance(examples, list):
64
+ loaded.examples = [item for item in examples if isinstance(item, dict)]
65
+
66
+ candidate = None
67
+ for attr in ("GRAPH", "app"):
68
+ obj = getattr(module, attr, None)
69
+ if obj is not None and (_is_runnable(obj) or hasattr(obj, "compile")):
70
+ candidate = obj
71
+ break
72
+ if candidate is None:
73
+ factory_name = getattr(module, "__graph__", None)
74
+ names = [factory_name] if isinstance(factory_name, str) else []
75
+ names.extend(FACTORY_NAMES)
76
+ for name in names:
77
+ fn = getattr(module, name, None)
78
+ if callable(fn):
79
+ candidate = fn()
80
+ break
81
+ if candidate is None:
82
+ raise AttributeError(
83
+ "need GRAPH, app, or build_graph()/get_graph()/create_graph()"
84
+ )
85
+ loaded.app, loaded.has_checkpointer = _compile_if_needed(candidate)
86
+ except Exception as exc:
87
+ loaded.error = f"{type(exc).__name__}: {exc}"
88
+ return loaded
graphviagent/record.py ADDED
@@ -0,0 +1,372 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import time
5
+ from typing import Any
6
+ from uuid import uuid4
7
+
8
+
9
+ def _elapsed_ms(started: float) -> float:
10
+ return round((time.perf_counter() - started) * 1000, 2)
11
+
12
+
13
+ def jsonable(obj: Any) -> Any:
14
+ if obj is None or isinstance(obj, (bool, int, float, str)):
15
+ return obj
16
+ if isinstance(obj, dict):
17
+ return {str(key): jsonable(value) for key, value in obj.items()}
18
+ if isinstance(obj, (list, tuple)):
19
+ return [jsonable(item) for item in obj]
20
+ return str(obj)
21
+
22
+
23
+ def merge_state(current: dict, update: dict) -> dict:
24
+ merged = copy.deepcopy(current)
25
+ for key, value in update.items():
26
+ if isinstance(value, list) and isinstance(merged.get(key), list):
27
+ merged[key] = list(merged[key]) + list(value)
28
+ else:
29
+ merged[key] = copy.deepcopy(value)
30
+ return merged
31
+
32
+
33
+ def _format_error(exc: BaseException) -> str:
34
+ return f"{type(exc).__name__}: {exc}"
35
+
36
+
37
+ def _branch_hints(update: dict) -> list[str]:
38
+ hints: list[str] = []
39
+ for key in ("path", "choice", "loop_choice"):
40
+ value = update.get(key)
41
+ if isinstance(value, str):
42
+ hints.append(value)
43
+ decisions = update.get("decisions") or []
44
+ if decisions and isinstance(decisions[0], dict):
45
+ choice = decisions[0].get("choice")
46
+ if isinstance(choice, str):
47
+ hints.append(choice)
48
+ return hints
49
+
50
+
51
+ def _guess_failed_node(raw_events: list[tuple[str, dict, float]], edges: list[tuple[str, str]]) -> str | None:
52
+ if not raw_events:
53
+ starts = [target for source, target in edges if source in {"__start__", "START"}]
54
+ return starts[0] if starts else None
55
+ last, last_update, _ = raw_events[-1]
56
+ nxt = [
57
+ target
58
+ for source, target in edges
59
+ if source == last and target not in {"__end__", "END", "__start__", "START"}
60
+ ]
61
+ if not nxt:
62
+ return last
63
+ if len(nxt) == 1:
64
+ return nxt[0]
65
+ if isinstance(last_update, dict):
66
+ for hint in _branch_hints(last_update):
67
+ if hint in nxt:
68
+ return hint
69
+ return nxt[0]
70
+
71
+
72
+ def extract_reason(update: dict) -> str:
73
+ if not isinstance(update, dict):
74
+ return ""
75
+ if isinstance(update.get("error"), str):
76
+ return update["error"]
77
+ if isinstance(update.get("reason"), str):
78
+ return update["reason"]
79
+ if update.get("choice") is not None and not update.get("decisions"):
80
+ return str(update["choice"])
81
+ decisions = update.get("decisions") or []
82
+ if decisions and isinstance(decisions[0], dict):
83
+ return str(decisions[0].get("reason") or decisions[0].get("choice") or "")
84
+ return ""
85
+
86
+
87
+ def extract_decisions(update: dict) -> list[dict]:
88
+ if not isinstance(update, dict):
89
+ return []
90
+ decisions = update.get("decisions")
91
+ if isinstance(decisions, list):
92
+ return [item for item in decisions if isinstance(item, dict)]
93
+ if update.get("reason") or update.get("choice") is not None:
94
+ return [
95
+ {
96
+ "step": update.get("step") or "",
97
+ "choice": update.get("choice"),
98
+ "reason": update.get("reason") or str(update.get("choice")),
99
+ }
100
+ ]
101
+ return []
102
+
103
+
104
+ def graph_edges(app: Any) -> list[tuple[str, str]]:
105
+ try:
106
+ graph = app.get_graph()
107
+ except Exception:
108
+ return []
109
+ edges: list[tuple[str, str]] = []
110
+ for edge in getattr(graph, "edges", []) or []:
111
+ source = getattr(edge, "source", None)
112
+ target = getattr(edge, "target", None)
113
+ if source is None and isinstance(edge, (tuple, list)) and len(edge) >= 2:
114
+ source, target = edge[0], edge[1]
115
+ if source is None or target is None:
116
+ continue
117
+ edges.append((str(source), str(target)))
118
+ return edges
119
+
120
+
121
+ def unused_targets(node: str, next_node: str | None, edges: list[tuple[str, str]]) -> list[str]:
122
+ unused: list[str] = []
123
+ for source, target in edges:
124
+ if source != node:
125
+ continue
126
+ if target in {"__end__", "END", "__start__", "START"}:
127
+ if next_node is None and target in {"__end__", "END"}:
128
+ continue
129
+ if next_node and target == next_node:
130
+ continue
131
+ if next_node is None and target in {"__end__", "END"}:
132
+ continue
133
+ unused.append(target)
134
+ return unused
135
+
136
+
137
+ def invoke_node(app: Any, node_name: str, state: dict) -> dict:
138
+ node = app.nodes[node_name]
139
+ if hasattr(node, "invoke"):
140
+ result = node.invoke(state)
141
+ return result if isinstance(result, dict) else {"result": result}
142
+ bound = getattr(node, "bound", None) or getattr(node, "runnable", None)
143
+ if bound is not None and hasattr(bound, "invoke"):
144
+ result = bound.invoke(state)
145
+ return result if isinstance(result, dict) else {"result": result}
146
+ raise RuntimeError(f"cannot invoke node {node_name!r}")
147
+
148
+
149
+ def record_run(
150
+ app: Any,
151
+ user_input: dict,
152
+ *,
153
+ thread_id: str | None = None,
154
+ has_checkpointer: bool = False,
155
+ ) -> dict:
156
+ run_id = thread_id or uuid4().hex
157
+ config = {"configurable": {"thread_id": run_id}} if has_checkpointer else {}
158
+ state = copy.deepcopy(user_input)
159
+ steps: list[dict] = []
160
+ visits: dict[str, int] = {}
161
+ edges = graph_edges(app)
162
+
163
+ stream = app.stream(user_input, config) if config else app.stream(user_input)
164
+ raw_events: list[tuple[str, dict, float]] = []
165
+ run_error: str | None = None
166
+ started = time.perf_counter()
167
+ try:
168
+ for event in stream:
169
+ if not isinstance(event, dict) or not event:
170
+ continue
171
+ node, update = next(iter(event.items()))
172
+ if not isinstance(update, dict):
173
+ update = {"value": update}
174
+ raw_events.append((str(node), update, _elapsed_ms(started)))
175
+ started = time.perf_counter()
176
+ except Exception as exc:
177
+ run_error = _format_error(exc)
178
+ failed = _guess_failed_node(raw_events, edges)
179
+ if failed:
180
+ raw_events.append((failed, {"error": run_error}, _elapsed_ms(started)))
181
+
182
+ for index, (node, update, elapsed_ms) in enumerate(raw_events):
183
+ visits[node] = visits.get(node, 0) + 1
184
+ next_node = raw_events[index + 1][0] if index + 1 < len(raw_events) else None
185
+ state_in = copy.deepcopy(state)
186
+ failed = isinstance(update, dict) and update.get("error")
187
+ state_out = copy.deepcopy(state_in) if failed else merge_state(state, update)
188
+ step = {
189
+ "step_id": f"{node}#{visits[node]}",
190
+ "index": index,
191
+ "node": node,
192
+ "update": jsonable(update),
193
+ "state_in": jsonable(state_in),
194
+ "state_out": jsonable(state_out),
195
+ "reason": extract_reason(update),
196
+ "decisions": jsonable(extract_decisions(update)),
197
+ "unused": unused_targets(node, next_node, edges),
198
+ "elapsed_ms": elapsed_ms,
199
+ }
200
+ if failed:
201
+ step["error"] = str(update.get("error"))
202
+ steps.append(step)
203
+ state = state_out
204
+
205
+ return {
206
+ "id": run_id,
207
+ "input": jsonable(user_input),
208
+ "steps": steps,
209
+ "result": jsonable(state),
210
+ "has_checkpointer": has_checkpointer,
211
+ "thread_id": run_id if has_checkpointer else None,
212
+ "elapsed_ms": round(sum(step.get("elapsed_ms") or 0 for step in steps), 2),
213
+ "error": run_error,
214
+ }
215
+
216
+
217
+ def _incoming_state(
218
+ step: dict, state_patch: dict | None = None, state_in: dict | None = None
219
+ ) -> dict:
220
+ if state_in is not None:
221
+ if not isinstance(state_in, dict):
222
+ return {"value": state_in}
223
+ return copy.deepcopy(state_in)
224
+ return merge_state(copy.deepcopy(step.get("state_in") or {}), state_patch or {})
225
+
226
+
227
+ def replay_step(
228
+ app: Any,
229
+ run: dict,
230
+ step_id: str,
231
+ state_patch: dict | None = None,
232
+ state_in: dict | None = None,
233
+ ) -> dict:
234
+ step = _find_step(run, step_id)
235
+ state_in = _incoming_state(step, state_patch, state_in)
236
+ started = time.perf_counter()
237
+ try:
238
+ update = invoke_node(app, step["node"], state_in)
239
+ if not isinstance(update, dict):
240
+ update = {"value": update}
241
+ run_error = None
242
+ except Exception as exc:
243
+ run_error = _format_error(exc)
244
+ update = {"error": run_error}
245
+ elapsed_ms = _elapsed_ms(started)
246
+ state_out = copy.deepcopy(state_in) if run_error else merge_state(state_in, update)
247
+ new_step = {
248
+ "step_id": f"{step['node']}#replay",
249
+ "index": 0,
250
+ "node": step["node"],
251
+ "update": jsonable(update),
252
+ "state_in": jsonable(state_in),
253
+ "state_out": jsonable(state_out),
254
+ "reason": extract_reason(update),
255
+ "decisions": jsonable(extract_decisions(update)),
256
+ "unused": [],
257
+ "elapsed_ms": elapsed_ms,
258
+ }
259
+ if run_error:
260
+ new_step["error"] = run_error
261
+ return {
262
+ "id": uuid4().hex,
263
+ "input": jsonable(state_in),
264
+ "steps": [new_step],
265
+ "result": jsonable(state_out),
266
+ "has_checkpointer": False,
267
+ "thread_id": None,
268
+ "parent_id": run["id"],
269
+ "mode": "replay",
270
+ "from_step": step_id,
271
+ "elapsed_ms": elapsed_ms,
272
+ "error": run_error,
273
+ }
274
+
275
+
276
+ def resume_from_step(
277
+ app: Any,
278
+ run: dict,
279
+ step_id: str,
280
+ state_patch: dict | None = None,
281
+ state_in: dict | None = None,
282
+ ) -> dict:
283
+ step = _find_step(run, step_id)
284
+ start = step["index"]
285
+ remaining = run["steps"][start:]
286
+ state = _incoming_state(step, state_patch, state_in)
287
+ steps: list[dict] = []
288
+ visits: dict[str, int] = {}
289
+ edges = graph_edges(app)
290
+
291
+ if run.get("has_checkpointer") and run.get("thread_id"):
292
+ try:
293
+ return _resume_with_checkpointer(app, run, step, state)
294
+ except Exception:
295
+ pass
296
+
297
+ raw: list[tuple[str, dict, float]] = []
298
+ current = state
299
+ run_error: str | None = None
300
+ for recorded in remaining:
301
+ started = time.perf_counter()
302
+ try:
303
+ update = invoke_node(app, recorded["node"], current)
304
+ if not isinstance(update, dict):
305
+ update = {"value": update}
306
+ except Exception as exc:
307
+ run_error = _format_error(exc)
308
+ update = {"error": run_error}
309
+ raw.append((recorded["node"], update, _elapsed_ms(started)))
310
+ break
311
+ raw.append((recorded["node"], update, _elapsed_ms(started)))
312
+ current = merge_state(current, update)
313
+
314
+ current = copy.deepcopy(state)
315
+ for index, (node, update, elapsed_ms) in enumerate(raw):
316
+ visits[node] = visits.get(node, 0) + 1
317
+ next_node = raw[index + 1][0] if index + 1 < len(raw) else None
318
+ state_in = copy.deepcopy(current)
319
+ failed = isinstance(update, dict) and update.get("error")
320
+ state_out = copy.deepcopy(state_in) if failed else merge_state(current, update)
321
+ step = {
322
+ "step_id": f"{node}#{visits[node]}",
323
+ "index": index,
324
+ "node": node,
325
+ "update": jsonable(update),
326
+ "state_in": jsonable(state_in),
327
+ "state_out": jsonable(state_out),
328
+ "reason": extract_reason(update),
329
+ "decisions": jsonable(extract_decisions(update)),
330
+ "unused": unused_targets(node, next_node, edges),
331
+ "elapsed_ms": elapsed_ms,
332
+ }
333
+ if failed:
334
+ step["error"] = str(update.get("error"))
335
+ steps.append(step)
336
+ current = state_out
337
+
338
+ return {
339
+ "id": uuid4().hex,
340
+ "input": jsonable(state),
341
+ "steps": steps,
342
+ "result": jsonable(current),
343
+ "has_checkpointer": False,
344
+ "thread_id": None,
345
+ "parent_id": run["id"],
346
+ "mode": "replay_from",
347
+ "from_step": step_id,
348
+ "elapsed_ms": round(sum(item.get("elapsed_ms") or 0 for item in steps), 2),
349
+ "error": run_error,
350
+ }
351
+
352
+
353
+ def _resume_with_checkpointer(app: Any, run: dict, step: dict, state_in: dict) -> dict:
354
+ config = {"configurable": {"thread_id": run["thread_id"]}}
355
+ update = invoke_node(app, step["node"], state_in)
356
+ if not isinstance(update, dict):
357
+ update = {"value": update}
358
+ app.update_state(config, update, as_node=step["node"])
359
+ app.invoke(None, config)
360
+ return record_run(
361
+ app,
362
+ run["input"],
363
+ thread_id=uuid4().hex,
364
+ has_checkpointer=True,
365
+ )
366
+
367
+
368
+ def _find_step(run: dict, step_id: str) -> dict:
369
+ for step in run.get("steps") or []:
370
+ if step.get("step_id") == step_id:
371
+ return step
372
+ raise KeyError(f"unknown step {step_id}")
graphviagent/render.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def _reason(step: dict) -> str:
5
+ return step.get("reason") or ""
6
+
7
+
8
+ def format_elapsed(ms: object) -> str:
9
+ try:
10
+ value = float(ms) # type: ignore[arg-type]
11
+ except (TypeError, ValueError):
12
+ return ""
13
+ if value < 1000:
14
+ text = f"{value:.1f}ms"
15
+ return text.replace(".0ms", "ms")
16
+ return f"{value / 1000:.2f}s"
17
+
18
+
19
+ def _detail(step: dict) -> str:
20
+ update = step.get("update") or {}
21
+ if not isinstance(update, dict):
22
+ return str(update)
23
+ reason = _reason(step)
24
+ if reason:
25
+ return reason
26
+ for key in ("greeting", "shout", "polished", "output", "result"):
27
+ if update.get(key) not in (None, ""):
28
+ return str(update[key])
29
+ if not update:
30
+ return ""
31
+ parts = [f"{key}={update[key]}" for key in list(update)[:3]]
32
+ return ", ".join(parts)
33
+
34
+
35
+ def ascii_tree(title: str, steps: list[dict]) -> str:
36
+ lines = [title]
37
+ indent = 0
38
+ for step in steps:
39
+ extra = _detail(step)
40
+ elapsed = format_elapsed(step.get("elapsed_ms"))
41
+ bits = [part for part in (extra, elapsed) if part]
42
+ suffix = f" {' '.join(bits)}" if bits else ""
43
+ lines.append(f"{' ' * indent}└─ {step['node']}{suffix}")
44
+ indent += 1
45
+ if steps:
46
+ lines.append(f"{' ' * indent}└─ END")
47
+ return "\n".join(lines)
48
+
49
+
50
+ def _esc(text: str) -> str:
51
+ return (
52
+ text.replace('"', "#quot;")
53
+ .replace("<", " ")
54
+ .replace(">", " ")
55
+ .replace("\n", "<br/>")
56
+ )
57
+
58
+
59
+ def unrolled_mermaid(steps: list[dict]) -> str:
60
+ lines = ["flowchart TD", " startNode([START])"]
61
+ prev = "startNode"
62
+ for index, step in enumerate(steps):
63
+ nid = f"n{index}"
64
+ detail = _detail(step)
65
+ label = f"{step['node']}<br/>{detail}" if detail else step["node"]
66
+ lines.append(f' {nid}["{_esc(label)}"]')
67
+ lines.append(f" {prev} --> {nid}")
68
+ for unused_i, unused in enumerate(step.get("unused") or []):
69
+ other_id = f"{nid}u{unused_i}"
70
+ lines.append(f' {other_id}["{_esc(str(unused))}<br/>not taken"]:::skipped')
71
+ lines.append(f" {nid} -.-> {other_id}")
72
+ prev = nid
73
+ lines.append(" endNode([END])")
74
+ lines.append(f" {prev} --> endNode")
75
+ lines.append(" classDef skipped fill:#1c1c22,stroke:#3a3a44,color:#8b8b96")
76
+ return "\n".join(lines)