diff --git a/docs/agent-file.md b/docs/agent-file.md index fdca0d8..56a3e2c 100644 --- a/docs/agent-file.md +++ b/docs/agent-file.md @@ -78,10 +78,11 @@ The full model story — dialects, keys, local endpoints, retries — is [Models ```yaml memory: - scope: thread # default: none + scope: thread # default: none + persistent: true # default: false ``` -`none` (the default) starts every run fresh. `thread` persists conversation history per conversation key — the chat channel or thread (Discord, Slack, Telegram), the webhook caller's `conversation_id`, or the REPL session — so follow-ups work ("make it weekly instead"). History lives in the agent's own SQLite file, nowhere else. +`none` (the default) starts every run fresh. `thread` persists conversation history per conversation key — the chat channel or thread (Discord, Slack, Telegram), the webhook caller's `conversation_id`, or the REPL session — so follow-ups work ("make it weekly instead"). `persistent: true` gives the agent `remember`/`recall`/`list_memories`/`forget` tools — facts that survive across conversation keys and container restarts, not just one thread's transcript. Both live in the agent's own SQLite file, nowhere else, and compose freely. The full story, including what the model sees in its system prompt and how it's audited, is in [Memory](memory.md). ## Limits @@ -115,6 +116,7 @@ The `env:` block grants environment variables to tools and MCP servers — and o - **`skills:`** — markdown files that teach the agent how to use something well; capability stays with the config. → [Skills](skills.md) - **`tools:`** — capability beyond the natives: MCP servers, and tool search to keep their schemas out of context. → [Tools](tools.md) - **`permissions:`** — deny-by-default allowlists for hosts, executables, and paths. Omit the block and the agent can touch nothing. → [Permissions](permissions.md) +- **`memory:`** — conversation history (`scope`) and facts that survive across conversations and restarts (`persistent`). → [Memory](memory.md) ## Validating diff --git a/docs/docker-run.md b/docs/docker-run.md index ec69f13..5b1f12c 100644 --- a/docs/docker-run.md +++ b/docs/docker-run.md @@ -82,8 +82,9 @@ curl -s 127.0.0.1:55031/healthz | jq # the addr af ps printed Each agent owns one SQLite file: `/data/.db` in the container, or wherever `AF_DATA_DIR` points (locally it defaults to `.looped/`). It holds: - **sessions/messages** - the conversation history per conversation key (when `memory.scope: thread`) +- **memories** - facts the agent chose to remember, keyed by name, visible across every conversation key (when `memory.persistent: true`) — see [Memory](memory.md) - **runs** - every run, with its trigger, input, status, steps, tokens and timestamps -- **audit** - every permission decision, allowed and denied +- **audit** - every permission decision, allowed and denied, plus every memory write and delete - **identity** - the name the agent chose on first boot This means the agent's full history sits in one file you can query: everything the agent did, including the actions its permissions denied. Persist the volume; with a fresh one the agent starts over and names itself again. diff --git a/docs/memory.md b/docs/memory.md new file mode 100644 index 0000000..cc2ba19 --- /dev/null +++ b/docs/memory.md @@ -0,0 +1,67 @@ +--- +title: "Memory" +description: "Two independent kinds of memory: thread history that replays a conversation, and persistent memory that survives across conversations and restarts." +--- + +The `memory:` block controls two independent things: whether a conversation's history replays on the next message in that thread, and whether the agent can save facts that outlive any single conversation. + +```yaml +memory: + scope: thread # default: none + persistent: true # default: false +``` + +Both default off. An agent with no `memory:` block starts every run with a blank slate — no history, no remembered facts — which is the right choice for a stateless webhook handler that shouldn't accumulate anything between calls. + +## Thread history: `scope` + +`scope: thread` persists the message transcript per *conversation key* — the chat channel or thread (Discord, Slack, Telegram), the webhook caller's `conversation_id`, or the REPL session. On the next event in the same conversation, the full prior transcript loads back in, so follow-ups work ("make it weekly instead" refers to what was just discussed). `scope: none` (the default) starts every run fresh, even within what a human would call the same conversation. + +This is the entire transcript, replayed verbatim — expensive in context, but complete. It answers "what did we just say to each other," not "what do you know about me." + +## Persistent memory: `persistent` + +`persistent: true` gives the agent four tools, backed by its own SQLite file: + +| Tool | Effect | +| --- | --- | +| `remember` | Save or update a fact under a key. Overwrites any existing value for that key. | +| `recall` | Read one fact back by key. | +| `list_memories` | List every key and value currently held. | +| `forget` | Delete a fact by key. | + +Unlike thread history, these facts are keyed by nothing but the agent itself — they're visible from *every* conversation key, and they survive a run that starts with `scope: none`. This is where an agent puts a user's stated preference ("always deploy to us-east"), a fact it was told once and shouldn't need repeating ("the on-call rotation is in #incidents"), or a note to its future self about long-running work ("waiting on PR #204 to merge before continuing the migration"). Thread history can't do this: it's scoped to one conversation key, and it's a full transcript rather than a distilled fact. + +The agent decides what's worth remembering — there's no automatic extraction from the conversation. A `purpose` that expects the agent to retain user preferences should say so explicitly, the same way it would spell out any other expected behavior. + +### What the model sees + +Reading every remembered fact into every system prompt would burn context as memories accumulate, so persistent memory follows the same progressive-disclosure shape as [skills](skills.md#progressive-disclosure): the system prompt carries only the *keys* currently held — + +``` +You have persistent memory — facts and preferences that survive across +conversations and restarts. Use recall to read one, list_memories to browse, +remember to save or update one, forget to delete one. Keys you already have: +- deploy_region +- oncall_channel +``` + +— and the agent spends a `recall` or `list_memories` call to pull the value into context only when a turn actually needs it. An agent with fifty memories costs fifty short lines until it reads one. + +### Where it lives, and its boundaries + +Memories live in the `memories` table of the agent's own SQLite file, alongside sessions, runs, audit and identity — the same file described in [Persistence: the data volume](docker-run.md#persistence-the-data-volume). This keeps memory agent-local, consistent with one agent doing one job: there is no mechanism for one agent to read another's memories, and a fresh data volume clears memory exactly the way it clears identity and history. + +Every `remember` and `forget` call lands in the [audit trail](docker-run.md#persistence-the-data-volume) as a `memory` event (`{ action: "remember" | "forget", key }`), visible at `GET /audit` alongside permission decisions. `recall` and `list_memories` are read-only and aren't audited, the same way an allowed `read_file` isn't. + +## Combining both + +`scope` and `persistent` compose freely — they answer different questions: + +```yaml +memory: + scope: thread # replay this conversation's transcript + persistent: true # and carry facts across every conversation +``` + +A support-bot agent might run `scope: thread` alone (each ticket thread needs its own context, nothing more), while a personal-assistant agent typically wants both: thread history for the back-and-forth of the current request, persistent memory for "she prefers window seats" to still be true next month, in a different channel. diff --git a/docs/meta.json b/docs/meta.json index 8687c11..c319298 100644 --- a/docs/meta.json +++ b/docs/meta.json @@ -21,6 +21,7 @@ "skills", "tools", "permissions", + "memory", "---Models---", "models" ] diff --git a/packages/core/config/schema.ts b/packages/core/config/schema.ts index c260599..4e42287 100644 --- a/packages/core/config/schema.ts +++ b/packages/core/config/schema.ts @@ -161,6 +161,10 @@ const MemoryConfigSchema = z.strictObject({ scope: z.enum(["thread", "none"]).default("none").describe( "thread: conversation history persists per conversation key (chat channel or thread, webhook conversation_id). none: every run starts fresh.", ), + persistent: z.boolean().default(false).describe( + "Give the agent remember/recall/list_memories/forget tools backed by its own SQLite file — " + + "facts and preferences that survive across conversation keys and restarts, not just one thread's history.", + ), }).describe("What the agent remembers between events."); const LimitsSchema = z.strictObject({ @@ -352,6 +356,8 @@ export interface Permissions { export interface MemoryConfig { /** thread: conversation history persists per conversation key. none: every run starts fresh. */ scope: "thread" | "none"; + /** Give the agent remember/recall/list_memories/forget tools, backed by its own SQLite file. */ + persistent: boolean; } /** Per-run budgets. */ diff --git a/packages/core/runtime/service.ts b/packages/core/runtime/service.ts index 3bbf21b..db43696 100644 --- a/packages/core/runtime/service.ts +++ b/packages/core/runtime/service.ts @@ -12,6 +12,7 @@ import { runAgent, type RunResult } from "../loop/loop.ts"; import { Store } from "../store/store.ts"; import { type AgentIdentity, ensureIdentity, identityNote } from "./identity.ts"; import { createSkillTool, loadSkills, type Skill, skillsPromptSection } from "../skills/skills.ts"; +import { createMemoryTools, type MemoryEvent, memoryPromptSection } from "../tools/memory.ts"; import { connectMcpServers, type McpConnections } from "../tools/mcp.ts"; import { SEARCH_AUTO_THRESHOLD, ToolRegistry } from "../tools/registry.ts"; @@ -88,9 +89,15 @@ export class AgentService { * Tools follow the permissions (minimalism: a tool the agent can't use * doesn't exist for it — no dead schemas burning a small model's context). */ - #buildTools(engine: PermissionEngine): () => NativeTool[] { + #buildTools( + engine: PermissionEngine, + onMemoryEvent: (event: MemoryEvent) => void, + ): () => NativeTool[] { const always: NativeTool[] = [currentTimeTool, ...this.#extraTools]; if (this.#skills?.length) always.push(createSkillTool(this.#skills)); + if (this.config.memory?.persistent) { + always.push(...createMemoryTools(this.store, onMemoryEvent)); + } if (this.config.permissions?.run?.length) { always.push(createRunBashTool({ permissions: engine, env: this.#env })); } @@ -139,20 +146,23 @@ export class AgentService { const engine = new PermissionEngine(this.config.permissions, (e) => { decisions.push(e.decision); }); + const memoryEvents: MemoryEvent[] = []; const useSessions = this.config.memory?.scope === "thread" && event.conversationKey; const sessionId = useSessions ? this.store.sessionFor(event.conversationKey!) : undefined; const history = sessionId !== undefined ? this.store.loadMessages(sessionId) : []; + const memories = this.config.memory?.persistent ? this.store.listMemories() : []; const result = await runAgent({ config: { ...this.config, purpose: this.config.purpose + skillsPromptSection(this.#skills ?? []) + + memoryPromptSection(memories) + identityNote(this.config, identity.name), }, provider: this.#provider, - tools: this.#buildTools(engine), + tools: this.#buildTools(engine, (e) => memoryEvents.push(e)), input: event.input, history, }); @@ -171,6 +181,9 @@ export class AgentService { for (const decision of decisions) { this.store.recordAudit({ runId, kind: "permission", detail: decision }); } + for (const memoryEvent of memoryEvents) { + this.store.recordAudit({ runId, kind: "memory", detail: memoryEvent }); + } return result; } diff --git a/packages/core/store/store.ts b/packages/core/store/store.ts index 3a9e242..f3ffa7d 100644 --- a/packages/core/store/store.ts +++ b/packages/core/store/store.ts @@ -40,8 +40,26 @@ CREATE TABLE IF NOT EXISTS identity ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS memories ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); `; +/** One remembered fact, as written to the memories table. */ +export interface MemoryRecord { + /** The key the agent chose to file this fact under. */ + key: string; + /** The fact itself. */ + value: string; + /** ISO timestamp of first write. */ + createdAt: string; + /** ISO timestamp of the most recent write. */ + updatedAt: string; +} + /** One completed run, as written to the runs table. */ export interface RunRecord { /** Session the run belonged to; absent for sessionless runs. */ @@ -186,4 +204,48 @@ export class Store { ) .run(key, value); } + + /** + * Persist a memory, overwriting any previous value under the same key. + * This is the agent's cross-conversation, cross-restart memory (M37) — + * distinct from thread history, which lives in sessions/messages. + */ + rememberMemory(key: string, value: string) { + this.#db + .prepare( + `INSERT INTO memories (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`, + ) + .run(key, value); + } + + /** Recall one memory by key. */ + recallMemory(key: string): MemoryRecord | undefined { + const row = this.#db + .prepare("SELECT key, value, created_at, updated_at FROM memories WHERE key = ?") + .get(key) as + | { key: string; value: string; created_at: string; updated_at: string } + | undefined; + if (!row) return undefined; + return { key: row.key, value: row.value, createdAt: row.created_at, updatedAt: row.updated_at }; + } + + /** All memories, most recently updated first. */ + listMemories(): MemoryRecord[] { + const rows = this.#db + .prepare("SELECT key, value, created_at, updated_at FROM memories ORDER BY updated_at DESC") + .all() as { key: string; value: string; created_at: string; updated_at: string }[]; + return rows.map((r) => ({ + key: r.key, + value: r.value, + createdAt: r.created_at, + updatedAt: r.updated_at, + })); + } + + /** Delete a memory; returns true if a row was removed. */ + forgetMemory(key: string): boolean { + const result = this.#db.prepare("DELETE FROM memories WHERE key = ?").run(key); + return result.changes > 0; + } } diff --git a/packages/core/store/store_test.ts b/packages/core/store/store_test.ts index f5c89c1..8669b8e 100644 --- a/packages/core/store/store_test.ts +++ b/packages/core/store/store_test.ts @@ -65,3 +65,18 @@ Deno.test("identity persists (the agent's self-chosen name lives here)", () => { assertEquals(store.getIdentity("name"), "Ada II"); store.close(); }); + +Deno.test("memories persist across keys, independent of session history", () => { + const store = tempStore(); + assertEquals(store.recallMemory("favorite_color"), undefined); + store.rememberMemory("favorite_color", "blue"); + assertEquals(store.recallMemory("favorite_color")?.value, "blue"); + store.rememberMemory("favorite_color", "green"); + assertEquals(store.recallMemory("favorite_color")?.value, "green"); + store.rememberMemory("timezone", "UTC"); + assertEquals(store.listMemories().length, 2); + assert(store.forgetMemory("timezone")); + assertEquals(store.forgetMemory("timezone"), false); + assertEquals(store.listMemories().length, 1); + store.close(); +}); diff --git a/packages/core/tools/memory.ts b/packages/core/tools/memory.ts new file mode 100644 index 0000000..1f468c4 --- /dev/null +++ b/packages/core/tools/memory.ts @@ -0,0 +1,92 @@ +import { z } from "zod"; +import type { MemoryRecord, Store } from "../store/store.ts"; +import { defineTool, type NativeTool } from "./types.ts"; + +const MAX_LIST_CHARS = 4_000; + +/** + * Progressive disclosure, mirroring skills: the system prompt carries a + * cheap index of what the agent already remembers, so a small model knows + * to check before asking the operator to repeat themselves. + */ +export function memoryPromptSection(memories: MemoryRecord[]): string { + if (!memories.length) return ""; + const lines = memories.map((m) => `- ${m.key}`).join("\n"); + return `\n\nYou have persistent memory — facts and preferences that survive across ` + + `conversations and restarts. Use recall to read one, list_memories to browse, remember ` + + `to save or update one, forget to delete one. Keys you already have:\n${lines}`; +} + +/** A memory write or delete, for the audit trail. */ +export interface MemoryEvent { + /** Which tool caused the event. */ + action: "remember" | "forget"; + /** The key affected. */ + key: string; +} + +/** + * Build the remember/recall/list_memories/forget native tools over an + * agent's own store. `onEvent`, when given, fires on every write/delete so + * the caller can land it in the audit trail (writes only — recall and + * list_memories are read-only and don't need auditing). + */ +export function createMemoryTools( + store: Store, + onEvent?: (event: MemoryEvent) => void, +): NativeTool[] { + const remember = defineTool({ + name: "remember", + description: + "Save or update a fact under a key, persisted across conversations and restarts. " + + "Overwrites any existing value for the same key.", + schema: z.strictObject({ key: z.string().min(1), value: z.string().min(1) }), + readOnly: false, + execute({ key, value }) { + store.rememberMemory(key, value); + onEvent?.({ action: "remember", key }); + return `remembered ${key}`; + }, + }); + + const recall = defineTool({ + name: "recall", + description: "Read a previously remembered fact by key.", + schema: z.strictObject({ key: z.string().min(1) }), + readOnly: true, + execute({ key }) { + const memory = store.recallMemory(key); + if (!memory) return `no memory under key: ${key}`; + return memory.value; + }, + }); + + const listMemories = defineTool({ + name: "list_memories", + description: "List all remembered keys and values.", + schema: z.strictObject({}), + readOnly: true, + execute() { + const memories = store.listMemories(); + if (!memories.length) return "no memories yet"; + const text = memories.map((m) => `${m.key}: ${m.value}`).join("\n"); + return text.length > MAX_LIST_CHARS + ? text.slice(0, MAX_LIST_CHARS) + `\n[truncated at ${MAX_LIST_CHARS} chars]` + : text; + }, + }); + + const forget = defineTool({ + name: "forget", + description: "Delete a remembered fact by key.", + schema: z.strictObject({ key: z.string().min(1) }), + readOnly: false, + execute({ key }) { + const removed = store.forgetMemory(key); + if (removed) onEvent?.({ action: "forget", key }); + return removed ? `forgot ${key}` : `no memory under key: ${key}`; + }, + }); + + return [remember, recall, listMemories, forget]; +} diff --git a/packages/core/tools/memory_test.ts b/packages/core/tools/memory_test.ts new file mode 100644 index 0000000..525de64 --- /dev/null +++ b/packages/core/tools/memory_test.ts @@ -0,0 +1,64 @@ +import { assert, assertEquals } from "@std/assert"; +import { Store } from "../store/store.ts"; +import { createMemoryTools, type MemoryEvent, memoryPromptSection } from "./memory.ts"; + +function tools(onEvent?: (event: MemoryEvent) => void) { + const store = new Store(":memory:"); + const [remember, recall, listMemories, forget] = createMemoryTools(store, onEvent); + return { store, remember, recall, listMemories, forget }; +} + +Deno.test("remember/recall/forget round-trip", async () => { + const { remember, recall, forget } = tools(); + assertEquals( + await recall.execute(JSON.stringify({ key: "color" })), + "no memory under key: color", + ); + assertEquals( + await remember.execute(JSON.stringify({ key: "color", value: "blue" })), + "remembered color", + ); + assertEquals(await recall.execute(JSON.stringify({ key: "color" })), "blue"); + assertEquals( + await remember.execute(JSON.stringify({ key: "color", value: "green" })), + "remembered color", + ); + assertEquals(await recall.execute(JSON.stringify({ key: "color" })), "green"); + assertEquals(await forget.execute(JSON.stringify({ key: "color" })), "forgot color"); + assertEquals( + await recall.execute(JSON.stringify({ key: "color" })), + "no memory under key: color", + ); +}); + +Deno.test("list_memories shows everything remembered", async () => { + const { remember, listMemories } = tools(); + assertEquals(await listMemories.execute("{}"), "no memories yet"); + await remember.execute(JSON.stringify({ key: "color", value: "blue" })); + await remember.execute(JSON.stringify({ key: "timezone", value: "UTC" })); + const result = await listMemories.execute("{}"); + assert(result.includes("color: blue")); + assert(result.includes("timezone: UTC")); +}); + +Deno.test("writes and deletes fire onEvent for the audit trail; reads don't", async () => { + const events: MemoryEvent[] = []; + const { remember, recall, forget } = tools((e) => events.push(e)); + await remember.execute(JSON.stringify({ key: "color", value: "blue" })); + await recall.execute(JSON.stringify({ key: "color" })); + await forget.execute(JSON.stringify({ key: "color" })); + await forget.execute(JSON.stringify({ key: "color" })); // no-op: nothing to forget + assertEquals(events, [ + { action: "remember", key: "color" }, + { action: "forget", key: "color" }, + ]); +}); + +Deno.test("memoryPromptSection lists keys cheaply, empty when there's nothing yet", () => { + assertEquals(memoryPromptSection([]), ""); + const section = memoryPromptSection([ + { key: "color", value: "blue", createdAt: "now", updatedAt: "now" }, + ]); + assert(section.includes("- color")); + assert(!section.includes("blue")); // values stay out of the prompt; recall fetches them +}); diff --git a/schema/agent.json b/schema/agent.json index aafd9b1..5641b9b 100644 --- a/schema/agent.json +++ b/schema/agent.json @@ -431,6 +431,11 @@ "thread", "none" ] + }, + "persistent": { + "default": false, + "description": "Give the agent remember/recall/list_memories/forget tools backed by its own SQLite file — facts and preferences that survive across conversation keys and restarts, not just one thread's history.", + "type": "boolean" } }, "additionalProperties": false,