Memory and Persistence¶
The SynthOrg framework separates two distinct storage concerns:
- Agent memory: what agents know, remember, and learn (working, episodic, semantic, procedural, social)
- Operational data: tasks, cost records, messages, and audit logs generated during execution
Both are implemented behind pluggable protocol interfaces, making storage backends swappable via configuration without modifying application code.
This page covers agent memory: types, levels, the backend protocol, embedder selection, and the consolidation / retention pipeline.
Related design docs¶
- Shared Organisational Memory: company-wide knowledge (policies, ADRs, procedures) behind
OrgMemoryBackend. - Operational Data Persistence:
PersistenceBackendprotocol, per-entity repositories, SQLite + Postgres backends, schema strategy, multi-tenancy, database-enforced invariants. - Memory Learning and Injection: procedural memory auto-generation (failure + success capture), cross-agent skill pool, injection strategies (context / tool-based / self-editing),
MemoryServiceREST + MCP entry point. - Living Documentation: per-project documentation as a dual-purpose wiki + RAG namespace, integrated via the
PROJECT_DOCmemory category andProjectAwareMemoryFacade. - Knowledge and Provenance Substrate: heavy-duty document/knowledge RAG over an ingested external corpus (specs, codebases, web pages, tickets) with citation tracking, reusing the hybrid retrieval stack via the
KNOWLEDGEmemory category.
Memory Architecture¶
| Working Memory | Episodic Memory | Semantic Memory | Procedural Memory |
|---|---|---|---|
| Current task context | Past events & decisions | Knowledge & facts learned | Skills & how-to |
Storage Backend: sqlvector (durable; pgvector on Postgres, sqlite-vec on
SQLite), inmemory (ephemeral, discouraged), composite (namespace routing).
See Decision Log.
Each agent maintains its own memory store. The storage backend is selected via configuration
and all access flows through the MemoryBackend protocol.
Why memory lives in the operational database¶
Agent memory is stored in the same Postgres or SQLite database as everything else, rather than in a dedicated vector service. That keeps one thing to run, back up and migrate, and it lets tag filtering, expiry, and agent scoping be plain SQL rather than predicates re-implemented in application code because the store cannot express them.
The lexical arm uses an ordinary inverted-index table (memory_entry_terms)
scored by shared BM25 code, not FTS5 or tsvector. An ordinary table is
portable, so the two backends are held to one behavioural contract by the
conformance suite instead of being two implementations that merely resemble
each other, and ranking stays beside the RRF and MMR code that already exists.
pg_search is AGPL
ParadeDB's pg_search is the usual answer for BM25 on Postgres and is
AGPL-3.0. The Licence Compatibility rule bars it. Use core
tsvector/pg_trgm or the inverted-index table.
Retrieval¶
Retrieval is two-stage, which is the consistent finding across the IR literature:
- Recall wide from two orthogonal signals: dense vector similarity and BM25 over the inverted index, fused by Reciprocal Rank Fusion (Cormack et al., SIGIR 2009). RRF operates on ranks, so it sidesteps the score-normalisation problem that makes a weighted sum of cosine distance and BM25 unreliable. Each arm is over-fetched before fusion so a document ranked mid-list by one signal can still reach the fused top-k on the strength of the other.
- Narrow to a small injected set, because more retrieved context is not better (see Context budget).
Dense KNN always returns something
A vector index returns its k nearest neighbours regardless of whether any
of them are relevant: a nonsense query against a store holding one memory
still returns that memory. Worse, RRF min-max normalises, so the top fused
hit scores exactly 1.0 however irrelevant it is, which makes any
threshold on the fused score meaningless.
Two guards sit in the backend: a query whose embedding carries no signal
skips the dense arm entirely, and dense hits no closer than orthogonal to
the query are dropped (with normalised embeddings, orthogonal sits at
1/(1+sqrt(2)) ~= 0.414, so the floor is a geometric statement rather
than a tuned number).
Neither is a calibrated relevance gate. Raw similarity is a poor binary judge of whether a memory will actually help; an applied study measured its ability to predict that at AUC 0.50, no better than chance. Abstention is a first-class success case.
A retriever honours its settings or refuses them¶
MemoryRetrievalConfig.retriever selects between a flat retriever and the
hierarchical one, and three fields (max_workers_per_query,
reflective_retry_enabled, max_retry_count) exist only for the second: the
flat path has no supervisor to fan out, retry against, or bound. Setting one of
them under retriever: flat is therefore refused at construction, not warned
about.
That is the general rule stated once. A setting an operator writes, that the system persists, that the dashboard shows back, and that nothing then applies is worse than one that never existed: it reads as configured. The two shapes are the only honest ones, and this is a configuration a retriever cannot honour, so it fails loud rather than accepting a value it will ignore. Making the flat path honour them is the alternative that was rejected: it would mean importing the whole supervisor apparatus, which is a different retrieval architecture, not a setting.
The refusal is judged on VALUE, never on presence. A model_dump() /
model_validate() round-trip marks every field as explicitly set, so a
presence test refuses a config nobody touched: that is exactly the defect it
replaced, where one field's warning fired fifty times in a single run against a
value equal to its own default. A field equal to its default was not configured,
whatever model_fields_set says about how it arrived.
Where this can fire is bounded: neither retriever nor the three fields is a
live setting, so no operator write reaches it. It is the static company-config
at boot, and a failure there is booked by the subsystem reconciler as a failed
activation, reported by GET /subsystems, and retried on the next pass, never
a crash.
Context budget¶
Injected memory competes with the prompt, and past a point it actively harms accuracy:
- Lost in the middle (Liu et al., TACL 2024): accuracy is U-shaped in the position of the relevant item, even in long-context models.
- Context rot (Chroma, 2026): degradation is non-uniform and each added distractor compounds the loss. The mechanism is architectural, so larger windows do not fix it.
So injection is capped by engine.memory_context_token_budget (default 2000,
read per task so an operator change applies without a restart), and injected
entries are placed adjacent to the system prompt rather than buried
mid-context.
Memory Types¶
| Type | Scope | Persistence | Example |
|---|---|---|---|
| Working | Current task | None (in-context) | "I'm implementing the auth endpoint" |
| Episodic | Past events | Configurable | "Last sprint the team chose JWT over sessions" |
| Semantic | Knowledge | Long-term | "This project uses Litestar with aiosqlite" |
| Procedural | Skills/patterns | Long-term | "Code reviews require 2 approvals here" |
| Social | Relationships | Long-term | "The QA lead prefers detailed test plans" |
| Project doc | Project-scoped living documentation | Long-term | "Q3 status report: checkout flow shipped, retention trending up" |
Memory Levels¶
Memory persistence is configurable per agent, from no persistence to fully persistent storage.
The persistence level lives on each agent's MemoryConfig.type (default session); it is not a
company-wide memory setting.
Memory Level Configuration
Per agent, under the agent's identity card:
Company-wide, under the memory namespace:
Memory Backend Protocol¶
Agent memory is implemented behind a pluggable MemoryBackend protocol with three concrete
implementations: SqlVectorBackend (durable; pgvector on Postgres, sqlite-vec on SQLite),
InMemory (ephemeral, discouraged), and Composite (namespace-based routing adapter); see
Decision Log. Application
code depends only on the protocol; the storage engine is an implementation detail swappable via
config.
Enums¶
| Enum | Values | Purpose |
|---|---|---|
MemoryCategory |
WORKING, EPISODIC, SEMANTIC, PROCEDURAL, SOCIAL, PROJECT_DOC, KNOWLEDGE, PROJECT_BRAIN | Memory type categories |
MemoryLevel |
PERSISTENT, PROJECT, SESSION, NONE | Persistence level per agent |
ConsolidationInterval |
HOURLY, DAILY, WEEKLY, NEVER | How often old memories are compressed |
MemoryBackend Protocol¶
@runtime_checkable
class MemoryBackend(Protocol):
"""Lifecycle + CRUD for agent memory storage."""
async def connect(self) -> None: ...
async def disconnect(self) -> None: ...
async def health_check(self) -> bool: ...
@property
def is_connected(self) -> bool: ...
@property
def backend_name(self) -> NotBlankStr: ...
async def store(self, agent_id: NotBlankStr, request: MemoryStoreRequest) -> NotBlankStr:
"""Raises: MemoryConnectionError, MemoryStoreError."""
...
async def retrieve(self, agent_id: NotBlankStr, query: MemoryQuery) -> tuple[MemoryEntry, ...]:
"""Raises: MemoryConnectionError, MemoryRetrievalError."""
...
async def get(self, agent_id: NotBlankStr, memory_id: NotBlankStr) -> MemoryEntry | None:
"""Raises: MemoryConnectionError, MemoryRetrievalError."""
...
async def delete(self, agent_id: NotBlankStr, memory_id: NotBlankStr) -> bool:
"""Raises: MemoryConnectionError, MemoryStoreError."""
...
async def count(self, agent_id: NotBlankStr, *, category: MemoryCategory | None = None) -> int:
"""Raises: MemoryConnectionError, MemoryRetrievalError."""
...
MemoryCapabilities Protocol¶
Backends that implement MemoryCapabilities expose what features they support, enabling
runtime capability checks before attempting operations.
@runtime_checkable
class MemoryCapabilities(Protocol):
"""Capability discovery for memory backends."""
@property
def supported_categories(self) -> frozenset[MemoryCategory]: ...
@property
def supports_graph(self) -> bool: ...
@property
def supports_temporal(self) -> bool: ...
@property
def supports_vector_search(self) -> bool: ...
@property
def supports_shared_access(self) -> bool: ...
@property
def max_memories_per_agent(self) -> int | None: ...
SharedKnowledgeStore Protocol¶
Backends that support cross-agent shared knowledge implement this protocol alongside
MemoryBackend. Not all backends require cross-agent queries; this keeps the base protocol
clean.
@runtime_checkable
class SharedKnowledgeStore(Protocol):
"""Cross-agent shared knowledge operations."""
async def publish(self, agent_id: NotBlankStr, request: MemoryStoreRequest) -> NotBlankStr:
"""Raises: MemoryConnectionError, MemoryStoreError."""
...
async def search_shared(self, query: MemoryQuery, *, exclude_agent: NotBlankStr | None = None) -> tuple[MemoryEntry, ...]:
"""Raises: MemoryConnectionError, MemoryRetrievalError."""
...
async def retract(self, agent_id: NotBlankStr, memory_id: NotBlankStr) -> bool:
"""Raises: MemoryConnectionError, MemoryStoreError."""
...
See Multi-Agent Memory Consistency for the consistency model used
when multiple agents share the OrgFactRepository, including MVCC snapshot reads,
append-only write semantics, and conflict handling.
Error Hierarchy¶
All memory errors inherit from MemoryError so callers can catch the entire family with a
single except clause.
| Error | When Raised |
|---|---|
MemoryError |
Base exception for all memory operations |
MemoryConnectionError |
Backend connection cannot be established or is lost |
MemoryStoreError |
A store or delete operation fails |
MemoryRetrievalError |
A retrieve, search, or count operation fails |
MemoryNotFoundError |
A specific memory ID is not found |
MemoryConfigError |
Memory configuration is invalid |
MemoryCapabilityError |
An unsupported operation is attempted for a backend |
FineTuneDependencyError |
ML dependencies (torch, sentence-transformers[train], datasets, accelerate, transformers) are missing |
FineTuneCancelledError |
A fine-tuning pipeline run is cancelled |
FineTuneTrainingDataError |
Stage 2's triples file is empty or malformed |
Configuration¶
memory:
backend: "sqlvector" # sqlvector, composite, inmemory
options:
retention_days: null # null = forever
max_memories_per_agent: 10000
shared_knowledge_base: true
consolidation:
interval: "daily" # drives the consolidation scheduler
# The embedder binding is resolved at boot from the YAML override below,
# then from settings (memory.embedder_model, a provider-bound MODEL_REF,
# plus the optional memory.embedder_dims pin), which are applied last and
# so win per field. Nothing else: an unresolved binding leaves memory off
# rather than choosing a model.
Configuration is modelled by CompanyMemoryConfig (top-level), MemoryStorageConfig
(storage paths/backends), and MemoryOptionsConfig (behaviour tuning). All are frozen
Pydantic models. create_memory_backend(config, *, deps=...) returns an isolated
MemoryBackend per company; deps carries the vector repository from the persistence
layer and the embedder, neither of which the backend can invent for itself.
Boot wiring and the fail-loud rule¶
api/lifecycle_helpers/memory_backend_wiring.wire_memory_backend builds the
backend and publishes it on MemoryStateSlice. It runs before
_install_runtime_services, so an engine constructed in the same boot can
already read it.
When no embedding model resolves, no backend is wired and the failure is logged at ERROR. There is no automatic fallback to keyword-only memory:
Why there is no silent fallback
Memory previously arrived as a side effect of the training-service auto-wire, which published an ephemeral in-process store whose entire matcher was a substring test. Every consumer (agent memory, the project brain, the knowledge substrate, living docs) silently got keyword recall over a dict that emptied on restart, while the settings page advertised a durable backend. A store that looks like working memory but recalls the wrong things is worse than one that is plainly off.
The ephemeral backend remains reachable as an explicit operator choice
(memory.backend: inmemory) and is marked discouraged in settings. It is never
selected automatically.
A backend wired later still reaches agents¶
The engine resolves its injection strategy live, through
MemoryInjectionResolver (workers/_memory_assembly.py), cached on the
identity of the two backends it is built from and re-read per task. Captured
once at construction, an engine built while the embedding model was unreachable
would hold None for the life of the process: the reconciler wiring the backend
on a later pass would reach nothing, so every agent would keep running with no
recall and the operator's fix could not take effect without a restart. Recall
being off is a state the process is expected to leave, so nothing may hold a
snapshot of it.
The cache key covers org_memory_backend as well as the vector backend. The
two are separate subsystems with separate requirements, so they can come up on
different passes; keyed on the vector backend alone, an org backend that
arrived second would never reach the strategy and company-wide knowledge would
stay out of every agent's context.
Being off is also now reported rather than only logged. The reconciler
escalates a subsystem that enters blocked or failed through
NotificationDispatcher (HEALTH category), deduplicated per subsystem and
reason so a condition alerts once rather than once per pass. GET /subsystems
answers "why is this not up" for whoever asks; this is what reaches an operator
who is not asking, which is the gap that let memory stay off through a whole
working session.
Embedding Model Selection¶
The operator names the embedding model. Nothing selects one.
memory.embedder_model is a MODEL_REF, so its type refuses a value that names
a model without the provider serving it, and the guided setup's picker offers
the connected providers' catalogue unfiltered plus the built-in embedder. There
is no ranking table, no recommendation, and no deployment-tier inference. Unset
means memory stays OFF and says so.
Selection used to be the product's job: a shipped benchmark table ranked models, matched one against the operator's catalogue, and read its vector width from a static column in the same table. That produced a binding whose width the vector store could not index, on a stack whose only embedder was a Matryoshka model that could have served any narrower width, and nobody had decided anything.
The built-in embedder is chosen, never fallen back to
builtin/hashing is deterministic feature hashing: it matches shared
vocabulary, not meaning. It exists so an operator with no embedding model can
still run, and it is reachable only by naming it. No path substitutes it
for a model that failed to load, a provider that went unreachable, a missing
optional dependency, or an unset setting, and no embedder is constructed
inside an except handler. Memory quietly becoming lexical is the same
silent-failure class as memory quietly becoming ephemeral, one layer down.
Enforced by check_no_silent_embedder_fallback.py.
The vector width is measured, not looked up. On selection the backend embeds
a probe string through the chosen pair and counts components
(memory/embedding/probe.py). That is the model's own answer, so a model this
codebase has never heard of is as usable as one it has, and the call doubles as
proof the binding works at all: a model that cannot embed fails at selection
rather than at the first memory write.
The measured width is never written back. memory.embedder_dims is the
operator's own truncation pin, which is how a wide Matryoshka model is brought
under the index ceiling, and persisting a measurement into it would make the two
indistinguishable: a width measured for one model would outlive it and be
applied to the next as though it had been asked for, silently truncating vectors
from a model it was never measured against. Boot measures again, against
whatever model is bound then.
The width the probe reports is used as-is, including above pgvector's 4000- dimension HNSW ceiling: those vectors are stored and searched correctly, just without an approximate index, which the health surface reports as DEGRADED and readiness ignores. Truncating automatically would be sound only for a Matryoshka model, and knowing which models those are is the shipped-table approach this section replaced.
This binding is the only embedding model the product serves retrieval from, and nothing else is selectable: a meeting's conflict detectors score positions with the built-in lexical embedder, chosen for that job rather than offered as one option among several. A second locally-loaded embedder selected from somewhere else would be a second surface for the same decision.
Fine-tuning is the one place a model is loaded locally, and where that happens
depends on the install. A Docker install runs each stage in the configured
fine-tuning image, so nothing loads into the backend process: the backend image
carries neither torch nor sentence-transformers. A bare-metal install with
those extras present and no image configured runs the stage in-process instead
(the execution-config derivation below picks between the two).
Why the rankings were about quality, not selection¶
The analysis below still holds and is why the reference page is worth reading before choosing. It informs an operator's decision; it no longer makes one.
Embedding model quality directly determines memory retrieval accuracy. The
LMEB benchmark (Zhao et al., March 2026) evaluates embedding
models on long-horizon memory retrieval across four types that map directly to SynthOrg's
MemoryCategory enum:
| SynthOrg Category | LMEB Category | Evaluation Priority |
|---|---|---|
| EPISODIC | Episodic (69 tasks) | High |
| PROCEDURAL | Procedural (67 tasks) | High |
| SEMANTIC | Semantic (15 tasks) | Medium |
| SOCIAL | Dialogue (42 tasks) | Medium |
| WORKING | N/A (in-context) | N/A |
MTEB scores do not predict memory retrieval quality (Pearson: -0.115, Spearman: -0.130). Embedding model selection must be evaluated on LMEB, not MTEB. See Decision Log and the Embedding Evaluation reference page for the full analysis and the measured results by resource class.
Key findings:
- Larger models do not always outperform smaller ones on memory retrieval
- Dialogue/social memory is the hardest retrieval category for all models
- Instruction sensitivity varies per model; must be validated per deployment
- Results are reported for three resource classes: full-resource (7-12B), mid-resource (1-4B), and CPU-only (< 1B)
Those classes describe what was measured, for an operator sizing a deployment. They are not tiers this codebase infers, and nothing reads them.
Domain-Specific Embedding Fine-Tuning¶
Domain-specific fine-tuning can improve retrieval quality by 10-27% over base models (NVIDIA evaluation). The pipeline requires no manual annotation and runs on a single GPU.
Pipeline stages:
- Training-data generation: the run selects a source via
FineTuneRequest.data_source. In directory mode an LLM generates query-document pairs from a static org-document directory (policies, ADRs, procedures, coding standards); in trajectory mode the pipeline harvests the organisation's real working history (accepted deliverables, distillation trajectories, corrected-failure lessons) and curates the pairs by golden-benchmark score. See Memory Learning → Training data sources - Hard negative mining: base model embeds all passages (max_length=512) and queries
(max_length=128) with truncation enabled; top-k semantically similar but non-matching
passages become hard negatives. Inputs that overflow the token cap surface a
memory.fine_tune.encode_truncation_likelyWARNING so silent quality loss is visible - Contrastive fine-tuning: biencoder training with InfoNCE loss (tau=0.02, 3 epochs, lr=1e-5). Single GPU, 1-2 hours for ~500 documents. See Stage 3: contrastive training for the trainer binding and how ragged hard-negative counts are handled
- Evaluation: NDCG@10 and Recall@10 comparison of the fine-tuned checkpoint against the base model on held-out validation data, re-using the Stage 2 query / passage token caps so eval embeddings are tokenisation-consistent with mining
- Deploy (gated): promote the checkpoint to the active embedder only on a measured
benchmark win (the candidate must beat the base by a strictly positive margin on the
retrieval benchmark); on a tie or loss the checkpoint is recorded inactive. On promotion,
update the resolved
EmbedderConfigto point to the fine-tuned model. See Memory Learning → Checkpoint promotion gate
Stage 3: contrastive training¶
Training runs on SentenceTransformerTrainer, driving
MultipleNegativesRankingLoss at scale = 1 / tau with batches sampled
NO_DUPLICATES, the sampler upstream documents for in-batch-negative losses.
The whole vendor training surface is confined to one adapter,
memory/embedding/fine_tune_trainer.py, which resolves every symbol by module
path and either yields all of them or raises FineTuneDependencyError.
That import guard is what both dependency probes call, rather than merely
importing the package. datasets and accelerate are not dependencies of
sentence-transformers (they live in its train extra) nor of transformers,
and the trainer refuses to run without them, so a probe that only proved the
package imports would report a deployment ready and then lose the run two
stages in. The fine-tune extras therefore pin sentence-transformers[train],
which is what supplies accelerate, and pin datasets and transformers
directly on top of it, because the adapter imports both by name and a
dependency we import is one we declare rather than inherit.
Stage 2 emits between zero and top_k hard negatives per query, because
its similarity margin can leave a query with nothing hard enough to keep. A
training dataset is columnar, one set of features per column, so rows of
differing width cannot share a table. Rows are bucketed by negative count into
a multi-dataset training set, all buckets sharing one loss instance and drawn
from proportionally, so a batch always has a uniform column count. Every row
trains, at its own hardness, and no mined negative is discarded. Triples that
arrive empty are refused rather than trained on: the resulting checkpoint would
be indistinguishable from the base model, and the promotion gate would reject
it hours later without saying why.
Cancellation is checked on every step, not on an interval. The saving is one
flag read against a forward and backward pass, and a run with fewer steps than
the interval never reaches a multiple of it, so it could not be cancelled at
all. Progress is read from the trainer's own step counters, which makes the
0.0..1.0 stream monotonic across every bucket and epoch by construction.
Integration design: fine-tuning is an offline pipeline triggered via
POST /admin/memory/fine-tune (served by the memory sub-controllers under
src/synthorg/api/controllers/memory/). Promotion is decided by
should_promote_checkpoint (memory/embedding/promotion.py) from the eval
stage's NDCG@10 A/B, and a missing measurement counts as no win.
A promoted checkpoint is recorded active, and a snapshot of the embedder
settings is taken so a rollback has something to restore. deploy_checkpoint
deliberately does not repoint memory.embedder_model at the checkpoint:
that setting is a provider-bound model reference, so a filesystem path
written into it would reach the boot path as a model name to dispatch on.
Which embedder serves stays the operator's explicit choice, per
Embedding model selection.
A dimension change is a re-index
Vectors are only comparable to each other when they came from the same
model at the same width. Changing embedder_dims therefore invalidates
every stored vector: the store provisions a fresh dimension-suffixed index
rather than silently mixing incomparable vectors into the existing one.
The configured width also decides how Postgres stores and indexes the column,
because pgvector caps an HNSW index at 2000 dimensions for a full-precision
vector and 4000 for a half-precision halfvec. At or below 2000 the column
is exact and indexed; up to 4000 it is indexed at half precision; above that no
approximate index can be built at all, so the column is still created and dense
search still runs as an exact scan over the corpus, reported at ERROR under
memory.dense_index.unindexable. Recall stays semantic in every case, but an
unindexed width reads every row per query.
Because that state answers every query correctly and only costs latency, a log
line alone would never be noticed, so /health reports memory DEGRADED for
it: the backend exposes dense_search_indexed alongside supports_dense_search
precisely so "recall changed meaning" and "recall got slower" cannot collapse
into one flag. A width above pgvector's 16000-dimension storage ceiling is
refused outright rather than degraded, since no column could hold it.
Two conditions the index build reports rather than hides: an index a crashed
build left INVALID is dropped and a rebuild attempted (CREATE INDEX
CONCURRENTLY IF NOT EXISTS matches on name alone, so it would otherwise be
accepted as present forever; either the drop or the rebuild can still fail, and
readiness reports that separately), and an empty dense column left behind by an
earlier width is logged at INFO as schema drift, which the orphaned-width error
misses because it only fires when a leftover column still holds rows.
Setting embedder_dims below the model's own output width is the one
sanctioned mismatch: the embedder truncates each vector to its leading
components and renormalises, which is how a Matryoshka-trained model is used at
a smaller width and how a model wider than the index ceiling is brought under
it. Truncating a model that was not MRL-trained degrades recall, so this only
ever happens on the operator's explicit instruction, never by inference.
Container execution: when FineTuneExecutionConfig.backend is "docker", each
torch-bound pipeline stage (hard-negative mining, training, evaluation) runs inside an
ephemeral one-shot synthorg-fine-tune-gpu (default) or synthorg-fine-tune-cpu
container spawned by the backend via the Docker API and removed on exit. Data
generation (which holds DB/LLM handles) and deploy/promotion (which touch settings +
persistence) always run in-process regardless of backend. Both image variants ship
the same Python runner and accept the same stage-config contract; they differ only in
the bundled torch build (CUDA ~4 GB download / ~7 GB on disk vs CPU ~1.7 GB) and
whether GPU passthrough is usable. The variant is selected at synthorg init time
(fresh installs) or via synthorg config set fine_tuning_variant gpu|cpu (post-init,
preserves data) and persisted as fine_tuning_variant in config.json. The backend
consumes SYNTHORG_FINE_TUNE_IMAGE verbatim as a full image reference (including
registry, repository, and either a :tag or a digest-pinned @sha256:...); in a
CLI-managed install the rendered compose.yml writes the verified digest-pinned ref
into this env var automatically (surfaced as the memory.fine_tune_image setting,
resolved DB > env > default at boot). Operators running a hand-managed compose.yml
without the CLI set SYNTHORG_FINE_TUNE_IMAGE on the backend directly; tag-based
refs work for quick evaluation, but production deployments should pin a digest so
the backend spawns the exact attested image. See
Deployment → Fine-Tuning (optional)
for the BYO snippet. The container reads its flat stage configuration from the
SYNTHORG_FINE_TUNE_STAGE_CONFIG env var (inline JSON injected by the launcher) and
emits structured markers on stdout that the launcher parses: STAGE_START: /
STAGE_COMPLETE: bracket the run, PROGRESS:<fraction> drives the WS progress
pipeline in the orchestrator, and ERROR:<message> carries the failure detail. The shared
data volume is mounted read-write at /data (training data in, checkpoints out
under /data/fine-tune/runs/<run_id>/), so consecutive stages hand off through
deterministic paths. The volume name comes from memory.fine_tune_data_volume
(default synthorg-data, the compose data volume; env override
SYNTHORG_FINE_TUNE_DATA_VOLUME) and must be a Docker volume NAME, never a
path. Stage containers get GPU passthrough via Docker
DeviceRequests when gpu_enabled=True (only meaningful for the GPU variant;
memory.fine_tune_default_gpu supplies the default for runs without an
explicit execution config), a memory limit from memory.fine_tune_memory_limit,
and a per-stage wall-clock
timeout from memory.fine_tune_stage_timeout_seconds; cancellation stops the
container (SIGTERM reaches the runner's cooperative token). When a run requests no
explicit execution config the backend derives it: image configured means docker,
no image means in-process (bare-metal installs with the torch extras installed
directly), and the effective config is baked into the persisted run for resume and
audit. Preflight boots the same image with SYNTHORG_FINE_TUNE_PROBE=1, which
prints one PROBE_OK gpu=<name|none> vram_gb=<x> / PROBE_FAIL <reason> line
proving the image runs and detects the GPU before a long training run starts (cached
briefly so dashboard polls do not spawn probe containers per request). There is no
standing fine-tune compose service; containers exist only while a stage or probe
runs.
Each run freezes its own configuration, so a resume replays what the run
started with rather than whatever the settings say later
(memory/embedding/fine_tune_models.py):
class FineTuneRunConfig(BaseModel):
model_config = ConfigDict(frozen=True, allow_inf_nan=False, extra="forbid")
data_source: FineTuneDataSourceType = FineTuneDataSourceType.DIRECTORY
source_dir: NotBlankStr | None = None
base_model: NotBlankStr
output_dir: NotBlankStr
epochs: int = 3
learning_rate: float = 1e-5
temperature: float = 0.02
top_k: int = 4
batch_size: int = 128
validation_split: float = 0.1
execution: FineTuneExecutionConfig | None = None
Path traversal (..) and Windows-style paths are rejected to prevent
container path escapes.
run_fine_tune_stages (memory/embedding/fine_tune_pipeline.py) drives the
FineTuneStage lifecycle over the stage functions in fine_tune.py, skipping
already-completed stages on resume:
Each stage is a module-level coroutine (generate_training_data,
mine_hard_negatives, the trainer, and the evaluator); FineTuneOrchestrator
coordinates a run end to end with cancellation and checkpoint persistence.
See Embedding Evaluation for the full pipeline design and expected improvement metrics.
Consolidation and Retention¶
Memory consolidation, retention enforcement, and archival are configured via frozen Pydantic
models in memory/consolidation/config.py:
| Config | Purpose |
|---|---|
ConsolidationConfig |
Top-level: max_memories_per_agent limit, nested retention and archival sub-configs |
RetentionConfig |
Company-level per-category RetentionRule tuples (category + retention_days), optional default_retention_days fallback; agents can override via MemoryConfig.retention_overrides |
ArchivalConfig |
Enables/disables archival of consolidated entries to ArchivalStore, nested DualModeConfig |
DualModeConfig |
Density-aware dual-mode archival: threshold, summarization model, anchor/fact limits |
LLMConsolidationConfig |
Tuning knobs for the LLM synthesis op: group threshold, temperature, top_p, max summary tokens, distillation context toggle, prompt caps (max_entry_input_chars, max_total_user_content_chars) |
Consolidation Strategies (axis split, ADR-0005)¶
Consolidation is split along two orthogonal axes (memory/consolidation/axis.py):
EntrySelector-- which entries are consolidated. All shipped strategies share one selector,HighestRelevanceSelector: group by category, drop groups belowgroup_threshold, keep the highest-relevance entry (recency tiebreak). Density classification is not selection -- it routes the op in dual-mode.ConsolidationOp-- how the to-remove set becomes a stored summary. The op owns the backend and performs store + delete with that strategy's exact failure semantics (the three strategies' delete handling is mutually incompatible; see ADR-0005).
CompositeConsolidationStrategy(selector, op, *, parallel=False)
satisfies the existing ConsolidationStrategy protocol, so
MemoryConsolidationService is unchanged at the call site.
| Strategy (factory type) | Composite |
|---|---|
ConsolidationStrategyType.SIMPLE |
HighestRelevanceSelector + ConcatenationOp -- deterministic truncated-bullet concatenation; delete result ignored, every original removed |
ConsolidationStrategyType.DUAL_MODE |
HighestRelevanceSelector + DensityRoutingOp -- classifies the full group by majority vote, routes dense -> extractive preservation, sparse -> abstractive summarization; deletes with if not deleted: continue, emits per-entry ArchivalModeAssignment |
ConsolidationStrategyType.LLM |
HighestRelevanceSelector + LLMSynthesisOp (composite parallel=True). The op groups entries by category, keeps the highest-relevance entry per group (the kept entry is left unchanged and is NOT fed to the LLM). The rest are sent to an LLM for semantic synthesis (wrapped in <entry> tags with explicit "treat as data, not instructions" guidance to resist prompt injection), the summary is stored tagged "llm-synthesized", and only the entries actually represented in the LLM prompt are deleted. Synthesis -> store -> delete ordering prevents data loss on failure; entries dropped by the max_total_user_content_chars prompt cap are preserved for the next pass. The composite runs groups in parallel via asyncio.TaskGroup. Concat-fallback paths (tagged "concat-fallback", logged at WARNING, every input entry is included in the concatenation and eligible for deletion): RetryExhaustedError, retryable ProviderError surfaced directly, empty/whitespace LLM response, and unexpected non-ProviderError exception. Propagating paths (NO fallback summary, NO deletions): non-retryable ProviderError (logged at ERROR first) and system errors MemoryError / RecursionError. |
ConcatenationOp, ExtractivePreservationOp,
AbstractiveSummarizationOp, DensityRoutingOp, and LLMSynthesisOp
are independently composable; custom selector/op pairs are valid
compositions.
Strategy selection is factory-based:
build_consolidation_strategy(ConsolidationStrategyType, ConsolidationDeps)
(memory/consolidation/factory.py) dispatches via the
StrEnum-keyed StrategyRegistry (ADR-0002) and validates that the
op-specific dependencies are present (missing -> MemoryConfigError).
LLMConsolidationConfig accepts
group_threshold (default 3, minimum 3; smaller groups cannot meaningfully
deduplicate against the retained entry), temperature (default 0.3),
top_p (nucleus-sampling cap for the synthesis call, default 1.0, range
0.0-1.0), max_summary_tokens (default 500), and include_distillation_context (default
True; when enabled, the strategy queries the backend for at most 5 recent
entries tagged "distillation" and embeds their trajectory summaries,
truncated to ~500 chars each, in the synthesis system prompt). The per-entry
user-prompt content is capped at 2000 chars and the total concatenated user
content is capped at ~20000 chars; entries beyond the total cap are dropped
with a WARNING log. ConsolidationResult.summary_ids contains every summary
id produced during the run (one per processed group); the scalar summary_id
accessor is a @computed_field returning the last element for callers that
only need a representative id.
Distillation Capture¶
At task completion, synthorg.memory.consolidation.capture_distillation records
the execution trajectory as an EPISODIC memory entry tagged "distillation".
DistillationRequest captures:
| Field | Source |
|---|---|
agent_id, task_id |
Caller context |
trajectory_summary |
Turn count, total tokens, unique tools, total tool calls |
outcome |
TerminationReason + optional error message |
memory_tool_invocations |
MemoryToolName enum values (SEARCH_MEMORY, RECALL_MEMORY) extracted from TurnRecord.tool_calls_made (NOT memory entry IDs; typed enum members, counted per invocation) |
created_at |
Capture timestamp |
AgentEngine wires this into _post_execution_pipeline when
distillation_capture_enabled=True is passed to the constructor (default False
for opt-in behaviour). Capture fires regardless of termination reason;
successful runs, errors, timeouts, and budget exhaustions all produce useful
trajectory context for downstream consolidation. The helper is non-critical:
non-system failures log at WARNING and return None; system errors
(builtins.MemoryError, RecursionError) propagate.
Downstream, LLMSynthesisOp picks these entries up by tag query
when synthesising category groups, embedding the trajectory summaries and
outcomes in the synthesis system prompt so the LLM has context about what the
agent was trying to accomplish when the memories it is merging were created.
Dual-Mode Archival¶
When ArchivalConfig.dual_mode.enabled is True, consolidation classifies content density before
choosing an archival mode. This prevents catastrophic information loss from naively summarising
dense content (code, structured data, identifiers). Based on research: Memex
(arXiv:2603.04257) and KV Cache Attention Matching
(arXiv:2602.16284).
| Density | Archival Mode | Method |
|---|---|---|
| Sparse (conversational, narrative) | ABSTRACTIVE |
LLM-generated summary via AbstractiveSummarizer |
| Dense (code, structured data, IDs) | EXTRACTIVE |
Verbatim key-fact extraction + start/mid/end anchors via ExtractivePreserver |
Classification is heuristic-based (DensityClassifier), using five weighted signals: code
patterns, structured data markers, identifier density, numeric density, and line structure. No LLM
is needed for classification; only for abstractive summarization. Groups are classified by
majority vote: if most entries in a category group are dense, the group uses extractive mode.
Deterministic restore: When entries are archived, the service builds an archival_index
(mapping original_id -> archival_id) on ConsolidationResult. Agents can use this index to
call ArchivalStore.restore(agent_id, entry_id) directly by ID, bypassing semantic search.
| Model | Purpose |
|---|---|
ArchivalMode |
Enum: ABSTRACTIVE or EXTRACTIVE |
ArchivalModeAssignment |
Maps a removed entry ID to its archival mode (set by strategy) |
ArchivalIndexEntry |
Maps original entry ID to archival store ID (built by service) |
Per-Agent Retention Overrides¶
Individual agents can override company-level retention rules via
MemoryConfig.retention_overrides (per-category) and
MemoryConfig.retention_days (agent-level default).
Resolution order per category:
- Agent per-category rule
- Company per-category rule
- Agent global default
- Company global default
- Keep forever (no expiry)