Skip to content

symfonic.capabilities.memory.factory

factory

Memory as something a host can compose, from a store and a scope.

The public counterpart to what SymfonicAgent._retrieval_bundle does privately. A generated project needs memory on an Agent and today the only way to get it is to build a SymfonicAgent, which is the dependency the kernel-native scaffold exists to remove.

One store, one scope, one capability. Capabilities are scope-bound at construction -- a memory capability recalls, records and erases for exactly one scope -- which is why a host keeps one agent per scope rather than passing a scope per turn. This factory is where that binding happens, and it is the only place it needs to.

The three grants travel together, and that is not a convenience. memory-read without memory-write is a deployment that recalls and never records; memory-write without memory-flush is worse -- the write stage stages a memory as pending, and a pending memory is not retrievable until flush, so a turn would answer, the store would fill, and nothing could ever be recalled. TA8.71 found exactly that shipped. Asking for memory means asking for all three.

Nothing here imports the engine, reads a private, or knows what a turn is.

MemoryBundleFactory

MemoryBundleFactory(store: Any, *, limit: int = 5, recall_budget: RecallBudget | None = None, extractor: MemoryExtractorPort | None = None, consolidation: Any | None = None, conversation: Any | None = None, recent_turns: int = 0, activation: Any | None = None)

Builds the memory capability for one scope over a shared store.

The store is shared across scopes and the capability is not: a store isolates by scope on every read and write, and a capability is a scope. Handing the same store to two capabilities is how two tenants share persistence without sharing memory.

Parameters:

Name Type Description Default
store Any

an HMS satisfying the retrieval, write and lifecycle ports. One object for all three because staging, publishing and erasing are operations on one place -- and because a deployment that split them would have to answer what happens when only two are present, which the ports already refuse.

required
limit int

how many memories a turn recalls.

5
recall_budget RecallBudget | None

explicit UTF-8 block/item ceilings; independent of working-turn retention. None preserves legacy character caps.

None
extractor MemoryExtractorPort | None

a :class:~.ports.MemoryExtractorPort, normally :class:~.extraction.MemoryExtractionService built over an extraction model. Given one, the capability runs it after the final model round and writes what it returns, so a turn that said something worth keeping is remembered without the deployment calling anything. None -- the default -- means this deployment does not extract: it still recalls, records the exchange and erases. Checked here rather than mid-turn; see :func:~.ports.validate_extractor.

None
consolidation Any | None

a :class:~.napping.ConsolidationCoordinator. Given one, the capability naps on the cadence it carries -- after the turn's memories are published, on the run's background registry, once per scope. None -- the default -- means this deployment consolidates from its own scheduler or not at all, and one shared coordinator across scopes is correct: it counts turns per scope and locks per scope.

None

Raises:

Type Description
ConfigurationError

if extractor cannot serve the port.

Source code in src/symfonic/capabilities/memory/factory.py
def __init__(
    self,
    store: Any,
    *,
    limit: int = 5,
    recall_budget: RecallBudget | None = None,
    extractor: MemoryExtractorPort | None = None,
    consolidation: Any | None = None,
    conversation: Any | None = None,
    recent_turns: int = 0,
    activation: Any | None = None,
) -> None:
    """
    Args:
        store: an HMS satisfying the retrieval, write and lifecycle ports.
            One object for all three because staging, publishing and
            erasing are operations on one place -- and because a
            deployment that split them would have to answer what happens
            when only two are present, which the ports already refuse.
        limit: how many memories a turn recalls.
        recall_budget: explicit UTF-8 block/item ceilings; independent of
            working-turn retention. None preserves legacy character caps.
        extractor: a :class:`~.ports.MemoryExtractorPort`, normally
            :class:`~.extraction.MemoryExtractionService` built over an
            extraction model. Given one, the capability runs it after the
            final model round and writes what it returns, so a turn that
            said something worth keeping is remembered without the
            deployment calling anything. ``None`` -- the default -- means
            this deployment does not extract: it still recalls, records
            the exchange and erases. Checked here rather than mid-turn;
            see :func:`~.ports.validate_extractor`.
        consolidation: a
            :class:`~.napping.ConsolidationCoordinator`. Given one, the
            capability naps on the cadence it carries -- after the turn's
            memories are published, on the run's background registry, once
            per scope. ``None`` -- the default -- means this deployment
            consolidates from its own scheduler or not at all, and one
            shared coordinator across scopes is correct: it counts turns
            per scope and locks per scope.

    Raises:
        ConfigurationError: if ``extractor`` cannot serve the port.
    """
    validate_extractor(extractor)
    if recall_budget is not None:
        from symfonic.capabilities.memory.budget import RecallBudget
        from symfonic.capabilities.memory.errors import MemoryContractError
        if not isinstance(recall_budget, RecallBudget):
            raise MemoryContractError("recall_budget must be a RecallBudget")
    self._store = store
    self._limit = limit
    self._recall_budget = recall_budget
    self._extractor = extractor
    self._consolidation = consolidation
    self._conversation = conversation
    self._recent_turns = recent_turns
    self._activation = activation

