Files
coder/coderd/x/chatd/memory.go
T
Garrett DelfosseandClaude Opus 5.5 a08ba6a70c feat: add shared project memory API for chats in a project (#29301)
- Experiment, stacked on #29242. UI is in #29583.
- Adds **project memory**: `chat_project_memories` rows with a name
(lowercase slug, unique per project), a one-line description (150
characters) and a body (8KB). Up to 200 per project, shared by every
root chat in the project. Chats outside a project and subagents have no
memory.
- Memories are immutable: they are created and deleted, never edited.
Agent tools are `read_memory`, `save_memory`, `delete_memory` and
`consolidate_memory`. Saving an existing name fails and points the agent
at `consolidate_memory` to replace it.
- **Consolidation is agent-driven, in the turn**, modeled on how Claude
Code compacts its memory index when warned it is near its limit. From
160 memories (80%), save results ask the agent to get under 140 (70%)
with `consolidate_memory`, choosing what to merge and drop. That tool
deletes and saves memories in one transaction under the per-project
advisory lock, and rolls back entirely if a delete is missing, a name is
taken, or the result exceeds 200. There is no background extraction or
consolidation.
- **The memory index reaches the model as model-only conversation
messages**, not in a tool description or the system prompt. At turn
start the chat gets a full `<project-memory-index>` snapshot when its
prompt has none (first turn, or after compaction), otherwise a
`<project-memory-index-update>` listing only what changed. Tool
definitions and the system prompt carry no memory state, so memory
writes never invalidate the provider's cached prompt prefix. Writes by
other chats, or by this chat mid-turn, reach the model at its next turn.
- REST API (create, list, get, delete) under
`/api/experimental/organizations/{organization}/chats/projects/{project}/memories`.
Memory permissions follow projects onto the `agents-access` role.
- **Every memory change is audited** as `ChatProjectMemory` create or
delete. REST changes are attributed to the caller. Tool changes
(`save_memory`, `delete_memory`, `consolidate_memory`) are attributed to
the chat owner, carry the `chat_id`, and record one entry per memory
after the change commits, so a rolled-back consolidation records
nothing.

### Examples

**What the model sees.** The system prompt gets a fixed guidance block
with no memory state:

```text
<memory>
Memory is durable context shared by every chat in the project "platform".
Save facts that will matter in future chats: … The memory index is in the conversation: a <project-memory-index> message lists every saved memory, and <project-memory-index-update> messages at the start of later turns list what changed. Changes made during a turn, including your own, appear in the next turn's update. … Memories cannot be edited: replace one that no longer holds with consolidate_memory, or delete it.
</memory>
```

On the first turn, after the user's message, the chat gets a model-only
row with the full index (hidden in the UI):

```text
<project-memory-index>
- deploy-day: Deploys happen on Tuesdays
- release-owner: Alice owns releases; ping her before tagging
</project-memory-index>
```

On a later turn where memories changed, it gets only the difference:

```text
<project-memory-index-update>
Added or changed:
- deploy-day: Deploys happen on Wednesdays (changed 2026-09-30)
- staging-url: Staging lives at staging.example.com
Removed:
- old-ci-notes
</project-memory-index-update>
```

Resulting request layout, with the provider's cached prefix unchanged by
memory writes:

```text
[tools: fixed]  [system: fixed <memory> guidance]  [turn 1 user] [index snapshot] [assistant/tools …]  [turn 2 user] [index update] [assistant …]
```

**Tool calls and results** (produced by the tools in this PR):

```text
save_memory {"name":"staging-url","description":"Staging lives at staging.example.com","body":"Staging is https://staging.example.com; deploys from main."}
→ {"count":3,"saved":"staging-url"}

save_memory {"name":"deploy-day", …}   // name already exists
→ error: a memory named "deploy-day" already exists; read it, then replace it with consolidate_memory by deleting "deploy-day" and saving the new version in one call

consolidate_memory {"delete":["deploy-day"],"save":[{"name":"deploy-day","description":"Deploys happen on Wednesdays (changed 2026-09-30)","body":"The team deploys every Wednesday since 2026-09-30."}]}
→ {"count":3,"deleted":["deploy-day"],"saved":["deploy-day"]}

read_memory {"name":"deploy-day"}
→ {"name":"deploy-day","description":"…","body":"…","created_at":"2026-09-30T15:04:05Z","created_by":"alice"}
```

Near and at the cap:

```text
save_memory … → {"count":160,"saved":"one-more","warning":"memory is 160/200, approaching the limit. Consolidate it to under 140 memories now with consolidate_memory: merge overlapping memories into one, and drop stale, wrong, or superseded ones."}

save_memory … → error: memory is full (200/200); use consolidate_memory to merge overlapping memories and drop stale ones, and include this memory in its save list
```

**REST memory object** (`GET .../chats/projects/{project}/memories`):

```json
{
  "id": "59500000-0000-4000-8000-000000000001",
  "project_id": "7c0e4a3e-5b1f-4f7d-9a51-2f3c8f6d2b10",
  "organization_id": "bb640d07-ca8a-4869-b6bc-ae61ebb2fda1",
  "name": "deploy-day",
  "description": "Deploys happen on Wednesdays (changed 2026-09-30)",
  "body": "The team deploys every Wednesday since 2026-09-30.",
  "created_by": "0ed9befc-4911-4ccf-a8e2-559bf72daa94",
  "created_by_username": "alice",
  "created_at": "2026-09-30T15:04:05Z"
}
```

Create takes `{"name", "description", "body"}` and answers 409 on a
duplicate name or at the cap.

<details>
<summary>Screenshots (predate the immutable-memory rework; tool output
shape has since changed)</summary>


![save_memory](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr2-01-chat-saves-memory-raw.png)

![read_memory](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr2-02-chat-reads-memory-raw.png)
![Agent reads every
memory](https://raw.githubusercontent.com/coder/coder/f0ssel/chat-projects-assets/v3/pr2-agent-lists-memories-raw.png)

</details>

---
Generated with Coder Agents on behalf of @f0ssel.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 12:52:10 -04:00

178 lines
6.7 KiB
Go

package chatd
import (
"context"
"encoding/json"
"net/http"
"github.com/google/uuid"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
)
// resolveProjectMemory returns the durable-memory store and project name for
// a chat. Only root chats inside a project have memory; chats outside a
// project, subagents, the disabled experiment, and transient lookup failures
// all report ok=false.
func (p *Server) resolveProjectMemory(ctx context.Context, chat database.Chat) (store chattool.MemoryStore, projectName string, ok bool) {
if !p.experiments.Enabled(codersdk.ExperimentChatProjects) {
return nil, "", false
}
if chat.ParentChatID.Valid || !chat.ProjectID.Valid {
return nil, "", false
}
project, err := p.db.GetChatProjectByID(ctx, chat.ProjectID.UUID)
if err != nil {
p.logger.Debug(ctx, "failed to load chat project for memory", slog.F("chat_id", chat.ID), slog.Error(err))
return nil, "", false
}
return chattool.NewProjectMemoryStore(p.db, chat.ProjectID.UUID, chat.OrganizationID, chat.OwnerID, p.memoryAuditor(chat)), project.Name, true
}
// memoryAuditor records memory changes made by a chat's tools. There is no
// HTTP request, so entries are attributed to the chat owner, whose
// permissions the change ran with, and name the chat that made it.
func (p *Server) memoryAuditor(chat database.Chat) chattool.MemoryAuditFunc {
return func(ctx context.Context, action database.AuditAction, oldMemory, newMemory database.ChatProjectMemory) {
if p.chatWorker == nil || p.chatWorker.opts.Auditor == nil {
return
}
auditor := p.chatWorker.opts.Auditor.Load()
if auditor == nil {
return
}
// Marshaling a map of strings cannot fail.
raw, _ := json.Marshal(map[string]string{"chat_id": chat.ID.String()})
status := http.StatusCreated
if action == database.AuditActionDelete {
status = http.StatusNoContent
}
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.ChatProjectMemory]{
Audit: *auditor,
Log: p.logger.With(slog.F("chat_id", chat.ID)),
UserID: chat.OwnerID,
OrganizationID: chat.OrganizationID,
Action: action,
Old: oldMemory,
New: newMemory,
Status: status,
AdditionalFields: raw,
})
}
}
// memoryIndexMessage returns the model-only message that brings the chat's
// view of the project memory index up to date, or ok=false when none is
// due. The index lives in conversation history rather than in a tool
// description or the system prompt, because providers cache the request as
// one prefix of tools, system prompt, then messages: a changing index there
// would rewrite the whole cache for every chat in the project on each memory
// write. Appended messages leave the cached prefix intact.
//
// A full snapshot is sent when the prompt has none, which covers a chat's
// first turn and compaction, which drops the earlier snapshot. Changes since
// the model last saw the index go out only at turn start, so other chats'
// writes reach the model on its next turn and never mid-turn. Nothing is sent
// while the latest assistant step has tool calls without results, which must
// follow it directly.
func (p *Server) memoryIndexMessage(ctx context.Context, chat database.Chat) (chatstate.Message, bool, error) {
store, _, ok := p.resolveProjectMemory(ctx, chat)
if !ok {
return chatstate.Message{}, false, nil
}
// Prompt rows include the model-only rows this sends, and exclude rows
// compaction has replaced.
promptRows, err := p.db.GetChatMessagesForPromptByChatID(ctx, chat.ID)
if err != nil {
return chatstate.Message{}, false, xerrors.Errorf("load prompt messages: %w", err)
}
turnStart := currentTurnStepCount(promptRows) == 0
if !turnStart {
if lastActiveMessageRole(promptRows) == database.ChatMessageRoleAssistant {
return chatstate.Message{}, false, nil
}
// A step can commit some tool results while others, such as
// client-executed dynamic tools, are still pending. Providers reject a
// message between a tool call and its result, so wait until every call
// is resolved.
localCalls, dynamicCalls, _, err := unresolvedToolCallsFromHistory(promptRows, dynamicToolNamesFromChat(chat))
if err != nil {
return chatstate.Message{}, false, xerrors.Errorf("find unresolved tool calls: %w", err)
}
if len(localCalls) > 0 || len(dynamicCalls) > 0 {
return chatstate.Message{}, false, nil
}
}
messages := promptRows
snapshotOnly := !turnStart
current, err := store.List(ctx)
if err != nil {
return chatstate.Message{}, false, xerrors.Errorf("list project memories: %w", err)
}
seen, hasSnapshot := chattool.ReplayMemoryIndex(memoryIndexTexts(messages))
var text string
switch {
case !hasSnapshot && len(current) == 0:
return chatstate.Message{}, false, nil
case !hasSnapshot:
text = chattool.FormatMemoryIndexSnapshot(current)
case snapshotOnly:
return chatstate.Message{}, false, nil
default:
changed, removed := chattool.DiffMemoryIndex(seen, current)
if len(changed) == 0 && len(removed) == 0 {
return chatstate.Message{}, false, nil
}
text = chattool.FormatMemoryIndexUpdate(changed, removed)
}
content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{codersdk.ChatMessageText(text)})
if err != nil {
return chatstate.Message{}, false, xerrors.Errorf("marshal memory index: %w", err)
}
return chatstate.Message{
Role: database.ChatMessageRoleUser,
Content: content,
Visibility: database.ChatMessageVisibilityModel,
ModelConfigID: uuid.NullUUID{UUID: chat.LastModelConfigID, Valid: chat.LastModelConfigID != uuid.Nil},
ContentVersion: chatprompt.CurrentContentVersion,
}, true, nil
}
// memoryIndexTexts returns the text of active model-only user rows. Users
// cannot author model-only rows, so index text cannot be forged from chat
// input.
func memoryIndexTexts(messages []database.ChatMessage) []string {
var texts []string
for _, msg := range messages {
if msg.Deleted || msg.Compressed || msg.Role != database.ChatMessageRoleUser || msg.Visibility != database.ChatMessageVisibilityModel {
continue
}
parts, err := chatprompt.ParseContent(msg)
if err != nil {
continue
}
for _, part := range parts {
if part.Type == codersdk.ChatMessagePartTypeText {
texts = append(texts, part.Text)
}
}
}
return texts
}
func lastActiveMessageRole(messages []database.ChatMessage) database.ChatMessageRole {
for i := len(messages) - 1; i >= 0; i-- {
if !messages[i].Deleted && !messages[i].Compressed {
return messages[i].Role
}
}
return ""
}