Skip to content

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 + L1 prefix 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

block_names: tuple[str, ...]

The names this snapshot carries, in render order.

empty property

empty: bool

True when this snapshot contributes no bytes to any region.

resolver_id class-attribute instance-attribute

resolver_id: int = 0

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

matches(
    scope: TenantScope, resolver: PromptBlockResolver
) -> bool

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
def matches(self, scope: TenantScope, resolver: PromptBlockResolver) -> bool:
    """``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.
    """
    return scope.scope_path == self.scope_path and self.resolver_id == id(resolver)

parts

parts() -> BlockParts

This snapshot as the prompt paths' :class:BlockParts shape.

Source code in src/symfonic/core/prompt/blocks/snapshot.py
def parts(self) -> BlockParts:
    """This snapshot as the prompt paths' :class:`BlockParts` shape."""
    return BlockParts(l1=self.l1, revision_key=self.revision_key)

RunSnapshotSlot dataclass

RunSnapshotSlot(snapshot: BlockSnapshot | None = None)

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

store(snapshot: BlockSnapshot) -> BlockSnapshot

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 bleed resolver_id was 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
def store(self, snapshot: BlockSnapshot) -> BlockSnapshot:
    """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 bleed ``resolver_id`` was 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.
    """
    current = self.snapshot
    if current is None:
        self.snapshot = snapshot
        return snapshot
    if (
        current.scope_path == snapshot.scope_path
        and current.resolver_id == snapshot.resolver_id
    ):
        return current
    return snapshot

active_run_snapshot

active_run_snapshot() -> BlockSnapshot | None

This run's snapshot, or None outside a delegated run.

Source code in src/symfonic/core/prompt/blocks/snapshot.py
def active_run_snapshot() -> BlockSnapshot | None:
    """This run's snapshot, or ``None`` outside a delegated run."""
    slot = _run_snapshot.get()
    return None if slot is None else slot.snapshot

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
async def 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.
    """
    stamp = now or datetime.now(UTC)
    raw = (
        await resolver.resolve(scope)
        if specs is None
        else await resolver.resolve(scope, specs=specs)
    )
    resolved = inheritable_blocks(raw)
    renderable, skipped = cached_layer_blocks(resolved)
    _warn_skipped(skipped)
    return BlockSnapshot(
        # ``renderable``, NOT ``resolved`` -- review fix (LOW, per-task
        # t10-snapshot-inherit). This used to be ``resolved``, so an L2
        # block (excluded from ``l1`` and, since the fix above, from
        # ``revision_key``) still appeared in ``BlockSnapshot.blocks`` /
        # ``block_names``: the snapshot advertised content its own body
        # did not carry. The module docstring's claim that a filtered
        # block is "absent from BlockSnapshot.blocks *and* from the
        # rendered body" was already true for ``inherit=False`` (filtered
        # above, before this call) but not for a layer=L2 exclusion until
        # this line matched ``revision_key`` below.
        blocks=renderable,
        scope_path=scope.scope_path,
        resolved_at=stamp,
        l1=render_blocks(renderable, policy=policy, now=stamp) or None,
        # Keyed on ``renderable``, NOT ``resolved`` -- the same correction
        # ``PromptBlockInjector.build`` carries. This key travels out as
        # ``BlockParts.revision_key`` and becomes the child's cached-prefix
        # identity, so keying it on the unsplit set would let an L2 block
        # (FOCUS) churn invalidate a child's prefix whose ``l1`` bytes did
        # not change -- defeating, on the delegated path, the exact memo
        # the cached lane exists to provide.
        revision_key=revision_key(renderable, stamp),
        resolver_id=id(resolver),
    )

close_run_snapshot

close_run_snapshot(
    token: Token[RunSnapshotSlot | None],
) -> None

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
def close_run_snapshot(token: Token[RunSnapshotSlot | None]) -> None:
    """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.
    """
    _run_snapshot.reset(token)

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
async def 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.
    """
    slot = _run_snapshot.get()
    if slot is None:
        return None
    existing = slot.snapshot
    if existing is not None and existing.matches(scope, resolver):
        return existing
    captured = await capture_block_snapshot(
        resolver, scope, policy=policy, now=now, specs=specs,
    )
    # ``existing`` is stale from here on: the capture above yields to the
    # loop, and the slot is shared with every task spawned from this
    # run's context. The decision is therefore re-taken inside
    # :meth:`RunSnapshotSlot.store`, which takes it atomically. Round 7.
    installed = slot.store(captured)
    if installed is not captured:
        return installed
    if slot.snapshot is captured:
        return captured
    logger.warning(
        "a run-local prompt-block snapshot slot already holds a snapshot for "
        "a different resolver or scope (scope_path=%r); this is expected for "
        "a depth-0 nested run inheriting a delegated ancestor's slot, so the "
        "new capture is returned without touching the slot -- the owning "
        "snapshot is neither read nor overwritten by this call.",
        scope.scope_path,
    )
    return captured

in_run_snapshot_scope

in_run_snapshot_scope() -> bool

True when a snapshot slot is open -- i.e. inside a delegated run.

Source code in src/symfonic/core/prompt/blocks/snapshot.py
def in_run_snapshot_scope() -> bool:
    """``True`` when a snapshot slot is open -- i.e. inside a delegated run."""
    return _run_snapshot.get() is not None

inheritable_blocks

inheritable_blocks(
    blocks: Iterable[ResolvedBlock],
) -> tuple[ResolvedBlock, ...]

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
def inheritable_blocks(
    blocks: Iterable[ResolvedBlock],
) -> tuple[ResolvedBlock, ...]:
    """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.
    """
    return tuple(b for b in blocks if getattr(b.spec, "inherit", True))

open_run_snapshot

open_run_snapshot() -> Token[RunSnapshotSlot | None]

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.

Source code in src/symfonic/core/prompt/blocks/snapshot.py
def open_run_snapshot() -> Token[RunSnapshotSlot | None]:
    """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.
    """
    return _run_snapshot.set(RunSnapshotSlot())