Skip to content

Interrupts

interrupt() pauses a node until a person answers: the thread is stored as paused, and a later call with resume=Resume(...) continues it.

from nodestep import (
    END,
    START,
    BaseState,
    Graph,
    InMemoryStateStore,
    Resume,
    interrupt,
    node,
)


class Refund(BaseState):
    amount: float = 0.0
    approved: bool = False


@node
def review(state: Refund) -> dict:
    answer = interrupt(f"Approve a refund of {state.amount}?", id="approve")
    return {"approved": answer == "yes"}


graph = Graph(Refund, state_store=InMemoryStateStore()).flow(
    START >> review,
    review >> END,
)

paused = graph.invoke({"amount": 80}, thread_id="refund-1")
print(paused.status)
for key, pending in paused.interrupts.items():
    print(key, pending.payload)

done = graph.invoke(None, thread_id="refund-1", resume=Resume("yes"))
print(done.status, done.state.approved)
interrupted
review:approve Approve a refund of 80.0?
completed True

The first call stops in review and returns the pending interrupt with its payload. The second call answers it. review runs again, and this time interrupt() returns the answer.

A paused thread must be stored, so interrupts need a state store and a thread_id.

Keys and answers

Each interrupt has an id that you choose. Its key in the result is the task and the id, as in "review:approve".

Resume(value) answers the only pending interrupt. When several are pending, Resume(answers={key: value, ...}) answers each of them by key:

from typing import Annotated

from pydantic import Field

from nodestep import (
    END,
    START,
    BaseState,
    Command,
    Graph,
    InMemoryStateStore,
    Resume,
    add,
    interrupt,
    node,
)


class Release(BaseState):
    sign_offs: Annotated[list[str], add] = Field(default_factory=list)


@node
def legal(state: Release) -> dict:
    answer = interrupt("Legal: approve the release?", id="sign_off")
    return {"sign_offs": [f"legal: {answer}"]}


@node
def security(state: Release) -> dict:
    answer = interrupt("Security: approve the release?", id="sign_off")
    return {"sign_offs": [f"security: {answer}"]}


@node(goto=[legal, security])
def request_sign_offs(state: Release) -> Command:
    return Command(goto=[legal, security])


graph = Graph(Release, state_store=InMemoryStateStore()).flow(
    START >> request_sign_offs,
    legal >> END,
    security >> END,
)

paused = graph.invoke({}, thread_id="release-1")
print(paused.status, sorted(paused.interrupts))

answers = {"legal:sign_off": "approved", "security:sign_off": "approved"}
done = graph.invoke(None, thread_id="release-1", resume=Resume(answers=answers))
print(done.status, done.state.sign_offs)
interrupted ['legal:sign_off', 'security:sign_off']
completed ['legal: approved', 'security: approved']

Both nodes use the id sign_off, and their keys still differ, because each key starts with its task. Each pending Interrupt has its key, id, node, task_id and payload.

What happens on resume

The resumed node runs again from the top. Code before interrupt() therefore runs twice. Put side effects after the interrupt, or keep expensive results in ctx.cache, which survives the pause:

from nodestep import (
    END,
    START,
    BaseState,
    Graph,
    InMemoryStateStore,
    NodeContext,
    Resume,
    interrupt,
    node,
)


class Quote(BaseState):
    price: float = 0.0
    accepted: bool = False


@node
def offer(state: Quote, ctx: NodeContext) -> dict:
    print("offer runs")
    if "price" not in ctx.cache:
        print("pricing the order")
        ctx.cache["price"] = 42.0
    answer = interrupt(f"Accept a price of {ctx.cache['price']}?", id="accept")
    return {"price": ctx.cache["price"], "accepted": answer == "yes"}


graph = Graph(Quote, state_store=InMemoryStateStore()).flow(
    START >> offer,
    offer >> END,
)

graph.invoke({}, thread_id="quote-1")
result = graph.invoke(None, thread_id="quote-1", resume=Resume("yes"))
print(result.state)
offer runs
pricing the order
offer runs
price=42.0 accepted=True

offer runs twice, and the price is computed once. While a thread is paused, it takes only answers. To start it over, fork it at an earlier event; see Time travel.

Answering in code

A middleware can answer an interrupt, so the run does not pause. interrupt() asks the on_interrupt hook of each middleware in order, and the first answer wins:

from nodestep import END, START, BaseState, Graph, Middleware, interrupt, node
from nodestep.middleware import NodeMiddlewareContext


