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,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