Memory and On-Demand Skills
Move durable facts and full capability instructions out of resident context, retrieving them only when relevant.
What exactly changes in this chapter?
Move from compressing old context to retrieving external facts and capability instructions on demand.
- Source- and scope-attributed MemoryRecord objects with deterministic retrieval
- Resident lightweight descriptors with lazily loaded full skill instructions
- Retrieval and loading enter context through observable events; external data is never disguised as model memory.
s09 · Memory and On-Demand Skills
Context should contain what the next turn needs, not every fact and capability the harness has ever learned. Durable memory and full skill instructions stay outside the resident prompt until explicit retrieval or loading.
What you will build
s08 compressed older session history into a checkpoint. Compaction still starts from information already in one session. Coding agents also need project facts that outlive a turn, and capability instructions too large to keep in every request. This chapter adds two deliberately separate stores:
MemoryStoreholds selected facts with stable identity, scope, and source attribution.SkillCatalogkeeps lightweight descriptors resident and loads full instructions only after a skill is selected.
Both enter the loop through structured tools and visible memory.read / skill.load events. Nothing is described as the model “remembering” data that the harness actually retrieved.
By the end, you should be able to:
- distinguish message history, compacted checkpoints, durable memory, artifacts, instructions, and skills;
- define a provenance-bearing memory record and deterministic retrieval contract;
- explain why memory writes need stricter review than reads;
- keep skill descriptors small while loading full instructions and tool requirements on demand;
- trace retrieval and loading without exposing hidden reasoning;
- identify stale-memory, poisoning, prompt-injection, capability-escalation, and privacy risks.
Prerequisites
You should understand context budgets, compaction, tool dispatch, and instruction precedence from s03, s05, s06, and s08. The retrieval algorithm here is intentionally lexical and deterministic. It teaches boundaries, not state-of-the-art semantic search.
The problem: “put everything in the prompt” does not scale
A mature agent may know about repository conventions, past architectural decisions, test commands, user preferences, dozens of skills, hundreds of MCP tools, and large artifacts. Injecting everything into every model request creates four failures:
- Budget pressure: static capability text competes with the current task.
- Attention dilution: irrelevant instructions make the relevant rule harder to follow.
- Staleness: an old fact remains silently active after the project changes.
- Authority confusion: retrieved data, project policy, tool output, and model text look equally trusted.
The opposite extreme—never retaining anything—forces repeated discovery and loses durable decisions. The harness needs selective persistence and explicit retrieval.
| Data class | Typical lifetime | Authority | How it enters context |
|---|---|---|---|
| Recent messages | Current session | User/model/tool event | Loop state |
| Compacted checkpoint | Current session/branch | Derived from journal | Context policy |
| Project instructions | Directory/version scoped | Repository policy | Instruction discovery |
| Artifact | Task or durable storage | Tool/environment result | Handle plus explicit read |
| Memory record | Turn, session, project, or user scope | Curated fact with source | Retrieval result |
| Skill descriptor | Installation scope | Extension metadata | Small resident catalog |
| Skill instructions | Loaded task scope | Reviewed extension content | Explicit load |
Memory is not a larger system prompt. It is a database with selection, provenance, invalidation, trust, and observability requirements.
Mental model: external stores and a context compiler
The model sees a finite projection compiled for one turn:
current user task
│
├── memory_search(query) ──> source-attributed facts
│ │
├── skill descriptors ── select ── load_skill(id)
│ │
└──────────── context compiler ◄───┘
│
▼
next model.requestMemory and skills share “load on demand,” but their semantics differ:
- A memory claims something about a project, user, or prior work. It needs freshness and evidence.
- A skill contributes procedural instructions, tools, or workflow. It needs installation trust and capability review.
- An artifact is usually a large immutable or versioned result addressed by a handle.
- An instruction has policy precedence and may constrain all work under a directory.
Treating these as one bag of text makes it impossible to reason about authority.
Retrieval contract
A useful retrieval response answers:
- Which query and scope were used?
- Which record IDs matched, in what stable order?
- What source supports each record?
- When was it written or last validated?
- Was the result truncated, filtered, or denied?
The lesson implements the first three. Later production work should add timestamps, version ranges, confidence, contradiction state, and access policy.
Build memory and lazy skills step by step
Step 1: give each memory identity and source
The smallest record is explicit:
@dataclass(frozen=True)
class MemoryRecord:
id: str
content: str
scope: str
source: strcontent alone would be ambiguous. scope="workspace" says where the fact applies; source="AGENTS.md#testing" lets a user or validator find the policy that supports it.
Step 2: make writes deliberate
remember() rejects duplicate IDs and empty content/source:
def remember(self, record):
if record.id in self._records:
raise ValueError(f"memory already exists: {record.id}")
if not record.content.strip() or not record.source.strip():
raise ValueError("memory content and source must be non-empty")
self._records[record.id] = recordThe demo preloads one reviewed record. It does not let the model write memory. A production write path should require a schema, scope, source evidence, secret scan, contradiction check, and often user or policy approval.
Step 3: use deterministic retrieval before semantic ranking
The teaching search lowercases query terms, counts term matches, and sorts by descending score then stable ID:
terms = {term for term in query.lower().split() if term}
score = sum(term in haystack for term in terms)
ranked.sort(key=lambda item: (-item[0], item[1]))This is not sophisticated, but it is reproducible and easy to test. Vector search introduces embedding versions, distance thresholds, index freshness, and nondeterministic ties that need their own experiment contract.
Step 4: expose retrieval as a bounded tool
The model requests memory_search with a structured query. The handler returns only matching records:
{
"matches": [
{
"id": "project-test-policy",
"content": "Run the focused test before the full suite.",
"source": "AGENTS.md#testing"
}
]
}The tool result enters message history using the same roundtrip contract as s03. Retrieval does not bypass tool validation or context budgets.
Step 5: record memory.read
The handler emits an observation between tool.request and tool.result:
trace.emit(
"memory.read",
actor_kind="harness",
actor_id="workspace-memory",
payload={
"query": query,
"match_ids": [...],
"sources": [...],
},
)The event records selection and provenance, not hidden model reasoning. Sensitive memory content may be omitted from public traces while IDs and redaction status remain.
Step 6: keep skill descriptors resident
SkillCatalog.descriptors() returns only identity and a short description:
{"id": "test-first", "description": "Choose focused checks before broad regression tests."}If fifty skills each contain a thousand tokens of instructions, descriptors let the harness or model choose one without spending the entire context budget. Descriptor quality becomes a retrieval problem: it must be specific enough to select correctly without embedding the whole skill.
Step 7: load full instructions explicitly
After selection, load_skill returns instructions and tool requirements:
{
"id": "test-first",
"instructions": "Run the smallest relevant test, inspect failure, then widen coverage.",
"tool_names": ["run_command"]
}Loading instructions does not automatically grant the named tools. s10 and s11 separate approval and sandbox policy from capability description.
Step 8: record skill.load
The trace event includes the stable skill ID and declared tool names. A production event may also include package version, content digest, signer/trust source, load reason, and instruction size.
Step 9: continue through the ordinary loop
The first scripted model turn requests both tools. The runner executes them in order, appends two tool messages, and makes the second model request. No memory-specific control loop is required; memory and skills are extensions of the existing tool/event protocol.
Run the retrieval path
Execute:
python3 -m curriculum.lessons.s09_memory_skills.demoThe central event sequence is:
tool.request memory_search
memory.read
tool.result memory_search
tool.request load_skill
skill.load
tool.result load_skill
model.requestVerify all 13 committed events:
python3 -m curriculum.golden verify s09-memory-skillsInspect the evidence path
The memory.read event names project-test-policy and AGENTS.md#testing. The following tool result includes the content. A UI can link the source; a public trace can redact content while retaining the record identity.
Inspect the capability path
The skill.load event names test-first and run_command. That declaration is informational. The demo does not register run_command, so the loaded skill cannot silently execute it. Capability grant remains a separate harness decision.
Inspect the second request
runner.model.requests[1] has four messages: user, assistant, memory tool result, and skill tool result. This makes the retrieval boundary visible in ordinary model history.
Failure modes and trust boundaries
| Failure | Consequence | Safer design |
|---|---|---|
| Stale memory | Agent follows an obsolete command or architecture | Version scope, validation date, invalidation and contradiction state |
| Memory poisoning | Untrusted tool output becomes durable authority | Restricted write path, source allowlist, review and provenance |
| Secret retention | Credential survives across sessions | Classification, secret scan, encryption, retention and deletion |
| Over-broad scope | One project's rule affects another | Explicit workspace/user/session namespace |
| Retrieval flood | Irrelevant records consume context | Limit, threshold, diversity, size budget and “no match” result |
| Descriptor ambiguity | Wrong skill is loaded | Specific descriptions, examples, conflict tests and explicit selection |
| Skill prompt injection | Loaded instructions override higher policy | Trust tiers, instruction delimiters and precedence compiler |
| Capability escalation | Skill declares a dangerous tool and gains it | Tool grants remain policy-controlled; declaration is not authorization |
| Package drift | Same skill ID resolves to new content | Version pin and content digest in load event |
| Silent no-match fallback | Model invents a “remembered” fact | Explicit empty result and visible uncertainty |
Retrieved text is data with provenance, not automatically a trusted instruction. Its authority depends on type, scope, source, and policy.
Exercises with acceptance criteria
A. No-match behavior
Search for deployment region. Acceptance: return an empty tuple and emit memory.read with no match IDs; the model must not receive an invented default.
B. Stable ranking
Add two records with the same score. Acceptance: results are ordered by stable ID, so repeated runs produce the same Golden Trace.
C. Contradictory memory
Add a newer record contradicting project-test-policy. Design fields for supersedes, valid_from, and status. Acceptance: retrieval never returns both as equally current facts.
D. Memory write gate
Add a memory_write proposal that cannot commit directly. Acceptance: missing source, secret-like content, or workspace mismatch is rejected; an approved write emits memory.write with redacted metadata.
E. Skill digest
Calculate SHA-256 over canonical skill content and include it in skill.load. Acceptance: changing instructions without changing the digest fails validation.
F. Capability separation
Load a skill that declares run_command while the tool registry lacks it. Acceptance: the skill text is available, but any request for the absent tool receives an unknown-tool error.
Run the focused contract:
python3 -m unittest \
curriculum.tests.test_vertical_slice.VerticalSliceTests.test_s09_retrieves_memory_and_loads_skill -vDeep dive: production retrieval architecture
Memory lifecycle
A durable record needs creation, validation, retrieval, update, contradiction, expiration, and deletion states. Append-only history can record changes while a current projection selects the active version. “Last write wins” is rarely adequate for safety-critical constraints.
Scope and identity
Useful scopes include turn, branch, session, workspace, repository revision, user, and organization. A memory should bind to stable project identity, not only a local absolute path. Forked repositories and renamed workspaces require explicit lineage rules.
Retrieval evaluation
Measure recall of necessary facts, precision of returned facts, stale-record rate, contradiction handling, context cost, latency, and downstream task success. A high semantic-similarity score is not enough if the retrieved fact has the wrong version or authority.
Prompt injection and data/instruction separation
Tool output or documentation can contain imperative text. A memory reducer should not automatically transform “ignore previous instructions” into durable policy. Store data with type and source, compile trusted instructions through a separate precedence system, and expose untrusted text with clear delimiters.
Skill supply chain
Skills are executable-adjacent content. Production catalogs need package origin, version, digest/signature, allowed tools, network expectations, lifecycle hooks, and update policy. Loading a skill can change agent behavior even without code execution, so review and provenance matter.
Lazy tools and schema stability
Some systems keep placeholder descriptors stable and start MCP servers or reconcile live schemas only when selected. This improves startup and cache behavior but introduces load failures mid-task. Events should distinguish selection, process startup, schema reconciliation, and final availability.
Real-agent evidence boundary
The linked source/documentation claims cover Codex skills/plugins, Pi extensions, Reasonix lazy plugin/MCP behavior, and Claude Code hooks. They support an adjacent extension-runtime comparison. They do not establish a shared durable-memory implementation across products.
From context to safety
Memory and skills can influence actions, but they must not own authorization. A remembered preference cannot override a fresh denial, and a skill requiring shell access cannot grant itself that access. s10 begins the safety track by inserting explicit approval policy between model request and effect.
Checkpoint
Before entering safety, answer:
- How does durable memory differ from a compacted checkpoint?
- Why must every memory record carry scope and source?
- Why are memory writes riskier than memory reads?
- What should stay in a skill descriptor, and what should load later?
- Why does a skill's tool declaration not authorize the tool?
- Which events make retrieval and loading auditable?
You now have the full context track: deterministic instructions, hard budgets, replayable facts, semantic compaction, and on-demand retrieval. The next chapter will decide whether a requested action is permitted before any side effect occurs.
How does the teaching harness map to real agents?
The claims directly cover skills, plugins, hooks, or lazy extension runtimes, but no uniform cross-product durable-memory semantics are established, so the memory portion remains an adjacent mapping.
adjacentAt the pinned revision, Codex assembles selected skills and plugins into turn context and runs typed lifecycle hooks around session start, tool use, permissions, stop, compaction, and session end.
source + source · reviewedPi · Extension Runtimepi-source-extension-runtimePi's coding-agent layer uses an extension runner to dispatch lifecycle events and lets extensions add or replace tools, commands, UI, providers, context transforms, and compaction behavior without modifying the low-level agent loop.
source + source · reviewedReasonix · Extension Runtimereasonix-source-extension-runtimeReasonix plugin manifests can contribute hooks and MCP servers, while lazy MCP placeholders keep cached tool metadata stable and defer process startup or live schema reconciliation until needed.
source + source · reviewedClaude Code · Extension Runtimeclaude-code-doc-hooksClaude Code's official hook contract exposes lifecycle events including pre- and post-tool use, permission requests, session start/end, and stop; PreToolUse hooks can deny a call before normal permission-mode checks, while allow decisions cannot override stricter deny rules.
official-doc · reviewedsession.start
Actor reference-agent produced sequence 0. Redaction status is clean.
{
"max_turns": 8,
"tool_count": 2
}1 events are visible; hidden chain-of-thought is not part of the trace.
Download JSONLNot just “try it”: know exactly what passes.
The long-form exercises leave room to explore; these commands protect the chapter's executable baseline.
Verify on-demand loading
memory.read and skill.load each appear once with source or skill identity.
python3 -m curriculum.golden verify s09-memory-skillsTest the retrieval contract
Queries return only relevant source-attributed memory; unknown skills fail explicitly rather than being silently injected.
python3 -m unittest curriculum.tests.test_vertical_slice.VerticalSliceTests.test_s09_retrieves_memory_and_loads_skill -vNot pseudocode: this is the program the lesson actually runs.
Every important step in the tutorial can be checked against this source and its automated tests.
from curriculum.harness import (
AgentRunner,
MemoryRecord,
MemoryStore,
ModelTurn,
ScriptedModel,
SkillCatalog,
SkillDefinition,
Tool,
ToolCall,
ToolRegistry,
)
from curriculum.lessons.common import lesson_trace, print_run
def build_demo() -> tuple[AgentRunner, object]:
trace = lesson_trace("s09-memory-skills")
memory = MemoryStore()
memory.remember(
MemoryRecord(
id="project-test-policy",
content="Run the focused test before the full suite.",
scope="workspace",
source="AGENTS.md#testing",
)
)
skills = SkillCatalog()
skills.register(
SkillDefinition(
id="test-first",
description="Choose focused checks before broad regression tests.",
instructions="Run the smallest relevant test, inspect failure, then widen coverage.",
tool_names=("run_command",),
)
)
def search_memory(args):
matches = memory.search(args["query"])
parent = trace.events[-1]["event_id"]
trace.emit(
"memory.read",
actor_kind="harness",
actor_id="workspace-memory",
parent_event_id=parent,
payload={
"query": args["query"],
"match_ids": [record.id for record in matches],
"sources": [record.source for record in matches],
},
)
return {
"matches": [
{"id": record.id, "content": record.content, "source": record.source}
for record in matches
]
}
def load_skill(args):
skill = skills.load(args["skill_id"])
parent = trace.events[-1]["event_id"]
trace.emit(
"skill.load",
actor_kind="harness",
actor_id="skill-catalog",
parent_event_id=parent,
payload={"skill_id": skill.id, "tool_names": list(skill.tool_names)},
)
return {
"id": skill.id,
"instructions": skill.instructions,
"tool_names": list(skill.tool_names),
}
registry = ToolRegistry()
registry.register(
Tool(
name="memory_search",
description="Retrieve source-attributed workspace memory on demand.",
parameters={
"type": "object",
"additionalProperties": False,
"required": ["query"],
"properties": {"query": {"type": "string"}},
},
handler=search_memory,
)
)
registry.register(
Tool(
name="load_skill",
description="Load full skill instructions only after selecting a descriptor.",
parameters={
"type": "object",
"additionalProperties": False,
"required": ["skill_id"],
"properties": {"skill_id": {"type": "string"}},
},
handler=load_skill,
)
)
model = ScriptedModel(
[
ModelTurn(
tool_calls=(
ToolCall(
id="call-memory",
name="memory_search",
arguments={"query": "project test policy"},
),
ToolCall(
id="call-skill",
name="load_skill",
arguments={"skill_id": "test-first"},
),
)
),
ModelTurn(
content="I will run the focused test first, then widen coverage.",
stop=True,
),
]
)
return AgentRunner(model=model, tools=registry, trace=trace), trace
def main() -> None:
runner, trace = build_demo()
result = runner.run("Recall the project testing rule and load the relevant skill.")
print_run(result.final_text, trace)
if __name__ == "__main__":
main()
Open the file on GitHub ↗