Skip to content

feat(memory): Two-tier memory store — separate agent-notes and user-profile stores #3019

@hamza-jeddad

Description

@hamza-jeddad

Background

All memory currently lands in a single flat SQLite table (pkg/memory/database/). A production memory subsystem distinguishes between two fundamentally different kinds of persistent knowledge:

  • Agent notes (memory target): facts the agent learns about the environment — project conventions, tool quirks, OS details, coding styles, lessons learned.
  • User profile (user target): facts the agent learns about the person — name, role, timezone, communication style, pet peeves, preferences.

Keeping these separate gives operators independent control (e.g. enable user profiling but disable agent notes), gives the model clearer context in the system prompt, and lets each tier have its own size budget.

Sub-issue of #3011.

Proposed design

1. Storage

Add a tier column (TEXT NOT NULL DEFAULT 'memory', values 'memory' or 'user') to the existing user_memories table in pkg/memory/database/sqlite/. Migration required. All existing rows default to 'memory'.

ALTER TABLE user_memories ADD COLUMN tier TEXT NOT NULL DEFAULT 'memory';
CREATE INDEX IF NOT EXISTS idx_user_memories_tier ON user_memories(tier);

2. Memory tool target parameter

Extend add_memory, update_memory, delete_memory, search_memories, and get_memories in pkg/tools/builtin/memory/ with an optional target parameter:

{
  "name": "target",
  "type": "string",
  "enum": ["memory", "user"],
  "description": "Which memory tier: 'memory' for agent notes, 'user' for user profile.",
  "default": "memory"
}

All existing callers that omit target get 'memory' — fully backward compatible.

3. Per-tier character budgets

Add two config fields to AgentConfig in pkg/config/latest/types.go:

YAML key Go type Default Description
memory_char_limit *int 2200 Max chars for agent-notes tier
user_char_limit *int 1375 Max chars for user-profile tier

Enforcement: when add_memory / update_memory would push a tier over budget, return a structured error listing current usage and asking the model to replace or remove an entry first.

4. System-prompt injection (two separate blocks)

The inject_memories builtin (from #3015) must emit two distinct blocks when both tiers are enabled:

════════════════════════════════════════════════
MEMORY (agent notes) [42% — 924/2,200 chars]
════════════════════════════════════════════════
<entry 1>
§
<entry 2>

════════════════════════════════════════════════
USER PROFILE (who the user is) [31% — 427/1,375 chars]
════════════════════════════════════════════════
<entry 1>
§
<entry 2>

Each block is omitted entirely when its tier is empty.

5. Per-agent config toggles

agents:
  my_agent:
    tools:
      - type: memory
    memory_enabled: true        # agent-notes tier (default: true)
    user_profile_enabled: true  # user-profile tier (default: true)
    memory_char_limit: 2200
    user_char_limit: 1375

Implementation checklist

  • pkg/memory/database/sqlite/ — add tier column + migration; update all query helpers to accept and filter by tier
  • pkg/tools/builtin/memory/ — add target param to all five memory tools; enforce per-tier char budget on writes; return structured usage in every tool response
  • pkg/config/latest/types.go — add MemoryEnabled, UserProfileEnabled, MemoryCharLimit, UserCharLimit to AgentConfig
  • agent-schema.json — extend schema with new fields + descriptions
  • pkg/hooks/builtins/inject_memories.go — split injected block into two sections; respect per-tier enable flags
  • pkg/agent/agent.go — add accessors for the four new config fields
  • DB migration: existing rows get tier = 'memory'; no data loss
  • Unit tests: tier routing, budget enforcement, block rendering, empty-tier omission
  • Integration test: memories written to each tier appear in the correct system-prompt block only

Acceptance criteria

  • add_memory(content="…", target="memory") stores in agent-notes tier
  • add_memory(content="…", target="user") stores in user-profile tier
  • add_memory(content="…") (no target) defaults to 'memory' — backward compatible
  • search_memories(query="…", target="user") returns only user-profile entries
  • get_memories(target="memory") returns only agent-note entries
  • Writing past a tier's char_limit returns a structured error with current usage and does not truncate existing entries
  • System prompt contains two visually distinct blocks when both tiers are non-empty
  • memory_enabled: false suppresses the agent-notes block; user_profile_enabled: false suppresses the user-profile block
  • Existing agents with no target usage see zero behavioural change
  • DB migration runs cleanly on non-empty databases

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/agentFor work that has to do with the general agent loop/agentic features of the apparea/configFor configuration parsing, YAML, environment variablesarea/toolsFor features/issues/fixes related to the usage of built-in and MCP tools
    No fields configured for Enhancement.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions