reactifact 0.6.0__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 (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. reactifact-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,164 @@
1
+ """Tool-level fault injection and call recording for `reactifact.testing`.
2
+
3
+ reactifact has no central tool registry — a `Tool` lives in a plain
4
+ `dict[str, Tool]` on whichever `ToolUse`/`ToolUseHITL` produce instance an
5
+ agent wires up (`reactifact/tool_use.py`). To inject a fault (or just record
6
+ calls) for a tool by name, this module scans every agent's `produces` for
7
+ such a dict and wraps the matching `Tool.execute` in place, restoring the
8
+ original object afterward.
9
+
10
+ **Important caveat**: `_ToolLoopBase._run_tool` (`reactifact/tool_use.py`)
11
+ catches any exception raised by `Tool.execute` and turns it into a plain text
12
+ string handed back to the LLM (`"Tool 'x' failed: ..."`) — it never
13
+ propagates out of the produce. So a fault injected via `lab.fail(...)` does
14
+ **not** abort the scenario run: the agent's LLM sees a tool-failure message
15
+ and may retry, give up, or answer anyway, exactly like a real transient tool
16
+ failure. `result.tools.called(name)` will show the failed call (`.error` set)
17
+ while `result.errors.none()` can still legitimately pass — the agent didn't
18
+ crash, it just saw an error and continued. Tools invoked directly from custom
19
+ `Produce` code (outside a `ToolUse` loop) are not affected by this
20
+ swallowing: an injected exception propagates normally there.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Callable, Iterator, Sequence
26
+ from dataclasses import dataclass
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ from reactifact.tools import Tool, ToolOutput
30
+
31
+ if TYPE_CHECKING:
32
+ from reactifact.agents import Agent
33
+
34
+
35
+ @dataclass
36
+ class ToolFault:
37
+ """A queued fault for one tool name.
38
+
39
+ `times=None` (default) raises on every call; `times=N` raises for the
40
+ first `N` calls, then delegates to the real tool — the natural shape for
41
+ testing "fails then recovers on retry" behavior.
42
+ """
43
+
44
+ tool_name: str
45
+ error: BaseException | Callable[[], BaseException]
46
+ times: int | None = None
47
+
48
+
49
+ @dataclass
50
+ class ToolCallRecord:
51
+ """One recorded tool invocation, real or faulted."""
52
+
53
+ tool: str
54
+ args: dict[str, Any]
55
+ output: ToolOutput | None
56
+ error: str | None
57
+
58
+
59
+ class ToolCallRecorder:
60
+ """Collects `ToolCallRecord`s. Installed on every run, fault or not."""
61
+
62
+ def __init__(self) -> None:
63
+ self.calls: list[ToolCallRecord] = []
64
+
65
+ def record(
66
+ self,
67
+ tool: str,
68
+ args: dict[str, Any],
69
+ output: ToolOutput | None,
70
+ error: str | None,
71
+ ) -> None:
72
+ self.calls.append(
73
+ ToolCallRecord(tool=tool, args=args, output=output, error=error)
74
+ )
75
+
76
+
77
+ def _iter_tool_dicts(agents: Sequence[Agent]) -> Iterator[dict[str, Tool]]:
78
+ """Yields every `dict[str, Tool]`-shaped mapping found on any agent produce.
79
+
80
+ Duck-typed (checks `.execute` on the values) rather than importing the
81
+ private `_ToolLoopBase` class, so it keeps working if that internal is
82
+ renamed or restructured.
83
+ """
84
+ for agent in agents:
85
+ for produce_obj in getattr(agent, "produces", None) or []:
86
+ tools = getattr(produce_obj, "tools", None)
87
+ if (
88
+ isinstance(tools, dict)
89
+ and tools
90
+ and all(hasattr(v, "execute") for v in tools.values())
91
+ ):
92
+ yield tools
93
+
94
+
95
+ class _WrappedTool(Tool):
96
+ """Wraps one `Tool` instance: records every call, and raises `fault`'s
97
+ error for its first `fault.times` calls (or forever, if `times=None`)."""
98
+
99
+ def __init__(
100
+ self, inner: Tool, fault: ToolFault | None, recorder: ToolCallRecorder
101
+ ) -> None:
102
+ self.name = inner.name
103
+ self.description = inner.description
104
+ self.destructive = inner.destructive
105
+ self.schema = inner.schema
106
+ self._inner = inner
107
+ self._fault = fault
108
+ self._remaining = fault.times if fault is not None else None
109
+ self._recorder = recorder
110
+
111
+ async def execute(self, args: dict[str, Any]) -> ToolOutput:
112
+ fault = self._fault
113
+ if fault is not None and (self._remaining is None or self._remaining > 0):
114
+ if self._remaining is not None:
115
+ self._remaining -= 1
116
+ err = fault.error() if callable(fault.error) else fault.error
117
+ self._recorder.record(self.name, args, None, str(err))
118
+ raise err
119
+ try:
120
+ output = await self._inner.execute(args)
121
+ except Exception as exc:
122
+ self._recorder.record(self.name, args, None, str(exc))
123
+ raise
124
+ self._recorder.record(self.name, args, output, output.error or None)
125
+ return output
126
+
127
+
128
+ def _wrap(tool: Tool, fault: ToolFault | None, recorder: ToolCallRecorder) -> Tool:
129
+ return _WrappedTool(tool, fault, recorder)
130
+
131
+
132
+ class FaultInstaller:
133
+ """Context manager: wraps matching tools for the duration of one run.
134
+
135
+ Restores the exact original `Tool` objects in `__exit__`, even if the
136
+ wrapped run raises.
137
+ """
138
+
139
+ def __init__(
140
+ self,
141
+ agents: Sequence[Agent],
142
+ faults: list[ToolFault],
143
+ recorder: ToolCallRecorder,
144
+ ) -> None:
145
+ self._agents = agents
146
+ self._faults = {f.tool_name: f for f in faults}
147
+ self._recorder = recorder
148
+ self._originals: list[tuple[dict[str, Tool], str, Tool]] = []
149
+
150
+ def __enter__(self) -> FaultInstaller:
151
+ seen: set[int] = set()
152
+ for tools in _iter_tool_dicts(self._agents):
153
+ if id(tools) in seen:
154
+ continue
155
+ seen.add(id(tools))
156
+ for name, tool in list(tools.items()):
157
+ self._originals.append((tools, name, tool))
158
+ tools[name] = _wrap(tool, self._faults.get(name), self._recorder)
159
+ return self
160
+
161
+ def __exit__(self, *exc: object) -> None:
162
+ for tools, name, original in self._originals:
163
+ tools[name] = original
164
+ self._originals.clear()
@@ -0,0 +1,350 @@
1
+ """`ScenarioLab` — the single entry point for `reactifact.testing`.
2
+
3
+ Ties `fault.py` (tool fault injection + call recording), `mock.py` (fault
4
+ injection for any other resource — the LLM, the embedder, a source),
5
+ `record.py` (record/replay LLM wrapping) and `assertions.py` (chained
6
+ assertions) into one "seed some artifacts, run the agents, assert on what
7
+ happened" call:
8
+
9
+ lab = ScenarioLab([my_agent], resources=lambda: build_resources())
10
+ lab.fail("search", TimeoutError("boom"), times=1)
11
+ lab.fail_resource("llm", ConnectionError("model unreachable"))
12
+ result = await lab.run(Question(text="..."))
13
+ result.artifacts(Answer).exists()
14
+ result.tools.called("search")
15
+ result.errors.none()
16
+
17
+ A fresh `Context`/`Runtime` is built on every `run()` — scenarios never share
18
+ state, so a queued fault or the tool-call recorder can't leak from one `run()`
19
+ into the next.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable, Sequence
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, TypeVar
28
+
29
+ from pydantic import BaseModel
30
+
31
+ from reactifact.agents import Agent
32
+ from reactifact.budget import Budget, RunStats
33
+ from reactifact.context import Context
34
+ from reactifact.resources import RuntimeResources
35
+ from reactifact.runtime import Runtime
36
+ from reactifact.tracing.models import RunTrace
37
+ from reactifact.tracing.tracer import Tracer
38
+
39
+ from .assertions import (
40
+ ArtifactAssertions,
41
+ ErrorAssertions,
42
+ LLMAssertions,
43
+ PathAssertions,
44
+ ToolAssertions,
45
+ )
46
+ from .fault import FaultInstaller, ToolCallRecord, ToolCallRecorder, ToolFault
47
+ from .mock import ResourceFault, ResourceFaultInstaller
48
+ from .record import Mode, wrap_llm
49
+
50
+ T = TypeVar("T", bound=BaseModel)
51
+
52
+
53
+ class _CapturingTracer(Tracer):
54
+ """Captures the single `RunTrace` a scenario's one `arun()` produces."""
55
+
56
+ def __init__(self) -> None:
57
+ super().__init__()
58
+ self.trace: RunTrace | None = None
59
+
60
+ async def on_turn_end(self, trace: RunTrace) -> None:
61
+ self.trace = trace
62
+
63
+
64
+ @dataclass
65
+ class ScenarioResult:
66
+ """Everything a scenario assertion needs, read off one `ScenarioLab.run()`.
67
+
68
+ `context`/`stats` are exposed directly for anything the assertion groups
69
+ don't cover; the properties below are the intended entry points.
70
+ """
71
+
72
+ context: Context
73
+ stats: RunStats | None
74
+ trace: RunTrace | None
75
+ calls: list[ToolCallRecord]
76
+
77
+ def artifacts(self, artifact_type: type[T]) -> ArtifactAssertions[T]:
78
+ return ArtifactAssertions(self.context, artifact_type)
79
+
80
+ @property
81
+ def tools(self) -> ToolAssertions:
82
+ return ToolAssertions(self.calls)
83
+
84
+ @property
85
+ def path(self) -> PathAssertions:
86
+ return PathAssertions(self.trace)
87
+
88
+ @property
89
+ def llm(self) -> LLMAssertions:
90
+ return LLMAssertions(self.trace)
91
+
92
+ @property
93
+ def errors(self) -> ErrorAssertions:
94
+ return ErrorAssertions(self.trace, self.stats)
95
+
96
+
97
+ class ScenarioLab:
98
+ """Runs `agents` against seeded artifacts, once per `run()` call.
99
+
100
+ `mode` controls the LLM behind `resources.llm` (see `reactifact.testing.record`):
101
+ - `"live"` (default): the real provider, unchanged.
102
+ - `"record"`: wraps it in `ReplayLLM(mode="record")`, appending every call
103
+ to `recording_path`.
104
+ - `"replay"`: wraps it in `ReplayLLM(mode="replay")` — no live network
105
+ calls; a call that diverges from the recording raises `ReplayMiss`
106
+ rather than being answered with a guess (§59).
107
+
108
+ `isolate_errors` defaults to `True` here (unlike `Runtime`'s own default)
109
+ so a scenario can assert on a crashed agent via `result.errors` instead of
110
+ the whole `run()` raising; pass `False` to let an unexpected agent
111
+ exception fail the test the ordinary way.
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ agents: list[Agent],
117
+ *,
118
+ resources: RuntimeResources | Callable[[], RuntimeResources] | None = None,
119
+ budget: Budget | None = None,
120
+ max_concurrency: int | None = None,
121
+ isolate_errors: bool = True,
122
+ mode: Mode = "live",
123
+ recording_path: str | Path = "scenario_calls.jsonl",
124
+ ) -> None:
125
+ self._agents = agents
126
+ self._resources = resources
127
+ self._budget = budget
128
+ self._max_concurrency = max_concurrency
129
+ self._isolate_errors = isolate_errors
130
+ self._mode: Mode = mode
131
+ self._recording_path = Path(recording_path)
132
+ self._faults: list[ToolFault] = []
133
+ self._resource_faults: list[ResourceFault] = []
134
+
135
+ def fail(
136
+ self,
137
+ tool_name: str,
138
+ error: BaseException | Callable[[], BaseException],
139
+ *,
140
+ times: int | None = None,
141
+ ) -> None:
142
+ """Queues a fault for the next `run()`.
143
+
144
+ `tool_name` raises `error` (or the result of calling it, if callable —
145
+ useful for a fresh exception instance per call) instead of executing.
146
+ `times=None` (default) faults every call; `times=N` faults the first
147
+ `N` calls, then delegates to the real tool. Queued faults are consumed
148
+ by the next `run()` and don't carry over to the one after it.
149
+ """
150
+ self._faults.append(ToolFault(tool_name, error, times=times))
151
+
152
+ def fail_resource(
153
+ self,
154
+ name: str,
155
+ error: BaseException | Callable[[], BaseException],
156
+ *,
157
+ method: str | None = None,
158
+ times: int | None = None,
159
+ ) -> None:
160
+ """Queues a fault for a named resource — the general-purpose analog
161
+ of `fail()` for anything that isn't a tool: `"llm"`, `"embedder"`, a
162
+ source id (`resources.sources[id]`), or a name set via
163
+ `resources.set(name, ...)`.
164
+
165
+ Wraps the resource in a duck-typed proxy for the next `run()`/
166
+ `.turn()`: `method=None` (default) fails every callable on it;
167
+ naming one method (e.g. `"embed"`, `"search"`) faults only that
168
+ method. `times=None` faults every call; `times=N` faults the first
169
+ `N`, then delegates to the real resource — same shape as `fail()`.
170
+ Raises `ScenarioError` at run time if `name` doesn't match any
171
+ resource, or matches one that's `None` (nothing configured to fail).
172
+ """
173
+ self._resource_faults.append(
174
+ ResourceFault(name, error, method=method, times=times)
175
+ )
176
+
177
+ def _build_resources(self) -> RuntimeResources:
178
+ resources = self._resources() if callable(self._resources) else self._resources
179
+ resources = resources or RuntimeResources()
180
+ resources.llm = wrap_llm(
181
+ resources.llm, mode=self._mode, recording_path=self._recording_path
182
+ )
183
+ return resources
184
+
185
+ async def run(self, *seed: Any, max_iterations: int = 100) -> ScenarioResult:
186
+ """Seeds `seed` artifacts into a fresh `Context`, runs the agents to
187
+ completion (or budget/iteration exhaustion), and returns the result.
188
+ """
189
+ context = Context(resources=self._build_resources())
190
+ for data in seed:
191
+ context.create(data)
192
+ return await _execute_turn(
193
+ agents=self._agents,
194
+ faults=self._take_faults(),
195
+ resource_faults=self._take_resource_faults(),
196
+ budget=self._budget,
197
+ max_concurrency=self._max_concurrency,
198
+ isolate_errors=self._isolate_errors,
199
+ context=context,
200
+ max_iterations=max_iterations,
201
+ )
202
+
203
+ def scenario(self) -> Scenario:
204
+ """Starts a multi-turn scenario: one `Context` reused across `.turn()`
205
+ calls, for flows that need more than one round of user input to reach
206
+ the state under test (e.g. a design pick, then a plan, then an
207
+ approval — each its own turn on the same project). The record/replay
208
+ LLM wrapper is built once here, so its call index persists correctly
209
+ across turns.
210
+ """
211
+ context = Context(resources=self._build_resources())
212
+ return Scenario(
213
+ agents=self._agents,
214
+ budget=self._budget,
215
+ max_concurrency=self._max_concurrency,
216
+ isolate_errors=self._isolate_errors,
217
+ take_faults=self._take_faults,
218
+ take_resource_faults=self._take_resource_faults,
219
+ context=context,
220
+ )
221
+
222
+ def _take_faults(self) -> list[ToolFault]:
223
+ """Pops and clears the queued tool faults — one-shot, for the next turn."""
224
+ faults, self._faults = self._faults, []
225
+ return faults
226
+
227
+ def _take_resource_faults(self) -> list[ResourceFault]:
228
+ """Pops and clears the queued resource faults — one-shot, for the
229
+ next turn."""
230
+ faults, self._resource_faults = self._resource_faults, []
231
+ return faults
232
+
233
+
234
+ async def _execute_turn(
235
+ *,
236
+ agents: Sequence[Agent],
237
+ faults: list[ToolFault],
238
+ resource_faults: list[ResourceFault],
239
+ budget: Budget | None,
240
+ max_concurrency: int | None,
241
+ isolate_errors: bool,
242
+ context: Context,
243
+ max_iterations: int,
244
+ ) -> ScenarioResult:
245
+ """Runs one turn to completion: install faults/recorder, `runtime.arun()`,
246
+ restore the tools/resources, return the result. Shared by
247
+ `ScenarioLab.run()` and `Scenario.turn()` so there is exactly one place
248
+ that builds a `Runtime`.
249
+ """
250
+ recorder = ToolCallRecorder()
251
+ tracer = _CapturingTracer()
252
+ runtime = Runtime(
253
+ context,
254
+ agents=list(agents),
255
+ budget=budget,
256
+ max_concurrency=max_concurrency,
257
+ tracer=tracer,
258
+ isolate_errors=isolate_errors,
259
+ )
260
+ with (
261
+ FaultInstaller(agents, faults, recorder),
262
+ ResourceFaultInstaller(context.resources, resource_faults),
263
+ ):
264
+ await runtime.arun(max_iterations=max_iterations)
265
+
266
+ return ScenarioResult(
267
+ context=context,
268
+ stats=runtime.last_stats,
269
+ trace=tracer.trace,
270
+ calls=recorder.calls,
271
+ )
272
+
273
+
274
+ def _combined_trace(traces: list[RunTrace]) -> RunTrace | None:
275
+ if not traces:
276
+ return None
277
+ return RunTrace(spans=[span for trace in traces for span in trace.spans])
278
+
279
+
280
+ class Scenario:
281
+ """A multi-turn scenario: one `Context`/agent set reused across `.turn()`
282
+ calls (see `ScenarioLab.scenario()`).
283
+
284
+ Each `.turn()` seeds new artifacts onto the *same* context (so a later
285
+ turn sees everything an earlier one produced), runs the agents to
286
+ completion, and returns that turn's own `ScenarioResult` — faults queued
287
+ via `lab.fail(...)` are still one-shot, consumed by the next `.turn()`
288
+ only. `.path`/`.tools`/`.llm`/`.errors` mirror `ScenarioResult`'s, but
289
+ aggregated over every turn run so far, for conversation-wide assertions
290
+ (e.g. "the model was never called across the whole exchange").
291
+ """
292
+
293
+ def __init__(
294
+ self,
295
+ *,
296
+ agents: Sequence[Agent],
297
+ budget: Budget | None,
298
+ max_concurrency: int | None,
299
+ isolate_errors: bool,
300
+ take_faults: Callable[[], list[ToolFault]],
301
+ take_resource_faults: Callable[[], list[ResourceFault]],
302
+ context: Context,
303
+ ) -> None:
304
+ self._agents = agents
305
+ self._budget = budget
306
+ self._max_concurrency = max_concurrency
307
+ self._isolate_errors = isolate_errors
308
+ self._take_faults = take_faults
309
+ self._take_resource_faults = take_resource_faults
310
+ self.context = context
311
+ self.all_traces: list[RunTrace] = []
312
+ self.all_calls: list[ToolCallRecord] = []
313
+
314
+ async def turn(self, *seed: Any, max_iterations: int = 100) -> ScenarioResult:
315
+ for data in seed:
316
+ self.context.create(data)
317
+ result = await _execute_turn(
318
+ agents=self._agents,
319
+ faults=self._take_faults(),
320
+ resource_faults=self._take_resource_faults(),
321
+ budget=self._budget,
322
+ max_concurrency=self._max_concurrency,
323
+ isolate_errors=self._isolate_errors,
324
+ context=self.context,
325
+ max_iterations=max_iterations,
326
+ )
327
+ if result.trace is not None:
328
+ self.all_traces.append(result.trace)
329
+ self.all_calls.extend(result.calls)
330
+ return result
331
+
332
+ @property
333
+ def path(self) -> PathAssertions:
334
+ """Agent path across every turn run so far, in order."""
335
+ return PathAssertions(_combined_trace(self.all_traces))
336
+
337
+ @property
338
+ def tools(self) -> ToolAssertions:
339
+ """Tool calls across every turn run so far."""
340
+ return ToolAssertions(self.all_calls)
341
+
342
+ @property
343
+ def llm(self) -> LLMAssertions:
344
+ """LLM usage across every turn run so far."""
345
+ return LLMAssertions(_combined_trace(self.all_traces))
346
+
347
+ @property
348
+ def errors(self) -> ErrorAssertions:
349
+ """Isolated agent errors across every turn run so far."""
350
+ return ErrorAssertions(_combined_trace(self.all_traces), None)
@@ -0,0 +1,166 @@
1
+ """Generic resource-level fault injection for `reactifact.testing`.
2
+
3
+ `fault.py` covers tools (the only thing reactifact keeps in a named registry).
4
+ Everything else an agent depends on lives on `RuntimeResources` — the LLM,
5
+ the embedder, a named `Source`, or an arbitrary object stashed via
6
+ `resources.set(name, ...)` — each with a *different* shape (`llm.complete`/
7
+ `.stream`, `embedder.embed`, `source.search`/`.asearch`/`.resolve`, or
8
+ whatever an app-specific resource exposes). reactifact never does `isinstance`
9
+ checks against its own provider/source ABCs (see `fault.py`'s own docstring
10
+ — everything is duck-typed), so a single reflection-based proxy that
11
+ intercepts calls by name is a safe stand-in for any of them: this is the
12
+ one general "mock this resource, make it fail, watch the honest-fallback
13
+ path (§59) kick in" primitive, for whatever `resources.llm`/`.embedder`/
14
+ `.sources[...]`/`.get(...)` isn't a tool.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import inspect
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ from .exceptions import ScenarioError
25
+
26
+ if TYPE_CHECKING:
27
+ from reactifact.resources import RuntimeResources
28
+
29
+
30
+ @dataclass
31
+ class ResourceFault:
32
+ """A queued fault for one named resource.
33
+
34
+ `resource` is `"llm"`, `"embedder"`, a source id (`resources.sources
35
+ [id]`), or a name set via `resources.set(name, ...)`. `method=None`
36
+ (default) fails every callable on the resource; naming one (e.g.
37
+ `"embed"`) faults only that method, leaving the rest of the resource
38
+ working normally. `times=None` faults every call; `times=N` faults the
39
+ first `N`, then delegates to the real resource — same shape as
40
+ `fault.ToolFault`.
41
+ """
42
+
43
+ resource: str
44
+ error: BaseException | Callable[[], BaseException]
45
+ method: str | None = None
46
+ times: int | None = None
47
+
48
+
49
+ def _get_resource(resources: RuntimeResources, name: str) -> tuple[Any, bool]:
50
+ """Returns `(value, found)` — `found=False` means no such resource exists
51
+ at all (as opposed to existing but being `None`)."""
52
+ if name == "llm":
53
+ return resources.llm, True
54
+ if name == "embedder":
55
+ return resources.embedder, True
56
+ if name in resources.sources:
57
+ return resources.sources[name], True
58
+ if name in resources.additional:
59
+ return resources.additional[name], True
60
+ return None, False
61
+
62
+
63
+ def _set_resource(resources: RuntimeResources, name: str, value: Any) -> None:
64
+ if name == "llm":
65
+ resources.llm = value
66
+ elif name == "embedder":
67
+ resources.embedder = value
68
+ elif name in resources.sources:
69
+ resources.sources[name] = value
70
+ else:
71
+ resources.set(name, value)
72
+
73
+
74
+ class _FailingProxy:
75
+ """Wraps `inner`, intercepting `fault.method` (or every public callable,
76
+ if `method=None`) to raise `fault.error` instead of delegating, for the
77
+ fault's next `times` calls (or forever). Everything else — attributes,
78
+ other methods — passes straight through to `inner`.
79
+ """
80
+
81
+ def __init__(self, inner: Any, fault: ResourceFault) -> None:
82
+ object.__setattr__(self, "_inner", inner)
83
+ object.__setattr__(self, "_fault", fault)
84
+ object.__setattr__(self, "_remaining", fault.times)
85
+
86
+ def _take_error(self, name: str) -> BaseException | None:
87
+ fault: ResourceFault = object.__getattribute__(self, "_fault")
88
+ if fault.method is not None and name != fault.method:
89
+ return None
90
+ remaining = object.__getattribute__(self, "_remaining")
91
+ if remaining is not None:
92
+ if remaining <= 0:
93
+ return None
94
+ object.__setattr__(self, "_remaining", remaining - 1)
95
+ return fault.error() if callable(fault.error) else fault.error
96
+
97
+ def __getattr__(self, name: str) -> Any:
98
+ attr = getattr(object.__getattribute__(self, "_inner"), name)
99
+ if not callable(attr) or name.startswith("_"):
100
+ return attr
101
+
102
+ if inspect.isasyncgenfunction(attr):
103
+
104
+ async def _failing_agen(*args: Any, **kwargs: Any) -> Any:
105
+ err = self._take_error(name)
106
+ if err is not None:
107
+ raise err
108
+ async for item in attr(*args, **kwargs):
109
+ yield item
110
+
111
+ return _failing_agen
112
+
113
+ if inspect.iscoroutinefunction(attr):
114
+
115
+ async def _failing_coro(*args: Any, **kwargs: Any) -> Any:
116
+ err = self._take_error(name)
117
+ if err is not None:
118
+ raise err
119
+ return await attr(*args, **kwargs)
120
+
121
+ return _failing_coro
122
+
123
+ def _failing_sync(*args: Any, **kwargs: Any) -> Any:
124
+ err = self._take_error(name)
125
+ if err is not None:
126
+ raise err
127
+ return attr(*args, **kwargs)
128
+
129
+ return _failing_sync
130
+
131
+
132
+ class ResourceFaultInstaller:
133
+ """Context manager: wraps the resources named in `faults` for the
134
+ duration of one turn, restoring the originals in `__exit__` even if the
135
+ wrapped run raises.
136
+ """
137
+
138
+ def __init__(
139
+ self, resources: RuntimeResources, faults: list[ResourceFault]
140
+ ) -> None:
141
+ self._resources = resources
142
+ self._faults = {f.resource: f for f in faults}
143
+ self._originals: list[tuple[str, Any]] = []
144
+
145
+ def __enter__(self) -> ResourceFaultInstaller:
146
+ for name, fault in self._faults.items():
147
+ original, found = _get_resource(self._resources, name)
148
+ if not found:
149
+ raise ScenarioError(
150
+ f"fail_resource({name!r}, ...): no such resource — expected "
151
+ '"llm", "embedder", a source id, or a name set via '
152
+ "resources.set(...)"
153
+ )
154
+ if original is None:
155
+ raise ScenarioError(
156
+ f"fail_resource({name!r}, ...): resource is None (not "
157
+ "configured for this scenario) — nothing to fail"
158
+ )
159
+ self._originals.append((name, original))
160
+ _set_resource(self._resources, name, _FailingProxy(original, fault))
161
+ return self
162
+
163
+ def __exit__(self, *exc: object) -> None:
164
+ for name, original in self._originals:
165
+ _set_resource(self._resources, name, original)
166
+ self._originals.clear()