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.
Files changed (49) hide show
  1. stepfork/__init__.py +32 -0
  2. stepfork/__main__.py +6 -0
  3. stepfork/cli/__init__.py +1 -0
  4. stepfork/cli/diff.py +156 -0
  5. stepfork/cli/export.py +210 -0
  6. stepfork/cli/inspect.py +155 -0
  7. stepfork/cli/main.py +54 -0
  8. stepfork/cli/replay.py +221 -0
  9. stepfork/cli/validate.py +160 -0
  10. stepfork/diff/__init__.py +25 -0
  11. stepfork/diff/compare.py +144 -0
  12. stepfork/diff/engine.py +267 -0
  13. stepfork/diff/models.py +63 -0
  14. stepfork/export/__init__.py +20 -0
  15. stepfork/export/entrypoint.py +58 -0
  16. stepfork/export/generator.py +178 -0
  17. stepfork/export/runtime.py +101 -0
  18. stepfork/fork/__init__.py +4 -0
  19. stepfork/inspect/__init__.py +11 -0
  20. stepfork/inspect/inspector.py +143 -0
  21. stepfork/inspect/models.py +42 -0
  22. stepfork/minimize/__init__.py +4 -0
  23. stepfork/py.typed +0 -0
  24. stepfork/recorder/__init__.py +21 -0
  25. stepfork/recorder/llm.py +68 -0
  26. stepfork/recorder/session.py +500 -0
  27. stepfork/recorder/tooling.py +208 -0
  28. stepfork/replay/__init__.py +32 -0
  29. stepfork/replay/exceptions.py +37 -0
  30. stepfork/replay/plan.py +282 -0
  31. stepfork/replay/session.py +338 -0
  32. stepfork/trace/__init__.py +115 -0
  33. stepfork/trace/canonical.py +55 -0
  34. stepfork/trace/hashing.py +75 -0
  35. stepfork/trace/integrity.py +196 -0
  36. stepfork/trace/jsonable.py +97 -0
  37. stepfork/trace/manifest.py +85 -0
  38. stepfork/trace/models.py +271 -0
  39. stepfork/trace/redaction.py +294 -0
  40. stepfork/trace/replay_policy.py +18 -0
  41. stepfork/trace/schema.py +25 -0
  42. stepfork/trace/storage.py +250 -0
  43. stepfork/trace/validation.py +285 -0
  44. stepfork/version.py +5 -0
  45. stepfork-0.1.0a2.dist-info/METADATA +377 -0
  46. stepfork-0.1.0a2.dist-info/RECORD +49 -0
  47. stepfork-0.1.0a2.dist-info/WHEEL +4 -0
  48. stepfork-0.1.0a2.dist-info/entry_points.txt +2 -0
  49. stepfork-0.1.0a2.dist-info/licenses/LICENSE +184 -0
