symfonic.kernel¶
kernel ¶
symfonic.kernel โ compiled invocation and shared event projections.
Two things live here and nothing else: the function that turns a normalized
configuration into a frozen :class:InvocationPlan, and the loop that executes
one. Both are written against symfonic.kernel.contracts and the standard
library alone โ no provider SDK, no message library, no transport โ which is
the property the architecture gate enforces on every commit.
The W1 bootstrap invocation core (symfonic.agent._bootstrap) was absorbed
into this package by T2.3.2, retiring the transitional second path its charter
sanctioned. The public facade's own behavior is unchanged, and the T2.1.3
contract suite reruns against this kernel to prove it.
AdapterMetrics
dataclass
¶
AdapterMetrics(high_watermark: int = 0, byte_high_watermark: int = 0, events_shed: dict[str, int] = dict(), blocked_seconds: float = 0.0, terminal_delivery_failed: bool = False, abandoned: bool = False)
Observable pressure for one adapter on one run (BP-12).
BackgroundRegistry ¶
Every task one invocation spawned, owned from creation to teardown.
Source code in src/symfonic/kernel/background.py
entries
property
¶
The owner/purpose/deadline of every task still held.
drain
async
¶
Await what finishes inside the grace window, cancel and await the rest.
Closing first is what makes the window a bound: a task that spawned another task on its way out would otherwise refill the registry behind the drain, and the loop would be as long as the work chose to make it.
Source code in src/symfonic/kernel/background.py
spawn ¶
spawn(work: Coroutine[Any, Any, Any], *, owner: str, purpose: str, deadline_seconds: float | None = None) -> asyncio.Task[Any]
Register and start one unit of run-owned work.
Every rejection closes work first. A coroutine that is refused and
then left unawaited would surface as a RuntimeWarning from whatever
code happened to run next, attributing this module's refusal to an
innocent bystander.
Source code in src/symfonic/kernel/background.py
BoundedEventBuffer ¶
A per-run queue bounded by both event count and approximate bytes.
One slot is held back from non-terminal events under reserve. Under
preempt, a terminal event evicts only the oldest non-terminal entries.
No module-global registry or storage is involved (BP-8/BP-13).
Source code in src/symfonic/kernel/backpressure.py
close
async
¶
Detach this run's consumer and release all buffered references.
Source code in src/symfonic/kernel/backpressure.py
get
async
¶
Take the oldest surviving event, preserving emission order.
Source code in src/symfonic/kernel/backpressure.py
put
async
¶
Put an event, returning False only for a declared shed.
Source code in src/symfonic/kernel/backpressure.py
CallbackEventAdapter ¶
CallbackEventAdapter(policy: EventAdapter, callbacks: Iterable[Callable[[KernelEvent], Any]], *, deadline_seconds: float | None, context: RequestContext | None = None)
Awaited, ordered, error-isolated callback event delivery.
Source code in src/symfonic/kernel/fanout.py
CompileRequest
dataclass
¶
CompileRequest(config_digest: str, scope: RequestScope = RequestScope(), model: ModelResolution = ModelResolution(), instructions: str | None = None, tools: Sequence[ToolDescriptor] = (), stages: Sequence[StageDescriptor] = (), bindings: ServiceBindings = ServiceBindings(), effect_grants: frozenset[str] = frozenset(), limits: PlanLimits = PlanLimits(), event_program: EventProgram = EventProgram(), capabilities: Sequence[str] = ())
The compiler's only accepted input (IPL-1 step 1).
It is a normalized value, never raw adopter configuration: re-parsing legacy input inside the compiler is how a second, subtly different interpretation of a config file gets born.
DrainReport
dataclass
¶
What draining the registry cost: work that finished, work cut, work that failed.
failures is not a subset of cancelled: a task can finish well inside
the grace window and still have raised. Reporting the two separately is what
lets teardown say "nothing was forced, but something owned by this run
broke" โ a sentence the old awaited/cancelled pair could not form.
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.
Source code in src/symfonic/kernel/invoker.py
InvocationRunner ¶
Drive a run and emit its sole internal stream.
stream_model selects only the provider transport. It does not select a
loop, accumulator, result builder, lifecycle, or event pipeline: those are
all this method, once.
events
async
¶
events(kernel: Any, plan: InvocationPlan, request: TurnRequest, *, stream_model: bool, context: RequestContext | None = None) -> AsyncIterator[KernelEvent]
Yield one run's events, owning its lifecycle on every exit path.
context is a seam, not a sharing mechanism: a caller that needs to
read the run's teardown record supplies the context it constructed for
this run. A context that has already run is refused, which is RCX-3
enforced rather than merely documented.
Source code in src/symfonic/kernel/runner.py
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 | |
RequestContext ¶
Request-local mutable state, with one writer per slot (RCX-2).
Source code in src/symfonic/kernel/context.py
lifecycle
property
¶
The run's lifecycle coordinator โ resources, work, and teardown.
resolved
property
¶
The turn's resolved inputs, or None before prompt assembly ends.
Read-only by construction: ResolvedInputs has no mutating method and
freezes its payloads, so handing it to a later phase hands a fact rather
than a channel.
bind_generation ¶
Record the generation this invocation runs against, exactly once.
Write-once is what keeps a long-lived agent from mixing generations mid-invocation when a cutover flips underneath it (T2.3.6): the plan holds the binding stage, and the context holds the binding result.
Source code in src/symfonic/kernel/context.py
bind_resolved ¶
Record the turn's resolved-input snapshot, exactly once (#24).
Before this, the snapshot was a local in run_prompt_assembly: built
by the resolution pass, handed to the compilation pass, and unreachable
the moment that function returned. A value published by a
prompt-assembly stage could not be read by a later phase at all โ not
because the envelope forbade it, but because nothing carried it.
Write-once for the same reason bind_generation is, and the reason
matters more here. scratch(namespace) could also transport a value,
and it is the wrong carrier: it hands back the live dict, so any holder
can rewrite what a resolution stage decided. A governance verdict read
three phases later must have one answer per turn, not a
last-writer-wins one. ResolvedInputs is already deeply immutable and
refuses payloads it cannot freeze, so the only mutability left to remove
was the binding itself.
Source code in src/symfonic/kernel/context.py
reserve ¶
Return request with a run-unique call_id.
Source code in src/symfonic/kernel/context.py
reserve_call_id ¶
Return a run-unique tool-call id, keeping the provider's when usable.
Source code in src/symfonic/kernel/context.py
scratch ¶
spawn ¶
spawn(work: Any, *, owner: str, purpose: str, deadline_seconds: float | None = None) -> asyncio.Task[Any]
Create run-owned background work (RCX-8/BP-8); never fire-and-forget.
Source code in src/symfonic/kernel/context.py
teardown
async
¶
Unwind this run's lifecycle exactly once and return its record.
ResultCollector ¶
RunLifecycle ¶
RunLifecycle(run_id: str, *, deadline_seconds: float | None = None, grace_seconds: float = DEFAULT_TEARDOWN_GRACE_SECONDS)
Owns one run's releasable state and unwinds it exactly once.
Source code in src/symfonic/kernel/lifecycle.py
deadline
property
¶
The run's clock: how much time is left, and what running out means.
report
property
¶
The teardown record, or None while the run is still open.
acquire ¶
Take a resource and register its release in the same expression.
Returning the resource is what makes the acquisition and the release
impossible to separate in a diff: there is no way to write the first
without the second. The release lands in post_drain: background work
this run spawned may still be writing through the resource, and giving
it back first is a use-after-release the drain would then hide.
Source code in src/symfonic/kernel/lifecycle.py
bounded
async
¶
Await one step of forward progress inside the run's remaining budget.
This is how a deadline reaches a consumer-paced entry point, and it
is the reason the budget is not left to :meth:scope alone: a scope
can only bound a call one task both enters and leaves, so before this
existed deadline_seconds was enforced on run() and silently
ignored on every streaming projection โ one plan value with two
meanings, which is the entry-point drift the kernel exists to remove.
Source code in src/symfonic/kernel/lifecycle.py
deliver_terminal
async
¶
deliver_terminal(event: KernelEvent, deliver: Callable[[KernelEvent], Awaitable[Any]] | None = None) -> bool
Record the run's terminal event and attempt its delivery (BP-10).
The attempt is bounded and unshielded, and that is deliberate. On the cancellation path the delivering task is already cancelled, so a shielded attempt would keep a dead consumer's write alive past the run that owed it; failing fast and recording BP-10 case 3 is the honest outcome. Buffer pressure never reaches here โ capacity for a terminal event is reserved or preempted upstream (BP-4).
A grace of 0 means "cancel owned work immediately", not "skip
teardown"; the delivery it bounds is left unbounded rather than cut
before it can write a byte.
Source code in src/symfonic/kernel/lifecycle.py
ensure_ready
async
¶
Make durable state ready once, registering its teardown on success.
Registration happens here rather than at construction because a checkpointer that never became ready has nothing to flush, and a checkpointer that did must be flushed no matter which of the eight call sites happened to trigger readiness first.
Both land in post_drain: a spawned checkpoint writer flushed before
it was drained would have its writes silently dropped.
Source code in src/symfonic/kernel/lifecycle.py
owns_timeout ¶
push_finalizer ¶
push_finalizer(finalizer: Callable[[], Any], *, name: str | None = None, phase: FinalizerPhase = 'post_drain') -> None
Register cleanup to run in reverse registration order, within its phase.
The default phase runs after owned work is drained, which is what
keeps whatever the finalizer releases alive while the run's own tasks
may still be using it. Pass phase="pre_drain" only for the inverse
dependency: a finalizer the drain itself is waiting on.
Source code in src/symfonic/kernel/lifecycle.py
record_pressure ¶
Freeze one adapter's BP-12 numbers into the run's record.
Source code in src/symfonic/kernel/lifecycle.py
require_open ¶
Refuse post-close mutation and emission (RCX-10, BP-9).
Reads stay legal โ diagnostics about a finished run are the reason the object survives its teardown at all.
Source code in src/symfonic/kernel/lifecycle.py
scope
async
¶
The run's cancellation scope: a deadline is an error, not a cancel.
asyncio.timeout implements an elapsed deadline by cancelling the
body, so without this translation the two situations CXL-6 insists on
separating would reach the caller as the same exception. External
cancellation passes straight through, unswallowed (CXL-2/CXL-3).
Enter it only in a task that both enters and leaves it. The kernel's own
entry points do not: they bill the same clock through :meth:bounded,
one step at a time, which is legal from a generator that may be resumed
by a different task than the one that suspended it.
Source code in src/symfonic/kernel/lifecycle.py
spawn ¶
spawn(work: Any, *, owner: str, purpose: str, deadline_seconds: float | None = None) -> asyncio.Task[Any]
Create run-owned background work (RCX-8/BP-8); never fire-and-forget.
Source code in src/symfonic/kernel/lifecycle.py
teardown
async
¶
Unwind everything, once, and return the record of having done it.
Idempotent because it runs from a finally that several exit paths
can reach: a second call returns the first call's report rather than
re-running finalizers against state they already released.
Source code in src/symfonic/kernel/lifecycle.py
StructuredOutputAdapter ¶
TextStreamAdapter ¶
TypedStreamAdapter ¶
compile_invocation_plan ¶
Compile one immutable, stateless plan. Performs no effect of any kind.
Source code in src/symfonic/kernel/compiler.py
derive_child_plan ¶
Compile a child plan by narrowing the parent (IPL-6).
Every widening attempt is rejected here rather than at the point of use. A sub-agent that could add a tool, a grant, a tenant or a token budget its parent did not have would make the parent's plan a suggestion.