Skip to content

Remember the caller between calls - #4

Merged
coderloganli merged 2 commits into
mainfrom
add-agent-memory
Aug 19, 2026
Merged

Remember the caller between calls#4
coderloganli merged 2 commits into
mainfrom
add-agent-memory

Conversation

@coderloganli

Copy link
Copy Markdown
Owner

The agent forgot everything on hanging up. Conversation history is the last six
turns of the current session, so a companion met the same person new every
time. This adds semantic long-term memory only: what is true about the
caller, surviving the call.

What it does

A bounded set of facts about the caller — a category and one natural-language
sentence — injected whole into the prompt as one system message, and never
searched.

system: conversation_system / character / scene   ← existing
system: what you already know about this person   ← new
[the recent six turns]                            ← existing
user:   this utterance

Facts are written by an extraction that runs every N turns on a spawned task the
turn never waits for. It reads the recent turns and the current set, asks the
model for a replacement set, and reconciles the difference in one transaction.

The three decisions

  • ADR-0021 — an
    extracted fact set, injected whole; not retrieval. Retrieval runs inside the
    turn against a two-second budget, and it fires on similarity when what a
    companion must know — their name, their cat, what was promised — is
    unconditional. It also retrieves transcripts, recognition errors included,
    rather than conclusions.
  • ADR-0022
    extraction every N turns on a spawned task. Not at the end of the call,
    because callers hang up and that end is not reliably observed.
  • ADR-0023 — keyed
    on (user_id, character_id). A persona referring to something it was never
    told reads as broken, which is worse than forgetting.

Cost on the turn path

One indexed local SELECT, no network call. A read that fails is logged and the
turn proceeds without it: memory failing makes the agent forgetful, never makes a
call fail.

Privacy

A uid identifies but does not authenticate, and this now keeps notes about a
person. GET /api/memory shows a caller everything held about them across
personas; DELETE /api/memory forgets it, optionally narrowed to one persona
with ?character_id=. Both behind the existing token. docs/product.md §4 says
plainly that the notes exist, that a model writes them, and that they reach the
model provider on every call.

Off unless configured: sonari.toml.example switches it on so a clean clone
demonstrates the feature, while an absent [memory] section leaves it off, so a
configuration file written before this does not silently start extracting.

Tests

26 cases, listed in the task document and implemented one for one: injection and
its placement, that a broken store cannot fail a turn, persona isolation,
scheduling on the Nth turn and not the others, parsing and both caps and
deduplication, and that every way extraction can fail leaves the stored set
alone. The acceptance case drives a real turn, lets the scheduler spawn, and
asserts the fact reaches a second session's prompt. The reconcile SQL is tested
against a live database and skips itself without DATABASE_DSN.

CI previously ran only cargo test -p harness, so none of this would have gated
anything; it now runs cargo test -p agent -p api as well.

./scripts/dev.sh cargo test --workspace   → 56 binaries, all ok
clippy --workspace --all-targets -D warnings → clean

Known costs, all recorded

  • Rewriting the whole set is lossy — the model can drop a fact it should keep.
    The raw turns stay in llm_messages, the set is small enough to read, and
    GET /api/memory makes the loss visible.
  • A fact said in the last turns before a hang-up can be missed.
  • Two simultaneous calls by one caller to one persona are last-writer-wins.

Not in this change

Short-term memory (the six-turn window, rolling summaries), episodic memory
("last time we talked about X"), and vector retrieval. pgvector stays unused;
the image already allows it when episodic memory arrives.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NgGCfYp1iBEpD8iL4Dg5XQ

coderloganli and others added 2 commits August 17, 2026 21:50
The agent forgot everything on hanging up: history was the last six turns
of the current session, so a companion met the same person new each time.

Long-term semantic memory only. A bounded set of natural-language facts
about the caller — a category and one sentence — injected whole into the
prompt and never searched. Retrieval was the obvious answer and does not
fit: it runs inside the turn against a two-second budget, and it fires on
similarity when what a companion must know is unconditional. Reasoning in
ADR-0021.

Reading costs one indexed local query on the turn path and no network
call. Writing is a model call every N turns on a spawned task the turn
never waits for (ADR-0022); a failure there leaves the stored set alone
and the call unaffected. Facts are keyed on (user_id, character_id), so
what one persona was told another does not know (ADR-0023).

A caller can see what is held about them and delete it, at
GET and DELETE /api/memory. A uid identifies but does not authenticate,
so notes kept about a person have to be visible to them.

Off unless configured. The [memory] section switches it on and bounds it;
an absent section leaves it off, so a configuration written before this
does not silently start sending notes to the model provider.

CI now runs the agent and api suites, which is what gates any of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgGCfYp1iBEpD8iL4Dg5XQ
An ADR records the decision that was taken. Memory has more rejected
options than taken ones — whether to retrieve or inject whole, rewrite or
patch, close the category list or leave it open — and none of that was
written down anywhere a person could find it.

docs/memory.md carries both: what the four kinds of memory are and which
exist here, how semantic memory is stored, read and written, and then
thirteen decision points with the options and what each costs. The
assumptions nobody has measured are listed as such, including the one
that would overturn a structural decision.

Linked from docs/README.md, architecture.md and the retrieval table in
CLAUDE.md, so it is reachable rather than merely present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015SH5nghQRqj6797aj4FTVk
@coderloganli
coderloganli merged commit b9e31f2 into main Aug 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant