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,32 @@
|
|
|
1
|
+
"""Frozen, live, and hybrid replay engines."""
|
|
2
|
+
|
|
3
|
+
from stepfork.replay.exceptions import (
|
|
4
|
+
RecordedDependencyError,
|
|
5
|
+
ReplayError,
|
|
6
|
+
ReplayExhaustedError,
|
|
7
|
+
ReplayMismatchError,
|
|
8
|
+
ReplayPolicyError,
|
|
9
|
+
)
|
|
10
|
+
from stepfork.replay.plan import RecordedCall, extract_recorded_calls
|
|
11
|
+
from stepfork.replay.session import (
|
|
12
|
+
MatchedCall,
|
|
13
|
+
ReplayDecision,
|
|
14
|
+
ReplaySession,
|
|
15
|
+
ReplaySummary,
|
|
16
|
+
active_replay,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"MatchedCall",
|
|
21
|
+
"RecordedCall",
|
|
22
|
+
"RecordedDependencyError",
|
|
23
|
+
"ReplayDecision",
|
|
24
|
+
"ReplayError",
|
|
25
|
+
"ReplayExhaustedError",
|
|
26
|
+
"ReplayMismatchError",
|
|
27
|
+
"ReplayPolicyError",
|
|
28
|
+
"ReplaySession",
|
|
29
|
+
"ReplaySummary",
|
|
30
|
+
"active_replay",
|
|
31
|
+
"extract_recorded_calls",
|
|
32
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Structured exceptions raised while replaying a recorded trace."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ReplayError(Exception):
|
|
7
|
+
"""Base class for every replay failure."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReplayMismatchError(ReplayError):
|
|
11
|
+
"""The live execution diverged from the recorded dependency sequence."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ReplayExhaustedError(ReplayMismatchError):
|
|
15
|
+
"""A dependency call was made after every recorded call was consumed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ReplayPolicyError(ReplayError):
|
|
19
|
+
"""A replay policy forbids handling the dependency call automatically."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RecordedDependencyError(ReplayError):
|
|
23
|
+
"""Replay reproduced a dependency failure captured in the trace.
|
|
24
|
+
|
|
25
|
+
The original exception type cannot be reconstructed safely, so replay
|
|
26
|
+
raises this dedicated exception carrying the recorded error type and
|
|
27
|
+
sanitized message.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, kind: str, name: str, error_type: str, message: str):
|
|
31
|
+
self.kind = kind
|
|
32
|
+
self.name = name
|
|
33
|
+
self.error_type = error_type
|
|
34
|
+
self.message = message
|
|
35
|
+
super().__init__(
|
|
36
|
+
f"recorded {kind} failure for {name!r}: {error_type}: {message}"
|
|
37
|
+
)
|
stepfork/replay/plan.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Extraction of replayable dependency calls from a recorded trace.
|
|
2
|
+
|
|
3
|
+
The recorded dependency sequence is the ordered list of ``tool_call`` and
|
|
4
|
+
``llm_request`` events. Each call is paired with its captured outcome:
|
|
5
|
+
|
|
6
|
+
- ``tool_call`` → the ``tool_result`` whose ``parent_id`` points at it.
|
|
7
|
+
- ``llm_request`` → the ``llm_response`` whose ``parent_id`` points at it, or
|
|
8
|
+
the ``error`` event whose ``parent_id`` points at it when the call failed.
|
|
9
|
+
|
|
10
|
+
Pairing falls back to forward name matching when a legacy bundle does not use
|
|
11
|
+
parent links.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
from stepfork.trace.models import (
|
|
20
|
+
ErrorEvent,
|
|
21
|
+
EventStatus,
|
|
22
|
+
EventType,
|
|
23
|
+
JsonValue,
|
|
24
|
+
LLMRequest,
|
|
25
|
+
LLMResponse,
|
|
26
|
+
ToolCall,
|
|
27
|
+
ToolResult,
|
|
28
|
+
Trace,
|
|
29
|
+
)
|
|
30
|
+
from stepfork.trace.replay_policy import ReplayPolicy
|
|
31
|
+
|
|
32
|
+
ERROR_PAYLOAD_KEYS = ("error_type", "message")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class RecordedCall:
|
|
37
|
+
"""One dependency call captured in a trace."""
|
|
38
|
+
|
|
39
|
+
index: int
|
|
40
|
+
kind: Literal["tool", "llm"]
|
|
41
|
+
label: str
|
|
42
|
+
name: str | None
|
|
43
|
+
model: str | None
|
|
44
|
+
provider: str | None
|
|
45
|
+
input: JsonValue
|
|
46
|
+
policy: ReplayPolicy
|
|
47
|
+
status: EventStatus
|
|
48
|
+
output: JsonValue | None
|
|
49
|
+
error_type: str | None
|
|
50
|
+
error_message: str | None
|
|
51
|
+
missing: bool
|
|
52
|
+
call_event_id: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def extract_recorded_calls(trace: Trace) -> list[RecordedCall]:
|
|
56
|
+
"""Return the ordered dependency calls recorded in ``trace``."""
|
|
57
|
+
children: dict[str, list[int]] = {}
|
|
58
|
+
for index, event in enumerate(trace.events):
|
|
59
|
+
if event.parent_id is not None:
|
|
60
|
+
children.setdefault(event.parent_id, []).append(index)
|
|
61
|
+
|
|
62
|
+
paired_results: set[str] = set()
|
|
63
|
+
calls: list[RecordedCall] = []
|
|
64
|
+
|
|
65
|
+
for index, event in enumerate(trace.events):
|
|
66
|
+
if isinstance(event, ToolCall):
|
|
67
|
+
result_index = _find_child_index(
|
|
68
|
+
trace,
|
|
69
|
+
children.get(event.id, []),
|
|
70
|
+
wanted=EventType.TOOL_RESULT,
|
|
71
|
+
)
|
|
72
|
+
if result_index is None:
|
|
73
|
+
result_index = _find_forward_result(
|
|
74
|
+
trace,
|
|
75
|
+
start=index + 1,
|
|
76
|
+
name=event.name,
|
|
77
|
+
paired_results=paired_results,
|
|
78
|
+
)
|
|
79
|
+
result = trace.events[result_index] if result_index is not None else None
|
|
80
|
+
if isinstance(result, ToolResult):
|
|
81
|
+
paired_results.add(result.id)
|
|
82
|
+
calls.append(
|
|
83
|
+
_build_tool_call(
|
|
84
|
+
index=len(calls),
|
|
85
|
+
call=event,
|
|
86
|
+
result=result if isinstance(result, ToolResult) else None,
|
|
87
|
+
trace=trace,
|
|
88
|
+
children=children,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
elif isinstance(event, LLMRequest):
|
|
92
|
+
response_index = _find_child_index(
|
|
93
|
+
trace,
|
|
94
|
+
children.get(event.id, []),
|
|
95
|
+
wanted=EventType.LLM_RESPONSE,
|
|
96
|
+
)
|
|
97
|
+
error_index = _find_child_index(
|
|
98
|
+
trace,
|
|
99
|
+
children.get(event.id, []),
|
|
100
|
+
wanted=EventType.ERROR,
|
|
101
|
+
)
|
|
102
|
+
response = (
|
|
103
|
+
trace.events[response_index] if response_index is not None else None
|
|
104
|
+
)
|
|
105
|
+
error = trace.events[error_index] if error_index is not None else None
|
|
106
|
+
calls.append(
|
|
107
|
+
_build_llm_call(
|
|
108
|
+
index=len(calls),
|
|
109
|
+
request=event,
|
|
110
|
+
response=response if isinstance(response, LLMResponse) else None,
|
|
111
|
+
error=error if isinstance(error, ErrorEvent) else None,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return calls
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _find_child_index(
|
|
119
|
+
trace: Trace,
|
|
120
|
+
child_indexes: list[int],
|
|
121
|
+
*,
|
|
122
|
+
wanted: EventType,
|
|
123
|
+
) -> int | None:
|
|
124
|
+
for child_index in child_indexes:
|
|
125
|
+
if trace.events[child_index].type is wanted:
|
|
126
|
+
return child_index
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _find_forward_result(
|
|
131
|
+
trace: Trace,
|
|
132
|
+
*,
|
|
133
|
+
start: int,
|
|
134
|
+
name: str,
|
|
135
|
+
paired_results: set[str],
|
|
136
|
+
) -> int | None:
|
|
137
|
+
for index in range(start, len(trace.events)):
|
|
138
|
+
event = trace.events[index]
|
|
139
|
+
if isinstance(event, ToolCall) and event.name == name:
|
|
140
|
+
return None
|
|
141
|
+
if (
|
|
142
|
+
isinstance(event, ToolResult)
|
|
143
|
+
and event.name == name
|
|
144
|
+
and event.id not in paired_results
|
|
145
|
+
):
|
|
146
|
+
return index
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _build_tool_call(
|
|
151
|
+
*,
|
|
152
|
+
index: int,
|
|
153
|
+
call: ToolCall,
|
|
154
|
+
result: ToolResult | None,
|
|
155
|
+
trace: Trace,
|
|
156
|
+
children: dict[str, list[int]],
|
|
157
|
+
) -> RecordedCall:
|
|
158
|
+
error_type: str | None = None
|
|
159
|
+
error_message: str | None = None
|
|
160
|
+
|
|
161
|
+
if result is None:
|
|
162
|
+
return RecordedCall(
|
|
163
|
+
index=index,
|
|
164
|
+
kind="tool",
|
|
165
|
+
label=call.name,
|
|
166
|
+
name=call.name,
|
|
167
|
+
model=None,
|
|
168
|
+
provider=None,
|
|
169
|
+
input=call.input,
|
|
170
|
+
policy=call.replay_policy,
|
|
171
|
+
status=EventStatus.SKIPPED,
|
|
172
|
+
output=None,
|
|
173
|
+
error_type=None,
|
|
174
|
+
error_message=None,
|
|
175
|
+
missing=True,
|
|
176
|
+
call_event_id=call.id,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if result.status is EventStatus.ERROR:
|
|
180
|
+
error_index = _find_child_index(
|
|
181
|
+
trace,
|
|
182
|
+
children.get(result.id, []),
|
|
183
|
+
wanted=EventType.ERROR,
|
|
184
|
+
)
|
|
185
|
+
if error_index is not None:
|
|
186
|
+
error = trace.events[error_index]
|
|
187
|
+
if isinstance(error, ErrorEvent):
|
|
188
|
+
error_type = error.error_type
|
|
189
|
+
error_message = error.message
|
|
190
|
+
if error_type is None:
|
|
191
|
+
error_type, error_message = _error_from_payload(result.output)
|
|
192
|
+
|
|
193
|
+
return RecordedCall(
|
|
194
|
+
index=index,
|
|
195
|
+
kind="tool",
|
|
196
|
+
label=call.name,
|
|
197
|
+
name=call.name,
|
|
198
|
+
model=None,
|
|
199
|
+
provider=None,
|
|
200
|
+
input=call.input,
|
|
201
|
+
policy=call.replay_policy,
|
|
202
|
+
status=result.status,
|
|
203
|
+
output=result.output,
|
|
204
|
+
error_type=error_type,
|
|
205
|
+
error_message=error_message,
|
|
206
|
+
missing=False,
|
|
207
|
+
call_event_id=call.id,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _build_llm_call(
|
|
212
|
+
*,
|
|
213
|
+
index: int,
|
|
214
|
+
request: LLMRequest,
|
|
215
|
+
response: LLMResponse | None,
|
|
216
|
+
error: ErrorEvent | None,
|
|
217
|
+
) -> RecordedCall:
|
|
218
|
+
label = f"{request.provider}/{request.model}" if request.provider else request.model
|
|
219
|
+
|
|
220
|
+
if response is not None:
|
|
221
|
+
return RecordedCall(
|
|
222
|
+
index=index,
|
|
223
|
+
kind="llm",
|
|
224
|
+
label=label,
|
|
225
|
+
name=None,
|
|
226
|
+
model=request.model,
|
|
227
|
+
provider=request.provider,
|
|
228
|
+
input=request.input,
|
|
229
|
+
policy=request.replay_policy,
|
|
230
|
+
status=response.status,
|
|
231
|
+
output=response.output,
|
|
232
|
+
error_type=None,
|
|
233
|
+
error_message=None,
|
|
234
|
+
missing=False,
|
|
235
|
+
call_event_id=request.id,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
if error is not None:
|
|
239
|
+
return RecordedCall(
|
|
240
|
+
index=index,
|
|
241
|
+
kind="llm",
|
|
242
|
+
label=label,
|
|
243
|
+
name=None,
|
|
244
|
+
model=request.model,
|
|
245
|
+
provider=request.provider,
|
|
246
|
+
input=request.input,
|
|
247
|
+
policy=request.replay_policy,
|
|
248
|
+
status=EventStatus.ERROR,
|
|
249
|
+
output=None,
|
|
250
|
+
error_type=error.error_type,
|
|
251
|
+
error_message=error.message,
|
|
252
|
+
missing=False,
|
|
253
|
+
call_event_id=request.id,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return RecordedCall(
|
|
257
|
+
index=index,
|
|
258
|
+
kind="llm",
|
|
259
|
+
label=label,
|
|
260
|
+
name=None,
|
|
261
|
+
model=request.model,
|
|
262
|
+
provider=request.provider,
|
|
263
|
+
input=request.input,
|
|
264
|
+
policy=request.replay_policy,
|
|
265
|
+
status=EventStatus.SKIPPED,
|
|
266
|
+
output=None,
|
|
267
|
+
error_type=None,
|
|
268
|
+
error_message=None,
|
|
269
|
+
missing=True,
|
|
270
|
+
call_event_id=request.id,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _error_from_payload(output: JsonValue) -> tuple[str | None, str | None]:
|
|
275
|
+
if not isinstance(output, dict):
|
|
276
|
+
return None, None
|
|
277
|
+
error_type = output.get("error_type")
|
|
278
|
+
message = output.get("message")
|
|
279
|
+
return (
|
|
280
|
+
error_type if isinstance(error_type, str) else None,
|
|
281
|
+
message if isinstance(message, str) else None,
|
|
282
|
+
)
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Deterministic frozen replay of recorded dependency boundaries.
|
|
2
|
+
|
|
3
|
+
Replay never iterates over events and prints them. It executes the agent's
|
|
4
|
+
local control flow and intercepts instrumented dependency boundaries
|
|
5
|
+
(``@trace_tool`` functions and ``llm_request`` calls), substituting captured
|
|
6
|
+
outputs while verifying that the live call sequence matches the recording.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from contextvars import ContextVar, Token
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import TracebackType
|
|
15
|
+
from typing import Literal, Self
|
|
16
|
+
|
|
17
|
+
from stepfork.replay.exceptions import (
|
|
18
|
+
RecordedDependencyError,
|
|
19
|
+
ReplayError,
|
|
20
|
+
ReplayExhaustedError,
|
|
21
|
+
ReplayMismatchError,
|
|
22
|
+
ReplayPolicyError,
|
|
23
|
+
)
|
|
24
|
+
from stepfork.replay.plan import RecordedCall, extract_recorded_calls
|
|
25
|
+
from stepfork.trace.canonical import canonical_json_bytes
|
|
26
|
+
from stepfork.trace.models import EventStatus, JsonValue, Trace
|
|
27
|
+
from stepfork.trace.redaction import redact_json
|
|
28
|
+
from stepfork.trace.replay_policy import ReplayPolicy
|
|
29
|
+
|
|
30
|
+
PREVIEW_LIMIT = 200
|
|
31
|
+
|
|
32
|
+
_current_replay: ContextVar[ReplaySession | None] = ContextVar(
|
|
33
|
+
"stepfork_replay",
|
|
34
|
+
default=None,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def active_replay() -> ReplaySession | None:
|
|
39
|
+
"""Return the replay session active in the current context, if any."""
|
|
40
|
+
return _current_replay.get()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class MatchedCall:
|
|
45
|
+
"""One recorded dependency call matched during replay."""
|
|
46
|
+
|
|
47
|
+
index: int
|
|
48
|
+
kind: Literal["tool", "llm"]
|
|
49
|
+
label: str
|
|
50
|
+
action: Literal["substituted", "executed"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ReplayDecision:
|
|
55
|
+
"""How the caller should handle the current dependency call."""
|
|
56
|
+
|
|
57
|
+
execute: bool
|
|
58
|
+
value: JsonValue | None
|
|
59
|
+
matched: MatchedCall
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ReplaySummary:
|
|
64
|
+
"""Structured replay outcome for reporting."""
|
|
65
|
+
|
|
66
|
+
mode: str
|
|
67
|
+
matched: tuple[MatchedCall, ...]
|
|
68
|
+
remaining: tuple[str, ...]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ReplaySession:
|
|
72
|
+
"""Replay a recorded trace by intercepting dependency boundaries.
|
|
73
|
+
|
|
74
|
+
Use as a context manager so the session is installed in the current
|
|
75
|
+
context::
|
|
76
|
+
|
|
77
|
+
with ReplaySession.from_trace("failure.sftrace", mode="frozen") as replay:
|
|
78
|
+
result = run_agent()
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, trace: Trace, *, mode: ReplayPolicy | str) -> None:
|
|
82
|
+
self.trace = trace
|
|
83
|
+
self.mode = ReplayPolicy(mode)
|
|
84
|
+
self.calls: list[RecordedCall] = extract_recorded_calls(trace)
|
|
85
|
+
self._cursor = 0
|
|
86
|
+
self._matched: list[MatchedCall] = []
|
|
87
|
+
self._token: Token[ReplaySession | None] | None = None
|
|
88
|
+
self._entered = False
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_trace(
|
|
92
|
+
cls,
|
|
93
|
+
path: str | Path | Trace,
|
|
94
|
+
*,
|
|
95
|
+
mode: ReplayPolicy | str = ReplayPolicy.FROZEN,
|
|
96
|
+
) -> ReplaySession:
|
|
97
|
+
"""Build a replay session from a `.sftrace` bundle or loaded trace."""
|
|
98
|
+
trace = path if isinstance(path, Trace) else Trace.load(Path(path))
|
|
99
|
+
return cls(trace, mode=mode)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def matched(self) -> list[MatchedCall]:
|
|
103
|
+
"""Dependency calls matched so far, in call order."""
|
|
104
|
+
return list(self._matched)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def pending(self) -> int:
|
|
108
|
+
"""Number of recorded dependency calls not yet consumed."""
|
|
109
|
+
return len(self.calls) - self._cursor
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def summary(self) -> ReplaySummary:
|
|
113
|
+
"""Structured summary of the replay session state."""
|
|
114
|
+
return ReplaySummary(
|
|
115
|
+
mode=self.mode.value,
|
|
116
|
+
matched=tuple(self._matched),
|
|
117
|
+
remaining=tuple(call.label for call in self.calls[self._cursor :]),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def __enter__(self) -> Self:
|
|
121
|
+
if self._entered:
|
|
122
|
+
raise ReplayError("ReplaySession cannot be re-entered")
|
|
123
|
+
self._entered = True
|
|
124
|
+
self._token = _current_replay.set(self)
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def __exit__(
|
|
128
|
+
self,
|
|
129
|
+
exc_type: type[BaseException] | None,
|
|
130
|
+
exc: BaseException | None,
|
|
131
|
+
tb: TracebackType | None,
|
|
132
|
+
) -> Literal[False]:
|
|
133
|
+
if self._token is not None:
|
|
134
|
+
_current_replay.reset(self._token)
|
|
135
|
+
self._token = None
|
|
136
|
+
self._entered = False
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def before_tool(
|
|
140
|
+
self,
|
|
141
|
+
*,
|
|
142
|
+
name: str,
|
|
143
|
+
input: JsonValue,
|
|
144
|
+
) -> ReplayDecision:
|
|
145
|
+
"""Match and plan a tool call against the recorded sequence."""
|
|
146
|
+
return self._dispatch("tool", name=name, model=None, provider=None, input=input)
|
|
147
|
+
|
|
148
|
+
def before_llm(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
provider: str | None,
|
|
152
|
+
model: str,
|
|
153
|
+
input: JsonValue,
|
|
154
|
+
) -> ReplayDecision:
|
|
155
|
+
"""Match and plan an LLM call against the recorded sequence."""
|
|
156
|
+
return self._dispatch(
|
|
157
|
+
"llm",
|
|
158
|
+
name=None,
|
|
159
|
+
model=model,
|
|
160
|
+
provider=provider,
|
|
161
|
+
input=input,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def verify_complete(self) -> None:
|
|
165
|
+
"""Raise if recorded dependency calls remain unconsumed."""
|
|
166
|
+
remaining = self.calls[self._cursor :]
|
|
167
|
+
if not remaining:
|
|
168
|
+
return
|
|
169
|
+
labels = ", ".join(
|
|
170
|
+
f"#{call.index + 1} {call.kind} {call.label}" for call in remaining[:5]
|
|
171
|
+
)
|
|
172
|
+
suffix = "" if len(remaining) <= 5 else f" (+{len(remaining) - 5} more)"
|
|
173
|
+
raise ReplayMismatchError(
|
|
174
|
+
f"replay ended before consuming {len(remaining)} recorded "
|
|
175
|
+
f"dependency call(s): {labels}{suffix}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _dispatch(
|
|
179
|
+
self,
|
|
180
|
+
kind: Literal["tool", "llm"],
|
|
181
|
+
*,
|
|
182
|
+
name: str | None,
|
|
183
|
+
model: str | None,
|
|
184
|
+
provider: str | None,
|
|
185
|
+
input: JsonValue,
|
|
186
|
+
) -> ReplayDecision:
|
|
187
|
+
label = name if kind == "tool" else (model or "unknown-model")
|
|
188
|
+
|
|
189
|
+
if self.mode is ReplayPolicy.FORBIDDEN:
|
|
190
|
+
raise ReplayPolicyError(
|
|
191
|
+
f"replay mode 'forbidden' rejects the {kind} call {label!r}"
|
|
192
|
+
)
|
|
193
|
+
if self.mode is ReplayPolicy.MANUAL:
|
|
194
|
+
raise ReplayPolicyError(
|
|
195
|
+
f"replay mode 'manual' requires approval before handling the "
|
|
196
|
+
f"{kind} call {label!r}"
|
|
197
|
+
)
|
|
198
|
+
if self.mode is ReplayPolicy.DERIVED:
|
|
199
|
+
raise ReplayPolicyError("replay mode 'derived' is not supported in v0.1")
|
|
200
|
+
if not self._entered:
|
|
201
|
+
raise ReplayError(
|
|
202
|
+
"ReplaySession is not active; enter it with "
|
|
203
|
+
"'with ReplaySession.from_trace(...)' before running the agent"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if self._cursor >= len(self.calls):
|
|
207
|
+
raise ReplayExhaustedError(
|
|
208
|
+
f"unexpected {kind} call {label!r}: all "
|
|
209
|
+
f"{len(self.calls)} recorded dependency call(s) were already "
|
|
210
|
+
"consumed"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
position = self._cursor
|
|
214
|
+
expected = self.calls[position]
|
|
215
|
+
self._match_identity(
|
|
216
|
+
expected,
|
|
217
|
+
kind=kind,
|
|
218
|
+
name=name,
|
|
219
|
+
model=model,
|
|
220
|
+
provider=provider,
|
|
221
|
+
position=position,
|
|
222
|
+
)
|
|
223
|
+
self._match_input(expected, input=input, position=position)
|
|
224
|
+
|
|
225
|
+
if expected.policy is ReplayPolicy.FORBIDDEN:
|
|
226
|
+
raise ReplayPolicyError(
|
|
227
|
+
f"recorded {kind} call {expected.label!r} forbids automatic replay"
|
|
228
|
+
)
|
|
229
|
+
if expected.policy is ReplayPolicy.MANUAL:
|
|
230
|
+
raise ReplayPolicyError(
|
|
231
|
+
f"recorded {kind} call {expected.label!r} requires manual "
|
|
232
|
+
"approval before replay"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if expected.missing:
|
|
236
|
+
raise ReplayMismatchError(
|
|
237
|
+
f"no captured result for recorded {kind} call "
|
|
238
|
+
f"{expected.label!r} at position {position + 1}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
self._cursor = position + 1
|
|
242
|
+
matched = MatchedCall(
|
|
243
|
+
index=position,
|
|
244
|
+
kind=kind,
|
|
245
|
+
label=expected.label,
|
|
246
|
+
action="substituted" if self.mode is ReplayPolicy.FROZEN else "executed",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if self.mode is ReplayPolicy.LIVE:
|
|
250
|
+
self._matched.append(matched)
|
|
251
|
+
return ReplayDecision(execute=True, value=None, matched=matched)
|
|
252
|
+
|
|
253
|
+
if expected.status is EventStatus.ERROR:
|
|
254
|
+
self._matched.append(matched)
|
|
255
|
+
raise RecordedDependencyError(
|
|
256
|
+
kind=kind,
|
|
257
|
+
name=expected.label,
|
|
258
|
+
error_type=expected.error_type or "DependencyError",
|
|
259
|
+
message=expected.error_message or "recorded dependency failure",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
self._matched.append(matched)
|
|
263
|
+
return ReplayDecision(
|
|
264
|
+
execute=False,
|
|
265
|
+
value=expected.output,
|
|
266
|
+
matched=matched,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def _match_identity(
|
|
270
|
+
self,
|
|
271
|
+
expected: RecordedCall,
|
|
272
|
+
*,
|
|
273
|
+
kind: Literal["tool", "llm"],
|
|
274
|
+
name: str | None,
|
|
275
|
+
model: str | None,
|
|
276
|
+
provider: str | None,
|
|
277
|
+
position: int,
|
|
278
|
+
) -> None:
|
|
279
|
+
position_label = position + 1
|
|
280
|
+
if expected.kind != kind:
|
|
281
|
+
raise ReplayMismatchError(
|
|
282
|
+
f"call #{position_label}: expected {expected.kind} "
|
|
283
|
+
f"{expected.label!r} but the agent made a {kind} call "
|
|
284
|
+
f"{name if kind == 'tool' else model!r}"
|
|
285
|
+
)
|
|
286
|
+
if kind == "tool" and name != expected.name:
|
|
287
|
+
raise ReplayMismatchError(
|
|
288
|
+
f"call #{position_label}: expected tool {expected.label!r} "
|
|
289
|
+
f"but the agent called {name!r}"
|
|
290
|
+
)
|
|
291
|
+
if kind == "llm":
|
|
292
|
+
if model != expected.model:
|
|
293
|
+
raise ReplayMismatchError(
|
|
294
|
+
f"call #{position_label}: expected LLM model "
|
|
295
|
+
f"{expected.model!r} but the agent used {model!r}"
|
|
296
|
+
)
|
|
297
|
+
if (
|
|
298
|
+
expected.provider is not None
|
|
299
|
+
and provider is not None
|
|
300
|
+
and provider != expected.provider
|
|
301
|
+
):
|
|
302
|
+
raise ReplayMismatchError(
|
|
303
|
+
f"call #{position_label}: expected provider "
|
|
304
|
+
f"{expected.provider!r} but the agent used {provider!r}"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def _match_input(
|
|
308
|
+
self,
|
|
309
|
+
expected: RecordedCall,
|
|
310
|
+
*,
|
|
311
|
+
input: JsonValue,
|
|
312
|
+
position: int,
|
|
313
|
+
) -> None:
|
|
314
|
+
sanitized = redact_json(input).value
|
|
315
|
+
try:
|
|
316
|
+
matches = canonical_json_bytes(sanitized) == canonical_json_bytes(
|
|
317
|
+
expected.input
|
|
318
|
+
)
|
|
319
|
+
except TypeError:
|
|
320
|
+
matches = False
|
|
321
|
+
if matches:
|
|
322
|
+
return
|
|
323
|
+
raise ReplayMismatchError(
|
|
324
|
+
f"call #{position + 1}: input diverged from the recording\n"
|
|
325
|
+
f" recorded: {_preview(expected.input)}\n"
|
|
326
|
+
f" actual: {_preview(sanitized)}"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _preview(value: JsonValue) -> str:
|
|
331
|
+
"""Return a short, sanitized, single-line preview of a JSON value."""
|
|
332
|
+
try:
|
|
333
|
+
text = canonical_json_bytes(value).decode("utf-8", errors="replace")
|
|
334
|
+
except Exception:
|
|
335
|
+
return "<unserializable>"
|
|
336
|
+
if len(text) > PREVIEW_LIMIT:
|
|
337
|
+
return f"{text[: PREVIEW_LIMIT - 1]}…"
|
|
338
|
+
return text
|