symfonic.kernel.invoker¶
invoker ¶
The minimal invocation kernel: one loop, driven entirely by a frozen plan.
Read the loop and notice what is not in it — no provider, no message class,
no tool registry, no feature flag, no if streaming:. Every one of those is
reached through a port the plan bound, which is what makes this the single
invocation path IPL-2 demands rather than the first of several.
All consumer shapes are projections of :class:~symfonic.kernel.runner.InvocationRunner;
this class supplies its phase operations and no longer contains a second loop.
InvocationKernel ¶
Bases: CapabilityRungs
Executes a compiled plan. Holds no state of its own, ever.
The three per-round capability rungs -- post-model, pre-tool and
post-tool -- are inherited from :class:~symfonic.kernel.rungs.CapabilityRungs,
which is a split for the 300-line module budget and nothing more: they are
still methods on this object, because the runner reaches them through the
kernel it was handed.
One instance may serve unlimited concurrent invocations: everything a run
accumulates lives in the :class:RequestContext created for that run, so
two runs of the same plan share nothing but the frozen plan itself.
Every entry point accepts that context as an optional argument. It is a diagnostics seam, not a sharing mechanism — a context that has already served an invocation is refused (RCX-3) — and it is what makes RCX-11's teardown record readable from all four projections rather than from none of them. A lifecycle guarantee no caller can observe is one no caller can hold us to.
assemble_prompt ¶
The prompt-assembly phase, as a value.
A pure function of (plan, request) (STG-7): it produces a value and
performs nothing, which is what keeps the assembled prompt reproducible
from the plan alone. Returning the assembly rather than opening the turn
with it is what gives a future PROMPT_ASSEMBLY stage something to
receive and something to hand back — the seam W2 needs, without any
dispatch yet.
Synchronous on purpose. A deadline reaches work through
lifecycle.bounded, which bounds an awaitable; there is no await
point here for one to fire at, and adding one to a pure function would
buy nothing. When stage dispatch lands it is the dispatcher that must
establish the bounded await, not this method.
Source code in src/symfonic/kernel/invoker.py
bind ¶
The kernel-owned bind phase: capture the generation vector.
Separated from :meth:assemble_prompt so the two phases the ladder
names as distinct are distinct in the code as well. They were one
method, which meant prompt-assembly had no seam a stage could ever
run at — a capability could declare a stage there and the kernel had
nowhere to call it, which is one of the ways "declared but inert"
happened.
bind stays kernel-only (KERNEL_OWNED_PHASES): a capability that
could inject here would observe or outlive a run it does not own.
Source code in src/symfonic/kernel/invoker.py
finish
async
¶
Prepare the outcome before the runner dispatches commit finalizers.
Extraction and conversation projection can fail. The runner calls this before FINALIZE and refreshes duration after it, without repeating either potentially failing operation after a successful commit.
Source code in src/symfonic/kernel/invoker.py
open_turn ¶
Run the kernel-owned bind phase, then assemble the prompt.
Composition only — the two phases are :meth:bind and
:meth:assemble_prompt. Kept as one entry point so runner and every
existing caller are unchanged by the split.
The synchronous path, and it dispatches nothing: bind and
assemble_prompt, no capability stage. A capability that contributed
a prompt-assembly stage sees it run on the other path and not on
this one.
It has no caller in src/. All four public projections — run,
stream, stream_text, stream_typed — go through
InvocationRunner.events, which calls :meth:open_turn_dispatched.
The docstring here used to say this was "what runner calls today",
which stopped being true when the runner moved.
Kept public and kept dispatch-free deliberately: a caller that wants the two phases without adopter code running in them has one entry point that says so, and a contract test pins that it stays silent (#23 slice 3).
Source code in src/symfonic/kernel/invoker.py
open_turn_dispatched
async
¶
open_turn_dispatched(plan: InvocationPlan, ctx: RequestContext, request: TurnRequest, *, handlers: Mapping[str, Any] | None = None) -> tuple[Any, tuple[Any, ...]]
bind + prompt-assembly with the stages actually dispatched.
The composition lives in :mod:symfonic.kernel.prompt_assembly; this is
the kernel-facing name for it, so a caller reaches one object not two.
Source code in src/symfonic/kernel/invoker.py
reserve_requests
staticmethod
¶
Give every requested call a run-unique join key (RES-3).
Tool authorization is not re-derived here: the bound tool port is built from plan group G4 and is the single manifest source, so a second allowlist check in the loop would be a second answer to the same question (IPL-5).
Source code in src/symfonic/kernel/invoker.py
run
async
¶
Collect the blocking projection of the runner's event stream.
The run's deadline is not re-applied here. It is billed once, by the
runner, on every step of forward progress (RunLifecycle.bounded); a
second enforcer would be a second answer to "did this run run out of
time", the two would race, and the teardown record would name whichever
timer won (IPL-5). Everything outside the runner is the collector's own
iteration and one synchronous build_result; neither can hang.
Source code in src/symfonic/kernel/invoker.py
stream ¶
stream(plan: InvocationPlan, request: TurnRequest, *, context: RequestContext | None = None) -> AsyncIterator[KernelEvent]
Return the event stream for one invocation (EVT-1…EVT-10).
Source code in src/symfonic/kernel/invoker.py
stream_text ¶
stream_text(plan: InvocationPlan, request: TurnRequest, *, context: RequestContext | None = None) -> AsyncIterator[str]
Return the string-delta projection without widening the facade API.
Source code in src/symfonic/kernel/invoker.py
stream_typed ¶
stream_typed(plan: InvocationPlan, request: TurnRequest, *, context: RequestContext | None = None) -> AsyncIterator[KernelEvent]
The typed/structured projection, attached in its own right.
It used to be return self.stream(...). ST2 (TA8.29) builds the
facade's typed route on this method, and the alias became a hazard: a
later concession to stream would silently retarget the typed route,
and not inheriting the StreamChunk projection's losses is that
route's whole reason to exist. Same runner, adapter and events,
separately attached, so either can change alone.