Skip to content

symfonic.core.prompt.blocks.resolver

resolver

PromptBlockResolver -- the pinned lane, structurally exempt from ranking.

Ordinary recall is probabilistic: a query is embedded, candidates are scored, everything under hydration_min_relevance is dropped and what survives is truncated to top-K. That is the right shape for "what did we discuss about invoices", and the wrong shape for "who is this user". Asking about the weather has no lexical or semantic overlap with SOUL: name is Amiel, so a ranked lane quietly forgets the user's name on exactly the turns that do not mention it.

This resolver is the other lane. For every declared block it performs one deterministic read and returns what it found:

  • an adapter-backed block is await source.load(scope, spec.block_id);
  • a memory-backed block is a label-prefix scan -- query_nodes(scope, label_prefix=..., limit=None).

The bypass is structural, not a flag

hydration_min_relevance is applied inside _hydrate_impl (agent/engine.py) and the top-K break inside retrieve() (memory/retrieval/engine.py). This module never calls either function, so there is no gate for a future edit to mis-set: the setting cannot suppress a pinned block because the code path carrying the setting is never entered. For the same reason the lane issues zero embedding calls and therefore cannot fail on an embedding-provider outage, and a block resolves identically for every query -- there is no query parameter on :meth:PromptBlockResolver.resolve to vary.

Failure policy, and why last_known_good is memory-only

Each spec declares :attr:PromptBlockSpec.failure_policy:

  • fail_closed -- raise :class:BlockResolutionError. The default for the authored tiers: an agent whose BOUNDARIES block is missing is an agent running without its constraints, which is worse than an agent that did not run.
  • omit -- drop the block. The default for the learned tiers: a missing USER_PROFILE costs personalisation for a turn.
  • last_known_good -- serve a revision this process loaded successfully during its own lifetime, and nothing else. It is not a disk cache and never survives a restart: content republished from an unattributed on-disk copy is content no operator can date, and a process that has never seen the block has no "last known good" to serve, so the policy degrades to fail_closed rather than inventing an empty block.

The cache key is (scope.scope_path, block_id) -- the same :func:~symfonic.core.prompt.blocks.protocol.block_isolation_key the sources store under, and deliberately not tenant_id. org:acme -> brand:widgets and org:acme -> brand:gadgets share a tenant_id; keyed on it, one brand's cached IDENTITY would be served into the other brand's prompt during an outage.

Learned blocks keep per-fact provenance

A memory-backed block does not become a paragraph of text. Each scanned node becomes one :class:~symfonic.core.prompt.blocks.types.BlockFact carrying that node's own source and recorded_at, so the renderer can attribute each statement individually instead of stamping the whole block with one invented timestamp. That mapping -- node to fact, and the durable revision over the scan -- lives in :mod:~symfonic.core.prompt.blocks.memory_lane, so this module stays about which blocks to read and what to do when a read fails.

DEFAULT_GATE_TIMEOUT module-attribute

DEFAULT_GATE_TIMEOUT = 0.5

Seconds an awaitable render_when predicate is given.

Deliberately far tighter than the load timeout. A gate is a host-state lookup; anything that needs seconds is a source, and should be one.

PR #79 review fix (item 7): this bound applies only to the awaitable branch of :meth:PromptBlockResolver._gate_allows -- the coroutine is awaited under asyncio.wait_for(..., timeout=self._gate_timeout). A synchronous render_when runs inline, by design (see _gate_allows's own docstring: a thread hop per block per turn would cost more than the check it is dispatching), and nothing bounds it. A sync predicate must therefore be a cheap in-memory read and must not block -- one that does stalls the event loop for every concurrent turn sharing this process, not just the turn that called it, and this timeout will not save it.

DEFAULT_LAST_KNOWN_GOOD_MAX_ENTRIES module-attribute

DEFAULT_LAST_KNOWN_GOOD_MAX_ENTRIES = 4096

Entries retained in the last_known_good cache before the oldest, least-recently-served one is evicted.

DEFAULT_LOAD_TIMEOUT module-attribute

DEFAULT_LOAD_TIMEOUT = 5.0

Seconds a single source read (or memory-lane scan) is given before it counts as a failure. Bounds the one failure mode none of the three on_source_failure policies can see on their own: a source that hangs rather than raises.

BlockGateError

Bases: TypeError

A render_when predicate misbehaved.

A TypeError, deliberately NOT a StorageError, for the reason written out at the except StorageError clause below: anything a block raises that IS a StorageError gets routed through on_source_failure, and an omit-policy block would then vanish from every prompt, silently, for as long as the host's bug lived.

