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,500 @@
|
|
|
1
|
+
"""Recording sessions that capture agent executions as `.sftrace` traces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from contextvars import ContextVar, Token
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Any, Literal, TypeVar
|
|
14
|
+
|
|
15
|
+
from stepfork.replay.session import active_replay
|
|
16
|
+
from stepfork.trace import (
|
|
17
|
+
ErrorEvent,
|
|
18
|
+
EventBase,
|
|
19
|
+
EventStatus,
|
|
20
|
+
EventType,
|
|
21
|
+
FailureInfo,
|
|
22
|
+
JsonValue,
|
|
23
|
+
LLMRequest,
|
|
24
|
+
LLMResponse,
|
|
25
|
+
ReplayPolicy,
|
|
26
|
+
RunEnd,
|
|
27
|
+
RunStart,
|
|
28
|
+
RunStatus,
|
|
29
|
+
StateChange,
|
|
30
|
+
ToolCall,
|
|
31
|
+
ToolResult,
|
|
32
|
+
Trace,
|
|
33
|
+
TraceSerializationError,
|
|
34
|
+
save_trace,
|
|
35
|
+
)
|
|
36
|
+
from stepfork.trace.jsonable import Serializer, to_json_value
|
|
37
|
+
from stepfork.trace.manifest import EnvironmentInfo, TraceTotals
|
|
38
|
+
from stepfork.version import __version__
|
|
39
|
+
|
|
40
|
+
DEFAULT_TRACE_DIR = Path(".stepfork") / "traces"
|
|
41
|
+
|
|
42
|
+
E = TypeVar("E", bound=EventBase)
|
|
43
|
+
|
|
44
|
+
_current_recorder: ContextVar[RecordingSession | None] = ContextVar(
|
|
45
|
+
"stepfork_recording",
|
|
46
|
+
default=None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def active_recorder() -> RecordingSession | None:
|
|
51
|
+
"""Return the recording session active in the current context, if any."""
|
|
52
|
+
return _current_recorder.get()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RecordingSession:
|
|
56
|
+
"""Capture LLM, tool, and state events for one agent run.
|
|
57
|
+
|
|
58
|
+
Enter the session as a context manager; the trace is persisted when the
|
|
59
|
+
block exits and any application exception is always preserved::
|
|
60
|
+
|
|
61
|
+
with record("booking-agent", output="failure.sftrace") as session:
|
|
62
|
+
result = run_agent()
|
|
63
|
+
session.set_output(result)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
agent_name: str,
|
|
69
|
+
*,
|
|
70
|
+
output: str | Path | None = None,
|
|
71
|
+
overwrite: bool = False,
|
|
72
|
+
serializer: Serializer | None = None,
|
|
73
|
+
run_input: Any = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.trace = Trace(
|
|
76
|
+
agent_name=agent_name,
|
|
77
|
+
environment=EnvironmentInfo(
|
|
78
|
+
python=f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
79
|
+
os=sys.platform,
|
|
80
|
+
stepfork_version=__version__,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
self._serializer = serializer
|
|
84
|
+
self._run_input = run_input
|
|
85
|
+
self._output_path = (
|
|
86
|
+
Path(output)
|
|
87
|
+
if output is not None
|
|
88
|
+
else _default_output_path(agent_name, self.trace.run_id)
|
|
89
|
+
)
|
|
90
|
+
self._overwrite = overwrite
|
|
91
|
+
self._stack: list[str] = []
|
|
92
|
+
self._token: Token[RecordingSession | None] | None = None
|
|
93
|
+
self._started = False
|
|
94
|
+
self._finished = False
|
|
95
|
+
self._inert = False
|
|
96
|
+
self._pending_output: JsonValue | None = None
|
|
97
|
+
self._has_output = False
|
|
98
|
+
self.path: Path | None = None
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def agent_name(self) -> str:
|
|
102
|
+
"""Name of the recorded agent."""
|
|
103
|
+
return self.trace.agent_name
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def run_id(self) -> str:
|
|
107
|
+
"""Opaque run identifier for this recording."""
|
|
108
|
+
return self.trace.run_id
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def output_path(self) -> Path:
|
|
112
|
+
"""Where the trace bundle will be (or was) written."""
|
|
113
|
+
return self._output_path
|
|
114
|
+
|
|
115
|
+
def to_json(self, value: Any) -> JsonValue:
|
|
116
|
+
"""Convert a runtime value into a JSON trace payload."""
|
|
117
|
+
return to_json_value(value, serializer=self._serializer)
|
|
118
|
+
|
|
119
|
+
def __enter__(self) -> RecordingSession:
|
|
120
|
+
if self._started:
|
|
121
|
+
raise RuntimeError("RecordingSession cannot be re-entered")
|
|
122
|
+
self._started = True
|
|
123
|
+
token = _current_recorder.set(self)
|
|
124
|
+
try:
|
|
125
|
+
if active_replay() is not None:
|
|
126
|
+
# Frozen replay: agent entrypoints keep their record() blocks.
|
|
127
|
+
# Stay inert so replay never writes a new trace bundle.
|
|
128
|
+
self._inert = True
|
|
129
|
+
self._stack = []
|
|
130
|
+
self._token = token
|
|
131
|
+
return self
|
|
132
|
+
if self._run_input is None:
|
|
133
|
+
start = self.trace.add(RunStart())
|
|
134
|
+
else:
|
|
135
|
+
start = self.trace.add(RunStart(input=self.to_json(self._run_input)))
|
|
136
|
+
except BaseException:
|
|
137
|
+
_current_recorder.reset(token)
|
|
138
|
+
raise
|
|
139
|
+
self._stack = [start.id]
|
|
140
|
+
self._token = token
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
def __exit__(
|
|
144
|
+
self,
|
|
145
|
+
exc_type: type[BaseException] | None,
|
|
146
|
+
exc: BaseException | None,
|
|
147
|
+
tb: TracebackType | None,
|
|
148
|
+
) -> Literal[False]:
|
|
149
|
+
try:
|
|
150
|
+
if self._inert:
|
|
151
|
+
return False
|
|
152
|
+
if not self._finished:
|
|
153
|
+
self._finished = True
|
|
154
|
+
self._finalize(exc)
|
|
155
|
+
self.path = save_trace(
|
|
156
|
+
self.trace,
|
|
157
|
+
self._output_path,
|
|
158
|
+
overwrite=self._overwrite,
|
|
159
|
+
)
|
|
160
|
+
except Exception as save_error:
|
|
161
|
+
if exc is None:
|
|
162
|
+
raise
|
|
163
|
+
exc.add_note(f"stepfork: trace was not saved: {save_error}")
|
|
164
|
+
finally:
|
|
165
|
+
if self._token is not None:
|
|
166
|
+
_current_recorder.reset(self._token)
|
|
167
|
+
self._token = None
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def push_scope(self, event_id: str) -> None:
|
|
171
|
+
"""Make ``event_id`` the parent scope for subsequently recorded events."""
|
|
172
|
+
if self._inert:
|
|
173
|
+
return
|
|
174
|
+
self._stack.append(event_id)
|
|
175
|
+
|
|
176
|
+
def pop_scope(self) -> None:
|
|
177
|
+
"""Leave the current dependency scope."""
|
|
178
|
+
if self._inert:
|
|
179
|
+
return
|
|
180
|
+
if len(self._stack) <= 1:
|
|
181
|
+
raise RuntimeError("cannot leave the root recording scope")
|
|
182
|
+
self._stack.pop()
|
|
183
|
+
|
|
184
|
+
def record_llm_request(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
model: str,
|
|
188
|
+
input: Any,
|
|
189
|
+
provider: str | None = None,
|
|
190
|
+
replay_policy: ReplayPolicy = ReplayPolicy.FROZEN,
|
|
191
|
+
) -> LLMRequest:
|
|
192
|
+
"""Record an outgoing LLM request."""
|
|
193
|
+
event = LLMRequest(
|
|
194
|
+
model=model,
|
|
195
|
+
input=self.to_json(input),
|
|
196
|
+
provider=provider,
|
|
197
|
+
replay_policy=replay_policy,
|
|
198
|
+
)
|
|
199
|
+
return self._append(event)
|
|
200
|
+
|
|
201
|
+
def record_llm_response(
|
|
202
|
+
self,
|
|
203
|
+
request: LLMRequest,
|
|
204
|
+
output: Any,
|
|
205
|
+
*,
|
|
206
|
+
model: str | None = None,
|
|
207
|
+
prompt_tokens: int | None = None,
|
|
208
|
+
completion_tokens: int | None = None,
|
|
209
|
+
total_tokens: int | None = None,
|
|
210
|
+
cost_usd: float | None = None,
|
|
211
|
+
) -> LLMResponse:
|
|
212
|
+
"""Record the LLM response paired with ``request``."""
|
|
213
|
+
event = LLMResponse(
|
|
214
|
+
model=model if model is not None else request.model,
|
|
215
|
+
output=self.to_json(output),
|
|
216
|
+
duration_ms=_elapsed_ms(request.timestamp),
|
|
217
|
+
prompt_tokens=prompt_tokens,
|
|
218
|
+
completion_tokens=completion_tokens,
|
|
219
|
+
total_tokens=total_tokens,
|
|
220
|
+
cost_usd=cost_usd,
|
|
221
|
+
parent_id=request.id,
|
|
222
|
+
)
|
|
223
|
+
return self._append(event)
|
|
224
|
+
|
|
225
|
+
def record_tool_call(
|
|
226
|
+
self,
|
|
227
|
+
*,
|
|
228
|
+
name: str,
|
|
229
|
+
input: Any,
|
|
230
|
+
replay_policy: ReplayPolicy = ReplayPolicy.FROZEN,
|
|
231
|
+
) -> ToolCall:
|
|
232
|
+
"""Record an outgoing tool call."""
|
|
233
|
+
event = ToolCall(
|
|
234
|
+
name=name,
|
|
235
|
+
input=self.to_json(input),
|
|
236
|
+
replay_policy=replay_policy,
|
|
237
|
+
)
|
|
238
|
+
return self._append(event)
|
|
239
|
+
|
|
240
|
+
def record_tool_result(
|
|
241
|
+
self,
|
|
242
|
+
call: ToolCall,
|
|
243
|
+
output: Any,
|
|
244
|
+
*,
|
|
245
|
+
status: EventStatus = EventStatus.OK,
|
|
246
|
+
) -> ToolResult:
|
|
247
|
+
"""Record the result paired with ``call``."""
|
|
248
|
+
event = ToolResult(
|
|
249
|
+
name=call.name,
|
|
250
|
+
output=self.to_json(output),
|
|
251
|
+
duration_ms=_elapsed_ms(call.timestamp),
|
|
252
|
+
status=status,
|
|
253
|
+
parent_id=call.id,
|
|
254
|
+
)
|
|
255
|
+
return self._append(event)
|
|
256
|
+
|
|
257
|
+
def record_tool_failure(self, call: ToolCall, exc: BaseException) -> ToolResult:
|
|
258
|
+
"""Record a failed tool result and a linked error event for ``exc``."""
|
|
259
|
+
error_type = type(exc).__name__
|
|
260
|
+
message = str(exc) or error_type
|
|
261
|
+
result = self._append(
|
|
262
|
+
ToolResult(
|
|
263
|
+
name=call.name,
|
|
264
|
+
output={"error_type": error_type, "message": message},
|
|
265
|
+
duration_ms=_elapsed_ms(call.timestamp),
|
|
266
|
+
status=EventStatus.ERROR,
|
|
267
|
+
parent_id=call.id,
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
self._append(
|
|
271
|
+
ErrorEvent(
|
|
272
|
+
error_type=error_type,
|
|
273
|
+
message=message,
|
|
274
|
+
parent_id=result.id,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
def record_llm_failure(self, request: LLMRequest, exc: BaseException) -> ErrorEvent:
|
|
280
|
+
"""Record a failed LLM call linked to ``request``."""
|
|
281
|
+
error_type = type(exc).__name__
|
|
282
|
+
message = str(exc) or error_type
|
|
283
|
+
return self._append(
|
|
284
|
+
ErrorEvent(
|
|
285
|
+
error_type=error_type,
|
|
286
|
+
message=message,
|
|
287
|
+
parent_id=request.id,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def record_state_change(
|
|
292
|
+
self,
|
|
293
|
+
*,
|
|
294
|
+
key: str | None = None,
|
|
295
|
+
before: Any = None,
|
|
296
|
+
after: Any = None,
|
|
297
|
+
) -> StateChange:
|
|
298
|
+
"""Record a change to agent state."""
|
|
299
|
+
event = StateChange(
|
|
300
|
+
key=key,
|
|
301
|
+
before=self.to_json(before),
|
|
302
|
+
after=self.to_json(after),
|
|
303
|
+
)
|
|
304
|
+
return self._append(event)
|
|
305
|
+
|
|
306
|
+
def record_error(
|
|
307
|
+
self,
|
|
308
|
+
error_type: str,
|
|
309
|
+
message: str,
|
|
310
|
+
*,
|
|
311
|
+
details: Any = None,
|
|
312
|
+
parent_id: str | None = None,
|
|
313
|
+
) -> ErrorEvent:
|
|
314
|
+
"""Record an error event in the current scope."""
|
|
315
|
+
event = ErrorEvent(
|
|
316
|
+
error_type=error_type,
|
|
317
|
+
message=message or error_type,
|
|
318
|
+
details=self.to_json(details) if details is not None else None,
|
|
319
|
+
parent_id=parent_id,
|
|
320
|
+
)
|
|
321
|
+
return self._append(event)
|
|
322
|
+
|
|
323
|
+
def set_output(self, value: Any) -> None:
|
|
324
|
+
"""Set the final run output recorded in ``run_end``."""
|
|
325
|
+
self._pending_output = self.to_json(value)
|
|
326
|
+
self._has_output = True
|
|
327
|
+
|
|
328
|
+
@contextmanager
|
|
329
|
+
def llm_call(
|
|
330
|
+
self,
|
|
331
|
+
*,
|
|
332
|
+
model: str,
|
|
333
|
+
input: Any,
|
|
334
|
+
provider: str | None = None,
|
|
335
|
+
replay_policy: ReplayPolicy = ReplayPolicy.FROZEN,
|
|
336
|
+
) -> Iterator[LLMCallHandle]:
|
|
337
|
+
"""Manually record an LLM call around a ``with`` body.
|
|
338
|
+
|
|
339
|
+
This API records during :func:`record` sessions. Replayable agents
|
|
340
|
+
should prefer :func:`stepfork.llm_request`, which substitutes captured
|
|
341
|
+
responses during frozen replay instead of executing the body.
|
|
342
|
+
"""
|
|
343
|
+
request = self.record_llm_request(
|
|
344
|
+
model=model,
|
|
345
|
+
input=input,
|
|
346
|
+
provider=provider,
|
|
347
|
+
replay_policy=replay_policy,
|
|
348
|
+
)
|
|
349
|
+
handle = LLMCallHandle(request)
|
|
350
|
+
self.push_scope(request.id)
|
|
351
|
+
try:
|
|
352
|
+
yield handle
|
|
353
|
+
except BaseException as exc:
|
|
354
|
+
self.record_llm_failure(request, exc)
|
|
355
|
+
raise
|
|
356
|
+
finally:
|
|
357
|
+
self.pop_scope()
|
|
358
|
+
self.record_llm_response(
|
|
359
|
+
request,
|
|
360
|
+
handle.output,
|
|
361
|
+
model=handle.model,
|
|
362
|
+
prompt_tokens=handle.prompt_tokens,
|
|
363
|
+
completion_tokens=handle.completion_tokens,
|
|
364
|
+
total_tokens=handle.total_tokens,
|
|
365
|
+
cost_usd=handle.cost_usd,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def _append(self, event: E) -> E:
|
|
369
|
+
if event.parent_id is None and self._stack:
|
|
370
|
+
event = event.model_copy(update={"parent_id": self._stack[-1]})
|
|
371
|
+
return self.trace.add(event)
|
|
372
|
+
|
|
373
|
+
def _finalize(self, exc: BaseException | None) -> None:
|
|
374
|
+
output: JsonValue | None = None
|
|
375
|
+
failure: FailureInfo | None = None
|
|
376
|
+
|
|
377
|
+
if exc is not None:
|
|
378
|
+
error_type = type(exc).__name__
|
|
379
|
+
message = str(exc) or error_type
|
|
380
|
+
error_event = self.record_error(error_type, message)
|
|
381
|
+
failure = FailureInfo(
|
|
382
|
+
type=error_type,
|
|
383
|
+
message=message,
|
|
384
|
+
step=error_event.step,
|
|
385
|
+
)
|
|
386
|
+
status = RunStatus.FAILED
|
|
387
|
+
else:
|
|
388
|
+
status = RunStatus.COMPLETED
|
|
389
|
+
if self._has_output:
|
|
390
|
+
output = self._pending_output
|
|
391
|
+
|
|
392
|
+
run_end = self.trace.add(RunEnd(run_status=status, output=output))
|
|
393
|
+
duration_ms = max(
|
|
394
|
+
0,
|
|
395
|
+
int((run_end.timestamp - self.trace.created_at).total_seconds() * 1000),
|
|
396
|
+
)
|
|
397
|
+
self.trace.totals = TraceTotals(
|
|
398
|
+
events=len(self.trace.events),
|
|
399
|
+
llm_calls=sum(
|
|
400
|
+
event.type.value == "llm_request" for event in self.trace.events
|
|
401
|
+
),
|
|
402
|
+
tool_calls=sum(
|
|
403
|
+
event.type.value == "tool_call" for event in self.trace.events
|
|
404
|
+
),
|
|
405
|
+
cost_usd=_total_cost(self.trace),
|
|
406
|
+
duration_ms=duration_ms,
|
|
407
|
+
)
|
|
408
|
+
self.trace.status = status
|
|
409
|
+
self.trace.failure = failure
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class LLMCallHandle:
|
|
413
|
+
"""Mutable handle yielded by :meth:`RecordingSession.llm_call`."""
|
|
414
|
+
|
|
415
|
+
def __init__(self, request: LLMRequest) -> None:
|
|
416
|
+
self.request = request
|
|
417
|
+
self.model: str | None = request.model
|
|
418
|
+
self.output: Any = None
|
|
419
|
+
self.prompt_tokens: int | None = None
|
|
420
|
+
self.completion_tokens: int | None = None
|
|
421
|
+
self.total_tokens: int | None = None
|
|
422
|
+
self.cost_usd: float | None = None
|
|
423
|
+
|
|
424
|
+
def set_output(
|
|
425
|
+
self,
|
|
426
|
+
value: Any,
|
|
427
|
+
*,
|
|
428
|
+
model: str | None = None,
|
|
429
|
+
prompt_tokens: int | None = None,
|
|
430
|
+
completion_tokens: int | None = None,
|
|
431
|
+
total_tokens: int | None = None,
|
|
432
|
+
cost_usd: float | None = None,
|
|
433
|
+
) -> None:
|
|
434
|
+
"""Attach the response payload and optional usage metadata."""
|
|
435
|
+
self.output = value
|
|
436
|
+
if model is not None:
|
|
437
|
+
self.model = model
|
|
438
|
+
self.prompt_tokens = prompt_tokens
|
|
439
|
+
self.completion_tokens = completion_tokens
|
|
440
|
+
self.total_tokens = total_tokens
|
|
441
|
+
self.cost_usd = cost_usd
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def record(
|
|
445
|
+
agent_name: str,
|
|
446
|
+
*,
|
|
447
|
+
output: str | Path | None = None,
|
|
448
|
+
overwrite: bool = False,
|
|
449
|
+
serializer: Serializer | None = None,
|
|
450
|
+
run_input: Any = None,
|
|
451
|
+
) -> RecordingSession:
|
|
452
|
+
"""Create a recording session for one agent run.
|
|
453
|
+
|
|
454
|
+
When ``output`` is omitted the trace is written under
|
|
455
|
+
``.stepfork/traces/``. The session persists the trace on exit, including
|
|
456
|
+
when the recorded block raises, and always re-raises the original error.
|
|
457
|
+
|
|
458
|
+
Inside an active replay the session is inert: the block runs, but no
|
|
459
|
+
trace is recorded or written, so replaying an entrypoint never creates
|
|
460
|
+
new bundles.
|
|
461
|
+
"""
|
|
462
|
+
return RecordingSession(
|
|
463
|
+
agent_name,
|
|
464
|
+
output=output,
|
|
465
|
+
overwrite=overwrite,
|
|
466
|
+
serializer=serializer,
|
|
467
|
+
run_input=run_input,
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _elapsed_ms(since: datetime) -> int:
|
|
472
|
+
return max(0, int((datetime.now(UTC) - since).total_seconds() * 1000))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _total_cost(trace: Trace) -> float | None:
|
|
476
|
+
costs = [
|
|
477
|
+
event.cost_usd
|
|
478
|
+
for event in trace.events
|
|
479
|
+
if event.type is EventType.LLM_RESPONSE
|
|
480
|
+
and isinstance(event, LLMResponse)
|
|
481
|
+
and event.cost_usd is not None
|
|
482
|
+
]
|
|
483
|
+
if not costs:
|
|
484
|
+
return None
|
|
485
|
+
return sum(costs)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _default_output_path(agent_name: str, run_id: str) -> Path:
|
|
489
|
+
safe_agent = re.sub(r"[^A-Za-z0-9._-]+", "-", agent_name).strip("-") or "agent"
|
|
490
|
+
return DEFAULT_TRACE_DIR / f"{safe_agent}-{run_id}.sftrace"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
__all__ = [
|
|
494
|
+
"DEFAULT_TRACE_DIR",
|
|
495
|
+
"LLMCallHandle",
|
|
496
|
+
"RecordingSession",
|
|
497
|
+
"TraceSerializationError",
|
|
498
|
+
"active_recorder",
|
|
499
|
+
"record",
|
|
500
|
+
]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""`@trace_tool`: capture tool boundaries during recording and replay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from typing import Any, ParamSpec, TypeVar, cast, overload
|
|
9
|
+
|
|
10
|
+
from stepfork.recorder.session import RecordingSession, active_recorder
|
|
11
|
+
from stepfork.replay.session import ReplayDecision, active_replay
|
|
12
|
+
from stepfork.trace import JsonValue, ReplayPolicy, ToolCall
|
|
13
|
+
from stepfork.trace.jsonable import Serializer, to_json_value
|
|
14
|
+
|
|
15
|
+
P = ParamSpec("P")
|
|
16
|
+
R = TypeVar("R")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@overload
|
|
20
|
+
def trace_tool(func: Callable[P, R], /) -> Callable[P, R]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@overload
|
|
24
|
+
def trace_tool(
|
|
25
|
+
func: None = None,
|
|
26
|
+
*,
|
|
27
|
+
name: str | None = None,
|
|
28
|
+
replay_policy: ReplayPolicy = ReplayPolicy.FROZEN,
|
|
29
|
+
serializer: Serializer | None = None,
|
|
30
|
+
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def trace_tool(
|
|
34
|
+
func: Callable[P, R] | None = None,
|
|
35
|
+
*,
|
|
36
|
+
name: str | None = None,
|
|
37
|
+
replay_policy: ReplayPolicy = ReplayPolicy.FROZEN,
|
|
38
|
+
serializer: Serializer | None = None,
|
|
39
|
+
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
|
|
40
|
+
"""Record a tool call and its result inside an active recording.
|
|
41
|
+
|
|
42
|
+
Outside a recording (and outside replay) the decorated function runs
|
|
43
|
+
unchanged. During frozen replay the function body is not executed and the
|
|
44
|
+
recorded result is returned instead.
|
|
45
|
+
|
|
46
|
+
Supported forms::
|
|
47
|
+
|
|
48
|
+
@trace_tool
|
|
49
|
+
def search(destination: str) -> dict: ...
|
|
50
|
+
|
|
51
|
+
@trace_tool(name="flight_search")
|
|
52
|
+
def search(destination: str) -> dict: ...
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def decorate(target: Callable[P, R]) -> Callable[P, R]:
|
|
56
|
+
tool_name = name if name is not None else target.__name__
|
|
57
|
+
if inspect.iscoroutinefunction(target):
|
|
58
|
+
|
|
59
|
+
async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
|
|
60
|
+
run = _ToolRun(
|
|
61
|
+
target=target,
|
|
62
|
+
tool_name=tool_name,
|
|
63
|
+
args=args,
|
|
64
|
+
kwargs=kwargs,
|
|
65
|
+
replay_policy=replay_policy,
|
|
66
|
+
serializer=serializer,
|
|
67
|
+
)
|
|
68
|
+
if run.passthrough:
|
|
69
|
+
return await cast(Awaitable[Any], target(*args, **kwargs))
|
|
70
|
+
if run.substituted:
|
|
71
|
+
return run.substitute()
|
|
72
|
+
try:
|
|
73
|
+
result = await cast(Awaitable[Any], target(*args, **kwargs))
|
|
74
|
+
except BaseException as exc:
|
|
75
|
+
run.record_failure(exc)
|
|
76
|
+
raise
|
|
77
|
+
else:
|
|
78
|
+
run.record_success(result)
|
|
79
|
+
return result
|
|
80
|
+
finally:
|
|
81
|
+
run.close()
|
|
82
|
+
|
|
83
|
+
return cast(
|
|
84
|
+
Callable[P, R],
|
|
85
|
+
functools.wraps(target)(async_wrapper),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
89
|
+
run = _ToolRun(
|
|
90
|
+
target=target,
|
|
91
|
+
tool_name=tool_name,
|
|
92
|
+
args=args,
|
|
93
|
+
kwargs=kwargs,
|
|
94
|
+
replay_policy=replay_policy,
|
|
95
|
+
serializer=serializer,
|
|
96
|
+
)
|
|
97
|
+
if run.passthrough:
|
|
98
|
+
return target(*args, **kwargs)
|
|
99
|
+
if run.substituted:
|
|
100
|
+
return cast(R, run.substitute())
|
|
101
|
+
try:
|
|
102
|
+
result = target(*args, **kwargs)
|
|
103
|
+
except BaseException as exc:
|
|
104
|
+
run.record_failure(exc)
|
|
105
|
+
raise
|
|
106
|
+
else:
|
|
107
|
+
run.record_success(result)
|
|
108
|
+
return result
|
|
109
|
+
finally:
|
|
110
|
+
run.close()
|
|
111
|
+
|
|
112
|
+
return functools.wraps(target)(wrapper)
|
|
113
|
+
|
|
114
|
+
if func is None:
|
|
115
|
+
return decorate
|
|
116
|
+
return decorate(func)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _ToolRun:
|
|
120
|
+
"""One instrumented tool invocation shared by sync and async wrappers."""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
target: Callable[..., Any],
|
|
126
|
+
tool_name: str,
|
|
127
|
+
args: tuple[Any, ...],
|
|
128
|
+
kwargs: dict[str, Any],
|
|
129
|
+
replay_policy: ReplayPolicy,
|
|
130
|
+
serializer: Serializer | None,
|
|
131
|
+
) -> None:
|
|
132
|
+
self.replay = active_replay()
|
|
133
|
+
self.session: RecordingSession | None = None
|
|
134
|
+
self.call: ToolCall | None = None
|
|
135
|
+
self.decision: ReplayDecision | None = None
|
|
136
|
+
self.input_json: JsonValue | None = None
|
|
137
|
+
|
|
138
|
+
if self.replay is None and active_recorder() is None:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
bound = _bind_arguments(target, args, kwargs)
|
|
142
|
+
if self.replay is not None:
|
|
143
|
+
self.input_json = to_json_value(bound, serializer=serializer)
|
|
144
|
+
self.decision = self.replay.before_tool(
|
|
145
|
+
name=tool_name,
|
|
146
|
+
input=self.input_json,
|
|
147
|
+
)
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
session = active_recorder()
|
|
151
|
+
if session is None: # pragma: no cover - defensive
|
|
152
|
+
return
|
|
153
|
+
self.session = session
|
|
154
|
+
self.call = session.record_tool_call(
|
|
155
|
+
name=tool_name,
|
|
156
|
+
input=bound,
|
|
157
|
+
replay_policy=replay_policy,
|
|
158
|
+
)
|
|
159
|
+
session.push_scope(self.call.id)
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def passthrough(self) -> bool:
|
|
163
|
+
"""True when neither recording nor replay is active."""
|
|
164
|
+
return self.replay is None and self.session is None
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def substituted(self) -> bool:
|
|
168
|
+
"""True when replay returns a captured value instead of executing."""
|
|
169
|
+
return self.decision is not None and not self.decision.execute
|
|
170
|
+
|
|
171
|
+
def substitute(self) -> JsonValue | None:
|
|
172
|
+
"""Return the captured output for frozen replay."""
|
|
173
|
+
if self.decision is None: # pragma: no cover - defensive
|
|
174
|
+
raise RuntimeError("no replay decision to substitute")
|
|
175
|
+
return self.decision.value
|
|
176
|
+
|
|
177
|
+
def record_success(self, result: Any) -> None:
|
|
178
|
+
"""Record the successful tool result when a session is active."""
|
|
179
|
+
if self.session is None or self.call is None:
|
|
180
|
+
return
|
|
181
|
+
self.session.record_tool_result(self.call, result)
|
|
182
|
+
|
|
183
|
+
def record_failure(self, exc: BaseException) -> None:
|
|
184
|
+
"""Record the failed tool result when a session is active."""
|
|
185
|
+
if self.session is None or self.call is None:
|
|
186
|
+
return
|
|
187
|
+
self.session.record_tool_failure(self.call, exc)
|
|
188
|
+
|
|
189
|
+
def close(self) -> None:
|
|
190
|
+
"""Leave the recording scope opened for this tool call."""
|
|
191
|
+
if self.session is None or self.call is None:
|
|
192
|
+
return
|
|
193
|
+
self.session.pop_scope()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _bind_arguments(
|
|
197
|
+
target: Callable[..., Any],
|
|
198
|
+
args: tuple[Any, ...],
|
|
199
|
+
kwargs: dict[str, Any],
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
try:
|
|
202
|
+
bound = inspect.signature(target).bind(*args, **kwargs)
|
|
203
|
+
except (TypeError, ValueError):
|
|
204
|
+
return {"args": list(args), "kwargs": dict(kwargs)}
|
|
205
|
+
arguments: dict[str, Any] = dict(bound.arguments)
|
|
206
|
+
arguments.pop("self", None)
|
|
207
|
+
arguments.pop("cls", None)
|
|
208
|
+
return arguments
|