symfonic.core.nodes.elicitation¶
elicitation ¶
ElicitationNode — interrupt node for ask_user tool calls.
This node is inserted into the graph topology between the react node and
the tools node when ask_user_enabled=True. It runs AFTER the react
node has returned normally (and its AIMessage has been checkpointed), so
the interrupt() call here can never orphan a ToolMessage.
Graph flow when ask_user is called
react -> (conditional: _ask_user_pending set?) yes -> elicitation [interrupt() here] [on resume: inject ToolMessage] no -> tools | END (normal tool/finish routing)
ask_user_pending_is_current ¶
Is _ask_user_pending from THIS run, or left over from an old one?
A user who never answers leaves the marker set forever: nothing clears it but the elicitation node, and that node is only reached by the very router this predicate feeds. Every later turn on the thread would re-route to a question the user already walked away from.
Two independent discriminators are checked, and either can condemn the marker as stale:
run_id
_build_input_state mints a fresh run_id per turn
only when the caller does not supply one (core/runtime.py:
run_id = run_id or uuid.uuid4().hex[:12]). run_id is a
PUBLIC kwarg on run()/stream()/stream_typed() -- an
adopter threading a stable conversation id through it for
tracing makes every turn share a run_id, so this check alone
always passes for that deployment. A resume enters through a
Command whose update carries only deps and
_callback_manager, so the checkpointed run_id correctly
survives a resume either way.
Markers written before this field existed carry no ``run_id``;
those pass this check (treated as current), so an in-flight
checkpoint keeps working.
minted_at (wall-clock TTL)
Stamped alongside run_id where the marker is built
(core/nodes/react.py). When ttl_seconds is supplied and
the marker is older than that, it is stale -- independent of
whether run_id matches. This is what actually bounds
abandonment when a caller-supplied run_id defeats the check
above.
Used by all three routers that read the marker
(:func:elicitation_condition, interrupt_condition, and the
precondition gate's router) -- guarding only one of them would leave
the others routing to the dead question.
Source code in src/symfonic/core/nodes/elicitation.py
create_elicitation_node ¶
Return the elicitation node function.
The node reads state["_ask_user_pending"], calls interrupt(),
and on resume builds a ToolMessage and clears _ask_user_pending.
Source code in src/symfonic/core/nodes/elicitation.py
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 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 141 | |
elicitation_condition ¶
Conditional-edge router: routes to 'elicitation' when ask_user is pending.
Used as the conditional edge from 'react'. Returns:
- "elicitation" when _ask_user_pending is set (truthy)
- "continue" when normal tool calls are present (handled by
the existing tools_condition edge)
- "finish" when there are no tool calls at all
ttl_seconds is bound at graph-wiring time (core/presets.py)
from FrameworkConfig.ask_user_pause_ttl_seconds; callers that
omit it (e.g. direct unit tests) get the pre-TTL, run_id-only
behaviour.