Skip to content

symfonic.kernel.turn_guards

turn_guards

What ends a turn during prompt-assembly, and why it is raised and not logged.

Both guards answer the same question — may this turn continue? — and both exist because an earlier version answered "yes" by default and let the model be called on a prompt that was missing something. They live together because the policy is one policy: raise, do not degrade. Degradation has to be asked for, and the way to ask is no_change(reason); a handler that raised, or returned something that could not be applied, chose nothing.

Split out of :mod:.prompt_assembly when it crossed the 300-line budget. The split is not arbitrary: that module is the composition of a phase with the dispatcher, and this is the policy about failure inside it. They are read for different reasons and by different people.

require_no_crashed_stage

require_no_crashed_stage(traces: tuple[StageTrace, ...], *, phase: str) -> None

A handler that raised ends the turn. A stage nothing answered does not.

The narrower half of :func:require_no_failed_stage, and the distinction is not cosmetic. StageOutcome.FAILED covers two different facts:

  • the handler ran and raised — error is set. That is #92's case, and the reasoning is entirely about it: a handler that raised chose nothing, so continuing would answer as though nothing was wrong.
  • no handler resolved for the stage at all — error is None. The stage never ran, and for a CapabilityContribution this cannot happen: validate() refuses a declared stage with no handler at fold time. It is reachable only on the hand-assembled path, where the extension-seam probe declares a post-model stage with no body on purpose and has a test saying so.

prompt-assembly refuses both, and keeps doing so: it has never met the second case, so narrowing it there would be changing a behaviour on the strength of a case that does not arise. The two new phases use this instead, and the asymmetry is stated here rather than left to be discovered.

Source code in src/symfonic/kernel/turn_guards.py
def require_no_crashed_stage(
    traces: tuple[StageTrace, ...], *, phase: str
) -> None:
    """A handler that **raised** ends the turn. A stage nothing answered does not.

    The narrower half of :func:`require_no_failed_stage`, and the distinction is
    not cosmetic. ``StageOutcome.FAILED`` covers two different facts:

    * the handler ran and raised — ``error`` is set. That is #92's case, and the
      reasoning is entirely about it: *a handler that raised chose nothing*, so
      continuing would answer as though nothing was wrong.
    * no handler resolved for the stage at all — ``error`` is ``None``. The
      stage never ran, and for a ``CapabilityContribution`` this cannot happen:
      ``validate()`` refuses a declared stage with no handler at fold time. It
      is reachable only on the hand-assembled path, where the extension-seam
      probe declares a ``post-model`` stage with no body on purpose and has a
      test saying so.

    ``prompt-assembly`` refuses both, and keeps doing so: it has never met the
    second case, so narrowing it there would be changing a behaviour on the
    strength of a case that does not arise. The two new phases use this instead,
    and the asymmetry is stated here rather than left to be discovered.
    """
    crashed = [
        t
        for t in traces
        if t.outcome is StageOutcome.FAILED and t.error is not None
    ]
    if not crashed:
        return
    first = crashed[0]
    # A stage may raise a typed contract refusal deliberately. Re-wrapping it
    # as the generic "stage crashed" error erases the rule, phase and
    # disposition the caller needs to explain why an effect did not execute.
    # Unexpected exceptions still take the generic path below.
    if isinstance(first.error, ContractViolationError) and getattr(
        first.error, "preserve_contract_identity", False
    ):
        raise first.error
    names = ", ".join(f"{t.capability}:{t.stage_id}" for t in crashed)
    raise ContractViolationError(
        f"{phase} stage(s) raised and the turn cannot proceed: {names}. "
        f"First failure: {first.reason}. A capability that wants the turn to "
        "continue without it returns no_change(reason); a handler that raised "
        "made no such choice, and finishing the turn would report success for "
        "a stage that crashed."
    ) from first.error

require_no_failed_stage

require_no_failed_stage(traces: tuple[StageTrace, ...], *, phase: str = 'prompt-assembly') -> None

EVT-7: a stage that raised ends the turn, in whichever phase it ran.

phase names the phase in the message and nothing else. It exists because #23 wired post-model and pre-tool and neither called this, so a handler that raised in either was turned into a FAILED trace by the dispatcher and then read by nobody -- the turn finished normally with a stage having crashed. That is precisely the hole described below, reintroduced at two new call sites by the change that created them.

