varly 1.0.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.
Files changed (76) hide show
  1. varly/__init__.py +0 -0
  2. varly/adapters/__init__.py +31 -0
  3. varly/adapters/_mapping.py +50 -0
  4. varly/adapters/_payload.py +30 -0
  5. varly/adapters/_sequential.py +129 -0
  6. varly/adapters/_source_hints.py +41 -0
  7. varly/adapters/capture/__init__.py +1 -0
  8. varly/adapters/capture/adapter.py +66 -0
  9. varly/adapters/errors.py +17 -0
  10. varly/adapters/json/__init__.py +0 -0
  11. varly/adapters/json/adapter.py +25 -0
  12. varly/adapters/langgraph/__init__.py +0 -0
  13. varly/adapters/langgraph/adapter.py +73 -0
  14. varly/adapters/langgraph/normalize.py +194 -0
  15. varly/adapters/openai/__init__.py +0 -0
  16. varly/adapters/openai/adapter.py +73 -0
  17. varly/adapters/openai/normalize.py +151 -0
  18. varly/analyzer/__init__.py +33 -0
  19. varly/analyzer/diagnostic.py +29 -0
  20. varly/analyzer/diff.py +62 -0
  21. varly/analyzer/engine.py +18 -0
  22. varly/analyzer/evaluator.py +55 -0
  23. varly/analyzer/export.py +46 -0
  24. varly/analyzer/reports.py +160 -0
  25. varly/analyzer/state_builder.py +12 -0
  26. varly/capture/__init__.py +11 -0
  27. varly/capture/model.py +33 -0
  28. varly/capture/recorder.py +144 -0
  29. varly/contracts/__init__.py +22 -0
  30. varly/contracts/builtins/__init__.py +23 -0
  31. varly/contracts/builtins/business.py +159 -0
  32. varly/contracts/builtins/metrics.py +87 -0
  33. varly/contracts/builtins/semantic.py +84 -0
  34. varly/contracts/builtins/structural.py +83 -0
  35. varly/contracts/errors.py +21 -0
  36. varly/contracts/loader.py +119 -0
  37. varly/contracts/model.py +33 -0
  38. varly/core/__init__.py +85 -0
  39. varly/core/errors.py +35 -0
  40. varly/core/labeling.py +31 -0
  41. varly/core/ordering.py +69 -0
  42. varly/core/state.py +60 -0
  43. varly/core/topology.py +208 -0
  44. varly/core/trace.py +68 -0
  45. varly/core/types.py +44 -0
  46. varly/interfaces/__init__.py +0 -0
  47. varly/interfaces/cli/__init__.py +0 -0
  48. varly/interfaces/cli/commands/__init__.py +0 -0
  49. varly/interfaces/cli/commands/diff.py +93 -0
  50. varly/interfaces/cli/commands/validate.py +53 -0
  51. varly/interfaces/cli/commands/verify.py +169 -0
  52. varly/interfaces/cli/commands/view.py +160 -0
  53. varly/interfaces/cli/main.py +38 -0
  54. varly/interfaces/cli/render.py +80 -0
  55. varly/interfaces/library/__init__.py +21 -0
  56. varly/interfaces/library/api.py +105 -0
  57. varly/interfaces/viewer/.gitignore +1 -0
  58. varly/interfaces/viewer/index.html +289 -0
  59. varly/interfaces/viewer/report.py +140 -0
  60. varly/parser/__init__.py +20 -0
  61. varly/parser/builder.py +114 -0
  62. varly/parser/errors.py +13 -0
  63. varly/parser/json_loader.py +47 -0
  64. varly/parser/schema.py +133 -0
  65. varly/resources/__init__.py +16 -0
  66. varly/resources/fixtures/__init__.py +0 -0
  67. varly/resources/fixtures/mock_run.json +40 -0
  68. varly/resources/policies/__init__.py +0 -0
  69. varly/resources/policies/dev.yaml +29 -0
  70. varly/resources/policies/mvp.yaml +29 -0
  71. varly/resources/policies/strict.yaml +30 -0
  72. varly-1.0.0.dist-info/METADATA +279 -0
  73. varly-1.0.0.dist-info/RECORD +76 -0
  74. varly-1.0.0.dist-info/WHEEL +4 -0
  75. varly-1.0.0.dist-info/entry_points.txt +2 -0
  76. varly-1.0.0.dist-info/licenses/LICENSE +21 -0
