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_synthesis — PROPOSED, 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: aMemoryStore 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:
- 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.
-
No new infrastructure. The same
MemoryStorethat 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 moduleskill_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 firstprocess_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:
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_conversations—memory.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 uniontools_requiredinto selection (tool-loader.mdx:350), it does not wait for the model to call a tool.
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 aSKILL.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 set —
name,
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
procedurestable, recalled viarecall_skilloverprocedures.embedding. #887 owns thesynthesize → procedures → optional SKILL.md exportpath. - #691 owns the external, installed corpus. The proposed
gaia.skillsloader (greenfield —skill-format.mdx:16) loads installedSKILL.mdfiles from~/.gaia/skills/<name>/(agent-skills.mdx:193).
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 overprocedures.embedding” in the sameMemoryStore(tool-loader.mdx:358) — this spec’sprocedurestable provides exactly that column and index.- When a skill matches, the loader unions its
tools_requiredahead of semantic results (tool-loader.mdx:351) — this spec emitstools_required(the locked #691 field) on every recipe procedure. - The selection formula is
loaded = CORE ∪ SKILL ∪ SEMANTIC ∪ escape-hatch, precedenceCORE > SKILL > SEMANTIC(tool-loader.mdx:362). #887 is the producer of theSKILLinput; it adds nothing to and removes nothing from that formula.
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 verifiedfile: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:
- Embedding-corpus mismatch.
recall_skillmatches a goal against a trigger embedding;knowledgerecall 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 overprocedures.embedding, exactly what the tool-loader spec promised (tool-loader.mdx:358). category='skill'is already taken for something else.VALID_CATEGORIESalready 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 beNULLon all of today’s knowledge rows.
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 throughrecall_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
procedurestable — 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
procedurestable only; it does not auto-write aSKILL.mdto 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_OCCURRENCESruns clearedMIN_SUCCESS_RATEat 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 owntool_history— never raw network content.
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.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 (perCLAUDE.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
procedurestable is present; noknowledge/tool_historyrow 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), aproceduresrow is created with a valid distilled body and correctprovenance.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/NOOPonly (neverDELETE); a higher-success_countcandidate supersedes viasuperseded_by(memory_store.py:227). gaia eval agentshows no regression on the affected category vs the committed baseline (LLM-affecting change — eval required perCLAUDE.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_requiredare unioned ahead of semantic results by the tool-loader, pertool-loader.mdx:369— and with #887 absent the loader’sSKILLsignal 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 reusestriage-support-ticket
(skill-format.mdx:415)
so its tools_required resolve to real registry tools.
A. The synthesised SKILL.md (Skill.parse output — the on-disk #691 document)
A. The synthesised SKILL.md (Skill.parse output — the on-disk #691 document)
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).B. The intermediate DISTILL_SYSTEM_PROMPT output (before Skill.parse)
B. The intermediate DISTILL_SYSTEM_PROMPT output (before Skill.parse)
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.C. A stored procedures row
C. A stored procedures row
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.D. recall_skill at planning time (the live contract surface)
D. recall_skill at planning time (the live contract surface)
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).- 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) plusGAIA_MEMORY_DISABLED. This spec proposes surfacing the five thresholds on the existing memory-settings surface (mirroring how the tool-loader dropped its proposedconfig.tomlsection forChatAgentConfig+ env,tool-loader.mdx:312). Confirm the exact key location at PR. procedurestable vsknowledge.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.recall_skillinjection-budget tuning. The spec sets a default cap ofMAX_RECALL_BODY_CHARS = 1500per body attop_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.- 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. - Nav placement. This doc registers under Ecosystem alongside
skill-formatandagent-skills. Confirm it stays there given the in-flight Agent-UI / Hub restructure (a placement check, not a design fork).