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.
- graphviagent/__init__.py +5 -0
- graphviagent/cli.py +49 -0
- graphviagent/discover.py +26 -0
- graphviagent/load.py +88 -0
- graphviagent/record.py +372 -0
- graphviagent/render.py +76 -0
- graphviagent/server.py +1378 -0
- graphviagent/store.py +91 -0
- graphviagent-0.1.0.dist-info/METADATA +107 -0
- graphviagent-0.1.0.dist-info/RECORD +14 -0
- graphviagent-0.1.0.dist-info/WHEEL +5 -0
- graphviagent-0.1.0.dist-info/entry_points.txt +2 -0
- graphviagent-0.1.0.dist-info/licenses/LICENSE +21 -0
- graphviagent-0.1.0.dist-info/top_level.txt +1 -0
graphviagent/__init__.py
ADDED
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()
|
graphviagent/discover.py
ADDED
|
@@ -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)
|