for_scope

for_scope(scope: MemoryScope | Any) -> MemoryCapability

The capability that recalls, records and erases for scope.

Source code in src/symfonic/capabilities/memory/factory.py
def for_scope(self, scope: MemoryScope | Any) -> MemoryCapability:
    """The capability that recalls, records and erases for ``scope``."""
    memory_scope = as_memory_scope(scope)
    hydrator = hydrator_for(
        self._store,
        conversation=self._conversation,
        recent_turns=self._recent_turns,
        activation=self._activation,
    )
    return MemoryCapability(
        hydrator,
        scope=memory_scope,
        limit=self._limit,
        recall_budget=self._recall_budget,
        writer=self._store,
        # Closed over the scope, not read from the turn. ``Agent.run``
        # takes a prompt and nothing about tenancy -- that is the published
        # facade decision -- so the turn request carries no scope and a
        # producer that looked for one filed nothing at all. The agent IS
        # the scope: it was composed for exactly this one, which is the
        # same fact that makes a host keep one agent per scope.
        records_from=_producer_for(memory_scope),
        lifecycle=self._store,
        extractor=self._extractor,
        consolidation=self._consolidation,
    )

as_memory_scope

as_memory_scope(scope: Any) -> MemoryScope

Accept either scope type, so a host is not forced to pick one.

A platform scope and a memory scope are two spellings of one identity, and making a caller convert would put the translation in every composition root instead of here.

to_memory_scope() is tried first and its result is checked, because on FrameworkTenantScope that method returns another framework scope rather than a memory scope. Trusting it returned an object with no segments, and the mismatch surfaced far away -- inside a retrieval, as a missing attribute on a type nobody in that traceback had named. So the fallback below reads the scope's own path, which is the one identity both spellings agree on.

Source code in src/symfonic/capabilities/memory/factory.py
def as_memory_scope(scope: Any) -> MemoryScope:
    """Accept either scope type, so a host is not forced to pick one.

    A platform scope and a memory scope are two spellings of one identity, and
    making a caller convert would put the translation in every composition root
    instead of here.

    ``to_memory_scope()`` is tried first and its **result is checked**, because
    on ``FrameworkTenantScope`` that method returns another framework scope
    rather than a memory scope. Trusting it returned an object with no
    ``segments``, and the mismatch surfaced far away -- inside a retrieval,
    as a missing attribute on a type nobody in that traceback had named. So
    the fallback below reads the scope's own path, which is the one identity
    both spellings agree on.
    """
    if isinstance(scope, MemoryScope):
        return scope
    converted = getattr(scope, "to_memory_scope", None)
    if callable(converted):
        result = converted()
        if isinstance(result, MemoryScope):
            return result
    # A platform ``SubjectScope`` spells the same identity as an ordered tuple
    # of segments. Read before ``path`` because it is the narrower signal: a
    # scope carrying both means the same thing either way, and a scope carrying
    # only this one is what an authenticated request produces.
    segments = getattr(scope, "segments", None)
    if segments:
        ids = [str(segment) for segment in segments if segment]
        if ids:
            return MemoryScope(*ids[:3])

    levels = getattr(scope, "path", None)
    if levels:
        ids = [str(level.id) for level in levels if getattr(level, "id", None)]
        if ids:
            return MemoryScope(*ids[:3])
    raise TypeError(
        f"{type(scope).__name__} is neither a MemoryScope nor convertible to "
        "one, so the capability cannot be bound to a scope"
    )

memory_capabilities

memory_capabilities(store: Any, scope: MemoryScope | Any, *, limit: int = 5, recall_budget: RecallBudget | None = None, extractor: MemoryExtractorPort | None = None, consolidation: Any | None = None, conversation: Any | None = None, recent_turns: int = 0, activation: Any | None = None) -> list[Any]

The capability and the grants it needs, as one list to compose.

Parameters:

Name Type Description Default
store Any

an HMS satisfying the retrieval, write and lifecycle ports.

required
scope MemoryScope | Any

the scope this agent serves. Closed over by the capability, so one agent is one tenant.

required
limit int

how many memories a turn recalls.

5
recall_budget RecallBudget | None

explicit UTF-8 rendered recall ceilings. None preserves legacy character caps; unrelated to the working conversation window.

None
extractor MemoryExtractorPort | None

a :class:~.ports.MemoryExtractorPort -- normally :class:~.extraction.MemoryExtractionService -- run after the final model round so a turn's durable facts are written without the deployment invoking anything. None composes memory that recalls and records the exchange and extracts nothing.

