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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- 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()
|