symfonic.core.nodes.interrupt¶
interrupt ¶
Generic InterruptNode — Roadmap Item 9 (v7.2-bound).
Parallel to elicitation.py: receives a _interrupt_pending state
marker carrying a registered interrupt name + payload, calls
interrupt(), and on resume injects the typed response back into the
graph so the next react step can act on it.
Topology¶
The node is wired into the LangGraph workflow when
FrameworkConfig.experimental_interrupt=True. It sits beside the
elicitation node (which still handles the built-in ask_user
interrupt unchanged) so the two pause paths share no code: a regression
in the generic path cannot influence ask_user, and vice-versa.
Pending state shape¶
A tool/node triggers an interrupt by returning::
{"_interrupt_pending": {
"name": "approval_required", # registered interrupt name
"interrupt_id": "i-<12hex>", # uuid4.hex[:12]; client correlation
"payload": {...}, # JSON-serialised payload
"tool_call_id": "...", # optional; only set when called from a tool
}}
The :class:SymfonicAgent.interrupt helper builds this marker so callers
do not assemble it by hand.
INTERRUPT_ACK_SENTINEL
module-attribute
¶
Carried by Command(resume=...) when the response dumps to {}.
langgraph treats an empty resume map as nothing to resume with, so the
node would re-fire its interrupt() forever. Stripped here so the
sentinel never reaches the model.
build_interrupt_marker ¶
build_interrupt_marker(*, name: str, interrupt_id: str, payload: dict[str, Any], tool_call_id: str | None = None, run_id: str | None = None) -> dict[str, Any]
Construct the state-update dict a tool/node returns to trigger an interrupt.
Public helper used by :meth:SymfonicAgent.interrupt and any tool
that wants to wire a generic interrupt by hand. Keeping this in one
place avoids the marker shape drifting between callers.
Stamps minted_at unconditionally so
:func:interrupt_pending_is_current can apply a TTL even when no
run_id is available (the common case here -- see that
function's docstring). run_id is optional: pass it when the
caller has one in scope (e.g. a node with access to graph
state); a tool body generally does not.
Source code in symfonic/core/nodes/interrupt.py
create_interrupt_node ¶
Return the generic interrupt node function.
The node reads state["_interrupt_pending"], calls interrupt(),
and on resume injects the typed response back into the messages
channel as a structured ToolMessage (when the interrupt was
triggered from a tool) or as plain state (otherwise).
Source code in symfonic/core/nodes/interrupt.py
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 | |
interrupt_condition ¶
Conditional-edge router shared with the elicitation condition.
Returns:
| Type | Description |
|---|---|
str
|
|
str
|
|
str
|
|
str
|
|
Wired in by presets.py when experimental_interrupt=True so a
graph that mixes ask_user AND generic interrupts routes through
the right pause node deterministically. ttl_seconds is bound at
graph-wiring time from FrameworkConfig.ask_user_pause_ttl_seconds;
callers that omit it get the pre-TTL, run_id-only behaviour.
Source code in symfonic/core/nodes/interrupt.py
interrupt_pending_is_current ¶
Is _interrupt_pending from THIS run, or left over from an old one?
Symmetric with :func:symfonic.core.nodes.elicitation.ask_user_pending_is_current
-- deliberately duplicated rather than shared, matching this module's
existing "the two pause paths share no code" isolation (see module
docstring): a regression in one predicate cannot influence the other.
Nothing clears _interrupt_pending but the interrupt node, and
that node is only reached by the very router this predicate feeds.
An abandoned generic interrupt would otherwise re-route every later
turn on the thread forever.
run_id is checked when the marker carries one, with the same
back-compat rule as ask_user: a marker with no run_id passes
this check (treated as current). In practice run_id is rarely
available where the generic marker is built (:func:build_interrupt_marker
is called from arbitrary tool bodies via SymfonicAgent.interrupt(),
which has no graph state to read it from) -- minted_at is
the discriminator that actually bounds abandonment for this path.
minted_at (wall-clock TTL) is stamped unconditionally by
:func:build_interrupt_marker; when ttl_seconds is supplied and
the marker is older than that, it is stale independent of the
run_id outcome.