Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions strands-py/src/strands/memory/extraction/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
from ...types.content import ContentBlock, Message
from ...types.exceptions import AggregateMemoryError
from ..types import MemoryStore
from .types import DEFAULT_MEMORY_MESSAGE_FILTER, Extractor, ExtractorContext, MemoryMessageFilter
from .resolve_extraction_config import _ResolvedExtractionConfig
from .types import Extractor, ExtractorContext, MemoryMessageFilter

logger = logging.getLogger(__name__)

Expand All @@ -28,6 +29,20 @@
BACKOFF_PROBE_INTERVAL = 3


@dataclass
class _ExtractionBinding:
"""A store paired with its fully-resolved extraction config.

Attributes:
store: The memory store to extract into.
config: The store's fully-resolved extraction config (triggers, extractor,
filter).
"""

store: MemoryStore
config: _ResolvedExtractionConfig


@dataclass
class _Buffered:
"""A buffered message and its monotonically increasing sequence number."""
Expand All @@ -46,22 +61,27 @@ class ExtractionCoordinator:
for repeatedly failing stores.
"""

def __init__(self, stores: list[MemoryStore], default_model: Model) -> None:
def __init__(self, bindings: list[_ExtractionBinding], default_model: Model) -> None:
"""Initialize the coordinator.

Args:
stores: The extraction-configured stores this coordinator manages.
bindings: The extraction-configured stores this coordinator manages,
each paired with its fully-resolved config.
default_model: The agent's model, passed to extractors that do not
configure their own.
"""
self._stores = list(stores)
self._stores = [binding.store for binding in bindings]
# Per store: its resolved extraction config (triggers, extractor, filter).
self._configs: dict[int, _ResolvedExtractionConfig] = {
id(binding.store): binding.config for binding in bindings
}
self._default_model = default_model
# Messages waiting to be saved, oldest first.
self._pending: list[_Buffered] = []
# The ``seq`` to assign the next buffered message.
self._next_seq = 0
# Per store: ``seq`` of the last message it has saved (-1 means none).
self._marks: dict[int, int] = {id(store): -1 for store in stores}
self._marks: dict[int, int] = {id(binding.store): -1 for binding in bindings}
# Per store: the currently-running save task, so the next save waits its turn.
self._chains: dict[int, asyncio.Task] = {}
# Per store: consecutive save failures, reset to 0 on success.
Expand Down Expand Up @@ -161,20 +181,17 @@ async def _extract(self, store: MemoryStore) -> None:
if not fresh:
return

extraction = store.extraction
if extraction is None:
return
config = self._configs[id(store)]

# Mark saved before saving so a queued save won't pick these up again;
# rolled back below on failure.
self._marks[id(store)] = fresh[-1].seq

message_filter = extraction.filter or DEFAULT_MEMORY_MESSAGE_FILTER
filtered = self._filter_messages([buffered.message for buffered in fresh], message_filter)
filtered = self._filter_messages([buffered.message for buffered in fresh], config.filter)

try:
if filtered:
await self._write(store, filtered, extraction.extractor)
await self._write(store, filtered, config.extractor)
# Successful write clears the failure streak and ends backoff. A
# fully filtered (empty) turn never touched the backend, so it
# leaves backoff state untouched.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Resolves a store's ``extraction`` setting into a concrete config.

The single place the ``bool | ExtractionConfig`` shorthand is interpreted and
per-store defaults are applied, so the :class:`~strands.memory.memory_manager.MemoryManager`
and :class:`~strands.memory.extraction.coordinator.ExtractionCoordinator` never
re-apply defaults or normalize shapes themselves.
"""

from __future__ import annotations

from dataclasses import dataclass

from ..types import MemoryStore, _has_method
from .model_extractor import ModelExtractor
from .triggers import IntervalTrigger
from .types import (
DEFAULT_MEMORY_MESSAGE_FILTER,
ExtractionConfig,
ExtractionTrigger,
Extractor,
MemoryMessageFilter,
)

# Default cadence when an ``ExtractionConfig`` omits its ``trigger``: extract every N turns.
DEFAULT_EXTRACTION_TRIGGER_TURNS = 5
Comment thread
opieter-aws marked this conversation as resolved.


@dataclass
class _ResolvedExtractionConfig:
"""An :class:`ExtractionConfig` with every field resolved to a concrete value.

Produced by :func:`_resolve_extraction_config` so the ``MemoryManager`` and
``ExtractionCoordinator`` never have to re-apply defaults or normalize shapes.

Attributes:
triggers: Normalized to a list (a single trigger is wrapped). Never empty
for a resolved config (an explicit empty list is left empty for the
manager to reject).
extractor: The extractor that distills facts client-side and stores them
via the store's ``add`` method, or ``None`` to use the store's
``add_messages`` method (server-side extraction).
filter: The content-block filter applied before extraction.
"""

