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, summary: STMSummaryPort | None = None, reflection: ReflectionPort | None = None, consolidation: Any | None = None, conversation: Any | None = None, recent_turns: int = 0, activation: Any | None = None, retrieval_policy: RetrievalPolicy | None = None, retrieval: RetrievalCoordinator | None = None, exclude_speakers: frozenset[str] = frozenset(), episodic_telemetry_sink: EpisodicTelemetrySink | None = None, operation_journal: MemoryOperationJournalPort | None = None, generations: Any = None, triage: IntentTriageCapability | 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
|
summary
|
STMSummaryPort | None
|
an explicit evicted-turn summary collaborator. It is
materialized only into JIT prompts; omitting it disables the
contribution and makes no source call.
|
None
|
reflection
|
ReflectionPort | None
|
a completed-exchange insight collaborator. It runs
after publication and persists accepted scoped insights.
|
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, summary, or
reflection cannot serve its 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,
summary: STMSummaryPort | None = None,
reflection: ReflectionPort | None = None,
consolidation: Any | None = None,
conversation: Any | None = None,
recent_turns: int = 0,
activation: Any | None = None,
retrieval_policy: RetrievalPolicy | None = None,
retrieval: RetrievalCoordinator | None = None,
exclude_speakers: frozenset[str] = frozenset(),
episodic_telemetry_sink: EpisodicTelemetrySink | None = None,
operation_journal: MemoryOperationJournalPort | None = None,
generations: Any = None, # minted by memory_capabilities only (CORE-199)
triage: IntentTriageCapability | 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`.
summary: an explicit evicted-turn summary collaborator. It is
materialized only into JIT prompts; omitting it disables the
contribution and makes no source call.
reflection: a completed-exchange insight collaborator. It runs
after publication and persists accepted scoped insights.
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``, ``summary``, or
``reflection`` cannot serve its port.
"""
validate_extractor(extractor)
validate_summary(summary)
validate_reflector(reflection)
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._summary = summary
self._reflection = reflection
self._consolidation = consolidation
self._conversation = conversation
self._recent_turns = recent_turns
self._activation = activation
self._retrieval_policy = retrieval_policy
self._retrieval = retrieval
self._exclude_speakers = exclude_speakers
self._episodic_telemetry_sink = episodic_telemetry_sink
self._operation_journal = operation_journal
self._generations = generations
self._triage = triage
|
for_scope
for_scope(scope: MemoryScope | Any) -> Any
Compose kind-aware paths, or an explicitly positional MemoryScope.
Source code in src/symfonic/capabilities/memory/factory.py
| def for_scope(self, scope: MemoryScope | Any) -> Any:
"""Compose kind-aware paths, or an explicitly positional MemoryScope."""
from symfonic.capabilities.memory.operation_journal import receipt_backed
memory_scope = composition_scope(scope)
if receipt_backed(self._store):
from symfonic.capabilities.memory.receipt_composition import receipt_for_scope
return receipt_for_scope(self, memory_scope)
if memory_scope.identity_version:
from symfonic.capabilities.memory.graph_native_admission import require_composition
require_composition(self, memory_scope)
from symfonic.capabilities.memory.turn_generation import guarded_writer
writer = guarded_writer(self._store, self._conversation, memory_scope, self._generations)
hydrator = hydrator_for(
self._store, conversation=self._conversation, recent_turns=self._recent_turns,
activation=self._activation,
retrieval_policy=self._retrieval_policy,
retrieval=self._retrieval,
exclude_speakers=self._exclude_speakers,
)
from symfonic.capabilities.memory.port_inspection import pair_guard
return MemoryCapability(
hydrator,
scope=memory_scope,
limit=self._limit,
recall_budget=self._recall_budget,
writer=pair_guard(writer, hydrator, memory_scope),
# 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=writer,
extractor=self._extractor,
summary=self._summary,
reflection=self._reflection,
consolidation=self._consolidation,
episodic_telemetry_sink=self._episodic_telemetry_sink,
operation_journal=self._operation_journal,
triage=self._triage,
)
|
as_memory_scope
as_memory_scope(scope: Any) -> MemoryScope
Refuse paths deeper than this positional memory scope can preserve.
Existing one- to three-level kinds still use the legacy positional key.
Their kind-identity migration is tracked separately under CORE-171.
Source code in src/symfonic/capabilities/memory/binding.py
| def as_memory_scope(scope: Any) -> MemoryScope:
"""Refuse paths deeper than this positional memory scope can preserve.
Existing one- to three-level kinds still use the legacy positional key.
Their kind-identity migration is tracked separately under CORE-171.
"""
if isinstance(scope, MemoryScope):
return scope
path_ids: tuple[str, ...] = ()
levels = getattr(scope, "path", None)
if levels and not isinstance(levels, (str, bytes)):
path_levels = tuple(levels)
if len(path_levels) > 3:
raise MemoryContractError(
"scope path has more than three levels and cannot be represented by MemoryScope"
)
path_ids = tuple(str(level.id) for level in path_levels if getattr(level, "id", None))
if len(path_ids) != len(path_levels):
raise MemoryContractError("scope path contains an empty level")
segment_ids: tuple[str, ...] = ()
segments = getattr(scope, "segments", None)
if segments:
raw_segments = tuple(segments)
segment_ids = tuple(str(segment) for segment in raw_segments if segment)
if len(segment_ids) != len(raw_segments):
raise MemoryContractError("scope segments contain an empty level")
if len(segment_ids) > 3:
raise MemoryContractError(
"scope path has more than three levels and cannot be represented by MemoryScope"
)
if path_ids and segment_ids and path_ids != segment_ids:
raise MemoryContractError("scope path and segments disagree")
ids = path_ids or segment_ids
converted = getattr(scope, "to_memory_scope", None)
if callable(converted):
result = converted()
if isinstance(result, MemoryScope):
if ids and result.segments != ids:
raise MemoryContractError(
"to_memory_scope lost a scope level before memory composition"
)
return result
if ids:
return MemoryScope(*ids)
raise TypeError(
f"{type(scope).__name__} is neither a MemoryScope nor convertible to "
"one, so the capability cannot be bound to a scope"
)
|