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 tofail_closedrather 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
¶
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
¶
Entries retained in the last_known_good cache before the oldest,
least-recently-served one is evicted.
DEFAULT_LOAD_TIMEOUT
module-attribute
¶
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 ¶
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
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.
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
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
resolve_block
async
¶
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
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 | |
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.
facts
property
¶
Per-fact provenance for learned blocks; None for authored ones.
from_last_known_good
class-attribute
instance-attribute
¶
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.