Контракт шага и effects (§24)¶
Один абзац, который стоит держать в голове, читая пример или пиша шаг:
Шаг описывает, что должно измениться, через effects — или возвращает свой результат; рантайм компилирует набор изменений в один атомарный патч и коммитит его.
Patchпочти никогда не собирают вручную — это транспорт рантайма.
from reactifact import Artifact, Effects
async def run(
self,
doc: Artifact[Document],
turn: Artifact[Turn],
effects: Effects,
) -> None:
evidence = effects.create(Evidence(...), id="evidence:q1")
answer = effects.create(Answer(...), id="answer:q1")
evidence.link("extracted_from", doc)
answer.link("supported_by", evidence)
effects.update(turn, status="answered")
effects.ask("Утвердить смету?", kind="approval") # HITL (§60)
Класс-шаг, который пишет эффекты, объявляет effects: Effects в run() —
рантайм инжектит слот, привязанный к запуску. Функция @step получает один
аргумент call: StepCall и пишет через call.effects; это один и тот же
набор эффектов, просто разные поверхности (у функции нет self).
Входы можно выбирать аннотациями: Annotated[T, When(...)] задаёт условие,
Annotated[T, Wakes(False)] читает тип как вход, не будя шаг:
history: Annotated[ConversationHistory, Wakes(False)]. Для debounce,
кастомных event_types и include_subclasses используйте явный Consume(...).
scope коррелирует типизированные входы. scope = "query_id" — shorthand;
если коррелируемый артефакт опознаётся по id, а не по полю данных, укажите
владельца:
Теперь Question матчится по artifact.id, а Hypothesis/Evidence/Claim —
по data.question_id. Scope(..., key=fn) задаёт кастомный extractor, когда
ключ лежит в notes (например, у PendingQuestion).
Возврат или effects¶
Возврат модели (или списка моделей) — это create-сахар:
effects нужны, когда шагу требуется явный id, update/link/delete, HITL или
несколько create. None означает «изменений нет» (guard или неподходящая
ветка).
Идемпотентность: create_once и upsert¶
Пересобираемому id (f"answer:{qid}") нужен гард — create_once сворачивает
его в вызов:
handle = effects.create_once(Answer(...), id=f"answer:{qid}")
if handle is None:
return None # уже отвечено — делать нечего
Create на существующий id и так работает как create-or-refresh (новая версия
той же логической сущности, §42/§43); upsert — явное имя для этого:
Внешние эффекты: act (outbox)¶
create/update/link/ask меняют состояние. Письмо, webhook или деплой
меняют мир и не должны повторяться при replay/retry. Запишите намерение —
runtime выполнит его после коммита:
act создаёт PendingAction под стабильным id action:{key}. Шаг не
выполняет I/O сам: передайте Runtime(dispatcher=...), и runtime вызовет его
один раз на закоммиченное намерение. См. Outbox
и durability.
Три слоя¶
| Слой | Что это | Кто пишет |
|---|---|---|
| Step | реакция: guard → LLM/расчёт → effects.* или возврат |
приложение (вы) |
| Effects | заявленный набор изменений (creates/updates/links/questions) на запуск | вы, через инжектированный effects |
| Patch | скомпилированные операции, применяемые одним коммитом | рантайм |
Ничего не применяется, пока шаг не завершится — атомарность структурная
(§41), без rollback-машинерии. События, валидация по produces и трейс
чтений/записей выводятся из тех же скомпилированных операций.
Зачем effects инжектятся¶
Слот эффектов привязан к запуску: рантайм кладёт новый перед вызовом и убирает
после (contextvar) — безопасно при параллельных шагах. Класс-шаг получает его
параметром (effects: Effects), функция — через call.effects. Хэндлы можно
строить между выражениями (evidence создан выше и связывается ниже), поэтому
код читается «про артефакты», а не «про id».
Где ещё встречается Patch¶
Agent.run— escape hatch для кастомных агентов, собирающих change-set вручную.- Рецепты (
fan_out_sources,materialize_doc) иStatusMachineпишут в слот; tool loop (ToolUse/ToolUseHITL) и HITL (effects.ask) — тоже эффекты. - Тесты и продвинутая сборка могут строить
Patch; обычным шагам он не нужен.
Правило¶
Если внутри шага появляется Patch() — остановитесь и пишите effects; компиляцию
делает рантайм.
Функциональная форма (@step) — та же поверхность¶
Декорированная функция получает call и использует call.effects:
from reactifact import StepCall, step
@step(produces=Answer, consumes=Question)
async def answer_turn(call: StepCall) -> None:
question = call.trigger
answer = call.effects.create(
Answer(text=...), id=f"answer:{question.id}"
)
answer.link("derived_from", question)
Возврат модели / списка / Patch / None компилируется рантаймом, поэтому
короткие шаги остаются однострочниками.
Какой стиль выбрать¶
Подкласс и функция @step — два канонических стиля: подкласс, когда у шага
есть логика, достойная имени, @step — для короткой разовой операции.
Переопределение Agent.run(self, event, context) -> Patch остаётся
низкоуровневым escape hatch, а не повседневным стилем.