Quality gates¶
A long autonomous run needs a way to ask "is this good enough?" and act on the answer — accept the work, send it back for another pass, or stop and get a human. That checkpoint is a quality gate. AgentFlow expresses it not as a new kind of node or a new backend, but as a thin convention over primitives you already have: a decision function, a three-way conditional edge, and a per-gate attempt counter. The rule it enforces is the one that keeps long runs debuggable — a work node writes an artifact, and a separate gate decides what happens next.
The contract¶
A gate is one dataclass and one helper. The evaluator returns a GateResult; add_quality_gate wires it into the graph with routing and an attempt budget.
from agentflow import END, Graph, last
from agentflow.prebuilt import GateResult, GateState, add_quality_gate
from typing import Annotated
class ReviewState(GateState):
artifact: Annotated[str, last]
async def evaluate(state, ctx) -> GateResult:
score = await grade(state["artifact"])
return GateResult(
passed=score >= 0.85,
score=score,
feedback=None if score >= 0.85 else "add error handling",
)
g = Graph(ReviewState)
g.add_node("revise", revise)
g.add_node("publish", publish)
g.add_node("ask_human", ask_human)
add_quality_gate(
g, "review", evaluate,
on_pass="publish", on_fail="revise", on_escalate="ask_human",
max_attempts=3,
)
g.add_edge("revise", "review") # revise loops back into the gate
GateResult carries a verdict plus context for humans and revise nodes:
GateResult(
passed, # the only field routing needs
score=None, # for observability / thresholds
reasons=[], # why it failed, for logs and human review
feedback=None, # actionable text a revise node can act on
)
The three routes¶
Every gate routes exactly one of three ways, and all three targets are required — a gate never silently ships a failing artifact once it runs out of attempts.
| Outcome | Route | Typical target |
|---|---|---|
passed is true |
on_pass |
the next real step, or END |
| failed, attempts remain | on_fail |
a revise node that loops back into the gate |
failed at max_attempts |
on_escalate |
a human-in-the-loop node, or a park-and-report node |
flowchart TD
work --> gate{quality gate}
gate -- passed --> publish[on_pass]
gate -- failed, tries left --> revise[on_fail]
revise --> gate
gate -- out of tries --> human[on_escalate]
The gate keeps its attempt count in the gate_attempts channel and its last verdict in gate_results, both provided by the GateState mixin. One pair of channels serves any number of gates in a graph — the gate name is the key — so you never declare per-gate fields. The verdict is stored as a plain dict ({"passed", "score", "reasons", "feedback"}), not a GateResult object, so it is JSON-serializable and survives a durable checkpointer; read it as state["gate_results"]["name"]["passed"].
GateResult vs Candidate¶
AgentFlow has two "is it good enough?" types, and they are not duplicates — they answer different questions, so pick by what you are building.
Candidate |
GateResult |
|
|---|---|---|
| Produced by | the worker in iterate_until_converged |
an evaluator in add_quality_gate |
| Key field | done — "the loop may stop" |
passed — "this meets the bar" |
| Who owns the decision | the loop (best-so-far, patience, max iterations) | the gate (pass / revise / escalate) |
| Shape | a self-contained optimize loop with a score to climb | an explicit checkpoint in a larger graph that routes three ways |
Use iterate_until_converged when the whole task is "keep improving until good enough". Use add_quality_gate when a gate is one station in a bigger workflow that may hand off to a human. They compose: a gate's on_fail can point at a revise node that is itself a small loop.
Seeing gates in telemetry¶
A gate emits a GateEvent through ctx.emit on every decision: started, then one of passed / failed / escalated, each carrying the gate name, attempt number, score, and reasons. So your observability sinks show exactly where a process looped on quality and why it escalated — no manual logging.
from agentflow import GateEvent, Hooks
class GateLog(Hooks):
async def on_event(self, thread, step, node, event):
if isinstance(event, GateEvent):
print(f"{event.gate} {event.phase} attempt={event.attempt} score={event.score}")
app = g.compile(hooks=GateLog())
In a JsonlTelemetry trace these arrive as event lines with eventType: "GateEvent" and the phase and attempt in the payload — a durable record of every gate decision in the run.
Escalating to a human¶
When a gate escalates, the natural on_escalate target is a node that suspends the run for a person. Pair it with the structured interrupt convention: send a QualityGateReview so the reviewer sees the artifact, the criteria, and the machine's own assessment.
from agentflow.interrupts import QualityGateReview
async def ask_human(state, ctx):
result = state["gate_results"]["review"] # a plain dict
decision = await ctx.interrupt(QualityGateReview(
artifact=state["artifact"],
criteria=["tests green", "no secrets", "API stable"],
auto_score=result["score"],
reasons=result["reasons"],
gate="review",
).to_payload())
return {"approved": decision == "accept"}
Because the payload has a type discriminator, a UI or control-plane worker can switch on interrupt_type(payload) and render the right prompt. See human-in-the-loop for the resume mechanics.
A complete example¶
examples/quality_gate.py is a runnable version of a draft → gate → revise/publish/escalate loop, and examples/research_plan_implement_review.py shows two gates guarding a longer research → plan → implement → review workflow. The long-running loops chapter walks through those shapes and the idempotency recipe that keeps the expensive steps from re-running.