Skip to content

Memory loop

How fastaiagent.learn turns past traces into facts that future agents pick up automatically. This is the offline way facts are learned; for how it relates to Memory(learn=) and plane facts, see How memory works.

End-to-end flow

   Agent runs
        │
        ▼
   spans table in local.db        ← every run is already traced
        │
        ▼
   fastaiagent learn               ← offline CLI, runs on demand
        │ uses an LLM to extract durable facts
        ▼
   learned_memory table            ← schema migration v8
        │
        ▼
   PersistentFactBlock             ← read-only at runtime
        │ injects facts as a SystemMessage
        ▼
   Next agent run                  ← prompt now carries learned context

The pieces

1. MemoryStore — persistence

fastaiagent.learn.MemoryStore is a thin wrapper around the new learned_memory table. It exposes:

store.add(Fact(scope="agent", scope_id="my-agent", fact="…"))
store.list_active(scope="agent", scope_id="my-agent")
store.supersede(old_id=12, new_id=34)

Inserts are idempotent: re-adding the same (scope, scope_id, fact, project_id) returns the existing row id. To replace a fact rather than duplicate it, call supersede — the old row is preserved with superseded_by pointing at the new row, so the audit chain stays intact.

Runtime alternative to this offline loop

You don't have to run the offline fastaiagent learn job to populate learned_memory. FactExtractionBlock(persist=True) writes facts to the same table during a run — stamped with the run's trace id — and MemoryStore.add(...) writes them directly from your own code. All three producers feed the same store and the same Memory page.

2. extract_facts_from_trace — the LLM call

from fastaiagent.learn import extract_facts_from_trace
from fastaiagent.trace.storage import TraceStore

trace = TraceStore().get_trace("…trace_id…")
facts = extract_facts_from_trace(
    trace,
    llm=fa.LLMClient(provider="openai", model="gpt-4o-mini"),
    scope="agent",
    scope_id="my-agent",
)

Returns a list of candidate Fact objects (not yet stored). The extractor is best-effort — bad LLM output yields [] rather than raising. PII guidance is baked into the prompt; the CLI also gates user / project scopes behind --allow-personal.

3. run_extraction — the windowed batch

For the common "process my last N hours of traces" path:

from fastaiagent.learn import MemoryStore, run_extraction
import fastaiagent as fa

results = run_extraction(
    llm=fa.LLMClient(provider="openai", model="gpt-4o-mini"),
    store=MemoryStore(),
    scope="agent",
    scope_id="my-agent",
    agent_name="my-agent",   # only traces in which my-agent ran
    last_hours=24,
)
new = sum(len(r.new_ids) for r in results)   # facts inserted by this run

This is what the fastaiagent learn CLI calls under the hood. It reads traces newest first, only within the window, skips its own learn.extract calls and anything already mined for this scope and id (reprocess=True to mine them again), and stops at max_traces (default 100). written_ids lists every stored candidate; new_ids only the rows this run inserted.

4. PersistentFactBlock — the re-injection

from fastaiagent.agent.memory_blocks import PersistentFactBlock

memory = fa.ComposableMemory(
    primary=fa.AgentMemory(),
    blocks=[
        PersistentFactBlock(scope="agent", scope_id="my-agent", max_facts=30),
    ],
)
agent = fa.Agent(name="my-agent", system_prompt="…", llm=llm, memory=memory)

Every agent.arun(...) now sees a Learned facts (agent:my-agent): system message right after the system prompt, with the active facts (newest first, capped at max_facts). The block is read-only at runtime; the facts it reads come from any of the producers above.

Conflict resolution

Same fact text → idempotent insert (the UNIQUE constraint deduplicates).

Semantically conflicting facts (e.g. "prefers terse" vs "prefers verbose") are not auto-detected by v1. Two paths:

  1. Manual via CLI. Inspect fastaiagent learn list, then fastaiagent learn supersede <old_id> <new_id>.
  2. Automatic via re-extraction. New traces produce newer rows; downstream consumers can prefer recency by ordering on created_at DESC (which list_active does). Re-running the loop does not re-read traces it already mined.

A fully automatic semantic-dedup pass (LLM judge per scope_id batch) is tracked in the future-work backlog.

Privacy

fastaiagent learn extracts only agent-scoped facts by default. Both --scope user and --scope project require --allow-personal to be set explicitly:

# Allowed:
fastaiagent learn --scope agent --scope-id deep-research

# Refused without opt-in:
fastaiagent learn --scope user --scope-id user-42
# → "Refusing to extract user/project-scoped facts without --allow-personal."

The extraction prompt also instructs the model to skip names, emails, phone numbers, and addresses. This is best-effort, not a guarantee — review extracted facts before re-injecting them in production.

Inspecting the table

Via the local UI:

GET /api/learned_memory                        # active facts
GET /api/learned_memory?include_superseded=true
GET /api/learned_memory?scope=agent&scope_id=my-agent
GET /api/learned_memory/scopes                 # distinct (scope, scope_id) pairs

Or directly:

sqlite3 .fastaiagent/local.db \
  "SELECT id, scope, scope_id, fact, superseded_by FROM learned_memory ORDER BY created_at DESC LIMIT 20;"

What's not in v1

  • Skill extraction — reusable mini-procedures. Needs a different storage model + replay-eval to verify a skill before re-injecting.
  • Prompt/harness mutation — Harrison's Meta-Harness pattern. Requires an automated quality gate before a coding agent's prompt rewrites can ship.
  • Online learning — covered by Memory(learn=llm) / FactExtractionBlock(persist=True), which write facts during a run; this page is the offline batch path.
  • Cron triggers — fastaiagent learn runs on demand. Schedule it however you want.