semantic-trace 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_trace/__init__.py +78 -0
- agent_trace/cli.py +158 -0
- agent_trace/core/__init__.py +39 -0
- agent_trace/core/schema.py +401 -0
- agent_trace/core/serializer.py +175 -0
- agent_trace/engine/__init__.py +33 -0
- agent_trace/engine/invariants.py +340 -0
- agent_trace/engine/replay.py +173 -0
- agent_trace/integrations/__init__.py +18 -0
- agent_trace/integrations/langgraph.py +213 -0
- semantic_trace-0.1.0.dist-info/METADATA +251 -0
- semantic_trace-0.1.0.dist-info/RECORD +15 -0
- semantic_trace-0.1.0.dist-info/WHEEL +4 -0
- semantic_trace-0.1.0.dist-info/entry_points.txt +2 -0
- semantic_trace-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_trace/__init__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""agent-trace: Semantic tracing primitive for AI agents.
|
|
2
|
+
|
|
3
|
+
Minimal, composable, and zero-bloat by design.
|
|
4
|
+
|
|
5
|
+
Quick start:
|
|
6
|
+
from agent_trace import Trace, IntentInvariant, InvariantType, semantic_replay
|
|
7
|
+
|
|
8
|
+
invariants = [
|
|
9
|
+
IntentInvariant(
|
|
10
|
+
id="valid-json",
|
|
11
|
+
description="Output must be valid JSON",
|
|
12
|
+
invariant_type=InvariantType.SUBSTRING_CHECK,
|
|
13
|
+
config={"substring": '"summary"'},
|
|
14
|
+
fidelity_threshold=1.0,
|
|
15
|
+
),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
with Trace(name="my-agent", invariants=invariants, output_file="traces/run.jsonl") as trace:
|
|
19
|
+
trace.add_span(span)
|
|
20
|
+
|
|
21
|
+
report = semantic_replay("traces/run.jsonl")
|
|
22
|
+
print(report.summary())
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from agent_trace.core.schema import (
|
|
26
|
+
ActionType,
|
|
27
|
+
IntentInvariant,
|
|
28
|
+
InvariantResult,
|
|
29
|
+
InvariantType,
|
|
30
|
+
ReplayReport,
|
|
31
|
+
Span,
|
|
32
|
+
Trace,
|
|
33
|
+
TraceMetadata,
|
|
34
|
+
TraceModel,
|
|
35
|
+
)
|
|
36
|
+
from agent_trace.core.serializer import (
|
|
37
|
+
read_trace_from_jsonl,
|
|
38
|
+
write_metadata_to_jsonl,
|
|
39
|
+
write_span_to_jsonl,
|
|
40
|
+
)
|
|
41
|
+
from agent_trace.engine.invariants import (
|
|
42
|
+
BaseInvariantChecker,
|
|
43
|
+
InvariantViolation,
|
|
44
|
+
LLMAsJudgeChecker,
|
|
45
|
+
SchemaInvariantChecker,
|
|
46
|
+
SubstringInvariantChecker,
|
|
47
|
+
)
|
|
48
|
+
from agent_trace.engine.replay import (
|
|
49
|
+
mechanical_replay,
|
|
50
|
+
semantic_replay,
|
|
51
|
+
validate_trace,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
__version__ = "0.1.0"
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"ActionType",
|
|
58
|
+
"BaseInvariantChecker",
|
|
59
|
+
"IntentInvariant",
|
|
60
|
+
"InvariantResult",
|
|
61
|
+
"InvariantType",
|
|
62
|
+
"InvariantViolation",
|
|
63
|
+
"LLMAsJudgeChecker",
|
|
64
|
+
"ReplayReport",
|
|
65
|
+
"SchemaInvariantChecker",
|
|
66
|
+
"Span",
|
|
67
|
+
"SubstringInvariantChecker",
|
|
68
|
+
"Trace",
|
|
69
|
+
"TraceMetadata",
|
|
70
|
+
"TraceModel",
|
|
71
|
+
"__version__",
|
|
72
|
+
"mechanical_replay",
|
|
73
|
+
"read_trace_from_jsonl",
|
|
74
|
+
"semantic_replay",
|
|
75
|
+
"validate_trace",
|
|
76
|
+
"write_metadata_to_jsonl",
|
|
77
|
+
"write_span_to_jsonl",
|
|
78
|
+
]
|
agent_trace/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""CLI entry point for inspecting and validating trace files.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
trace <command> <trace_file> [options]
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
info Show trace metadata summary
|
|
8
|
+
validate Run mechanical (structural) validation
|
|
9
|
+
replay Run full semantic replay with invariant checks
|
|
10
|
+
spans List all spans with their durations and invariant results
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from agent_trace.core.serializer import read_trace_from_jsonl
|
|
21
|
+
from agent_trace.engine.replay import mechanical_replay, validate_trace
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cmd_info(args: argparse.Namespace) -> None:
|
|
25
|
+
"""Print trace metadata summary to stdout."""
|
|
26
|
+
trace = read_trace_from_jsonl(args.trace_file)
|
|
27
|
+
if args.json:
|
|
28
|
+
print(json.dumps(trace.metadata.model_dump(mode="json"), indent=2))
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
print(f"Trace ID: {trace.metadata.trace_id}")
|
|
32
|
+
print(f"Session ID: {trace.metadata.session_id}")
|
|
33
|
+
print(f"Agent: {trace.metadata.agent_name}")
|
|
34
|
+
print(f"Start: {trace.metadata.start_time.isoformat()}")
|
|
35
|
+
end = trace.metadata.end_time.isoformat() if trace.metadata.end_time else "N/A"
|
|
36
|
+
print(f"End: {end}")
|
|
37
|
+
print(f"Spans: {len(trace.spans)}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_validate(args: argparse.Namespace) -> None:
|
|
41
|
+
"""Run mechanical validation and exit non-zero on errors."""
|
|
42
|
+
errors = mechanical_replay(args.trace_file)
|
|
43
|
+
if args.json:
|
|
44
|
+
print(json.dumps({"valid": len(errors) == 0, "errors": errors}, indent=2))
|
|
45
|
+
if errors:
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if errors:
|
|
50
|
+
print("Structural errors:")
|
|
51
|
+
for err in errors:
|
|
52
|
+
print(f" - {err}")
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
print("Structural validation passed.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_replay(args: argparse.Namespace) -> None:
|
|
58
|
+
"""Run full semantic replay and exit non-zero on violations."""
|
|
59
|
+
report = validate_trace(args.trace_file)
|
|
60
|
+
|
|
61
|
+
if args.json:
|
|
62
|
+
output = {
|
|
63
|
+
"clean": report.is_clean,
|
|
64
|
+
"trace_id": str(report.trace_id),
|
|
65
|
+
"agent_name": report.agent_name,
|
|
66
|
+
"total_spans": report.total_spans,
|
|
67
|
+
"total_invariants": report.total_invariants,
|
|
68
|
+
"pass_rate": report.pass_rate,
|
|
69
|
+
"structural_errors": report.structural_errors,
|
|
70
|
+
"violations": [
|
|
71
|
+
{
|
|
72
|
+
"span_id": v.span_id,
|
|
73
|
+
"invariant_id": v.invariant_id,
|
|
74
|
+
"expected_score": v.expected_score,
|
|
75
|
+
"actual_score": v.actual_score,
|
|
76
|
+
}
|
|
77
|
+
for v in report.violations
|
|
78
|
+
],
|
|
79
|
+
}
|
|
80
|
+
print(json.dumps(output, indent=2))
|
|
81
|
+
if not report.is_clean:
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
print(report.summary())
|
|
86
|
+
if not report.is_clean:
|
|
87
|
+
report.print_violations()
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_spans(args: argparse.Namespace) -> None:
|
|
92
|
+
"""List all spans with action type, UUID, duration, and invariant results."""
|
|
93
|
+
trace = read_trace_from_jsonl(args.trace_file)
|
|
94
|
+
|
|
95
|
+
if args.json:
|
|
96
|
+
spans = []
|
|
97
|
+
for span in trace.spans:
|
|
98
|
+
span_data = span.model_dump(mode="json")
|
|
99
|
+
spans.append(span_data)
|
|
100
|
+
print(json.dumps(spans, indent=2))
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
for span in trace.spans:
|
|
104
|
+
if span.duration_ms is not None:
|
|
105
|
+
print(f"[{span.action_type}] {span.span_id} ({span.duration_ms:.0f}ms)")
|
|
106
|
+
else:
|
|
107
|
+
print(f"[{span.action_type}] {span.span_id}")
|
|
108
|
+
if span.invariant_results:
|
|
109
|
+
for inv_id, score in span.invariant_results.items():
|
|
110
|
+
status = "PASS" if score >= 1.0 else "FAIL"
|
|
111
|
+
print(f" {inv_id}: {score:.2f} [{status}]")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main() -> None:
|
|
115
|
+
"""CLI entry point. Parses arguments and dispatches to the appropriate command."""
|
|
116
|
+
parser = argparse.ArgumentParser(
|
|
117
|
+
prog="trace",
|
|
118
|
+
description="Semantic tracing CLI for AI agents. Inspect, validate, and replay trace files.",
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"--version",
|
|
122
|
+
action="version",
|
|
123
|
+
version="agent-trace 0.1.0",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
127
|
+
|
|
128
|
+
for name, help_text, handler in [
|
|
129
|
+
("info", "Show trace metadata summary", cmd_info),
|
|
130
|
+
("validate", "Run structural validation", cmd_validate),
|
|
131
|
+
("replay", "Run full semantic replay", cmd_replay),
|
|
132
|
+
("spans", "List all spans", cmd_spans),
|
|
133
|
+
]:
|
|
134
|
+
sub = subparsers.add_parser(name, help=help_text)
|
|
135
|
+
sub.add_argument("trace_file", type=str, help="Path to the JSONL trace file")
|
|
136
|
+
sub.add_argument(
|
|
137
|
+
"--json",
|
|
138
|
+
action="store_true",
|
|
139
|
+
help="Output in JSON format for machine consumption",
|
|
140
|
+
)
|
|
141
|
+
sub.set_defaults(handler=handler)
|
|
142
|
+
|
|
143
|
+
args = parser.parse_args()
|
|
144
|
+
|
|
145
|
+
if args.command is None:
|
|
146
|
+
parser.print_help()
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
trace_path = Path(args.trace_file)
|
|
150
|
+
if not trace_path.exists():
|
|
151
|
+
print(f"Error: file not found: {args.trace_file}", file=sys.stderr)
|
|
152
|
+
sys.exit(1)
|
|
153
|
+
|
|
154
|
+
args.handler(args)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Core data models and serialization for agent-trace.
|
|
2
|
+
|
|
3
|
+
Provides Pydantic models (TraceModel, Span, IntentInvariant), the Trace
|
|
4
|
+
context manager, and JSONL serialization utilities.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from agent_trace.core.schema import (
|
|
8
|
+
ActionType,
|
|
9
|
+
IntentInvariant,
|
|
10
|
+
InvariantResult,
|
|
11
|
+
InvariantType,
|
|
12
|
+
ReplayReport,
|
|
13
|
+
Span,
|
|
14
|
+
Trace,
|
|
15
|
+
TraceMetadata,
|
|
16
|
+
TraceModel,
|
|
17
|
+
)
|
|
18
|
+
from agent_trace.core.serializer import (
|
|
19
|
+
read_trace_from_jsonl,
|
|
20
|
+
write_metadata_to_jsonl,
|
|
21
|
+
write_span_to_jsonl,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# Models
|
|
26
|
+
"ActionType",
|
|
27
|
+
"IntentInvariant",
|
|
28
|
+
"InvariantResult",
|
|
29
|
+
"InvariantType",
|
|
30
|
+
"ReplayReport",
|
|
31
|
+
"Span",
|
|
32
|
+
"Trace",
|
|
33
|
+
"TraceMetadata",
|
|
34
|
+
"TraceModel",
|
|
35
|
+
# Serialization
|
|
36
|
+
"read_trace_from_jsonl",
|
|
37
|
+
"write_metadata_to_jsonl",
|
|
38
|
+
"write_span_to_jsonl",
|
|
39
|
+
]
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Pydantic v2 data models and Trace context manager for agent-trace.
|
|
2
|
+
|
|
3
|
+
All models use strict typing and Pydantic v2 validation. No mutable defaults.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from agent_trace.engine.invariants import InvariantViolation
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class InvariantType(str, Enum):
|
|
22
|
+
"""Enumeration of built-in invariant checker types.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
SCHEMA_MATCH: Validates output against a Pydantic-compatible schema.
|
|
26
|
+
SUBSTRING_CHECK: Checks for a target substring in the JSON-serialized output.
|
|
27
|
+
LLM_AS_JUDGE: Uses an LLM to semantically evaluate the output.
|
|
28
|
+
CUSTOM: Placeholder for user-defined checkers via the ABC.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
SCHEMA_MATCH = "SCHEMA_MATCH"
|
|
32
|
+
SUBSTRING_CHECK = "SUBSTRING_CHECK"
|
|
33
|
+
LLM_AS_JUDGE = "LLM_AS_JUDGE"
|
|
34
|
+
CUSTOM = "CUSTOM"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ActionType(str, Enum):
|
|
38
|
+
"""Enumeration of span action types.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
LLM_CALL: A call to a language model.
|
|
42
|
+
TOOL_CALL: A call to an external tool.
|
|
43
|
+
AGENT_STEP: A high-level agent reasoning step.
|
|
44
|
+
CUSTOM: A user-defined action type.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
LLM_CALL = "llm_call"
|
|
48
|
+
TOOL_CALL = "tool_call"
|
|
49
|
+
AGENT_STEP = "agent_step"
|
|
50
|
+
CUSTOM = "custom"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TraceMetadata(BaseModel):
|
|
54
|
+
"""Metadata header for a trace.
|
|
55
|
+
|
|
56
|
+
Written as the first line of every JSONL trace file.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
trace_id: Unique identifier for this trace.
|
|
60
|
+
session_id: Identifier for the agent session that produced this trace.
|
|
61
|
+
agent_name: Human-readable name of the agent.
|
|
62
|
+
start_time: UTC timestamp when the trace was created.
|
|
63
|
+
end_time: UTC timestamp when the trace was finalized. None until closed.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
trace_id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
|
67
|
+
session_id: str
|
|
68
|
+
agent_name: str
|
|
69
|
+
start_time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
70
|
+
end_time: datetime | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IntentInvariant(BaseModel):
|
|
74
|
+
"""A declarative intent assertion attached to a span.
|
|
75
|
+
|
|
76
|
+
Invariants express what the span's output *should* satisfy. They are
|
|
77
|
+
evaluated during semantic replay.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
id: Unique identifier for this invariant.
|
|
81
|
+
description: Human-readable description of the intent.
|
|
82
|
+
invariant_type: The checker type to use.
|
|
83
|
+
config: Rule parameters passed to the checker (e.g., ``{"substring": "..."}``).
|
|
84
|
+
fidelity_threshold: Minimum score (0.0-1.0) for the check to pass.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
description: str
|
|
89
|
+
invariant_type: InvariantType
|
|
90
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
91
|
+
fidelity_threshold: float = Field(ge=0.0, le=1.0, default=1.0)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Span(BaseModel):
|
|
95
|
+
"""A single unit of execution within a trace.
|
|
96
|
+
|
|
97
|
+
Each span represents one atomic action (LLM call, tool invocation, etc.)
|
|
98
|
+
and may carry attached invariants for later verification.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
span_id: Unique identifier for this span.
|
|
102
|
+
parent_id: Optional parent span UUID for nested execution.
|
|
103
|
+
trace_id: The trace this span belongs to.
|
|
104
|
+
timestamp: UTC timestamp when the span started.
|
|
105
|
+
action_type: The kind of action this span represents.
|
|
106
|
+
input_data: The input payload for the action.
|
|
107
|
+
output_data: The output payload produced by the action.
|
|
108
|
+
duration_ms: Execution duration in milliseconds, if available.
|
|
109
|
+
attached_invariants: Intent invariants to verify during replay.
|
|
110
|
+
invariant_results: Post-execution scores, populated by semantic replay.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
span_id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
|
114
|
+
parent_id: uuid.UUID | None = None
|
|
115
|
+
trace_id: uuid.UUID
|
|
116
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
117
|
+
action_type: ActionType
|
|
118
|
+
input_data: dict[str, Any] = Field(default_factory=dict)
|
|
119
|
+
output_data: dict[str, Any] = Field(default_factory=dict)
|
|
120
|
+
duration_ms: float | None = None
|
|
121
|
+
attached_invariants: list[IntentInvariant] = Field(default_factory=list)
|
|
122
|
+
invariant_results: dict[str, float] | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TraceModel(BaseModel):
|
|
126
|
+
"""A complete trace consisting of metadata and ordered spans.
|
|
127
|
+
|
|
128
|
+
This is the core Pydantic model. For the high-level context manager API,
|
|
129
|
+
use ``Trace`` instead.
|
|
130
|
+
|
|
131
|
+
Attributes:
|
|
132
|
+
metadata: The trace header with session and agent information.
|
|
133
|
+
spans: Ordered list of spans captured during execution.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
metadata: TraceMetadata
|
|
137
|
+
spans: list[Span] = Field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class InvariantResult:
|
|
142
|
+
"""Result of a single invariant check during replay.
|
|
143
|
+
|
|
144
|
+
Attributes:
|
|
145
|
+
invariant_id: Identifier of the invariant that was checked.
|
|
146
|
+
span_id: UUID of the span that was evaluated.
|
|
147
|
+
score: The actual score returned by the checker (0.0-1.0).
|
|
148
|
+
threshold: The minimum score required for the check to pass.
|
|
149
|
+
passed: Whether the score meets or exceeds the threshold.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
invariant_id: str
|
|
153
|
+
span_id: str
|
|
154
|
+
score: float
|
|
155
|
+
threshold: float
|
|
156
|
+
passed: bool
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class ReplayReport:
|
|
161
|
+
"""Comprehensive report from semantic or mechanical replay.
|
|
162
|
+
|
|
163
|
+
Provides summary statistics, violation details, and a human-readable
|
|
164
|
+
summary string. Iterable over violations for backward compatibility.
|
|
165
|
+
|
|
166
|
+
Attributes:
|
|
167
|
+
trace_file: Path to the trace file that was replayed.
|
|
168
|
+
trace_id: UUID of the trace.
|
|
169
|
+
agent_name: Name of the agent that produced the trace.
|
|
170
|
+
total_spans: Number of spans in the trace.
|
|
171
|
+
total_invariants: Total number of invariant checks performed.
|
|
172
|
+
violations: List of invariant violations (score below threshold).
|
|
173
|
+
results: All invariant check results (passing and failing).
|
|
174
|
+
structural_errors: Structural errors from mechanical validation.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
trace_file: Path
|
|
178
|
+
trace_id: uuid.UUID
|
|
179
|
+
agent_name: str
|
|
180
|
+
total_spans: int
|
|
181
|
+
total_invariants: int
|
|
182
|
+
violations: list[InvariantViolation]
|
|
183
|
+
results: list[InvariantResult]
|
|
184
|
+
structural_errors: list[str] = field(default_factory=list)
|
|
185
|
+
|
|
186
|
+
def __iter__(self):
|
|
187
|
+
return iter(self.violations)
|
|
188
|
+
|
|
189
|
+
def __len__(self) -> int:
|
|
190
|
+
return len(self.violations)
|
|
191
|
+
|
|
192
|
+
def __getitem__(self, index: int):
|
|
193
|
+
return self.violations[index]
|
|
194
|
+
|
|
195
|
+
def __bool__(self) -> bool:
|
|
196
|
+
return bool(self.violations) or bool(self.structural_errors)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def is_clean(self) -> bool:
|
|
200
|
+
"""True if no violations and no structural errors."""
|
|
201
|
+
return not self.violations and not self.structural_errors
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def pass_rate(self) -> float:
|
|
205
|
+
"""Fraction of invariant checks that passed."""
|
|
206
|
+
if self.total_invariants == 0:
|
|
207
|
+
return 1.0
|
|
208
|
+
passed = sum(1 for r in self.results if r.passed)
|
|
209
|
+
return passed / self.total_invariants
|
|
210
|
+
|
|
211
|
+
def summary(self) -> str:
|
|
212
|
+
"""Return a human-readable summary string."""
|
|
213
|
+
lines = [
|
|
214
|
+
f"Replay Report: {self.agent_name}",
|
|
215
|
+
f" Trace: {self.trace_id}",
|
|
216
|
+
f" File: {self.trace_file}",
|
|
217
|
+
f" Spans: {self.total_spans}",
|
|
218
|
+
f" Invariants checked: {self.total_invariants}",
|
|
219
|
+
f" Pass rate: {self.pass_rate:.0%}",
|
|
220
|
+
]
|
|
221
|
+
if self.structural_errors:
|
|
222
|
+
lines.append(f" Structural errors: {len(self.structural_errors)}")
|
|
223
|
+
if self.violations:
|
|
224
|
+
lines.append(f" Violations: {len(self.violations)}")
|
|
225
|
+
else:
|
|
226
|
+
lines.append(" Status: ALL CLEAR")
|
|
227
|
+
return "\n".join(lines)
|
|
228
|
+
|
|
229
|
+
def print_violations(self) -> None:
|
|
230
|
+
"""Print all violations and structural errors to stdout."""
|
|
231
|
+
if not self.violations and not self.structural_errors:
|
|
232
|
+
print("No violations found.")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
if self.structural_errors:
|
|
236
|
+
print("Structural errors:")
|
|
237
|
+
for err in self.structural_errors:
|
|
238
|
+
print(f" - {err}")
|
|
239
|
+
|
|
240
|
+
if self.violations:
|
|
241
|
+
print("Invariant violations:")
|
|
242
|
+
for v in self.violations:
|
|
243
|
+
print(
|
|
244
|
+
f" Span {v.span_id}: invariant {v.invariant_id} "
|
|
245
|
+
f"(expected>={v.expected_score:.2f}, got {v.actual_score:.2f})"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class Trace:
|
|
250
|
+
"""Context manager for capturing agent traces.
|
|
251
|
+
|
|
252
|
+
Creates a trace header, collects spans, and writes to a JSONL file
|
|
253
|
+
in real-time. On exit, finalizes the trace with an end timestamp.
|
|
254
|
+
|
|
255
|
+
Usage:
|
|
256
|
+
with Trace(name="my-agent", output_file="traces/run.jsonl") as trace:
|
|
257
|
+
trace.add_span(span)
|
|
258
|
+
|
|
259
|
+
Or with default invariants attached to every span:
|
|
260
|
+
invariants = [IntentInvariant(...)]
|
|
261
|
+
with Trace(name="my-agent", invariants=invariants, output_file="traces/run.jsonl") as trace:
|
|
262
|
+
# spans automatically get the invariants
|
|
263
|
+
trace.add_span(span)
|
|
264
|
+
|
|
265
|
+
For backward compatibility, can also be initialized with a metadata object:
|
|
266
|
+
trace = Trace(metadata=TraceMetadata(...))
|
|
267
|
+
trace.add_span(span)
|
|
268
|
+
trace.save("traces/run.jsonl")
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
name: Human-readable name of the agent (required unless metadata is provided).
|
|
272
|
+
metadata: Pre-built TraceMetadata (alternative to name).
|
|
273
|
+
invariants: Default invariants to attach to every span.
|
|
274
|
+
output_file: Path to the JSONL file for real-time streaming.
|
|
275
|
+
session_id: Identifier for the agent session. Auto-generated if omitted.
|
|
276
|
+
trace_id: Explicit trace UUID. Auto-generated if omitted.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
name: str | None = None,
|
|
282
|
+
*,
|
|
283
|
+
metadata: TraceMetadata | None = None,
|
|
284
|
+
invariants: list[IntentInvariant] | None = None,
|
|
285
|
+
output_file: str | Path | None = None,
|
|
286
|
+
session_id: str | None = None,
|
|
287
|
+
trace_id: uuid.UUID | None = None,
|
|
288
|
+
) -> None:
|
|
289
|
+
if name is not None and metadata is not None:
|
|
290
|
+
raise ValueError("Provide either 'name' or 'metadata', not both")
|
|
291
|
+
|
|
292
|
+
if metadata is not None:
|
|
293
|
+
self._model = TraceModel(metadata=metadata, spans=[])
|
|
294
|
+
self._output_file: Path | None = None
|
|
295
|
+
self._default_invariants: list[IntentInvariant] = []
|
|
296
|
+
elif name is not None:
|
|
297
|
+
self._model = TraceModel(
|
|
298
|
+
metadata=TraceMetadata(
|
|
299
|
+
trace_id=trace_id or uuid.uuid4(),
|
|
300
|
+
session_id=session_id or f"session-{uuid.uuid4().hex[:8]}",
|
|
301
|
+
agent_name=name,
|
|
302
|
+
),
|
|
303
|
+
spans=[],
|
|
304
|
+
)
|
|
305
|
+
self._output_file = Path(output_file).resolve() if output_file else None
|
|
306
|
+
self._default_invariants = list(invariants) if invariants else []
|
|
307
|
+
else:
|
|
308
|
+
raise ValueError("Either 'name' or 'metadata' must be provided")
|
|
309
|
+
|
|
310
|
+
self._active = False
|
|
311
|
+
|
|
312
|
+
def __enter__(self) -> Trace:
|
|
313
|
+
if self._active:
|
|
314
|
+
raise RuntimeError("Trace is already active. Create a new instance.")
|
|
315
|
+
self._active = True
|
|
316
|
+
if self._output_file:
|
|
317
|
+
from agent_trace.core.serializer import write_metadata_to_jsonl
|
|
318
|
+
|
|
319
|
+
write_metadata_to_jsonl(self._output_file, self._model)
|
|
320
|
+
return self
|
|
321
|
+
|
|
322
|
+
def __exit__(self, *args: Any) -> bool:
|
|
323
|
+
self._active = False
|
|
324
|
+
self._model.metadata.end_time = datetime.now(timezone.utc)
|
|
325
|
+
if self._output_file:
|
|
326
|
+
from agent_trace.core.serializer import write_metadata_to_jsonl
|
|
327
|
+
|
|
328
|
+
write_metadata_to_jsonl(self._output_file, self._model)
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
def __repr__(self) -> str:
|
|
332
|
+
status = "active" if self._active else "inactive"
|
|
333
|
+
return (
|
|
334
|
+
f"Trace(name={self._model.metadata.agent_name!r}, "
|
|
335
|
+
f"trace_id={self.trace_id!r}, "
|
|
336
|
+
f"spans={len(self.spans)}, "
|
|
337
|
+
f"status={status!r})"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def model(self) -> TraceModel:
|
|
342
|
+
"""The underlying TraceModel. Modifications affect the trace."""
|
|
343
|
+
return self._model
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def trace_id(self) -> uuid.UUID:
|
|
347
|
+
return self._model.metadata.trace_id
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def session_id(self) -> str:
|
|
351
|
+
return self._model.metadata.session_id
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def spans(self) -> list[Span]:
|
|
355
|
+
return list(self._model.spans)
|
|
356
|
+
|
|
357
|
+
def add_span(self, span: Span) -> None:
|
|
358
|
+
"""Add a span to the trace.
|
|
359
|
+
|
|
360
|
+
Attaches default invariants and writes to the output file if
|
|
361
|
+
the context manager is active.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
span: The span to add. Must have a matching trace_id.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
ValueError: If the span's trace_id does not match this trace.
|
|
368
|
+
"""
|
|
369
|
+
if span.trace_id != self.trace_id:
|
|
370
|
+
raise ValueError(
|
|
371
|
+
f"Span trace_id {span.trace_id} does not match trace {self.trace_id}"
|
|
372
|
+
)
|
|
373
|
+
span.attached_invariants.extend(self._default_invariants)
|
|
374
|
+
self._model.spans.append(span)
|
|
375
|
+
if self._active and self._output_file:
|
|
376
|
+
from agent_trace.core.serializer import write_span_to_jsonl
|
|
377
|
+
|
|
378
|
+
write_span_to_jsonl(self._output_file, span)
|
|
379
|
+
|
|
380
|
+
def save(self, output_file: str | Path | None = None) -> None:
|
|
381
|
+
"""Save the complete trace to a JSONL file.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
output_file: Path to write. Uses the constructor value if omitted.
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
ValueError: If no output file is specified.
|
|
388
|
+
"""
|
|
389
|
+
from agent_trace.core.serializer import (
|
|
390
|
+
write_metadata_to_jsonl,
|
|
391
|
+
write_span_to_jsonl,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
path = Path(output_file).resolve() if output_file else self._output_file
|
|
395
|
+
if not path:
|
|
396
|
+
raise ValueError("No output file specified")
|
|
397
|
+
|
|
398
|
+
self._model.metadata.end_time = datetime.now(timezone.utc)
|
|
399
|
+
write_metadata_to_jsonl(path, self._model)
|
|
400
|
+
for span in self._model.spans:
|
|
401
|
+
write_span_to_jsonl(path, span)
|