Skip to content

symfonic.kernel.pre_tool

pre_tool

The pre-tool seam: capability stages that see a round's tool calls.

23 slice 2, and the same shape as :mod:symfonic.kernel.post_model — dispatch

and nothing else, grants passed explicitly, no contract invented for what a contribution mutates.

Two things are particular to this phase.

It is per batch, not per call. The two governance stages this phase is for (tool_preconditions, policy_steering) both delegate to sweep, which asks each applicable objector about each call. One invocation covers the whole collection, so narrowing the seam to a single call would either need the subject narrowed first or make every objector re-examine calls it has already judged. The requests are handed over as a tuple for that reason.

Its cardinality can be zero. The runner breaks out of the loop when a round returns no tool requests, so a turn that answers directly runs this phase no times at all. That falls out of the existing control flow for free, which is exactly why it needs a test of its own: nothing else guards it, and a dispatch moved one line earlier would run on every round without any other test noticing.

PreToolContext dataclass

PreToolContext(plan: Any, request: Any, stage: Any, resolved: Any, turn: Any, requests: tuple[Any, ...], transcript: Any = None)

What a pre-tool stage is handed.

requests is the whole batch the round asked for, already reserved by the kernel — so a stage sees the call ids the transcript will join on, not the provider's own. turn comes too, because a precondition is a fact about the call and a policy is a fact about the deployment, and the second sometimes needs what the model said to judge the first.

run_pre_tool async

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

Dispatch the pre-tool stages, and return the calls to dispatch.

Called only on a round that has calls; see the module docstring on why the zero case is the control flow's and not this function's.

Returns (traces, requests). The second element is the batch the loop must execute: the reserved calls, or whatever a resolution stage amended them to. Before this, the loop iterated the batch built before the stages ran, so a stage that rewrote a call had nowhere to put it -- governance_rungs._undeliverable said so in the framework's own words, and three separate defects were that one sentence.

The mechanism is prompt-assembly's, not a new one: a :class:~.contracts.stages.StageKind.RESOLUTION stage returns applied(ResolvedInput(...)) and the kernel folds it. What was missing is that this phase folded nothing.

Source code in src/symfonic/kernel/pre_tool.py
async def run_pre_tool(
    plan: Any,
    request: Any,
    turn: Any,
    requests: Sequence[Any],
    *,
    resolved: Any = None,
    transcript: Any = None,
    handlers: Mapping[str, StageHandler] | None = None,
) -> tuple[tuple[StageTrace, ...], tuple[Any, ...]]:
    """Dispatch the ``pre-tool`` stages, and return the calls to dispatch.

    Called only on a round that has calls; see the module docstring on why the
    zero case is the control flow's and not this function's.

    Returns ``(traces, requests)``. The second element is the batch the loop
    must execute: the reserved calls, or whatever a resolution stage amended
    them to. Before this, the loop iterated the batch built *before* the
    stages ran, so a stage that rewrote a call had nowhere to put it --
    ``governance_rungs._undeliverable`` said so in the framework's own words,
    and three separate defects were that one sentence.

    The mechanism is prompt-assembly's, not a new one: a
    :class:`~.contracts.stages.StageKind.RESOLUTION` stage returns
    ``applied(ResolvedInput(...))`` and the kernel folds it. What was missing
    is that this phase folded nothing.
    """
    bound = getattr(plan.bindings, "stage_handlers", None) or {}
    table: dict[str, StageHandler] = {**bound, **(handlers or {})}
    amended: list[Any] = []

    async def take_amendment(stage: Any, contribution: Any) -> None:
        """Adopt one resolution stage's rewritten batch.

        Recorded rather than applied blindly: a stage that answered with
        something other than a ``ResolvedInput`` carrying calls has not
        amended anything, and silently ignoring it would let a capability
        believe it had rewritten a call it did not.
        """
        if isinstance(contribution, ResolvedInput):
            value = contribution.value
            if isinstance(value, tuple) and value:
                amended.append((stage, contribution))

    traces = await StageDispatcher(table).run_phase(
        plan.stage_program,
        Phase.PRE_TOOL,
        context_for=lambda stage: PreToolContext(
            plan=plan,
            request=request,
            stage=stage,
            resolved=resolved,
            turn=turn,
            requests=tuple(requests),
            transcript=transcript,
        ),
        # Explicit. ``None`` skips the effect check rather than failing it.
        grants=frozenset(getattr(plan, "effect_grants", frozenset())),
        apply=take_amendment,
    )

    # EVT-7, the same policy prompt-assembly applies -- and this call exists
    # because it did not. A handler that raises is turned into a FAILED trace by
    # the dispatcher, and nothing read the traces, so a capability stage could
    # crash and the turn would finish reporting nothing.
    #
    # A precondition stage is the worst place to lose one: it is the phase whose
    # whole job is refusing a call, and a crashed refusal that reads as silence
    # lets the call run.
    require_no_crashed_stage(traces, phase="pre-tool")
    return traces, _final_batch(tuple(requests), amended)