stepfork 0.1.0a2__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.
- stepfork/__init__.py +32 -0
- stepfork/__main__.py +6 -0
- stepfork/cli/__init__.py +1 -0
- stepfork/cli/diff.py +156 -0
- stepfork/cli/export.py +210 -0
- stepfork/cli/inspect.py +155 -0
- stepfork/cli/main.py +54 -0
- stepfork/cli/replay.py +221 -0
- stepfork/cli/validate.py +160 -0
- stepfork/diff/__init__.py +25 -0
- stepfork/diff/compare.py +144 -0
- stepfork/diff/engine.py +267 -0
- stepfork/diff/models.py +63 -0
- stepfork/export/__init__.py +20 -0
- stepfork/export/entrypoint.py +58 -0
- stepfork/export/generator.py +178 -0
- stepfork/export/runtime.py +101 -0
- stepfork/fork/__init__.py +4 -0
- stepfork/inspect/__init__.py +11 -0
- stepfork/inspect/inspector.py +143 -0
- stepfork/inspect/models.py +42 -0
- stepfork/minimize/__init__.py +4 -0
- stepfork/py.typed +0 -0
- stepfork/recorder/__init__.py +21 -0
- stepfork/recorder/llm.py +68 -0
- stepfork/recorder/session.py +500 -0
- stepfork/recorder/tooling.py +208 -0
- stepfork/replay/__init__.py +32 -0
- stepfork/replay/exceptions.py +37 -0
- stepfork/replay/plan.py +282 -0
- stepfork/replay/session.py +338 -0
- stepfork/trace/__init__.py +115 -0
- stepfork/trace/canonical.py +55 -0
- stepfork/trace/hashing.py +75 -0
- stepfork/trace/integrity.py +196 -0
- stepfork/trace/jsonable.py +97 -0
- stepfork/trace/manifest.py +85 -0
- stepfork/trace/models.py +271 -0
- stepfork/trace/redaction.py +294 -0
- stepfork/trace/replay_policy.py +18 -0
- stepfork/trace/schema.py +25 -0
- stepfork/trace/storage.py +250 -0
- stepfork/trace/validation.py +285 -0
- stepfork/version.py +5 -0
- stepfork-0.1.0a2.dist-info/METADATA +377 -0
- stepfork-0.1.0a2.dist-info/RECORD +49 -0
- stepfork-0.1.0a2.dist-info/WHEEL +4 -0
- stepfork-0.1.0a2.dist-info/entry_points.txt +2 -0
- stepfork-0.1.0a2.dist-info/licenses/LICENSE +184 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Portable Stepfork trace format and serialization."""
|
|
2
|
+
|
|
3
|
+
from stepfork.trace.canonical import CANONICAL_JSON_PROFILE, canonical_json_bytes
|
|
4
|
+
from stepfork.trace.hashing import hash_event_payloads, hash_json
|
|
5
|
+
from stepfork.trace.integrity import (
|
|
6
|
+
IntegrityIssue,
|
|
7
|
+
IntegrityRecord,
|
|
8
|
+
IntegrityResult,
|
|
9
|
+
IntegrityStatus,
|
|
10
|
+
verify_bundle_integrity,
|
|
11
|
+
)
|
|
12
|
+
from stepfork.trace.jsonable import (
|
|
13
|
+
Serializer,
|
|
14
|
+
TraceSerializationError,
|
|
15
|
+
to_json_value,
|
|
16
|
+
)
|
|
17
|
+
from stepfork.trace.manifest import (
|
|
18
|
+
EnvironmentInfo,
|
|
19
|
+
FailureInfo,
|
|
20
|
+
RunStatus,
|
|
21
|
+
TraceManifest,
|
|
22
|
+
TraceTotals,
|
|
23
|
+
)
|
|
24
|
+
from stepfork.trace.models import (
|
|
25
|
+
ErrorEvent,
|
|
26
|
+
Event,
|
|
27
|
+
EventBase,
|
|
28
|
+
EventStatus,
|
|
29
|
+
EventType,
|
|
30
|
+
JsonValue,
|
|
31
|
+
LLMRequest,
|
|
32
|
+
LLMResponse,
|
|
33
|
+
RunEnd,
|
|
34
|
+
RunStart,
|
|
35
|
+
StateChange,
|
|
36
|
+
ToolCall,
|
|
37
|
+
ToolResult,
|
|
38
|
+
Trace,
|
|
39
|
+
)
|
|
40
|
+
from stepfork.trace.redaction import (
|
|
41
|
+
REDACTION_REPLACEMENT,
|
|
42
|
+
RedactionEntry,
|
|
43
|
+
RedactionManifest,
|
|
44
|
+
RedactionResult,
|
|
45
|
+
redact_json,
|
|
46
|
+
)
|
|
47
|
+
from stepfork.trace.replay_policy import ReplayPolicy
|
|
48
|
+
from stepfork.trace.schema import (
|
|
49
|
+
event_json_schema,
|
|
50
|
+
manifest_json_schema,
|
|
51
|
+
trace_json_schema,
|
|
52
|
+
)
|
|
53
|
+
from stepfork.trace.storage import (
|
|
54
|
+
TraceStorageError,
|
|
55
|
+
build_manifest,
|
|
56
|
+
load_trace,
|
|
57
|
+
save_trace,
|
|
58
|
+
)
|
|
59
|
+
from stepfork.trace.validation import (
|
|
60
|
+
ValidationIssue,
|
|
61
|
+
ValidationResult,
|
|
62
|
+
validate_bundle,
|
|
63
|
+
validate_trace,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"CANONICAL_JSON_PROFILE",
|
|
68
|
+
"REDACTION_REPLACEMENT",
|
|
69
|
+
"EnvironmentInfo",
|
|
70
|
+
"ErrorEvent",
|
|
71
|
+
"Event",
|
|
72
|
+
"EventBase",
|
|
73
|
+
"EventStatus",
|
|
74
|
+
"EventType",
|
|
75
|
+
"FailureInfo",
|
|
76
|
+
"IntegrityIssue",
|
|
77
|
+
"IntegrityRecord",
|
|
78
|
+
"IntegrityResult",
|
|
79
|
+
"IntegrityStatus",
|
|
80
|
+
"JsonValue",
|
|
81
|
+
"LLMRequest",
|
|
82
|
+
"LLMResponse",
|
|
83
|
+
"RedactionEntry",
|
|
84
|
+
"RedactionManifest",
|
|
85
|
+
"RedactionResult",
|
|
86
|
+
"ReplayPolicy",
|
|
87
|
+
"RunEnd",
|
|
88
|
+
"RunStart",
|
|
89
|
+
"RunStatus",
|
|
90
|
+
"Serializer",
|
|
91
|
+
"StateChange",
|
|
92
|
+
"ToolCall",
|
|
93
|
+
"ToolResult",
|
|
94
|
+
"Trace",
|
|
95
|
+
"TraceManifest",
|
|
96
|
+
"TraceSerializationError",
|
|
97
|
+
"TraceStorageError",
|
|
98
|
+
"TraceTotals",
|
|
99
|
+
"ValidationIssue",
|
|
100
|
+
"ValidationResult",
|
|
101
|
+
"build_manifest",
|
|
102
|
+
"canonical_json_bytes",
|
|
103
|
+
"event_json_schema",
|
|
104
|
+
"hash_event_payloads",
|
|
105
|
+
"hash_json",
|
|
106
|
+
"load_trace",
|
|
107
|
+
"manifest_json_schema",
|
|
108
|
+
"redact_json",
|
|
109
|
+
"save_trace",
|
|
110
|
+
"to_json_value",
|
|
111
|
+
"trace_json_schema",
|
|
112
|
+
"validate_bundle",
|
|
113
|
+
"validate_trace",
|
|
114
|
+
"verify_bundle_integrity",
|
|
115
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Deterministic JSON serialization for Stepfork hashes.
|
|
2
|
+
|
|
3
|
+
Stepfork v0.1 uses a documented Python JSON profile for canonicalization:
|
|
4
|
+
sorted object keys, compact separators, UTF-8 bytes, non-ASCII characters
|
|
5
|
+
preserved, and non-finite floats rejected. This is not full RFC 8785.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from stepfork.trace.models import JsonValue
|
|
15
|
+
|
|
16
|
+
CANONICAL_JSON_PROFILE = "stepfork-json-v0.1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CanonicalJsonError(TypeError):
|
|
20
|
+
"""Raised when a value cannot be canonically serialized as JSON."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def canonical_json_bytes(value: JsonValue) -> bytes:
|
|
24
|
+
"""Return deterministic UTF-8 JSON bytes for a JSON-compatible value."""
|
|
25
|
+
_validate_json_value(value)
|
|
26
|
+
try:
|
|
27
|
+
return json.dumps(
|
|
28
|
+
value,
|
|
29
|
+
ensure_ascii=False,
|
|
30
|
+
sort_keys=True,
|
|
31
|
+
separators=(",", ":"),
|
|
32
|
+
allow_nan=False,
|
|
33
|
+
).encode("utf-8")
|
|
34
|
+
except (TypeError, ValueError) as exc:
|
|
35
|
+
raise CanonicalJsonError("value is not canonical JSON serializable") from exc
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_json_value(value: Any) -> None:
|
|
39
|
+
if value is None or isinstance(value, str | bool | int):
|
|
40
|
+
return
|
|
41
|
+
if isinstance(value, float):
|
|
42
|
+
if not math.isfinite(value):
|
|
43
|
+
raise CanonicalJsonError("non-finite floats are not supported")
|
|
44
|
+
return
|
|
45
|
+
if isinstance(value, list):
|
|
46
|
+
for item in value:
|
|
47
|
+
_validate_json_value(item)
|
|
48
|
+
return
|
|
49
|
+
if isinstance(value, dict):
|
|
50
|
+
for key, item in value.items():
|
|
51
|
+
if not isinstance(key, str):
|
|
52
|
+
raise CanonicalJsonError("JSON object keys must be strings")
|
|
53
|
+
_validate_json_value(item)
|
|
54
|
+
return
|
|
55
|
+
raise CanonicalJsonError(f"unsupported JSON value type: {type(value).__name__}")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""SHA-256 helpers for sanitized JSON payloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
from stepfork.trace.canonical import canonical_json_bytes
|
|
8
|
+
from stepfork.trace.models import (
|
|
9
|
+
ErrorEvent,
|
|
10
|
+
Event,
|
|
11
|
+
JsonValue,
|
|
12
|
+
LLMRequest,
|
|
13
|
+
LLMResponse,
|
|
14
|
+
RunEnd,
|
|
15
|
+
RunStart,
|
|
16
|
+
StateChange,
|
|
17
|
+
ToolCall,
|
|
18
|
+
ToolResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hash_json(value: JsonValue) -> str:
|
|
23
|
+
"""Return the SHA-256 hex digest for a canonical JSON value."""
|
|
24
|
+
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def hash_event_payloads(event: Event) -> Event:
|
|
28
|
+
"""Return an event copy with payload hashes populated where applicable."""
|
|
29
|
+
updates: dict[str, str | None] = {"input_hash": None, "output_hash": None}
|
|
30
|
+
|
|
31
|
+
if isinstance(event, RunStart):
|
|
32
|
+
if "input" in event.model_fields_set:
|
|
33
|
+
updates["input_hash"] = hash_json(event.input)
|
|
34
|
+
elif isinstance(event, LLMRequest | ToolCall):
|
|
35
|
+
updates["input_hash"] = hash_json(event.input)
|
|
36
|
+
elif isinstance(event, LLMResponse | ToolResult):
|
|
37
|
+
updates["output_hash"] = hash_json(event.output)
|
|
38
|
+
elif isinstance(event, StateChange):
|
|
39
|
+
if "before" in event.model_fields_set:
|
|
40
|
+
updates["input_hash"] = hash_json(event.before)
|
|
41
|
+
if "after" in event.model_fields_set:
|
|
42
|
+
updates["output_hash"] = hash_json(event.after)
|
|
43
|
+
elif isinstance(event, ErrorEvent):
|
|
44
|
+
if event.details is not None or "details" in event.model_fields_set:
|
|
45
|
+
updates["output_hash"] = hash_json(event.details)
|
|
46
|
+
elif isinstance(event, RunEnd) and (
|
|
47
|
+
event.output is not None or "output" in event.model_fields_set
|
|
48
|
+
):
|
|
49
|
+
updates["output_hash"] = hash_json(event.output)
|
|
50
|
+
|
|
51
|
+
return event.model_copy(update=updates)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def verify_event_payload_hashes(event: Event) -> list[str]:
|
|
55
|
+
"""Return payload hash mismatch issue fields for an event."""
|
|
56
|
+
expected = hash_event_payloads(event)
|
|
57
|
+
mismatches: list[str] = []
|
|
58
|
+
|
|
59
|
+
if event.input_hash is not None and event.input_hash != expected.input_hash:
|
|
60
|
+
mismatches.append("input_hash")
|
|
61
|
+
if event.output_hash is not None and event.output_hash != expected.output_hash:
|
|
62
|
+
mismatches.append("output_hash")
|
|
63
|
+
|
|
64
|
+
return mismatches
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def event_payload_hash_fields(event: Event) -> set[str]:
|
|
68
|
+
"""Return hash fields that are expected for the event's payload shape."""
|
|
69
|
+
expected = hash_event_payloads(event)
|
|
70
|
+
fields: set[str] = set()
|
|
71
|
+
if expected.input_hash is not None:
|
|
72
|
+
fields.add("input_hash")
|
|
73
|
+
if expected.output_hash is not None:
|
|
74
|
+
fields.add("output_hash")
|
|
75
|
+
return fields
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Tamper-evident integrity records for `.sftrace` bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
14
|
+
|
|
15
|
+
from stepfork.trace.storage import EVENTS_FILE, MANIFEST_FILE, REDACTIONS_FILE
|
|
16
|
+
|
|
17
|
+
INTEGRITY_FILE = "integrity.json"
|
|
18
|
+
INTEGRITY_ALGORITHM = "sha256"
|
|
19
|
+
INTEGRITY_FILES = (MANIFEST_FILE, EVENTS_FILE, REDACTIONS_FILE)
|
|
20
|
+
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class IntegrityStatus(StrEnum):
|
|
24
|
+
"""Result status for bundle integrity verification."""
|
|
25
|
+
|
|
26
|
+
VERIFIED = "verified"
|
|
27
|
+
MISMATCH = "mismatch"
|
|
28
|
+
UNVERIFIED_LEGACY = "unverified_legacy"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IntegrityIssue(BaseModel):
|
|
32
|
+
"""Machine-readable integrity issue."""
|
|
33
|
+
|
|
34
|
+
model_config = ConfigDict(extra="forbid")
|
|
35
|
+
|
|
36
|
+
code: str
|
|
37
|
+
message: str
|
|
38
|
+
file: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class IntegrityRecord(BaseModel):
|
|
42
|
+
"""Persisted integrity metadata for a `.sftrace` bundle."""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(extra="forbid")
|
|
45
|
+
|
|
46
|
+
schema_version: Literal["0.1"] = "0.1"
|
|
47
|
+
algorithm: str = INTEGRITY_ALGORITHM
|
|
48
|
+
files: dict[str, str] = Field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class IntegrityResult(BaseModel):
|
|
52
|
+
"""Structured bundle integrity verification result."""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra="forbid")
|
|
55
|
+
|
|
56
|
+
status: IntegrityStatus
|
|
57
|
+
issues: list[IntegrityIssue] = Field(default_factory=list)
|
|
58
|
+
verified_files: list[str] = Field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_bundle_integrity(path: Path) -> IntegrityRecord:
|
|
62
|
+
"""Write integrity metadata for the finalized bundle payload files."""
|
|
63
|
+
record = IntegrityRecord(
|
|
64
|
+
files={filename: _hash_file(path / filename) for filename in INTEGRITY_FILES}
|
|
65
|
+
)
|
|
66
|
+
(path / INTEGRITY_FILE).write_text(
|
|
67
|
+
record.model_dump_json(indent=2) + "\n",
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
)
|
|
70
|
+
return record
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def verify_bundle_integrity(path: str | Path) -> IntegrityResult:
|
|
74
|
+
"""Verify a bundle's integrity record."""
|
|
75
|
+
bundle_path = Path(path)
|
|
76
|
+
integrity_path = bundle_path / INTEGRITY_FILE
|
|
77
|
+
if not integrity_path.exists():
|
|
78
|
+
return IntegrityResult(
|
|
79
|
+
status=IntegrityStatus.UNVERIFIED_LEGACY,
|
|
80
|
+
issues=[
|
|
81
|
+
IntegrityIssue(
|
|
82
|
+
code="integrity_unverified",
|
|
83
|
+
message="Bundle has no integrity.json metadata.",
|
|
84
|
+
file=INTEGRITY_FILE,
|
|
85
|
+
)
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
if not integrity_path.is_file():
|
|
89
|
+
return IntegrityResult(
|
|
90
|
+
status=IntegrityStatus.MISMATCH,
|
|
91
|
+
issues=[
|
|
92
|
+
IntegrityIssue(
|
|
93
|
+
code="integrity_metadata_invalid",
|
|
94
|
+
message="integrity.json is not a file.",
|
|
95
|
+
file=INTEGRITY_FILE,
|
|
96
|
+
)
|
|
97
|
+
],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
payload = json.loads(integrity_path.read_text(encoding="utf-8"))
|
|
102
|
+
record = IntegrityRecord.model_validate(payload)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
return _metadata_invalid("integrity.json is not valid JSON.")
|
|
105
|
+
except ValidationError:
|
|
106
|
+
return _metadata_invalid("integrity.json metadata is invalid.")
|
|
107
|
+
|
|
108
|
+
issues = _validate_record(record)
|
|
109
|
+
verified_files: list[str] = []
|
|
110
|
+
if issues:
|
|
111
|
+
return IntegrityResult(status=IntegrityStatus.MISMATCH, issues=issues)
|
|
112
|
+
|
|
113
|
+
for filename in INTEGRITY_FILES:
|
|
114
|
+
file_path = bundle_path / filename
|
|
115
|
+
if not file_path.is_file():
|
|
116
|
+
issues.append(
|
|
117
|
+
IntegrityIssue(
|
|
118
|
+
code="missing_file",
|
|
119
|
+
message=f"{filename} is missing.",
|
|
120
|
+
file=filename,
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
|
|
125
|
+
actual = _hash_file(file_path)
|
|
126
|
+
expected = record.files[filename]
|
|
127
|
+
if hmac.compare_digest(actual, expected):
|
|
128
|
+
verified_files.append(filename)
|
|
129
|
+
else:
|
|
130
|
+
issues.append(
|
|
131
|
+
IntegrityIssue(
|
|
132
|
+
code="integrity_mismatch",
|
|
133
|
+
message="SHA-256 mismatch.",
|
|
134
|
+
file=filename,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return IntegrityResult(
|
|
139
|
+
status=IntegrityStatus.VERIFIED if not issues else IntegrityStatus.MISMATCH,
|
|
140
|
+
issues=issues,
|
|
141
|
+
verified_files=verified_files,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _validate_record(record: IntegrityRecord) -> list[IntegrityIssue]:
|
|
146
|
+
issues: list[IntegrityIssue] = []
|
|
147
|
+
if record.algorithm != INTEGRITY_ALGORITHM:
|
|
148
|
+
issues.append(
|
|
149
|
+
IntegrityIssue(
|
|
150
|
+
code="integrity_algorithm_unsupported",
|
|
151
|
+
message=f"Unsupported integrity algorithm {record.algorithm!r}.",
|
|
152
|
+
file=INTEGRITY_FILE,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return issues
|
|
156
|
+
|
|
157
|
+
for filename in INTEGRITY_FILES:
|
|
158
|
+
digest = record.files.get(filename)
|
|
159
|
+
if digest is None:
|
|
160
|
+
issues.append(
|
|
161
|
+
IntegrityIssue(
|
|
162
|
+
code="integrity_metadata_invalid",
|
|
163
|
+
message=f"Missing digest for {filename}.",
|
|
164
|
+
file=filename,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
elif _SHA256_RE.fullmatch(digest) is None:
|
|
168
|
+
issues.append(
|
|
169
|
+
IntegrityIssue(
|
|
170
|
+
code="integrity_metadata_invalid",
|
|
171
|
+
message=f"Invalid SHA-256 digest for {filename}.",
|
|
172
|
+
file=filename,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
return issues
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _metadata_invalid(message: str) -> IntegrityResult:
|
|
179
|
+
return IntegrityResult(
|
|
180
|
+
status=IntegrityStatus.MISMATCH,
|
|
181
|
+
issues=[
|
|
182
|
+
IntegrityIssue(
|
|
183
|
+
code="integrity_metadata_invalid",
|
|
184
|
+
message=message,
|
|
185
|
+
file=INTEGRITY_FILE,
|
|
186
|
+
)
|
|
187
|
+
],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _hash_file(path: Path) -> str:
|
|
192
|
+
digest = hashlib.sha256()
|
|
193
|
+
with path.open("rb") as file:
|
|
194
|
+
for chunk in iter(lambda: file.read(1024 * 1024), b""):
|
|
195
|
+
digest.update(chunk)
|
|
196
|
+
return digest.hexdigest()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Conversion of runtime values into JSON-compatible trace payloads.
|
|
2
|
+
|
|
3
|
+
Stepfork never falls back to ``repr()`` when recording payloads. Values must
|
|
4
|
+
already be JSON-compatible (``None``, ``str``, ``bool``, ``int``, finite
|
|
5
|
+
``float``, ``list``/``tuple``, ``dict`` with ``str`` keys) or the caller must
|
|
6
|
+
provide an explicit serializer hook.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any, TypeAlias
|
|
14
|
+
|
|
15
|
+
from stepfork.trace.models import JsonValue
|
|
16
|
+
|
|
17
|
+
Serializer: TypeAlias = Callable[[Any], Any]
|
|
18
|
+
|
|
19
|
+
MAX_DEPTH = 100
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TraceSerializationError(TypeError):
|
|
23
|
+
"""Raised when a runtime value cannot be represented as JSON."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def to_json_value(
|
|
27
|
+
value: Any,
|
|
28
|
+
*,
|
|
29
|
+
serializer: Serializer | None = None,
|
|
30
|
+
path: str = "$",
|
|
31
|
+
depth: int = 0,
|
|
32
|
+
) -> JsonValue:
|
|
33
|
+
"""Convert ``value`` into a JSON-compatible value or raise a clear error.
|
|
34
|
+
|
|
35
|
+
Non-JSON values are first passed through the optional ``serializer`` hook.
|
|
36
|
+
The hook must return a JSON-compatible value; returning the input object
|
|
37
|
+
unchanged (or another unsupported value) is an error.
|
|
38
|
+
"""
|
|
39
|
+
if depth > MAX_DEPTH:
|
|
40
|
+
raise TraceSerializationError(
|
|
41
|
+
f"value at {path} exceeds the maximum nesting depth of {MAX_DEPTH}; "
|
|
42
|
+
"this usually means a cyclic structure"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if value is None or isinstance(value, str | bool | int):
|
|
46
|
+
return value
|
|
47
|
+
if isinstance(value, float):
|
|
48
|
+
if not math.isfinite(value):
|
|
49
|
+
raise TraceSerializationError(
|
|
50
|
+
f"non-finite float at {path} cannot be recorded as JSON"
|
|
51
|
+
)
|
|
52
|
+
return value
|
|
53
|
+
if isinstance(value, list | tuple):
|
|
54
|
+
return [
|
|
55
|
+
to_json_value(
|
|
56
|
+
item,
|
|
57
|
+
serializer=serializer,
|
|
58
|
+
path=f"{path}[{index}]",
|
|
59
|
+
depth=depth + 1,
|
|
60
|
+
)
|
|
61
|
+
for index, item in enumerate(value)
|
|
62
|
+
]
|
|
63
|
+
if isinstance(value, dict):
|
|
64
|
+
converted: dict[str, JsonValue] = {}
|
|
65
|
+
for key, item in value.items():
|
|
66
|
+
if not isinstance(key, str):
|
|
67
|
+
raise TraceSerializationError(
|
|
68
|
+
f"object key at {path} must be a string, got "
|
|
69
|
+
f"{type(key).__name__}: {key!r}"
|
|
70
|
+
)
|
|
71
|
+
converted[key] = to_json_value(
|
|
72
|
+
item,
|
|
73
|
+
serializer=serializer,
|
|
74
|
+
path=f"{path}.{key}",
|
|
75
|
+
depth=depth + 1,
|
|
76
|
+
)
|
|
77
|
+
return converted
|
|
78
|
+
|
|
79
|
+
if serializer is not None:
|
|
80
|
+
converted_value = serializer(value)
|
|
81
|
+
if converted_value is value:
|
|
82
|
+
raise TraceSerializationError(
|
|
83
|
+
f"serializer returned the original {type(value).__name__} "
|
|
84
|
+
f"unchanged at {path}"
|
|
85
|
+
)
|
|
86
|
+
return to_json_value(
|
|
87
|
+
converted_value,
|
|
88
|
+
serializer=serializer,
|
|
89
|
+
path=path,
|
|
90
|
+
depth=depth + 1,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
raise TraceSerializationError(
|
|
94
|
+
f"cannot serialize {type(value).__name__} at {path}; pass "
|
|
95
|
+
"JSON-compatible values or provide a serializer hook via "
|
|
96
|
+
"record(..., serializer=...) or trace_tool(..., serializer=...)"
|
|
97
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Manifest models for the Stepfork `.sftrace` v0.1 contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import Literal, Self
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RunStatus(StrEnum):
|
|
13
|
+
"""Lifecycle status for an agent run."""
|
|
14
|
+
|
|
15
|
+
RUNNING = "running"
|
|
16
|
+
COMPLETED = "completed"
|
|
17
|
+
FAILED = "failed"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FailureInfo(BaseModel):
|
|
21
|
+
"""Failure summary stored in a trace manifest."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
type: str = Field(min_length=1)
|
|
26
|
+
message: str = Field(min_length=1)
|
|
27
|
+
step: int = Field(ge=0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EnvironmentInfo(BaseModel):
|
|
31
|
+
"""Optional environment metadata captured by future recorders."""
|
|
32
|
+
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
python: str | None = None
|
|
36
|
+
os: str | None = None
|
|
37
|
+
stepfork_version: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TraceTotals(BaseModel):
|
|
41
|
+
"""Aggregate counters for a trace."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(extra="forbid")
|
|
44
|
+
|
|
45
|
+
events: int = Field(default=0, ge=0)
|
|
46
|
+
llm_calls: int = Field(default=0, ge=0)
|
|
47
|
+
tool_calls: int = Field(default=0, ge=0)
|
|
48
|
+
cost_usd: float | None = Field(default=None, ge=0)
|
|
49
|
+
duration_ms: int | None = Field(default=None, ge=0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TraceManifest(BaseModel):
|
|
53
|
+
"""Top-level manifest for a portable `.sftrace` directory."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="forbid")
|
|
56
|
+
|
|
57
|
+
format: Literal["stepfork"] = "stepfork"
|
|
58
|
+
schema_version: Literal["0.1"] = "0.1"
|
|
59
|
+
|
|
60
|
+
run_id: str = Field(min_length=1)
|
|
61
|
+
agent_name: str = Field(min_length=1)
|
|
62
|
+
created_at: datetime
|
|
63
|
+
|
|
64
|
+
status: RunStatus
|
|
65
|
+
|
|
66
|
+
failure: FailureInfo | None = None
|
|
67
|
+
environment: EnvironmentInfo = Field(default_factory=EnvironmentInfo)
|
|
68
|
+
totals: TraceTotals = Field(default_factory=TraceTotals)
|
|
69
|
+
|
|
70
|
+
@field_validator("created_at")
|
|
71
|
+
@classmethod
|
|
72
|
+
def created_at_must_be_timezone_aware(cls, value: datetime) -> datetime:
|
|
73
|
+
"""Reject naive datetimes instead of assuming UTC."""
|
|
74
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
75
|
+
raise ValueError("created_at must be timezone-aware")
|
|
76
|
+
return value
|
|
77
|
+
|
|
78
|
+
@model_validator(mode="after")
|
|
79
|
+
def failure_must_match_status(self) -> Self:
|
|
80
|
+
"""Keep manifest failure details aligned with run status."""
|
|
81
|
+
if self.status is RunStatus.FAILED and self.failure is None:
|
|
82
|
+
raise ValueError("failure is required when status is failed")
|
|
83
|
+
if self.status is not RunStatus.FAILED and self.failure is not None:
|
|
84
|
+
raise ValueError("failure must be omitted unless status is failed")
|
|
85
|
+
return self
|