Control Flow¶
Scatter¶
Return Scatter([event1, event2, ...]) to fan out into multiple events; each dispatches separately in the next round. Annotate the return as Scatter[WorkItem] (or Scatter[A | B] for heterogeneous targets) — renders as a dashed edge in graph.namespaces().mermaid(). Bare Scatter, Scatter[Any], Scatter[Event], and Scatter[T] (TypeVar) are rejected at graph construction with TypeError (v0.10).
from langgraph_events import EventLog, IntegrationEvent, Scatter, on
class Batch(IntegrationEvent):
items: tuple[str, ...]
class WorkItem(IntegrationEvent):
item: str
class WorkDone(IntegrationEvent):
result: str
class BatchResult(IntegrationEvent):
results: tuple[str, ...]
@on(Batch)
def split(event: Batch) -> Scatter[WorkItem]:
return Scatter([WorkItem(item=i) for i in event.items])
@on(WorkItem)
def process(event: WorkItem) -> WorkDone:
return WorkDone(result=f"done:{event.item}")
@on(WorkDone)
def gather(event: WorkDone, log: EventLog) -> BatchResult | None:
all_done = log.filter(WorkDone)
batch = log.latest(Batch)
if len(all_done) >= len(batch.items):
return BatchResult(results=tuple(e.result for e in all_done))
return None
See the Map-Reduce pattern.
Invariants¶
Declare invariants={InvariantClass: predicate, ...} as a class-level attribute on a Command (or via invariants= on @on() for external handlers). Each predicate runs twice per matching event:
| Phase | Timing | Log scope | On failure |
|---|---|---|---|
| Pre-check | Before handler body | Committed events | Skip handler; emit InvariantViolated (would_emit empty) |
| Post-check | After handler returns | Committed + emitted | Drop emissions; emit InvariantViolated (would_emit = handler's events) |
Atomicity
The two phases give the DDD semantic: the domain's consistency rules hold before the handler runs and after its effects commit.
- Typed markers — subclass
Invariantonce per rule; reference the class on declaration and reactor sides. Typos fail at graph construction. - Declared on the
Command'sinvariantsdict; forwarded to the inlinehandle()wrapper. - Recovery reactors emit namespace-level events (Command-private outcomes are reserved for
handle()).
class CustomerNotBanned(Invariant):
"""Customer must not be on the banned list.""" # pre-check catches
class OrderTotalWithinLimit(Invariant):
"""Cumulative placed amount must stay under a daily limit.""" # post-check catches
class Order(Namespace):
class Place(Command):
customer_id: str = ""
amount: int = 0
invariants = {
CustomerNotBanned: lambda log: not log.has(CustomerBanned),
OrderTotalWithinLimit: lambda log: (
sum(e.amount for e in log.filter(Order.Place.Placed)) < 100
),
}
class Placed(DomainEvent):
order_id: str = ""
amount: int = 0
def handle(self) -> Order.Place.Placed:
return Order.Place.Placed(
order_id=f"o-{self.customer_id}", amount=self.amount
)
class Rejected(DomainEvent):
"""Sibling to ``Place`` — emitted by recovery reactors."""
reason: str = ""
@on(InvariantViolated, invariant=OrderTotalWithinLimit)
def rolled_back(event: InvariantViolated) -> Order.Rejected:
rolled = event.would_emit[0] # the Placed the handler would have emitted
return Order.Rejected(reason=f"over limit (would emit {rolled.amount})")
CustomerNotBanned: pre-check only — truth ≠ handler outputs. OrderTotalWithinLimit: post-check catches the case where this handler's Placed is what pushes the total over.
Invariant reactors
- Catch all:
@on(InvariantViolated). Pin to a class:@on(InvariantViolated, invariant=SomeInvariant). - Framework verifies pinned classes are actually declared somewhere — otherwise
TypeErrorat graph construction ("would never fire"). - Pinned reactors fire for both pre- and post-check failures without distinguishing them — inspect
event.would_emitto tell them apart.
Semantics
- Predicates receive
EventLog; must be sync (async rejected at decoration) and pure functions oflog. - Pre-check log = committed events. Post-check log = committed + everything the current node call has buffered.
- Multiple invariants short-circuit; one
InvariantViolatedper phase. - Predicate exceptions propagate (not converted to violations).
- Invariants run around
raises=: pre-check gates the body entirely; post-check skips on caught exceptions (those emitHandlerRaisedinstead). - Post-check is a no-op when the handler returned
None(empty buffer) or declares no invariants. Invariantsubclasses must be zero-arg instantiable (framework callsCls()at emission time forisinstancematching).
Modeling errors — when to use what¶
| Situation | Vehicle |
|---|---|
| Expected domain outcome (including failure) | DomainEvent (Order.Place.Rejected) |
| Consistency rule gating a command (pre and/or post) | invariants= → InvariantViolated |
| Infrastructure failure (rate limit, timeout, parse error) | Exception + raises= → HandlerRaised |
Warnings at construction¶
graph.namespaces() emits two warnings that flag structural smells. Filter with warnings.filterwarnings("ignore", category=...) if intentional.
CommandChainWarning—chain-causation edge: an inlineCommand.handle()returning anotherCommand. Prefer emitting a fact and reacting to it.DomainPatternWarning— 2+ events in the same namespace fan out to identical target sets (a missing shared base / common reactor).
IntegrationEvent placement
IntegrationEvent must be defined at module scope. Nesting inside a Namespace or Command raises TypeError at class creation (v0.10) — integration events cross a context boundary by definition.
Interrupted / Resumed¶
Subclass Interrupted with typed fields to pause for human input. Resume with graph.resume(event) (requires a checkpointer); a Resumed event emits alongside the dispatched event.
from langgraph.checkpoint.memory import MemorySaver
class OrderConfirmationRequested(Interrupted):
order_id: str
total: float
class ApprovalSubmitted(IntegrationEvent):
approved: bool
@on(OrderPlaced)
def confirm(event: OrderPlaced) -> OrderConfirmationRequested:
return OrderConfirmationRequested(order_id=event.order_id, total=event.total)
@on(ApprovalSubmitted)
def handle_approval(event: ApprovalSubmitted, log: EventLog) -> OrderConfirmed | OrderCancelled:
request = log.latest(OrderConfirmationRequested)
if event.approved:
return OrderConfirmed(order_id=request.order_id)
return OrderCancelled(reason="User declined")
graph = EventGraph([confirm, handle_approval], checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "order-1"}}
graph.invoke(OrderPlaced(order_id="A1", total=99.99), config=config)
state = graph.get_state(config)
if state.is_interrupted:
print(f"Approve {state.interrupted.order_id} for ${state.interrupted.total}?")
log = graph.resume(ApprovalSubmitted(approved=True), config=config)
See HITL pattern and Checkpointer Evolution.
Typed payloads — InterruptedWithPayload¶
For interrupts whose frontend needs an action-discriminated dict, subclass langgraph_events.agui.InterruptedWithPayload[PayloadT] and implement interrupt_payload(self) -> PayloadT. The AG-UI adapter recognises the contract directly — see AG-UI.
Soft-timeout — RunPaused¶
Pass deadline=time.monotonic() + budget to any graph entry point (invoke, ainvoke, astream_events, stream_events, resume, aresume, astream_resume, stream_resume, or AGUIAdapter.stream). When the router observes a wall-clock time past the deadline between dispatch rounds, it emits a RunPaused(elapsed_seconds=…) and the run terminates cleanly.
from time import monotonic
log = graph.invoke(Started(data="job-42"), deadline=monotonic() + 25.0)
if log.latest(RunPaused):
# Soft-stopped. The next /run on the same thread_id continues
# from the LangGraph checkpoint — same mechanism as a normal
# follow-up turn. No Command(resume=...) required.
...
Unlike MaxRoundsExceeded, RunPaused is not terminal across runs: the router advances the cursor past it so a fresh /run on the same thread_id excludes the old RunPaused from new_events and continues. Resume semantics intentionally differ from Interrupted:
Interruptedwrites a pending interrupt task to the checkpoint; resume usesCommand(resume=value)to deliver a typed resume value to the paused node.RunPausedwrites no checkpoint task; the worker/UI just makes a new/runcall (with the samethread_idand a fresh deadline). LangGraph's checkpointer replays from the last completed node.
Position deadline strictly tighter than whichever hard cancellation the caller already has (asyncio.wait_for, SAQ job_timeout, LangGraph's timeout=) so the soft boundary fires first and the wire-format finalize path runs cleanly. In-flight events from the round when the deadline fires are persisted in the event log but not dispatched — same drop-on-pause semantic as MaxRoundsExceeded. Handlers should produce events such that a clean round-boundary stop leaves recoverable state.
RunPaused is emitted at most once per /run, even when many parallel handlers are still in flight when the deadline fires. The router gates re-emission so that downstream projections (custom reducers, message-channel notices) can rely on one inline entry per pause.
Surfacing the pause inline¶
A RunPaused is just an event in the log. To turn it into a user-visible system message in the same channel as your chat history, register a custom reducer that handles both MessageEvent and RunPaused:
from langchain_core.messages import BaseMessage, SystemMessage
from langgraph.graph.message import add_messages
from langgraph_events import Event, MessageEvent, Reducer, RunPaused
def project(event: Event) -> list[BaseMessage]:
if isinstance(event, MessageEvent):
return event.as_messages()
if isinstance(event, RunPaused):
return [SystemMessage(
id=f"sys-paused-{event.elapsed_seconds:.6f}",
content=f"Paused after {round(event.elapsed_seconds)}s. "
f"Send a follow-up to continue.",
)]
return []
messages = Reducer(
name="messages",
event_type=(MessageEvent, RunPaused),
fn=project,
reducer=add_messages,
default=[],
)
AG-UI wire shape¶
RunPaused is intentionally not surfaced on the AG-UI wire by default. There is no built-in mapping: FallbackMapper skips it (one-time warning) because the previous CustomEvent(name="interrupted", value={"kind": "soft_timeout", …}) overload collided with HITL Interrupted events on the same wire name. Apps that want a pause signal on the wire register their own EventMapper — see AG-UI → Custom Mappers.
Field Matchers¶
@on(Event, field=Type) dispatches only when event.field is a Type instance; if the handler signature includes a parameter named field, the value is injected:
@on(Resumed, interrupted=OrderConfirmationRequested)
def handle(event: Resumed, interrupted: OrderConfirmationRequested) -> OrderConfirmed:
# `interrupted` is guaranteed to be OrderConfirmationRequested.
...
- Works on any field typed as
EventorException. - Field names validated at graph construction (typos raise
TypeError). - Omit the parameter to filter dispatch without injection.
Handler Exceptions¶
Declare raises=(ExceptionClass, ...) on a Command (or via raises= on @on(...)). Caught exceptions emit HandlerRaised carrying the exception, handler name, and source_event; subscribe with @on(HandlerRaised, exception=…) to react.
class RateLimitError(Exception):
def __init__(self, retry_after: float) -> None:
super().__init__(f"retry after {retry_after}s")
self.retry_after = retry_after
class Question(Namespace):
class Ask(Command):
question: str = ""
raises = (RateLimitError,)
class Answered(DomainEvent):
answer: str = ""
def handle(self) -> Question.Ask.Answered:
if upstream_rate_limited():
raise RateLimitError(retry_after=0.2)
return Question.Ask.Answered(answer=...)
@on(HandlerRaised, exception=RateLimitError)
def backoff(event: HandlerRaised, exception: RateLimitError) -> Question.Ask:
return Question.Ask(question=event.source_event.question) # retry
- Every type in
raises=must be covered by at least one catcher, else graph construction fails withTypeError. A catcher coversXif it has no field matchers, or onlyexception=X-or-superclass. Non-exceptionmatchers don't count. - Only
Exceptionsubclasses allowed —BaseException/KeyboardInterrupt/SystemExit/GeneratorExit/asyncio.CancelledErrorrejected.CancelledErrorsurfaces asCancelled(aHaltedsubtype). - Unhandled raises propagate and crash the run. Catchers can themselves declare
raises=to escalate. - Use
HandlerRaised.source_event(notevent) for the triggering event — avoids kwarg collision.
See the Error Recovery pattern.