That asymmetry is worse for a gate than for a source. on_source_failure exists for source unavailability -- a wedged mount, a database outage, conditions outside the process that may clear on their own. A predicate is in-process host code: if it raises once it will raise every turn, and its failure leaves no trace at all in the rendered prompt. Failing the turn is louder and therefore safer.

BlockLoadTimeout

Bases: StorageError

A source (or the memory-lane scan) did not respond within budget.

Deliberately a :class:~symfonic.core.protocols.StorageError subclass rather than a bare asyncio.TimeoutError: a hang is a form of source unavailability exactly like a raised exception, so it must reach :meth:PromptBlockResolver._apply_failure_policy through the same narrowed catch (v8.20 review fix S7) that keeps a genuine programming error from being absorbed by on_source_failure.

BlockResolutionError

BlockResolutionError(
    *,
    block_id: str,
    scope_path: str,
    policy: str,
    reason: str,
)

Bases: StorageError

A block could not be resolved and its policy said to fail the turn.

Raised for fail_closed, and for last_known_good when this process has never loaded the block successfully. It derives from :class:~symfonic.core.protocols.StorageError so a caller already mapping storage failures does not need a new except clause, and it carries block_id/scope_path/policy so an operator can tell which block in which scope stopped the turn.

Source code in src/symfonic/core/prompt/blocks/resolver.py
def __init__(
    self,
    *,
    block_id: str,
    scope_path: str,
    policy: str,
    reason: str,
) -> None:
    self.block_id = block_id
    self.scope_path = scope_path
    self.policy = policy
    super().__init__(
        f"block {block_id!r} could not be resolved for scope_path "
        f"{scope_path!r} under policy {policy!r}: {reason}"
    )

MemoryLaneScanner

Bases: Protocol

The one memory operation the pinned lane is allowed to perform.

Satisfied structurally by the graph store, whose query_nodes accepts these arguments among others. Nothing here can rank, score or embed.

query_nodes async

query_nodes(
    scope: TenantScope,
    *,
    label_prefix: str | None = None,
    limit: int | None = None,
) -> Sequence[Any]

Return every node in scope whose label starts with the prefix.

Source code in src/symfonic/core/prompt/blocks/memory_lane.py
async def query_nodes(
    self,
    scope: TenantScope,
    *,
    label_prefix: str | None = None,
    limit: int | None = None,
) -> Sequence[Any]:
    """Return every node in ``scope`` whose label starts with the prefix."""
    ...

PromptBlockResolver

PromptBlockResolver(
    specs: Iterable[PromptBlockSpec] = (),
    *,
    memory: MemoryLaneScanner | None = None,
    load_timeout: float = DEFAULT_LOAD_TIMEOUT,
    gate_timeout: float = DEFAULT_GATE_TIMEOUT,
    last_known_good_max_entries: int = DEFAULT_LAST_KNOWN_GOOD_MAX_ENTRIES,
)

Resolves every declared block for a scope, deterministically.

specs are the declared blocks. memory is required only when at least one spec selects the memory lane; declaring one without it is a construction-time error rather than a per-turn failure, so a misconfigured deployment does not start.

The instance holds one piece of mutable state: the last_known_good cache, which is in-process by construction. It lives on the resolver rather than in a module-level dict so a second resolver -- a test, a second tenant's worker -- cannot inherit revisions it never loaded. It is populated only for blocks whose failure_policy is last_known_good (no other policy ever reads it) and bounded by last_known_good_max_entries, an LRU over (scope_path, block_id) so a per-conversation scope cannot grow it without limit.

Source code in src/symfonic/core/prompt/blocks/resolver.py
def __init__(
    self,
    specs: Iterable[PromptBlockSpec] = (),
    *,
    memory: MemoryLaneScanner | None = None,
    load_timeout: float = DEFAULT_LOAD_TIMEOUT,
    gate_timeout: float = DEFAULT_GATE_TIMEOUT,
    last_known_good_max_entries: int = DEFAULT_LAST_KNOWN_GOOD_MAX_ENTRIES,
) -> None:
    self.specs: tuple[PromptBlockSpec, ...] = tuple(specs)
    self.memory = memory
    self._load_timeout = load_timeout
    self._gate_timeout = gate_timeout
    self._last_known_good: OrderedDict[tuple[str, str], BlockRevision] = (
        OrderedDict()
    )
    self._last_known_good_max_entries = max(1, int(last_known_good_max_entries))
    self._check_specs()

resolve async

resolve(
    scope: TenantScope,
    *,
    specs: Iterable[PromptBlockSpec] | None = None,
) -> tuple[ResolvedBlock, ...]

