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,271 @@
1
+ """Typed event models for the Stepfork `.sftrace` v0.1 contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeAlias, TypeVar, cast
9
+ from uuid import uuid4
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
12
+ from typing_extensions import TypeAliasType
13
+
14
+ from stepfork.trace.manifest import (
15
+ EnvironmentInfo,
16
+ FailureInfo,
17
+ RunStatus,
18
+ TraceTotals,
19
+ )
20
+ from stepfork.trace.replay_policy import ReplayPolicy
21
+
22
+ JsonPrimitive: TypeAlias = str | int | float | bool | None
23
+ if TYPE_CHECKING:
24
+ JsonValue: TypeAlias = JsonPrimitive | list[Any] | dict[str, Any]
25
+ else:
26
+ JsonValue = TypeAliasType(
27
+ "JsonValue",
28
+ JsonPrimitive | list["JsonValue"] | dict[str, "JsonValue"],
29
+ )
30
+ E = TypeVar("E", bound="EventBase")
31
+
32
+
33
+ def _new_event_id() -> str:
34
+ """Return an opaque local event ID."""
35
+ return f"evt_{uuid4().hex}"
36
+
37
+
38
+ def _new_run_id() -> str:
39
+ """Return an opaque local run ID."""
40
+ return f"run_{uuid4().hex}"
41
+
42
+
43
+ def _utc_now() -> datetime:
44
+ """Return the current timezone-aware UTC timestamp."""
45
+ return datetime.now(UTC)
46
+
47
+
48
+ class EventStatus(StrEnum):
49
+ """Status for a recorded event."""
50
+
51
+ OK = "ok"
52
+ ERROR = "error"
53
+ SKIPPED = "skipped"
54
+
55
+
56
+ class EventType(StrEnum):
57
+ """The deliberately small v0.1 event taxonomy."""
58
+
59
+ RUN_START = "run_start"
60
+ LLM_REQUEST = "llm_request"
61
+ LLM_RESPONSE = "llm_response"
62
+ TOOL_CALL = "tool_call"
63
+ TOOL_RESULT = "tool_result"
64
+ STATE_CHANGE = "state_change"
65
+ ERROR = "error"
66
+ RUN_END = "run_end"
67
+
68
+
69
+ class EventBase(BaseModel):
70
+ """Common fields shared by all persisted trace events."""
71
+
72
+ model_config = ConfigDict(extra="forbid")
73
+
74
+ id: str = Field(default_factory=_new_event_id, min_length=1)
75
+ run_id: str = Field(default_factory=_new_run_id, min_length=1)
76
+ parent_id: str | None = Field(default=None, min_length=1)
77
+
78
+ step: int = Field(default=0, ge=0)
79
+
80
+ type: EventType
81
+ timestamp: datetime = Field(default_factory=_utc_now)
82
+ duration_ms: int | None = Field(default=None, ge=0)
83
+
84
+ status: EventStatus = EventStatus.OK
85
+ replay_policy: ReplayPolicy = ReplayPolicy.FROZEN
86
+
87
+ input_hash: str | None = None
88
+ output_hash: str | None = None
89
+
90
+ redactions: list[str] = Field(default_factory=list)
91
+
92
+ @field_validator("timestamp")
93
+ @classmethod
94
+ def timestamp_must_be_timezone_aware(cls, value: datetime) -> datetime:
95
+ """Reject naive datetimes instead of assuming UTC."""
96
+ if value.tzinfo is None or value.utcoffset() is None:
97
+ raise ValueError("timestamp must be timezone-aware")
98
+ return value
99
+
100
+
101
+ class RunStart(EventBase):
102
+ """Event marking the start of a run."""
103
+
104
+ type: Literal[EventType.RUN_START] = EventType.RUN_START
105
+
106
+ input: JsonValue | None = None
107
+
108
+
109
+ class LLMRequest(EventBase):
110
+ """Request sent to an LLM provider."""
111
+
112
+ type: Literal[EventType.LLM_REQUEST] = EventType.LLM_REQUEST
113
+
114
+ provider: str | None = Field(default=None, min_length=1)
115
+ model: str = Field(min_length=1)
116
+ input: JsonValue
117
+
118
+
119
+ class LLMResponse(EventBase):
120
+ """Response received from an LLM provider."""
121
+
122
+ type: Literal[EventType.LLM_RESPONSE] = EventType.LLM_RESPONSE
123
+
124
+ model: str | None = Field(default=None, min_length=1)
125
+ output: JsonValue
126
+
127
+ prompt_tokens: int | None = Field(default=None, ge=0)
128
+ completion_tokens: int | None = Field(default=None, ge=0)
129
+ total_tokens: int | None = Field(default=None, ge=0)
130
+ cost_usd: float | None = Field(default=None, ge=0)
131
+
132
+
133
+ class ToolCall(EventBase):
134
+ """Call made to a tool.
135
+
136
+ Tool calls can be constructed directly with only `name` and JSON `input`.
137
+ Runtime defaults fill in event identity, run identity, 0-based step, and a
138
+ timezone-aware timestamp; `Trace.add()` normalizes run ID and step.
139
+ """
140
+
141
+ type: Literal[EventType.TOOL_CALL] = EventType.TOOL_CALL
142
+
143
+ name: str = Field(min_length=1)
144
+ input: JsonValue
145
+
146
+
147
+ class ToolResult(EventBase):
148
+ """Result returned by a tool."""
149
+
150
+ type: Literal[EventType.TOOL_RESULT] = EventType.TOOL_RESULT
151
+
152
+ name: str = Field(min_length=1)
153
+ output: JsonValue
154
+
155
+
156
+ class StateChange(EventBase):
157
+ """Change to agent state represented as JSON-compatible values."""
158
+
159
+ type: Literal[EventType.STATE_CHANGE] = EventType.STATE_CHANGE
160
+
161
+ key: str | None = Field(default=None, min_length=1)
162
+ before: JsonValue | None = None
163
+ after: JsonValue | None = None
164
+
165
+
166
+ class ErrorEvent(EventBase):
167
+ """Error observed during a run."""
168
+
169
+ type: Literal[EventType.ERROR] = EventType.ERROR
170
+
171
+ status: EventStatus = EventStatus.ERROR
172
+ error_type: str = Field(min_length=1)
173
+ message: str = Field(min_length=1)
174
+ details: JsonValue | None = None
175
+
176
+
177
+ class RunEnd(EventBase):
178
+ """Event marking the end of a run."""
179
+
180
+ type: Literal[EventType.RUN_END] = EventType.RUN_END
181
+
182
+ run_status: RunStatus
183
+ output: JsonValue | None = None
184
+
185
+
186
+ Event: TypeAlias = Annotated[
187
+ RunStart
188
+ | LLMRequest
189
+ | LLMResponse
190
+ | ToolCall
191
+ | ToolResult
192
+ | StateChange
193
+ | ErrorEvent
194
+ | RunEnd,
195
+ Field(discriminator="type"),
196
+ ]
197
+
198
+
199
+ class Trace(BaseModel):
200
+ """Minimal in-memory trace container.
201
+
202
+ `Trace.add()` uses 0-based logical steps: the first added event receives
203
+ step `0`, the second receives step `1`, and so on. Partial traces can be
204
+ saved for development and later validated in partial or strict mode.
205
+ """
206
+
207
+ model_config = ConfigDict(extra="forbid")
208
+
209
+ run_id: str = Field(default_factory=_new_run_id, min_length=1)
210
+ agent_name: str = Field(min_length=1)
211
+ created_at: datetime = Field(default_factory=_utc_now)
212
+ status: RunStatus = RunStatus.RUNNING
213
+ failure: FailureInfo | None = None
214
+ environment: EnvironmentInfo = Field(default_factory=EnvironmentInfo)
215
+ totals: TraceTotals = Field(default_factory=TraceTotals)
216
+ events: list[Event] = Field(default_factory=list)
217
+
218
+ @field_validator("created_at")
219
+ @classmethod
220
+ def created_at_must_be_timezone_aware(cls, value: datetime) -> datetime:
221
+ """Reject naive datetimes instead of assuming UTC."""
222
+ if value.tzinfo is None or value.utcoffset() is None:
223
+ raise ValueError("created_at must be timezone-aware")
224
+ return value
225
+
226
+ def add(self, event: E) -> E:
227
+ """Append an event after normalizing run ID and 0-based logical step."""
228
+ normalized = event.model_copy(
229
+ update={"run_id": self.run_id, "step": len(self.events)}
230
+ )
231
+ self.events.append(cast(Event, normalized))
232
+ self.totals = self._computed_totals()
233
+ return normalized
234
+
235
+ def _computed_totals(self) -> TraceTotals:
236
+ """Return manifest counters derived from the current event sequence."""
237
+ return TraceTotals(
238
+ events=len(self.events),
239
+ llm_calls=sum(event.type is EventType.LLM_REQUEST for event in self.events),
240
+ tool_calls=sum(event.type is EventType.TOOL_CALL for event in self.events),
241
+ cost_usd=self.totals.cost_usd,
242
+ duration_ms=self.totals.duration_ms,
243
+ )
244
+
245
+ def save(
246
+ self,
247
+ path: str | Path,
248
+ *,
249
+ status: RunStatus | str | None = None,
250
+ failure: FailureInfo | None = None,
251
+ environment: EnvironmentInfo | None = None,
252
+ overwrite: bool = False,
253
+ ) -> Path:
254
+ """Persist this trace as a `.sftrace` directory bundle."""
255
+ from stepfork.trace.storage import save_trace
256
+
257
+ return save_trace(
258
+ self,
259
+ Path(path),
260
+ status=status,
261
+ failure=failure,
262
+ environment=environment,
263
+ overwrite=overwrite,
264
+ )
265
+
266
+ @classmethod
267
+ def load(cls, path: str | Path) -> Trace:
268
+ """Load a trace from a `.sftrace` directory bundle."""
269
+ from stepfork.trace.storage import load_trace
270
+
271
+ return load_trace(Path(path))
@@ -0,0 +1,294 @@
1
+ """Best-effort secret redaction for trace persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, Literal, cast
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
10
+
11
+ from stepfork.trace.manifest import TraceManifest
12
+ from stepfork.trace.models import Event, JsonValue, Trace
13
+
14
+ REDACTION_REPLACEMENT = "[REDACTED]"
15
+ MANIFEST_REDACTION_ID = "__manifest__"
16
+
17
+ SENSITIVE_KEY_NORMALIZED = {
18
+ "apikey",
19
+ "accesstoken",
20
+ "refreshtoken",
21
+ "authtoken",
22
+ "authorization",
23
+ "password",
24
+ "passwd",
25
+ "secret",
26
+ "clientsecret",
27
+ "privatekey",
28
+ "sessiontoken",
29
+ "bearertoken",
30
+ }
31
+
32
+ PROTECTED_EVENT_FIELDS = {
33
+ "id",
34
+ "run_id",
35
+ "parent_id",
36
+ "step",
37
+ "type",
38
+ "timestamp",
39
+ "duration_ms",
40
+ "status",
41
+ "replay_policy",
42
+ "input_hash",
43
+ "output_hash",
44
+ "redactions",
45
+ "prompt_tokens",
46
+ "completion_tokens",
47
+ "total_tokens",
48
+ "cost_usd",
49
+ "run_status",
50
+ }
51
+
52
+ PROTECTED_MANIFEST_FIELDS = {
53
+ "format",
54
+ "schema_version",
55
+ "run_id",
56
+ "created_at",
57
+ "status",
58
+ "totals",
59
+ }
60
+
61
+ _event_adapter: TypeAdapter[Event] = TypeAdapter(Event)
62
+
63
+ _CREDENTIAL_PATTERNS = [
64
+ re.compile(r"(?i)(Authorization:\s*Bearer\s+)([A-Za-z0-9._~+/=-]{8,})"),
65
+ re.compile(r"(?i)(Bearer\s+)([A-Za-z0-9._~+/=-]{8,})"),
66
+ re.compile(r"\b(sk-[A-Za-z0-9_-]{8,})\b"),
67
+ re.compile(r"\b(ghp_[A-Za-z0-9_]{8,})\b"),
68
+ re.compile(r"\b(gho_[A-Za-z0-9_]{8,})\b"),
69
+ re.compile(r"\b(github_pat_[A-Za-z0-9_]{8,})\b"),
70
+ ]
71
+
72
+
73
+ class RedactionError(ValueError):
74
+ """Raised when a value cannot be safely redacted for persistence."""
75
+
76
+
77
+ class RedactionEntry(BaseModel):
78
+ """Persisted redaction metadata without raw secret values."""
79
+
80
+ model_config = ConfigDict(extra="forbid")
81
+
82
+ event_id: str = Field(min_length=1)
83
+ path: str = Field(min_length=1)
84
+ reason: str = Field(min_length=1)
85
+ replacement: Literal["[REDACTED]"] = "[REDACTED]"
86
+
87
+
88
+ class RedactionManifest(BaseModel):
89
+ """Redactions recorded for a `.sftrace` bundle."""
90
+
91
+ model_config = ConfigDict(extra="forbid")
92
+
93
+ schema_version: Literal["0.1"] = "0.1"
94
+ entries: list[RedactionEntry] = Field(default_factory=list)
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class RedactionFinding:
99
+ """Internal redaction finding before an event ID is attached."""
100
+
101
+ path: str
102
+ reason: str
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class RedactionResult:
107
+ """Sanitized JSON value plus non-secret metadata."""
108
+
109
+ value: JsonValue
110
+ findings: list[RedactionFinding] = field(default_factory=list)
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class PreparedTrace:
115
+ """Trace data prepared for persistence."""
116
+
117
+ trace: Trace
118
+ manifest: TraceManifest
119
+ redactions: RedactionManifest
120
+
121
+
122
+ def redact_json(value: JsonValue) -> RedactionResult:
123
+ """Recursively redact sensitive keys and credential-like string spans."""
124
+ sanitized, findings = _redact_value(value, path="")
125
+ return RedactionResult(value=cast(JsonValue, sanitized), findings=findings)
126
+
127
+
128
+ def prepare_trace_for_persistence(
129
+ trace: Trace,
130
+ manifest: TraceManifest,
131
+ ) -> PreparedTrace:
132
+ """Return sanitized trace data and redaction metadata for writing."""
133
+ manifest_payload = manifest.model_dump(mode="json")
134
+ sanitized_manifest_payload, manifest_findings = _redact_mapping(
135
+ manifest_payload,
136
+ path="",
137
+ protected_fields=PROTECTED_MANIFEST_FIELDS,
138
+ )
139
+ sanitized_manifest = TraceManifest.model_validate(sanitized_manifest_payload)
140
+
141
+ sanitized_events: list[Event] = []
142
+ entries: list[RedactionEntry] = []
143
+ for event in trace.events:
144
+ event_payload = event.model_dump(mode="json")
145
+ sanitized_event_payload, findings = _redact_mapping(
146
+ event_payload,
147
+ path="",
148
+ protected_fields=PROTECTED_EVENT_FIELDS,
149
+ )
150
+ event_entries = [
151
+ RedactionEntry(
152
+ event_id=event.id,
153
+ path=finding.path,
154
+ reason=finding.reason,
155
+ )
156
+ for finding in findings
157
+ ]
158
+ if event_entries:
159
+ existing = sanitized_event_payload.get("redactions", [])
160
+ sanitized_event_payload["redactions"] = [
161
+ *existing,
162
+ *(entry.path for entry in event_entries),
163
+ ]
164
+ sanitized_events.append(_event_adapter.validate_python(sanitized_event_payload))
165
+ entries.extend(event_entries)
166
+
167
+ entries.extend(
168
+ RedactionEntry(
169
+ event_id=MANIFEST_REDACTION_ID,
170
+ path=finding.path,
171
+ reason=finding.reason,
172
+ )
173
+ for finding in manifest_findings
174
+ )
175
+
176
+ sanitized_trace = Trace(
177
+ run_id=sanitized_manifest.run_id,
178
+ agent_name=sanitized_manifest.agent_name,
179
+ created_at=sanitized_manifest.created_at,
180
+ status=sanitized_manifest.status,
181
+ failure=sanitized_manifest.failure,
182
+ environment=sanitized_manifest.environment,
183
+ totals=sanitized_manifest.totals,
184
+ events=sanitized_events,
185
+ )
186
+ return PreparedTrace(
187
+ trace=sanitized_trace,
188
+ manifest=sanitized_manifest,
189
+ redactions=RedactionManifest(entries=_dedupe_entries(entries)),
190
+ )
191
+
192
+
193
+ def _redact_value(value: Any, *, path: str) -> tuple[Any, list[RedactionFinding]]:
194
+ if isinstance(value, dict):
195
+ return _redact_mapping(value, path=path, protected_fields=set())
196
+ if isinstance(value, list):
197
+ findings: list[RedactionFinding] = []
198
+ redacted_items: list[Any] = []
199
+ for index, item in enumerate(value):
200
+ redacted_item, item_findings = _redact_value(
201
+ item,
202
+ path=f"{path}/{index}",
203
+ )
204
+ redacted_items.append(redacted_item)
205
+ findings.extend(item_findings)
206
+ return redacted_items, findings
207
+ if isinstance(value, str):
208
+ return _redact_string(value, path=path)
209
+ return value, []
210
+
211
+
212
+ def _redact_mapping(
213
+ value: dict[str, Any],
214
+ *,
215
+ path: str,
216
+ protected_fields: set[str],
217
+ ) -> tuple[dict[str, Any], list[RedactionFinding]]:
218
+ findings: list[RedactionFinding] = []
219
+ redacted: dict[str, Any] = {}
220
+
221
+ for key in sorted(value):
222
+ item = value[key]
223
+ item_path = f"{path}/{_escape_json_pointer(key)}"
224
+ if key in protected_fields:
225
+ _assert_protected_value_is_safe(item)
226
+ redacted[key] = item
227
+ continue
228
+
229
+ if _is_sensitive_key(key):
230
+ if item == REDACTION_REPLACEMENT:
231
+ redacted[key] = item
232
+ else:
233
+ redacted[key] = REDACTION_REPLACEMENT
234
+ findings.append(
235
+ RedactionFinding(path=item_path, reason="sensitive_key")
236
+ )
237
+ continue
238
+
239
+ redacted_item, item_findings = _redact_value(item, path=item_path)
240
+ redacted[key] = redacted_item
241
+ findings.extend(item_findings)
242
+
243
+ return redacted, findings
244
+
245
+
246
+ def _redact_string(value: str, *, path: str) -> tuple[str, list[RedactionFinding]]:
247
+ if value == REDACTION_REPLACEMENT:
248
+ return value, []
249
+
250
+ redacted = value
251
+ changed = False
252
+ for pattern in _CREDENTIAL_PATTERNS:
253
+ redacted, replacements = pattern.subn(_replace_credential_match, redacted)
254
+ changed = changed or replacements > 0
255
+
256
+ if not changed:
257
+ return value, []
258
+ return redacted, [RedactionFinding(path=path, reason="credential_pattern")]
259
+
260
+
261
+ def _replace_credential_match(match: re.Match[str]) -> str:
262
+ if len(match.groups()) == 2:
263
+ return f"{match.group(1)}{REDACTION_REPLACEMENT}"
264
+ return REDACTION_REPLACEMENT
265
+
266
+
267
+ def _is_sensitive_key(key: str) -> bool:
268
+ return _normalize_key(key) in SENSITIVE_KEY_NORMALIZED
269
+
270
+
271
+ def _normalize_key(key: str) -> str:
272
+ camel_split = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key)
273
+ return re.sub(r"[^a-z0-9]", "", camel_split.lower())
274
+
275
+
276
+ def _escape_json_pointer(segment: str) -> str:
277
+ return segment.replace("~", "~0").replace("/", "~1")
278
+
279
+
280
+ def _assert_protected_value_is_safe(value: Any) -> None:
281
+ if isinstance(value, str) and _redact_string(value, path="")[0] != value:
282
+ raise RedactionError("credential pattern found in protected structural field")
283
+
284
+
285
+ def _dedupe_entries(entries: list[RedactionEntry]) -> list[RedactionEntry]:
286
+ seen: set[tuple[str, str, str]] = set()
287
+ unique: list[RedactionEntry] = []
288
+ for entry in entries:
289
+ key = (entry.event_id, entry.path, entry.reason)
290
+ if key in seen:
291
+ continue
292
+ seen.add(key)
293
+ unique.append(entry)
294
+ return unique
@@ -0,0 +1,18 @@
1
+ """Replay policy values for recorded trace dependencies."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class ReplayPolicy(StrEnum):
7
+ """Describe how a dependency should behave during replay.
8
+
9
+ `frozen` returns captured output, `live` re-executes the dependency,
10
+ `manual` requires explicit user approval, `forbidden` prevents automatic
11
+ execution, and `derived` recomputes locally from deterministic inputs.
12
+ """
13
+
14
+ FROZEN = "frozen"
15
+ LIVE = "live"
16
+ MANUAL = "manual"
17
+ FORBIDDEN = "forbidden"
18
+ DERIVED = "derived"
@@ -0,0 +1,25 @@
1
+ """JSON Schema helpers for the Stepfork trace contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import TypeAdapter
8
+
9
+ from stepfork.trace.manifest import TraceManifest
10
+ from stepfork.trace.models import Event, Trace
11
+
12
+
13
+ def event_json_schema() -> dict[str, Any]:
14
+ """Return the generated JSON Schema for the discriminated Event union."""
15
+ return TypeAdapter(Event).json_schema()
16
+
17
+
18
+ def manifest_json_schema() -> dict[str, Any]:
19
+ """Return the generated JSON Schema for trace manifests."""
20
+ return TraceManifest.model_json_schema()
21
+
22
+
23
+ def trace_json_schema() -> dict[str, Any]:
24
+ """Return the generated JSON Schema for the in-memory Trace model."""
25
+ return Trace.model_json_schema()