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
stepfork/diff/engine.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Semantic comparison of two recorded agent behaviors.
|
|
2
|
+
|
|
3
|
+
Timestamps, event IDs, run IDs, and wall-clock durations are ignored by
|
|
4
|
+
default. Comparison covers dependency call order, tool names, LLM models,
|
|
5
|
+
inputs, outputs, error types, state changes, and run status.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from stepfork.diff.compare import collect_field_changes, values_equal
|
|
14
|
+
from stepfork.diff.models import DiffResult, FieldChange, StepDiff, TraceSummary
|
|
15
|
+
from stepfork.replay.plan import extract_recorded_calls
|
|
16
|
+
from stepfork.trace import (
|
|
17
|
+
ErrorEvent,
|
|
18
|
+
JsonValue,
|
|
19
|
+
LLMRequest,
|
|
20
|
+
RunEnd,
|
|
21
|
+
StateChange,
|
|
22
|
+
ToolCall,
|
|
23
|
+
Trace,
|
|
24
|
+
)
|
|
25
|
+
from stepfork.trace.redaction import redact_json
|
|
26
|
+
|
|
27
|
+
TraceInput = str | Path | Trace
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class BehaviorStep:
|
|
32
|
+
"""One comparable unit of agent behavior extracted from a trace."""
|
|
33
|
+
|
|
34
|
+
step_type: str
|
|
35
|
+
label: str
|
|
36
|
+
payload: dict[str, JsonValue]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def diff_traces(
|
|
40
|
+
baseline: TraceInput,
|
|
41
|
+
candidate: TraceInput,
|
|
42
|
+
) -> DiffResult:
|
|
43
|
+
"""Compare baseline and candidate traces as structured behavior.
|
|
44
|
+
|
|
45
|
+
Accepts `.sftrace` paths or already-loaded :class:`Trace` objects and
|
|
46
|
+
raises `TraceStorageError` for unreadable bundles.
|
|
47
|
+
"""
|
|
48
|
+
baseline_trace = _load(baseline)
|
|
49
|
+
candidate_trace = _load(candidate)
|
|
50
|
+
|
|
51
|
+
baseline_steps = extract_behavior(baseline_trace)
|
|
52
|
+
candidate_steps = extract_behavior(candidate_trace)
|
|
53
|
+
steps = _diff_steps(baseline_steps, candidate_steps)
|
|
54
|
+
|
|
55
|
+
added = sum(step.kind == "added" for step in steps)
|
|
56
|
+
removed = sum(step.kind == "removed" for step in steps)
|
|
57
|
+
changed = sum(step.kind == "changed" for step in steps)
|
|
58
|
+
unchanged = sum(step.kind == "unchanged" for step in steps)
|
|
59
|
+
|
|
60
|
+
return DiffResult(
|
|
61
|
+
equivalent=added == 0 and removed == 0 and changed == 0,
|
|
62
|
+
baseline=_summary(baseline_trace, baseline),
|
|
63
|
+
candidate=_summary(candidate_trace, candidate),
|
|
64
|
+
steps=steps,
|
|
65
|
+
added=added,
|
|
66
|
+
removed=removed,
|
|
67
|
+
changed=changed,
|
|
68
|
+
unchanged=unchanged,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_behavior(trace: Trace) -> list[BehaviorStep]:
|
|
73
|
+
"""Extract the ordered, sanitized behavior steps from a trace."""
|
|
74
|
+
calls = {call.call_event_id: call for call in extract_recorded_calls(trace)}
|
|
75
|
+
steps: list[BehaviorStep] = []
|
|
76
|
+
|
|
77
|
+
for event in trace.events:
|
|
78
|
+
if isinstance(event, ToolCall | LLMRequest):
|
|
79
|
+
call = calls.get(event.id)
|
|
80
|
+
if call is None: # pragma: no cover - defensive
|
|
81
|
+
continue
|
|
82
|
+
steps.append(
|
|
83
|
+
BehaviorStep(
|
|
84
|
+
step_type=call.kind,
|
|
85
|
+
label=call.label,
|
|
86
|
+
payload=_sanitized_payload(
|
|
87
|
+
input=call.input,
|
|
88
|
+
output=call.output,
|
|
89
|
+
status=call.status.value,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
elif isinstance(event, StateChange):
|
|
94
|
+
steps.append(
|
|
95
|
+
BehaviorStep(
|
|
96
|
+
step_type="state",
|
|
97
|
+
label=event.key or "state",
|
|
98
|
+
payload={
|
|
99
|
+
"before": _sanitize(event.before),
|
|
100
|
+
"after": _sanitize(event.after),
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
elif isinstance(event, ErrorEvent):
|
|
105
|
+
steps.append(
|
|
106
|
+
BehaviorStep(
|
|
107
|
+
step_type="error",
|
|
108
|
+
label=event.error_type,
|
|
109
|
+
payload={"message": _sanitize(event.message)},
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
elif isinstance(event, RunEnd):
|
|
113
|
+
steps.append(
|
|
114
|
+
BehaviorStep(
|
|
115
|
+
step_type="run",
|
|
116
|
+
label="run",
|
|
117
|
+
payload={
|
|
118
|
+
"run_status": event.run_status.value,
|
|
119
|
+
"output": _sanitize(event.output),
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return steps
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _diff_steps(
|
|
128
|
+
baseline_steps: list[BehaviorStep],
|
|
129
|
+
candidate_steps: list[BehaviorStep],
|
|
130
|
+
) -> list[StepDiff]:
|
|
131
|
+
diffs: list[StepDiff] = []
|
|
132
|
+
total = max(len(baseline_steps), len(candidate_steps))
|
|
133
|
+
|
|
134
|
+
for index in range(total):
|
|
135
|
+
if index >= len(baseline_steps):
|
|
136
|
+
step = candidate_steps[index]
|
|
137
|
+
diffs.append(
|
|
138
|
+
StepDiff(
|
|
139
|
+
index=index,
|
|
140
|
+
kind="added",
|
|
141
|
+
step_type=step.step_type,
|
|
142
|
+
label=step.label,
|
|
143
|
+
changes=[],
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
continue
|
|
147
|
+
if index >= len(candidate_steps):
|
|
148
|
+
step = baseline_steps[index]
|
|
149
|
+
diffs.append(
|
|
150
|
+
StepDiff(
|
|
151
|
+
index=index,
|
|
152
|
+
kind="removed",
|
|
153
|
+
step_type=step.step_type,
|
|
154
|
+
label=step.label,
|
|
155
|
+
changes=[],
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
baseline_step = baseline_steps[index]
|
|
161
|
+
candidate_step = candidate_steps[index]
|
|
162
|
+
changes: list[FieldChange] = []
|
|
163
|
+
|
|
164
|
+
if baseline_step.step_type != candidate_step.step_type:
|
|
165
|
+
changes.append(
|
|
166
|
+
FieldChange(
|
|
167
|
+
path="type",
|
|
168
|
+
kind="changed",
|
|
169
|
+
expected=baseline_step.step_type,
|
|
170
|
+
actual=candidate_step.step_type,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
if baseline_step.label != candidate_step.label:
|
|
174
|
+
changes.append(
|
|
175
|
+
FieldChange(
|
|
176
|
+
path="label",
|
|
177
|
+
kind="changed",
|
|
178
|
+
expected=baseline_step.label,
|
|
179
|
+
actual=candidate_step.label,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
keys = sorted(set(baseline_step.payload) | set(candidate_step.payload))
|
|
184
|
+
for key in keys:
|
|
185
|
+
if key not in baseline_step.payload:
|
|
186
|
+
changes.append(
|
|
187
|
+
FieldChange(
|
|
188
|
+
path=key,
|
|
189
|
+
kind="added",
|
|
190
|
+
expected=None,
|
|
191
|
+
actual=candidate_step.payload[key],
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
elif key not in candidate_step.payload:
|
|
195
|
+
changes.append(
|
|
196
|
+
FieldChange(
|
|
197
|
+
path=key,
|
|
198
|
+
kind="removed",
|
|
199
|
+
expected=baseline_step.payload[key],
|
|
200
|
+
actual=None,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
collect_field_changes(
|
|
205
|
+
baseline_step.payload[key],
|
|
206
|
+
candidate_step.payload[key],
|
|
207
|
+
path=key,
|
|
208
|
+
output=changes,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
diffs.append(
|
|
212
|
+
StepDiff(
|
|
213
|
+
index=index,
|
|
214
|
+
kind="changed" if changes else "unchanged",
|
|
215
|
+
step_type=baseline_step.step_type,
|
|
216
|
+
label=baseline_step.label,
|
|
217
|
+
fields=keys,
|
|
218
|
+
changes=changes,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return diffs
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _sanitized_payload(
|
|
226
|
+
*,
|
|
227
|
+
input: JsonValue,
|
|
228
|
+
output: JsonValue | None,
|
|
229
|
+
status: str,
|
|
230
|
+
) -> dict[str, JsonValue]:
|
|
231
|
+
return {
|
|
232
|
+
"input": _sanitize(input),
|
|
233
|
+
"output": _sanitize(output),
|
|
234
|
+
"status": status,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _sanitize(value: JsonValue | None) -> JsonValue:
|
|
239
|
+
return redact_json(value).value
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _summary(trace: Trace, source: TraceInput) -> TraceSummary:
|
|
243
|
+
path = str(source) if isinstance(source, str | Path) else None
|
|
244
|
+
return TraceSummary(
|
|
245
|
+
path=path,
|
|
246
|
+
agent_name=trace.agent_name,
|
|
247
|
+
run_id=trace.run_id,
|
|
248
|
+
status=trace.status.value,
|
|
249
|
+
events=len(trace.events),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _load(source: TraceInput) -> Trace:
|
|
254
|
+
if isinstance(source, Trace):
|
|
255
|
+
return source
|
|
256
|
+
return Trace.load(Path(source))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def steps_equivalent(left: BehaviorStep, right: BehaviorStep) -> bool:
|
|
260
|
+
"""Return True when two behavior steps describe identical behavior."""
|
|
261
|
+
if left.step_type != right.step_type or left.label != right.label:
|
|
262
|
+
return False
|
|
263
|
+
if set(left.payload) != set(right.payload):
|
|
264
|
+
return False
|
|
265
|
+
return all(
|
|
266
|
+
values_equal(left.payload[key], right.payload[key]) for key in left.payload
|
|
267
|
+
)
|
stepfork/diff/models.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Structured, machine-readable behavioral diff models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from stepfork.trace.models import JsonValue
|
|
10
|
+
|
|
11
|
+
ChangeKind = Literal["added", "removed", "changed", "unchanged"]
|
|
12
|
+
LeafChangeKind = Literal["added", "removed", "changed"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FieldChange(BaseModel):
|
|
16
|
+
"""One changed field difference between baseline and candidate."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(extra="forbid")
|
|
19
|
+
|
|
20
|
+
path: str
|
|
21
|
+
kind: LeafChangeKind
|
|
22
|
+
expected: JsonValue | None = None
|
|
23
|
+
actual: JsonValue | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StepDiff(BaseModel):
|
|
27
|
+
"""Comparison result for one aligned behavior step."""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
|
|
31
|
+
index: int
|
|
32
|
+
kind: ChangeKind
|
|
33
|
+
step_type: str
|
|
34
|
+
label: str
|
|
35
|
+
fields: list[str] = Field(default_factory=list)
|
|
36
|
+
changes: list[FieldChange] = Field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TraceSummary(BaseModel):
|
|
40
|
+
"""Identity metadata for one side of the comparison."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
path: str | None = None
|
|
45
|
+
agent_name: str
|
|
46
|
+
run_id: str
|
|
47
|
+
status: str
|
|
48
|
+
events: int
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DiffResult(BaseModel):
|
|
52
|
+
"""Structured behavioral comparison between two traces."""
|
|
53
|
+
|
|
54
|
+
model_config = ConfigDict(extra="forbid")
|
|
55
|
+
|
|
56
|
+
equivalent: bool
|
|
57
|
+
baseline: TraceSummary
|
|
58
|
+
candidate: TraceSummary
|
|
59
|
+
steps: list[StepDiff] = Field(default_factory=list)
|
|
60
|
+
added: int = 0
|
|
61
|
+
removed: int = 0
|
|
62
|
+
changed: int = 0
|
|
63
|
+
unchanged: int = 0
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Regression-test exporters."""
|
|
2
|
+
|
|
3
|
+
from stepfork.export.entrypoint import EntrypointError, resolve_entrypoint
|
|
4
|
+
from stepfork.export.generator import (
|
|
5
|
+
ExportError,
|
|
6
|
+
ExportExistsError,
|
|
7
|
+
export_pytest_test,
|
|
8
|
+
generate_pytest_source,
|
|
9
|
+
)
|
|
10
|
+
from stepfork.export.runtime import run_regression_case
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"EntrypointError",
|
|
14
|
+
"ExportError",
|
|
15
|
+
"ExportExistsError",
|
|
16
|
+
"export_pytest_test",
|
|
17
|
+
"generate_pytest_source",
|
|
18
|
+
"resolve_entrypoint",
|
|
19
|
+
"run_regression_case",
|
|
20
|
+
]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Trusted resolution of ``MODULE:FUNCTION`` entrypoints.
|
|
2
|
+
|
|
3
|
+
Stepfork only imports entrypoints the user passes explicitly on the command
|
|
4
|
+
line or in API calls. Trace bundles are data and are never imported, evaluated,
|
|
5
|
+
or executed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EntrypointError(ValueError):
|
|
18
|
+
"""Raised when an entrypoint specification cannot be resolved."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_entrypoint(
|
|
22
|
+
spec: str,
|
|
23
|
+
*,
|
|
24
|
+
import_root: Path | None = None,
|
|
25
|
+
) -> Callable[[], Any]:
|
|
26
|
+
"""Import ``MODULE:FUNCTION`` and return the zero-argument callable.
|
|
27
|
+
|
|
28
|
+
``import_root`` (when given) is prepended to ``sys.path`` so local
|
|
29
|
+
project modules such as ``examples.booking_agent`` resolve regardless of
|
|
30
|
+
how Python was started.
|
|
31
|
+
"""
|
|
32
|
+
module_name, separator, attribute_path = spec.partition(":")
|
|
33
|
+
if not separator or not module_name or not attribute_path:
|
|
34
|
+
raise EntrypointError(
|
|
35
|
+
f"invalid entrypoint {spec!r}; expected the form MODULE:FUNCTION"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if import_root is not None:
|
|
39
|
+
root = str(import_root)
|
|
40
|
+
if root not in sys.path:
|
|
41
|
+
sys.path.insert(0, root)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
module = importlib.import_module(module_name)
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
raise EntrypointError(
|
|
47
|
+
f"cannot import module {module_name!r}: {type(exc).__name__}: {exc}"
|
|
48
|
+
) from exc
|
|
49
|
+
|
|
50
|
+
target: Any = module
|
|
51
|
+
for part in attribute_path.split("."):
|
|
52
|
+
if not hasattr(target, part):
|
|
53
|
+
raise EntrypointError(f"module {module_name!r} has no attribute {part!r}")
|
|
54
|
+
target = getattr(target, part)
|
|
55
|
+
|
|
56
|
+
if not callable(target):
|
|
57
|
+
raise EntrypointError(f"entrypoint {spec!r} is not callable")
|
|
58
|
+
return cast("Callable[[], Any]", target)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Generation of executable pytest regression tests from `.sftrace` bundles.
|
|
2
|
+
|
|
3
|
+
Generated files come from a fixed, reviewed template. Trace payloads are never
|
|
4
|
+
interpolated into executable code: only sanitized JSON literals, paths, and
|
|
5
|
+
the user-supplied entrypoint specification are embedded.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import pprint
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from stepfork.trace import JsonValue
|
|
16
|
+
from stepfork.trace.redaction import redact_json
|
|
17
|
+
from stepfork.version import __version__
|
|
18
|
+
|
|
19
|
+
MAX_RELATIVE_PARENTS = 3
|
|
20
|
+
|
|
21
|
+
TEMPLATE = '''"""Stepfork regression test for {title}.
|
|
22
|
+
|
|
23
|
+
Generated by stepfork {version} from the trace bundle `{trace_name}`.
|
|
24
|
+
Regenerate with the same `stepfork export` command plus `--overwrite`.
|
|
25
|
+
|
|
26
|
+
This test executes the trusted local entrypoint `{entrypoint}` under frozen
|
|
27
|
+
Stepfork replay and asserts the behavior recorded in EXPECTATION. Stepfork
|
|
28
|
+
never executes code embedded in a trace bundle.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from stepfork.export.runtime import run_regression_case
|
|
36
|
+
|
|
37
|
+
TRACE_PATH = {trace_expr}
|
|
38
|
+
IMPORT_ROOT = {import_root_expr}
|
|
39
|
+
ENTRYPOINT = {entrypoint_literal}
|
|
40
|
+
MODE = {mode_literal}
|
|
41
|
+
EXPECTATION = {expectation_literal}
|
|
42
|
+
HAS_EXPECTATION = {has_expectation_literal}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def {function_name}() -> None:
|
|
46
|
+
"""Fail when the agent diverges from the expected outcome."""
|
|
47
|
+
run_regression_case(
|
|
48
|
+
trace_path=TRACE_PATH,
|
|
49
|
+
import_root=IMPORT_ROOT,
|
|
50
|
+
entrypoint=ENTRYPOINT,
|
|
51
|
+
expectation=EXPECTATION,
|
|
52
|
+
has_expectation=HAS_EXPECTATION,
|
|
53
|
+
mode=MODE,
|
|
54
|
+
)
|
|
55
|
+
'''
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ExportError(ValueError):
|
|
59
|
+
"""Raised when a regression test cannot be generated."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ExportExistsError(ExportError):
|
|
63
|
+
"""Raised when the output file exists and overwrite was not requested."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def generate_pytest_source(
|
|
67
|
+
*,
|
|
68
|
+
trace_path: Path,
|
|
69
|
+
output_path: Path,
|
|
70
|
+
entrypoint: str,
|
|
71
|
+
expectation: JsonValue | None = None,
|
|
72
|
+
has_expectation: bool = True,
|
|
73
|
+
mode: str = "frozen",
|
|
74
|
+
import_root: Path | None = None,
|
|
75
|
+
) -> str:
|
|
76
|
+
"""Return the source code for a pytest regression test."""
|
|
77
|
+
trace = trace_path.resolve()
|
|
78
|
+
root = (import_root if import_root is not None else Path.cwd()).resolve()
|
|
79
|
+
|
|
80
|
+
return TEMPLATE.format(
|
|
81
|
+
title=_docstring_text(_trace_title(trace)),
|
|
82
|
+
version=_docstring_text(__version__),
|
|
83
|
+
trace_name=_docstring_text(trace.name),
|
|
84
|
+
entrypoint=_docstring_text(entrypoint),
|
|
85
|
+
trace_expr=_path_expression(trace, output_path),
|
|
86
|
+
import_root_expr=_path_expression(root, output_path),
|
|
87
|
+
entrypoint_literal=repr(entrypoint),
|
|
88
|
+
mode_literal=repr(mode),
|
|
89
|
+
expectation_literal=pprint.pformat(
|
|
90
|
+
redact_json(expectation).value if has_expectation else None,
|
|
91
|
+
width=88,
|
|
92
|
+
sort_dicts=True,
|
|
93
|
+
),
|
|
94
|
+
has_expectation_literal=repr(has_expectation),
|
|
95
|
+
function_name=_test_function_name(trace),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def export_pytest_test(
|
|
100
|
+
*,
|
|
101
|
+
trace_path: Path,
|
|
102
|
+
output_path: Path,
|
|
103
|
+
entrypoint: str,
|
|
104
|
+
expectation: JsonValue | None = None,
|
|
105
|
+
has_expectation: bool = True,
|
|
106
|
+
mode: str = "frozen",
|
|
107
|
+
import_root: Path | None = None,
|
|
108
|
+
overwrite: bool = False,
|
|
109
|
+
) -> Path:
|
|
110
|
+
"""Write a pytest regression test and return its path."""
|
|
111
|
+
if output_path.exists() and not overwrite:
|
|
112
|
+
raise ExportExistsError(
|
|
113
|
+
f"{output_path} already exists; pass --overwrite to replace it"
|
|
114
|
+
)
|
|
115
|
+
if output_path.exists() and output_path.is_dir():
|
|
116
|
+
raise ExportError(f"{output_path} is a directory, not a file")
|
|
117
|
+
|
|
118
|
+
source = generate_pytest_source(
|
|
119
|
+
trace_path=trace_path,
|
|
120
|
+
output_path=output_path,
|
|
121
|
+
entrypoint=entrypoint,
|
|
122
|
+
expectation=expectation,
|
|
123
|
+
has_expectation=has_expectation,
|
|
124
|
+
mode=mode,
|
|
125
|
+
import_root=import_root,
|
|
126
|
+
)
|
|
127
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
output_path.write_text(source, encoding="utf-8")
|
|
129
|
+
return output_path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _path_expression(target: Path, output_path: Path) -> str:
|
|
133
|
+
output_dir = output_path.parent.resolve()
|
|
134
|
+
try:
|
|
135
|
+
relative = os.path.relpath(target, output_dir)
|
|
136
|
+
except ValueError: # different drives on Windows
|
|
137
|
+
return f"Path({str(target)!r})"
|
|
138
|
+
|
|
139
|
+
parts = Path(relative).parts
|
|
140
|
+
if parts.count("..") > MAX_RELATIVE_PARENTS:
|
|
141
|
+
return f"Path({str(target)!r})"
|
|
142
|
+
if not parts:
|
|
143
|
+
return "Path(__file__).resolve().parent"
|
|
144
|
+
suffix = "".join(f" / {part!r}" for part in parts)
|
|
145
|
+
return f"Path(__file__).resolve().parent{suffix}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _safe_stem(trace_path: Path) -> str:
|
|
149
|
+
stem = re.sub(r"\W+", "_", trace_path.stem).strip("_")
|
|
150
|
+
return stem
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _docstring_text(value: str, *, limit: int = 200) -> str:
|
|
154
|
+
"""Return text that is safe to embed inside a triple-quoted docstring.
|
|
155
|
+
|
|
156
|
+
Trace directory names and entrypoint specs are user- or attacker-supplied.
|
|
157
|
+
Interpolating them verbatim lets a triple double-quote or backslash
|
|
158
|
+
sequence terminate the module docstring early and turn the remainder into
|
|
159
|
+
code. Collapse whitespace, neutralize quotes and backslashes, and bound
|
|
160
|
+
the length.
|
|
161
|
+
"""
|
|
162
|
+
collapsed = " ".join(value.split())
|
|
163
|
+
escaped = collapsed.replace("\\", "\\\\").replace('"', '\\"')
|
|
164
|
+
if len(escaped) > limit:
|
|
165
|
+
escaped = escaped[: limit - 1] + "…"
|
|
166
|
+
return escaped
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _trace_title(trace_path: Path) -> str:
|
|
170
|
+
stem = _safe_stem(trace_path)
|
|
171
|
+
return stem or "agent run"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _test_function_name(trace_path: Path) -> str:
|
|
175
|
+
stem = _safe_stem(trace_path)
|
|
176
|
+
if not stem:
|
|
177
|
+
return "test_stepfork_regression"
|
|
178
|
+
return f"test_stepfork_regression_{stem}"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Runtime helper executed by Stepfork-generated pytest regression tests.
|
|
2
|
+
|
|
3
|
+
This module is deliberately small, fixed, and reviewed. Generated tests only
|
|
4
|
+
assemble literals and call :func:`run_regression_case`; no generated code is
|
|
5
|
+
ever derived from trace payloads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from stepfork.diff.compare import first_difference
|
|
13
|
+
from stepfork.export.entrypoint import EntrypointError, resolve_entrypoint
|
|
14
|
+
from stepfork.replay import (
|
|
15
|
+
ReplayError,
|
|
16
|
+
ReplayMismatchError,
|
|
17
|
+
ReplayPolicyError,
|
|
18
|
+
ReplaySession,
|
|
19
|
+
)
|
|
20
|
+
from stepfork.trace import (
|
|
21
|
+
JsonValue,
|
|
22
|
+
Trace,
|
|
23
|
+
TraceSerializationError,
|
|
24
|
+
TraceStorageError,
|
|
25
|
+
canonical_json_bytes,
|
|
26
|
+
to_json_value,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
PREVIEW_LIMIT = 300
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_regression_case(
|
|
33
|
+
*,
|
|
34
|
+
trace_path: Path,
|
|
35
|
+
import_root: Path | None,
|
|
36
|
+
entrypoint: str,
|
|
37
|
+
expectation: JsonValue | None = None,
|
|
38
|
+
has_expectation: bool = True,
|
|
39
|
+
mode: str = "frozen",
|
|
40
|
+
) -> JsonValue:
|
|
41
|
+
"""Replay a trusted entrypoint and assert the expected behavior.
|
|
42
|
+
|
|
43
|
+
Fails with an actionable ``AssertionError`` when the trace is missing, the
|
|
44
|
+
entrypoint cannot load, replay diverges from the recorded dependency
|
|
45
|
+
sequence, or the returned value differs from ``expectation``.
|
|
46
|
+
"""
|
|
47
|
+
bundle = Path(trace_path)
|
|
48
|
+
if not bundle.exists():
|
|
49
|
+
raise AssertionError(f"stepfork: trace bundle not found: {bundle}")
|
|
50
|
+
try:
|
|
51
|
+
recorded = Trace.load(bundle)
|
|
52
|
+
except TraceStorageError as exc:
|
|
53
|
+
raise AssertionError(f"stepfork: trace bundle unreadable: {exc}") from exc
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
entry = resolve_entrypoint(entrypoint, import_root=import_root)
|
|
57
|
+
except EntrypointError as exc:
|
|
58
|
+
raise AssertionError(f"stepfork: entrypoint failed: {exc}") from exc
|
|
59
|
+
|
|
60
|
+
with ReplaySession.from_trace(recorded, mode=mode) as replay:
|
|
61
|
+
try:
|
|
62
|
+
actual = entry()
|
|
63
|
+
except ReplayMismatchError as exc:
|
|
64
|
+
raise AssertionError(f"stepfork replay divergence: {exc}") from exc
|
|
65
|
+
except ReplayPolicyError as exc:
|
|
66
|
+
raise AssertionError(f"stepfork replay policy violation: {exc}") from exc
|
|
67
|
+
try:
|
|
68
|
+
replay.verify_complete()
|
|
69
|
+
except ReplayError as exc:
|
|
70
|
+
raise AssertionError(f"stepfork replay divergence: {exc}") from exc
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
actual_json = to_json_value(actual)
|
|
74
|
+
except TraceSerializationError as exc:
|
|
75
|
+
raise AssertionError(
|
|
76
|
+
f"stepfork: entrypoint returned a non-serializable value: {exc}"
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
if has_expectation:
|
|
80
|
+
difference = first_difference(expectation, actual_json)
|
|
81
|
+
if difference is not None:
|
|
82
|
+
path, expected_value, actual_value = difference
|
|
83
|
+
raise AssertionError(
|
|
84
|
+
f"behavior mismatch at '{path}': "
|
|
85
|
+
f"expected {_preview(expected_value)}, "
|
|
86
|
+
f"got {_preview(actual_value)}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return actual_json
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _preview(value: JsonValue | None) -> str:
|
|
93
|
+
if value is None:
|
|
94
|
+
return "None"
|
|
95
|
+
try:
|
|
96
|
+
text = canonical_json_bytes(value).decode("utf-8", errors="replace")
|
|
97
|
+
except TypeError:
|
|
98
|
+
return "<unserializable>"
|
|
99
|
+
if len(text) > PREVIEW_LIMIT:
|
|
100
|
+
return f"{text[: PREVIEW_LIMIT - 1]}…"
|
|
101
|
+
return text
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Trace inspection helpers."""
|
|
2
|
+
|
|
3
|
+
from stepfork.inspect.inspector import inspect_bundle, inspect_trace
|
|
4
|
+
from stepfork.inspect.models import EventSummary, TraceInspection
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"EventSummary",
|
|
8
|
+
"TraceInspection",
|
|
9
|
+
"inspect_bundle",
|
|
10
|
+
"inspect_trace",
|
|
11
|
+
]
|