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,245 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import re
7
+ from collections.abc import Callable
8
+ from typing import Generic, Literal, TypeVar
9
+
10
+ from pydantic import BaseModel, ValidationError
11
+
12
+ from .context import Context
13
+ from .providers import LLMRequest, Message
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ TModel = TypeVar("TModel", bound=BaseModel)
18
+
19
+ #: Why `structured_llm` returned None — passed to an `on_error` hook so a
20
+ #: caller can tell "no provider configured" (offline/misconfigured) apart
21
+ #: from "the provider was called and failed" (network/rate-limit/outage) and
22
+ #: "the model replied but not with valid JSON" — all three collapse to the
23
+ #: same `None` return (the honest-fallback contract, §67), but a caller that
24
+ #: needs to alert on real outages can now distinguish them without changing
25
+ #: how it handles the `None`.
26
+ StructuredLLMFailure = Literal["no_provider", "provider_error", "parse_error"]
27
+ OnStructuredError = Callable[[StructuredLLMFailure, BaseException | None], None]
28
+
29
+ SYSTEM_STRUCTURED = (
30
+ "You produce structured output. Reply with a single JSON object only, "
31
+ "no commentary, no code fences."
32
+ )
33
+
34
+
35
+ def _extract_json(text: str) -> str | None:
36
+ """Extracts the first balanced JSON object from the model text.
37
+
38
+ Local models often add text/code fences around JSON — a tolerant
39
+ parser (§67: parsing is not the LLM's job).
40
+ """
41
+ text = text.strip()
42
+ text = re.sub(r"^```(?:json)?", "", text, flags=re.MULTILINE).rstrip("`").strip()
43
+ try:
44
+ start = text.index("{")
45
+ except ValueError:
46
+ return None
47
+ depth = 0
48
+ in_string = False
49
+ escaped = False
50
+ for i in range(start, len(text)):
51
+ ch = text[i]
52
+ if in_string:
53
+ if escaped:
54
+ escaped = False
55
+ elif ch == "\\":
56
+ escaped = True
57
+ elif ch == '"':
58
+ in_string = False
59
+ continue
60
+ if ch == '"':
61
+ in_string = True
62
+ elif ch == "{":
63
+ depth += 1
64
+ elif ch == "}":
65
+ depth -= 1
66
+ if depth == 0:
67
+ return text[start : i + 1]
68
+ return None
69
+
70
+
71
+ def parse_structured(text: str, schema: type[TModel]) -> TModel | None:
72
+ """Tolerant parsing of an LLM response into a pydantic schema."""
73
+ for candidate in (text, _extract_json(text)):
74
+ if not candidate:
75
+ continue
76
+ try:
77
+ return schema.model_validate_json(candidate)
78
+ except ValidationError:
79
+ try:
80
+ return schema.model_validate(json.loads(candidate))
81
+ except (ValueError, ValidationError):
82
+ continue
83
+ return None
84
+
85
+
86
+ async def structured_llm(
87
+ context: Context,
88
+ *,
89
+ schema: type[TModel],
90
+ system: str = SYSTEM_STRUCTURED,
91
+ user: str,
92
+ attempts: int = 2,
93
+ temperature: float | None = None,
94
+ max_tokens: int | None = None,
95
+ on_error: OnStructuredError | None = None,
96
+ ) -> TModel | None:
97
+ """Single LLM call against a schema: JSON + tolerant parse + retry.
98
+
99
+ Returns `schema` or None (not enough resources / the model did not return valid JSON).
100
+ Deterministic logic (JSON, retry) stays in code; the LLM only reasons (§9, §67).
101
+
102
+ `temperature`/`max_tokens`: `None` uses the provider default; pass a value
103
+ for a per-call override.
104
+
105
+ `on_error`, if given, is called right before returning None with *why*
106
+ ("no_provider" | "provider_error" | "parse_error") and the exception when
107
+ there is one — for callers that want to distinguish "offline" from "the
108
+ provider is down" (e.g. to alert) without changing how they handle `None`.
109
+ """
110
+ llm = context.resources.llm
111
+ if llm is None:
112
+ if on_error is not None:
113
+ on_error("no_provider", None)
114
+ return None
115
+ instruction = f"Reply with a single JSON object matching this schema:\n{schema.model_json_schema()}"
116
+
117
+ def _request(text: str) -> LLMRequest:
118
+ return LLMRequest(
119
+ messages=[
120
+ Message.system(system),
121
+ Message.user(text),
122
+ ],
123
+ temperature=temperature,
124
+ response_format={"type": "json_object"},
125
+ max_tokens=max_tokens,
126
+ )
127
+
128
+ request = _request(f"{instruction}\n\n{user}")
129
+ total = max(attempts, 1)
130
+ for attempt in range(total):
131
+ try:
132
+ response = await llm.complete(request)
133
+ except Exception as exc:
134
+ logger.warning(
135
+ "structured_llm: provider call failed (attempt %s/%s): %r",
136
+ attempt + 1,
137
+ total,
138
+ exc,
139
+ )
140
+ if attempt + 1 < total:
141
+ await asyncio.sleep(0.4 * (attempt + 1)) # backoff on network failures
142
+ request = _request(
143
+ f"{instruction}\n\n{user}\n\n"
144
+ "The previous request failed. Return a single strict JSON object only."
145
+ )
146
+ continue
147
+ if on_error is not None:
148
+ on_error("provider_error", exc)
149
+ return None # provider/network failed — honest fallback
150
+ parsed = parse_structured(response.text, schema)
151
+ if parsed is not None:
152
+ return parsed
153
+ logger.debug(
154
+ "structured_llm parse failed (attempt %s): %.160r",
155
+ attempt + 1,
156
+ response.text,
157
+ )
158
+ if attempt + 1 < total:
159
+ request = _request(
160
+ f"{instruction}\n\n{user}\n\n"
161
+ "Previous reply was not valid JSON. Return a single strict JSON object only."
162
+ )
163
+ if on_error is not None:
164
+ on_error("parse_error", None)
165
+ return None
166
+
167
+
168
+ async def llm_reply(
169
+ context: Context,
170
+ *,
171
+ system: str = "",
172
+ user: str,
173
+ attempts: int = 2,
174
+ temperature: float | None = None,
175
+ max_tokens: int | None = None,
176
+ on_error: OnStructuredError | None = None,
177
+ ) -> str | None:
178
+ """A *plain-text* chat completion → `str`, or `None` on an honest failure.
179
+
180
+ Convenience over `structured_llm` with a single-text schema: same retries,
181
+ same tolerant parsing, same `None` fallback (no model / provider failure) —
182
+ but no need to declare a one-field body model for free-form replies (§67).
183
+
184
+ It sends exactly **one** system message (the wire format is not a place for
185
+ multiple system blocks — extra context belongs in artifacts/views, §28).
186
+
187
+ `on_error`: see `structured_llm`.
188
+ """
189
+ body = await structured_llm(
190
+ context,
191
+ schema=_ReplyBody,
192
+ system=system,
193
+ user=user,
194
+ attempts=attempts,
195
+ temperature=temperature,
196
+ max_tokens=max_tokens,
197
+ on_error=on_error,
198
+ )
199
+ return body.text if body is not None else None
200
+
201
+
202
+ class _ReplyBody(BaseModel):
203
+ text: str
204
+
205
+
206
+ class StructuredLLM(Generic[TModel]):
207
+ """A reusable structured call: a fixed schema + system, varying only `user`.
208
+
209
+ Build a role once, use it wherever the same struct is needed:
210
+
211
+ extract_facts = StructuredLLM(schema=Facts, system=SYSTEM_EXTRACTOR)
212
+ facts = await extract_facts.call(context, user="Summarize this page: …")
213
+
214
+ All deterministic logic (JSON extraction, retries, backoff) is delegated to
215
+ `structured_llm` (§67); the object only carries the fixed parts.
216
+ """
217
+
218
+ def __init__(
219
+ self,
220
+ schema: type[TModel],
221
+ *,
222
+ system: str = SYSTEM_STRUCTURED,
223
+ attempts: int = 2,
224
+ temperature: float = 0.0,
225
+ max_tokens: int = 2048,
226
+ on_error: OnStructuredError | None = None,
227
+ ):
228
+ self.schema = schema
229
+ self.system = system
230
+ self.attempts = attempts
231
+ self.temperature = temperature
232
+ self.max_tokens = max_tokens
233
+ self.on_error = on_error
234
+
235
+ async def call(self, context: Context, user: str) -> TModel | None:
236
+ return await structured_llm(
237
+ context,
238
+ schema=self.schema,
239
+ system=self.system,
240
+ user=user,
241
+ attempts=self.attempts,
242
+ temperature=self.temperature,
243
+ max_tokens=self.max_tokens,
244
+ on_error=self.on_error,
245
+ )
@@ -0,0 +1,48 @@
1
+ """reactifact.testing — a scenario-testing harness for agent pipelines.
2
+
3
+ `ScenarioLab` is the entry point: seed some artifacts, run a set of agents,
4
+ assert on what happened (artifacts produced, tools called, agent path,
5
+ LLM usage, isolated errors). `lab.fail(...)` injects tool faults;
6
+ `lab.fail_resource(...)` injects a fault into any other resource (the LLM,
7
+ the embedder, a source); `mode=` on `ScenarioLab` switches the LLM between
8
+ live, record, and replay.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from .assertions import (
14
+ ArtifactAssertions,
15
+ ErrorAssertions,
16
+ LLMAssertions,
17
+ PathAssertions,
18
+ ToolAssertions,
19
+ )
20
+ from .exceptions import AssertionFailure, ScenarioError, ScenarioSkip
21
+ from .fault import ToolCallRecord, ToolCallRecorder, ToolFault
22
+ from .lab import Scenario, ScenarioLab, ScenarioResult
23
+ from .mock import ResourceFault
24
+ from .record import Mode, mode_from_env
25
+ from .registry import ScenarioCase, collect, scenario
26
+
27
+ __all__ = [
28
+ "ArtifactAssertions",
29
+ "AssertionFailure",
30
+ "ErrorAssertions",
31
+ "LLMAssertions",
32
+ "Mode",
33
+ "PathAssertions",
34
+ "ResourceFault",
35
+ "Scenario",
36
+ "ScenarioCase",
37
+ "ScenarioError",
38
+ "ScenarioLab",
39
+ "ScenarioResult",
40
+ "ScenarioSkip",
41
+ "ToolAssertions",
42
+ "ToolCallRecord",
43
+ "ToolCallRecorder",
44
+ "ToolFault",
45
+ "collect",
46
+ "mode_from_env",
47
+ "scenario",
48
+ ]
@@ -0,0 +1,326 @@
1
+ """Assertion objects for `reactifact.testing` scenario results.
2
+
3
+ Each assertion group is a thin, chainable wrapper over data reactifact already
4
+ computes — `Context.list_artifacts()` for artifacts, `RunTrace` for the agent
5
+ path and LLM calls, and (for tools) the harness's own `ToolCallRecorder`
6
+ (`reactifact.testing.fault`), since `AgentSpan` has no field for raw tool
7
+ invocations. Every failure raises `AssertionFailure` with the actually
8
+ observed data inlined, so a failing scenario test is debuggable straight from
9
+ the pytest output.
10
+
11
+ Dropped from v1 (no faithful reactifact analog): a LangGraph-style "node status"
12
+ assertion. reactifact's closest concept, `ProgressEvent` from `Runtime.astream()`,
13
+ is a live-streaming concept, not a post-hoc trace field; a `result.events`
14
+ assertion group may be added later if `ScenarioLab` grows a streaming mode.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections.abc import Iterable
21
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
22
+
23
+ from pydantic import BaseModel
24
+
25
+ from .exceptions import AssertionFailure
26
+
27
+ if TYPE_CHECKING:
28
+ from reactifact.budget import RunStats
29
+ from reactifact.context import Context
30
+ from reactifact.tracing.models import AgentSpan, LLMCall, RunTrace
31
+
32
+ from .fault import ToolCallRecord
33
+
34
+ T = TypeVar("T", bound=BaseModel)
35
+
36
+
37
+ class ArtifactAssertions(Generic[T]):
38
+ """Assertions over `context.list_artifacts(artifact_type)`."""
39
+
40
+ def __init__(self, context: Context, artifact_type: type[T]) -> None:
41
+ self._context = context
42
+ self._type = artifact_type
43
+
44
+ def all(self) -> list[T]:
45
+ return [a.data for a in self._context.list_artifacts(self._type)]
46
+
47
+ def exists(self) -> T:
48
+ """Asserts at least one exists; returns the most recently created."""
49
+ artifact = self._context.latest(self._type)
50
+ if artifact is None:
51
+ raise AssertionFailure(
52
+ f"expected an artifact of type {self._type.__name__!r}, found none "
53
+ f"(context has: {self._present_types()})"
54
+ )
55
+ return artifact.data
56
+
57
+ def none(self) -> None:
58
+ found = self.all()
59
+ if found:
60
+ raise AssertionFailure(
61
+ f"expected no {self._type.__name__!r} artifacts, found {len(found)}: "
62
+ f"{found!r}"
63
+ )
64
+
65
+ def count(self, n: int) -> None:
66
+ found = self.all()
67
+ if len(found) != n:
68
+ raise AssertionFailure(
69
+ f"expected {n} artifact(s) of type {self._type.__name__!r}, "
70
+ f"found {len(found)}: {found!r}"
71
+ )
72
+
73
+ def latest(self) -> T:
74
+ return self.exists()
75
+
76
+ def matches(self, pattern: str, *, field: str = "text") -> T:
77
+ """Regex-searches `field` on the latest artifact (default: `.text`)."""
78
+ data = self.exists()
79
+ value = str(getattr(data, field, None))
80
+ if re.search(pattern, value) is None:
81
+ raise AssertionFailure(
82
+ f"{self._type.__name__}.{field} = {value!r} does not match "
83
+ f"pattern {pattern!r}"
84
+ )
85
+ return data
86
+
87
+ def field_equals(self, field: str, value: Any) -> T:
88
+ data = self.exists()
89
+ actual = getattr(data, field, None)
90
+ if actual != value:
91
+ raise AssertionFailure(
92
+ f"{self._type.__name__}.{field} = {actual!r}, expected {value!r}"
93
+ )
94
+ return data
95
+
96
+ def equals(self, **fields: Any) -> T:
97
+ """Asserts every given field on the latest artifact matches at once."""
98
+ data = self.exists()
99
+ mismatches = {
100
+ name: (getattr(data, name, None), expected)
101
+ for name, expected in fields.items()
102
+ if getattr(data, name, None) != expected
103
+ }
104
+ if mismatches:
105
+ raise AssertionFailure(
106
+ f"{self._type.__name__} field mismatch(es): "
107
+ + ", ".join(
108
+ f"{name}={actual!r} (expected {expected!r})"
109
+ for name, (actual, expected) in mismatches.items()
110
+ )
111
+ )
112
+ return data
113
+
114
+ def contains(self, substring: str, *, field: str = "text") -> T:
115
+ """Plain substring check on `field` of the latest artifact (no regex)."""
116
+ data = self.exists()
117
+ value = str(getattr(data, field, None))
118
+ if substring not in value:
119
+ raise AssertionFailure(
120
+ f"{self._type.__name__}.{field} = {value!r} does not contain "
121
+ f"{substring!r}"
122
+ )
123
+ return data
124
+
125
+ def field_in(self, field: str, values: Iterable[Any]) -> T:
126
+ """Asserts `field` on the latest artifact is one of `values`."""
127
+ data = self.exists()
128
+ options = list(values)
129
+ actual = getattr(data, field, None)
130
+ if actual not in options:
131
+ raise AssertionFailure(
132
+ f"{self._type.__name__}.{field} = {actual!r}, expected one of {options!r}"
133
+ )
134
+ return data
135
+
136
+ def _present_types(self) -> str:
137
+ names = sorted({type(a.data).__name__ for a in self._context.list_artifacts()})
138
+ return ", ".join(names) if names else "(none)"
139
+
140
+
141
+ class ToolAssertions:
142
+ """Assertions over recorded tool calls (`ToolCallRecorder`, see `fault.py`).
143
+
144
+ Independent of `RunTrace`: `AgentSpan` does not carry raw tool
145
+ invocations, so this reads the harness's own call log instead.
146
+ """
147
+
148
+ def __init__(self, calls: list[ToolCallRecord]) -> None:
149
+ self._calls = calls
150
+
151
+ def call_order(self) -> list[str]:
152
+ return [c.tool for c in self._calls]
153
+
154
+ def called(self, name: str) -> list[ToolCallRecord]:
155
+ matches = [c for c in self._calls if c.tool == name]
156
+ if not matches:
157
+ raise AssertionFailure(
158
+ f"expected tool {name!r} to be called, but it never was "
159
+ f"(called: {self.call_order()})"
160
+ )
161
+ return matches
162
+
163
+ def never_called(self, name: str) -> None:
164
+ matches = [c for c in self._calls if c.tool == name]
165
+ if matches:
166
+ raise AssertionFailure(
167
+ f"expected tool {name!r} to never be called, but it was called "
168
+ f"{len(matches)} time(s) with args {[m.args for m in matches]}"
169
+ )
170
+
171
+ def called_times(self, name: str, n: int) -> None:
172
+ matches = [c for c in self._calls if c.tool == name]
173
+ if len(matches) != n:
174
+ raise AssertionFailure(
175
+ f"expected tool {name!r} to be called {n} time(s), "
176
+ f"was called {len(matches)} time(s)"
177
+ )
178
+
179
+ def called_any(self, *names: str) -> ToolCallRecord:
180
+ """Asserts at least one of `names` was called; returns its first call."""
181
+ for call in self._calls:
182
+ if call.tool in names:
183
+ return call
184
+ raise AssertionFailure(
185
+ f"expected at least one of {list(names)} to be called "
186
+ f"(called: {self.call_order()})"
187
+ )
188
+
189
+ def called_with(self, name: str, **kwargs: Any) -> ToolCallRecord:
190
+ for call in self._calls:
191
+ if call.tool != name:
192
+ continue
193
+ if all(call.args.get(k) == v for k, v in kwargs.items()):
194
+ return call
195
+ raise AssertionFailure(
196
+ f"no call to tool {name!r} matched args {kwargs!r} "
197
+ f"(calls: {[(c.tool, c.args) for c in self._calls if c.tool == name]})"
198
+ )
199
+
200
+
201
+ class PathAssertions:
202
+ """Assertions over the agent execution path (`RunTrace.spans[*].agent`)."""
203
+
204
+ def __init__(self, trace: RunTrace | None) -> None:
205
+ self._trace = trace
206
+
207
+ def all(self) -> list[str]:
208
+ if self._trace is None:
209
+ return []
210
+ return [span.agent for span in self._trace.spans]
211
+
212
+ def contains(self, agent_name: str) -> None:
213
+ if agent_name not in self.all():
214
+ raise AssertionFailure(
215
+ f"expected agent {agent_name!r} to have run, path was {self.all()}"
216
+ )
217
+
218
+ def not_contains(self, agent_name: str) -> None:
219
+ if agent_name in self.all():
220
+ raise AssertionFailure(
221
+ f"expected agent {agent_name!r} to not run, but path was {self.all()}"
222
+ )
223
+
224
+ def times(self, agent_name: str) -> int:
225
+ """How many times `agent_name` appears in the path (a measurement,
226
+ not an assertion — pair it with your own `==` check)."""
227
+ return self.all().count(agent_name)
228
+
229
+ def sequence(self, *names: str) -> None:
230
+ """Asserts `names` appear, in order, as a (not-necessarily-contiguous) subsequence."""
231
+ path = self.all()
232
+ pos = 0
233
+ for name in names:
234
+ try:
235
+ pos = path.index(name, pos) + 1
236
+ except ValueError as exc:
237
+ raise AssertionFailure(
238
+ f"expected subsequence {list(names)} in path {path}, "
239
+ f"but {name!r} was not found after position {pos}"
240
+ ) from exc
241
+
242
+ def exact_sequence(self, *names: str) -> None:
243
+ path = self.all()
244
+ if path != list(names):
245
+ raise AssertionFailure(f"expected path {list(names)}, got {path}")
246
+
247
+ def any_of(self, *names: str) -> None:
248
+ """Asserts at least one of `names` ran (membership over the path)."""
249
+ path = self.all()
250
+ if not any(name in path for name in names):
251
+ raise AssertionFailure(
252
+ f"expected at least one of {list(names)} in path, got {path}"
253
+ )
254
+
255
+
256
+ class LLMAssertions:
257
+ """Assertions over `RunTrace.llm_calls` (populated automatically since
258
+ `ScenarioLab` always attaches a tracer)."""
259
+
260
+ def __init__(self, trace: RunTrace | None) -> None:
261
+ self._trace = trace
262
+
263
+ def _calls(self) -> list[LLMCall]:
264
+ return self._trace.llm_calls if self._trace is not None else []
265
+
266
+ @property
267
+ def calls(self) -> int:
268
+ return len(self._calls())
269
+
270
+ @property
271
+ def tokens(self) -> int:
272
+ return sum(c.prompt_tokens + c.completion_tokens for c in self._calls())
273
+
274
+ def max_calls(self, n: int) -> None:
275
+ if self.calls > n:
276
+ raise AssertionFailure(
277
+ f"expected at most {n} LLM call(s), got {self.calls}"
278
+ )
279
+
280
+ def max_tokens(self, n: int) -> None:
281
+ if self.tokens > n:
282
+ raise AssertionFailure(f"expected at most {n} token(s), got {self.tokens}")
283
+
284
+ def by_agent(self, agent_name: str) -> list[LLMCall]:
285
+ return [c for c in self._calls() if c.agent == agent_name]
286
+
287
+
288
+ class ErrorAssertions:
289
+ """Assertions over isolated agent errors (`Runtime(isolate_errors=True)`).
290
+
291
+ Reads `AgentSpan.error` (per-agent) and `RunStats.errors` (count) — both
292
+ require the scenario's `Runtime` to have been constructed with
293
+ `isolate_errors=True`; otherwise an agent exception propagates before a
294
+ trace/stats exist at all.
295
+ """
296
+
297
+ def __init__(self, trace: RunTrace | None, stats: RunStats | None) -> None:
298
+ self._trace = trace
299
+ self._stats = stats
300
+
301
+ def _errored_spans(self) -> list[AgentSpan]:
302
+ if self._trace is None:
303
+ return []
304
+ return [span for span in self._trace.spans if span.error]
305
+
306
+ def none(self) -> None:
307
+ errored = self._errored_spans()
308
+ if errored:
309
+ raise AssertionFailure(
310
+ f"expected no agent errors, found: "
311
+ f"{[(s.agent, s.error) for s in errored]}"
312
+ )
313
+
314
+ def count(self) -> int:
315
+ if self._stats is not None:
316
+ return self._stats.errors
317
+ return len(self._errored_spans())
318
+
319
+ def expected(self, agent_name: str) -> AgentSpan:
320
+ for span in self._errored_spans():
321
+ if span.agent == agent_name:
322
+ return span
323
+ raise AssertionFailure(
324
+ f"expected agent {agent_name!r} to have errored, but no error span "
325
+ f"for it was found (errored agents: {[s.agent for s in self._errored_spans()]})"
326
+ )
@@ -0,0 +1,27 @@
1
+ """Exceptions for `reactifact.testing`."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ScenarioError(Exception):
7
+ """Misuse of the scenario harness itself (not a failed assertion) — e.g.
8
+ `lab.fail_resource("no_such_name", ...)` naming a resource that doesn't
9
+ exist on this scenario's `RuntimeResources`.
10
+ """
11
+
12
+
13
+ class ScenarioSkip(Exception):
14
+ """A scenario opts out of running (e.g. no API key, no recorded fixture).
15
+
16
+ Raise this from inside a `@scenario`-decorated function — the `reactifact
17
+ scenario` CLI reports it as `SKIP` (with this exception's message),
18
+ distinct from a failed assertion or a crash.
19
+ """
20
+
21
+
22
+ class AssertionFailure(AssertionError):
23
+ """A scenario assertion did not hold.
24
+
25
+ Subclasses `AssertionError` so pytest's assertion introspection/output
26
+ still applies to it like any other failed `assert`.
27
+ """