Skip to content

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.


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.

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-review round dispatches once per reviewer, so one round can be two rejections;
  • each CI failure a fix session was handed, from the ci_fix.failure_logs event 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) or Bash (Claude Code) call whose result is marked as an error or says so in the tool’s own format: Mastra’s Command finished (exit code N) with a non-zero N, or Command failed:, at the start of the output, and Claude Code’s Exit code N on 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) or Edit, Write and MultiEdit (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.

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.

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.

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.

/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.


  • 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 Flagged filter 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 INJECTION scanner 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 as memory.consolidation_scan_unavailable; neither is a security event. An unreadable pattern store fails the write instead, with no trace: commitToMemory fails for a Temporal retry, while the channel turn summary, passive ingest and recordLessonDirectly log 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-signals and external-approval-gate have the same review and CI loops and write none. Nor does the sign-off loop of four-eyes, when people reject the change three times: those rejections are people’s, often without a written reason, and a REVIEW_FAILED lesson is written as the review network’s. A run whose CI wait times out writes no lesson, and only ticket-to-merge writes 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 a cond on nodes.<id>.error — which gives up the run’s typed failure exit (the run would end at a terminate node instead) and leaves only String(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, commitToMemory makes no model call and stores nothing, recording a memory.lesson_skipped trace 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-eyes sign-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, Write and MultiEdit by 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: with workflow.transcriptCaptureEnabled off, or after workflow.transcriptRetentionDays has pruned them, the lesson gets no digest. A transcript cut short — truncated at workflow.transcriptMaxBytes, incomplete after a failed part write, abandoned mid-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.evidence says 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.lessonMaxAgeDays drops a lesson from recall by its created_at alone, 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.consolidatedFrom and supersededById connect 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

Source on GitHub

Built with Astro and Starlight