Skip to content

symfonic.memory.layers.prospective

prospective

ProspectiveLayer -- triggers, reminders, and pending tasks.

Stores triggers as graph nodes with condition and task properties. Supports resolution (marking triggers as completed).

ProspectiveLayer

ProspectiveLayer(
    graph_store: GraphMemoryStore,
    embedding_provider: EmbeddingProvider | None = None,
    embedding_cache: EmbeddingCache | None = None,
)

Prospective memory layer for triggers and reminders.

BaseMemoryStore contract
  • retrieve: returns [] -- triggers are NOT generic hydration hits
  • write: stores a trigger as a graph node
  • summarize: returns empty string
  • link: no-op
  • delete: removes a trigger node

v7.3 Item 13.1: optional embedding_provider for auto-embed.

Default None preserves byte-identical pre-Item-13.1 behaviour. When supplied, every set_trigger write runs through :func:maybe_embed so prospective triggers auto-embed when the caller leaves node.embedding=None. Caller-provided embeddings always win.

Per-kind embedding-text rule: triggers always fall into the default branch of :func:symfonic.memory.embeddings.auto_embed.resolve_embedding_text and embed properties['content'] (the trigger's human-readable description) rather than the label.

Source code in src/symfonic/memory/layers/prospective.py
def __init__(
    self,
    graph_store: GraphMemoryStore,
    embedding_provider: EmbeddingProvider | None = None,
    embedding_cache: EmbeddingCache | None = None,
) -> None:
    """v7.3 Item 13.1: optional ``embedding_provider`` for auto-embed.

    Default ``None`` preserves byte-identical pre-Item-13.1 behaviour.
    When supplied, every ``set_trigger`` write runs through
    :func:`maybe_embed` so prospective triggers auto-embed when the
    caller leaves ``node.embedding=None``. Caller-provided embeddings
    always win.

    Per-kind embedding-text rule: triggers always fall into the
    default branch of
    :func:`symfonic.memory.embeddings.auto_embed.resolve_embedding_text`
    and embed ``properties['content']`` (the trigger's human-readable
    description) rather than the label.
    """
    self._graph = graph_store
    self._embedding_provider = embedding_provider
    self._embedding_cache = embedding_cache
    # v7.22 T-7.22.10 -- emit-once tracking for MultiScopeIgnoredWarning.
    # Prospective.retrieve() returns [] by design (since v6.x); the
    # warning is no-op-but-consistent so adopters routing a multi-scope
    # scope through every layer see exactly ONE warning per non-
    # procedural layer per tenant -- not three with one silent gap.
    self._multi_scope_warned: set[tuple[str, str | None]] = set()

delete async

delete(scope: TenantScope, node_id: NodeId) -> None

Delete a trigger node.

Source code in src/symfonic/memory/layers/prospective.py
async def delete(self, scope: TenantScope, node_id: NodeId) -> None:
    """Delete a trigger node."""
    await self._graph.delete_node(scope, node_id)

get_pending async

get_pending(scope: TenantScope) -> list[MemoryEntry]

Get all unresolved triggers for the tenant.

Source code in src/symfonic/memory/layers/prospective.py
async def get_pending(self, scope: TenantScope) -> list[MemoryEntry]:
    """Get all unresolved triggers for the tenant."""
    nodes = await self._graph.query_nodes(scope, layer=MemoryLayer.PROSPECTIVE)
    pending: list[MemoryEntry] = []
    for node in nodes:
        if not node.properties.get("resolved", False):
            pending.append(
                MemoryEntry(
                    layer=MemoryLayer.PROSPECTIVE,
                    tenant_id=node.tenant_id,
                    content=str(node.properties.get("content", node.label)),
                    node_id=node.id,
                    importance=node.importance,
                    metadata={
                        "condition": node.properties.get("condition", ""),
                        "task": node.properties.get("task", ""),
                    },
                    created_at=node.created_at,
                )
            )
    return pending
link(
    scope: TenantScope,
    source: NodeId,
    target: NodeId,
    relationship: str,
) -> None

No-op. Triggers are standalone nodes.

Source code in src/symfonic/memory/layers/prospective.py
async def link(
    self, scope: TenantScope, source: NodeId, target: NodeId, relationship: str
) -> None:
    """No-op. Triggers are standalone nodes."""
    return

render_for_prompt

render_for_prompt(
    entry: Any,
    scrubber: Callable[[dict[str, Any]], dict[str, Any]]
    | None = None,
) -> str

Render a prospective entry for MEMORY_CONTEXT.

v7.7: explicit override delegating to :func:render_legacy for byte-identical pre-v7.7 output. Prospective triggers don't carry the v7.5+ skill-aware metadata signals so a layer-specific shape would have no upside.

Source code in src/symfonic/memory/layers/prospective.py
def render_for_prompt(
    self,
    entry: Any,
    scrubber: Callable[[dict[str, Any]], dict[str, Any]] | None = None,
) -> str:
    """Render a prospective entry for ``MEMORY_CONTEXT``.

    v7.7: explicit override delegating to :func:`render_legacy`
    for byte-identical pre-v7.7 output.  Prospective triggers
    don't carry the v7.5+ skill-aware metadata signals so a
    layer-specific shape would have no upside.
    """
    return render_legacy(entry, scrubber)

resolve_trigger async

resolve_trigger(
    scope: TenantScope, node_id: NodeId
) -> None

Mark a trigger as resolved.

Source code in src/symfonic/memory/layers/prospective.py
async def resolve_trigger(self, scope: TenantScope, node_id: NodeId) -> None:
    """Mark a trigger as resolved."""
    await self._graph.update_node(scope, node_id, {"resolved": True})

retrieve async

retrieve(
    scope: TenantScope, query: str, top_k: int = 5
) -> list[MemoryEntry]

Return [] -- prospective triggers are not generic hits.

Previously this bulk-dumped every unresolved trigger via get_pending regardless of query, so a bare "Hello" recited all pending reminders into MEMORY_CONTEXT. Pending triggers surface only through the dedicated PROSPECTIVE_TASKS prompt channel and the explicit :meth:get_pending API; they must never enter per-turn semantic hydration. Callers that need the trigger list must call :meth:get_pending directly.

v7.22 T-7.22.10: emits :class:MultiScopeIgnoredWarning on first encounter of a scope carrying inherits_from. Consistency with the other non-procedural layers -- even though the result is always [], adopters wiring a multi-scope scope through every layer get exactly one warning per layer rather than a silent gap.

Source code in src/symfonic/memory/layers/prospective.py
async def retrieve(
    self, scope: TenantScope, query: str, top_k: int = 5
) -> list[MemoryEntry]:
    """Return ``[]`` -- prospective triggers are not generic hits.

    Previously this bulk-dumped every unresolved trigger via
    ``get_pending`` regardless of ``query``, so a bare "Hello"
    recited all pending reminders into ``MEMORY_CONTEXT``. Pending
    triggers surface only through the dedicated ``PROSPECTIVE_TASKS``
    prompt channel and the explicit :meth:`get_pending` API; they
    must never enter per-turn semantic hydration. Callers that need
    the trigger list must call :meth:`get_pending` directly.

    v7.22 T-7.22.10: emits :class:`MultiScopeIgnoredWarning` on first
    encounter of a scope carrying ``inherits_from``.  Consistency with
    the other non-procedural layers -- even though the result is
    always ``[]``, adopters wiring a multi-scope scope through every
    layer get exactly one warning per layer rather than a silent gap.
    """
    from symfonic.memory._multi_scope_warn import maybe_warn_multi_scope

    maybe_warn_multi_scope(self, self._multi_scope_warned, scope)
    return []

set_trigger async

set_trigger(
    scope: TenantScope, trigger: MemoryEntry
) -> MemoryNode

Store a trigger with condition and task properties.

The trigger's metadata should include 'condition' (str) and 'task' (str) keys.

Source code in src/symfonic/memory/layers/prospective.py
async def set_trigger(
    self, scope: TenantScope, trigger: MemoryEntry
) -> MemoryNode:
    """Store a trigger with condition and task properties.

    The trigger's metadata should include 'condition' (str) and
    'task' (str) keys.
    """
    node = MemoryNode(
        layer=MemoryLayer.PROSPECTIVE,
        tenant_id=scope.tenant_id,
        label=trigger.content[:100],
        properties={
            "content": trigger.content,
            "condition": trigger.metadata.get("condition", ""),
            "task": trigger.metadata.get("task", ""),
            "resolved": False,
        },
        importance=trigger.importance,
    )
    if trigger.node_id:
        node.id = trigger.node_id
    # v7.3 Item 13.1: auto-embed when a provider is configured and the
    # caller didn't pre-populate node.embedding.
    # TODO(v7.4): batch embeddings when called in a tight loop.
    node = await maybe_embed(
        node, self._embedding_provider, self._embedding_cache,
    )
    return await self._graph.add_node(scope, node)

summarize async

summarize(
    scope: TenantScope, entries: list[MemoryEntry]
) -> str

Not meaningful for prospective triggers. Returns empty string.

Source code in src/symfonic/memory/layers/prospective.py
async def summarize(self, scope: TenantScope, entries: list[MemoryEntry]) -> str:
    """Not meaningful for prospective triggers. Returns empty string."""
    return ""

write async

write(scope: TenantScope, entry: MemoryEntry) -> None

Write a trigger entry.

Source code in src/symfonic/memory/layers/prospective.py
async def write(self, scope: TenantScope, entry: MemoryEntry) -> None:
    """Write a trigger entry."""
    await self.set_trigger(scope, entry)