None
consolidation Any | None

a :class:~.napping.ConsolidationCoordinator -- built over a :class:~.consolidation.ConsolidationRuntime whose roster came from :func:~.phases.quick.quick_phases. Given one, the turn's last act is to advance this scope's cadence and run the cycle it makes due, in the background. None composes memory that never consolidates on its own.

None
activation Any | None

a :class:~.recall.SpreadingActivation over an :class:~.recall.AssociationSource. It adds bounded graph neighbours to direct recall. None preserves direct recall.

None

Raises:

Type Description
ConfigurationError

if extractor cannot serve the port.

Returned together because a capability may not grant itself an effect and a caller that forgot one would get a fold refusal naming a grant rather than a missing feature. The three are what memory is.

Prompting is NOT included, and composing memory alone does not put recall in front of the model. Memory is a resolution stage: it reaches the store and leaves an entry in the turn's snapshot. Prompting is the compilation stage that reads that snapshot and renders it. Fold memory by itself and both ports are called, the block is composed, the snapshot is populated -- and the model receives the bare instructions, because nothing consumed the entry. That exact defect has been found in this codebase twice.

It is not added here because a factory named for memory that quietly composed prompting would decide a deployment's prompt on its behalf. A composition root wanting recall in the prompt adds a PromptingCapability beside this, and :mod:tests.platform.test_vertical_slice shows the pair.

Source code in src/symfonic/capabilities/memory/factory.py
def memory_capabilities(
    store: Any,
    scope: MemoryScope | Any,
    *,
    limit: int = 5,
    recall_budget: RecallBudget | None = None,
    extractor: MemoryExtractorPort | None = None,
    consolidation: Any | None = None,
    conversation: Any | None = None,
    recent_turns: int = 0,
    activation: Any | None = None,
) -> list[Any]:
    """The capability *and* the grants it needs, as one list to compose.

    Args:
        store: an HMS satisfying the retrieval, write and lifecycle ports.
        scope: the scope this agent serves. Closed over by the capability, so
            one agent is one tenant.
        limit: how many memories a turn recalls.
        recall_budget: explicit UTF-8 rendered recall ceilings. None preserves
            legacy character caps; unrelated to the working conversation window.
        extractor: a :class:`~.ports.MemoryExtractorPort` -- normally
            :class:`~.extraction.MemoryExtractionService` -- run after the
            final model round so a turn's durable facts are written without
            the deployment invoking anything. ``None`` composes memory that
            recalls and records the exchange and extracts nothing.
        consolidation: a :class:`~.napping.ConsolidationCoordinator` -- built
            over a :class:`~.consolidation.ConsolidationRuntime` whose roster
            came from :func:`~.phases.quick.quick_phases`. Given one, the
            turn's last act is to advance this scope's cadence and run the
            cycle it makes due, in the background. ``None`` composes memory
            that never consolidates on its own.
        activation: a :class:`~.recall.SpreadingActivation` over an
            :class:`~.recall.AssociationSource`. It adds bounded graph
            neighbours to direct recall. ``None`` preserves direct recall.

    Raises:
        ConfigurationError: if ``extractor`` cannot serve the port.

    Returned together because a capability may not grant itself an effect and
    a caller that forgot one would get a fold refusal naming a grant rather
    than a missing feature. The three are what memory is.

    **Prompting is NOT included, and composing memory alone does not put recall
    in front of the model.** Memory is a *resolution* stage: it reaches the
    store and leaves an entry in the turn's snapshot. Prompting is the
    *compilation* stage that reads that snapshot and renders it. Fold memory by
    itself and both ports are called, the block is composed, the snapshot is
    populated -- and the model receives the bare instructions, because nothing
    consumed the entry. That exact defect has been found in this codebase twice.

    It is not added here because a factory named for memory that quietly
    composed prompting would decide a deployment's prompt on its behalf. A
    composition root wanting recall in the prompt adds a ``PromptingCapability``
    beside this, and :mod:`tests.platform.test_vertical_slice` shows the pair.
    """
    from symfonic.kernel.contracts.effects import GrantEffects

    # ``memory-consolidate`` travels with the rest for the same reason the
    # other three do: it is granted only when a nap is composed, and a caller
    # who passed a coordinator and no grant would read a fold refusal naming an
    # effect rather than the feature they asked for.
    effects = ["memory-read", "memory-write", "memory-flush"]
    if consolidation is not None:
        effects.append("memory-consolidate")
    return [
        GrantEffects(*effects),
        MemoryBundleFactory(
            store, limit=limit, recall_budget=recall_budget,
            extractor=extractor, consolidation=consolidation,
            conversation=conversation, recent_turns=recent_turns,
            activation=activation,
        ).for_scope(scope),
    ]