@@ -0,0 +1,250 @@
1
+ """Filesystem persistence for `.sftrace` directory bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+ from uuid import uuid4
9
+
10
+ from pydantic import TypeAdapter, ValidationError
11
+
12
+ from stepfork.trace.hashing import hash_event_payloads
13
+ from stepfork.trace.manifest import (
14
+ EnvironmentInfo,
15
+ FailureInfo,
16
+ RunStatus,
17
+ TraceManifest,
18
+ TraceTotals,
19
+ )
20
+ from stepfork.trace.models import Event, EventType, Trace
21
+ from stepfork.trace.redaction import RedactionManifest, prepare_trace_for_persistence
22
+
23
+ SCHEMA_VERSION = "0.1"
24
+ MANIFEST_FILE = "manifest.json"
25
+ EVENTS_FILE = "events.jsonl"
26
+ REDACTIONS_FILE = "redactions.json"
27
+ REQUIRED_FILES = (MANIFEST_FILE, EVENTS_FILE, REDACTIONS_FILE)
28
+
29
+ _event_adapter: TypeAdapter[Event] = TypeAdapter(Event)
30
+
31
+
32
+ class TraceStorageError(ValueError):
33
+ """Raised when a `.sftrace` bundle cannot be saved or loaded."""
34
+
35
+
36
+ def build_manifest(
37
+ trace: Trace,
38
+ *,
39
+ status: RunStatus | str | None = None,
40
+ failure: FailureInfo | None = None,
41
+ environment: EnvironmentInfo | None = None,
42
+ ) -> TraceManifest:
43
+ """Construct a manifest from an in-memory trace."""
44
+ manifest_status = RunStatus(status) if status is not None else trace.status
45
+ manifest_failure = trace.failure if failure is None else failure
46
+ manifest_environment = trace.environment if environment is None else environment
47
+ totals = _computed_totals(trace)
48
+
49
+ return TraceManifest(
50
+ run_id=trace.run_id,
51
+ agent_name=trace.agent_name,
52
+ created_at=trace.created_at,
53
+ status=manifest_status,
54
+ failure=manifest_failure,
55
+ environment=manifest_environment,
56
+ totals=totals,
57
+ )
58
+
59
+
60
+ def save_trace(
61
+ trace: Trace,
62
+ path: Path,
63
+ *,
64
+ status: RunStatus | str | None = None,
65
+ failure: FailureInfo | None = None,
66
+ environment: EnvironmentInfo | None = None,
67
+ overwrite: bool = False,
68
+ ) -> Path:
69
+ """Persist a trace as a `.sftrace` directory and return its path."""
70
+ path = path.expanduser()
71
+ _require_sftrace_suffix(path)
72
+ manifest = build_manifest(
73
+ trace,
74
+ status=status,
75
+ failure=failure,
76
+ environment=environment,
77
+ )
78
+ prepared = prepare_trace_for_persistence(trace, manifest)
79
+ persisted_events = [hash_event_payloads(event) for event in prepared.trace.events]
80
+
81
+ if path.exists() and not path.is_dir():
82
+ raise TraceStorageError(f"{path} exists and is not a directory")
83
+ if path.exists() and any(path.iterdir()):
84
+ if not overwrite:
85
+ raise TraceStorageError(f"{path} already exists and is not empty")
86
+ if not _has_required_bundle_files(path):
87
+ raise TraceStorageError(f"{path} is not a valid .sftrace bundle")
88
+
89
+ parent = path.parent
90
+ parent.mkdir(parents=True, exist_ok=True)
91
+ temporary = parent / f".{path.name}.tmp-{uuid4().hex}"
92
+ temporary.mkdir()
93
+
94
+ try:
95
+ _write_manifest(temporary / MANIFEST_FILE, prepared.manifest)
96
+ _write_events(temporary / EVENTS_FILE, persisted_events)
97
+ _write_redactions(temporary / REDACTIONS_FILE, prepared.redactions)
98
+ from stepfork.trace.integrity import write_bundle_integrity
99
+
100
+ write_bundle_integrity(temporary)
101
+
102
+ if path.exists():
103
+ if any(path.iterdir()):
104
+ backup = parent / f".{path.name}.bak-{uuid4().hex}"
105
+ path.rename(backup)
106
+ try:
107
+ temporary.rename(path)
108
+ except Exception:
109
+ backup.rename(path)
110
+ raise
111
+ shutil.rmtree(backup)
112
+ else:
113
+ path.rmdir()
114
+ temporary.rename(path)
115
+ else:
116
+ temporary.rename(path)
117
+ except Exception:
118
+ if temporary.exists():
119
+ shutil.rmtree(temporary)
120
+ raise
121
+
122
+ return path
123
+
124
+
125
+ def load_trace(path: Path) -> Trace:
126
+ """Load a `.sftrace` directory without repairing invalid data."""
127
+ path = path.expanduser()
128
+ _require_sftrace_suffix(path)
129
+ if not path.exists():
130
+ raise TraceStorageError(f"{path} does not exist")
131
+ if not path.is_dir():
132
+ raise TraceStorageError(f"{path} is not a directory")
133
+
134
+ for filename in REQUIRED_FILES:
135
+ file_path = path / filename
136
+ if not file_path.exists():
137
+ raise TraceStorageError(f"missing required file: {file_path}")
138
+ if not file_path.is_file():
139
+ raise TraceStorageError(f"required path is not a file: {file_path}")
140
+
141
+ manifest = _read_manifest(path / MANIFEST_FILE)
142
+ events = _read_events(path / EVENTS_FILE)
143
+ _read_redactions(path / REDACTIONS_FILE)
144
+
145
+ return Trace(
146
+ run_id=manifest.run_id,
147
+ agent_name=manifest.agent_name,
148
+ created_at=manifest.created_at,
149
+ status=manifest.status,
150
+ failure=manifest.failure,
151
+ environment=manifest.environment,
152
+ totals=manifest.totals,
153
+ events=events,
154
+ )
155
+
156
+
157
+ def _computed_totals(trace: Trace) -> TraceTotals:
158
+ return TraceTotals(
159
+ events=len(trace.events),
160
+ llm_calls=sum(event.type is EventType.LLM_REQUEST for event in trace.events),
161
+ tool_calls=sum(event.type is EventType.TOOL_CALL for event in trace.events),
162
+ cost_usd=trace.totals.cost_usd,
163
+ duration_ms=trace.totals.duration_ms,
164
+ )
165
+
166
+
167
+ def _require_sftrace_suffix(path: Path) -> None:
168
+ if path.suffix != ".sftrace":
169
+ raise TraceStorageError("trace bundle path must end with .sftrace")
170
+
171
+
172
+ def _has_required_bundle_files(path: Path) -> bool:
173
+ return all((path / filename).is_file() for filename in REQUIRED_FILES)
174
+
175
+
176
+ def _write_manifest(path: Path, manifest: TraceManifest) -> None:
177
+ path.write_text(
178
+ manifest.model_dump_json(indent=2) + "\n",
179
+ encoding="utf-8",
180
+ )
181
+
182
+
183
+ def _write_events(path: Path, events: list[Event]) -> None:
184
+ with path.open("w", encoding="utf-8") as file:
185
+ for event in events:
186
+ file.write(_event_adapter.dump_json(event).decode("utf-8"))
187
+ file.write("\n")
188
+
189
+
190
+ def _write_redactions(path: Path, redactions: RedactionManifest) -> None:
191
+ path.write_text(
192
+ redactions.model_dump_json(indent=2) + "\n",
193
+ encoding="utf-8",
194
+ )
195
+
196
+
197
+ def _read_manifest(path: Path) -> TraceManifest:
198
+ try:
199
+ payload = json.loads(path.read_text(encoding="utf-8"))
200
+ except json.JSONDecodeError as exc:
201
+ raise TraceStorageError(f"{path}: invalid JSON: {exc.msg}") from exc
202
+
203
+ if not isinstance(payload, dict):
204
+ raise TraceStorageError(f"{path}: invalid manifest: expected object")
205
+ if payload.get("schema_version") != SCHEMA_VERSION:
206
+ raise TraceStorageError(
207
+ f"{path}: unsupported schema version {payload.get('schema_version')!r}"
208
+ )
209
+
210
+ try:
211
+ return TraceManifest.model_validate(payload)
212
+ except ValidationError as exc:
213
+ raise TraceStorageError(f"{path}: invalid manifest") from exc
214
+
215
+
216
+ def _read_events(path: Path) -> list[Event]:
217
+ events: list[Event] = []
218
+ with path.open(encoding="utf-8") as file:
219
+ for line_number, line in enumerate(file, start=1):
220
+ if line.strip() == "":
221
+ raise TraceStorageError(f"{path}:{line_number}: blank JSONL line")
222
+ try:
223
+ payload = json.loads(line)
224
+ except json.JSONDecodeError as exc:
225
+ raise TraceStorageError(
226
+ f"{path}:{line_number}: invalid JSON: {exc.msg}"
227
+ ) from exc
228
+ try:
229
+ events.append(_event_adapter.validate_python(payload))
230
+ except ValidationError as exc:
231
+ raise TraceStorageError(f"{path}:{line_number}: invalid event") from exc
232
+ return events
233
+
234
+
235
+ def _read_redactions(path: Path) -> None:
236
+ try:
237
+ payload = json.loads(path.read_text(encoding="utf-8"))
238
+ except json.JSONDecodeError as exc:
239
+ raise TraceStorageError(f"{path}: invalid JSON: {exc.msg}") from exc
240
+ if not isinstance(payload, dict):
241
+ raise TraceStorageError(f"{path}: invalid redactions object")
242
+ if payload.get("schema_version") != SCHEMA_VERSION:
243
+ raise TraceStorageError(
244
+ f"{path}: unsupported redactions schema version "
245
+ f"{payload.get('schema_version')!r}"
246
+ )
247
+ try:
248
+ RedactionManifest.model_validate(payload)
249
+ except ValidationError as exc:
250
+ raise TraceStorageError(f"{path}: invalid redactions metadata") from exc
@@ -0,0 +1,285 @@
1
+ """Structured validation for Stepfork traces and `.sftrace` bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Self
7
+
8
+ from pydantic import BaseModel, ConfigDict, model_validator
9
+
10
+ from stepfork.trace.hashing import (
11
+ event_payload_hash_fields,
12
+ verify_event_payload_hashes,
13
+ )
14
+ from stepfork.trace.integrity import IntegrityStatus, verify_bundle_integrity
15
+ from stepfork.trace.manifest import RunStatus
16
+ from stepfork.trace.models import EventType, Trace
17
+ from stepfork.trace.storage import REQUIRED_FILES, TraceStorageError, load_trace
18
+
19
+
20
+ class ValidationIssue(BaseModel):
21
+ """Machine-readable validation issue."""
22
+
23
+ model_config = ConfigDict(extra="forbid")
24
+
25
+ code: str
26
+ message: str
27
+ event_id: str | None = None
28
+ step: int | None = None
29
+ location: str | None = None
30
+
31
+
32
+ class ValidationResult(BaseModel):
33
+ """Structured validation result."""
34
+
35
+ model_config = ConfigDict(extra="forbid")
36
+
37
+ valid: bool = True
38
+ issues: list[ValidationIssue]
39
+
40
+ @model_validator(mode="after")
41
+ def valid_matches_issues(self) -> Self:
42
+ """Keep `valid` derived from the issue list."""
43
+ self.valid = not self.issues
44
+ return self
45
+
46
+
47
+ def validate_trace(
48
+ trace: Trace,
49
+ *,
50
+ strict: bool = True,
51
+ verify_payload_hashes: bool = False,
52
+ ) -> ValidationResult:
53
+ """Validate cross-event structure for an in-memory trace."""
54
+ issues: list[ValidationIssue] = []
55
+
56
+ if trace.totals.events != len(trace.events):
57
+ issues.append(
58
+ ValidationIssue(
59
+ code="event_count_mismatch",
60
+ message=(
61
+ "Manifest event count "
62
+ f"{trace.totals.events} does not match {len(trace.events)}."
63
+ ),
64
+ location="manifest.json:totals.events",
65
+ )
66
+ )
67
+
68
+ seen: dict[str, int] = {}
69
+ first_positions: dict[str, int] = {}
70
+ for index, event in enumerate(trace.events):
71
+ first_positions.setdefault(event.id, index)
72
+ previous_step: int | None = None
73
+ run_start_count = 0
74
+ has_error_event = False
75
+
76
+ for index, event in enumerate(trace.events):
77
+ if event.id in seen:
78
+ issues.append(
79
+ ValidationIssue(
80
+ code="duplicate_event_id",
81
+ message=f"Event {event.id} appears more than once.",
82
+ event_id=event.id,
83
+ step=event.step,
84
+ )
85
+ )
86
+ else:
87
+ seen[event.id] = index
88
+
89
+ if event.run_id != trace.run_id:
90
+ issues.append(
91
+ ValidationIssue(
92
+ code="run_id_mismatch",
93
+ message=(
94
+ f"Event {event.id} run_id {event.run_id!r} does not "
95
+ f"match manifest run_id {trace.run_id!r}."
96
+ ),
97
+ event_id=event.id,
98
+ step=event.step,
99
+ )
100
+ )
101
+
102
+ if previous_step is not None and event.step < previous_step:
103
+ issues.append(
104
+ ValidationIssue(
105
+ code="step_order",
106
+ message=(
107
+ f"Event {event.id} step {event.step} is less than "
108
+ f"the previous step {previous_step}."
109
+ ),
110
+ event_id=event.id,
111
+ step=event.step,
112
+ )
113
+ )
114
+ previous_step = event.step
115
+
116
+ if event.parent_id is not None:
117
+ parent_index = first_positions.get(event.parent_id)
118
+ if parent_index is None:
119
+ issues.append(
120
+ ValidationIssue(
121
+ code="invalid_parent",
122
+ message=(
123
+ f"Event {event.id} references missing parent "
124
+ f"{event.parent_id}."
125
+ ),
126
+ event_id=event.id,
127
+ step=event.step,
128
+ )
129
+ )
130
+ elif parent_index >= index:
131
+ issues.append(
132
+ ValidationIssue(
133
+ code="parent_not_earlier",
134
+ message=(
135
+ f"Event {event.id} parent {event.parent_id} does "
136
+ "not appear earlier in the trace."
137
+ ),
138
+ event_id=event.id,
139
+ step=event.step,
140
+ )
141
+ )
142
+
143
+ if event.type is EventType.RUN_START:
144
+ run_start_count += 1
145
+ if event.type is EventType.ERROR:
146
+ has_error_event = True
147
+
148
+ if verify_payload_hashes:
149
+ expected_hash_fields = event_payload_hash_fields(event)
150
+ for field in sorted(expected_hash_fields):
151
+ if getattr(event, field) is None:
152
+ issues.append(
153
+ ValidationIssue(
154
+ code="integrity_unverified",
155
+ message=f"Event {event.id} is missing {field}.",
156
+ event_id=event.id,
157
+ step=event.step,
158
+ location=f"events.jsonl:{field}",
159
+ )
160
+ )
161
+ for field in verify_event_payload_hashes(event):
162
+ issues.append(
163
+ ValidationIssue(
164
+ code="payload_hash_mismatch",
165
+ message=f"Event {event.id} has a {field} mismatch.",
166
+ event_id=event.id,
167
+ step=event.step,
168
+ location=f"events.jsonl:{field}",
169
+ )
170
+ )
171
+
172
+ if strict and run_start_count != 1:
173
+ issues.append(
174
+ ValidationIssue(
175
+ code="run_start_count",
176
+ message=(
177
+ "Strict validation requires exactly one run_start; "
178
+ f"found {run_start_count}."
179
+ ),
180
+ location="events.jsonl",
181
+ )
182
+ )
183
+
184
+ if strict and trace.status is RunStatus.FAILED and not has_error_event:
185
+ issues.append(
186
+ ValidationIssue(
187
+ code="missing_error_event",
188
+ message=(
189
+ "Failed traces require at least one error event in strict mode."
190
+ ),
191
+ location="events.jsonl",
192
+ )
193
+ )
194
+
195
+ return ValidationResult(issues=issues)
196
+
197
+
198
+ def validate_bundle(
199
+ path: str | Path,
200
+ *,
201
+ strict: bool = True,
202
+ verify_integrity: bool = False,
203
+ ) -> ValidationResult:
204
+ """Load and validate a `.sftrace` bundle."""
205
+ bundle_path = Path(path)
206
+ missing = _missing_files(bundle_path)
207
+ if missing:
208
+ return ValidationResult(
209
+ issues=[
210
+ ValidationIssue(
211
+ code="missing_file",
212
+ message=f"Missing required file: {file_path}.",
213
+ location=str(file_path),
214
+ )
215
+ for file_path in missing
216
+ ]
217
+ )
218
+
219
+ try:
220
+ trace = load_trace(bundle_path)
221
+ except TraceStorageError as exc:
222
+ message = str(exc)
223
+ return ValidationResult(
224
+ issues=[
225
+ ValidationIssue(
226
+ code=_storage_error_code(message),
227
+ message=message,
228
+ location=_storage_error_location(message),
229
+ )
230
+ ]
231
+ )
232
+
233
+ result = validate_trace(
234
+ trace,
235
+ strict=strict,
236
+ verify_payload_hashes=verify_integrity,
237
+ )
238
+ if not verify_integrity:
239
+ return result
240
+
241
+ integrity_result = verify_bundle_integrity(bundle_path)
242
+ integrity_issues = [
243
+ ValidationIssue(
244
+ code=issue.code,
245
+ message=issue.message,
246
+ location=issue.file,
247
+ )
248
+ for issue in integrity_result.issues
249
+ ]
250
+ if integrity_result.status is IntegrityStatus.VERIFIED:
251
+ return result
252
+ return ValidationResult(issues=[*result.issues, *integrity_issues])
253
+
254
+
255
+ def _missing_files(path: Path) -> list[Path]:
256
+ if not path.exists() or not path.is_dir():
257
+ return [path]
258
+ return [
259
+ path / filename
260
+ for filename in REQUIRED_FILES
261
+ if not (path / filename).is_file()
262
+ ]
263
+
264
+
265
+ def _storage_error_code(message: str) -> str:
266
+ if "unsupported" in message:
267
+ return "unsupported_schema"
268
+ if "invalid JSON" in message or "blank JSONL line" in message:
269
+ return "invalid_json"
270
+ if "invalid manifest" in message:
271
+ return "invalid_manifest"
272
+ if "invalid event" in message:
273
+ return "invalid_event"
274
+ if "missing required file" in message:
275
+ return "missing_file"
276
+ return "invalid_manifest"
277
+
278
+
279
+ def _storage_error_location(message: str) -> str | None:
280
+ if ":" not in message:
281
+ return None
282
+ first, second, *_ = message.split(":")
283
+ if second.isdigit():
284
+ return f"{first}:{second}"
285
+ return first
stepfork/version.py ADDED
@@ -0,0 +1,5 @@
1
+ """Single source of truth for the Stepfork package version."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0a2"