triggers: list[ExtractionTrigger]
extractor: Extractor | None
filter: MemoryMessageFilter


def _resolve_extraction_config(
extraction: bool | ExtractionConfig | None,
store: MemoryStore,
) -> _ResolvedExtractionConfig | None:
"""Resolve a store's ``extraction`` setting into a :class:`_ResolvedExtractionConfig`.

The single place the ``bool | ExtractionConfig`` shorthand is interpreted:
``False``/``None`` is off (returns ``None``), ``True`` enables all defaults, an
:class:`ExtractionConfig` defaults its unset fields. The defaults are:

- **triggers**: every :data:`DEFAULT_EXTRACTION_TRIGGER_TURNS` turns. An
explicit empty list is left empty for the ``MemoryManager`` to reject.
- **extractor**: chosen from the methods the store implements. A store that
implements only ``add`` cannot extract server-side, so it defaults to a
:class:`~strands.memory.extraction.model_extractor.ModelExtractor` that
distills facts client-side (via model calls) and stores each one through
``add``. A store that implements ``add_messages`` supports server-side
extraction, so it defaults to no extractor: the manager hands raw messages
to ``add_messages`` and the backend extracts them itself, with no model call.
- **filter**: :data:`DEFAULT_MEMORY_MESSAGE_FILTER`.

Args:
extraction: The store's ``extraction`` setting.
store: The store, inspected for the write methods it implements to pick the
default extractor.

Returns:
The resolved config, or ``None`` when extraction is disabled.
"""
if not extraction:
return None
config = ExtractionConfig() if extraction is True else extraction

triggers: list[ExtractionTrigger]
if config.trigger is None:
triggers = [IntervalTrigger(turns=DEFAULT_EXTRACTION_TRIGGER_TURNS)]
elif isinstance(config.trigger, list):
triggers = config.trigger
else:
triggers = [config.trigger]

extractor = config.extractor
if extractor is None:
# Pick the default extractor from the store's write methods:
# - implements only ``add``: it cannot extract server-side, so default to a
# ModelExtractor that distills facts client-side and stores each via ``add``.
# - implements ``add_messages`` (whether or not it also implements ``add``): extract
# server-side. Leave the extractor None so raw messages go straight to
# ``add_messages`` with no model call.
if _has_method(store, "add") and not _has_method(store, "add_messages"):
extractor = ModelExtractor()

return _ResolvedExtractionConfig(
triggers=triggers,
extractor=extractor,
filter=config.filter or DEFAULT_MEMORY_MESSAGE_FILTER,
)
16 changes: 10 additions & 6 deletions strands-py/src/strands/memory/extraction/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,18 +127,22 @@ class ExtractionConfig:
"""Per-store automatic-extraction configuration.

Attributes:
trigger: When to run extraction. A single trigger or a non-empty list;
multiple triggers compose (extraction runs whenever any fires).
trigger: When to run extraction. A single trigger or a list; multiple
triggers compose (extraction runs whenever any fires). Omit to default
to every 5 turns; an explicit empty list is rejected at construction.
extractor: How to turn messages into entries. When set, the store must
implement ``add``. When omitted, the manager hands the filtered
messages straight to the store's ``add_messages`` (for backends that
extract server-side).
implement ``add``. When omitted, the default depends on the store's
write methods: a store implementing only ``add`` defaults to a
:class:`~strands.memory.extraction.model_extractor.ModelExtractor`
that distills facts client-side, while a store implementing
``add_messages`` uses server-side extraction (the manager hands the
filtered messages straight to ``add_messages``, no model call).
filter: Content blocks to strip before extraction. Defaults to
:data:`DEFAULT_MEMORY_MESSAGE_FILTER` (excludes ``toolUse`` /
``toolResult``). Pass ``MemoryMessageFilter(exclude=[])`` to keep tool
blocks.
"""

