Skip to content

symfonic.kernel.pre_model

pre_model

The pre-model seam: capability stages that read a round before it is sent.

The last silent rung. Phase.PRE_MODEL sat in PHASE_LADDER between prompt-assembly and post-model while nothing dispatched it, so a capability declaring a pre-model stage compiled into the plan, took its place in the compiled order, received its effect grants, and was invoked in no pass. That is the shape :mod:symfonic.kernel.post_tool records TA8.39 finding for POST_TOOL; this is the same defect at the one phase it outlived, and it is named in 11.0's Known gaps.

Why it is not a second prompt-assembly. That question decides whether the phase is wired or deleted, so it is answered here rather than assumed. prompt-assembly runs once per turn, before the round loop opens. This rung runs once per round, inside it. It is therefore the only seam that sees the transcript as it will be sent on this round -- including tool results that a previous round's post-tool folded into it. A stage that must look at the conversation the model is about to be given, rather than the one it was given first, has nowhere else to stand.

Deliberately the same shape as :mod:symfonic.kernel.post_model and :mod:symfonic.kernel.post_tool, and for their reasons rather than by imitation:

  • it does not bind. The turn is already open by the time a round begins; RequestContext.bind_generation is write-once and raises on a second call.
  • it applies nothing. apply=None is the documented case in dispatch's docstring, not a placeholder. No contract exists for what a pre-model contribution mutates, and inventing one here -- transcript amendment is the obvious candidate -- would be the fabrication T3.5.2 refused on run. A stage that must rewrite what the model reads has pre-tool's amendment contract as the worked precedent whenever someone brings the evidence for this phase's.
  • it grants explicitly. grants= defaults to None in run_phase and None skips the effect check, so a new call site that forgets the argument reopens STG-8 in silence -- the PR #92 class, which is why a test names it.

PreModelContext dataclass

PreModelContext(plan: Any, request: Any, stage: Any, resolved: Any = None, transcript: Any = None)

What a pre-model stage is handed for one round about to be sent.

run_pre_model async

run_pre_model(plan: Any, request: Any, *, resolved: Any = None, transcript: Any = None, handlers: Mapping[str, StageHandler] | None = None) -> tuple[StageTrace, ...]

Dispatch the pre-model stages for one round about to be sent.

Handlers come from plan.bindings.stage_handlers overlaid with handlers, which exists for tests and for a composition root that has not moved yet. Additive, so a plan's own handler cannot be silently replaced.

Source code in src/symfonic/kernel/pre_model.py
async def run_pre_model(
    plan: Any,
    request: Any,
    *,
    resolved: Any = None,
    transcript: Any = None,
    handlers: Mapping[str, StageHandler] | None = None,
) -> tuple[StageTrace, ...]:
    """Dispatch the ``pre-model`` stages for one round about to be sent.

    Handlers come from ``plan.bindings.stage_handlers`` overlaid with
    ``handlers``, which exists for tests and for a composition root that has
    not moved yet. Additive, so a plan's own handler cannot be silently
    replaced.
    """
    bound = getattr(plan.bindings, "stage_handlers", None) or {}
    table: dict[str, StageHandler] = {**bound, **(handlers or {})}
    traces = await StageDispatcher(table).run_phase(
        plan.stage_program,
        Phase.PRE_MODEL,
        context_for=lambda stage: PreModelContext(
            plan=plan, request=request, stage=stage,
            resolved=resolved, transcript=transcript,
        ),
        # Explicit, not defaulted: see the module docstring.
        grants=frozenset(getattr(plan, "effect_grants", frozenset())),
    )
    # EVT-7. A handler that raises becomes a FAILED trace, and a rung whose
    # traces nothing reads lets a crashed stage finish the turn reporting
    # nothing. Worse here than after the model: a stage that crashes *before*
    # the call has not observed the round it was placed to observe, and the
    # round goes out anyway.
    require_no_crashed_stage(traces, phase="pre-model")
    return traces