agent-tracegraph 0.2.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.
- agent_tracegraph-0.2.0.dist-info/METADATA +380 -0
- agent_tracegraph-0.2.0.dist-info/RECORD +28 -0
- agent_tracegraph-0.2.0.dist-info/WHEEL +4 -0
- agent_tracegraph-0.2.0.dist-info/entry_points.txt +2 -0
- agent_tracegraph-0.2.0.dist-info/licenses/LICENSE +191 -0
- tracegraph/__init__.py +38 -0
- tracegraph/adapters/__init__.py +13 -0
- tracegraph/adapters/base.py +22 -0
- tracegraph/adapters/langgraph_checkpoint.py +804 -0
- tracegraph/adapters/otlp_spans.py +703 -0
- tracegraph/adapters/phoenix_export.py +218 -0
- tracegraph/analysis/__init__.py +55 -0
- tracegraph/analysis/ahu.py +329 -0
- tracegraph/analysis/diagnose.py +676 -0
- tracegraph/analysis/explain.py +46 -0
- tracegraph/analysis/patterns.py +628 -0
- tracegraph/artifact.py +192 -0
- tracegraph/cli.py +1242 -0
- tracegraph/input_validation.py +19 -0
- tracegraph/model.py +225 -0
- tracegraph/normalize.py +248 -0
- tracegraph/py.typed +0 -0
- tracegraph/review_candidates.py +136 -0
- tracegraph/sqlite_snapshot.py +65 -0
- tracegraph/store/__init__.py +22 -0
- tracegraph/store/base.py +43 -0
- tracegraph/store/in_memory.py +94 -0
- tracegraph/store/ladybug.py +505 -0
tracegraph/artifact.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""The portable artifact: tracegraph's system of record.
|
|
2
|
+
|
|
3
|
+
A :class:`~tracegraph.model.NormalizedTrace` serializes to a single self-describing
|
|
4
|
+
JSON file (schema-versioned). This artifact — not any graph-DB file — is the source
|
|
5
|
+
of truth: query backends (the in-memory store and optional LadybugDB cache) are
|
|
6
|
+
always rebuildable from it. Parquet is a future option; JSON keeps Phase 0 boring.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import hashlib
|
|
13
|
+
from collections.abc import Iterable, Mapping
|
|
14
|
+
import re
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import tempfile
|
|
18
|
+
|
|
19
|
+
from tracegraph.model import NormalizedTrace, StepSource
|
|
20
|
+
|
|
21
|
+
#: Bump when the on-disk shape changes incompatibly.
|
|
22
|
+
ARTIFACT_SCHEMA_VERSION = 3
|
|
23
|
+
|
|
24
|
+
#: Versions this build can read. 1 needs a migration (see ``_migrate_v1``); 2 -> 3 is the
|
|
25
|
+
#: identity, because 3 adds only a new ``Step.source`` value and changes nothing existing.
|
|
26
|
+
_READABLE_VERSIONS = (1, 2, ARTIFACT_SCHEMA_VERSION)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _required_version(nt: NormalizedTrace) -> int:
|
|
30
|
+
"""The lowest version that can read these bytes.
|
|
31
|
+
|
|
32
|
+
Stamped from the *content*, not from the build, so serializing an artifact that uses no
|
|
33
|
+
new vocabulary keeps producing byte-identical v2 output. That matters because the
|
|
34
|
+
envelope is inside the hash: ``review_candidates`` binds each candidate to the sha256 of
|
|
35
|
+
the artifact file, and ``LadybugStore`` round-trips artifacts byte for byte. A blanket
|
|
36
|
+
bump would invalidate digests already exported in review-candidate reports for artifacts
|
|
37
|
+
whose content did not change at all.
|
|
38
|
+
|
|
39
|
+
An artifact that *does* carry a derived task step is stamped 3, so an older build refuses
|
|
40
|
+
it with a clear "unsupported artifact schema_version" instead of a pydantic enum error.
|
|
41
|
+
"""
|
|
42
|
+
task = StepSource.TASK
|
|
43
|
+
return 3 if any(step.source is task for step in nt.steps) else 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dumps(nt: NormalizedTrace) -> str:
|
|
47
|
+
"""Serialize a trace to a JSON string (stable key order for diff-friendly output)."""
|
|
48
|
+
payload = {"schema_version": _required_version(nt), "trace": nt.model_dump(mode="json")}
|
|
49
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _migrate_v1(trace_payload: dict) -> dict:
|
|
53
|
+
"""Add only v2 evidence metadata; never reinterpret legacy causal semantics.
|
|
54
|
+
|
|
55
|
+
Copies exactly the containers it writes to — the envelope, the trace header, and the
|
|
56
|
+
CAUSED_BY edges — and shares everything else with the caller. Nothing outside those is
|
|
57
|
+
mutated, so sharing is safe. The cost still scales with the edge collection, which it has
|
|
58
|
+
to walk; what it no longer does is traverse unrelated nested values it never reads.
|
|
59
|
+
|
|
60
|
+
Whole-payload copies were tried twice and both leaked an exception type this module
|
|
61
|
+
promises never to raise. A JSON round-trip raises ``TypeError`` on any value json cannot
|
|
62
|
+
serialize, and ``from_obj`` accepts already-parsed payloads that may hold such values.
|
|
63
|
+
``copy.deepcopy`` fixed that but recurses per level, so a deeply-nested value in a field
|
|
64
|
+
the model ignores raised ``RecursionError`` on an artifact that used to load. Neither is
|
|
65
|
+
``OSError`` or ``ValueError``, so both escaped every load-boundary handler and crashed the
|
|
66
|
+
command. Touching only what we modify avoids the whole class: no traversal, no recursion.
|
|
67
|
+
"""
|
|
68
|
+
migrated = dict(trace_payload)
|
|
69
|
+
header = migrated.get("trace")
|
|
70
|
+
if isinstance(header, dict):
|
|
71
|
+
header = dict(header)
|
|
72
|
+
header.setdefault("causal_fidelity", "legacy_unknown")
|
|
73
|
+
header.setdefault("links_preserved", None)
|
|
74
|
+
migrated["trace"] = header
|
|
75
|
+
edges = migrated.get("edges")
|
|
76
|
+
# Match the *shape*, not a list of concrete types. The model accepts any iterable for its
|
|
77
|
+
# edge sequence (list, tuple, deque, a one-shot iterator), and enumerating types here
|
|
78
|
+
# meant every shape not on the list silently skipped migration, so its CAUSED_BY edges
|
|
79
|
+
# lost the legacy origin default. Strings, bytes and mappings are iterable but are never
|
|
80
|
+
# an edge sequence; anything else non-iterable falls through untouched for the model to
|
|
81
|
+
# reject as a clean ValueError. Materializing once also leaves a one-shot iterator usable,
|
|
82
|
+
# which iterating in place did not.
|
|
83
|
+
if isinstance(edges, Iterable) and not isinstance(edges, (str, bytes, bytearray, Mapping)):
|
|
84
|
+
try:
|
|
85
|
+
# Mapping, not dict: the model accepts any mapping as an edge record, and matching
|
|
86
|
+
# dict alone left mapping-shaped records without the legacy origin default.
|
|
87
|
+
migrated["edges"] = [
|
|
88
|
+
{"origin": "legacy_unknown", **edge}
|
|
89
|
+
if isinstance(edge, Mapping) and edge.get("type") == "CAUSED_BY"
|
|
90
|
+
else edge
|
|
91
|
+
for edge in edges
|
|
92
|
+
]
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
# Consuming a caller-supplied lazy sequence can raise anything at all. This is a
|
|
95
|
+
# load boundary that promises ValueError, and the v2 path already reports a
|
|
96
|
+
# failing iterator that way (as a ValidationError), so v1 must not differ.
|
|
97
|
+
raise ValueError(f"artifact edges could not be read: {exc}") from exc
|
|
98
|
+
return migrated
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def loads(text: str) -> NormalizedTrace:
|
|
102
|
+
"""Parse a trace from a JSON string, validating the schema version."""
|
|
103
|
+
try:
|
|
104
|
+
payload = json.loads(text)
|
|
105
|
+
except RecursionError as exc:
|
|
106
|
+
# Deeply-nested JSON overflows json's recursive scanner with a RecursionError (a
|
|
107
|
+
# RuntimeError, *not* a ValueError). Normalize it so the load boundary treats an
|
|
108
|
+
# over-nested stray as bad input — otherwise it escapes the CLI's (OSError, ValueError)
|
|
109
|
+
# handler and crashes the whole command with a raw traceback.
|
|
110
|
+
raise ValueError("JSON nesting too deep") from exc
|
|
111
|
+
return from_obj(payload)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def from_obj(payload: object) -> NormalizedTrace:
|
|
115
|
+
"""Validate an already-parsed artifact payload (the envelope ``loads`` would parse).
|
|
116
|
+
|
|
117
|
+
Exists so a caller that already holds the parsed JSON (e.g. the CLI, which sniffs
|
|
118
|
+
``schema_version`` first) doesn't re-parse the whole document. Raises only ``ValueError``
|
|
119
|
+
for malformed input, same contract as :func:`loads`.
|
|
120
|
+
"""
|
|
121
|
+
# The artifact envelope is a JSON object. A top-level array/string/number/null is valid
|
|
122
|
+
# JSON but not an artifact (e.g. a stray data export) — reject it as a clean ValueError
|
|
123
|
+
# rather than letting ``payload.get`` raise an opaque AttributeError that callers can't
|
|
124
|
+
# distinguish from a real bug. CLI directory globbing relies on this to skip non-artifacts.
|
|
125
|
+
if not isinstance(payload, dict):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"artifact JSON must be a top-level object, got {type(payload).__name__}"
|
|
128
|
+
)
|
|
129
|
+
version = payload.get("schema_version")
|
|
130
|
+
if version not in _READABLE_VERSIONS:
|
|
131
|
+
readable = ", ".join(str(v) for v in _READABLE_VERSIONS)
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"unsupported artifact schema_version {version!r} (this build reads {readable})"
|
|
134
|
+
)
|
|
135
|
+
# Guard the last raw dereference: a dict with the right schema_version but no "trace" key
|
|
136
|
+
# (e.g. `{"schema_version": 1}`) would otherwise raise a bare KeyError, which — like the
|
|
137
|
+
# AttributeError and RecursionError above — is neither OSError nor ValueError and would
|
|
138
|
+
# escape the CLI's load handler. With this guard, loads() raises *only* ValueError for any
|
|
139
|
+
# malformed input, so every load path (strict file, lenient directory skip) stays clean.
|
|
140
|
+
if "trace" not in payload:
|
|
141
|
+
raise ValueError('artifact JSON is missing the required "trace" object')
|
|
142
|
+
trace_payload = payload["trace"]
|
|
143
|
+
if not isinstance(trace_payload, dict):
|
|
144
|
+
raise ValueError('artifact "trace" must be an object')
|
|
145
|
+
if version == 1:
|
|
146
|
+
trace_payload = _migrate_v1(trace_payload)
|
|
147
|
+
return NormalizedTrace.model_validate(trace_payload)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def save(nt: NormalizedTrace, path: str | Path) -> None:
|
|
151
|
+
Path(path).write_text(dumps(nt), encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def save_atomic(nt: NormalizedTrace, path: str | Path, *, replace: bool = True) -> None:
|
|
155
|
+
"""Validate and atomically replace an artifact without leaving partial output."""
|
|
156
|
+
target = Path(path)
|
|
157
|
+
text = dumps(nt)
|
|
158
|
+
# Writer-side contract check before touching the destination: the payload we just
|
|
159
|
+
# serialized must round-trip through the model. Validates the parsed shape directly —
|
|
160
|
+
# no need to re-parse the JSON string we produced ourselves.
|
|
161
|
+
NormalizedTrace.model_validate(nt.model_dump(mode="json"))
|
|
162
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
temp_name: str | None = None
|
|
164
|
+
try:
|
|
165
|
+
with tempfile.NamedTemporaryFile(
|
|
166
|
+
mode="w", encoding="utf-8", dir=target.parent, prefix=f".{target.name}.", delete=False
|
|
167
|
+
) as handle:
|
|
168
|
+
handle.write(text)
|
|
169
|
+
handle.flush()
|
|
170
|
+
os.fsync(handle.fileno())
|
|
171
|
+
temp_name = handle.name
|
|
172
|
+
if replace:
|
|
173
|
+
os.replace(temp_name, target)
|
|
174
|
+
else:
|
|
175
|
+
os.link(temp_name, target)
|
|
176
|
+
finally:
|
|
177
|
+
if temp_name is not None:
|
|
178
|
+
Path(temp_name).unlink(missing_ok=True)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def load(path: str | Path) -> NormalizedTrace:
|
|
182
|
+
return loads(Path(path).read_text(encoding="utf-8"))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def default_path(identifier: str) -> Path:
|
|
186
|
+
"""Opaque source identifiers never become directories or reserved filenames."""
|
|
187
|
+
reserved = {"CON", "PRN", "AUX", "NUL"} | {f"{p}{i}" for p in ("COM", "LPT") for i in range(1, 10)}
|
|
188
|
+
if (re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", identifier)
|
|
189
|
+
and identifier.split(".")[0].upper() not in reserved
|
|
190
|
+
and not identifier.endswith(".")):
|
|
191
|
+
return Path(identifier + ".json")
|
|
192
|
+
return Path("trace-" + hashlib.sha256(identifier.encode()).hexdigest() + ".json")
|