Resolve every declared block for scope.

There is no query parameter, and adding one would be the bug: what a pinned block contains must not depend on what the user just typed. Ordering is (tier rank, spec.order, spec.name) -- stable across turns, so the rendered prefix does not churn, and tier rank sorts first so a non-canonical learned-tier spec cannot declare an order low enough to render ahead of an authored (platform/operating) block: order only breaks ties within a tier, it never crosses one.

Blocks whose source failed under omit are absent from the result; a fail_closed failure raises :class:BlockResolutionError instead of returning a partial set.

specs narrows the pass to a subset of the declared specs, for the one caller that has to resolve fewer blocks than were declared: the scope-less lane (:mod:~symfonic.core.prompt.blocks.scopeless), which resolves only the deployment-global blocks because the rest have no scope to key on. It is an override, not an injection point -- a spec this resolver never saw is rejected, so a caller cannot smuggle in a block that skipped :meth:_check_specs (duplicate names, a memory-lane spec with no scanner).

Source code in src/symfonic/core/prompt/blocks/resolver.py
async def resolve(
    self,
    scope: TenantScope,
    *,
    specs: Iterable[PromptBlockSpec] | None = None,
) -> tuple[ResolvedBlock, ...]:
    """Resolve every declared block for ``scope``.

    There is no query parameter, and adding one would be the bug:
    what a pinned block contains must not depend on what the user
    just typed. Ordering is ``(tier rank, spec.order, spec.name)`` --
    stable across turns, so the rendered prefix does not churn, and
    tier rank sorts first so a non-canonical learned-tier spec cannot
    declare an ``order`` low enough to render ahead of an authored
    (platform/operating) block: ``order`` only breaks ties *within*
    a tier, it never crosses one.

    Blocks whose source failed under ``omit`` are absent from the
    result; a ``fail_closed`` failure raises
    :class:`BlockResolutionError` instead of returning a partial set.

    ``specs`` narrows the pass to a **subset of the declared specs**,
    for the one caller that has to resolve fewer blocks than were
    declared: the scope-less lane
    (:mod:`~symfonic.core.prompt.blocks.scopeless`), which resolves
    only the deployment-global blocks because the rest have no scope
    to key on. It is an override, not an injection point -- a spec
    this resolver never saw is rejected, so a caller cannot smuggle
    in a block that skipped :meth:`_check_specs` (duplicate names, a
    memory-lane spec with no scanner).
    """
    selected = self.specs if specs is None else tuple(specs)
    if specs is not None:
        declared = {id(spec) for spec in self.specs}
        undeclared = sorted(
            spec.name for spec in selected if id(spec) not in declared
        )
        if undeclared:
            raise ValueError(
                f"resolve(specs=...) may only narrow the declared specs; "
                f"{undeclared} were never declared on this resolver and "
                "therefore never passed its construction-time checks"
            )
    resolved: list[ResolvedBlock] = []
    for spec in sorted(selected, key=_resolution_order):
        block = await self.resolve_block(scope, spec)
        if block is not None:
            resolved.append(block)
    return tuple(resolved)

resolve_block async

resolve_block(
    scope: TenantScope, spec: PromptBlockSpec
) -> ResolvedBlock | None

Resolve one block, applying its on_source_failure policy.

Returns None under omit or when a render_when gate declines.

