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
reactifact/structured.py
ADDED
|
@@ -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
|
+
"""
|