Skip to content

symfonic.agent.cutover.lifecycle_refusals

lifecycle_refusals

The refusals that enforce the public lifecycle contract (TA8.41).

The contract itself -- which entry point serves, refuses or is inert for each row, and which consumer reads it -- is data, and it lives next door in :mod:~symfonic.agent.cutover.lifecycle_contract. Read that first. This module is only the three raise sites, and it is separate for the reason :mod:~symfonic.agent.cutover.config_retirement is separate from :mod:~symfonic.agent.cutover.settings_contract.

Before dispatch, never from the verdict. All three run at the top of run/stream/stream_typed, beside :func:~symfonic.agent.engine._refuse_retired_arguments, and for that guard's three reasons: a verdict-driven refusal would be route-conditional, stream skips the verdict entirely on a line that predates the text-delta chunk contract, and a rolled-back switch would re-honour on the legacy body what this line declines to serve.

Emptiness is not use, once per refusal. response_model=None and transcript_persistence_enabled=False are what a caller who asks for nothing passes, and neither reaches a raise.

Every error here widens, and none renames. Each subclasses both SymfonicAgentError -- so an adopter's existing except around the agent API keeps catching it, which for StreamingDisabledError also means the bare error it replaces stays caught -- and CutoverPathError, which is the vocabulary the cutover guards refuse in.

StreamingDisabledError

StreamingDisabledError(entry_point: str, message: str)

Bases: SymfonicAgentError, CutoverPathError

streaming_enabled=False and a streaming entry point was asked.

The ancestry is a widening in both directions and a rename in neither. SymfonicAgentError is what this raised before it had a name of its own (a bare SymfonicAgentError("Streaming is disabled in configuration")), so every existing except and every existing message match still catch it. CutoverPathError is the vocabulary the cutover guards refuse in, which is what lets an operator tell a contract refusal from a provider failure without parsing prose.

Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
def __init__(self, entry_point: str, message: str) -> None:
    super().__init__(message)
    #: The streaming entry point that was asked. Recorded because a
    #: deployment that disables streaming usually calls exactly one of the
    #: two, and "which surface did my caller reach for" is the question the
    #: operator actually has.
    self.entry_point = entry_point
    #: The field that declined. Named on the exception so a handler does
    #: not have to match on wording.
    self.setting = "streaming_enabled"

StructuredOutputUnsupportedError

StructuredOutputUnsupportedError(entry_point: str, message: str)

Bases: SymfonicAgentError, CutoverPathError

response_model was supplied to a streaming entry point.

Same ancestry, same reason. Before this class the argument landed in **state_overrides and refused as a retired argument, which named the wrong thing: state_overrides is retired, response_model is not -- it is supported, on one surface, and this says which.

Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
def __init__(self, entry_point: str, message: str) -> None:
    super().__init__(message)
    self.entry_point = entry_point
    self.parameter = "response_model"

UnaddressableTranscriptError

UnaddressableTranscriptError(entry_point: str, message: str)

Bases: SymfonicAgentError, CutoverPathError

Transcript persistence was asked for on a turn nothing can read back.

transcript_persistence_enabled=True wires a LangGraph checkpointer, and a checkpointer needs a thread_id. The engine derives one from (scope, session_id) -- the single derivation site :func:symfonic.capabilities.human.threads.thread_id_for, shared with get_transcript and with working-deque rehydration -- and a turn that supplies neither has no key to file the checkpoint under.

Before TA8.41 that turn reached LangGraph and died there with ValueError: Checkpointer requires one or more of the following 'configurable' keys, an error that names no field of this framework and no action for the adopter. That is what made this row's admission-inventory outcome UNKNOWN on both driven entry points rather than a measurement.

Same ancestry, and the same reason, as the two errors above it.

Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
def __init__(self, entry_point: str, message: str) -> None:
    super().__init__(message, "bad_request")
    self.entry_point = entry_point
    self.setting = "transcript_persistence_enabled"

refuse_disabled_streaming

refuse_disabled_streaming(entry_point: str, config: Any) -> None

Refuse a streaming turn on an agent whose configuration disabled it.

