Skip to content

symfonic.core.contracts.elicitation

elicitation

Elicitation contracts — structured user questions and answers.

Provides schemas for the built-in ask_user tool and the associated event/response cycle.

AskUserAnswer

Bases: BaseModel

An answer to a specific question.

AskUserOption

Bases: BaseModel

A single option presented to the user.

AskUserQuestion

Bases: BaseModel

Internal (server-stamped) representation of a single question.

Constructed by the engine from AskUserQuestionInput after server-stamping the id. Never handed to the LLM as a tool spec.

AskUserQuestionInput

Bases: BaseModel

LLM-facing schema used as the tool argument spec for ask_user.

Deliberately has NO id field — the engine server-stamps ids after the LLM emits the tool call.

AskUserRequest

Bases: BaseModel

Server-stamped request with unique question IDs.

AskUserRequestInput

Bases: BaseModel

LLM-facing wrapper — used as the ask_user tool argument schema.

AskUserResponse

Bases: BaseModel

Collection of answers to a structured question request.

PauseTokenClaims

Bases: BaseModel

Binding payload for a pause token.

The name field is added in v7.1.x (Roadmap Item 9) to carry the registered interrupt name -- "ask_user" for the built-in case and any caller-registered name for generic interrupts. It defaults to "ask_user" so tokens minted before this field existed decode cleanly (the historical envelope had exactly the same security properties as today's ask_user interrupt).

The tool_call_id field is now optional: generic interrupts are not bound to a tool call. ask_user continues to populate it with the LangChain tool-call id so resume can build a matching ToolMessage.

The interrupt_id field is added in v7.1.x for generic interrupts; it pairs with InterruptEvent.interrupt_id so consumers can correlate the event to its resolved-event sibling. ask_user ignores this field (it has its own question_id family in the request payload).

validate_against_request

validate_against_request(
    response: AskUserResponse, request: AskUserRequest
) -> None

Cross-validate an answer response against its original request.

Source code in src/symfonic/core/contracts/elicitation.py
def validate_against_request(
    response: AskUserResponse,
    request: AskUserRequest,
) -> None:
    """Cross-validate an answer response against its original request."""
    by_id = {q.id: q for q in request.questions}
    answered = {a.question_id for a in response.answers}
    expected = set(by_id)
    if answered != expected:
        raise ValueError(f"answers cover {answered}, expected {expected}")

    for ans in response.answers:
        q = by_id[ans.question_id]
        valid_labels = {o.label for o in q.options}

        if not set(ans.selections).issubset(valid_labels):
            raise ValueError(
                f"q={ans.question_id}: unknown labels "
                f"{set(ans.selections) - valid_labels}",
            )

        if not q.multi_select and len(ans.selections) > 1:
            raise ValueError(
                f"q={ans.question_id}: single-select but got "
                f"{len(ans.selections)} selections",
            )

        if ans.other is not None and not q.allow_other:
            raise ValueError(
                f"q={ans.question_id}: 'other' provided but question "
                f"did not allow_other",
            )