varly/__init__.py ADDED
File without changes
@@ -0,0 +1,31 @@
1
+ """External-format adapters that translate telemetry into AIR traces."""
2
+
3
+ from varly.adapters.capture.adapter import adapt_file as adapt_capture_file
4
+ from varly.adapters.capture.adapter import adapt_payload as adapt_capture_payload
5
+ from varly.adapters.errors import (
6
+ AdapterError,
7
+ AdapterValidationError,
8
+ UnsupportedFormatError,
9
+ )
10
+ from varly.adapters.json.adapter import adapt_file as adapt_json_file
11
+ from varly.adapters.json.adapter import adapt_payload as adapt_json_payload
12
+ from varly.adapters.langgraph.adapter import adapt_file as adapt_langgraph_file
13
+ from varly.adapters.langgraph.adapter import (
14
+ adapt_payload as adapt_langgraph_payload,
15
+ )
16
+ from varly.adapters.openai.adapter import adapt_file as adapt_openai_file
17
+ from varly.adapters.openai.adapter import adapt_payload as adapt_openai_payload
18
+
19
+ __all__ = [
20
+ "AdapterError",
21
+ "AdapterValidationError",
22
+ "UnsupportedFormatError",
23
+ "adapt_capture_file",
24
+ "adapt_capture_payload",
25
+ "adapt_json_file",
26
+ "adapt_json_payload",
27
+ "adapt_langgraph_file",
28
+ "adapt_langgraph_payload",
29
+ "adapt_openai_file",
30
+ "adapt_openai_payload",
31
+ ]
@@ -0,0 +1,50 @@
1
+ """Shared event-type normalization for reference adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from varly.core.types import ControlEdgeKind, SemanticNodeType
6
+
7
+ _EVENT_ALIASES: dict[str, str] = {
8
+ "run_start": "run_start",
9
+ "chain_start": "run_start",
10
+ "on_chain_start": "run_start",
11
+ "llm_invoke": "llm_invoke",
12
+ "llm_call": "llm_invoke",
13
+ "on_chat_model_start": "llm_invoke",
14
+ "on_chat_model_end": "llm_invoke",
15
+ "on_llm_start": "llm_invoke",
16
+ "on_llm_end": "llm_invoke",
17
+ "tool_call": "tool_call",
18
+ "on_tool_start": "tool_call",
19
+ "function_call": "tool_call",
20
+ "tool_return": "tool_return",
21
+ "tool_output": "tool_return",
22
+ "on_tool_end": "tool_return",
23
+ "function_call_output": "tool_return",
24
+ "run_end": "run_end",
25
+ "chain_end": "run_end",
26
+ "on_chain_end": "run_end",
27
+ }
28
+
29
+
30
+ def normalize_event_type(raw_type: str) -> str:
31
+ """Map a framework-specific event name to the AIR domain event_type."""
32
+ normalized = _EVENT_ALIASES.get(raw_type)
33
+ if normalized is None:
34
+ msg = f"Unsupported event type: {raw_type!r}"
35
+ raise ValueError(msg)
36
+ return normalized
37
+
38
+
39
+ def semantic_type_for(event_type: str) -> str:
40
+ if event_type in {"tool_call", "tool_return"}:
41
+ return SemanticNodeType.RESOURCE
42
+ return SemanticNodeType.SEMANTIC
43
+
44
+
45
+ def control_kind_for(source_event: str, target_event: str) -> ControlEdgeKind:
46
+ if target_event == "tool_call":
47
+ return ControlEdgeKind.INVOKES
48
+ if source_event == "tool_call" and target_event == "tool_return":
49
+ return ControlEdgeKind.PRODUCES
50
+ return ControlEdgeKind.CAUSES
@@ -0,0 +1,30 @@
1
+ """Helpers to assemble canonical AIR JSON payloads from adapter inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from varly.core.types import AIR_SCHEMA_VERSION
8
+
9
+ AirNode = dict[str, Any]
10
+ AirEdge = dict[str, Any]
11
+ AirRead = dict[str, str]
12
+
13
+
14
+ def build_air_payload(
15
+ *,
16
+ trace_id: str,
17
+ root_id: str,
18
+ nodes: list[AirNode],
19
+ control_edges: list[AirEdge],
20
+ referential_edges: list[AirRead] | None = None,
21
+ ) -> dict[str, Any]:
22
+ """Build an AIR 1.0.0 payload ready for the parser."""
23
+ return {
24
+ "air_schema_version": AIR_SCHEMA_VERSION,
25
+ "trace_id": trace_id,
26
+ "root_id": root_id,
27
+ "nodes": nodes,
28
+ "control_edges": control_edges,
29
+ "referential_edges": referential_edges or [],
30
+ }
@@ -0,0 +1,129 @@
1
+ """Build AIR traces from ordered external event lists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Any
7
+
8
+ from varly.adapters._mapping import (
9
+ control_kind_for,
10
+ normalize_event_type,
11
+ semantic_type_for,
12
+ )
13
+ from varly.adapters._payload import build_air_payload
14
+ from varly.adapters.errors import AdapterValidationError
15
+ from varly.core.trace import Trace
16
+ from varly.parser import build_trace
17
+
18
+ ExternalEvent = Mapping[str, Any]
19
+ ExternalRead = Mapping[str, str]
20
+
21
+
22
+ def adapt_sequential_events(
23
+ *,
24
+ trace_id: str,
25
+ events: list[ExternalEvent],
26
+ reads: Sequence[ExternalRead] | None = None,
27
+ type_field: str,
28
+ tokens_field: str = "tokens",
29
+ ) -> Trace:
30
+ """Translate a linear external event log into a validated AIR trace."""
31
+ if not events:
32
+ msg = "Event log must contain at least one event"
33
+ raise AdapterValidationError(msg)
34
+
35
+ nodes: list[dict[str, Any]] = []
36
+ normalized_types: list[str] = []
37
+
38
+ for index, event in enumerate(events):
39
+ event_id = event.get("id")
40
+ raw_type = event.get(type_field)
41
+ if not isinstance(event_id, str) or not event_id:
42
+ msg = f"events[{index}].id must be a non-empty string"
43
+ raise AdapterValidationError(msg)
44
+ if not isinstance(raw_type, str) or not raw_type:
45
+ msg = f"events[{index}].{type_field} must be a non-empty string"
46
+ raise AdapterValidationError(msg)
47
+ try:
48
+ event_type = normalize_event_type(raw_type)
49
+ except ValueError as exc:
50
+ msg = f"events[{index}].{type_field}: {exc}"
51
+ raise AdapterValidationError(msg) from exc
52
+
53
+ labels: dict[str, Any] = {
54
+ "semantic_type": semantic_type_for(event_type),
55
+ "event_type": event_type,
56
+ }
57
+ timestamp_ms = event.get("timestamp_ms")
58
+ if isinstance(timestamp_ms, (int, float)) and not isinstance(
59
+ timestamp_ms, bool
60
+ ):
61
+ labels["timestamp_ms"] = timestamp_ms
62
+
63
+ name = event.get("name")
64
+ if isinstance(name, str) and name:
65
+ labels["name"] = name
66
+
67
+ tokens = _extract_tokens(event, tokens_field)
68
+ if tokens is not None and event_type == "llm_invoke":
69
+ labels["tokens"] = tokens
70
+
71
+ nodes.append({"id": event_id, "labels": labels})
72
+ normalized_types.append(event_type)
73
+
74
+ if normalized_types[0] != "run_start":
75
+ msg = "First event must represent run_start"
76
+ raise AdapterValidationError(msg)
77
+
78
+ root_id = str(events[0]["id"])
79
+ control_edges: list[dict[str, Any]] = []
80
+ for index in range(len(events) - 1):
81
+ source_id = str(events[index]["id"])
82
+ target_id = str(events[index + 1]["id"])
83
+ source_type = normalized_types[index]
84
+ target_type = normalized_types[index + 1]
85
+ control_edges.append(
86
+ {
87
+ "id": f"ec-{index:04d}",
88
+ "source": source_id,
89
+ "target": target_id,
90
+ "kind": control_kind_for(source_type, target_type),
91
+ }
92
+ )
93
+
94
+ referential_edges: list[dict[str, str]] = []
95
+ for read_index, read in enumerate(reads or ()):
96
+ source = read.get("source")
97
+ target = read.get("target")
98
+ if not isinstance(source, str) or not isinstance(target, str):
99
+ msg = f"reads[{read_index}] requires string source and target"
100
+ raise AdapterValidationError(msg)
101
+ referential_edges.append(
102
+ {
103
+ "id": f"er-{read_index:04d}",
104
+ "source": source,
105
+ "target": target,
106
+ "kind": "reads",
107
+ }
108
+ )
109
+
110
+ payload = build_air_payload(
111
+ trace_id=trace_id,
112
+ root_id=root_id,
113
+ nodes=nodes,
114
+ control_edges=control_edges,
115
+ referential_edges=referential_edges,
116
+ )
117
+ return build_trace(payload)
118
+
119
+
120
+ def _extract_tokens(event: ExternalEvent, tokens_field: str) -> int | float | None:
121
+ direct = event.get(tokens_field)
122
+ if isinstance(direct, (int, float)) and not isinstance(direct, bool):
123
+ return direct
124
+ usage = event.get("usage")
125
+ if isinstance(usage, Mapping):
126
+ total = usage.get("total_tokens")
127
+ if isinstance(total, (int, float)) and not isinstance(total, bool):
128
+ return total
129
+ return None
@@ -0,0 +1,41 @@
1
+ """Detect likely trace source from a JSON payload (onboarding hints)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, Literal
7
+
8
+ from varly.adapters.langgraph.normalize import (
9
+ is_langgraph_callbacks,
10
+ is_langgraph_run_v1,
11
+ )
12
+ from varly.adapters.openai.normalize import is_openai_responses, is_openai_run_v1
13
+ from varly.capture.model import CAPTURE_EVENT_LOG_VERSION
14
+
15
+ TraceSource = Literal["air", "capture", "langgraph", "openai"]
16
+
17
+
18
+ def detect_trace_source(payload: Mapping[str, Any]) -> TraceSource | None:
19
+ """Return the most likely adapter source for a JSON object, if recognizable."""
20
+ if payload.get("format_version") == CAPTURE_EVENT_LOG_VERSION:
21
+ return "capture"
22
+ if is_openai_responses(payload) or is_openai_run_v1(payload):
23
+ return "openai"
24
+ if is_langgraph_callbacks(payload) or is_langgraph_run_v1(payload):
25
+ return "langgraph"
26
+ if "air_schema_version" in payload and "nodes" in payload:
27
+ return "air"
28
+ return None
29
+
30
+
31
+ def wrong_source_message(
32
+ path: str,
33
+ *,
34
+ used: TraceSource,
35
+ detected: TraceSource,
36
+ ) -> str:
37
+ """Human-readable hint when --source does not match the file shape."""
38
+ return (
39
+ f"File {path!r} looks like a {detected} trace, but --source {used!r} was used. "
40
+ f"Try: varly verify {path} --source {detected} --contract <policy.yaml>"
41
+ )
@@ -0,0 +1 @@
1
+ """Capture event log adapter package."""
@@ -0,0 +1,66 @@
1
+ """Capture event log adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from varly.adapters._sequential import adapt_sequential_events
10
+ from varly.adapters.errors import AdapterValidationError, UnsupportedFormatError
11
+ from varly.adapters.json.adapter import load_external_json
12
+ from varly.capture import CAPTURE_EVENT_LOG_VERSION
13
+ from varly.core.trace import Trace
14
+
15
+
16
+ def adapt_file(path: Path) -> Trace:
17
+ """Translate a capture event log file into an AIR trace."""
18
+ payload = load_external_json(path)
19
+ return adapt_payload(payload)
20
+
21
+
22
+ def adapt_payload(payload: Mapping[str, Any]) -> Trace:
23
+ """Translate a capture event log payload into an AIR trace."""
24
+ version = payload.get("format_version")
25
+ if version != CAPTURE_EVENT_LOG_VERSION:
26
+ msg = (
27
+ f"Unsupported capture format_version: {version!r} "
28
+ f"(expected {CAPTURE_EVENT_LOG_VERSION!r})"
29
+ )
30
+ raise UnsupportedFormatError(msg)
31
+
32
+ run_id = payload.get("run_id")
33
+ steps = payload.get("steps")
34
+ if not isinstance(run_id, str) or not run_id:
35
+ msg = "Capture payload requires non-empty string run_id"
36
+ raise AdapterValidationError(msg)
37
+ if not isinstance(steps, list) or not steps:
38
+ msg = "Capture payload requires non-empty steps list"
39
+ raise AdapterValidationError(msg)
40
+
41
+ reads_raw = payload.get("reads", [])
42
+ if reads_raw is None:
43
+ reads_raw = []
44
+ if not isinstance(reads_raw, list):
45
+ msg = "Capture payload field reads must be a list when provided"
46
+ raise AdapterValidationError(msg)
47
+
48
+ reads: list[dict[str, str]] = []
49
+ for index, item in enumerate(reads_raw):
50
+ if not isinstance(item, dict):
51
+ msg = f"reads[{index}] must be an object"
52
+ raise AdapterValidationError(msg)
53
+ source = item.get("source")
54
+ target = item.get("target")
55
+ if not isinstance(source, str) or not isinstance(target, str):
56
+ msg = f"reads[{index}] requires string source and target"
57
+ raise AdapterValidationError(msg)
58
+ reads.append({"source": source, "target": target})
59
+
60
+ return adapt_sequential_events(
61
+ trace_id=run_id,
62
+ events=steps,
63
+ reads=reads,
64
+ type_field="event_type",
65
+ tokens_field="total_tokens",
66
+ )
@@ -0,0 +1,17 @@
1
+ """Adapter translation errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from varly.core.errors import VarlyError
6
+
7
+
8
+ class AdapterError(VarlyError):
9
+ """Base error for adapter translation failures."""
10
+
11
+
12
+ class UnsupportedFormatError(AdapterError):
13
+ """Raised when an external payload format is not recognized."""
14
+
15
+
16
+ class AdapterValidationError(AdapterError):
17
+ """Raised when an external payload is missing required fields."""
File without changes
@@ -0,0 +1,25 @@
1
+ """Static JSON adapter for canonical AIR trace files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from varly.core.trace import Trace
10
+ from varly.parser import load_json_object, parse_trace_file, parse_trace_payload
11
+
12
+
13
+ def adapt_file(path: Path) -> Trace:
14
+ """Load a canonical AIR JSON trace file."""
15
+ return parse_trace_file(path)
16
+
17
+
18
+ def adapt_payload(payload: Mapping[str, Any]) -> Trace:
19
+ """Build a trace from an in-memory canonical AIR payload."""
20
+ return parse_trace_payload(payload)
21
+
22
+
23
+ def load_external_json(path: Path) -> dict[str, Any]:
24
+ """Load a JSON object from disk without assuming AIR schema."""
25
+ return load_json_object(path)
File without changes
@@ -0,0 +1,73 @@
1
+ """LangGraph reference adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from varly.adapters._sequential import adapt_sequential_events
10
+ from varly.adapters.errors import AdapterValidationError, UnsupportedFormatError
11
+ from varly.adapters.json.adapter import load_external_json
12
+ from varly.adapters.langgraph.normalize import (
13
+ LANGGRAPH_RUN_V1,
14
+ is_langgraph_callbacks,
15
+ is_langgraph_run_v1,
16
+ normalize_callbacks_payload,
17
+ )
18
+ from varly.core.trace import Trace
19
+
20
+
21
+ def adapt_file(path: Path) -> Trace:
22
+ """Translate a LangGraph run export file into an AIR trace."""
23
+ payload = load_external_json(path)
24
+ return adapt_payload(payload)
25
+
26
+
27
+ def adapt_payload(payload: Mapping[str, Any]) -> Trace:
28
+ """Translate a LangGraph run export payload into an AIR trace."""
29
+ if is_langgraph_callbacks(payload):
30
+ payload = normalize_callbacks_payload(payload)
31
+ elif not is_langgraph_run_v1(payload):
32
+ version = payload.get("format_version")
33
+ msg = (
34
+ "Unsupported LangGraph payload: expected format_version "
35
+ f"{LANGGRAPH_RUN_V1!r} or callback events "
36
+ f"(got format_version={version!r})"
37
+ )
38
+ raise UnsupportedFormatError(msg)
39
+
40
+ run_id = payload.get("run_id")
41
+ events = payload.get("events")
42
+ if not isinstance(run_id, str) or not run_id:
43
+ msg = "LangGraph payload requires non-empty string run_id"
44
+ raise AdapterValidationError(msg)
45
+ if not isinstance(events, list) or not events:
46
+ msg = "LangGraph payload requires non-empty events list"
47
+ raise AdapterValidationError(msg)
48
+
49
+ reads_raw = payload.get("reads", [])
50
+ if reads_raw is None:
51
+ reads_raw = []
52
+ if not isinstance(reads_raw, list):
53
+ msg = "LangGraph payload field reads must be a list when provided"
54
+ raise AdapterValidationError(msg)
55
+
56
+ reads: list[dict[str, str]] = []
57
+ for index, item in enumerate(reads_raw):
58
+ if not isinstance(item, dict):
59
+ msg = f"reads[{index}] must be an object"
60
+ raise AdapterValidationError(msg)
61
+ source = item.get("source")
62
+ target = item.get("target")
63
+ if not isinstance(source, str) or not isinstance(target, str):
64
+ msg = f"reads[{index}] requires string source and target"
65
+ raise AdapterValidationError(msg)
66
+ reads.append({"source": source, "target": target})
67
+
68
+ return adapt_sequential_events(
69
+ trace_id=run_id,
70
+ events=events,
71
+ reads=reads,
72
+ type_field="type",
73
+ )
@@ -0,0 +1,194 @@
1
+ """Normalize recorded LangGraph callback dumps into langgraph.run.v1."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from varly.adapters.errors import AdapterValidationError
9
+
10
+ LANGGRAPH_RUN_V1 = "langgraph.run.v1"
11
+
12
+
13
+ def is_langgraph_run_v1(payload: Mapping[str, Any]) -> bool:
14
+ return payload.get("format_version") == LANGGRAPH_RUN_V1
15
+
16
+
17
+ def is_langgraph_callbacks(payload: Mapping[str, Any]) -> bool:
18
+ if payload.get("object") == "langgraph.callback_events":
19
+ return isinstance(payload.get("events"), list)
20
+ events = payload.get("events")
21
+ if not isinstance(events, list) or not events:
22
+ return False
23
+ first = events[0]
24
+ return isinstance(first, dict) and "event" in first and "type" not in first
25
+
26
+
27
+ def normalize_callbacks_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
28
+ """Translate LangChain/LangGraph callback events into langgraph.run.v1."""
29
+ events_raw = payload.get("events")
30
+ if not isinstance(events_raw, list) or not events_raw:
31
+ msg = "LangGraph callback payload requires a non-empty events list"
32
+ raise AdapterValidationError(msg)
33
+
34
+ run_id = payload.get("run_id")
35
+ if not isinstance(run_id, str) or not run_id:
36
+ run_id = _root_run_id(events_raw)
37
+ if not isinstance(run_id, str) or not run_id:
38
+ msg = "LangGraph callback payload requires a run_id"
39
+ raise AdapterValidationError(msg)
40
+
41
+ events: list[dict[str, Any]] = []
42
+ llm_index: dict[str, int] = {}
43
+ timestamp_ms = 0
44
+ last_llm_id: str | None = None
45
+ reads: list[dict[str, str]] = []
46
+
47
+ for index, item in enumerate(events_raw):
48
+ if not isinstance(item, dict):
49
+ msg = f"events[{index}] must be an object"
50
+ raise AdapterValidationError(msg)
51
+ event_name = item.get("event")
52
+ if not isinstance(event_name, str) or not event_name:
53
+ msg = f"events[{index}].event must be a non-empty string"
54
+ raise AdapterValidationError(msg)
55
+
56
+ item_run_id = item.get("run_id")
57
+ if not isinstance(item_run_id, str) or not item_run_id:
58
+ item_run_id = f"{run_id}:event-{index}"
59
+
60
+ if event_name == "on_chain_start" and _is_root(item):
61
+ events.append(
62
+ {
63
+ "id": f"{run_id}:start",
64
+ "type": "chain_start",
65
+ "timestamp_ms": timestamp_ms,
66
+ }
67
+ )
68
+ timestamp_ms += 100
69
+ elif event_name in {"on_chat_model_start", "on_llm_start"}:
70
+ last_llm_id = item_run_id
71
+ events.append(
72
+ {
73
+ "id": item_run_id,
74
+ "type": "on_chat_model_start",
75
+ "timestamp_ms": timestamp_ms,
76
+ }
77
+ )
78
+ llm_index[item_run_id] = len(events) - 1
79
+ timestamp_ms += 100
80
+ elif event_name in {"on_chat_model_end", "on_llm_end"}:
81
+ tokens = _tokens_from_callback(item)
82
+ existing = llm_index.get(item_run_id)
83
+ if existing is not None:
84
+ if tokens is not None:
85
+ events[existing]["tokens"] = tokens
86
+ else:
87
+ last_llm_id = item_run_id
88
+ event: dict[str, Any] = {
89
+ "id": item_run_id,
90
+ "type": "on_chat_model_start",
91
+ "timestamp_ms": timestamp_ms,
92
+ }
93
+ if tokens is not None:
94
+ event["tokens"] = tokens
95
+ events.append(event)
96
+ timestamp_ms += 100
97
+ elif event_name == "on_tool_start":
98
+ name = _callback_name(item, index, "on_tool_start")
99
+ events.append(
100
+ {
101
+ "id": item_run_id,
102
+ "type": "on_tool_start",
103
+ "name": name,
104
+ "timestamp_ms": timestamp_ms,
105
+ }
106
+ )
107
+ if last_llm_id is not None:
108
+ reads.append({"source": item_run_id, "target": last_llm_id})
109
+ timestamp_ms += 300
110
+ elif event_name == "on_tool_end":
111
+ name = _callback_name(item, index, "on_tool_end")
112
+ events.append(
113
+ {
114
+ "id": f"{item_run_id}:end",
115
+ "type": "on_tool_end",
116
+ "name": name,
117
+ "timestamp_ms": timestamp_ms,
118
+ }
119
+ )
120
+ timestamp_ms += 100
121
+ elif event_name == "on_chain_end" and _is_root(item):
122
+ events.append(
123
+ {
124
+ "id": f"{run_id}:end",
125
+ "type": "chain_end",
126
+ "timestamp_ms": timestamp_ms,
127
+ }
128
+ )
129
+ elif event_name in {"on_chain_start", "on_chain_end"}:
130
+ continue
131
+ else:
132
+ msg = f"events[{index}] has unsupported event: {event_name!r}"
133
+ raise AdapterValidationError(msg)
134
+
135
+ if not events:
136
+ msg = "LangGraph callback payload produced no mapped events"
137
+ raise AdapterValidationError(msg)
138
+
139
+ return {
140
+ "format_version": LANGGRAPH_RUN_V1,
141
+ "run_id": run_id,
142
+ "events": events,
143
+ "reads": reads,
144
+ }
145
+
146
+
147
+ def _is_root(item: Mapping[str, Any]) -> bool:
148
+ parent_ids = item.get("parent_ids")
149
+ if parent_ids is None:
150
+ return True
151
+ if isinstance(parent_ids, list):
152
+ return len(parent_ids) == 0
153
+ return False
154
+
155
+
156
+ def _root_run_id(events: list[object]) -> str | None:
157
+ for item in events:
158
+ if isinstance(item, dict) and item.get("event") == "on_chain_start":
159
+ if _is_root(item):
160
+ run_id = item.get("run_id")
161
+ if isinstance(run_id, str) and run_id:
162
+ return run_id
163
+ return None
164
+
165
+
166
+ def _callback_name(item: Mapping[str, Any], index: int, event_name: str) -> str:
167
+ name = item.get("name")
168
+ if isinstance(name, str) and name:
169
+ return name
170
+ msg = f"events[{index}] {event_name} requires a non-empty name"
171
+ raise AdapterValidationError(msg)
172
+
173
+
174
+ def _tokens_from_callback(item: Mapping[str, Any]) -> int | float | None:
175
+ data = item.get("data")
176
+ if not isinstance(data, Mapping):
177
+ return None
178
+ output = data.get("output")
179
+ if not isinstance(output, Mapping):
180
+ return None
181
+ for usage_container in (
182
+ output.get("llm_output"),
183
+ output.get("usage"),
184
+ output,
185
+ ):
186
+ if not isinstance(usage_container, Mapping):
187
+ continue
188
+ usage = usage_container.get("token_usage", usage_container)
189
+ if not isinstance(usage, Mapping):
190
+ continue
191
+ total = usage.get("total_tokens")
192
+ if isinstance(total, (int, float)) and not isinstance(total, bool):
193
+ return total
194
+ return None
File without changes