Called from stream and stream_typed in the position the bare SymfonicAgentError guard occupied, which is above the cutover dispatch on purpose: the field governs the entry point, so honouring it must not depend on which body would have served the turn.

Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
def refuse_disabled_streaming(entry_point: str, config: Any) -> None:
    """Refuse a streaming turn on an agent whose configuration disabled it.

    Called from ``stream`` and ``stream_typed`` in the position the bare
    ``SymfonicAgentError`` guard occupied, which is above the cutover dispatch
    on purpose: the field governs the *entry point*, so honouring it must not
    depend on which body would have served the turn.
    """
    if getattr(config, "streaming_enabled", True):
        return
    raise StreamingDisabledError(
        entry_point,
        "Streaming is disabled in configuration: "
        f"FrameworkConfig(streaming_enabled=False), and {entry_point}() is a "
        f"streaming entry point. On the {LEVER_RETIREMENT_LINE} line this field "
        "governs stream() and stream_typed() and nothing else -- run() serves "
        "the same turn without a stream and is unaffected by it. Set "
        "streaming_enabled=True to stream, or call run().",
    )

refuse_streaming_structured_output

refuse_streaming_structured_output(entry_point: str, response_model: Any) -> None

Refuse response_model on a streaming entry point, or return.

None is not a request, so the ordinary streaming turn never reaches the raise: emptiness is not use, the same rule TA8.26 states for the retired configuration fields.

Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
def refuse_streaming_structured_output(
    entry_point: str, response_model: Any
) -> None:
    """Refuse ``response_model`` on a streaming entry point, or return.

    ``None`` is not a request, so the ordinary streaming turn never reaches the
    raise: emptiness is not use, the same rule TA8.26 states for the retired
    configuration fields.
    """
    if response_model is None:
        return
    raise StructuredOutputUnsupportedError(
        entry_point,
        f"response_model={getattr(response_model, '__name__', response_model)!r} "
        f"was supplied to {entry_point}(), which does not serve it. On the "
        f"{LEVER_RETIREMENT_LINE} line structured output is a blocking-turn "
        "contract: it is bound to the compiled plan and delivered on "
        "AgentResponse.structured, and neither streaming projection has a field "
        "to carry it. Call run(query, response_model=...) for the "
        "structured answer, or stream without it and parse the streamed text "
        "yourself. It is refused rather than accepted-and-dropped because a "
        "model that shaped nothing is the silent no-op this line exists to end.",
    )

refuse_unaddressable_transcript

refuse_unaddressable_transcript(entry_point: str, config: Any, scope: Any, session_id: Any) -> None

Require a checkpoint identity when persistence is on, or return.

The precondition is the same one ask_user_enabled has enforced since v7.1.1, and it is stated here in the same words for the same cause: both flags wire the checkpointer, and a checkpoint bound to an ephemeral LangGraph-generated thread cannot be found again.

It is a refusal rather than a generated fallback identity because a transcript is only worth persisting if it can be read back, and the reader -- SymfonicAgent.get_transcript(scope=..., session_id=...) -- takes exactly the two things this turn declined to supply. Minting a per-run key here would produce a durable row no API can address, which is the accepted-then-no-op outcome the 11.0 guards exist to end.

Route-independent and above the dispatch, for :func:refuse_disabled_streaming's reasons.

Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
def refuse_unaddressable_transcript(
    entry_point: str, config: Any, scope: Any, session_id: Any
) -> None:
    """Require a checkpoint identity when persistence is on, or return.

    The precondition is the *same* one ``ask_user_enabled`` has enforced since
    v7.1.1, and it is stated here in the same words for the same cause: both
    flags wire the checkpointer, and a checkpoint bound to an ephemeral
    LangGraph-generated thread cannot be found again.

    It is a refusal rather than a generated fallback identity because a
    transcript is only worth persisting if it can be read back, and the reader
    -- ``SymfonicAgent.get_transcript(scope=..., session_id=...)`` -- takes
    exactly the two things this turn declined to supply. Minting a per-run key
    here would produce a durable row no API can address, which is the
    accepted-then-no-op outcome the 11.0 guards exist to end.

    Route-independent and above the dispatch, for
    :func:`refuse_disabled_streaming`'s reasons.
    """
    if not getattr(config, "transcript_persistence_enabled", False):
        return
    if scope is not None and session_id:
        return
    raise UnaddressableTranscriptError(
        entry_point,
        "transcript_persistence_enabled=True requires both `scope` and "
        f"`session_id` on every turn, and {entry_point}() supplied "
        f"{'no scope' if scope is None else 'no session_id'}. The two derive "
        "the deterministic thread_id the checkpoint is filed under, and "
        "get_transcript(scope=..., session_id=...) is the only way to read it "
        "back -- a checkpoint bound to an ephemeral LangGraph-generated thread "
        "is written and then unaddressable. Supply both, or set "
        "transcript_persistence_enabled=False.",
    )