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.