Skip to content

symfonic.capabilities.memory.recall_stage

recall_stage

The handler behind the retrieval stage: what a turn recalls.

Lifted out of :mod:symfonic.capabilities.memory.capability so that module stays inside the 300-line budget, and so the four handlers this capability answers with are four modules rather than three modules and a closure. It was the odd one out: write and lifecycle live in write_stages, the nap in nap_stage, and recall was written inline where the stage is declared.

Everything here is unchanged. The comments are the interesting part -- almost every one of them is a defect that shipped.

recall_handler

recall_handler(capability: Any) -> Callable[[Any], Any]

Recall for this turn's scope, and offer it to the prompt.

Source code in src/symfonic/capabilities/memory/recall_stage.py
def recall_handler(capability: Any) -> Callable[[Any], Any]:
    """Recall for this turn's scope, and offer it to the prompt."""

    async def handle(context: Any) -> StageResult[Any]:
        turn_request = getattr(context, "request", None)
        if turn_request is None:  # pragma: no cover - defensive
            return no_change("no turn request on the stage context")

        # The turn's scope wins over the configured one. A bundle is folded
        # once, at construction, so its scope is a *default* -- and using it
        # for a turn that named a tenant queried the wrong scope, returned
        # nothing, and produced a prompt with no recall and no error. The
        # configured scope stays the fallback for callers that state none.
        turn_scope = getattr(turn_request, "scope", None) or capability.scope
        hydration = await capability.hydrator.hydrate(
            MemoryQuery(
                scope=turn_scope,
                cue=capability.cue_from(turn_request),
                limit=capability.limit,
                recall_budget=capability.recall_budget,
                session_id=str(getattr(turn_request, "session_id", "") or ""),
            )
        )

        if hydration.contribution is None or not hydration.block:
            # ``block``, not ``result.memories``: the block is both
            # segments composed, so a turn whose recall found nothing but
            # whose conversation window has three lines still contributes.
            # Testing the recall alone dropped the window silently, which is
            # the failure this task exists to prevent.
            #
            # An unreachable store and an empty one are different facts, and
            # the reason says which. Contributing an empty entry would cost
            # the compiler a delimiter and a budget slot for no content.
            # ``contribution is None`` is not redundant with an empty
            # block, and mypy asking about it found a real gap: the
            # coordinator declares nothing in JIT mode *whatever* the block
            # says, so a hydration can carry text and no declaration. The
            # old code would have called ``replace(None, ...)`` there.
            return no_change(
                "a memory store was unreachable this turn"
                if hydration.degraded
                else recall_reason(hydration)
                or "nothing was declared for this turn: no recall, an "
                "empty conversation window, or hydration is in JIT mode",
                # Counted even here, and especially here: ``found=0`` is a
                # retrieval that ran and recalled nothing, which is what a
                # reader needs to tell from a retrieval that never ran.
                counts=recall_counts(hydration),
            )

        # The block arrives already composed -- window first, then recall,
        # under one ceiling with one drop ledger -- because
        # ``HydrationCoordinator`` owns that composition and is the only
        # thing that does. This capability used to render the recall itself
        # from ``result.render()``, which could only ever see half the turn's
        # memory: the conversation window is not in a retrieval result and
        # never was.
        #
        # Still rendered before the snapshot rather than handed over live:
        # review of PR #94 found that a retrieval result carries
        # MemoryRecord -> MemoryLayer, and an enum member is a process-wide
        # singleton, so one stage could set an attribute on it and every
        # later turn in the process would see it.
        source = ComposedMemorySource(
            block=hydration.block,
            revision=hydration.revision,
            # The turn's scope, not the configured one -- the same rule the
            # query above follows. Tagging the block with the bundle's
            # default while querying the turn's tenant made the source's
            # provenance a lie, and nothing checked it until the compiler
            # started receiving a scope: ``ComposedMemorySource`` then
            # refused its own block and the recall vanished from the prompt.
            scope_path=turn_scope.path,
        )
        # ``replace``, not a fresh construction. Naming fields by hand meant
        # hardcoding ``order=0`` -- so an adopter's ``order=37`` published 0.
        # ``ordering_key`` is ``(layer, order, contribution_id)``, so 0 ties
        # with every other 0 and ``memory.recall`` wins the alphabetical
        # tie-break, silently promoting the recall to the head of its layer.
        # Under budget pressure ``BudgetRow`` drops in reverse, so the
        # deployment then loses the operator's blocks *and* the recall.
        composed = replace(hydration.contribution, source=source)
        return applied(
            ResolvedInput(
                capability=HMS_CAPABILITY,
                value=(contribution_spec(composed),),
                # Provenance, not a digest of the prompt: what lets a reader
                # of the compiled prompt get from a rendered line back to the
                # hydration that produced it.
                provenance=hydration.revision,
            ),
            counts=recall_counts(hydration),
            # An APPLIED result carries no reason, so a route that could
            # not be reached is said here. Silence would make a turn that
            # recalled less look exactly like one with less to recall.
            diagnostics=(
                (reason,) if (reason := recall_reason(hydration)) else ()
            ),
        )

    return handle