symfonic.memory.layers¶
layers ¶
Memory layer implementations (Pentad model).
Re-exports all layer classes for convenient importing.
EpisodicLayer ¶
EpisodicLayer(
vector_backend: VectorBackend,
embedding_provider: EmbeddingProvider,
telemetry_sink: EpisodicTelemetrySink | None = None,
)
Episodic memory layer for narrative events and history.
BaseMemoryStore contract
- retrieve: queries events by vector similarity
- write: stores an event with its embedding
- summarize: returns empty string (delegates to compaction engine)
- link: no-op (episodes are vector-stored, not graph-linked)
- delete: removes an event from the vector store
Source code in src/symfonic/memory/layers/episodic.py
count_events
async
¶
Return the total number of stored episodic events for the tenant.
Delegates to VectorBackend.count() when available, otherwise falls back to 0 to avoid a full scan with an arbitrary embedding.
Source code in src/symfonic/memory/layers/episodic.py
delete
async
¶
link
async
¶
list_events
async
¶
Return up to limit episodic events for the tenant.
Uses a neutral zero-vector query so that all stored events are
returned in approximate descending-similarity order (deterministic
for vectors with the same embedding, arbitrary but consistent
otherwise). Prefer this over query_events when you need a
full listing rather than a relevance-ranked subset.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
TenantScope
|
Tenant scope for isolation. |
required |
limit
|
int
|
Maximum number of events to return (default 1000). |
1000
|
Returns:
| Type | Description |
|---|---|
list[MemoryEntry]
|
List of MemoryEntry instances (order: most-similar first). |
Notes
v7.13.6: when the embedder rejects embed("") (the OpenAI
embeddings endpoint returns 400 "input cannot be an empty
string") we transparently fall back to embed(" "). The
single-space probe is non-empty for OpenAI's validator while
remaining distance-agnostic enough to enumerate stored events
via similarity ranking. Sentence-transformers and other
backends that accept empty strings continue to use the
zero-string path. Fixes the v7.13.4 quick_nap phase-10 WARN
reported by Jarvio.
Source code in src/symfonic/memory/layers/episodic.py
query_events
async
¶
Query episodic events by vector similarity.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
TenantScope
|
Tenant scope for isolation. |
required |
query
|
str
|
Natural language query to search against. Must be a non-empty, non-whitespace string. |
required |
top_k
|
int
|
Maximum number of results to return. |
5
|
Returns:
| Type | Description |
|---|---|
list[MemoryEntry]
|
List of MemoryEntry instances ranked by similarity. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/symfonic/memory/layers/episodic.py
render_for_prompt ¶
render_for_prompt(
entry: Any,
scrubber: Callable[[dict[str, Any]], dict[str, Any]]
| None = None,
) -> str
Render an episodic entry for MEMORY_CONTEXT.
v7.7: when metadata['speaker'] is present (per-message row
introduced in Phase B), emit [episodic] <speaker>: <content>
so the LLM sees a clean speaker-prefixed line. Pre-v7.7 rows
(no speaker) or rows tagged speaker == 'legacy' fall back
to :func:render_legacy for byte-identical pre-v7.7 output
and prompt-cache parity.
Source code in src/symfonic/memory/layers/episodic.py
retrieve
async
¶
Retrieve episodic events matching the query.
v7.22 T-7.22.8: emits :class:MultiScopeIgnoredWarning on first
encounter of a scope with inherits_from. Episodic events are
speaker-attributed timestamped records bound to a single
(tenant, sub_tenant) pair; merging inherited scopes would
corrupt per-tenant cost accounting and break GDPR scope
guarantees (v5.5.0 Gate 3).
Source code in src/symfonic/memory/layers/episodic.py
store_event
async
¶
store_event(
scope: TenantScope,
event: MemoryEntry,
*,
source_phase: str = "episodic.store_event",
) -> None
Store an event with its embedding in the vector backend.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
TenantScope
|
Tenant scope for isolation. |
required |
event
|
MemoryEntry
|
The memory entry representing the event. |
required |
source_phase
|
str
|
Tag identifying which caller triggered the
write (e.g. |
'episodic.store_event'
|
v6.2 T05 telemetry: when the caller populates event.metadata
with any of tokens_in / tokens_out / input_tokens /
output_tokens / cost_usd / model /
embedding_latency_ms, the values are lifted onto the
:class:EpisodicTelemetryRecord for the corpus. Keys missing
from metadata remain None and are stripped from the JSONL
line (v6.1 shape preserved byte-for-byte). The engine can source
the token / cost fields from LLMPreCallEvent /
LLMEndEvent and attach them to the metadata before calling
store_event; direct callers that do not consume LangChain
callbacks may pass the values in metadata themselves.
Source code in src/symfonic/memory/layers/episodic.py
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | |
summarize
async
¶
write
async
¶
ProceduralLayer ¶
ProceduralLayer(
graph_store: GraphMemoryStore,
dedup_threshold: float = 0.65,
embedding_provider: EmbeddingProvider | None = None,
embedding_cache: EmbeddingCache | None = None,
)
Procedural memory layer for skills and workflows.
BaseMemoryStore contract
- retrieve: queries skills by content matching
- write: stores a skill as a graph node
- summarize: returns empty string (skills are standalone)
- link: no-op (skills are standalone graph nodes)
- delete: removes a skill node from the graph
v7.3 Item 13.1: optional embedding_provider for auto-embed.
Default None preserves byte-identical pre-Item-13.1 behaviour.
When supplied, every store_skill write runs through
:func:maybe_embed before the de-dup janitor sees it -- so
procedural skills auto-embed when the caller leaves
node.embedding=None. Caller-provided embeddings always win.
The embedding_cache is shared across all three graph-backed
layers by the orchestrator.
Per-kind embedding-text rule: procedural skills always fall into
the default branch of
:func:symfonic.memory.embeddings.auto_embed.resolve_embedding_text
and embed properties['content'] (the skill's rich description)
rather than the label (which is the skill_id).
Source code in src/symfonic/memory/layers/procedural/layer.py
approve_skill
async
¶
Promote a draft skill to approved+active.
Source code in src/symfonic/memory/layers/procedural/layer.py
delete
async
¶
link
async
¶
query_skills
async
¶
query_skills(
scope: TenantScope,
query: str,
top_k: int = 5,
*,
include_drafts: bool = False,
) -> list[MemoryEntry]
Query procedural skills by word-level matching.
Uses word intersection (like SemanticLayer.query_facts) instead of exact substring matching so that "deploy the app" can find a skill whose label/content contains "deploy" or "app".
By default only active (approved) skills are returned. Pass
include_drafts=True to return skills of all statuses.
Ordering contract (v7.19.2): the returned list is sorted by
node_id so the "first matching skill" downstream
consumers rely on (precondition_gate's first-call wins,
engine _maybe_resolve_forced_tool_choice's candidate
procedure selection) is deterministic across the in-memory /
mongodb / postgres backends. The in-memory backend's
dict.values() order happens to be insertion-stable on
CPython 3.7+, but mongodb / postgres do not guarantee
registration order without an explicit sort. Sorting at
query time pins the contract at the layer boundary so any
future backend additions inherit it automatically.
v7.22 (T-7.22.12-15) multi-scope: when scope.inherits_from
is non-empty, the query fans out to [scope, *scope.inherits_from]
and merges results by shadow override on
metadata['identifier'] first, then metadata['label'].
Empty inherits_from (the common case) delegates to the
single-scope fast path unchanged. See
docs/concepts/procedural-shadow-inheritance.md for merge semantics
and cache-invariant trade-offs.
Source code in src/symfonic/memory/layers/procedural/layer.py
reject_skill
async
¶
Mark a draft skill as rejected+inactive.
Source code in src/symfonic/memory/layers/procedural/layer.py
render_for_prompt ¶
render_for_prompt(
entry: Any,
scrubber: Callable[[dict[str, Any]], dict[str, Any]]
| None = None,
) -> str
Render a procedural entry for MEMORY_CONTEXT.
v7.7: closes the v7.6.4 procedural-render-bypass structurally.
When the entry carries v7.5+ skill-aware metadata (source
starts with "authored:" OR non-empty precondition of
either str or list[str] shape), delegate to the
v7.5.1 _render_skill_for_prompt helper so PRE-FLIGHT
markers reach the prompt. Pre-v7.5 procedural entries fall
through to :func:render_legacy for byte-identical pre-v7.7
output and prompt-cache parity.
Supersedes the inline _is_skill_aware_entry predicate +
dispatch block that lived in engine.py after the v7.6.4
hotfix; the engine's render loop is now polymorphic dispatch
with zero procedural-specific knowledge.
Source code in src/symfonic/memory/layers/procedural/layer.py
retrieve
async
¶
Retrieve skills matching the query.
seed_authored_skill
async
¶
seed_authored_skill(
scope: TenantScope,
skill: MemoryEntry,
*,
source_tag: str,
precondition: str
| list[str | dict[str, Any]]
| None = None,
) -> MemoryNode
Seed a hand-authored procedural skill (Jarvio Ask 7 Option B).
Convenience wrapper around :meth:store_skill for the
domain-plugin tier of procedural memory. The result lands at
status="approved" (immediately active in
:meth:query_skills), carries the required source tag so
:class:~symfonic.memory.janitor.SemanticMerge leaves it
alone, and optionally records one or more pre-flight
preconditions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
TenantScope
|
Tenant scope for isolation. |
required |
skill
|
MemoryEntry
|
The skill |
required |
source_tag
|
str
|
A discriminator the janitor uses to recognise
the authored tier. MUST start with |
required |
precondition
|
str | list[str | dict[str, Any]] | None
|
Optional pre-flight rule. Three shapes accepted (v7.6.2 -- Jarvio multi-precondition ask):
|
None
|
Returns:
| Type | Description |
|---|---|
MemoryNode
|
The persisted :class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
When |
Source code in src/symfonic/memory/layers/procedural/layer.py
store_skill
async
¶
Store a skill as a MemoryNode with steps and context properties.
Before inserting, the incoming skill is checked against existing procedural nodes via SemanticMerge. If a sufficiently similar node already exists the two are merged in-place and no new node is created.
The skill's metadata should include 'steps' (list[str]) and 'context' (str) keys for proper procedural storage.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
status
|
str
|
"draft" (inactive, pending review), "approved" (active), "rejected" (archived, will not be queried). |
'draft'
|
Source code in src/symfonic/memory/layers/procedural/layer.py
summarize
async
¶
write
async
¶
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
delete
async
¶
get_pending
async
¶
Get all unresolved triggers for the tenant.
Source code in src/symfonic/memory/layers/prospective.py
link
async
¶
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
resolve_trigger
async
¶
retrieve
async
¶
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
set_trigger
async
¶
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
summarize
async
¶
SemanticLayer ¶
SemanticLayer(
graph_store: GraphMemoryStore,
embedding_provider: EmbeddingProvider | None = None,
embedding_cache: EmbeddingCache | None = None,
)
Semantic memory layer for permanent facts and graph entities.
BaseMemoryStore contract
- retrieve: queries facts by content similarity
- write: stores a fact as a graph node
- summarize: returns empty string (facts are not summarizable in isolation)
- link: fully implemented (graph-native operation)
- delete: removes a fact node from the graph
Wrap graph_store with optional auto-embedding (v7.3 Item 13.1).
embedding_provider (default None) preserves byte-identical
pre-Item-13.1 behaviour -- the caller is still responsible for
populating node.embedding before write. When supplied, the
provider is invoked through :func:maybe_embed before every
store_fact / write so nodes the caller leaves with
embedding=None auto-embed. Caller-provided embeddings always
win.
embedding_cache is the shared sha256-keyed LRU cache the
orchestrator hands to every graph-backed layer; passing None
is supported but means this layer never benefits from a sibling
layer having already embedded the same text.
Per-kind embedding-text rule lives in
:func:symfonic.memory.embeddings.auto_embed.resolve_embedding_text
-- semantic entity nodes (label prefix Entity:) prefer
node.label; all other semantic nodes prefer
node.properties['content'].
Source code in src/symfonic/memory/layers/semantic.py
delete
async
¶
link
async
¶
Create a relationship edge between two semantic nodes.
Source code in src/symfonic/memory/layers/semantic.py
query_facts
async
¶
Query semantic facts by keyword matching.
For production use, this should be combined with embedding-based retrieval via the RetrievalEngine. This method provides basic keyword filtering as a fallback.
On a no-keyword-match it returns ONLY the always-on persona node
(the AGENT_IDENTITY allowlist) -- never the full
importance-sorted node set. The old "top-k by importance"
fallback recited unrelated high-importance facts on irrelevant
queries (e.g. a bare "Hello"); the allowlist still serves
"who are you?"-style identity turns without that leak.
Source code in src/symfonic/memory/layers/semantic.py
render_for_prompt ¶
render_for_prompt(
entry: Any,
scrubber: Callable[[dict[str, Any]], dict[str, Any]]
| None = None,
) -> str
Render a semantic entry for MEMORY_CONTEXT.
v7.7: explicit override delegating to :func:render_legacy so
the per-layer contract test catches a missing override on a
future subclass. The pre-v7.7 [layer] content (k=v) shape
is the correct semantic-fact render; layer-specific shaping
would change cache-keyed prompt bytes for every adopter.
Source code in src/symfonic/memory/layers/semantic.py
retrieve
async
¶
Retrieve semantic facts matching the query.
v7.22 T-7.22.7: emits :class:MultiScopeIgnoredWarning on first
encounter of a scope carrying inherits_from. The SemanticLayer
does NOT honour the field (facts are tenant-bound by entity
construction); the warning surfaces the silent-ignore class so
adopters know their inheritance setup is not propagating here.
Source code in src/symfonic/memory/layers/semantic.py
store_fact
async
¶
Store a fact as a MemoryNode in the graph.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
TenantScope
|
Tenant scope for isolation. |
required |
fact
|
MemoryEntry
|
The memory entry representing the fact. |
required |
Returns:
| Type | Description |
|---|---|
MemoryNode
|
The persisted MemoryNode. |
Source code in src/symfonic/memory/layers/semantic.py
summarize
async
¶
write
async
¶
WorkingLayer ¶
WorkingLayer(
max_entries: int = 10,
graph_store: object | None = None,
*,
stm_summary_mode: STMSummaryMode = "off",
working_graph_retention: int | None = None,
)
Working memory layer for active conversation context.
BaseMemoryStore contract
- retrieve: returns current context entries (from in-memory buffer)
- write: pushes an entry (FIFO eviction) and mirrors to graph if available
- summarize: returns empty string
- link: no-op
- delete: removes entry from graph when graph_store is set
- clear: clears in-memory deque AND all graph nodes for the tenant
When graph_store is provided, each write is also persisted as a MemoryNode with layer=WORKING so the metrics endpoint can count across restarts.
Graph retention cap: when working_graph_retention is set (an integer), the graph side is capped at that many nodes per tenant. On each push that exceeds the cap, the oldest nodes are pruned. The in-memory deque is governed separately by max_entries (FIFO eviction). The cap is intentionally opt-in (None = unbounded, the pre-F3 behaviour) so existing deployments that rely on the graph for long-term auditing are unaffected until they explicitly set a retention value.
Initialize the layer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_entries
|
int
|
FIFO buffer ceiling per tenant. |
10
|
graph_store
|
object | None
|
Optional graph backend for dual-write persistence. |
None
|
stm_summary_mode
|
STMSummaryMode
|
v7.0.1 R3. Controls the default behaviour of
:meth: |
'off'
|
Source code in src/symfonic/memory/layers/working.py
clear
async
¶
Clear all working context for the tenant.
Empties the in-memory deque AND deletes all WORKING-layer graph nodes for this tenant when a graph_store is configured. Nodes belonging to other tenants are untouched (tenant isolation is enforced by the query_nodes filter).
Source code in src/symfonic/memory/layers/working.py
delete
async
¶
Remove a working memory node from the graph backend.
The in-memory deque uses FIFO eviction only; this method targets the graph-persistence side so that explicit deletes (e.g. triggered by retention-cap pruning or a user request) actually remove the node from the durable store.
When graph_store is not configured the call is a no-op (backward compatible with deployments that use in-memory-only working memory).
Source code in src/symfonic/memory/layers/working.py
get_context
async
¶
Get all current working context entries for the tenant.
Returns entries in chronological order (oldest first).
Source code in src/symfonic/memory/layers/working.py
link
async
¶
push
async
¶
Push an entry to the working memory buffer.
If the buffer is at capacity, the oldest entry is evicted (FIFO). If a graph_store is available, the entry is also persisted as a node. When working_graph_retention is set, the oldest graph nodes for this tenant are pruned after the write so the count stays at or below the configured cap.
v7.26.2 (Shape C1): the optional durability kwarg marks the
dual-written graph node so the sleep-consolidation pipeline can
skip promotion of transient/expired rows. None (default)
writes NO durability key -- byte-identical to pre-v7.26.2 (the
node reads as "durable" on the consolidation side). Resolution
order: explicit kwarg > entry.metadata['durability'] > unset.
An invalid value raises :class:ValueError at the call site
(fail-loud, never a silent gate bypass).
Source code in src/symfonic/memory/layers/working.py
rehydrate_from_messages
async
¶
rehydrate_from_messages(
scope: TenantScope,
messages: list[Any],
*,
max_turns: int | None = None,
) -> int
Replay the tail of a persisted transcript into the working deque.
v7.27.0 restart-resume (Q7, option-b): on session resume the
in-memory deque is empty (process restarted), but the durable
checkpointer still holds state['messages']. This method
reconstructs the RECENCY WINDOW only -- the last N user/assistant
turns -- as MemoryEntry rows tagged with the SAME
speaker/turn_id/source metadata shape the live
auto-turn writes use (engine.py), so downstream consumers cannot
tell a rehydrated row from a freshly-written one.
Scope of restore (LOCKED, plan §4.c): recency window ONLY. Tool / system messages are skipped (they are not conversational turns and would blow the cap with noise -- R4). Durable HMS state is NOT touched (it was never lost). No graph write is made -- this is a pure in-memory deque replay; persisting these rows again would double-count graph nodes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
TenantScope
|
Tenant scope selecting the target deque. |
required |
messages
|
list[Any]
|
The checkpointer's |
required |
max_turns
|
int | None
|
Cap on user+assistant rows replayed (counted as
individual rows, not pairs). Defaults to |
None
|
Returns:
| Type | Description |
|---|---|
int
|
The number of rows replayed into the deque. |
Source code in src/symfonic/memory/layers/working.py
render_for_prompt ¶
render_for_prompt(
entry: Any,
scrubber: Callable[[dict[str, Any]], dict[str, Any]]
| None = None,
) -> str
Render a working-memory entry for MEMORY_CONTEXT.
v7.7: same speaker-prefix shape as :class:EpisodicLayer --
per-message rows emit [working] <speaker>: <content>; pre-
v7.7 rows (no speaker, or speaker == 'legacy') delegate
to :func:render_legacy for byte-identical pre-v7.7 output.
v7.7.1: the engine's working-memory pre-hydration block (see
SymfonicAgent._hydrate_impl -- the recency-prefix loop
immediately preceding the polymorphic-dispatch render) calls
this method on each recent entry so the per-layer render
contract covers both code paths. Pre-7.7.1 the recency
prefix hand-built [working] <content> inline, bypassing
the protocol -- a stale docstring previously cited
engine.py:5184-5189 for the call site, which was both the
wrong line range AND a path that never invoked the renderer
(same bypass class as the v7.6.4 procedural-render-bypass).
Source code in src/symfonic/memory/layers/working.py
retrieve
async
¶
Retrieve current working context entries.
v7.22 T-7.22.9: emits :class:MultiScopeIgnoredWarning on first
encounter of a scope with inherits_from (plan §2.d wires
warning here ONLY -- push / get_context have no
scope-merge semantics to ignore, so emitting from them would be
a false-positive in adopter mental models). Working memory is a
per-(tenant, sub_tenant) FIFO buffer; merging inherited
scopes would either leak cross-tenant context or require a
merge policy with no defensible default.
Source code in src/symfonic/memory/layers/working.py
summarize
async
¶
Summarise working-memory entries according to stm_summary_mode.
v7.0 T09 shipped the extractive summariser but the in-layer
summarize() hard-coded mode="extractive" regardless of
config. v7.0.1 R3 wires the stm_summary_mode kwarg through
so direct-API callers see the same behaviour as engine-routed
callers:
"off"(default) — returns"". Preserves v6.1.x byte-for-byte behaviour."extractive"— delegates tosummarize_entrieswith the deterministic first-sentence / last-sentence heuristic. Zero-cost, no LLM call."llm"— the in-layersummarize()is synchronous- equivalent over the MemoryEntry list and does not own a model provider. Falls through to the extractive path so this call never stalls on an upstream outage; callers who want the true LLM micro-call must invoke :func:symfonic.memory.working.stm_summary.summarize_entriesdirectly with amodel_provider(the engine wires that path viaSymfonicAgent).
Empty input always returns the empty string so the v6.1.x observable "no meaningful summary" behaviour is preserved on empty buffers independent of mode.
Source code in src/symfonic/memory/layers/working.py
write
async
¶
Push an entry to the working buffer (FIFO eviction) and persist to graph.