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.
- varly/__init__.py +0 -0
- varly/adapters/__init__.py +31 -0
- varly/adapters/_mapping.py +50 -0
- varly/adapters/_payload.py +30 -0
- varly/adapters/_sequential.py +129 -0
- varly/adapters/_source_hints.py +41 -0
- varly/adapters/capture/__init__.py +1 -0
- varly/adapters/capture/adapter.py +66 -0
- varly/adapters/errors.py +17 -0
- varly/adapters/json/__init__.py +0 -0
- varly/adapters/json/adapter.py +25 -0
- varly/adapters/langgraph/__init__.py +0 -0
- varly/adapters/langgraph/adapter.py +73 -0
- varly/adapters/langgraph/normalize.py +194 -0
- varly/adapters/openai/__init__.py +0 -0
- varly/adapters/openai/adapter.py +73 -0
- varly/adapters/openai/normalize.py +151 -0
- varly/analyzer/__init__.py +33 -0
- varly/analyzer/diagnostic.py +29 -0
- varly/analyzer/diff.py +62 -0
- varly/analyzer/engine.py +18 -0
- varly/analyzer/evaluator.py +55 -0
- varly/analyzer/export.py +46 -0
- varly/analyzer/reports.py +160 -0
- varly/analyzer/state_builder.py +12 -0
- varly/capture/__init__.py +11 -0
- varly/capture/model.py +33 -0
- varly/capture/recorder.py +144 -0
- varly/contracts/__init__.py +22 -0
- varly/contracts/builtins/__init__.py +23 -0
- varly/contracts/builtins/business.py +159 -0
- varly/contracts/builtins/metrics.py +87 -0
- varly/contracts/builtins/semantic.py +84 -0
- varly/contracts/builtins/structural.py +83 -0
- varly/contracts/errors.py +21 -0
- varly/contracts/loader.py +119 -0
- varly/contracts/model.py +33 -0
- varly/core/__init__.py +85 -0
- varly/core/errors.py +35 -0
- varly/core/labeling.py +31 -0
- varly/core/ordering.py +69 -0
- varly/core/state.py +60 -0
- varly/core/topology.py +208 -0
- varly/core/trace.py +68 -0
- varly/core/types.py +44 -0
- varly/interfaces/__init__.py +0 -0
- varly/interfaces/cli/__init__.py +0 -0
- varly/interfaces/cli/commands/__init__.py +0 -0
- varly/interfaces/cli/commands/diff.py +93 -0
- varly/interfaces/cli/commands/validate.py +53 -0
- varly/interfaces/cli/commands/verify.py +169 -0
- varly/interfaces/cli/commands/view.py +160 -0
- varly/interfaces/cli/main.py +38 -0
- varly/interfaces/cli/render.py +80 -0
- varly/interfaces/library/__init__.py +21 -0
- varly/interfaces/library/api.py +105 -0
- varly/interfaces/viewer/.gitignore +1 -0
- varly/interfaces/viewer/index.html +289 -0
- varly/interfaces/viewer/report.py +140 -0
- varly/parser/__init__.py +20 -0
- varly/parser/builder.py +114 -0
- varly/parser/errors.py +13 -0
- varly/parser/json_loader.py +47 -0
- varly/parser/schema.py +133 -0
- varly/resources/__init__.py +16 -0
- varly/resources/fixtures/__init__.py +0 -0
- varly/resources/fixtures/mock_run.json +40 -0
- varly/resources/policies/__init__.py +0 -0
- varly/resources/policies/dev.yaml +29 -0
- varly/resources/policies/mvp.yaml +29 -0
- varly/resources/policies/strict.yaml +30 -0
- varly-1.0.0.dist-info/METADATA +279 -0
- varly-1.0.0.dist-info/RECORD +76 -0
- varly-1.0.0.dist-info/WHEEL +4 -0
- varly-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
varly/adapters/errors.py
ADDED
|
@@ -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
|