class AutoApproveSmallAmounts(Middleware):
    def on_interrupt(self, ctx: NodeMiddlewareContext, payload: object) -> object:
        if isinstance(payload, dict) and payload.get("amount", 0) < 50:
            return "yes"
        return None


class Refund(BaseState):
    amount: float = 0.0
    approved: bool = False


@node
def review(state: Refund) -> dict:
    answer = interrupt({"amount": state.amount}, id="approve")
    return {"approved": answer == "yes"}


graph = Graph(Refund, middleware=[AutoApproveSmallAmounts()]).flow(
    START >> review,
    review >> END,
)

print(graph.invoke({"amount": 20}).state.approved)
True

The hook answers the small refund, so this graph needs no state store. A larger amount would pause the run, and that needs one.

Tool approvals

ToolInterruptMiddleware pauses an agent before a tool call runs, so a person can approve, edit or deny it. Each InterruptRule names a tool, or "*" for every tool, and may add a predicate on the arguments:

from nodestep.middleware import InterruptRule, gt, should_interrupt

rules = [InterruptRule(tool="refund", match=gt("amount", 100))]

print(should_interrupt(rules, "refund", {"amount": 120.0}))
print(should_interrupt(rules, "refund", {"amount": 20.0}))
print(should_interrupt(rules, "refund", {"amount": "a lot"}))
print(should_interrupt(rules, "lookup_order", {"order_id": "A-1001"}))
True
False
True
False

should_interrupt is the check the middleware makes before each call. The third call asks too: gt cannot compare a string with a number, and a predicate that fails counts as a match. Pass the rules as ToolInterruptMiddleware(rules=rules).

The paused call arrives as a ToolInterrupt payload. Answer it with True, or with a ToolDecision to approve, edit or deny the call. Pause for approval shows a complete agent.

Rules and defaults

Rule or setting What happens
State store Interrupts need a state store, and so a thread_id
Interrupt id Required. Unique within one run of its node, and made of letters, digits, _, . and -
Interrupt key "<task_id>:<id>", for example "review:approve"
Resume(value) Answers the only pending interrupt
Resume(answers={key: value, ...}) Answers several pending interrupts; it must answer all of them
resume= It takes only a Resume. Command is a node's return value, not an answer
Pending Interrupt Has key, id, node, task_id and payload
Checking the answers Answers are checked before anything runs. A key that is not pending, a pending interrupt without an answer, a single Resume(value) while several are pending, or resume= with nothing pending raises ResumeError
Resumed node It runs again from the top, so code before interrupt() runs twice. Put side effects after it, or keep expensive results in ctx.cache
Answer types Answers are normalized to JSON values: a pydantic model arrives as a dict, a date as a string
context= The context of the paused run is not stored. Pass it again with resume=
GraphInterrupt interrupt() raises GraphInterrupt, which derives from BaseException, so except Exception in the node does not catch it
Result of a paused run It shows the state before the paused superstep. Writes of tasks that finished in that superstep are applied on resume
Input on a paused thread New input raises ResumeError. Answer the interrupts, or fork the thread at an earlier event
on_interrupt hooks Asked in middleware order; the first hook that returns something other than None gives the answer, and the node continues
on_interrupt signature It must be synchronous
No hook answers The run pauses, which needs a state store
InterruptRule(tool=...) A tool name, or "*" for every tool
InterruptRule(match=None) Without a predicate, every call of the tool is asked for
Predicates gt, ge, lt, le, eq, matches (a regular expression on a string argument), all_fields and any_field (patterns for several arguments)
A predicate that raises Counts as a match, for example on a missing argument or one of another type: the call is asked for, not run
Approval interrupt id The id is "approve_" plus the tool call id. Characters other than letters, digits, _ and - are written as . and two hex digits per UTF-8 byte
Approval payload A ToolInterrupt with tool_name, tool_call_id and arguments
Approve True or ToolDecision(action="approve") runs the call
Edit ToolDecision(action="edit", arguments={...}) runs the call with changed arguments. An edit is merged into the call's arguments: its keys replace those of the call, and the others keep their values, so an edit cannot remove an argument
Deny Any other answer denies the call. A denied call is not run, and the model gets a tool message with the reason
Decision as a dict A dict with the fields of ToolDecision works too, since answers arrive as JSON
History after an approval The model's original call stays in the history. The tool message records the arguments the tool ran with in arguments