Skip to main content
Grounds on (exists today): src/gaia/agents/base/memory_store.py (MemoryStore, tool_history, knowledge, the schema migration) · src/gaia/agents/base/memory.py (the embedder, FAISS index, Mem0-style prompts, the consolidation pass) · src/gaia/agents/base/agent.py (_compose_system_prompt, _select_tools_for_turn) · src/gaia/ui/routers/memory.py (the /api/memory/* router the dashboard reads)Proposed runtime (not written yet): src/gaia/agents/base/skill_synthesis.py and the new procedures table + MemoryStore skill methods. Every such symbol is marked PROPOSED where it appears.Sibling specs: Skill Format defines the SKILL.md field grammar this spec emits; Dynamic Tool Loader Part 3 consumes the recall_skill / tools_required contract.
Component: the skill auto-synthesis pipeline — distil recurring successful tool sequences into procedural memory (issue #887).Module: gaia.agents.base.skill_synthesisPROPOSED, greenfield. No pipeline, procedures table, or recall_skill exists in code. The contract is grounded on the live memory layer in gaia.agents.base.memory / memory_store.Status: All PROPOSED — design, pending maintainer sign-off. Nothing in this spec is implemented. PR #606 (memory v2) is merged and provides every primitive the pipeline reuses; the SKILL.md format (#691) is authored and locked, so this spec references a settled format rather than inventing one.Target agent (v1): the base MemoryMixin (so ChatAgent and any memory-bearing agent inherit it). The on-disk default model is Gemma-4-E4B-it-GGUF; the embedder is nomic-embed-text-v2-moe-GGUF (768-dim), reused unchanged.

Why this exists

PR #606 gave GAIA a memory layer modelled on CoALA — Cognitive Architectures for Language Agents (Sumers et al., 2024), which types memory into working / episodic / semantic / procedural. #606 shipped the first three: a MemoryStore over SQLite, a tool_history table that records every tool call with its outcome and latency (memory_store.py:241), and a consolidation pass that distils old conversations into durable knowledge. The procedural tier was left for a follow-up. This spec is that tier. A skill in CoALA is procedural memory: a generalized policy distilled from episodic traces. The agent already keeps the episodic trace (tool_history). The missing loop is: notice that the same multi-step goal succeeded several times, distil it into a reusable Markdown procedure, store it, and surface it the next time a similar goal appears — so the agent stops re-deriving a solved task from scratch. Two design commitments follow from putting this in the memory layer rather than a new subsystem:
  1. It is a learning loop, not a marketplace. Synthesis creates skills from the agent’s own successful runs. Browsing, installing, and sharing skills is distribution work and stays in the marketplace track (#647). Splitting them lets the learning loop ship on the memory rails that already exist.
  2. No new infrastructure. The same MemoryStore that holds episodic + semantic memory holds procedural memory; the same embedder clusters goals; the same Mem0-style LLM-in-the-loop pattern distils the procedure; the same memory router exposes it. GAIA’s contribution here is the integration, not a new format or a new store.

Prior art (credit before contribution)

The pipeline reuses a substantial body of public work — credited before claiming anything as GAIA’s: GAIA’s contribution is the integration: procedural memory lives in the same MemoryStore, is distilled by the same Mem0-style pipeline, recalled through the same embedder, and exposed through the same memory router — no parallel subsystem.

Decided design

The pipeline is the maintainer’s five-step loop (#887 technical-spec comment), landing in a new module skill_synthesis.py (PROPOSED) and hooked into the existing memory maintenance pass. All five steps reuse #606 primitives — no new model, no new store engine.

The synthesis pass hooks into the existing maintenance pass

#606 already runs a maintenance pass on the first process_query() of a process — _run_memory_post_init() (memory.py:1206), which calls reconcile_memory(max_pairs=20) (memory.py:1377) and consolidate_old_sessions(max_sessions=5) (memory.py:1233). Synthesis is added as one more additive step in that pass — a proposed _synthesize_skills() after consolidation. It is off the hot path and bounded by max_clusters_per_pass.
The maintainer’s pseudocode names this hook on_consolidation_pass() calling _consolidate_old_sessions / _reconcile_contradictions / _prune_stale. Those exact method names do not exist on main — the real seam is _run_memory_post_init (memory.py:1206). The pseudocode is illustrative; this spec binds the hook to the real method. See Correcting the original framing.

The proposed procedures table

Procedural memory gets its own table, distinct from knowledge. The rationale is in Why procedures is a separate table; the schema:
This honours the corpus name the tool-loader spec already published — procedures.embedding (tool-loader.mdx:358) — and adds a separate FAISS index over it, distinct from the knowledge embedding index (memory.py:687). Migration is additive only — a v2→v3 migration mirroring _migrate_schema_locked (memory_store.py:364), which already grew knowledge from v1→v2 with ADD COLUMN statements (memory_store.py:388) — no existing row is rewritten.
Provenance is a best-effort audit trail, not a foreign key. provenance.from_sessions is validated at synthesis time — every id resolves to a real tool_history session at the moment the procedure is distilled. It is not enforced afterward: #606’s tool_history retention may later prune a referenced session, leaving a dangling id. That is acceptable and never blocks recall — the distilled markdown_body is already self-contained. An implementer must not add a foreign-key constraint or a “verify provenance on read” check that couples recall to tool_history longevity.

recall_skill(goal, top_k=2) is an internal method, not a sixth tool

The issue’s Scope C calls it a “new tool”, but the design keeps it a plain MemoryMixin/MemoryStore method invoked programmatically by the planner — not a sixth LLM-facing @tool. The matched procedure’s body is injected into the system context assembled by _compose_system_prompt (agent.py:591) — the same composition seam the dynamic tool-loader’s per-turn _select_tools_for_turn hook (agent.py:786) already uses. This:
  • preserves the five-tool memory registry (remember, recall, update_memory, forget, search_past_conversationsmemory.py:1984, :2067, :2250, :2335, :2344); the agent gains a capability without gaining a tool it must reason about choosing;
  • matches the tool-loader’s programmatic use — Part 3 calls recall_skill(goal) itself to union tools_required into selection (tool-loader.mdx:350), it does not wait for the model to call a tool.
Injection budget. Each matched body lands in the planner’s system prompt, so an unbounded body would both bloat the context on a small local model and defeat the tool-loader’s cache-warm seam, which recomputes the system prompt only when the tool selection changes (tool-loader.mdx:294). The spec therefore caps the injection at a default MAX_RECALL_BODY_CHARS = 1500 per body at top_k = 2 — a ≤ 3 KB worst-case prompt addition. DISTILL_SYSTEM_PROMPT already targets short, single-screen procedures, so the cap is a guardrail rather than the common path; a body over the cap is truncated at injection with an explicit … (truncated) marker while the full body stays in the procedures row. The exact value is tunable against a small-model eval at PR.

Thresholds

The five thresholds are the maintainer’s chosen constants, surfaced as config (see Open questions for the config-location decision):

The format contract

The pipeline emits a SKILL.md; it does not define the format. The format is Skill Format (#691), now authored and locked. This spec references that schema and never redefines a field. The subtlety — and the single most important correctness point — is the split between what the LLM emits and what Skill.parse() injects. The guiding rule: the LLM emits only the fields it derives from the learned procedure; fixed constants are injected, never emitted. So DISTILL_SYSTEM_PROMPT asks the model for an intermediate frontmatter of four learned fields, and a deterministic Skill.parse() maps them to the canonical #691 document and adds the two constants: A synthesised recipe therefore emits a bounded field setname, description, license, version, metadata.gaia.tools_required, and the body. It does not emit security_tier, permissions, requirements, or provided tools (skill-format.mdx:154:157): those are install-time trust and capability concerns owned by #691 / the marketplace (#647), explicitly out of #887’s scope. Each is optional in #691, so the file still validates; an omitted security_tier defaults to the most-restrictive experimental at load time. This is the one deliberate refinement of the maintainer’s pseudocode: his DISTILL_SYSTEM_PROMPT listed version: 1.0.0 inside the LLM’s required output (#887 technical-spec comment); this spec moves version (and license) to Skill.parse() injection because both are fixed, not derived. The pipeline otherwise keeps the maintainer’s prompt as written, and (a) treats the four learned fields as the intermediate shape, (b) specifies Skill.parse() as the translator to the locked schema, and (c) guarantees the emitted file validates under #691 — an explicit acceptance criterion (see Acceptance criteria traceability).

Two corpora, one format — the boundary with the loader/parser

#887 and #691 own different corpora and do not conflict:
  • #887 owns the internal, learned corpus. Synthesised procedures live in the procedures table, recalled via recall_skill over procedures.embedding. #887 owns the synthesize → procedures → optional SKILL.md export path.
  • #691 owns the external, installed corpus. The proposed gaia.skills loader (greenfield — skill-format.mdx:16) loads installed SKILL.md files from ~/.gaia/skills/<name>/ (agent-skills.mdx:193).
They meet only at the format: an exported #887 procedure is a valid #691 document, so it is installable like any other skill. They never share a table or a parser.

The tool-loader contract

Dynamic Tool Loader Part 3 is the named consumer. It is gated on #887 and returns [] cleanly until this lands, so the contract must match its already-published wording byte-for-byte:
  • recall_skill(goal) is “a vector search over procedures.embedding in the same MemoryStore (tool-loader.mdx:358) — this spec’s procedures table provides exactly that column and index.
  • When a skill matches, the loader unions its tools_required ahead of semantic results (tool-loader.mdx:351) — this spec emits tools_required (the locked #691 field) on every recipe procedure.
  • The selection formula is loaded = CORE ∪ SKILL ∪ SEMANTIC ∪ escape-hatch, precedence CORE > SKILL > SEMANTIC (tool-loader.mdx:362). #887 is the producer of the SKILL input; it adds nothing to and removes nothing from that formula.
This spec is the producer side of a soft dependency. It does not modify the loader. Until #887 ships, the loader’s SKILL signal is []; once it ships, the signal is non-empty for goals that match a stored procedure. Parts 0–2 of the loader are unaffected either way.

Reuse of the memory v2 layer

Every primitive the pipeline needs already exists in #606. The table below maps each reuse to its verified file:line on main.

Why procedures is a separate table

The maintainer’s tech spec left the storage shape open — procedures (or knowledge with kind='procedure')”. This spec chooses a separate procedures table, because knowledge and procedures diverge on every lifecycle axis. knowledge (memory_store.py:209) stores atomic beliefs; procedures store generalized multi-step policies. Two consequences make a shared table actively harmful:
  1. Embedding-corpus mismatch. recall_skill matches a goal against a trigger embedding; knowledge recall matches a query against fact-content embeddings. Mixing both in one FAISS index (memory.py:687) pollutes both result sets — procedures need their own index over procedures.embedding, exactly what the tool-loader spec promised (tool-loader.mdx:358).
  2. category='skill' is already taken for something else. VALID_CATEGORIES already contains "skill" (memory_store.py:97), but that is a user-told skill fact (“I know vim”), not a synthesised multi-step procedure. Reusing it would conflate two unrelated concepts, and every procedure-only column (success_count, tools_required, enabled, provenance) would be NULL on all of today’s knowledge rows.
Only embedding and superseded_by echo knowledge, and even those carry different semantics (goal vs content; success-rate vs contradiction). The choice is confirm-at-PR-review, not a pre-authoring blocker (see Open questions).

Off-states as safe floors

Every degraded condition lands on a conservative floor — the pipeline produces no procedural signal, never a broken recall or a silent wrong answer. The failure-mode rows are the maintainer’s table, grounded against the real config surface. This collapses the three user-chosen off-states (memory off, synthesis off, a disabled procedure) onto “no procedural signal” — a user can never lose a capability the agent had before — while the three failure states fail loudly per CLAUDE.md’s no-silent-fallback rule. The distinction is in the messaging (silent-and-expected vs actionable-error), not the mechanism: all of them leave the agent on its pre-synthesis behavior.

Trust boundary for injected bodies

A synthesised procedure is model-authored text that re-enters the planner’s system prompt through recall_skill_compose_system_prompt. That makes it a trust boundary — and a self-reinforcing one: a procedure distilled from a run that itself followed an earlier weak procedure could entrench that weakness. Three rules keep the loop honest:
  • Internal corpus only. A body injected at planning time comes only from the local procedures table — never a network source, an imported file, or an installed ~/.gaia/skills/ document. The external corpus (#691) loads through a different path and never crosses into the injection seam (see The format contract).
  • No auto-export. Synthesis writes to the procedures table only; it does not auto-write a SKILL.md to disk or publish anywhere. Promoting a procedure to the installable corpus is an explicit, user-initiated action on the marketplace track (#647) — a human reviews before a synthesised procedure becomes shareable.
  • Empirical gate, not just plausibility. A procedure only exists because a cluster of ≥ MIN_OCCURRENCES runs cleared MIN_SUCCESS_RATE at synthesis; once its track record decays it is superseded (Zep lineage) or disabled, so a once-good policy that starts failing stops being recalled. The distiller only ever reads the agent’s own tool_history — never raw network content.
This is a content-trust statement, distinct from the install-time security_tier / permissions controls, which gate the external corpus and stay out of #887’s scope (→ #691 / #647).

Correcting the original framing

The issue body and the maintainer’s pseudocode predate #691 locking and the current code; these are the corrections an implementer must internalize.
Retractions — do not build against these:
  • The intermediate frontmatter is not the on-disk schema. A reader of the maintainer’s DISTILL_SYSTEM_PROMPT must not write when_to_use / top-level tools_required to disk. The on-disk file is the #691 schema (description, metadata.gaia.tools_required); Skill.parse() is the only bridge.
  • recall_skill is not an LLM tool. Do not register a sixth memory @tool; it would change the five-tool registry the rest of the system assumes.
  • There is no [memory.skill_synthesis] TOML section to extend — it does not exist on main. Config rides the existing memory_settings.json surface (the exact key location is an open question).

KPIs

The pipeline is judged by the acceptance criteria, gated by no-harm — a loop that synthesises garbage or breaks recall is worse than no loop.

Phased build

Ship in reviewable phases; each phase holds the prior floor and earns its place with an eval run before merge (per CLAUDE.md).

Phase 0 — procedures schema + additive migration (PROPOSED)

Add the procedures table and a separate FAISS index over procedures.embedding; extend MemoryStore with put_skill / search_skills / supersede_skill / iter_sessions (all PROPOSED). Migration is additive only — a v2→v3 migration mirroring the v1→v2 ADD COLUMN pattern (memory_store.py:364). Success criteria:
  • A fresh DB and an existing v2 DB both open after the migration; the procedures table is present; no knowledge / tool_history row is altered (verified against a copied pre-migration DB).
  • The procedures FAISS index is independent of the knowledge index — building one does not touch the other.

Phase 1 — Detect → cluster → distill → reconcile/store (PROPOSED)

skill_synthesis.py with extract_sequences / cluster_by_goal / distill_cluster / Skill.parse / reconcile_and_store, hooked into the maintenance pass as _synthesize_skills after consolidate_old_sessions (memory.py:1233). Reuses the nomic-768 embedder (memory.py:148). Success criteria:
  • After 3 successful similar sequences (≥MIN_STEPS, ≥MIN_SUCCESS_RATE), a procedures row is created with a valid distilled body and correct provenance.from_sessions.
  • Fail-loud verified: a stubbed Lemonade-down skips the whole pass + logs; a malformed distill output skips that cluster + logs; an embedder failure re-raises — no smaller-model fallback in any path.
  • Reconciliation issues ADD / UPDATE / NOOP only (never DELETE); a higher-success_count candidate supersedes via superseded_by (memory_store.py:227).
  • gaia eval agent shows no regression on the affected category vs the committed baseline (LLM-affecting change — eval required per CLAUDE.md).

Phase 2 — recall_skill + planner injection + disabled-flag (PROPOSED)

recall_skill(goal, top_k=2) as an internal method (vector search over procedures.embedding); inject the matched body into the system context via _compose_system_prompt (agent.py:591); honour enabled = 0 in the recall path; emit tools_required for the tool-loader Part 3 union. Success criteria:
  • A matched goal injects the procedure body and the 4th attempt shows a measurable tool-step reduction vs the unmatched baseline.
  • Disabling a procedure (enabled = 0) prevents it from being recalled (the issue AC) — verified directly against the recall path.
  • A fixture procedure’s tools_required are unioned ahead of semantic results by the tool-loader, per tool-loader.mdx:369 — and with #887 absent the loader’s SKILL signal is [] (regression test).

Deferred — Memory Dashboard “Procedures” tab (NOT #887)

The dashboard tab is explicitly out of #887 — the maintainer’s tech spec lists it under “What this issue does NOT do … part of the dashboard work in #606’s deliverables; we just expose new data.” #887 stores and exposes the procedural data (count, last-used, provenance, enabled) through the existing /api/memory/* router pattern (ui/routers/memory.py:26), following the established three-tab dashboard shape (the DashboardTab union, MemoryDashboard.tsx:267). Rendering the “Procedures” tab and the “promote this conversation to a skill” button ride #606’s dashboard track, not this issue. The two dashboard ACs are deferred accordingly (see Acceptance criteria traceability).

Examples

All examples parse as YAML and validate under Skill Format. The recipe reuses triage-support-ticket (skill-format.mdx:415) so its tools_required resolve to real registry tools.
What Skill.parse() writes after distillation — a valid #691 document. name, description, license, version, and metadata.gaia.tools_required are all the locked schema. Each tools_required name is a real registry tool (query_documents from RAGToolsMixin, read_file from FileIOToolsMixin, remember from the tool-loader CORE set, tool-loader.mdx:269). No security_tier is emitted — skill security tiers are explicitly out of #887’s scope (→ #647); the field is optional in #691 and the loader defaults an omitted tier to the most-restrictive experimental (skill-format.mdx:154).
What the LLM emits — only the four learned fields: top-level name, when_to_use, tools_required, and a body that already contains the ## Edge cases section. No version, no license — those are fixed constants. Skill.parse() maps when_to_use → description, tools_required → metadata.gaia.tools_required, and injects the fixed license: MIT and version: 1.0.0 to produce example A.
The record as stored — one row of the procedures table. embedding is the 768-float BLOB over when_to_use; provenance links back to the tool_history sessions it was distilled from; success_count/attempt_count is the empirical track record; enabled = 1.
recall_skill is called programmatically at turn start. The matched procedure’s body is injected into the system context; its tools_required feed the tool-loader’s CORE ∪ SKILL ∪ SEMANTIC union ahead of semantic results.

Open questions

No blocking forks remain — the issue owner settled the design during planning. The items below are confirm-at-PR-review, not pre-authoring blockers; the maintainer said he will “steer from the PR” (#887 comment).
  1. Config surface for the thresholds. The maintainer’s pseudocode puts tunables in ~/.gaia/config.toml [memory.skill_synthesis], but that section does not exist — memory is configured via ~/.gaia/memory_settings.json (memory.py:57) plus GAIA_MEMORY_DISABLED. This spec proposes surfacing the five thresholds on the existing memory-settings surface (mirroring how the tool-loader dropped its proposed config.toml section for ChatAgentConfig + env, tool-loader.mdx:312). Confirm the exact key location at PR.
  2. procedures table vs knowledge.kind. This spec chose a separate table (Why procedures is a separate table); the maintainer’s tech spec left it open. Ratify the table choice at PR.
  3. recall_skill injection-budget tuning. The spec sets a default cap of MAX_RECALL_BODY_CHARS = 1500 per body at top_k = 2 (see Decided design), which bounds the prompt addition and protects the tool-loader’s cache-warm seam (tool-loader.mdx:294). Confirm the exact value against a small-model eval at PR — a tuning question, no longer an open design fork.
  4. Synthesis cadence. #606’s maintenance pass runs once per process on the first query (memory.py:1206). The issue also mentions “daily background”. Confirm whether v1 piggybacks on the existing per-process pass only, or adds a scheduled cron.
  5. Nav placement. This doc registers under Ecosystem alongside skill-format and agent-skills. Confirm it stays there given the in-flight Agent-UI / Hub restructure (a placement check, not a design fork).

Current state of the code

The memory layer it builds on is real and merged; the synthesis pipeline is entirely greenfield.

Dependencies


Acceptance-criteria traceability

Every Scope item and acceptance criterion from #887, mapped to its section and what it guarantees a consumer. Deferrals are explicit, with the track that owns them.