symfonic.core.prompt.blocks.snapshot¶
snapshot ¶
BlockSnapshot -- the one view of standing context a delegated child sees.
A parent agent re-resolves its blocks every turn: that is correct for the top-level run, because the user is the thing changing and a correction made two turns ago must land in the prompt. A delegated child is the opposite case. It is handed a self-contained task, runs a bounded loop, and returns. If it re-resolved on every internal turn it would be open to two failures the parent is not:
- Self-contradiction mid-run. A parent (or a sibling child, or the consolidation worker) writing USER_PROFILE while the child is on turn three means the child's turn-four prompt disagrees with its turn-two reasoning, with no event in the transcript to explain the change.
- A prefix that never caches. The child's whole economic case is
that its
L0 + L1prefix is stable for the length of its run. A block whose body can move between internal turns invalidates that prefix at an arbitrary point, and the child re-bills the full prefix.
So a child resolves once, at spawn, into a frozen
:class:BlockSnapshot, and every subsequent prompt build in that run
reads the snapshot rather than the resolver.
Run-local, never instance-local¶
The snapshot lives in a :class:~contextvars.ContextVar slot opened for
the duration of one delegated run, not on the child
SymfonicAgent. That is not a style preference. A child agent object
is constructed once and reused across every delegation for the life of
the process, so a snapshot cached on the instance would serve
delegation #1's blocks to delegation #40 -- the same shape as the
RollingLadderStrategy._held_ids and SoulSchemaStore bugs this
codebase has already shipped twice. Run-local state cannot outlive the
run that opened it: :func:close_run_snapshot resets the token in a
finally, so the slot is gone whether the run returned or raised.
inherit is applied here, and only here¶
:attr:~symfonic.core.prompt.blocks.spec.PromptBlockSpec.inherit
False means "not visible to a delegated child", and this module is
the single place that reads it -- because this module is the single
place a child's blocks are assembled. A block filtered out here is
absent from :attr:BlockSnapshot.blocks and from the rendered body,
so there is no second surface on which it could reappear.
ONBOARDING is the block that motivates the field: it is
inherit=False in the canonical matrix (and, per
:mod:~symfonic.core.prompt.blocks.spec, cannot be widened back to
True), so a subagent spawned mid-conversation can never start
onboarding a user who is already onboarded.
BlockSnapshot
dataclass
¶
BlockSnapshot(
blocks: tuple[ResolvedBlock, ...],
scope_path: str,
resolved_at: datetime,
l1: str | None = None,
revision_key: tuple[str, ...] = (),
resolver_id: int = 0,
)
One delegated run's frozen view of its standing context.
Frozen, and carrying the already rendered body rather than a
promise to render one: byte identity across a child's internal turns
is then a property of the data, not of a memo that a future edit
could key wrongly. :attr:l1 is literally the same str object on
every turn of the run.
resolved_at is the render stamp as well as the audit stamp. The
renderer prints a learned fact's age ("recorded 2026-05-02, 93 days
ago"), so rendering with "now" on each turn could move the body at
midnight inside a long-running child; rendering with the capture
time cannot.
block_names
property
¶
The names this snapshot carries, in render order.
resolver_id
class-attribute
instance-attribute
¶
id() of the :class:PromptBlockResolver this snapshot was
captured from.
v8.20 review fix (S4). Not durable, not serialised, and not meant to
be -- a run-local snapshot never outlives the process, so a per-
process object identity is exactly as stable as it needs to be. It
exists so :meth:matches can tell "captured for this agent" from
"captured for a scope that happens to look the same", which
scope_path alone cannot: see :meth:matches.
matches ¶
True when this snapshot was captured for scope AND resolver.
scope_path alone is keyed the way it is for the reason the
resolver's own cache is: org:acme -> brand:widgets and
org:acme -> brand:gadgets share a tenant id, and one brand's
IDENTITY must never be served into the other's prompt. But
scope_path on its own answers a narrower question than the
run-local slot needs: TWO DIFFERENT agents delegated inside one
run -- each with its own declared blocks and its own
:class:~symfonic.core.prompt.blocks.resolver.PromptBlockResolver
-- can resolve the SAME scope_path (the same tenant), and before
this field existed the second agent's first prompt build matched
the first agent's already-open snapshot on scope_path alone
and received the first agent's rendered blocks -- PLATFORM tier
included -- without its own resolver ever being consulted.
Requiring the resolver identity too is what makes "this run's
snapshot" mean "this AGENT's snapshot for this run", which is
what every caller already assumes it means.
Source code in src/symfonic/core/prompt/blocks/snapshot.py
parts ¶
RunSnapshotSlot
dataclass
¶
The one mutable cell a delegated run owns.
A cell rather than a bare ContextVar[BlockSnapshot | None]
because the capture is lazy -- it happens on the run's first prompt
build, which may execute in a copied context (a task the graph
spawned). A set() there would be invisible to the run's own
context and every later turn would re-resolve; a mutation of a
shared cell is visible everywhere the context was copied from.
store ¶
Install snapshot if the slot is empty; never replace an owner.
First writer wins, and the two ways of losing get different answers because they are different situations:
- Same
(resolver, scope)-- one run's two prompt builds racing on its first turn. The loser adopts the winner, so both turns render identical bytes. Returning its own capture here would give the run two bodies, the exact failure the snapshot exists to prevent. - A different
(resolver, scope)-- a foreign owner. The loser keeps its OWN capture and the slot is left alone. Adopting the winner would serve one agent's rendered blocks, PLATFORM tier included, to an agent whose resolver was never consulted: the cross-agent bleedresolver_idwas added to stop (v8.20 S4), reached from the writer's side instead of the reader's.
PR #79 review fix (round 7): this is a compare-and-set, and it
used to be neither. It wrote unconditionally on a mismatch, so a
foreign late writer evicted the owner -- and
:func:ensure_run_snapshot's own "is the slot empty?" guard
could not prevent that, because it reads the slot BEFORE
awaiting the capture and writes after, and a delegated run's
slot is shared by every task spawned from its context (that
sharing is this class's entire purpose). Two depth-0 nested runs
dispatched concurrently by one turn's tool calls therefore both
saw an empty slot, both captured, and the second evicted the
first; the evicted run then re-resolved on its next build and
evicted the other in turn, so the two ping-ponged and NEITHER
was frozen. The check and the write have to happen together, and
this is the only place they can.
Await-free on purpose: under asyncio that is what makes the
read-decide-write sequence indivisible. Do not add an await
to this method.
Source code in src/symfonic/core/prompt/blocks/snapshot.py
active_run_snapshot ¶
This run's snapshot, or None outside a delegated run.
capture_block_snapshot
async
¶
capture_block_snapshot(
resolver: PromptBlockResolver,
scope: TenantScope,
*,
policy: RenderPolicy = DEFAULT_POLICY,
now: datetime | None = None,
specs: Sequence[PromptBlockSpec] | None = None,
) -> BlockSnapshot
Resolve and render scope's inheritable blocks, once.
This is the child's only resolver call for the whole run. A
fail_closed block that cannot be read raises
:class:~symfonic.core.prompt.blocks.resolver.BlockResolutionError
out of here and stops the delegation, which is the same answer the
parent would get: a child running without its boundaries is worse
than a child that did not run.
A block declared layer="L2" is excluded the same way
:meth:~symfonic.core.prompt.blocks.injection.PromptBlockInjector.build
excludes it from the parent's per-turn render, and for the same
reason (see :func:~symfonic.core.prompt.blocks.injection.cached_layer_blocks).
Unlike the injector, this function is called once per delegated run
rather than once per turn, so it does not need the injector's
per-process dedup set to avoid log spam -- see :func:_warn_skipped.
specs narrows the capture to a subset of the resolver's declared
specs, forwarded verbatim to
:meth:~symfonic.core.prompt.blocks.resolver.PromptBlockResolver.resolve
(which rejects anything not already declared) exactly as
:meth:~symfonic.core.prompt.blocks.injection.PromptBlockInjector.build
forwards it. Its one caller is the scope-less lane
(:func:~symfonic.core.prompt.blocks.scopeless.render_deployment_global),
which must freeze only the deployment-global half under the reserved
scope -- capturing the whole declared set there would resolve
tenant-keyed specs against a scope no operator configured, which is
the one thing that lane exists to refuse. Passed to resolve
ONLY when it is actually a narrowing: this function takes a
duck-typed resolver, so an unconditional specs=None keyword
would turn every existing resolve(self, scope) implementation
into a TypeError on the ordinary scoped path.
Source code in src/symfonic/core/prompt/blocks/snapshot.py
close_run_snapshot ¶
Close the slot token opened, restoring whatever preceded it.
Called from a finally: a run that raised must not leave its
snapshot visible to the caller that outlives it.
Source code in src/symfonic/core/prompt/blocks/snapshot.py
ensure_run_snapshot
async
¶
ensure_run_snapshot(
resolver: PromptBlockResolver,
scope: TenantScope,
*,
policy: RenderPolicy = DEFAULT_POLICY,
now: datetime | None = None,
specs: Sequence[PromptBlockSpec] | None = None,
) -> BlockSnapshot | None
Return this run's snapshot, capturing it once if it has none yet.
None means "no slot is open" -- an ordinary top-level run, which
must keep resolving per turn. It never means "the snapshot is
empty": an empty capture is a real :class:BlockSnapshot whose
:attr:~BlockSnapshot.l1 is None, so a child whose blocks all
declared inherit=False still gets a frozen answer rather than
falling back to a fresh resolve on every turn.
A slot with an existing snapshot for a different resolver or
scope is not this call's to overwrite. agent_depth=0 (the
agent-as-tool / adopter pattern) opens no slot of its own and
inherits whatever run-local slot a delegated ancestor already
opened, so a foreign (resolver, scope) pair reaching here is
expected, not a bug to raise on -- but calling :meth:RunSnapshotSlot.store
with it would replace the owning snapshot, and the owning agent's
next prompt build would then fail :meth:BlockSnapshot.matches,
re-resolve, and get a new l1 object -- the exact prefix-stability
break the slot exists to prevent. So a mismatch with an existing
snapshot captures fresh and returns it locally, touching neither the
slot nor the owner's already-stored snapshot; only a genuinely empty
slot is written to.
The existing read below cannot be the one that enforces that,
though, and round 7 moved the enforcement into
:meth:RunSnapshotSlot.store where it belongs: the capture in
between is an await, and the slot is shared with every task
spawned from this run's context, so two concurrent first captures
both read an empty slot and both proceeded to write. The read that
survives here is a fast path -- it skips a redundant capture when
this run's snapshot is already installed -- not a guarantee.
specs is forwarded to :func:capture_block_snapshot; see there.
Source code in src/symfonic/core/prompt/blocks/snapshot.py
in_run_snapshot_scope ¶
inheritable_blocks ¶
Return the blocks a delegated child may see.
inherit defaults to True -- read through getattr so a
spec-shaped stand-in in a test is not required to carry the field --
because the safe default for a declared block is that the child
gets it. The blocks that must not travel say so explicitly, and the
canonical matrix says it for ONBOARDING on the operator's behalf.
Source code in src/symfonic/core/prompt/blocks/snapshot.py
open_run_snapshot ¶
Open a fresh, empty snapshot slot for one delegated run.
Returns the token :func:close_run_snapshot must be handed. A
fresh slot per run is what makes a second delegation to the same
child object get a second snapshot rather than the first one's.