Тестирование агентских пайплайнов¶
reactifact.testing — поведенческий харнесс для агентских пайплайнов:
посеять артефакты, прогнать агентов, проверить, что произошло. Он намеренно
отделён от pytest — сценарии могут идти вживую, записывать реальную модель или
реплеить запись — но при желании встраивается в pytest.
from reactifact.testing import ScenarioLab
lab = ScenarioLab([my_agent], resources=lambda: build_resources())
result = await lab.run(Question(text="what's the refund policy?"))
result.artifacts(Answer).exists()
result.artifacts(Answer).linked("supported_by", Evidence)
result.tools.called("search")
result.path.contains("answer")
result.llm.max_calls(3)
result.errors.none()
На каждый run() создаётся свежий Context/Runtime, поэтому поставленный
сбой или рекордер вызовов тулов не утекают между прогонами.
Sync или async¶
run()/turn() — асинхронные; run_sync()/turn_sync() — то же самое для
обычных (не-async) pytest-тестов:
Группы ассертов¶
Всё, что сценарию нужно проверять, — это свойства ScenarioResult (у
Scenario — агрегированные варианты):
| Группа | Что проверяет |
|---|---|
result.artifacts(Type) |
поля последнего/любого артефакта: exists, none, count, contains, matches (regex), equals(**fields), field_in и linked(relation, Type) для провенанса |
result.relations |
рёбра графа артефактов: has/none/count (любой фильтр — wildcard), outgoing/incoming |
result.tools |
записанные вызовы тулов: called, never_called, called_times, called_with, called_any, call_order |
result.path |
путь исполнения агентов: contains, not_contains, sequence, exact_sequence, any_of, times |
result.llm |
вызовы/токены: calls, prompt_tokens, completion_tokens, tokens, max_calls, max_tokens, by_agent |
result.errors |
изолированные ошибки агентов (isolate_errors=True, дефолт харнесса): none, count, expected(agent) |
result.events |
progress-события context.announce(): contains/not_contains (regex), messages(kind=…), count, min_count |
Падения бросают AssertionFailure с вписанными наблюдаемыми данными — падающий
тест отлаживается прямо из вывода pytest.
Провенанс — это ассерт, а не соглашение¶
Типизированный граф артефактов — причина использовать reactifact, поэтому харнесс проверяет его напрямую:
answer = result.artifacts(Answer).exists()
evidence = result.artifacts(Answer).linked("supported_by", Evidence)
result.relations.has(source=answer.id, relation="supported_by")
result.relations.none(relation="contradicted_by")
Многоходовые сценарии¶
lab.scenario() переиспользует один Context между вызовами .turn(), поэтому
следующий ход видит всё, что произвёл предыдущий — для потоков, которым нужно
больше одного раунда ввода, чтобы дойти до проверяемого состояния:
convo = lab.scenario()
await convo.turn(UserMsg(text="вот мои пожелания"))
await convo.turn(UserMsg(text="1")) # выбрать вариант дизайна 1
await convo.turn(UserMsg(text="да, согласен")) # одобрить
convo.tools.called_times("estimate", 1) # агрегат по всем ходам
convo.path.contains("repair_flow")
Сбои и заглушки¶
Всё ниже — одноразовое: ставится на лабу, расходуется следующим
run()/.turn(), не переносится дальше.
# заставить тул падать (times=N: только первые N, затем делегирование)
lab.fail("search", ConnectionError("unreachable"), times=1)
lab.fail("kubectl", RuntimeError("boom"), when=lambda args: args["resource"] == "pods")
lab.fail("slow_tool", TimeoutError(), delay=5.0) # сначала задержка, потом падение
# заставить тул вернуть заранее заданный вывод вместо выполнения
lab.stub_tool("search", text="canned result")
# то же для любого нетул-ресурса — "llm", "embedder", id источника, имя,
# заданное через resources.set(...), или типизированный Type/ResourceKey
lab.fail_resource("llm", ConnectionError("model down"))
lab.fail_resource("embedder", RuntimeError("no vectors"), method="embed")
lab.stub_resource("catalog", returns={"sku": "A1"})
lab.stub_resource("catalog", side_effect=[{"sku": "A1"}, KeyError("missing")])
stub_resource(side_effect=[…]) следует семантике unittest.mock: элемент,
который является Exception, бросается, остальное возвращается. when(args) /
when(args, kwargs) сужают сбой до совпадающих вызовов; delay спит сначала
(async-ресурсы уступают; sync — блокируются).
Одна оговорка про тулы: ToolUse ловит исключение тула и отдаёт модели строку
"Tool 'x' failed: …", поэтому инъектированный сбой обычно не прерывает
прогон — агент видит его как реальный временный сбой и может повторить.
result.tools.called("x")[0].error покажет упавший вызов, а result.errors.none()
может при этом пройти.
Record / replay¶
mode= управляет LLM за resources.llm:
"live"(дефолт) — реальный провайдер;"record"— оборачивает его, дописывая каждый вызов вrecording_path;"replay"— без сети; вызов, разошедшийся с записью, бросаетReplayMiss, а не отвечает догадкой (§59).
lab = ScenarioLab(agents, resources=resources,
mode=mode_from_env(), # live/record/replay
recording_path="scenarios/data/calls.jsonl")
mode_from_env() читает $REACTIFACT_SCENARIO_MODE, который выставляет
reactifact scenario --mode … — так один и тот же сценарий идёт вживую,
пишется или реплеится в зависимости только от способа запуска.
Golden-снапшоты¶
Замораживает отпечаток прогона (context_hash + хеши промптов из трейса) и
падает при дрейфе. Отсутствующий файл — или update=True /
$REACTIFACT_GOLDEN_UPDATE=1 — записывает снапшот вместо падения, так что
первый прогон его заводит:
result.assert_golden("scenarios/data/gpu.golden.json")
# позже: REACTIFACT_GOLDEN_UPDATE=1 pytest # перезаписать после намеренного изменения
Сочетайте record/replay с golden для полностью офлайнового регресса на реальном прогоне.
Отладка прогона¶
result.explain() (и scenario.explain()) выводит всё, что произвёл прогон —
артефакты по типам, отношения, путь, вызовы тулов/LLM, ошибки — для вывода
падающего теста или быстрого print():
Запуск тестов¶
Под pytest подключите плагин, чтобы async def-сценарии шли без
pytest-asyncio, а ScenarioSkip становился skip:
Он же даёт фикстуру-фабрику scenario_lab, которая по умолчанию берёт mode=
из $REACTIFACT_SCENARIO_MODE:
def test_refund_policy(scenario_lab):
lab = scenario_lab([my_agent], resources=build_resources)
result = lab.run_sync(Question(text="refund policy?"))
result.artifacts(Answer).contains("14 days")
Вне pytest регистрируйте сценарии через @scenario и запускайте CLI — удобно
для live/record прогонов, которых обычная сессия pytest не должна касаться:
reactifact scenario examples.repair.scenarios
reactifact scenario examples.knowledge.scenarios --mode replay
Падение выводится в духе pytest: секция FAILURES, падающий фрейм из вашего
кода с исходной строкой, исключение и — если сценарий гонял ScenarioLab —
состояние прогона (result.explain()), так что отлаживать можно прямо из
терминала. --no-state убирает дамп состояния.
Для оценки вывода пайплайна (качество evidence, provenance grounding, корректность вычислений), а не проверки поведения, см. Evaluation.