Skip to content

symfonic.kernel.post_tool

post_tool

The post-tool seam: capability stages that read a round's tool results.

TA8.50 -- #23's sixth rung -- and deliberately the same shape as :mod:symfonic.kernel.post_model and :mod:symfonic.kernel.pre_tool — dispatch and nothing else, grants passed explicitly, require_no_crashed_stage after, no contract invented for what a contribution mutates. A sixth rung with looser rules than the fifth would be a hole, not a feature: whichever phase a capability attaches to, an ungranted effect is refused before the handler is entered and a handler that raises ends the turn.

This is the rung TA8.39 found declared and undispatched. Phase.POST_TOOL sat in PHASE_LADDER between pre-tool and finalize while kernel/invoker.py knew nothing about it, so a capability declaring a post-tool stage compiled into the plan, took its place in the compiled order, received its effect grants, and was invoked in no pass. Eight rows of the configuration inventory (C1-I, the tool-result lifecycle) were overridden on that one structural fact. This module is the seam those rows were waiting on; it does not admit any of them.

Two things are particular to this phase.

Where it sits, exactly. The dispatch is after record_tools — after the tool port produced its outcomes and after the conversation port folded them into the transcript. That is what "before the next round sees the result" means operationally: the transcript a post-tool stage is handed is the very object the next model.invoke is called with, so a stage that rewrites it changes what the next round reads. Dispatching one line earlier would hand a stage a transcript the results had not landed in yet, and a stage editing that would be editing a round the conversation port was about to overwrite. Driven, not documented: test_post_tool_dispatch asserts both halves — the stage sees the outcomes, and the next round sees the stage's edit.

Its cardinality is the tool-bearing rounds', not the model rounds'. It runs once per round that actually called tools — never on the round that answers directly, which breaks out of the loop above it, and never when the run is cancelled or the deadline fires while a tool is still executing. The seam is below the tool execution, so there is nothing here to unwind: a turn that dies between the tool and the rung has simply not reached the rung.

PostToolContext dataclass

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

What a post-tool stage is handed.

requests and outcomes are the whole batch, positionally aligned — the same per-batch decision :class:~symfonic.kernel.pre_tool.PreToolContext records, and for the same reason: the policies this phase is for judge a round's results together (what to keep, what to offload) rather than one call at a time, and narrowing the seam to a single result would make every such policy re-derive the batch it was already handed.

turn comes too, because what the model said while asking is part of the reading — a retention policy that cannot see the request cannot tell a result that answered the question from one that did not.

No RequestContext, for the reason StageContext states: a stage returns rather than mutates, so handing it the mutable run context would be handing it the thing it must not touch.

run_post_tool async

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

Dispatch the post-tool stages for one round's completed calls.

Handlers come from plan.bindings.stage_handlers overlaid with handlers, additively, exactly as the other two rungs resolve them.

Called only on a round that had calls and whose calls all completed; see the module docstring on why the zero case belongs to the control flow and not to this function.

Source code in src/symfonic/kernel/post_tool.py
async def run_post_tool(
    plan: Any,
    request: Any,
    turn: Any,
    requests: Sequence[Any],
    outcomes: Sequence[Any],
    *,
    resolved: Any = None,
    transcript: Any = None,
    handlers: Mapping[str, StageHandler] | None = None,
) -> tuple[StageTrace, ...]:
    """Dispatch the ``post-tool`` stages for one round's completed calls.

    Handlers come from ``plan.bindings.stage_handlers`` overlaid with
    ``handlers``, additively, exactly as the other two rungs resolve them.

    Called only on a round that had calls and whose calls all completed; see the
    module docstring on why the zero case belongs to the control flow and not to
    this function.
    """
    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.POST_TOOL,
        context_for=lambda stage: PostToolContext(
            plan=plan,
            request=request,
            stage=stage,
            resolved=resolved,
            turn=turn,
            requests=tuple(requests),
            outcomes=tuple(outcomes),
            transcript=transcript,
        ),
        # Explicit. ``None`` skips the effect check rather than failing it, so
        # a call site that forgets this argument reopens STG-8 in silence --
        # the PR #92 class, which is why every rung passes it by hand and every
        # rung has a test that fails when it stops.
        grants=frozenset(getattr(plan, "effect_grants", frozenset())),
    )

    # EVT-7, the policy every other rung applies. A handler that raises is
    # turned into a FAILED trace by the dispatcher, and a trace nobody reads is
    # a crash the turn reports as success.
    #
    # It is worth stating what this phase loses without it: a tool-result policy
    # that crashes has already let the raw result into the transcript, so the
    # turn would continue on exactly the state the policy existed to change,
    # with no signal that it did not run.
    require_no_crashed_stage(traces, phase="post-tool")
    return traces