The policy is the same at every phase on purpose. If it were not, which phase a capability attaches to would silently change what happens when its handler crashes, and no capability author reads the ladder for that.

Found in review of this module's first version, which returned the traces and let the caller decide — and then the caller decided nothing, so a capability whose handler raised had its contribution silently dropped and the model was called anyway, on a prompt missing whatever that capability was registered to add.

The reasoning that produced the hole is worth keeping: the traces were dropped at the call site because RequestContext has no runtime diagnostic slot, which is true. But a FAILED outcome is not a diagnostic — it is a control-flow fact — and "I cannot record where this went wrong" was treated as "I may ignore that it went wrong".

Raising rather than degrading, because degradation has to be asked for. A capability that wants a turn to continue without it can return no_change(reason), which is exactly what that outcome is for; a handler that raised did not choose anything.

Source code in src/symfonic/kernel/turn_guards.py
def require_no_failed_stage(
    traces: tuple[StageTrace, ...], *, phase: str = "prompt-assembly"
) -> None:
    """EVT-7: a stage that raised ends the turn, in whichever phase it ran.

    ``phase`` names the phase in the message and nothing else. It exists
    because #23 wired ``post-model`` and ``pre-tool`` and neither called this,
    so a handler that raised in either was turned into a FAILED trace by the
    dispatcher and then read by nobody -- the turn finished normally with a
    stage having crashed. That is precisely the hole described below,
    reintroduced at two new call sites by the change that created them.

    The policy is the same at every phase on purpose. If it were not, which
    phase a capability attaches to would silently change what happens when its
    handler crashes, and no capability author reads the ladder for that.

    Found in review of this module's first version, which returned the traces
    and let the caller decide — and then the caller decided nothing, so a
    capability whose handler raised had its contribution silently dropped and
    the model was called anyway, on a prompt missing whatever that capability
    was registered to add.

    The reasoning that produced the hole is worth keeping: the traces were
    dropped at the call site because ``RequestContext`` has no runtime
    diagnostic slot, which is true. But a FAILED outcome is not a diagnostic —
    it is a control-flow fact — and "I cannot record where this went wrong" was
    treated as "I may ignore that it went wrong".

    Raising rather than degrading, because degradation has to be *asked for*.
    A capability that wants a turn to continue without it can return
    ``no_change(reason)``, which is exactly what that outcome is for; a handler
    that raised did not choose anything.
    """
    failures = [t for t in traces if t.outcome is StageOutcome.FAILED]
    if not failures:
        return
    first = failures[0]
    names = ", ".join(f"{t.capability}:{t.stage_id}" for t in failures)
    raise ContractViolationError(
        f"{phase} stage(s) failed and the turn cannot proceed: {names}. "
        f"First failure: {first.reason}. A capability that wants the turn to "
        "continue without its contribution returns no_change(reason); a stage "
        "that raised made no such choice, and running the model on a prompt "
        "missing that contribution would answer as though nothing was wrong."
    ) from first.error

require_no_malformed

require_no_malformed(malformed: list[str]) -> None

A stage whose contribution could not be applied ends the turn.

Raised from two places: after resolution, so compilation never sees a snapshot the kernel already knows is incomplete, and after compilation for its own stages. The first call site is the one that matters -- without it a compilation stage failing for want of the missing entry raised first, and the operator was told that stage failed. The producer broke the contract; the consumer got named.

Source code in src/symfonic/kernel/turn_guards.py
def require_no_malformed(malformed: list[str]) -> None:
    """A stage whose contribution could not be applied ends the turn.

    Raised from two places: after resolution, so compilation never sees a
    snapshot the kernel already knows is incomplete, and after compilation for
    its own stages. The first call site is the one that matters -- without it a
    compilation stage failing for want of the missing entry raised first, and
    the operator was told *that* stage failed. The producer broke the contract;
    the consumer got named.
    """
    if not malformed:
        return
    raise ContractViolationError(
        "prompt-assembly stage(s) applied a contribution of the wrong type "
        f"and the turn cannot proceed: {malformed}. A PROMPT_ASSEMBLY stage "
        "contributes a PromptAssembly, and a resolution stage contributes a "
        "ResolvedInput; anything else cannot be applied, and continuing would "
        "call the model on a prompt missing whatever that stage was registered "
        "to add. A stage with nothing to contribute returns no_change(reason)."
    )