Agent memory
auto-swe keeps two long-lived memories in one pgvector-backed table, memory_items, separated by
its scope column:
| Memory | scope |
Keyed by | Read by |
|---|---|---|---|
| Lessons | swe-lessons |
repository (repo_id) |
executeImplementation, into the implementer’s system prompt |
| Channel memory | channel-memory |
Slack channel (channel_id), with team_id / org_id |
the channel assistant’s turns, reactive pass, digests and org flagging |
This doc covers what both share and how lessons work end to end. Channel memory’s own behaviour — passive ingestion, cross-channel reads, privacy — is in channel-assistant.md §6.
1. Storage
Section titled “1. Storage”A MemoryItem row holds a lessonSummary (what is recalled), a rationale, an optional
failureType, a confidence, a 1536-dimensional embedding of the summary with the embeddingModel that produced
it, and provenance: workflowId, workflowRunId, agentKey, model, costUsd, skillsActive
and a free-form metadata object. consolidatedAt marks a row that consolidation has merged into
a newer one, and supersededAt / supersededById one a later item replaced (§2); both kinds are
kept but never recalled or consolidated again.
The embedding column carries an HNSW cosine index (idx_memory_items_embedding). It is created by
hand-written DDL that Prisma cannot express — see the prisma-pgvector-hnsw skill before touching
schema.prisma. Embeddings come from the EmbeddingConfig singleton
(model-configuration.md); a row is only ever compared with rows embedded
by the same model, so vectors from different models are never compared.
2. Writing lessons
Section titled “2. Writing lessons”| Writer | When | Model call |
|---|---|---|
commitToMemory step |
When a review loop or a CI loop runs out of attempts (REVIEW_FAILED, CI_FAILED), before the run fails, in the engineering templates that have one: ticket-to-merge, agent-reviewed-pr, code-and-ci, consensus-review, dependency-update and four-eyes. In ticket-to-merge also after the pull request is merged (MERGED), and when nobody merges it before the merge wait times out (MERGE_TIMED_OUT, only if the review or CI rejected an attempt on the way) |
Yes — the commitToMemory agent writes the lesson from the run’s evidence |
| Merge-conflict resolver | After it resolves a conflict | No (recordLessonBackground) |
| Shell step | After a step that changed files and pushed them | No; the command is stored with credentials masked |
The step hands the agent the run’s evidence, read from the workflow’s own context and bounded
there (lib/lessonEvidence.ts): the outcome, the review network’s last rejection (in
consensus-review, the last round’s rejecting reviewers, joined), the tail of the last CI logs the
fix loop fetched, and the changed files with their line counts, the implementer’s notes and the
test totals — never the diff itself. Nothing clears one loop’s context when the other runs, so the
evidence follows the outcome: a CI_FAILED lesson gets the CI logs and no rejection, a
REVIEW_FAILED lesson the rejection and no CI logs, and a MERGED or COMPLETED lesson both, as
what earlier attempts hit before the run got past them.
The user message, built in code rather than in the
agent’s stored prompt, tells the agent to name a root cause only when the evidence shows one and
fences the evidence as data. The outcome is written into the lesson’s metadata.outcome by code,
not by the model. Another template’s commitToMemory step records COMPLETED.
That context holds only each loop’s latest rejection and CI logs, and a loop revisit overwrites its
nodes’ WorkflowStep rows, so the activity rebuilds a history of the run’s failures from its
AgentTrace rows (lib/lessonAttemptHistory.ts), every query scoped to the run, whatever the
outcome — a merged run’s lesson gets it too:
- each review network dispatch that rejected, worded as its fix was given it, from the reviewers’
verdict rows; a reviewer that crashed counts as a critical rejection, as it does in the review. A
consensus-reviewround dispatches once per reviewer, so one round can be two rejections; - each CI failure a fix session was handed, from the
ci_fix.failure_logsevent the CI fixer records with the last 4,000 characters of the logs. A failure that repeats the one before it, as after a fix that changed nothing, is listed once.
Both kinds of row carry Temporal’s activity id, which is the same on every retry of one scheduled
activity, so a retried review or fix counts once, by its last attempt. A row written without the id
is grouped by the batch it was written in instead and is never merged with another, so a retry of
it can count twice. The latest five entries of each kind are kept and share 4,000 characters, at
most 1,500 each: a rejection keeps its head, a log its tail. The history reaches the agent, inside
the same fence, only when some kind has more than one entry, and the agent is told to say so when
they failed in different ways. Every < in the fenced JSON is escaped, so quoted text cannot close
the fence. A history that cannot be read is traced as memory.attempt_history_unavailable and the
lesson is written from the latest evidence alone.
Alongside the history, the activity reads what the implementer actually did from the run’s
agent transcripts (lib/lessonTranscriptEvidence.ts), with no model call:
the newest three closed transcripts of the run’s implementer sessions (executeImplementation, the
CI, review and gate fixers, and the merge-conflict resolver). A retried activity counts once: of the
transcripts that share an activity type, recording id and step attempt, only the highest Temporal
attempt is read (a transcript with no recording id cannot be matched to its retries and is kept).
Each part’s sha256 is checked before it is decompressed, and a transcript with a part that fails is
skipped whole. Records are folded into the extraction one at a time and dropped, so no whole
transcript is held in memory; reading stops at the part that would take the total past 8 MiB
uncompressed. The whole digest — the setting read included — has 20 seconds, and at the limit the
work stops between records, parts and scans rather than running on unobserved. Code extracts four
kinds of entry:
- failed tools — a result of any tool other than the shell marked as an error, or a Mastra
workspace tool’s result that starts with the text it returns on failure (
Error reading file:,Error writing file:,Error listing directory:,Write blocked:,SECURITY CHECK FAILED): the tool, the head of its input (the file it named, if any) and the head of the error; - failed commands — a
bash(Mastra) orBash(Claude Code) call whose result is marked as an error or says so in the tool’s own format: Mastra’sCommand finished (exit code N)with a non-zero N, orCommand failed:, at the start of the output, and Claude Code’sExit code Non its first line. An exit code mentioned anywhere else in the output does not count. The entry is the head of the command and the tail of its output; a failing shell call is listed here only, never also as a failed tool; - churned files — a file written three or more times, by
writeFile(Mastra) orEdit,WriteandMultiEdit(Claude Code), with the count; - final notes — the head of each session’s last assistant text.
A model call’s request repeats the conversation so far, so a tool result is paired with its call by call id and each call counts once however often it is replayed. A provider whose calls carry no unique id — a Google call is keyed by its tool’s name — is paired by position within the record instead (the k-th call of a tool with the k-th result of that tool), and its replays are told apart by the tool’s name and input plus the result’s text, so two writes of one file with different content count as two.
Identical entries are listed once, the latest five of each kind are kept, and the kinds share 4,000
characters in equal parts. Every entry then goes through the memory gate’s INJECTION scan (§5):
one that matches is dropped, counted, and recorded as a memory.transcript_digest_dropped security
event naming the patterns and the transcripts, never the text; a scan that cannot complete leaves
the lesson without a digest. The digest reaches the agent as transcriptDigest inside the same
fence, labelled as a heuristic extract that is data, not instructions, and only when it holds an
entry. Only transcripts that contributed an entry are cited. A built digest is traced as
memory.transcript_digest (the count of each kind, the drops and the transcript ids); one that
cannot be built — a read error, the time limit, or the scan — as
memory.transcript_digest_unavailable with its reason, and the lesson is written without it.
memory.lessonTranscriptEvidence (setting registry, /admin/platform-settings, overridable per team
and organization, on by default) turns it off; off, no transcript is read.
Each lesson records where it came from. The row’s workflowRunId names the run (whose traces hold
the rest), agentKey and model the writer, and metadata.evidence cites the pull requests and
head commit it was written about plus a 300-character quote of the evidence that drove it — the
start of the rejection, or the end of the failing CI log, whichever the outcome failed on — and,
when the lesson saw a history, how many rejections and CI failures it was written from
(history), and, when it saw a transcript digest, the transcript ids and how many failed tools,
failed commands and churned files it held (transcripts). The model-free writers record their run
and an agentKey of mergeConflictResolver or shellStep.
The agent also grades its confidence by how directly the evidence supports the lesson — high
when a rejection or CI failure states the problem, medium when it points to it, low when the
lesson rests on the ticket or the outcome alone — stored as 0.9, 0.6 or 0.3. A consolidated lesson
takes the average of its graded sources; the model-free writers leave it null. A lesson below 0.5 is
still recalled, labelled low confidence in the implementer’s prompt.
Newer replaces older. After a write, every active item in the same repository (or channel) and
scope, embedded by the same model, at or above memory.supersedeThreshold similarity (default 0.92;
1 turns it off) is marked superseded by the new one. A lesson written from fresher evidence about
the same thing therefore replaces the old one in recall instead of sitting beside it; the old row
stays, with supersededById naming its replacement.
Every write goes through insertMemoryItem (packages/worker/src/lib/memoryStore.ts), which
applies the memory gate (§5) before it embeds anything.
3. Recalling lessons
Section titled “3. Recalling lessons”Every agent that works on a repository recalls that repository’s lessons into its system prompt,
each by the text that says what it is working on (recallLessonsBlock, lib/lessonRecall.ts):
| Agent | Recalled by |
|---|---|
Implementer (executeImplementation) |
The work request’s description |
| CI fixer | The end of the failing CI log |
| Review fixer | The reviewers’ rejection |
| Gate fixer | The gate’s name and the end of its output |
| Review network (all three personas) | The implementer’s notes and the changed files’ paths |
Each takes unsuperseded, unconsolidated rows embedded by the current model, at or above
lessonRetrievalThreshold similarity, at most lessonRetrievalLimit of them (both on
/admin/workflow-defaults, default 0.7 and 5). Matches are added inside a <recalled_memory>
fence that marks them as reference data, each with its id, its failure type and a
low confidence label where it applies. Recall failing is logged and the agent runs without
lessons.
Lessons can age out. memory.lessonMaxAgeDays (setting registry, /admin/platform-settings,
overridable per team and organization) is the age in days past which a lesson is no longer recalled.
It is 0 by default, which keeps every lesson whatever its age. It is resolved at the repository’s
owning team and its organization, since lessons belong to the repository, and applied as a filter
on created_at inside the similarity query, so lessonRetrievalLimit still counts only lessons
that may be recalled. searchLessons applies it too; explainLesson explains any lesson of the
repository whatever its age. An aged-out lesson is not deleted: it stays in the table and on
/lessons, can still be superseded, and comes back into recall if the setting is raised or
cleared. The owning team is cached for the ~30 s config-cache window; if the setting cannot be
read, the failure is logged and aging is treated as off rather than failing recall or
consolidation.
A run’s page lists, under Lessons, every lesson its agents recalled — once each, naming the
agents that were given it, and saying when one has since been merged, replaced or deleted — and the
lessons the run wrote (GET /api/v1/workflow-runs/:id/lessons). Whoever can see the run sees them:
a recalled lesson’s text is already in the run’s traces, as part of an agent’s prompt. The recalled
ids are read from the run’s own lessons.retrieved trace events, so a recall that was not traced is
not listed.
The implementer and the fixers also get two read-only tools, searchLessons and explainLesson,
bound to the session’s repository: one asks for lessons by the agent’s own query, the other shows
where a recalled lesson came from — its run, outcome and evidence, the lessons it was merged from,
and the ones it replaced (agents.md §3.2.1).
Every scoped search — lessons by repository, channel memory by channel, team or organization — runs
with pgvector’s iterative index scan (SET LOCAL hnsw.iterative_scan = strict_order, in
scopedVectorQuery). Without it, a plan that reads the HNSW index sees only the table-wide nearest
hnsw.ef_search candidates and filters them by scope afterwards, so a small repository beside a
crowded one would get nothing back. The setting needs pgvector 0.8 or later.
4. Consolidation
Section titled “4. Consolidation”A Temporal Schedule (consolidationCron, default weekly) starts one ConsolidateLessonsWorkflow
per repository with Connection.consolidationEnabled. A platform ADMIN can also run it for every
repository, or one, from /lessons, and a lead of a repository’s owning team for that repository. It clusters the repository’s active lessons that have not aged out (§3) by embedding similarity
(consolidationSimilarityThreshold, default 0.85), and for each cluster of at least
consolidationMinClusterSize (default 3) asks the lessonConsolidator agent for one or two
generalised lessons. In one transaction, under a per-repository advisory lock, it inserts those and
marks the sources consolidated. A consolidated row keeps the provenance a written one has: its
scope and entity columns, the consolidating agent and model, its share of the call’s cost, and
metadata.consolidatedFrom, the source ids — whose rows stay in the table, marked consolidated. Channel memory is
consolidated the same way on each ambient fire.
5. The memory gate
Section titled “5. The memory gate”Memory is replayed into every later run that recalls it, so it is held to a blocking rule
(packages/worker/src/lib/memoryGuard.ts): a write whose text matches an INJECTION scanner
pattern is refused, and so is a write whose scan did not complete (an INJECTION pattern overran
its budget or is quarantined); a pattern set that could not be loaded fails the write (only commitToMemory is retried; the other
writers log and drop the note). A recalled item that matches is dropped; a partial scan alone does not
drop one, while a pattern set that could not be loaded, or a scan that throws, recalls nothing. A write
refused for a match and a drop are security events on /admin/security — MEMORY_WRITE_REFUSED or
MEMORY_RECALL_DROPPED — naming the patterns and, for a drop, the item ids, never the text. A
transcript digest entry the lesson writer would have seen (§2) is screened the same way, and a
drop is listed under MEMORY_RECALL_DROPPED too, as memory.transcript_digest_dropped. See
agents.md §6.4.
6. Administration
Section titled “6. Administration”/lessons lists and searches lessons for every role. An engineer or lead sees the lessons of
the repositories they can reach — the owning team’s or a team it is shared with, under the access
gate — each with its failure type, the run’s outcome, a Low confidence label below 0.5, and a link
to the run it was written from. Managing a repository’s lessons — deleting one, starting
consolidation — takes what editing the repository takes: a platform ADMIN, or a LEAD or ADMIN of its
owning (and active) team. A shared team’s members read the lessons and do not manage them, and a
lesson whose repository is gone is a platform ADMIN’s alone. The list names the repositories the
caller manages (meta.managedRepoIds) so the page offers the controls only there. Platform ADMINs
also see every repository’s lessons, the per-repository counts, and consolidate all at once.
Anyone who reads a lesson can flag it as wrong. POST /api/v1/lessons/:id/flags records one
LessonFlag per person per lesson, with a reason of up to 500 characters (flagging again replaces
it); DELETE …/flags/mine withdraws it. Both are audited on the lesson. A lesson’s managers see the
reasons and who gave them, and either delete the lesson or clear its flags (DELETE …/flags, which
keeps the reasons in the audit row); everyone else sees the count and their own flag. The list takes
?flagged=true, and the page offers it as a filter. Deleting a lesson deletes its flags.
Channel memory is edited and deleted per channel (channel-assistant.md §6).
Deleting forgets, through every copy. Both delete routes run forgetMemoryItems
(@auto-swe/shared/lib/memoryForget) in one transaction with the audit row:
- Every row consolidated from the deleted item is deleted too, recursively, because it carries the deleted content.
- A deleted item that is itself a merged row takes its sources with it, recursively — they hold the same content, and bringing them back would undo the delete.
- The other sources of a merged row retracted on the way up become active again, unless another surviving merged row still stands for them. Consolidation may merge them again later, without the deleted item.
- Rows the deleted items had superseded become active again.
The response and the audit row list every id deleted and restored.
Erasing one person’s channel memory. POST /api/v1/platform/slack-channels/memory/erase-user
(ADMIN, body { "slackUserId": "U…" }) forgets, the same way, every channel-memory item written
from that Slack user’s turns (metadata.userSlackId) in every channel. Its audit row records the
Slack user id and how many items went and came back — never their text, which would keep what was
erased. There is no dashboard control for it; it is API-only.
After the embedding model changes, the Embeddings tab at /models shows how many memory rows
the configured model did not embed (GET /api/v1/platform/embedding-config/reembed) and starts
ReembedStaleMemoryWorkflow (POST to the same path, ADMIN, audited). The workflow walks those
rows in id order, 100 per activity and four embedding calls at a time, and continues as new every
50 batches. It runs under one fixed workflow id, so a second start while one runs is refused with
409 REEMBED_IN_PROGRESS.
Limitations
Section titled “Limitations”- A flag does not change recall. A flagged lesson is still recalled, unlabelled, until a lead or
an administrator deletes it; nothing reminds them that a flag is waiting beyond the
Flaggedfilter on/lessons. A lead’s delete is final, like an admin’s. - A flagger is named only to the people who act on it. Readers see how many flags a lesson has and their own; the reasons and who wrote them are shown to its managers and kept in the audit row when they clear the flags. At most the 20 newest reasons are listed per lesson.
- An incomplete scan refuses writes but not recall. While an
INJECTIONscanner pattern is quarantined (up to ten minutes in that process), every lesson, channel summary and passively ingested fact written in that process is refused, and consolidation leaves its clusters as they were. A refused write is traced as an outage (memory.write_scan_unavailable) and a consolidation asmemory.consolidation_scan_unavailable; neither is a security event. An unreadable pattern store fails the write instead, with no trace:commitToMemoryfails for a Temporal retry, while the channel turn summary, passive ingest andrecordLessonDirectlylog and drop the note, and consolidation leaves its cluster. Recall in that case returns nothing. Items already stored are still recalled, checked against the patterns that did run, so a stored item that only a skipped pattern would catch can reach a prompt in that window. - Not every loop that fails writes a lesson. The release templates
staged-deploy-signalsandexternal-approval-gatehave the same review and CI loops and write none. Nor does the sign-off loop offour-eyes, when people reject the change three times: those rejections are people’s, often without a written reason, and aREVIEW_FAILEDlesson is written as the review network’s. A run whose CI wait times out writes no lesson, and onlyticket-to-mergewrites one when the merge wait times out. - A step that throws writes no lesson. The security gate (
SECURITY_GATE_FAILURE, on the implementation and on every fix path), any other implementation error, and every other failing step end the run where they stand. Such a failure is routable to a lesson only by continuing past it —onFail: 'warn', then acondonnodes.<id>.error— which gives up the run’s typed failure exit (the run would end at aterminatenode instead) and leaves onlyString(err), the SDK wrapper’s “Activity task failed”, as the evidence the lesson would need. - A merge timeout with nothing rejected writes no lesson. When the change passed review and CI
at the first attempt and then timed out waiting for a merge,
commitToMemorymakes no model call and stores nothing, recording amemory.lesson_skippedtrace event: nothing in the evidence says why it was not merged. - The failure history is only as complete as the traces. Trace writes are best-effort, so an
attempt whose rows were not written is missing from it, and it holds only the latest five
entries of each kind; at most 60 trace rows of each kind are read, and a review dispatch the
bound cut short is left out. A
four-eyessign-off rejection is a person’s answer, not a trace, and is not in it. - The transcript digest is a heuristic extract. It recognises the Mastra workspace tools and
Claude Code’s
Bash,Edit,WriteandMultiEditby name, and a failed command by its exit code or error flag; a tool it does not know is reported only when its result is marked as an error, and a file written through the shell is not counted as churn. A provider whose calls carry no unique id is paired by position, and two identical calls with identical results — the same command run twice with the same output — count once. It reads only the newest three implementer transcripts, so an earlier session of a long run is not in it, and only transcripts that exist: withworkflow.transcriptCaptureEnabledoff, or afterworkflow.transcriptRetentionDayshas pruned them, the lesson gets no digest. A transcript cut short —truncatedatworkflow.transcriptMaxBytes,incompleteafter a failed part write,abandonedmid-attempt, or stopped by the 8 MiB read bound — yields a partial digest, and the read bound keeps a session’s earliest parts, not its last. - The CI run that exhausts the loop is never read. The loop fetches logs only for a failure it is about to fix, so the latest CI failure a lesson sees is the one before the run that ended the loop.
- A citation is a pointer, not a proof.
metadata.evidencesays what a lesson was written from; nothing checks that the lesson’s text follows from it, and a consolidated row cites only its sources, not their evidence. - Supersession is similarity, not contradiction. A newer lesson replaces an older one only when their summaries embed close together. Two lessons that contradict each other in different words both stay active, and a near-duplicate that adds a detail still replaces the original. Nothing un-supersedes a lesson.
- Aging is by date, not by relevance.
memory.lessonMaxAgeDaysdrops a lesson from recall by itscreated_atalone, so advice that is still true ages out with advice that is not, and nothing expires while the setting is 0. A consolidated lesson’s date is the day it was merged, so merging restarts the clock for what its sources said; sources that had already aged out are not merged. The setting has no workflow-template scope. - Confidence is the writer’s own grade. It is the model’s reading of a fixed rubric, not a measurement, and it labels a recalled lesson rather than filtering it.
- Forgetting follows provenance links, not content. A delete reaches the rows
metadata.consolidatedFromandsupersededByIdconnect to it. A separate lesson that happens to restate the deleted one — written by another run, never merged with it — stays. - Erasure reaches only what is attributed. A turn’s memory carries the asking user’s
userSlackId, but passive ingestion distils facts from many people’s messages and records no author, so those facts — and anything a summary of another person’s turn repeated — are not found by a per-user erasure. They can be deleted one by one. - A repository’s lessons outlive its deactivation. No route deletes a repository, so its lessons stay; one whose row is removed by hand keeps them, listed as “Deleted repository”.
- Changing the embedding model hides existing memory until an admin re-embeds it. The re-embed is not started on save, because it is one billed call per row. A row whose re-embed fails stays hidden and is counted in the workflow’s result; the next run retries it.
- Scoped search depends on pgvector 0.8. On an older pgvector the iterative scan is
unavailable: the worker logs a warning once and searches without it, and a small repository or
channel in a large table can get fewer matches than exist, or none. Even with it, the scan stops
after
hnsw.max_scan_tuples(pgvector’s default, 20,000) index tuples, so a scope whose nearest memories lie beyond that many closer rows from other scopes is still cut short. - The memory tools exist only under the Mastra loop. A session on the Claude Code harness gets
recalled lessons in its prompt but no
searchLessons/explainLesson; the harness has its own tool set. Eval cases and the merge-conflict resolver get neither tools nor recall, so both sides of an eval comparison see the same context. - A failed channel summary stores the raw exchange. When the summariser fails for a reason other than the memory gate or a refusal to spend (a malformed answer, a provider error), the turn’s text and reply are stored undistilled, truncated to the summary and rationale lengths.
- Channel-memory embeddings are outside the channel’s budget. Every embedding a channel makes — a summary, a raw-exchange fallback, a passive fact, a consolidated row, an org-flagging query — is priced onto the run that made it and its trace, never onto the channel’s monthly ledger or a turn’s hold, so the cap does not bind it.
© 2026 Jorge Barnaby
Source-available under FSL-1.1-MIT