windhover 0.8.1__tar.gz → 0.10.0__tar.gz
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.
- {windhover-0.8.1 → windhover-0.10.0}/PKG-INFO +14 -4
- {windhover-0.8.1 → windhover-0.10.0}/README.md +13 -3
- {windhover-0.8.1 → windhover-0.10.0}/pyproject.toml +1 -1
- {windhover-0.8.1 → windhover-0.10.0}/tests/test_smoke.py +128 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/demo_graph.py +28 -4
- {windhover-0.8.1 → windhover-0.10.0}/windhover/server.py +134 -20
- {windhover-0.8.1 → windhover-0.10.0}/windhover/static/index.html +195 -7
- {windhover-0.8.1 → windhover-0.10.0}/windhover/store.py +13 -5
- {windhover-0.8.1 → windhover-0.10.0}/windhover/tracer.py +76 -10
- {windhover-0.8.1 → windhover-0.10.0}/.github/workflows/ci.yml +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/.gitignore +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/LICENSE +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/SPEC.md +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/docs/graph.png +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/docs/logo.svg +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/docs/runs.png +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/docs/social-preview.png +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/docs/stats.png +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/docs/trace.png +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/tests/__init__.py +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/__init__.py +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/config.py +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/extract.py +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/pricing.json +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/static/icon.svg +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/static/manifest.json +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/cytoscape-dagre.js +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/cytoscape.min.js +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/dagre.min.js +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-500.woff2 +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2 +0 -0
- {windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-600.woff2 +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: windhover
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view.
|
|
5
5
|
Project-URL: Repository, https://github.com/justfeltlikerunning/windhover
|
|
6
6
|
Author: justfeltlikerunning
|
|
@@ -81,8 +81,11 @@ graph.invoke(input, config={
|
|
|
81
81
|
- **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
|
|
82
82
|
- **Error forensics** — failed runs show the full traceback; the failing node turns red on the
|
|
83
83
|
graph, and the node's source renders with the **throwing line highlighted**.
|
|
84
|
-
- **Human-in-the-loop
|
|
85
|
-
|
|
84
|
+
- **Human-in-the-loop console** — a paused graph shows an amber **interrupted** status with the
|
|
85
|
+
question it's asking; answer it (`Command(resume=…)`), redirect it (`Command(goto=…)`), set
|
|
86
|
+
static breakpoints per run (`interrupt_before`), **edit state** at any checkpoint
|
|
87
|
+
(`update_state`), or **fork** a thread from any historical checkpoint — all from the UI,
|
|
88
|
+
all pure LangGraph primitives.
|
|
86
89
|
- **State evolution** — every trace shows which state keys each node wrote, in order.
|
|
87
90
|
- **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
|
|
88
91
|
(`get_graph(xray=True)`).
|
|
@@ -90,7 +93,14 @@ graph.invoke(input, config={
|
|
|
90
93
|
status/tag/session filters, bookmarks, pagination, CSV/JSON export.
|
|
91
94
|
- **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
|
|
92
95
|
- **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
|
|
93
|
-
- **Live tail** — open a running run and watch its spans arrive in real time
|
|
96
|
+
- **Live tail** — open a running run and watch its spans arrive in real time; nodes can push
|
|
97
|
+
progress via LangGraph's `get_stream_writer()`.
|
|
98
|
+
- **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
|
|
99
|
+
event marker in the trace, parented to the node that fired it.
|
|
100
|
+
- **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
|
|
101
|
+
time-to-first-token; cache-read / reasoning token details show on the model line.
|
|
102
|
+
- **Memory browser** — graphs compiled with a LangGraph `Store` get a Memory view: browse
|
|
103
|
+
namespaces and search long-term memory items.
|
|
94
104
|
- **Time-travel** — checkpointed graphs get a per-thread checkpoint browser: state, writes,
|
|
95
105
|
and next-nodes at every superstep (`get_state_history`).
|
|
96
106
|
- **Run diff** — compare any two runs node-by-node: identical vs differing outputs,
|
|
@@ -62,8 +62,11 @@ graph.invoke(input, config={
|
|
|
62
62
|
- **Clickable graph** — tap a node for health, latency, wiring, its **source code**, and recent executions with payloads.
|
|
63
63
|
- **Error forensics** — failed runs show the full traceback; the failing node turns red on the
|
|
64
64
|
graph, and the node's source renders with the **throwing line highlighted**.
|
|
65
|
-
- **Human-in-the-loop
|
|
66
|
-
|
|
65
|
+
- **Human-in-the-loop console** — a paused graph shows an amber **interrupted** status with the
|
|
66
|
+
question it's asking; answer it (`Command(resume=…)`), redirect it (`Command(goto=…)`), set
|
|
67
|
+
static breakpoints per run (`interrupt_before`), **edit state** at any checkpoint
|
|
68
|
+
(`update_state`), or **fork** a thread from any historical checkpoint — all from the UI,
|
|
69
|
+
all pure LangGraph primitives.
|
|
67
70
|
- **State evolution** — every trace shows which state keys each node wrote, in order.
|
|
68
71
|
- **X-ray** — graphs with subgraphs get a canvas toggle that expands composite nodes
|
|
69
72
|
(`get_graph(xray=True)`).
|
|
@@ -71,7 +74,14 @@ graph.invoke(input, config={
|
|
|
71
74
|
status/tag/session filters, bookmarks, pagination, CSV/JSON export.
|
|
72
75
|
- **Sessions** — group runs into threads/batches; roll-up tokens, cost, errors.
|
|
73
76
|
- **Scores** — attach numeric evals to runs (API or UI): eval harnesses, LLM-as-judge, human review.
|
|
74
|
-
- **Live tail** — open a running run and watch its spans arrive in real time
|
|
77
|
+
- **Live tail** — open a running run and watch its spans arrive in real time; nodes can push
|
|
78
|
+
progress via LangGraph's `get_stream_writer()`.
|
|
79
|
+
- **Custom events** — `dispatch_custom_event("name", {...})` anywhere in your app lands as an
|
|
80
|
+
event marker in the trace, parented to the node that fired it.
|
|
81
|
+
- **Retries + TTFT** — tenacity retries badge the span (`↻2`); streaming LLM calls record
|
|
82
|
+
time-to-first-token; cache-read / reasoning token details show on the model line.
|
|
83
|
+
- **Memory browser** — graphs compiled with a LangGraph `Store` get a Memory view: browse
|
|
84
|
+
namespaces and search long-term memory items.
|
|
75
85
|
- **Time-travel** — checkpointed graphs get a per-thread checkpoint browser: state, writes,
|
|
76
86
|
and next-nodes at every superstep (`get_state_history`).
|
|
77
87
|
- **Run diff** — compare any two runs node-by-node: identical vs differing outputs,
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "windhover"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.10.0"
|
|
8
8
|
description = "Self-hosted, mobile-friendly observability for LangGraph — traces, run history, cost, and a living graph view."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -299,6 +299,130 @@ def test_datasets_and_scoring():
|
|
|
299
299
|
print("datasets + scoring OK")
|
|
300
300
|
|
|
301
301
|
|
|
302
|
+
def test_ttft_retry_custom_event_usage_detail():
|
|
303
|
+
p = tempfile.mktemp(suffix=".db")
|
|
304
|
+
s = Store(p)
|
|
305
|
+
tr = SpanBuilder(db_sink(s), run_name="deep")
|
|
306
|
+
tr.on_chain_start({}, {"q": 1}, run_id="root", parent_run_id=None)
|
|
307
|
+
tr.on_chat_model_start({"kwargs": {"model": "gpt-4o"}}, [[]],
|
|
308
|
+
run_id="llm1", parent_run_id="root")
|
|
309
|
+
time.sleep(0.02)
|
|
310
|
+
tr.on_llm_new_token("Hel", run_id="llm1") # -> ttft stamped
|
|
311
|
+
tr.on_llm_new_token("lo", run_id="llm1") # ignored (only first counts)
|
|
312
|
+
|
|
313
|
+
class RS: # tenacity RetryCallState stand-in
|
|
314
|
+
attempt_number = 3
|
|
315
|
+
tr.on_retry(RS(), run_id="llm1")
|
|
316
|
+
|
|
317
|
+
class Msg:
|
|
318
|
+
content = "Hello"
|
|
319
|
+
usage_metadata = {"input_tokens": 900, "output_tokens": 40,
|
|
320
|
+
"input_token_details": {"cache_read": 700},
|
|
321
|
+
"output_token_details": {"reasoning": 12}}
|
|
322
|
+
class Gen:
|
|
323
|
+
text = "Hello"; message = Msg()
|
|
324
|
+
class Resp:
|
|
325
|
+
llm_output = None; generations = [[Gen()]]
|
|
326
|
+
tr.on_llm_end(Resp(), run_id="llm1")
|
|
327
|
+
tr.on_custom_event("checkpoint-saved", {"rows": 42}, run_id="root")
|
|
328
|
+
tr.on_chain_end({}, run_id="root")
|
|
329
|
+
|
|
330
|
+
d = s.run_detail(tr.run_id)
|
|
331
|
+
llm = [x for x in d["spans"] if x["type"] == "llm"][0]
|
|
332
|
+
assert llm["ttft_ms"] is not None and llm["ttft_ms"] >= 15
|
|
333
|
+
assert llm["retries"] == 3
|
|
334
|
+
assert llm["usage_detail"] == {"input_cache_read": 700, "output_reasoning": 12}
|
|
335
|
+
assert llm["prompt_tokens"] == 900
|
|
336
|
+
ev = [x for x in d["spans"] if x["type"] == "event"]
|
|
337
|
+
assert ev and ev[0]["name"] == "checkpoint-saved" and ev[0]["output"] == {"rows": 42}
|
|
338
|
+
print("ttft/retry/custom-event/usage-detail OK")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_demo_memory_and_events_end_to_end():
|
|
342
|
+
"""Demo graph writes long-term memory + dispatches a custom event — both
|
|
343
|
+
must be observable."""
|
|
344
|
+
import importlib
|
|
345
|
+
import windhover.demo_graph as dg
|
|
346
|
+
importlib.reload(dg) # fresh InMemoryStore per test
|
|
347
|
+
p = tempfile.mktemp(suffix=".db")
|
|
348
|
+
s = Store(p)
|
|
349
|
+
tr = SpanBuilder(db_sink(s), run_name="demo")
|
|
350
|
+
dg.graph.invoke({"n": 7}, config={"callbacks": [tr],
|
|
351
|
+
"configurable": {"thread_id": "mem-t"}})
|
|
352
|
+
time.sleep(.1)
|
|
353
|
+
d = s.run_detail(tr.run_id)
|
|
354
|
+
ev = [x for x in d["spans"] if x["type"] == "event"]
|
|
355
|
+
assert ev and ev[0]["name"] == "summary-ready", [x["type"] for x in d["spans"]]
|
|
356
|
+
assert dg.graph.store is not None
|
|
357
|
+
items = dg.graph.store.search(("demo", "summaries"))
|
|
358
|
+
assert items and items[0].value["n"] == 21 # n=7 -> grow x3
|
|
359
|
+
assert ("demo", "summaries") in dg.graph.store.list_namespaces()
|
|
360
|
+
print("demo memory + custom event end-to-end OK")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def test_hitl_interrupt_resume():
|
|
364
|
+
"""Dynamic interrupt pauses (status interrupted); Command(resume) finishes."""
|
|
365
|
+
from typing import TypedDict
|
|
366
|
+
from langgraph.graph import StateGraph, START, END
|
|
367
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
368
|
+
from langgraph.types import Command, interrupt
|
|
369
|
+
p = tempfile.mktemp(suffix=".db")
|
|
370
|
+
s = Store(p)
|
|
371
|
+
|
|
372
|
+
class St(TypedDict):
|
|
373
|
+
x: int
|
|
374
|
+
def gate(st):
|
|
375
|
+
ok = interrupt({"question": f"allow x={st['x']}?"})
|
|
376
|
+
return {"x": st["x"] if ok else 0}
|
|
377
|
+
g = StateGraph(St)
|
|
378
|
+
g.add_node("gate", gate)
|
|
379
|
+
g.add_edge(START, "gate"); g.add_edge("gate", END)
|
|
380
|
+
app = g.compile(checkpointer=MemorySaver())
|
|
381
|
+
cfgc = {"configurable": {"thread_id": "hitl-1"}}
|
|
382
|
+
|
|
383
|
+
tr1 = SpanBuilder(db_sink(s), run_name="hitl")
|
|
384
|
+
out = app.invoke({"x": 5}, config={"callbacks": [tr1], **cfgc})
|
|
385
|
+
assert "__interrupt__" in out
|
|
386
|
+
time.sleep(.05)
|
|
387
|
+
assert s.run_detail(tr1.run_id)["status"] == "interrupted"
|
|
388
|
+
|
|
389
|
+
tr2 = SpanBuilder(db_sink(s), run_name="hitl")
|
|
390
|
+
out2 = app.invoke(Command(resume=True), config={"callbacks": [tr2], **cfgc})
|
|
391
|
+
assert out2["x"] == 5
|
|
392
|
+
time.sleep(.05)
|
|
393
|
+
assert s.run_detail(tr2.run_id)["status"] == "done"
|
|
394
|
+
print("HITL interrupt/resume OK")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def test_static_breakpoint_and_state_edit():
|
|
398
|
+
"""interrupt_before pauses with pending next; update_state edit sticks."""
|
|
399
|
+
from typing import TypedDict
|
|
400
|
+
from langgraph.graph import StateGraph, START, END
|
|
401
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
402
|
+
p = tempfile.mktemp(suffix=".db")
|
|
403
|
+
s = Store(p)
|
|
404
|
+
|
|
405
|
+
class St(TypedDict):
|
|
406
|
+
x: int
|
|
407
|
+
g = StateGraph(St)
|
|
408
|
+
g.add_node("inc", lambda st: {"x": st["x"] + 1})
|
|
409
|
+
g.add_node("mul", lambda st: {"x": st["x"] * 10})
|
|
410
|
+
g.add_edge(START, "inc"); g.add_edge("inc", "mul"); g.add_edge("mul", END)
|
|
411
|
+
app = g.compile(checkpointer=MemorySaver())
|
|
412
|
+
cfgc = {"configurable": {"thread_id": "bp-1"}}
|
|
413
|
+
|
|
414
|
+
tr = SpanBuilder(db_sink(s), run_name="bp")
|
|
415
|
+
for _ in app.stream({"x": 1}, config={"callbacks": [tr], **cfgc},
|
|
416
|
+
stream_mode="updates", interrupt_before=["mul"]):
|
|
417
|
+
pass
|
|
418
|
+
st = app.get_state(cfgc)
|
|
419
|
+
assert st.next == ("mul",), st.next # paused before mul
|
|
420
|
+
app.update_state(cfgc, {"x": 100}) # human edits the state
|
|
421
|
+
out = app.invoke(None, config=cfgc) # continue
|
|
422
|
+
assert out["x"] == 1000, out # 100 * 10 — edit took effect
|
|
423
|
+
print("static breakpoint + state edit OK")
|
|
424
|
+
|
|
425
|
+
|
|
302
426
|
if __name__ == "__main__":
|
|
303
427
|
test_cost(); print("cost OK")
|
|
304
428
|
test_store_roundtrip()
|
|
@@ -314,4 +438,8 @@ if __name__ == "__main__":
|
|
|
314
438
|
test_auth_check()
|
|
315
439
|
test_thread_capture_and_history()
|
|
316
440
|
test_datasets_and_scoring()
|
|
441
|
+
test_ttft_retry_custom_event_usage_detail()
|
|
442
|
+
test_demo_memory_and_events_end_to_end()
|
|
443
|
+
test_hitl_interrupt_resume()
|
|
444
|
+
test_static_breakpoint_and_state_edit()
|
|
317
445
|
print("ALL SMOKE TESTS PASSED")
|
|
@@ -24,11 +24,34 @@ def seed(s):
|
|
|
24
24
|
if n < 0:
|
|
25
25
|
raise ValueError(f"n must be non-negative, got {n} — the demo guard tripped")
|
|
26
26
|
return {"n": n}
|
|
27
|
-
def grow(s):
|
|
27
|
+
def grow(s):
|
|
28
|
+
time.sleep(.3)
|
|
29
|
+
grown = s["n"] * 3
|
|
30
|
+
if grown > 500: # human-in-the-loop: big growth needs approval
|
|
31
|
+
from langgraph.types import interrupt
|
|
32
|
+
approved = interrupt({"question": f"grow {s['n']} -> {grown}? (true/false)"})
|
|
33
|
+
if not approved:
|
|
34
|
+
return {"n": s["n"]}
|
|
35
|
+
return {"n": grown}
|
|
28
36
|
def parity(s): time.sleep(.4); return {"notes": ["even" if s["n"] % 2 == 0 else "odd"]}
|
|
29
37
|
def sign(s): time.sleep(.25); return {"notes": ["positive" if s["n"] > 0 else "non-positive"]}
|
|
30
38
|
def magnitude(s): time.sleep(.35); return {"notes": ["big" if abs(s["n"]) > 100 else "small"]}
|
|
31
|
-
def summarize(s):
|
|
39
|
+
def summarize(s):
|
|
40
|
+
time.sleep(.1)
|
|
41
|
+
try: # progress + custom events + long-term memory, all observable in Windhover
|
|
42
|
+
from langgraph.config import get_stream_writer, get_store
|
|
43
|
+
writer = get_stream_writer()
|
|
44
|
+
writer({"stage": "summarize", "pct": 50})
|
|
45
|
+
from langchain_core.callbacks import dispatch_custom_event
|
|
46
|
+
dispatch_custom_event("summary-ready", {"n": s["n"], "parts": len(s["notes"])})
|
|
47
|
+
store = get_store()
|
|
48
|
+
if store is not None:
|
|
49
|
+
store.put(("demo", "summaries"), f"n-{s['n']}",
|
|
50
|
+
{"n": s["n"], "notes": s["notes"]})
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
time.sleep(.1)
|
|
54
|
+
return {"notes": [f"n={s['n']}: " + ", ".join(s["notes"])]}
|
|
32
55
|
|
|
33
56
|
|
|
34
57
|
_g = StateGraph(State)
|
|
@@ -45,8 +68,9 @@ _g.add_edge("sign", "summarize")
|
|
|
45
68
|
_g.add_edge("magnitude", "summarize")
|
|
46
69
|
_g.add_edge("summarize", END)
|
|
47
70
|
|
|
48
|
-
try: # checkpointer
|
|
71
|
+
try: # checkpointer + store make Time-travel and Memory demoable
|
|
49
72
|
from langgraph.checkpoint.memory import MemorySaver
|
|
50
|
-
|
|
73
|
+
from langgraph.store.memory import InMemoryStore
|
|
74
|
+
graph = _g.compile(checkpointer=MemorySaver(), store=InMemoryStore())
|
|
51
75
|
except Exception:
|
|
52
76
|
graph = _g.compile()
|
|
@@ -163,37 +163,41 @@ def _sse(ev: str, data: dict) -> str:
|
|
|
163
163
|
return f"event: {ev}\ndata: {json.dumps(data)}\n\n"
|
|
164
164
|
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
try:
|
|
171
|
-
payload = await request.json()
|
|
172
|
-
except Exception:
|
|
173
|
-
payload = {}
|
|
174
|
-
session = payload.pop("_session", None)
|
|
175
|
-
tags = payload.pop("_tags", None)
|
|
176
|
-
thread = payload.pop("_thread", None)
|
|
177
|
-
tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
|
|
166
|
+
def _stream_execution(graph_input, config, tracer, stream_kwargs=None):
|
|
167
|
+
"""Shared SSE executor for /api/run and /api/threads/…/resume. Detects both
|
|
168
|
+
dynamic interrupts (__interrupt__ updates) and static breakpoints (stream
|
|
169
|
+
ends with pending next-nodes) and corrects the run status accordingly."""
|
|
178
170
|
run_id = tracer.run_id
|
|
179
|
-
config = {"callbacks": [tracer]}
|
|
180
|
-
if thread or getattr(graph, "checkpointer", None) is not None:
|
|
181
|
-
# a checkpointed graph needs a thread; default to the run id so
|
|
182
|
-
# time-travel works out of the box
|
|
183
|
-
config["configurable"] = {"thread_id": thread or run_id}
|
|
184
171
|
q: "queue.Queue" = queue.Queue()
|
|
185
172
|
|
|
186
173
|
def worker():
|
|
187
174
|
try:
|
|
188
175
|
interrupted = False
|
|
189
|
-
for
|
|
190
|
-
|
|
191
|
-
|
|
176
|
+
for mode, chunk in graph.stream(graph_input, config=config,
|
|
177
|
+
stream_mode=["updates", "custom"],
|
|
178
|
+
**(stream_kwargs or {})):
|
|
179
|
+
if mode == "custom":
|
|
180
|
+
# get_stream_writer() output from inside a node -> live progress
|
|
181
|
+
q.put(("progress", {"data": _trunc(chunk, 600)}))
|
|
182
|
+
continue
|
|
183
|
+
for node in chunk:
|
|
192
184
|
if node == "__interrupt__":
|
|
193
185
|
interrupted = True
|
|
194
186
|
q.put(("interrupt", {"run_id": run_id}))
|
|
195
187
|
else:
|
|
196
188
|
q.put(("node", {"node": node}))
|
|
189
|
+
if not interrupted and getattr(graph, "checkpointer", None) is not None:
|
|
190
|
+
try: # static breakpoint: stream ended but nodes are pending
|
|
191
|
+
st = graph.get_state(config)
|
|
192
|
+
if st.next:
|
|
193
|
+
interrupted = True
|
|
194
|
+
q.put(("interrupt", {"run_id": run_id,
|
|
195
|
+
"next": list(st.next)}))
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
if interrupted:
|
|
199
|
+
# tracer closed the run as done on root end; correct it
|
|
200
|
+
store.close_run(run_id, "interrupted", int(time.time() * 1000))
|
|
197
201
|
q.put(("interrupted" if interrupted else "done", {"run_id": run_id}))
|
|
198
202
|
except Exception as e:
|
|
199
203
|
# tracer already recorded the error span/close; surface to the client too
|
|
@@ -212,6 +216,86 @@ async def api_run(request: Request):
|
|
|
212
216
|
return StreamingResponse(stream(), media_type="text/event-stream")
|
|
213
217
|
|
|
214
218
|
|
|
219
|
+
@app.post("/api/run")
|
|
220
|
+
async def api_run(request: Request):
|
|
221
|
+
if graph is None:
|
|
222
|
+
return JSONResponse({"error": "no local graph (WINDHOVER_GRAPH unset)"}, 400)
|
|
223
|
+
try:
|
|
224
|
+
payload = await request.json()
|
|
225
|
+
except Exception:
|
|
226
|
+
payload = {}
|
|
227
|
+
session = payload.pop("_session", None)
|
|
228
|
+
tags = payload.pop("_tags", None)
|
|
229
|
+
thread = payload.pop("_thread", None)
|
|
230
|
+
pause_before = payload.pop("_interrupt_before", None)
|
|
231
|
+
pause_after = payload.pop("_interrupt_after", None)
|
|
232
|
+
tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref, session=session, tags=tags)
|
|
233
|
+
config = {"callbacks": [tracer]}
|
|
234
|
+
if thread or getattr(graph, "checkpointer", None) is not None:
|
|
235
|
+
# a checkpointed graph needs a thread; default to the run id so
|
|
236
|
+
# time-travel works out of the box
|
|
237
|
+
config["configurable"] = {"thread_id": thread or tracer.run_id}
|
|
238
|
+
sk = {}
|
|
239
|
+
if pause_before:
|
|
240
|
+
sk["interrupt_before"] = [str(n) for n in pause_before]
|
|
241
|
+
if pause_after:
|
|
242
|
+
sk["interrupt_after"] = [str(n) for n in pause_after]
|
|
243
|
+
return _stream_execution(payload, config, tracer, sk)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@app.post("/api/threads/{thread_id}/resume")
|
|
247
|
+
async def api_thread_resume(request: Request, thread_id: str):
|
|
248
|
+
"""Human-in-the-loop: answer an interrupt (Command(resume=…)), redirect
|
|
249
|
+
(Command(goto=…)), continue past a static breakpoint (no body), or fork
|
|
250
|
+
from an earlier checkpoint (checkpoint_id)."""
|
|
251
|
+
if graph is None or getattr(graph, "checkpointer", None) is None:
|
|
252
|
+
return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
|
|
253
|
+
try:
|
|
254
|
+
body = await request.json()
|
|
255
|
+
except Exception:
|
|
256
|
+
body = {}
|
|
257
|
+
try:
|
|
258
|
+
from langgraph.types import Command
|
|
259
|
+
except Exception:
|
|
260
|
+
return JSONResponse({"error": "langgraph.types.Command unavailable"}, 500)
|
|
261
|
+
if "value" in body:
|
|
262
|
+
graph_input = Command(resume=body["value"])
|
|
263
|
+
elif body.get("goto"):
|
|
264
|
+
graph_input = Command(goto=str(body["goto"]), update=body.get("update"))
|
|
265
|
+
else:
|
|
266
|
+
graph_input = None # plain continue (static breakpoint / after state edit)
|
|
267
|
+
tracer = SpanBuilder(db_sink(store), run_name=cfg.graph_ref,
|
|
268
|
+
session=body.get("_session"),
|
|
269
|
+
tags=(body.get("_tags") or []) + ["resume"])
|
|
270
|
+
configurable = {"thread_id": thread_id}
|
|
271
|
+
if body.get("checkpoint_id"):
|
|
272
|
+
configurable["checkpoint_id"] = str(body["checkpoint_id"])
|
|
273
|
+
config = {"callbacks": [tracer], "configurable": configurable}
|
|
274
|
+
return _stream_execution(graph_input, config, tracer)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@app.post("/api/threads/{thread_id}/state")
|
|
278
|
+
async def api_thread_update_state(request: Request, thread_id: str):
|
|
279
|
+
"""Human-in-the-loop: edit state at the current (or a given) checkpoint —
|
|
280
|
+
LangGraph's update_state. Follow with …/resume to continue on the edit."""
|
|
281
|
+
if graph is None or getattr(graph, "checkpointer", None) is None:
|
|
282
|
+
return JSONResponse({"error": "no local graph with a checkpointer"}, 400)
|
|
283
|
+
body = await request.json()
|
|
284
|
+
values = body.get("values")
|
|
285
|
+
if not isinstance(values, dict):
|
|
286
|
+
return JSONResponse({"error": "requires values (object of state keys)"}, 400)
|
|
287
|
+
configurable = {"thread_id": thread_id}
|
|
288
|
+
if body.get("checkpoint_id"):
|
|
289
|
+
configurable["checkpoint_id"] = str(body["checkpoint_id"])
|
|
290
|
+
try:
|
|
291
|
+
new_cfg = graph.update_state({"configurable": configurable}, values,
|
|
292
|
+
as_node=body.get("as_node"))
|
|
293
|
+
return JSONResponse({"ok": True, "checkpoint_id":
|
|
294
|
+
(new_cfg.get("configurable") or {}).get("checkpoint_id")})
|
|
295
|
+
except Exception as e:
|
|
296
|
+
return JSONResponse({"error": str(e)}, 400)
|
|
297
|
+
|
|
298
|
+
|
|
215
299
|
@app.post("/api/ingest")
|
|
216
300
|
async def api_ingest(request: Request):
|
|
217
301
|
ev = await request.json()
|
|
@@ -340,6 +424,36 @@ def api_thread_history(thread_id: str, limit: int = 80):
|
|
|
340
424
|
return JSONResponse({"thread_id": thread_id, "steps": steps})
|
|
341
425
|
|
|
342
426
|
|
|
427
|
+
@app.get("/api/memory/namespaces")
|
|
428
|
+
def api_memory_namespaces():
|
|
429
|
+
"""LangGraph long-term memory (Store) browser — namespaces."""
|
|
430
|
+
st = getattr(graph, "store", None) if graph is not None else None
|
|
431
|
+
if st is None:
|
|
432
|
+
return JSONResponse({"error": "no local graph with a store"}, 404)
|
|
433
|
+
try:
|
|
434
|
+
return JSONResponse({"namespaces": [list(ns) for ns in st.list_namespaces()]})
|
|
435
|
+
except Exception as e:
|
|
436
|
+
return JSONResponse({"error": str(e)}, 500)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@app.get("/api/memory/items")
|
|
440
|
+
def api_memory_items(namespace: str, query: str = None, limit: int = 50):
|
|
441
|
+
"""Items in one namespace (dot-separated); optional semantic/text query."""
|
|
442
|
+
st = getattr(graph, "store", None) if graph is not None else None
|
|
443
|
+
if st is None:
|
|
444
|
+
return JSONResponse({"error": "no local graph with a store"}, 404)
|
|
445
|
+
try:
|
|
446
|
+
ns = tuple(namespace.split(".")) if namespace else ()
|
|
447
|
+
items = st.search(ns, query=query, limit=max(1, min(limit, 200)))
|
|
448
|
+
return JSONResponse({"namespace": namespace, "items": [
|
|
449
|
+
{"key": i.key, "value": _trunc(i.value, 3000),
|
|
450
|
+
"created_at": str(getattr(i, "created_at", "") or "") or None,
|
|
451
|
+
"updated_at": str(getattr(i, "updated_at", "") or "") or None,
|
|
452
|
+
"score": getattr(i, "score", None)} for i in items]})
|
|
453
|
+
except Exception as e:
|
|
454
|
+
return JSONResponse({"error": str(e)}, 500)
|
|
455
|
+
|
|
456
|
+
|
|
343
457
|
@app.get("/api/datasets")
|
|
344
458
|
def api_datasets():
|
|
345
459
|
return JSONResponse([{k: d[k] for k in ("id", "name", "n_items", "created_ms")}
|
|
@@ -130,6 +130,58 @@ td.r,th.r{text-align:right}
|
|
|
130
130
|
.empty{padding:56px 20px;text-align:center;color:var(--muted)}
|
|
131
131
|
.empty .t{font-weight:600;color:var(--text2);margin-bottom:4px}
|
|
132
132
|
|
|
133
|
+
/* ---------- human-in-the-loop ---------- */
|
|
134
|
+
async function consumeRunStream(res){
|
|
135
|
+
const rd=res.body.getReader(),dec=new TextDecoder(); let buf='';
|
|
136
|
+
while(true){ const {value,done}=await rd.read(); if(done)break; buf+=dec.decode(value,{stream:true});
|
|
137
|
+
let ps=buf.split('\n\n'); buf=ps.pop();
|
|
138
|
+
for(const p of ps){ const ev=(p.match(/event: (.*)/)||[])[1],dm=(p.match(/data: (.*)/s)||[])[1]; if(!ev)continue; const d=dm?JSON.parse(dm):{};
|
|
139
|
+
if(ev==='node'&&cy){ cy.$id(d.node).addClass('running'); }
|
|
140
|
+
else if(ev==='progress'){ toast('progress: '+JSON.stringify(d.data).slice(0,120)); }
|
|
141
|
+
else if(ev==='interrupted'){ toast('Paused again — awaiting human'); }
|
|
142
|
+
else if(ev==='done'){ toast('Resumed run complete'); }
|
|
143
|
+
else if(ev==='error'){ toast('Resume error: '+(d.message||'failed')); } } }
|
|
144
|
+
if(VIEW==='runs') loadRuns();
|
|
145
|
+
}
|
|
146
|
+
function parseMaybeJSON(v){ if(v==='')return undefined;
|
|
147
|
+
try{ return JSON.parse(v); }catch(e){ return v; } }
|
|
148
|
+
window.resumeThread=async(tid)=>{
|
|
149
|
+
const raw=document.getElementById('hitl-val').value.trim();
|
|
150
|
+
const body={}; const v=parseMaybeJSON(raw); if(v!==undefined) body.value=v;
|
|
151
|
+
closeAll(); switchView('graph'); toast('Resuming thread '+tid.slice(0,8)+'…');
|
|
152
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
153
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
154
|
+
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
155
|
+
await consumeRunStream(res);
|
|
156
|
+
};
|
|
157
|
+
window.resumeGoto=async(tid)=>{
|
|
158
|
+
const g=document.getElementById('hitl-goto').value; if(!g) return toast('Pick a node first');
|
|
159
|
+
closeAll(); switchView('graph'); toast('Redirecting thread to '+g+'…');
|
|
160
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
161
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goto:g})});
|
|
162
|
+
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
163
|
+
await consumeRunStream(res);
|
|
164
|
+
};
|
|
165
|
+
window.rerunFrom=async(tid,cid)=>{
|
|
166
|
+
closeAll(); switchView('graph'); toast('Forking from checkpoint '+String(cid).slice(-8)+'…');
|
|
167
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
168
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({checkpoint_id:cid})});
|
|
169
|
+
if(!res.ok){ toast((await res.json()).error||'fork failed'); return; }
|
|
170
|
+
await consumeRunStream(res);
|
|
171
|
+
};
|
|
172
|
+
window.editState=async(tid,cid,valuesJson)=>{
|
|
173
|
+
const cur=window.prompt('Edit state values (JSON object) — applied via update_state:',valuesJson||'{}');
|
|
174
|
+
if(cur==null) return;
|
|
175
|
+
let values; try{ values=JSON.parse(cur); }catch(e){ return toast('Invalid JSON'); }
|
|
176
|
+
const body={values}; if(cid) body.checkpoint_id=cid;
|
|
177
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state',
|
|
178
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
179
|
+
const d=await res.json();
|
|
180
|
+
if(!res.ok) return toast(d.error||'update_state failed');
|
|
181
|
+
toast('State updated — new checkpoint '+String(d.checkpoint_id||'').slice(-8)+'; Resume to continue');
|
|
182
|
+
showHistory(tid);
|
|
183
|
+
};
|
|
184
|
+
|
|
133
185
|
/* ---------- time-travel (checkpoint history) ---------- */
|
|
134
186
|
window.showHistory=async(tid)=>{
|
|
135
187
|
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
|
|
@@ -153,6 +205,10 @@ window.showHistory=async(tid)=>{
|
|
|
153
205
|
<div class="tr-detail" id="${did}">
|
|
154
206
|
${s.writes!=null?kv('writes',s.writes):''}
|
|
155
207
|
${kv('state at this checkpoint',s.values)}
|
|
208
|
+
<div style="display:flex;gap:8px;margin:4px 0 10px">
|
|
209
|
+
<button class="btn ghost" onclick='rerunFrom("${esc(tid)}","${esc(s.checkpoint_id||'')}")'>⑂ Re-run from here</button>
|
|
210
|
+
<button class="btn ghost" onclick='editState("${esc(tid)}","${esc(s.checkpoint_id||'')}",${JSON.stringify(JSON.stringify(s.values??{}))})'>✎ Edit state</button>
|
|
211
|
+
</div>
|
|
156
212
|
</div></div>`;
|
|
157
213
|
}).join('')||'<div class="empty">No checkpoints recorded for this thread</div>';
|
|
158
214
|
$('#scrim').classList.add('on'); $('#drawer').classList.add('on');
|
|
@@ -236,6 +292,7 @@ window.pickDiff=async(id)=>{
|
|
|
236
292
|
.chip.tool{background:var(--chip-tool);color:var(--chip-tool-t)}
|
|
237
293
|
.chip.retriever{background:var(--chip-ret);color:var(--chip-ret-t)}
|
|
238
294
|
.chip.interrupt{background:var(--warn-weak);color:var(--warn)}
|
|
295
|
+
.chip.event{background:var(--surface2);color:var(--text2);border:1px dashed var(--border2)}
|
|
239
296
|
.tr-name{font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
240
297
|
.tr-name.err{color:var(--err)}
|
|
241
298
|
.tr-gantt{height:6px;background:var(--surface2);border-radius:3px;position:relative;overflow:hidden}
|
|
@@ -296,6 +353,8 @@ textarea:focus{border-color:var(--accent)}
|
|
|
296
353
|
.star.on{color:var(--warn)}
|
|
297
354
|
.tagchip{display:inline-block;background:var(--accent-weak);color:var(--accent-text);font-size:10.5px;font-weight:600;
|
|
298
355
|
padding:1.5px 7px;border-radius:5px;margin:1px 4px 1px 0;cursor:pointer}
|
|
356
|
+
.pause-chip{background:var(--surface2);color:var(--text2);border:1px dashed var(--border2)}
|
|
357
|
+
.pause-chip.on{background:var(--warn-weak);color:var(--warn);border-style:solid;border-color:var(--warn)}
|
|
299
358
|
.scorechip{display:inline-block;background:var(--ok-weak);color:var(--ok);font-size:10.5px;font-weight:600;
|
|
300
359
|
padding:1.5px 7px;border-radius:5px;margin:1px 4px 1px 0}
|
|
301
360
|
.pager{display:flex;align-items:center;gap:12px;justify-content:flex-end;margin-top:10px;color:var(--muted);font-size:12.5px}
|
|
@@ -347,6 +406,8 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
347
406
|
.tr-bar.tool{background:var(--chip-tool-t)}
|
|
348
407
|
.tr-bar.retriever{background:var(--chip-ret-t)}
|
|
349
408
|
.tr-bar.interrupt{background:var(--warn)}
|
|
409
|
+
.tr-bar.event{background:var(--border2)}
|
|
410
|
+
.badge-retry{font-family:var(--mono);font-size:10px;color:var(--warn);background:var(--warn-weak);border-radius:5px;padding:1px 6px;margin-left:6px}
|
|
350
411
|
.charts .card:nth-child(2) .cbar{background:var(--chip-llm-t)}
|
|
351
412
|
#cy{background:radial-gradient(circle,var(--border) 1px,transparent 1.5px) 0 0/24px 24px}
|
|
352
413
|
.legend{font-family:var(--mono);font-size:10.5px;letter-spacing:.06em;text-transform:uppercase}
|
|
@@ -390,9 +451,10 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
390
451
|
<button data-v="graph" class="on">${ICON.graph} Graph</button>
|
|
391
452
|
<button data-v="runs">${ICON.list} Runs</button>
|
|
392
453
|
<button data-v="sessions">${ICON.layers} Sessions</button>
|
|
454
|
+
<button data-v="memory" id="nav-mem" style="display:none">${ICON.mem} Memory</button>
|
|
393
455
|
<button data-v="stats">${ICON.chart} Stats</button>
|
|
394
456
|
</nav>
|
|
395
|
-
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.
|
|
457
|
+
<div class="side-foot"><span class="live-dot" id="live-dot" title="live topology"></span><span id="foot-note">watching</span><span class="spacer"></span><span class="mono" id="ver">v0.10</span></div>
|
|
396
458
|
</aside>
|
|
397
459
|
|
|
398
460
|
<main>
|
|
@@ -444,6 +506,16 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
444
506
|
<div class="card"><div id="sess-wrap"><div class="empty">Loading…</div></div></div>
|
|
445
507
|
</div>
|
|
446
508
|
</div>
|
|
509
|
+
<div id="view-memory" style="display:none">
|
|
510
|
+
<div class="page">
|
|
511
|
+
<div class="page-head"><h1>Memory</h1><span class="sub">LangGraph long-term store</span></div>
|
|
512
|
+
<div class="rtools">
|
|
513
|
+
<select id="mem-ns"></select>
|
|
514
|
+
<input type="search" id="mem-q" placeholder="Search this namespace…">
|
|
515
|
+
</div>
|
|
516
|
+
<div class="card"><div id="mem-wrap"><div class="empty">Loading…</div></div></div>
|
|
517
|
+
</div>
|
|
518
|
+
</div>
|
|
447
519
|
<div id="view-stats" style="display:none">
|
|
448
520
|
<div class="page">
|
|
449
521
|
<div class="page-head"><h1>Stats</h1><span class="sub">all recorded runs</span></div>
|
|
@@ -458,6 +530,7 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
458
530
|
<button data-v="graph" class="on">${ICON.graph}<span>Graph</span></button>
|
|
459
531
|
<button data-v="runs">${ICON.list}<span>Runs</span></button>
|
|
460
532
|
<button data-v="sessions">${ICON.layers}<span>Sessions</span></button>
|
|
533
|
+
<button data-v="memory" class="nav-mem" style="display:none">${ICON.mem}<span>Memory</span></button>
|
|
461
534
|
<button data-v="stats">${ICON.chart}<span>Stats</span></button>
|
|
462
535
|
</div>
|
|
463
536
|
|
|
@@ -481,6 +554,8 @@ th{font-family:var(--mono);letter-spacing:.11em;font-size:10.5px}
|
|
|
481
554
|
<input id="run-session" placeholder="session (optional)" style="flex:1;min-width:130px">
|
|
482
555
|
<input id="run-tags" placeholder="tags, comma-separated (optional)" style="flex:1;min-width:160px">
|
|
483
556
|
</div>
|
|
557
|
+
<div class="hint" style="margin:8px 0 4px">Pause before (static breakpoints — resume from the run drawer):</div>
|
|
558
|
+
<div id="pause-chips" style="display:flex;flex-wrap:wrap;gap:6px"></div>
|
|
484
559
|
<div class="actions">
|
|
485
560
|
<button class="btn ghost" onclick="closeAll()">Cancel</button>
|
|
486
561
|
<button class="btn primary" id="do-run">${ICON.play} Run</button>
|
|
@@ -497,6 +572,7 @@ const ICON = {
|
|
|
497
572
|
list:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M8 6h12M8 12h12M8 18h12"/><circle cx="4" cy="6" r="1.1" fill="currentColor" stroke="none"/><circle cx="4" cy="12" r="1.1" fill="currentColor" stroke="none"/><circle cx="4" cy="18" r="1.1" fill="currentColor" stroke="none"/></svg>',
|
|
498
573
|
chart:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4 20V9M10 20V4M16 20v-7M21 20H3"/></svg>',
|
|
499
574
|
layers:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"><path d="M12 3 3 8l9 5 9-5-9-5z"/><path d="M3 13l9 5 9-5"/></svg>',
|
|
575
|
+
mem:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><ellipse cx="12" cy="5.5" rx="7.5" ry="2.8"/><path d="M4.5 5.5v6c0 1.6 3.4 2.8 7.5 2.8s7.5-1.2 7.5-2.8v-6"/><path d="M4.5 11.5v6c0 1.6 3.4 2.8 7.5 2.8s7.5-1.2 7.5-2.8v-6"/></svg>',
|
|
500
576
|
play:'<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M7 4.8v14.4c0 .8.9 1.3 1.6.9l11-7.2c.6-.4.6-1.4 0-1.8l-11-7.2c-.7-.4-1.6.1-1.6.9z"/></svg>',
|
|
501
577
|
moon:'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M20 13.5A8 8 0 1 1 10.5 4 6.5 6.5 0 0 0 20 13.5z"/></svg>',
|
|
502
578
|
x:'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>',
|
|
@@ -673,20 +749,27 @@ function switchView(v){ VIEW=v;
|
|
|
673
749
|
if(location.hash!=='#'+v) history.replaceState(null,'','#'+v);
|
|
674
750
|
$$('.nav button,.bottom-nav button').forEach(b=>b.classList.toggle('on',b.dataset.v===v));
|
|
675
751
|
$('#crumb').textContent=v[0].toUpperCase()+v.slice(1);
|
|
676
|
-
for(const k of ['graph','runs','sessions','stats'])
|
|
752
|
+
for(const k of ['graph','runs','sessions','memory','stats'])
|
|
677
753
|
$('#view-'+k).style.display=v===k?'':'none';
|
|
678
|
-
if(v==='runs')loadRuns(); if(v==='sessions')loadSessions(); if(v==='
|
|
754
|
+
if(v==='runs')loadRuns(); if(v==='sessions')loadSessions(); if(v==='memory')loadMemory();
|
|
755
|
+
if(v==='stats'){loadStats();loadDatasets();}
|
|
679
756
|
if(cy&&v==='graph')setTimeout(()=>cy.resize(),40);
|
|
680
757
|
}
|
|
681
758
|
$$('.nav button,.bottom-nav button').forEach(b=>b.onclick=()=>switchView(b.dataset.v));
|
|
682
759
|
|
|
683
760
|
/* ---------- new run ---------- */
|
|
684
761
|
$('#newrun').onclick=async()=>{ let tpl={}; try{tpl=(await(await fetch('/api/schema')).json()).template||{};}catch(e){}
|
|
685
|
-
$('#input-json').value=JSON.stringify(tpl,null,2);
|
|
762
|
+
$('#input-json').value=JSON.stringify(tpl,null,2);
|
|
763
|
+
const pc=document.getElementById('pause-chips');
|
|
764
|
+
if(pc&&cy) pc.innerHTML=cy.nodes().filter(n=>!n.data('terminal')).map(n=>
|
|
765
|
+
`<span class="tagchip pause-chip" data-n="${esc(n.id())}" onclick="this.classList.toggle('on')">${esc(n.id())}</span>`).join('');
|
|
766
|
+
$('#scrim').classList.add('on'); $('#modal').classList.add('on'); };
|
|
686
767
|
$('#do-run').onclick=async()=>{ let input; try{input=JSON.parse($('#input-json').value||'{}');}catch(e){return toast('Invalid JSON input');}
|
|
687
768
|
const sess=$('#run-session').value.trim();
|
|
688
769
|
const tags=$('#run-tags').value.split(',').map(s=>s.trim()).filter(Boolean);
|
|
689
770
|
if(sess)input._session=sess; if(tags.length)input._tags=tags;
|
|
771
|
+
const paused=[...document.querySelectorAll('.pause-chip.on')].map(e=>e.dataset.n);
|
|
772
|
+
if(paused.length) input._interrupt_before=paused;
|
|
690
773
|
closeAll(); switchView('graph'); await runNow(input); };
|
|
691
774
|
|
|
692
775
|
async function runNow(input){
|
|
@@ -700,6 +783,7 @@ async function runNow(input){
|
|
|
700
783
|
for(const p of ps){ const ev=(p.match(/event: (.*)/)||[])[1],dm=(p.match(/data: (.*)/s)||[])[1]; if(!ev)continue; const d=dm?JSON.parse(dm):{};
|
|
701
784
|
if(ev==='node'){ cy.$id(prev).addClass('done').removeClass('running'); cy.$id(d.node).addClass('running');
|
|
702
785
|
cy.edges(`[source="${prev}"][target="${d.node}"]`).addClass('flow'); prev=d.node; }
|
|
786
|
+
else if(ev==='progress'){ toast('progress: '+JSON.stringify(d.data).slice(0,120)); }
|
|
703
787
|
else if(ev==='interrupt'){ toast('Run paused at an interrupt — awaiting resume'); }
|
|
704
788
|
else if(ev==='interrupted'){ cy.$id(prev).addClass('running'); toast('Run interrupted — see Runs for the payload'); }
|
|
705
789
|
else if(ev==='done'){ cy.$id(prev).addClass('done').removeClass('running'); cy.$id('__end__').addClass('done'); toast('Run complete — saved to Runs'); }
|
|
@@ -804,6 +888,19 @@ async function openRun(id){
|
|
|
804
888
|
<span class="chip node">${esc(s.name)}</span>
|
|
805
889
|
${Object.keys(s.output).map(k=>`<span class="tagchip" style="cursor:default">${esc(k)}</span>`).join('')}
|
|
806
890
|
<span style="color:var(--muted);font-size:11px;margin-left:auto" class="mono">${s.dur_ms!=null?fmtms(s.dur_ms):''}</span></div>`).join('');
|
|
891
|
+
if(r.status==='interrupted'&&r.thread_id){
|
|
892
|
+
const ivs=r.spans.filter(s=>s.type==='interrupt'); const iv=ivs.find(s=>s.output!=null)||ivs.pop();
|
|
893
|
+
html += `<div class="kv" style="margin-top:14px"><div class="k" style="color:var(--warn)">interrupt — awaiting human</div>
|
|
894
|
+
${iv&&iv.output!=null?`<pre>${esc(JSON.stringify(iv.output,null,2))}</pre>`:'<pre>paused at a breakpoint</pre>'}
|
|
895
|
+
<div class="scoreadd" style="margin-top:8px">
|
|
896
|
+
<input id="hitl-val" placeholder='resume value — JSON or text (blank = just continue)' style="flex:1;min-width:200px">
|
|
897
|
+
<button class="btn primary" onclick="resumeThread('${esc(r.thread_id)}')">Resume</button>
|
|
898
|
+
</div>
|
|
899
|
+
<div class="scoreadd">
|
|
900
|
+
<select id="hitl-goto"><option value="">goto node (optional)…</option></select>
|
|
901
|
+
<button class="btn ghost" onclick="resumeGoto('${esc(r.thread_id)}')">Send to node</button>
|
|
902
|
+
</div></div>`;
|
|
903
|
+
}
|
|
807
904
|
html += `<div class="kv" style="margin-top:14px"><div class="k">scores</div></div>
|
|
808
905
|
${(r.scores||[]).map(sc=>`<div class="scorerow"><span class="scorechip">${esc(sc.name)} ${sc.value}</span>
|
|
809
906
|
${sc.comment?`<span style="color:var(--muted);font-size:12px">${esc(sc.comment)}</span>`:''}
|
|
@@ -820,6 +917,9 @@ async function openRun(id){
|
|
|
820
917
|
$('#d-body').innerHTML=html;
|
|
821
918
|
openIds.forEach(i=>{const e=document.getElementById(i); if(e){e.style.display='block';
|
|
822
919
|
const row=e.previousElementSibling; row&&row.querySelector('.caret')?.classList.add('open');}});
|
|
920
|
+
const gsel=document.getElementById('hitl-goto');
|
|
921
|
+
if(gsel&&cy) gsel.innerHTML='<option value="">goto node (optional)…</option>'+
|
|
922
|
+
cy.nodes().filter(n=>!n.data('terminal')).map(n=>`<option>${esc(n.id())}</option>`).join('');
|
|
823
923
|
$('#scrim').classList.add('on'); $('#drawer').classList.add('on');
|
|
824
924
|
clearTimeout(window.__liveT);
|
|
825
925
|
if(r.status==='running')
|
|
@@ -843,6 +943,7 @@ function renderSpans(list,byParent,total,depth){
|
|
|
843
943
|
const kids=byParent[s.id]||[];
|
|
844
944
|
const left=((s.offset_ms||0)/total*100).toFixed(2), width=Math.max(1.2,(s.dur_ms||0)/total*100).toFixed(2);
|
|
845
945
|
const meta=[s.dur_ms!=null?s.dur_ms+'ms':null,
|
|
946
|
+
s.ttft_ms!=null?'ttft '+s.ttft_ms+'ms':null,
|
|
846
947
|
s.type==='llm'&&(s.prompt_tokens!=null||s.completion_tokens!=null)?fmtk((s.prompt_tokens||0)+(s.completion_tokens||0))+'t':null,
|
|
847
948
|
s.cost_usd!=null?'$'+s.cost_usd.toFixed(4):null].filter(Boolean).join(' · ');
|
|
848
949
|
const did='sp'+s.id;
|
|
@@ -850,12 +951,12 @@ function renderSpans(list,byParent,total,depth){
|
|
|
850
951
|
<div class="tr-row" onclick="tog('${did}',this)">
|
|
851
952
|
<div class="tr-main"><span class="indent" style="width:${depth*18}px"></span>
|
|
852
953
|
<span class="caret">▶</span><span class="chip ${s.type}">${s.type}</span>
|
|
853
|
-
<span class="tr-name ${s.status==='error'?'err':''}">${esc(s.name||s.type)}</span
|
|
954
|
+
<span class="tr-name ${s.status==='error'?'err':''}">${esc(s.name||s.type)}</span>${s.retries?`<span class="badge-retry">↻${s.retries}</span>`:''}</div>
|
|
854
955
|
<div class="tr-gantt"><div class="tr-bar ${s.type}" style="left:${left}%;width:${width}%"></div></div>
|
|
855
956
|
<div class="tr-meta">${meta}</div>
|
|
856
957
|
</div>
|
|
857
958
|
<div class="tr-detail" id="${did}">
|
|
858
|
-
${s.model?`<div class="kv"><div class="k">model</div><pre>${esc(s.model)}${s.prompt_tokens!=null?` · ${s.prompt_tokens} in / ${s.completion_tokens||0} out`:''}</pre></div>`:''}
|
|
959
|
+
${s.model?`<div class="kv"><div class="k">model</div><pre>${esc(s.model)}${s.prompt_tokens!=null?` · ${s.prompt_tokens} in / ${s.completion_tokens||0} out`:''}${s.usage_detail?' · '+esc(Object.entries(s.usage_detail).map(([k,v])=>`${v} ${k.replace('input_','').replace('output_','')}`).join(', ')):''}</pre></div>`:''}
|
|
859
960
|
${s.error?`<div class="kv"><div class="k">error${s.type==='node'?`<button class="copy" onclick="showSpanSource('${s.id}')">view source</button>`:''}</div><pre class="err">${esc(s.error)}</pre><div id="src-${s.id}"></div></div>`:''}
|
|
860
961
|
${s.input!=null?kv('input',s.input):''}
|
|
861
962
|
${s.output!=null?kv('output',s.output):''}
|
|
@@ -887,6 +988,58 @@ window.showSpanSource=async(sid)=>{
|
|
|
887
988
|
:'<pre>no source available for this node (external run, or inspect could not trace it)</pre>';
|
|
888
989
|
};
|
|
889
990
|
|
|
991
|
+
/* ---------- human-in-the-loop ---------- */
|
|
992
|
+
async function consumeRunStream(res){
|
|
993
|
+
const rd=res.body.getReader(),dec=new TextDecoder(); let buf='';
|
|
994
|
+
while(true){ const {value,done}=await rd.read(); if(done)break; buf+=dec.decode(value,{stream:true});
|
|
995
|
+
let ps=buf.split('\n\n'); buf=ps.pop();
|
|
996
|
+
for(const p of ps){ const ev=(p.match(/event: (.*)/)||[])[1],dm=(p.match(/data: (.*)/s)||[])[1]; if(!ev)continue; const d=dm?JSON.parse(dm):{};
|
|
997
|
+
if(ev==='node'&&cy){ cy.$id(d.node).addClass('running'); }
|
|
998
|
+
else if(ev==='progress'){ toast('progress: '+JSON.stringify(d.data).slice(0,120)); }
|
|
999
|
+
else if(ev==='interrupted'){ toast('Paused again — awaiting human'); }
|
|
1000
|
+
else if(ev==='done'){ toast('Resumed run complete'); }
|
|
1001
|
+
else if(ev==='error'){ toast('Resume error: '+(d.message||'failed')); } } }
|
|
1002
|
+
if(VIEW==='runs') loadRuns();
|
|
1003
|
+
}
|
|
1004
|
+
function parseMaybeJSON(v){ if(v==='')return undefined;
|
|
1005
|
+
try{ return JSON.parse(v); }catch(e){ return v; } }
|
|
1006
|
+
window.resumeThread=async(tid)=>{
|
|
1007
|
+
const raw=document.getElementById('hitl-val').value.trim();
|
|
1008
|
+
const body={}; const v=parseMaybeJSON(raw); if(v!==undefined) body.value=v;
|
|
1009
|
+
closeAll(); switchView('graph'); toast('Resuming thread '+tid.slice(0,8)+'…');
|
|
1010
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
1011
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
1012
|
+
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
1013
|
+
await consumeRunStream(res);
|
|
1014
|
+
};
|
|
1015
|
+
window.resumeGoto=async(tid)=>{
|
|
1016
|
+
const g=document.getElementById('hitl-goto').value; if(!g) return toast('Pick a node first');
|
|
1017
|
+
closeAll(); switchView('graph'); toast('Redirecting thread to '+g+'…');
|
|
1018
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
1019
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({goto:g})});
|
|
1020
|
+
if(!res.ok){ toast((await res.json()).error||'resume failed'); return; }
|
|
1021
|
+
await consumeRunStream(res);
|
|
1022
|
+
};
|
|
1023
|
+
window.rerunFrom=async(tid,cid)=>{
|
|
1024
|
+
closeAll(); switchView('graph'); toast('Forking from checkpoint '+String(cid).slice(-8)+'…');
|
|
1025
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/resume',
|
|
1026
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({checkpoint_id:cid})});
|
|
1027
|
+
if(!res.ok){ toast((await res.json()).error||'fork failed'); return; }
|
|
1028
|
+
await consumeRunStream(res);
|
|
1029
|
+
};
|
|
1030
|
+
window.editState=async(tid,cid,valuesJson)=>{
|
|
1031
|
+
const cur=window.prompt('Edit state values (JSON object) — applied via update_state:',valuesJson||'{}');
|
|
1032
|
+
if(cur==null) return;
|
|
1033
|
+
let values; try{ values=JSON.parse(cur); }catch(e){ return toast('Invalid JSON'); }
|
|
1034
|
+
const body={values}; if(cid) body.checkpoint_id=cid;
|
|
1035
|
+
const res=await fetch('/api/threads/'+encodeURIComponent(tid)+'/state',
|
|
1036
|
+
{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
|
1037
|
+
const d=await res.json();
|
|
1038
|
+
if(!res.ok) return toast(d.error||'update_state failed');
|
|
1039
|
+
toast('State updated — new checkpoint '+String(d.checkpoint_id||'').slice(-8)+'; Resume to continue');
|
|
1040
|
+
showHistory(tid);
|
|
1041
|
+
};
|
|
1042
|
+
|
|
890
1043
|
/* ---------- time-travel (checkpoint history) ---------- */
|
|
891
1044
|
window.showHistory=async(tid)=>{
|
|
892
1045
|
let d=null; try{ const r=await fetch('/api/threads/'+encodeURIComponent(tid)+'/history');
|
|
@@ -910,6 +1063,10 @@ window.showHistory=async(tid)=>{
|
|
|
910
1063
|
<div class="tr-detail" id="${did}">
|
|
911
1064
|
${s.writes!=null?kv('writes',s.writes):''}
|
|
912
1065
|
${kv('state at this checkpoint',s.values)}
|
|
1066
|
+
<div style="display:flex;gap:8px;margin:4px 0 10px">
|
|
1067
|
+
<button class="btn ghost" onclick='rerunFrom("${esc(tid)}","${esc(s.checkpoint_id||'')}")'>⑂ Re-run from here</button>
|
|
1068
|
+
<button class="btn ghost" onclick='editState("${esc(tid)}","${esc(s.checkpoint_id||'')}",${JSON.stringify(JSON.stringify(s.values??{}))})'>✎ Edit state</button>
|
|
1069
|
+
</div>
|
|
913
1070
|
</div></div>`;
|
|
914
1071
|
}).join('')||'<div class="empty">No checkpoints recorded for this thread</div>';
|
|
915
1072
|
$('#scrim').classList.add('on'); $('#drawer').classList.add('on');
|
|
@@ -1009,6 +1166,37 @@ function fmtk(n){ return n<1000?String(n):(n/1000).toFixed(1)+'k'; }
|
|
|
1009
1166
|
function ago(ms){ const s=(Date.now()-ms)/1000; if(s<60)return Math.round(s)+'s ago'; if(s<3600)return Math.round(s/60)+'m ago'; if(s<86400)return Math.round(s/3600)+'h ago'; return Math.round(s/86400)+'d ago'; }
|
|
1010
1167
|
let tT; function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); clearTimeout(tT); tT=setTimeout(()=>t.classList.remove('show'),2600); }
|
|
1011
1168
|
|
|
1169
|
+
let MEM_NS=[];
|
|
1170
|
+
async function probeMemory(){
|
|
1171
|
+
try{ const r=await fetch('/api/memory/namespaces'); if(!r.ok) return;
|
|
1172
|
+
MEM_NS=(await r.json()).namespaces||[];
|
|
1173
|
+
document.getElementById('nav-mem').style.display='';
|
|
1174
|
+
const m=document.querySelector('.bottom-nav .nav-mem'); if(m)m.style.display='';
|
|
1175
|
+
}catch(e){}
|
|
1176
|
+
}
|
|
1177
|
+
async function loadMemory(){
|
|
1178
|
+
const sel=document.getElementById('mem-ns');
|
|
1179
|
+
try{ const r=await fetch('/api/memory/namespaces'); MEM_NS=(await r.json()).namespaces||[]; }catch(e){}
|
|
1180
|
+
const cur=sel.value;
|
|
1181
|
+
sel.innerHTML=MEM_NS.map(ns=>`<option value="${esc(ns.join('.'))}">${esc(ns.join(' / '))}</option>`).join('');
|
|
1182
|
+
if(cur) sel.value=cur;
|
|
1183
|
+
const ns=sel.value;
|
|
1184
|
+
const w=document.getElementById('mem-wrap');
|
|
1185
|
+
if(!ns){ w.innerHTML='<div class="empty"><div class="t">No memories yet</div>Nodes write with <span class="mono">get_store().put(namespace, key, value)</span> — items appear here.</div>'; return; }
|
|
1186
|
+
const q=document.getElementById('mem-q').value.trim();
|
|
1187
|
+
const p=new URLSearchParams({namespace:ns}); if(q)p.set('query',q);
|
|
1188
|
+
let d={items:[]}; try{ d=await(await fetch('/api/memory/items?'+p)).json(); }catch(e){}
|
|
1189
|
+
w.innerHTML=(d.items||[]).length?`<div style="overflow:auto"><table>
|
|
1190
|
+
<thead><tr><th>Key</th><th>Value</th><th class="r">Updated</th></tr></thead>
|
|
1191
|
+
<tbody>${d.items.map(i=>`<tr>
|
|
1192
|
+
<td class="mono" style="font-weight:600;white-space:nowrap">${esc(i.key)}</td>
|
|
1193
|
+
<td><pre style="margin:0;white-space:pre-wrap;word-break:break-word;font:12px/1.5 var(--mono);max-width:640px">${esc(JSON.stringify(i.value,null,1)).slice(0,600)}</pre></td>
|
|
1194
|
+
<td class="r" style="color:var(--muted);white-space:nowrap">${i.updated_at?esc(String(i.updated_at).slice(0,19).replace('T',' ')):'—'}</td></tr>`).join('')}</tbody></table></div>`
|
|
1195
|
+
:'<div class="empty">Nothing in this namespace'+(q?' matching that query':'')+'.</div>';
|
|
1196
|
+
}
|
|
1197
|
+
document.addEventListener('change',e=>{ if(e.target&&e.target.id==='mem-ns') loadMemory(); });
|
|
1198
|
+
let memQT; document.addEventListener('input',e=>{ if(e.target&&e.target.id==='mem-q'){ clearTimeout(memQT); memQT=setTimeout(loadMemory,300); } });
|
|
1199
|
+
|
|
1012
1200
|
async function loadDatasets(){
|
|
1013
1201
|
const w=document.getElementById('ds-wrap'); if(!w) return;
|
|
1014
1202
|
let ds=[]; try{ ds=await(await fetch('/api/datasets')).json(); }catch(e){}
|
|
@@ -1027,7 +1215,7 @@ window.runDataset=async(id,name)=>{
|
|
|
1027
1215
|
toast('Eval started: '+d.items+' items → session '+d.session);
|
|
1028
1216
|
};
|
|
1029
1217
|
window.delDataset=async(id)=>{ await fetch('/api/datasets/'+id,{method:'DELETE'}); loadDatasets(); };
|
|
1030
|
-
loadGraph(); liveTopology();
|
|
1218
|
+
loadGraph(); liveTopology(); probeMemory();
|
|
1031
1219
|
/* deep links: #runs #sessions #stats #run=<id> */
|
|
1032
1220
|
(function(){ const h=location.hash.slice(1);
|
|
1033
1221
|
if(h==='runs'||h==='sessions'||h==='stats') switchView(h);
|
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import json, os, sqlite3, threading, time, uuid
|
|
14
14
|
from typing import Any, Optional
|
|
15
15
|
|
|
16
|
-
SCHEMA_VERSION =
|
|
16
|
+
SCHEMA_VERSION = 6
|
|
17
17
|
_lock = threading.Lock()
|
|
18
18
|
|
|
19
19
|
|
|
@@ -68,6 +68,11 @@ class Store:
|
|
|
68
68
|
c.execute("ALTER TABLE runs ADD COLUMN bookmarked INTEGER DEFAULT 0")
|
|
69
69
|
if "thread_id" not in cols:
|
|
70
70
|
c.execute("ALTER TABLE runs ADD COLUMN thread_id TEXT")
|
|
71
|
+
scols = [r[1] for r in c.execute("PRAGMA table_info(spans)").fetchall()]
|
|
72
|
+
for col, typ in (("retries", "INTEGER"), ("ttft_ms", "INTEGER"),
|
|
73
|
+
("usage_detail", "TEXT")):
|
|
74
|
+
if col not in scols:
|
|
75
|
+
c.execute(f"ALTER TABLE spans ADD COLUMN {col} {typ}")
|
|
71
76
|
try:
|
|
72
77
|
c.execute("SELECT count(*) FROM json_each('[1]')")
|
|
73
78
|
self.has_json1 = True
|
|
@@ -133,15 +138,18 @@ class Store:
|
|
|
133
138
|
with _lock, self._conn() as c:
|
|
134
139
|
c.execute("""INSERT OR REPLACE INTO spans
|
|
135
140
|
(id,run_id,parent_id,seq,type,name,status,started_ms,ended_ms,offset_ms,
|
|
136
|
-
dur_ms,input,output,model,prompt_tokens,completion_tokens,cost_usd,error
|
|
137
|
-
|
|
141
|
+
dur_ms,input,output,model,prompt_tokens,completion_tokens,cost_usd,error,
|
|
142
|
+
retries,ttft_ms,usage_detail)
|
|
143
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
138
144
|
(s["id"], s["run_id"], s.get("parent_id"), s.get("seq", 0), s["type"],
|
|
139
145
|
s.get("name"), s.get("status", "ok"), s.get("started_ms"), s.get("ended_ms"),
|
|
140
146
|
s.get("offset_ms"), s.get("dur_ms"),
|
|
141
147
|
json.dumps(s.get("input"), default=str) if s.get("input") is not None else None,
|
|
142
148
|
json.dumps(s.get("output"), default=str) if s.get("output") is not None else None,
|
|
143
149
|
s.get("model"), s.get("prompt_tokens"), s.get("completion_tokens"),
|
|
144
|
-
s.get("cost_usd"), s.get("error")
|
|
150
|
+
s.get("cost_usd"), s.get("error"),
|
|
151
|
+
s.get("retries"), s.get("ttft_ms"),
|
|
152
|
+
json.dumps(s.get("usage_detail")) if s.get("usage_detail") else None))
|
|
145
153
|
if self.has_fts:
|
|
146
154
|
c.execute("DELETE FROM span_fts WHERE span_id=?", (s["id"],))
|
|
147
155
|
c.execute("INSERT INTO span_fts(text,span_id,run_id) VALUES(?,?,?)",
|
|
@@ -316,7 +324,7 @@ class Store:
|
|
|
316
324
|
for k in ("input", "tags"):
|
|
317
325
|
d[k] = json.loads(d[k]) if d.get(k) else None
|
|
318
326
|
for s in spans:
|
|
319
|
-
for k in ("input", "output"):
|
|
327
|
+
for k in ("input", "output", "usage_detail"):
|
|
320
328
|
s[k] = json.loads(s[k]) if s.get(k) else None
|
|
321
329
|
d["spans"] = spans
|
|
322
330
|
d["scores"] = scores
|
|
@@ -77,24 +77,31 @@ def _model_name(serialized: dict, kw: dict) -> str:
|
|
|
77
77
|
return ids[-1] if ids else "llm"
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
def _usage(response: Any) -> tuple[Optional[int], Optional[int]]:
|
|
80
|
+
def _usage(response: Any) -> tuple[Optional[int], Optional[int], Optional[dict]]:
|
|
81
|
+
"""(prompt, completion, detail) — detail carries cache_read/cache_creation/
|
|
82
|
+
reasoning token counts when the provider reports them."""
|
|
81
83
|
# OpenAI-style aggregate
|
|
82
84
|
out = getattr(response, "llm_output", None) or {}
|
|
83
85
|
for key in ("token_usage", "usage"):
|
|
84
86
|
u = out.get(key) if isinstance(out, dict) else None
|
|
85
87
|
if u:
|
|
86
88
|
return (u.get("prompt_tokens") or u.get("input_tokens"),
|
|
87
|
-
u.get("completion_tokens") or u.get("output_tokens"))
|
|
89
|
+
u.get("completion_tokens") or u.get("output_tokens"), None)
|
|
88
90
|
# per-generation usage_metadata (chat models)
|
|
89
91
|
try:
|
|
90
92
|
for gens in response.generations:
|
|
91
93
|
for g in gens:
|
|
92
94
|
um = getattr(getattr(g, "message", None), "usage_metadata", None)
|
|
93
95
|
if um:
|
|
94
|
-
|
|
96
|
+
detail = {}
|
|
97
|
+
for side in ("input_token_details", "output_token_details"):
|
|
98
|
+
for k, v in (um.get(side) or {}).items():
|
|
99
|
+
if v:
|
|
100
|
+
detail[f"{side.split('_')[0]}_{k}"] = v
|
|
101
|
+
return um.get("input_tokens"), um.get("output_tokens"), detail or None
|
|
95
102
|
except Exception:
|
|
96
103
|
pass
|
|
97
|
-
return None, None
|
|
104
|
+
return None, None, None
|
|
98
105
|
|
|
99
106
|
|
|
100
107
|
def _gen_text(response: Any) -> str:
|
|
@@ -130,6 +137,7 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
130
137
|
self._open: dict[str, dict] = {} # langchain run_id -> pending span
|
|
131
138
|
self._span_of: dict[str, str] = {} # langchain run_id -> our span id (for parent links)
|
|
132
139
|
self._seq = 0
|
|
140
|
+
self._interrupted = False
|
|
133
141
|
|
|
134
142
|
# -- infra --
|
|
135
143
|
def _emit(self, ev: dict) -> None:
|
|
@@ -142,7 +150,7 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
142
150
|
return int((t - (self._t0 or t)) * 1000)
|
|
143
151
|
|
|
144
152
|
def _finish_span(self, info: dict, *, output=None, status="ok", error=None,
|
|
145
|
-
model=None, pt=None, ct=None):
|
|
153
|
+
model=None, pt=None, ct=None, usage_detail=None):
|
|
146
154
|
now = time.time()
|
|
147
155
|
sid = info["span_id"]
|
|
148
156
|
self._emit({"kind": "span", "id": sid, "run_id": self.run_id,
|
|
@@ -153,7 +161,10 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
153
161
|
"dur_ms": int((now - info["t"]) * 1000),
|
|
154
162
|
"input": info.get("input"), "output": output, "model": model,
|
|
155
163
|
"prompt_tokens": pt, "completion_tokens": ct,
|
|
156
|
-
"cost_usd": cost_of(model, pt, ct), "error": error
|
|
164
|
+
"cost_usd": cost_of(model, pt, ct), "error": error,
|
|
165
|
+
"retries": info.get("retries"),
|
|
166
|
+
"ttft_ms": int(info["ttft"] * 1000) if info.get("ttft") is not None else None,
|
|
167
|
+
"usage_detail": usage_detail})
|
|
157
168
|
self._seq += 1
|
|
158
169
|
|
|
159
170
|
# -- chains / nodes --
|
|
@@ -196,8 +207,11 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
196
207
|
# LangGraph human-in-the-loop: a paused graph surfaces __interrupt__
|
|
197
208
|
# in its final output — that's a pause awaiting Command(resume=...),
|
|
198
209
|
# not a completion.
|
|
199
|
-
interrupted =
|
|
200
|
-
|
|
210
|
+
interrupted = self._interrupted or (
|
|
211
|
+
isinstance(outputs, dict) and "__interrupt__" in outputs)
|
|
212
|
+
if interrupted and not self._interrupted:
|
|
213
|
+
# marker not yet emitted by the GraphInterrupt path
|
|
214
|
+
self._interrupted = True
|
|
201
215
|
self._finish_span(
|
|
202
216
|
{"span_id": uuid.uuid4().hex[:12], "type": "interrupt",
|
|
203
217
|
"name": "interrupt", "t": time.time(), "parent": None,
|
|
@@ -209,6 +223,32 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
209
223
|
|
|
210
224
|
def on_chain_error(self, error, *, run_id=None, **kw):
|
|
211
225
|
info = self._open.pop(run_id, None)
|
|
226
|
+
# LangGraph signals a human-in-the-loop pause by raising GraphInterrupt
|
|
227
|
+
# through the node — that's a pause, not a failure.
|
|
228
|
+
if type(error).__name__ in ("GraphInterrupt", "NodeInterrupt"):
|
|
229
|
+
payload = []
|
|
230
|
+
try:
|
|
231
|
+
for i in (error.args[0] if error.args else []):
|
|
232
|
+
payload.append(getattr(i, "value", str(i)))
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
if info:
|
|
236
|
+
self._finish_span(info, status="interrupted",
|
|
237
|
+
output=_trunc(payload) if payload else None)
|
|
238
|
+
if not self._interrupted: # one marker span per pause
|
|
239
|
+
self._interrupted = True
|
|
240
|
+
now = time.time()
|
|
241
|
+
self._emit({"kind": "span", "id": uuid.uuid4().hex[:12],
|
|
242
|
+
"run_id": self.run_id, "parent_id": None,
|
|
243
|
+
"seq": self._seq, "type": "interrupt", "name": "interrupt",
|
|
244
|
+
"status": "ok", "started_ms": int(now * 1000),
|
|
245
|
+
"ended_ms": int(now * 1000), "offset_ms": self._rel(now),
|
|
246
|
+
"dur_ms": 0, "input": None,
|
|
247
|
+
"output": _trunc(payload) if payload else None,
|
|
248
|
+
"model": None, "prompt_tokens": None,
|
|
249
|
+
"completion_tokens": None, "cost_usd": None, "error": None})
|
|
250
|
+
self._seq += 1
|
|
251
|
+
return
|
|
212
252
|
err = _err_text(error)
|
|
213
253
|
if info:
|
|
214
254
|
self._finish_span(info, status="error", error=err)
|
|
@@ -235,15 +275,41 @@ class SpanBuilder(BaseCallbackHandler):
|
|
|
235
275
|
info = self._open.pop(run_id, None)
|
|
236
276
|
if not info:
|
|
237
277
|
return
|
|
238
|
-
pt, ct = _usage(response)
|
|
278
|
+
pt, ct, detail = _usage(response)
|
|
239
279
|
self._finish_span(info, output=_gen_text(response)[:4000],
|
|
240
|
-
model=info["name"], pt=pt, ct=ct)
|
|
280
|
+
model=info["name"], pt=pt, ct=ct, usage_detail=detail)
|
|
241
281
|
|
|
242
282
|
def on_llm_error(self, error, *, run_id=None, **kw):
|
|
243
283
|
info = self._open.pop(run_id, None)
|
|
244
284
|
if info:
|
|
245
285
|
self._finish_span(info, status="error", error=_err_text(error), model=info["name"])
|
|
246
286
|
|
|
287
|
+
def on_llm_new_token(self, token, *, run_id=None, **kw):
|
|
288
|
+
# streaming: first token stamps time-to-first-token
|
|
289
|
+
info = self._open.get(run_id)
|
|
290
|
+
if info is not None and "ttft" not in info:
|
|
291
|
+
info["ttft"] = time.time() - info["t"]
|
|
292
|
+
|
|
293
|
+
# -- retries (tenacity, e.g. rate limits) --
|
|
294
|
+
def on_retry(self, retry_state, *, run_id=None, **kw):
|
|
295
|
+
info = self._open.get(run_id)
|
|
296
|
+
if info is not None:
|
|
297
|
+
info["retries"] = getattr(retry_state, "attempt_number", None) or \
|
|
298
|
+
(info.get("retries") or 0) + 1
|
|
299
|
+
|
|
300
|
+
# -- custom events (langchain dispatch_custom_event) --
|
|
301
|
+
def on_custom_event(self, name, data, *, run_id=None, **kw):
|
|
302
|
+
now = time.time()
|
|
303
|
+
self._emit({"kind": "span", "id": uuid.uuid4().hex[:12], "run_id": self.run_id,
|
|
304
|
+
"parent_id": self._span_of.get(run_id),
|
|
305
|
+
"seq": self._seq, "type": "event", "name": str(name), "status": "ok",
|
|
306
|
+
"started_ms": int(now * 1000), "ended_ms": int(now * 1000),
|
|
307
|
+
"offset_ms": self._rel(now), "dur_ms": 0,
|
|
308
|
+
"input": None, "output": _trunc(data), "model": None,
|
|
309
|
+
"prompt_tokens": None, "completion_tokens": None,
|
|
310
|
+
"cost_usd": None, "error": None})
|
|
311
|
+
self._seq += 1
|
|
312
|
+
|
|
247
313
|
# -- retrievers (LangChain RAG) --
|
|
248
314
|
def on_retriever_start(self, serialized, query, *, run_id=None, parent_run_id=None, **kw):
|
|
249
315
|
sid = uuid.uuid4().hex[:12]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{windhover-0.8.1 → windhover-0.10.0}/windhover/static/vendor/fonts/fraunces-600-italic.woff2
RENAMED
|
File without changes
|
|
File without changes
|