Skip to content

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)

create_elicitation_node

create_elicitation_node() -> Any

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
def create_elicitation_node() -> Any:
    """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``.
    """

    async def elicitation(state: dict[str, Any]) -> dict[str, Any]:
        from langchain_core.messages import ToolMessage
        from langgraph.types import interrupt

        from symfonic.core.contracts.elicitation import (
            AskUserQuestion,
            AskUserRequest,
        )

        pending: dict[str, Any] = state["_ask_user_pending"]
        tool_call_id: str = pending["tool_call_id"]
        raw_request: dict[str, Any] = pending.get("request_input", {})

        # Server-stamp question IDs (uuid4.hex[:12]) — the LLM never supplies ids.
        questions = [
            AskUserQuestion(**q_data)
            for q_data in raw_request.get("questions", [])
        ]
        stamped_request = AskUserRequest(questions=questions)

        # Call interrupt() — LangGraph surfaces this as an ExtensionEvent
        # with type="on_interrupt" in astream_events(v2).  The engine's
        # stream_typed() intercepts it and calls _mint_pause_token.
        # On resume, interrupt() returns the value passed to graph.invoke()
        # as the Command.resume payload.
        resume_value: Any = interrupt(
            {
                "type": "ask_user",
                "tool_call_id": tool_call_id,
                "request": stamped_request.model_dump(),
            }
        )

        # --- Resume path (re-invocation after graph.update_state / Command) ---
        # LangGraph re-runs this node body from the top; interrupt() returns
        # the resume payload this time instead of raising GraphInterrupt.
        if resume_value is not None:
            # The resume payload is either the raw AskUserResponse dict or
            # an already-parsed object.  The engine's resume() method builds
            # the ToolMessage and injects it via state_overrides["messages"],
            # so on the resume path we just clear the pending marker and let
            # the normal tool->react loop continue.
            tool_msg = ToolMessage(
                content=(
                    resume_value
                    if isinstance(resume_value, str)
                    else str(resume_value)
                ),
                tool_call_id=tool_call_id,
                name="ask_user",
            )
            return {
                "messages": [tool_msg],
                "_ask_user_pending": None,
            }

        # Should not reach here in normal operation, but be safe.
        logger.warning(
            "elicitation node: interrupt() returned None unexpectedly for "
            "tool_call_id=%s", tool_call_id
        )
        return {"_ask_user_pending": None}

    return elicitation

elicitation_condition

elicitation_condition(state: dict[str, Any]) -> str

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

Source code in src/symfonic/core/nodes/elicitation.py
def elicitation_condition(state: dict[str, Any]) -> str:
    """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
    """
    if state.get("_ask_user_pending"):
        return "elicitation"

    # Fall through to the standard tools_condition logic
    from symfonic.core.edges.tool_condition import tools_condition

    return tools_condition(state)