Long-term memory and skills
Overview
Two features, one mechanism: both are conventions over the phase-8 filesystem. There is no routing layer — /memories/ and /skills/ are reserved prefixes in the one Postgres-backed backend, enforced by internal/fs/path.go, because hango has exactly one backend and a router with a single destination buys nothing. Memory is always-on context: AGENTS.md and other configured sources are loaded once per run and contributed as prompt blocks after the cache breakpoint. Skills use progressive disclosure: only name, description and path enter the prompt; the body is read on demand with read_file.
Requirements
Functional
- `/memories/` and `/skills/` are reserved prefixes in the single `agent_files`-backed backend (namespace `{tenant_id, agent_id}`), validated in `internal/fs/path.go`. No `CompositeBackend`, no route table, no path rewriting.
- `MemoryMiddleware` implements exactly two hooks: `RunHook` (`BeforeRun` loads each configured source, default `/memories/AGENTS.md`, strips HTML comments, stores the content in its typed private state) and `PromptContributor` (`SystemBlocks` returns the loaded content as blocks). It does **not** implement `WrapModelCall`. Because system blocks are assembled in chain order at model-call time and Memory sits after `PromptCache` in the stack, its blocks land after the breakpoint index by construction.
- Missing memory files are not an error: they are skipped and logged once.
- The agent edits memory with the ordinary
write_file/edit_filetools; no dedicated memory tool exists. SkillsMiddleware:BeforeRunscans configured sources forSKILL.mdfiles, parses YAML frontmatter (name≤64 chars,description≤1024, optionalallowed_tools), and injects only an index of name, description and path into the system prompt. Later sources override same-named skills.- A skill's `allowed_tools` can only ever **narrow**: the effective set is `allowed_tools ∩ agent allowlist`. A skill file — which is agent-writable content and therefore attacker-influenceable — must never be able to grant a tool the agent is not already allowed to call. Names outside the intersection are dropped with a warning and an `agent.skill_tools_narrowed` span event.
- Writes to `/memories/` and `/skills/` follow the phase-8 rule: API-only for `owner`/`admin` until phase 15, then a default HITL interrupt rule.
- Invalid
SKILL.mdfrontmatter is skipped with a warning naming the path and the reason; one bad skill never fails a run. - Skills metadata and memory content never propagate from a subagent back to the parent.
- Memory and skills sources are configured per agent in
agents.config(memory: [...],skills: [...]) with tenant-level defaults from config.
Non-functional
- Memory must sit after
PromptCachein the stack so its contributed blocks land after the breakpoint index, or every turn pays full input cost. The order test asserts the breakpoint index, not a slice order. BeforeRunloading is bounded: a total byte cap across sources (HANGO_MEMORY_MAX_BYTES, default 64KiB) and a skill count cap (HANGO_SKILLS_MAX, default 100).- Cross-tenant isolation test for memory and skill paths (tenant B cannot read tenant A’s
/memories/AGENTS.md).
Architecture
// Red team: F14 — no CompositeBackend. Reserved prefixes are enforced in internal/fs/path.go
// (fs.PrefixMemories, fs.PrefixSkills, fs.IsReserved), over the single pg backend.
// internal/agent/mwmemory.go
type MemoryMiddleware struct {
Backend fs.Backend
Sources []string // default {"/memories/AGENTS.md"}
MaxBytes int
} // RunHook (load once) + PromptContributor (blocks land after the cache breakpoint index)
// Red team: F6 — deliberately NOT a ModelWrapper.
// internal/agent/mwskills.go
type Skill struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
AllowedTools []string `yaml:"allowed_tools"`
Path string `yaml:"-"`
}
type SkillsMiddleware struct {
Backend fs.Backend
Sources []string // default {"/skills/"}
Max int
} // RunHook (index scan) + PromptContributor (index only, never bodies)No new tables: memory and skills are rows in agent_files under reserved prefixes.
Instrumentation
- Spans:
agent.memory_loadandagent.skills_indexas children ofagent.before_run, withhango.memory.sources,hango.memory.bytes,hango.skills.count,hango.skills.invalid. - Span event
agent.memory_missingper configured source that does not exist, andagent.skill_invalidper rejectedSKILL.mdwith the path and reason. - Attributes on the
chatspan:hango.memory.bytesandhango.skills.countso prompt growth is attributable;langfuse.observation.metadata.memory_bytesand...skills_countfor Langfuse filtering. - Metrics:
hango.memory.load.durationhistogram,hango.memory.byteshistogram,hango.skills.indexedgauge,hango.skills.invalidcounter,hango.cache.read_ratiore-checked here because memory is the most likely cache-invalidation culprit. - Log fields: one info line per run summarizing loaded memory sources, total bytes and indexed skill count.
Related Code Files
Create
internal/agent/mwmemory.go,internal/agent/mwmemory_test.go.internal/agent/mwskills.go,internal/agent/mwskills_test.go.internal/agent/frontmatter.go— minimal YAML frontmatter parser for the small fixed schema (no YAML dependency), plus its test.docs/learning/11-memory-and-skills.md.
Modify
internal/agent/mwstack.go— fill the Skills slot (first, so its tools and index are available to everything) and the Memory slot (after PromptCache); update the pinned order test.internal/agent/subagent.go,internal/agent/childbuilder.go— subagents load their own memory and skills sources and never inherit the parent’s indexes.internal/fs/path.go— reserved-prefix constants andIsReservedalready exist from phase 8; this phase only adds the tests that memory and skills paths validate correctly.internal/config/config.go—HANGO_MEMORY_DEFAULT_SOURCES,HANGO_MEMORY_MAX_BYTES,HANGO_SKILLS_DEFAULT_SOURCES,HANGO_SKILLS_MAX.internal/server/handlers_agents.go— validatememoryandskillsconfig entries are absolute paths under the reserved prefixes.docs/system-architecture.md,docs/learning/README.md.
Implementation Steps
- Confirm the reserved prefixes behave: add table cases to `internal/fs/path_test.go` for `/memories/AGENTS.md`, `/skills/x/SKILL.md`, a near-miss (`/memorie/x`) and a traversal attempt into a reserved prefix. No new filesystem code is written in this phase.
- Write
internal/agent/frontmatter.go: parse a leading---block ofkey: valuelines plus simple[a, b]lists into the fixedSkillschema; reject unknown keys with a warning rather than an error; enforce the length caps. - Write
internal/agent/mwmemory.go:BeforeRunreads each source (skipping missing files), strips<!-- ... -->comments, enforces the total byte cap by truncating with a marker, and stores the result in typed private state.SystemBlocksreturns the content as blocks with a short header naming each source path so the agent knows where to edit. NoWrapModelCall. - Write
internal/agent/mwskills.go:BeforeRunglobs{source}/**/SKILL.md, parses frontmatter, applies last-source-wins override by name, caps the count, intersects each skill’sallowed_toolswith the agent allowlist, and stores the index.SystemBlocksrenders onlyname — description (path)lines plus one sentence telling the model toread_filethe path for the full instructions. - Place the middlewares in
DefaultStack: Skills first, Memory after PromptCache. Add a comment at the Memory slot stating the cache-invalidation constraint, and extend the order test to assert Memory’s blocks land at a higher index than the cache breakpoint. - Update the child builder so subagents load their own sources and start with empty memory/skills private state.
- Add the instrumentation from the section above.
- Tests: memory content appears in the assembled system blocks at an index after the cache breakpoint (index assertion); missing source is skipped with an event; byte cap truncates with a marker; an
ownercan write/memories/AGENTS.mdthrough the files API and the next run loads the new content, while an agent tool write is refused (integration test) ; skills index contains only name/description/path and never a body; a skill declaringallowed_toolsoutside the agent allowlist has them dropped and cannot call them ; an invalidSKILL.mdis skipped while valid ones still load; last-source-wins override; cross-tenant test that tenant B cannot read tenant A’s/memories/AGENTS.md. - Update docs and write the learning doc.
Learning Doc Outline
docs/learning/11-memory-and-skills.md must contain:
- Concept: memory and skills as conventions over a filesystem rather than new subsystems; progressive disclosure as a context-budget technique.
- Design choices:
/memories/and/skills/as reserved prefixes validated inpath.go, memory as always-on context, skills as index-only injection, subagents loading their own sources. - Learning note on the rejected option: deepagents routes these prefixes through a `CompositeBackend` because it genuinely has several backend kinds (state, filesystem, store). hango has one Postgres backend, so a router would be an indirection with a single destination — the prefix rule alone carries the same meaning. If a second backend kind ever lands (a real-disk or object-store backend), the router is the right move then, and this is the note that says so.
- Ordering constraint: memory’s blocks must land after the cache breakpoint index; what the cost looks like when this is violated, and why the assertion is on the index.
- Skill files are agent-writable content: why `allowed_tools` may only narrow, never grant.
- Trade-offs: always-on memory vs a retrieval tool vs embeddings; file-based skills vs registered plugins; per-agent vs per-tenant memory namespace.
- goclaw comparison:
internal/memorywith pgvector,channelmemory, aconsolidationworker doing episodic/semantic “dreaming”, and a knowledge graph — all product features hango deliberately omits; goclaw’s skills are slash-commands, not progressive-disclosure documents. - deepagents comparison:
MemoryMiddlewareloading sources atbefore_agentand appending them with an Anthropiccache_controlbreakpoint; memory edited through normalwrite_fileon the/memories/route of itsCompositeBackend;SkillsMiddlewarewithSKILL.mdfrontmatter (name≤64,description≤1024,allowed_tools), index-only prompt injection, last-source-wins, andskills_metadatanever inherited by subagents. - Why hango copies this almost exactly, and where it tightens it (reserved-prefix validation, byte and count caps, invalid-skill tolerance).
- How to debug this layer:
agent.memory_loadandagent.skills_indexspans show what was loaded and how long it took;agent.memory_missingandagent.skill_invalidevents explain “why doesn’t the agent know about my skill”;hango.memory.byteson thechatspan explains prompt growth; a collapsedhango.cache.read_ratioafter this phase points directly at memory ordering. - Exercises: write memory through the files API and observe it in the next run’s prompt; add a skill and watch the index appear; break the frontmatter and read the warning.
- Time budget: 7h implementation + 2h learning doc.
Success Criteria
-
go build ./... && go vet ./... && go test ./...pass. -
path_test.gocovers the reserved prefixes, a near-miss prefix and a traversal attempt into a reserved prefix. - Memory blocks appear in the assembled system blocks at an index strictly greater than the cache-breakpoint index, and reflect a file written through the files API in a previous run (integration test).
- A skill declaring
allowed_tools: [dangerous_tool]outside the agent allowlist cannot cause that tool to be callable; the narrowing event is recorded. - A missing memory source produces a span event and no error.
- The skills index contains name, description and path only; no skill body appears in any prompt (test).
- An invalid
SKILL.mdis skipped with a warning while the remaining skills still load (test). - Tenant B cannot read tenant A’s
/memories/AGENTS.md(test). - In Grafana,
agent.memory_loadandagent.skills_indexspans appear underagent.before_run, andhango.memory.bytes/hango.skills.indexedseries exist;hango.cache.read_ratiohas not collapsed compared with phase 10.
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
| Memory injection invalidates the prompt cache | High x High | hango.cache.read_ratio collapses, input cost jumps | Memory slot pinned after PromptCache with an order test; the cache-ratio metric is checked as a success criterion |
| Unbounded memory growth eats the context window | High x Medium | hango.memory.bytes climbing per run | Total byte cap with a truncation marker; the agent is told in the prompt header to keep memory concise |
| A malformed skill breaks every run | Medium x High | Runs fail at before_run | Per-skill tolerance: skip, warn, continue; asserted by a test with one broken and one valid skill |
| Reserved prefixes collide with agent-created paths | Medium x Medium | Agent writes /skills/notes.txt | path.go recognizes the prefixes and the filesystem middleware refuses agent writes to them with an explanatory error result; the prompt documents the reserved prefixes and their meaning |
| Load-once hooks add latency to every run | Medium x Low | hango.memory.load.duration climbing | Single indexed query per source prefix; the metric is the trigger for adding a per-agent cache if it ever matters |
| goclaw’s pgvector memory looks more capable | Low x Low | Feature envy | Out of scope: semantic memory is a product feature; the follow-up list records it |
Rollback
No migration to revert. Removing the Memory and Skills slots from DefaultStack restores phase 10 behaviour; files under /memories/ and /skills/ remain in agent_files and stay readable.