Skip to content

symfonic.capabilities.memory.capability

capability

Memory as something Agent can be handed — W2 phase 4.

Everything below hydrate already existed and was tested: the hydration stack has declared the three stages, with their effects, events and ordering, since T3.2.3 — and nothing ever compiled or ran them. This module is the twenty lines that hand the declaration and the handler to the kernel at once, the way PromptingCapability did for prompting.

memory.retrieval and memory.write are wired. POST_MODEL dispatches since

23 slice 1 — kernel/post_model.py, called from runner.py — so the write

half is a contribution the kernel actually runs, and TA8.61 made it one.

memory.lifecycle is wired too, since TA8.61 gave FINALIZE a rung (kernel/finalize.py — not kernel/finalizers.py, which is the teardown stack, a different thing with a similar name).

It is not optional decoration. MemoryWritePort.write stages a memory as pending and its own contract says pending memories are not retrievable until flush, so a deployment that contributed the write stage and not this one recorded into a void: the turn answered, the store filled, and nothing could ever be recalled. The two halves are declared together for that reason.

The write stage is declared only when the capability was handed something to write with. Absence means disabled, the same rule the facade applies to capabilities themselves: a deployment that passes no writer gets exactly the contribution it got before.

It resolves; it does not compile. Under the reformulated STG-7 this is a resolution stage: it reaches the store once per invocation under a memory-read grant, leaves one entry in the turn's snapshot, and never writes the assembly. The prompt compiler reads that entry afterwards, purely.

The seam stays vocabulary, not import. What the entry carries is :func:contribution_spec's mapping — described by its own docstring as "compiler-ready keyword values", whose keys are the prompt contract's field names. Prompting consuming that mapping is prompting reading its own vocabulary, not reaching into memory; and memory never imports prompting to produce it. That is the same seam contribution.py was written around, used as intended.

MemoryCapability dataclass

MemoryCapability(hydrator: HydrationCoordinator | None, scope: MemoryScope, cue_from: Callable[[Any], str] = lambda request: str(getattr(request, 'prompt', '') or ''), limit: int = 5, writer: Any | None = None, records_from: Callable[[Any], Any] | None = None, lifecycle: Any | None = None, consolidation: Any | None = None, extractor: Any | None = None, recall_budget: RecallBudget | None = None)

Recall from the memory store and offer it to the turn's prompt.

Usage::

Agent(provider, capabilities=[
    GrantEffects("memory-read"),
    MemoryCapability(bridge, scope=MemoryScope(tenant="acme")),
    PromptingCapability(sources=[...]),
])

The GrantEffects entry is not optional decoration: this capability declares a memory-read effect, and a capability may not grant itself one. Constructing the Agent without it fails, loudly, at construction.

contribute

contribute(request: CapabilityRequest) -> CapabilityContribution

Declare the retrieval stage and the handler that answers it.

Source code in src/symfonic/capabilities/memory/capability.py
def contribute(self, request: CapabilityRequest) -> CapabilityContribution:
    """Declare the retrieval stage and the handler that answers it."""

    handle = recall_handler(self)

    stages: tuple[StageDescriptor, ...] = ()
    handlers: dict[str, Any] = {}
    grants: set[str] = set()

    # Each stage is declared on its own evidence, the way the fold declares
    # each memory segment on its own: a deployment may recall without
    # recording, or record without recalling, and asking for one must not
    # silently deliver the other.
    if self.hydrator is not None:
        stages = (*stages, retrieval_stage())
        handlers[RETRIEVAL_STAGE] = handle
        grants.add("memory-read")

    if self.writer is not None and self.records_from is not None:
        stages = (*stages, write_stage())
        handlers[WRITE_STAGE] = self._write_handler()
        grants.add("memory-write")

    if self.writer is not None and self.extractor is not None:
        stages = (*stages, consolidation_stage())
        handlers[CONSOLIDATION_STAGE] = self._consolidation_handler()
        grants.add("memory-write")

    if self.lifecycle is not None:
        stages = (*stages, lifecycle_stage())
        handlers[LIFECYCLE_STAGE] = self._lifecycle_handler()
        grants.add("memory-flush")

    if self.consolidation is not None:
        stages = (*stages, nap_stage())
        handlers[NAP_STAGE] = self._nap_handler()
        grants.add("memory-consolidate")

    return CapabilityContribution(
        capability=HMS_CAPABILITY,
        stages=stages,
        handlers=handlers,
        effect_grants=frozenset(grants),
    )