Skip to content

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

assemble_prompt(plan: InvocationPlan, request: TurnRequest) -> PromptAssembly

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
def assemble_prompt(self, plan: InvocationPlan, request: TurnRequest) -> PromptAssembly:
    """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."""
    return PromptAssembly(
        instructions=plan.instructions,
        prompt=request.prompt,
        attachments=tuple(request.attachments),
        history=tuple(request.history),
    )

bind

bind(plan: InvocationPlan, ctx: RequestContext) -> None

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
def bind(self, plan: InvocationPlan, ctx: RequestContext) -> None:
    """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.
    """
    ctx.bind_generation(plan.identity.plan_id)

finish async

finish(plan: InvocationPlan, ctx: RequestContext, transcript: Any) -> InvocationOutcome

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
async def finish(
    self, plan: InvocationPlan, ctx: RequestContext, transcript: Any
) -> InvocationOutcome:
    """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.
    """
    response = plan.bindings.response
    output = (
        await StructuredOutputAdapter.attach(plan).extract(transcript)
        if response.structured
        else None
    )
    return InvocationOutcome(
        text=ctx.text,
        output=output,
        messages=plan.bindings.conversation.messages(transcript),
        tool_outcomes=tuple(ctx.tool_outcomes),
        usage=ctx.usage,
        run_id=ctx.run_id,
        duration_ms=ctx.duration_ms,
        stop_reason=ctx.stop_reason,
    )

open_turn

open_turn(plan: InvocationPlan, ctx: RequestContext, request: TurnRequest) -> Any

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
def open_turn(self, plan: InvocationPlan, ctx: RequestContext, request: TurnRequest) -> Any:
    """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).
    """
    self.bind(plan, ctx)
    assembly = self.assemble_prompt(plan, request)
    return plan.bindings.conversation.open_turn(assembly)

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
async def open_turn_dispatched(
    self,
    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.
    """
    return await run_prompt_assembly(self, plan, ctx, request, handlers=handlers)

reserve_requests staticmethod

reserve_requests(ctx: RequestContext, requests: Sequence[ToolRequest]) -> tuple[ToolRequest, ...]

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
@staticmethod
def reserve_requests(
    ctx: RequestContext, requests: Sequence[ToolRequest]
) -> tuple[ToolRequest, ...]:
    """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).
    """
    return tuple(ctx.reserve(request) for request in requests)

run async

run(plan: InvocationPlan, request: TurnRequest, *, context: RequestContext | None = None) -> Any

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
async def run(
    self,
    plan: InvocationPlan,
    request: TurnRequest,
    *,
    context: RequestContext | None = None,
) -> Any:
    """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."""
    from symfonic.kernel.runner import InvocationRunner

    ctx = context if context is not None else RequestContext(
        plan, run_id=request.run_id or None
    )
    source = InvocationRunner().events(self, plan, request, stream_model=False, context=ctx)
    return await ResultCollector.attach(plan).collect(source)

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
def stream(
    self,
    plan: InvocationPlan,
    request: TurnRequest,
    *,
    context: RequestContext | None = None,
) -> AsyncIterator[KernelEvent]:
    """Return the event stream for one invocation (EVT-1…EVT-10)."""
    from symfonic.kernel.runner import InvocationRunner

    source = InvocationRunner().events(self, plan, request, stream_model=True, context=context)
    return TypedStreamAdapter.attach(plan).project(source)

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
def stream_text(
    self,
    plan: InvocationPlan,
    request: TurnRequest,
    *,
    context: RequestContext | None = None,
) -> AsyncIterator[str]:
    """Return the string-delta projection without widening the facade API."""
    from symfonic.kernel.runner import InvocationRunner

    source = InvocationRunner().events(self, plan, request, stream_model=True, context=context)
    return TextStreamAdapter.attach(plan).project(source)

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.

Source code in src/symfonic/kernel/invoker.py
def stream_typed(
    self,
    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."""
    from symfonic.kernel.runner import InvocationRunner

    source = InvocationRunner().events(self, plan, request, stream_model=True, context=context)
    return TypedStreamAdapter.attach(plan).project(source)