mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
- 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>    </details> --- Generated with Coder Agents on behalf of @f0ssel. --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
178 lines
6.7 KiB
Go
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 ""
|
|
}
|