Source code in src/symfonic/core/prompt/blocks/resolver.py
async def resolve_block(
    self, scope: TenantScope, spec: PromptBlockSpec
) -> ResolvedBlock | None:
    """Resolve one block, applying its ``on_source_failure`` policy.

    Returns ``None`` under ``omit`` or when a ``render_when`` gate
    declines.
    """
    # Checked BEFORE _load, so a gated-off block costs no source I/O
    # at all. This is also why the gate belongs here rather than in a
    # source: both consumers of resolution -- the injector and the
    # snapshot lane -- funnel through resolve(), so a gate anywhere
    # else would cover one and let a delegated child render a block
    # the parent gated off.
    if spec.render_when is not None and not await self._gate_allows(scope, spec):
        return None

    try:
        revision = await self._load(scope, spec)
    except asyncio.CancelledError:
        # Cancellation is not a source failure; swallowing it under
        # `omit` would leave a cancelled turn running.
        raise
    except (ComputedBlockContractError, MissingFactsError, BlockGateError):
        # PR #79 review fix (item 5): named ahead of the ``except
        # StorageError`` / ``except Exception`` arms below so a
        # contract violation is never routed through
        # ``on_source_failure``, however it reaches this method.
        # ``ComputedBlockContractError`` is the case ``source.load()``
        # can actually raise here (a ``ComputedBlockSource`` callable
        # with the wrong signature or return shape -- see the S7 note
        # kept below); ``MissingFactsError`` and ``BlockGateError``
        # are named defensively for the same reason even though
        # today's call graph raises them one layer away (``_gate_
        # allows`` above this try, ``render_blocks`` after
        # ``resolve()`` returns) -- a future source implementation
        # that raises either from inside ``load()`` must not have
        # that policy violation silently absorbed either.
        #
        # v8.20 review fix (S7): this arm used to be ``except
        # StorageError`` alone with a bare ``except Exception``
        # falling through the module boundary -- see git history --
        # which absorbed EVERYTHING a source could raise, including
        # ``ComputedBlockContractError``, a deliberately-``TypeError``
        # (not ``StorageError``) signal that the host callable's own
        # bug in ``ComputedBlockSource`` must never be routed through
        # ``on_source_failure``: an ``omit``-policy learned block
        # would then disappear from every prompt, silently, for as
        # long as the bug lived. ``computed.py``'s module docstring
        # states that contract precisely.
        raise
    except StorageError as exc:
        # The family every typed source failure -- file, database,
        # computed-unavailable, memory-lane -- already derives from.
        return self._apply_failure_policy(scope, spec, exc)
    except Exception as exc:
        # PR #79 review fix (item 5): a duck-typed ``BlockSource``
        # is not required to raise ``StorageError`` for ordinary
        # unavailability -- an adapter wrapping a filesystem or an
        # HTTP client raises ``OSError`` / its own client exception
        # on outage, not this package's ``StorageError``. Before
        # this arm existed, that exception escaped ``resolve_block``
        # entirely (the ``except StorageError`` above does not
        # match), propagated out of ``resolve()``'s loop -- which
        # builds its result list locally and returns only at the
        # end -- and dropped every block already resolved in that
        # same pass, including a platform-tier BOUNDARIES block
        # that loaded cleanly moments earlier. Routing anything not
        # already named as a contract violation through
        # ``_apply_failure_policy`` (the same disposition a
        # ``StorageError`` gets) means an ``omit``/``last_known_
        # good`` block degrades exactly as its policy says, and a
        # ``fail_closed`` block still stops the turn -- it just does
        # so via ``BlockResolutionError`` with an attributable
        # ``block_id``/``scope_path`` instead of an opaque ``OSError``
        # surfacing three call frames away.
        return self._apply_failure_policy(scope, spec, exc)

    # NOTE: a learned-tier revision with `facts=None` is deliberately
    # *not* intercepted here and routed through `on_source_failure`.
    # `render_blocks` raises `MissingFactsError` for it, and the B2
    # wave-0 review fix (`agent/engine.py`, see
    # `.claude/docs/2026-08-06-stage3-review-triage.md`) made that
    # propagate as a loud failure specifically so a source contract
    # violation cannot be silently absorbed by an `omit` policy --
    # the same reasoning the `except StorageError` narrowing above
    # (S7) already applies to `ComputedBlockContractError`. Reproduced:
    # `tests/agent/test_stage3_review_b2_block_render_failure.py`
    # asserts `MissingFactsError` propagates for exactly this
    # omit-policy + `facts=None` shape and would regress if this
    # method intercepted it and returned `None` instead.
    if spec.failure_policy == "last_known_good":
        self._remember_last_known_good(
            block_isolation_key(scope, spec.block_id), revision
        )
    return ResolvedBlock(spec=spec, revision=revision)

ResolvedBlock dataclass

ResolvedBlock(
    spec: PromptBlockSpec,
    revision: BlockRevision,
    from_last_known_good: bool = False,
)

One declared block, resolved: its spec, its content, its revision.

Frozen, because this is the point-in-time snapshot the prompt cache is keyed on. :attr:revision_id is the durable identifier -- a content hash for a file, mem:<count>:<max updated_at> for the memory lane -- so a rebuild that finds an unchanged revision can reuse the rendered bytes rather than re-billing the cached prefix.

content property

content: str

The resolved body.

facts property

facts: tuple[BlockFact, ...] | None

Per-fact provenance for learned blocks; None for authored ones.

from_last_known_good class-attribute instance-attribute

from_last_known_good: bool = False

True when the source failed and a cached in-process revision was served under last_known_good. Callers that must not present possibly-stale content can filter on it; it is never True for a revision this resolve actually loaded.

is_learned property

is_learned: bool

True for the learned tiers -- the renderer's trust boundary.

name property

name: str

The block's name -- the id its source was asked for.

revision_id property

revision_id: str

The durable revision, for cache-key purposes.