trigger: ExtractionTrigger | list[ExtractionTrigger]
trigger: ExtractionTrigger | list[ExtractionTrigger] | None = None
extractor: Extractor | None = None
filter: MemoryMessageFilter | None = None
33 changes: 17 additions & 16 deletions strands-py/src/strands/memory/memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@
from ..tools.decorator import tool
from ..types.exceptions import AggregateMemoryError
from ..types.tools import AgentTool
from .extraction.coordinator import ExtractionCoordinator
from .extraction.types import ExtractionTrigger, ExtractionTriggerContext
from .extraction.coordinator import ExtractionCoordinator, _ExtractionBinding
from .extraction.resolve_extraction_config import _resolve_extraction_config
from .extraction.types import ExtractionTriggerContext
from .types import (
MemoryAddOptions,
MemoryAddToolConfig,
Expand Down Expand Up @@ -44,11 +45,6 @@
DEFAULT_MAX_SEARCH_RESULTS = 3


def _normalize_triggers(trigger: ExtractionTrigger | list[ExtractionTrigger]) -> list[ExtractionTrigger]:
"""Normalize a store's ``trigger`` field (a single trigger or a list) to a list."""
return list(trigger) if isinstance(trigger, list) else [trigger]


def _flatten_reasons(reasons: list[BaseException]) -> list[BaseException]:
"""Flatten nested aggregate errors so the leaves are concrete reasons."""
flattened: list[BaseException] = []
Expand Down Expand Up @@ -105,6 +101,7 @@ def __init__(
raise ValueError("MemoryManager: at least one store is required")

seen_names: set[str] = set()
extraction_bindings: list[_ExtractionBinding] = []
for store in stores:
if store.name in seen_names:
raise ValueError(f"MemoryManager: duplicate store name '{store.name}'")
Expand All @@ -115,13 +112,16 @@ def __init__(
f"MemoryManager: store '{store.name}' is writable but has no add or add_messages method"
)

if store.extraction is not None:
extraction_config = _resolve_extraction_config(store.extraction, store)
if extraction_config is not None:
if not store.writable:
raise ValueError(f"MemoryManager: store '{store.name}' has extraction config but is not writable")
if len(_normalize_triggers(store.extraction.trigger)) == 0:
if len(extraction_config.triggers) == 0:
raise ValueError(f"MemoryManager: store '{store.name}' has extraction config but no triggers")
# Each extraction shape needs its matching write sink.
if store.extraction.extractor is not None:
# Each extraction shape needs its matching write sink. An extractor produces discrete
# entries written via `add`; without an extractor the raw message batch goes to
# `add_messages`.
if extraction_config.extractor is not None:
if not _has_method(store, "add"):
raise ValueError(
f"MemoryManager: store '{store.name}' has an extractor but no add method "
Expand All @@ -132,14 +132,16 @@ def __init__(
f"MemoryManager: store '{store.name}' has extraction config without an extractor "
"but no add_messages method"
)
extraction_bindings.append(_ExtractionBinding(store=store, config=extraction_config))

super().__init__()

self._stores = list(stores)
self._search_stores = list(stores)
# `add`-targeting paths (tool / programmatic) need an `add` method specifically.
self._add_stores = [store for store in stores if store.writable and _has_method(store, "add")]
self._extraction_stores = [store for store in stores if store.writable and store.extraction is not None]
# Stores with extraction enabled, each paired with its resolved config; wired up in ``init_agent``.
self._extraction_stores = extraction_bindings

self._search_tool_config: MemoryToolConfig | bool
if search_tool_config is False:
Expand Down Expand Up @@ -525,10 +527,9 @@ def init_agent(self, agent: Agent) -> None:
# Buffer every message so extraction has its own copy to save from.
agent.add_hook(lambda event: coordinator.record(event.message), MessageAddedEvent)

for store in self._extraction_stores:
assert store.extraction is not None # noqa: S101 - extraction stores always configure this.
for trigger in _normalize_triggers(store.extraction.trigger):
trigger.attach(ExtractionTriggerContext(agent=agent, fire=self._make_fire(coordinator, store)))
for binding in self._extraction_stores:
for trigger in binding.config.triggers:
trigger.attach(ExtractionTriggerContext(agent=agent, fire=self._make_fire(coordinator, binding.store)))

@staticmethod
def _make_fire(coordinator: ExtractionCoordinator, store: MemoryStore) -> Callable[[], None]:
Expand Down
13 changes: 10 additions & 3 deletions strands-py/src/strands/memory/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,22 @@ class MemoryStoreConfig(Protocol):
writable: Whether this store accepts writes. A writable store requires at
least one write sink (:meth:`MemoryStore.add` or
:meth:`MemoryStore.add_messages`).
extraction: Automatic-extraction configuration. Requires the store to be
writable.
extraction: Automatic-extraction configuration for this writable store, as
a ``bool | config`` shorthand. ``True`` enables it with defaults; an
:class:`ExtractionConfig` defaults any unset field; ``False``/``None``
is off. The defaults run every 5 turns, and the extraction method
depends on the store's write methods: a store implementing only ``add``
uses a :class:`~strands.memory.extraction.model_extractor.ModelExtractor`
for client-side extraction (a model call to distill facts, stored via
``add``), while a store implementing ``add_messages`` uses server-side
extraction (the backend extracts the raw messages, no model call).
"""

name: str
description: str | None
max_search_results: int | None
writable: bool
extraction: ExtractionConfig | None
extraction: ExtractionConfig | bool | None


class MemoryStore(MemoryStoreConfig, Protocol):
Expand Down
Loading
Loading