Adaptive memory: learning what works and what fails
Status: Proposed — nothing below is implemented unless marked exists today
Priority: P1
Related: Adaptive Skills (the learned overlay on an
authored skill), Skill Synthesis, the verification
footer (#3376, #3677, #3989), the self-healing error store (_forget_errors_for_operation)
1. The problem
The agent repeats its mistakes across sessions, and it does not learn which approaches worked. Today a session ends and the only things that survive are facts, preferences and the raw text of tool errors. A run that took 60 steps because the agent explored the wrong directory first, triedpytest from the wrong cwd, and finally succeeded with
a different approach leaves behind… a fact or two. The next session starts the
same way. The benchmark campaigns saw exactly this: on identical tasks the same
model re-derives the same dead ends run after run, and the spread between runs
(37–61 steps on one TheRock task, ~30% on steps generally) is mostly rediscovery,
not model randomness.
Two halves are missing:
- Nothing records outcomes. The memory extractor
(
_EXTRACTION_PROMPT,memory.py:234) is asked for knowledge “useful in future conversations” and is explicitly told to skip “what the assistant did or changed”. That is the right rule for facts and the wrong rule for lessons — the approach is what the next session needs. - Nothing attributes. When a task passes or fails, no record says which stored
memory, skill delta or approach was in play, so there is no signal to raise or
lower confidence on.
use_countand the +0.02 confidence bump on recall (memory_store.py:1264) count retrieval, not effect.
error rows — is
one-directional and coarse: a failed call becomes “Known errors to avoid” in the
system prompt (memory.py:2395), and a later success on the same operation deletes
it (_forget_errors_for_operation, memory.py:2841). There is no “this worked”,
no “this worked instead”, and no memory of why the first attempt failed.
2. What exists today
Everything in this table is onmain and is what the design builds on. Nothing
here is new work.
3. Design
Four stages, each a small addition to a module that exists.3.1 Outcome record — one per task, derived, not asked for
At the end of every turn the loop already holds_turn_tool_executions (the
verification records) and _turn_tool_record (the grounding record). An
outcome record is those two folded into one row, written by the loop, never by
the model:
passed is computed the way the footer computes it — a repeated check counts by
its latest run (#3989), a check that ran before the last edit is stale, a refused
check did not run (#3677). That is the whole point of deriving it from
verification.py: the outcome record cannot be more optimistic than the footer.
Where it lives: a new outcomes table in memory_store.py next to knowledge.
It is not a knowledge row — it is evidence, and evidence is never recalled into
the prompt directly.
3.2 Attribution — what was in play
Attribution answers “which memory shaped this run?”. It is cheap because recall already tells us: every knowledge row the dynamic context injected (_build_dynamic_memory_context, memory.py:2491), every row a recall() call
returned, every active skill delta, and every stored error that matched an
operation key the run attempted. Those ids go into outcome.in_play.
No model call, no guessing. If a lesson was in the prompt and the task passed, the
lesson gets a confirmation; if it was in the prompt and the task failed on the
operation the lesson is about, it gets a contradiction. A lesson about pytest is
not blamed for a failed web fetch — attribution is scoped by _operation_key, the
same scoping _forget_errors_for_operation already uses.
3.3 Lessons — scoped, weighted, with evidence
A lesson is a new knowledge category,lesson, in EXTRACTABLE_CATEGORIES
(the model may write one) but with a write path that refuses a row without
evidence:
context and are recalled only when the agent is in that project.
Global lessons are those confirmed in two or more distinct projects — promotion
is automatic on the second confirmation from a different project_root, never on
the model’s say-so. Anything else (work, personal) stays what it is today: a
user-chosen context for facts, not a lesson scope.
Confidence moves only on attributed outcomes. A lesson nobody used decays at
reconcile_memory time — 0.02 per pass, the mirror of the recall bump — so an
unused lesson fades and a contradicted one falls out fast. Below 0.2 the row is
superseded by nothing (status-style: still in the table, out of every active
query), never deleted.
Where lessons come from, in order of trust:
- The loop, mechanically. A failed operation followed in the same turn by a
passing alternative on the same target is the cleanest signal there is:
error(op A) → ok(op B, same target) → passedwrites “prefer B over A for<target>” with the outcome as evidence. No model call. - The extractor, given one new instruction: when the outcome record says
passedand the approach contains a failed-then-succeeded pair, propose alessonop. The op is refused if itsevidencedoes not name this turn’s outcome id. - The model’s own
remember(category="lesson")— allowed, but stored at confidence 0.3 with evidence[]marked claimed, and never injected into the prompt until one attributed confirmation gives it evidence. A claim is a hypothesis, not a lesson.
3.4 Application — plan time and decision time
Plan time. Before the first tool call of a turn, recall lessons for the active project scope plus global, ranked by confidence × recency, capped at five, and inject them as aLessons: section next to Known errors to avoid: in
_build_dynamic_memory_context. Five lines, ~300 tokens; the NPU window is 32K and
the section is bounded the way the errors section is.
Decision time. Before a risky action — a mutating tool (is_mutating_tool,
verification.py:566), or any operation whose _operation_key has a stored
error or a low-confidence lesson — the loop checks the lesson store for that key.
A hit is surfaced as a one-line tool-result preamble (“A previous run of pytest tests/ here failed with …; the lesson on file says …”) rather than a block:
the model keeps the decision, the evidence is in front of it. This is the same
seam _forget_errors_for_operation and the confirmation gate already sit on, so
it is one hook, not a new loop.
4. The agent maintains its own memory
Lessons are only useful if they stay true, and the agent is the one who finds out they are not. It needs three operations, all of which exist in some form:
That last row is the one behavior change to existing code:
forget() and the
delete op become a supersede for lesson rows (facts keep today’s semantics — a
wrong phone number should be gone). The skill_deltas table already commits to
“the row is KEPT, never deleted” (memory_store.py:382); this extends the same
rule to lessons.
Edit history falls out of superseded_by: walking the chain backwards from
the active row gives every prior version with its created_at and evidence. No new
table — one query.
The user has to be able to see and change this. Today the surfaces are uneven:
- The Agent UI has full CRUD (
GET/POST/PUT/DELETE /api/memory/knowledge,src/gaia/ui/routers/memory.py:327–476) and a review flow. - The TUI’s
/memoryis view-only — a snapshot (tui/internal/ui/chat/memoryview.go). - The CLI’s
gaia memoryhas onlystatusandbootstrap(src/gaia/cli.py:2933).
gaia memory list [--category lesson] [--context ...],
gaia memory edit <id>, gaia memory retire <id> and gaia memory history <id>,
all thin wrappers on the store methods the UI router already calls, and gives the
TUI /memory an edit/retire action on the selected row through the same daemon
route. A lesson the user retires gets superseded_by = 'retired:user' and is never
re-learned from the same evidence: the write path checks the retired chain’s
evidence ids before inserting.
5. Safety
- Evidence, never a claim. A
lessonrow must carry at least one outcome id or it is stored as claimed at 0.3 and kept out of the prompt. The extractor’s existing “Knowledge that only the assistant’s answer asserts is not grounded” rule applies verbatim; the outcome record is the grounding. - Cross-project leakage guard. A project-scoped lesson is filtered by
contextat recall, the same filter facts use today. Promotion to global requires confirmations from two distinctproject_roots, and a global lesson whose next contradiction comes from a third project is demoted back to the project that last confirmed it. The digest, not the path, is stored: paths are user data. - No self-granted autonomy.
lessonis extractable, not privileged. It can never say “skip confirmation” — the confirmation gate readsCONFIRMATION_REQUIRED_TOOLS, not memory, and thepermissioncategory stays privileged exactly asmemory_store.py:117describes. - Caps. At most 5 lessons in the prompt, at most 200 active lessons per
project scope and 200 global; the lowest-confidence rows beyond the cap are
superseded at reconcile time. Outcome rows are pruned with the session they
belong to (
prune()already holds turns for the retention window). - Credential refusal stays in front of every write (
_looks_like_credential), and lessons go through the same secret scan as transcripts before they reach an eval artifact. - Incognito (
_incognito) skips outcome recording as it skipsremember().
6. Proving it: a benchmark series
The claim is behavioral, so the proof is a run, judged and metered by the same tools as every other benchmark (gaia eval tasks, the Opus judge, Fireworks
metering — see the harness in #4335). Three scenarios,
each a pair of sessions on one task with memory carried between them:
- Seed a mistake → avoid it. Session A runs a task whose fixture makes the
obvious approach fail (
pytestfromtests/finds noconftest; the repo root works). Session B runs a different task in the same project that needs the same check. Pass: session B never issues the failing form, and the transcript shows the lesson in the prompt. Metric: steps and tokens in B versus a memory-off B. - Learn what worked. Session A solves a task by a non-obvious route (a helper
script in
scripts/). Session B, a related task, uses the route on the first attempt. Metric: first-tool-call-to-success distance. - Unlearn when the environment changes. After scenario 1, the fixture is flipped so the once-failing form now works and the once-working form fails. Session C must contradict the lesson (confidence falls), succeed anyway, and session D must not carry the stale lesson. Pass: D’s prompt shows the superseded lesson gone and the new one present, with both outcome ids in the history.
--repeats 3, because one run cannot resolve a 20–30%
step effect. The judge sees the same tool record it sees today; the new fields are
in the transcript, so gaia.eval.bench.transcripts.tool_calls() needs no change.
A planted control — a lesson stored with fabricated evidence — must be refused
at write time, the same way gaia eval tasks controls proves the judge separates
honest from fabricated work.
The series ships as an adaptive_memory suite in eval/tasks/tasks.json and joins
the nightly benchmarks once the everyday suite is
green there.
7. Phased rollout
Phase 1 — record, don’t act. Outcome rows written by the loop; attribution filled from what was recalled;gaia memory status reports outcome counts and
pass rate. No lesson is created and nothing enters the prompt.
Must not break: the verification footer’s text, the extractor’s existing ops, any
memory eval category (gaia eval agent --category memory and --category gaia_memory, whose baselines stay
byte-identical because the prompt is unchanged).
Phase 2 — mechanical lessons, project-scoped. Source 1 only (failed-then-
succeeded pairs). Lessons injected at plan time, capped at five. forget() becomes
supersede for lessons. CLI list/edit/retire/history. Scenario 1 and 3 of the
benchmark series must pass.
Must not break: prompt size on the NPU profile (assert the section is ≤ 300
tokens in the unit test); the confirmation gate.
Phase 3 — extractor lessons and decision-time checks. Source 2, the
decision-time preamble on risky operations, global promotion on two-project
confirmation. Scenario 2 must pass and scenario 1’s step saving must clear the
run spread.
Must not break: the rag_quality and tool_selection eval baselines — a prompt
change means a full gaia eval agent comparison per CLAUDE.md.
Phase 4 — model-claimed lessons and the TUI editor. Source 3, TUI /memory
edit/retire, consolidation distilling lessons from old sessions.
8. Open questions
- Which project digest?
project_rootrealpath is the obvious key, but a worktree of the same repo is a different path and the same project. A gitremote.origin.urldigest with the path as fallback is the current proposal. - Is a step saving the right primary metric? Steps vary ~30% run to run on identical config. Pass rate and first-attempt success are more stable; steps may only be reportable as a band.
- Confidence constants. +0.15 / −0.25 / −0.02 are starting points; the benchmark series should tune them, not a review thread.
- Consolidation of lessons. Ten project lessons about
pytestinvocation want to become one. Thereconcile_memoryreinforce path can merge them, but the merged row’s evidence list grows without bound — a cap or a summary count is needed. - Sharing. A global lesson looks like the seed of a skill delta. Whether lessons can graduate into Adaptive Skills deltas — and through what consent gate — is deliberately out of scope here.
- The email agent. It lists
Agentahead ofMemoryMixin, which shadows the per-turn tool record (memory.py:1697). Outcome recording would fail safe there (no evidence, no lessons) but that is not coverage; the base order needs fixing before it can learn.