Durable execution (checkpoints)¶
Graph.run() accepts a checkpointer and a checkpoint_id. A checkpoint is
written before every node, so a crash or error resumes from the last safe
point instead of starting over.
from teff import Graph
from teff.checkpoint import SQLiteCheckpointer
from teff.node import Transform
nodes = {"shout": Transform(action="uppercase", input_key="text", output_key="loud")}
graph = Graph(nodes, edges=[], entry_point="shout")
cp = SQLiteCheckpointer("checkpoints.db")
# first run crashes at some node
await graph.run(state, checkpointer=cp, checkpoint_id="demo-run")
# same id resumes from the saved checkpoint and completes
await graph.run(state, checkpointer=cp, checkpoint_id="demo-run")
Backends¶
| Backend | Extra | Notes |
|---|---|---|
JSONFileCheckpointer |
core | file-based, owner/ subdirectories |
SQLiteCheckpointer |
core | stdlib SQLite, composite (owner, checkpoint_id) key |
PGCheckpointer |
teff[pg-checkpoint] |
needs PostgreSQL |
On resume the saved state wins over the passed-in state; a State instance
keeps its schema and reducers.
Multi-tenant checkpoints (owner scoping)¶
Pass owner= to scope checkpoints to a user/session/tenant. The same
checkpoint_id under different owners never collides, so one store can serve
many users:
await graph.run(state, checkpointer=cp, checkpoint_id="chat-1", owner="alice")
await graph.run(state, checkpointer=cp, checkpoint_id="chat-1", owner="bob") # separate
chats = await cp.list("alice") # ["chat-1", ...] — enumerate a user's runs
Use owner for anything that should be isolated per end-user. File
checkpoints land in an owner/ subdirectory; SQLite/PG store a composite
(owner, checkpoint_id) primary key (existing single-owner databases migrate
automatically).
When owner is omitted, runs fall under the default owner "default"
(teff.checkpoint.DEFAULT_OWNER). The CLI exposes the same knob:
--checkpoint-owner on teff run and teff inspect (defaults to default).
Human-in-the-loop (interrupts)¶
Pause a workflow for operator input with an Interrupt node. When execution
reaches it, graph.run() raises GraphInterrupt; resume with the same
checkpoint_id plus a resume dict:
from teff.checkpoint import JSONFileCheckpointer
from teff.node.interrupt import GraphInterrupt
from teff.flow import Flow
from teff.node import LLM
from teff.provider import ProviderRegistry
flow = Flow(
"approval",
providers=ProviderRegistry.from_presets("ollama"),
default_provider="ollama",
default_model="llama3.1:8b",
)
flow.step(LLM(prompt="Составь план: {task}", output_key="draft"))
flow.interrupt(key="approved", prompt="Одобрить? (да / правки)")
flow.step(LLM(prompt="{draft}\nВердикт: {approved}", output_key="final"))
graph = flow.compile()
cp = JSONFileCheckpointer("checkpoints")
try:
await graph.run(state=state, checkpointer=cp, checkpoint_id="run-1")
except GraphInterrupt as interrupt:
print(interrupt.prompt)
answer = input("> ")
result = await graph.run(
state=state,
checkpointer=cp,
checkpoint_id="run-1",
resume={"approved": answer},
)
The answer lands in state["approved"] and execution continues. Interrupts
require a checkpointer.
Conversation turns: run(message=...)¶
For chat applications the interrupt bookkeeping is better handled for you.
Call graph.run() with a message and the checkpoint id as the session id:
the run auto-detects a paused session from durable state and either resumes
it with the message (the operator's answer) or starts/continues the
conversation. A pause is not raised — it is folded into a TurnResult:
from teff.graph import TurnResult
session_id = "chat-1"
result: TurnResult = await graph.run(
state={},
message="Спланируй ремонт ванной 5 м².",
checkpointer=cp,
checkpoint_id=session_id,
initial_state=lambda: {"task": "remont"},
)
if result.waiting:
# the run paused on an Interrupt: surface result.prompt to the operator
answer = input(result.prompt + " ")
result = await graph.run(
state={}, message=answer, checkpointer=cp, checkpoint_id=session_id
)
else:
print(result.reply) # latest assistant reply from the durable state
The loop above survives any number of interrupts (e.g. a "rework" branch that
re-asks). initial_state seeds a fresh session, transient_keys are cleared
at the start of every turn, and messages_key names the message list.
graph.stream(message=...) is the streaming equivalent — a pause surfaces an
interrupt event (with key/prompt in its data) where the stream ends.
Validating the answer¶
A bare interrupt compares the resume value verbatim. To validate the answer
(and capture a value, e.g. a promo code) instead, pair the interrupt with an
Ask strategy and re-ask until it passes with
interrupt_loop:
from teff.node import Ask
flow.interrupt_loop(
key="code",
prompt="Введите промокод (формат XX-1234):",
accept=Ask.regex(
r"^[A-Z]{2}-[0-9]{4}$", decision_key="code_ok", value_key="discount_code"
),
body=LLM(model="llama3.1:8b", prompt="Введите корректный код.", output_key="hint"),
done=LLM(
model="llama3.1:8b",
prompt="Примени скидку {discount_code}.",
output_key="final",
),
)
Ask.llm(...) adds an LLM classifier so free-form answers ("конечно", "ок")
count as approval. See the
ask_strategies example and the
validate reference.
Revision loop¶
Wire a conditional cycle back to the Interrupt with Flow.loop():
flow.step(LLM(model="llama3.1:8b", prompt="Составь план: {task}", output_key="draft"))
flow.interrupt(key="approved", prompt="Одобрить? (да / правки)")
flow.loop(
key="approved",
until="да",
done=LLM(model="llama3.1:8b", prompt="{draft}", output_key="final"),
body=LLM(
model="llama3.1:8b",
prompt="Переработай {draft} с учётом: {approved}",
output_key="draft",
),
)
loop() wires decider --key=until--> done (stop) and
decider --key!=until--> body -> decider (repeat). max_iterations caps the
rounds. The decider can be any node that writes key, not just an Interrupt.