Human-in-the-Loop
ctx.interrupt() stops a run to wait on a decision: an approval, an edit, a freeform
reply. It resumes with the recorded answer. A signal tells a run something happened;
an interrupt asks a question and carries a payload describing it.
from pyrula.workflows import workflow, InterruptResponse, InterruptAction
@workflow(name="expense_approval")async def expense_approval(ctx, amount_cents: int) -> str: response: InterruptResponse = await ctx.interrupt( "approve_expense", payload={"amount_cents": amount_cents}, )
if response.action == InterruptAction.ACCEPT: await ctx.step( "disburse", lambda: disburse(amount_cents, idempotency_key=f"{ctx.run_id}:disburse"), ) return "approved" if response.action == InterruptAction.EDIT: await ctx.step( "disburse_edited", lambda: disburse( response.value["amount_cents"], idempotency_key=f"{ctx.run_id}:disburse_edited", ), ) return "approved with edit" return "rejected"The cycle
Section titled “The cycle”ctx.interrupt(event_id, payload) writes step:pending and run:interrupted, then
suspends the run (status interrupted). Something external resumes it with an
InterruptResponse. The engine emits run:resumed, and the interrupt call returns
that response. On replay the recorded response comes straight back, so the run isn’t
interrupted twice.
InterruptResponse
Section titled “InterruptResponse”@dataclassclass InterruptResponse: action: InterruptAction | str # accept | edit | respond | ignore value: Any = None| Action | Meaning |
|---|---|
accept | Go ahead as proposed. |
edit | Go ahead with value as the edited input. |
respond | Continue with value as a freeform reply. |
ignore | Skip the proposed action. |
This is the right gate for side effects that need explicit approval. Charges, irreversible writes, anything you’d want a person to sign off on before it runs. The approved effect should still be idempotent or carry an external idempotency key. See Determinism.
Delivering the resume over HTTP belongs to the Agents serving layer.