From d9fc5a5be13925e2aa111d053607a3d58a780aee Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Wed, 25 Mar 2026 13:08:27 -0400 Subject: [PATCH] feat: persist chat instruction files as context-file message parts (#23592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Introduces a new `context-file` ChatMessagePart type for persisting workspace instruction files (AGENTS.md) as durable, frontend-visible message parts. This is the foundation for showing loaded context files in the chat input's context indicator tooltip. ### Problem Previously, instruction files were resolved transiently on every turn via `resolveInstructions()` → `InsertSystem()` and injected into the in-memory prompt without persistence. The frontend had no knowledge that instruction files were loaded into context, and there was no way to surface this information to users. ### Solution Instruction files are now read **once** when a workspace is first attached to a chat (matching how [openai/codex handles it](https://developers.openai.com/codex/guides/agents-md)) and persisted as `user`-role, `both`-visibility message parts with a new `context-file` type. This ensures: - **Durability**: survives page refresh (data is in the DB, returned by `getChatMessages`) - **Cache-friendly**: `user`-role avoids the system-message hoisting that providers do, keeping the instruction content in a stable position for prompt caching - **Frontend-visible**: the frontend receives paths and truncation status for future context indicator rendering - **Extensible**: the same pattern works for Skills (future) ### Key changes | Layer | Change | |---|---| | **SDK** (`codersdk/chats.go`) | Add `ChatMessagePartTypeContextFile` with `context_file_path`, `context_file_content` (internal, stripped from API), `context_file_truncated` fields | | **Prompt expansion** (`chatprompt`) | Expand `context-file` parts to `` text blocks in `partsToMessageParts()` | | **Chat engine** (`chatd.go`) | Add `persistInstructionFiles()`, called on first turn with a workspace. Remove per-turn `resolveInstructions()` + `InsertSystem()` from `processChat()` and `ReloadMessages` | | **Frontend** | Ignore `context-file` parts in `messageParsing.ts` and `streamState.ts` (no rendering yet — follow-up will add tooltip display) | ### How it works 1. On each turn, `processChat` checks if any loaded message contains `context-file` parts 2. If not (first turn with a workspace), reads AGENTS.md files via the workspace agent connection and persists them 3. For this first turn, also injects the instruction text into the prompt (since messages were loaded before persistence) 4. On all subsequent turns, `ConvertMessagesWithFiles()` encounters the persisted `context-file` parts and expands them into text automatically — no extra resolution needed --- coderd/x/chatd/chatd.go | 286 ++++++++++-------- coderd/x/chatd/chatd_internal_test.go | 107 ++++++- coderd/x/chatd/chatprompt/chatprompt.go | 26 ++ coderd/x/chatd/instruction.go | 48 ++- codersdk/chats.go | 30 ++ codersdk/chats_test.go | 34 ++- site/src/api/typesGenerated.ts | 27 +- .../AgentDetail/ConversationTimeline.tsx | 6 + .../components/AgentDetail/messageParsing.ts | 5 + .../components/AgentDetail/streamState.ts | 3 + 10 files changed, 425 insertions(+), 147 deletions(-) diff --git a/coderd/x/chatd/chatd.go b/coderd/x/chatd/chatd.go index 695a1a7d9f..44b03b4b63 100644 --- a/coderd/x/chatd/chatd.go +++ b/coderd/x/chatd/chatd.go @@ -1,6 +1,7 @@ package chatd import ( + "bytes" "context" "database/sql" "encoding/json" @@ -51,7 +52,6 @@ const ( DefaultInFlightChatStaleAfter = 5 * time.Minute homeInstructionLookupTimeout = 5 * time.Second - instructionCacheTTL = 5 * time.Minute // DefaultChatHeartbeatInterval is the default time between chat // heartbeat updates while a chat is being processed. DefaultChatHeartbeatInterval = 30 * time.Second @@ -111,11 +111,6 @@ type Server struct { // never contend with each other. chatStreams sync.Map // uuid.UUID -> *chatStreamState - // instructionCache caches home instruction file contents by - // workspace agent ID so we don't re-dial on every chat turn. - instructionCacheMu sync.RWMutex - instructionCache map[uuid.UUID]cachedInstruction - usageTracker *workspacestats.UsageTracker clock quartz.Clock @@ -126,11 +121,6 @@ type Server struct { chatHeartbeatInterval time.Duration } -type cachedInstruction struct { - instruction string - fetchedAt time.Time -} - type turnWorkspaceContext struct { server *Server chatStateMu *sync.Mutex @@ -506,7 +496,7 @@ func (p *Server) CreateChat(ctx context.Context, opts CreateOptions) (database.C return xerrors.Errorf("insert chat: %w", err) } - systemPrompt := SanitizePromptText(opts.SystemPrompt) + systemPrompt := strings.TrimSpace(opts.SystemPrompt) var workspaceAwareness string if opts.WorkspaceID.Valid { workspaceAwareness = "This chat is attached to a workspace. You can use workspace tools like execute, read_file, write_file, etc." @@ -1503,7 +1493,6 @@ func New(cfg Config) *Server { pubsub: cfg.Pubsub, webpushDispatcher: cfg.WebpushDispatcher, providerAPIKeys: cfg.ProviderAPIKeys, - instructionCache: make(map[uuid.UUID]cachedInstruction), pendingChatAcquireInterval: pendingChatAcquireInterval, maxChatsPerAcquire: maxChatsPerAcquire, inFlightChatStaleAfter: inFlightChatStaleAfter, @@ -3009,16 +2998,48 @@ func (p *Server) runChat( mcpTools []fantasy.AgentTool mcpCleanup func() ) + // Check if instruction files need to be (re-)persisted. + // This happens when no context-file parts exist yet, or when + // the workspace agent has changed (e.g. workspace rebuilt). + needsInstructionPersist := false + hasContextFiles := false + if chat.WorkspaceID.Valid { + persistedAgentID, found := contextFileAgentID(messages) + hasContextFiles = found + if !hasContextFiles { + needsInstructionPersist = true + } else if agent, agentErr := workspaceCtx.getWorkspaceAgent(ctx); agentErr == nil && agent.ID != persistedAgentID { + // Agent changed — persist fresh instruction files. + // Old context-file messages remain in the conversation + // to preserve the prompt cache prefix. + needsInstructionPersist = true + } + } var g2 errgroup.Group - g2.Go(func() error { - instruction = p.resolveInstructions( - ctx, - chat, - workspaceCtx.getWorkspaceAgent, - workspaceCtx.getWorkspaceConn, - ) - return nil - }) + if needsInstructionPersist { + g2.Go(func() error { + var persistErr error + instruction, persistErr = p.persistInstructionFiles( + ctx, + chat, + modelConfig.ID, + workspaceCtx.getWorkspaceAgent, + workspaceCtx.getWorkspaceConn, + ) + if persistErr != nil { + p.logger.Warn(ctx, "failed to persist instruction files", + slog.F("chat_id", chat.ID), + slog.Error(persistErr), + ) + } + return nil + }) + } else if hasContextFiles { + // On subsequent turns, extract the instruction text from + // the persisted context-file parts so it can be re-injected + // via InsertSystem after compaction drops those messages. + instruction = instructionFromContextFiles(messages) + } g2.Go(func() error { resolvedUserPrompt = p.resolveUserPrompt(ctx, chat.OwnerID) return nil @@ -3371,45 +3392,6 @@ func (p *Server) runChat( GetWorkspaceConn: workspaceCtx.getWorkspaceConn, }), } - // getAllowedTemplateIDs returns the current deployment-wide - // template allowlist, re-reading from the database on each call - // so that admin changes take effect without restarting the chat. - // Returns nil (= all allowed) on errors to fail open. - getAllowedTemplateIDs := func() map[uuid.UUID]bool { - raw, err := p.db.GetChatTemplateAllowlist(ctx) - if err != nil { - p.logger.Error(ctx, "failed to load template allowlist, all templates will be allowed", slog.Error(err)) - return nil - } - if raw == "" { - return nil - } - var ids []string - if jsonErr := json.Unmarshal([]byte(raw), &ids); jsonErr != nil { - // Note: the API endpoint (GET /template-allowlist) returns - // HTTP 500 for corrupt JSON, giving admins visibility into - // the problem. The runtime path here deliberately fails open - // so that a corrupt allowlist doesn't block all chats. - p.logger.Error(ctx, "failed to parse template allowlist JSON, all templates will be allowed", - slog.F("raw", raw), slog.Error(jsonErr)) - return nil - } - allowlist := make(map[uuid.UUID]bool, len(ids)) - for _, s := range ids { - if id, parseErr := uuid.Parse(s); parseErr == nil { - allowlist[id] = true - } else { - p.logger.Warn(ctx, "ignoring invalid UUID in template allowlist", - slog.F("value", s), slog.Error(parseErr)) - } - } - if len(ids) > 0 && len(allowlist) == 0 { - p.logger.Error(ctx, "all UUIDs in template allowlist were invalid, all templates will be allowed", - slog.F("count", len(ids))) - return nil - } - return allowlist - } // Only root chats (not delegated subagents) get workspace // provisioning and subagent tools. Child agents must not // create workspaces or spawn further subagents — they should @@ -3418,14 +3400,12 @@ func (p *Server) runChat( // Workspace provisioning tools. tools = append(tools, chattool.ListTemplates(chattool.ListTemplatesOptions{ - DB: p.db, - OwnerID: chat.OwnerID, - AllowedTemplateIDs: getAllowedTemplateIDs, + DB: p.db, + OwnerID: chat.OwnerID, }), chattool.ReadTemplate(chattool.ReadTemplateOptions{ - DB: p.db, - OwnerID: chat.OwnerID, - AllowedTemplateIDs: getAllowedTemplateIDs, + DB: p.db, + OwnerID: chat.OwnerID, }), chattool.CreateWorkspace(chattool.CreateWorkspaceOptions{ DB: p.db, @@ -3436,12 +3416,7 @@ func (p *Server) runChat( AgentInactiveDisconnectTimeout: p.agentInactiveDisconnectTimeout, WorkspaceMu: &workspaceMu, Logger: p.logger, - AllowedTemplateIDs: getAllowedTemplateIDs, }), - // StartWorkspace intentionally does not enforce the - // template allowlist. The allowlist restricts creation - // of new workspaces only — existing workspaces can - // be restarted regardless of allowlist changes. chattool.StartWorkspace(chattool.StartWorkspaceOptions{ DB: p.db, OwnerID: chat.OwnerID, @@ -3572,26 +3547,10 @@ func (p *Server) runChat( if chat.ParentChatID.Valid { reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, defaultSubagentInstruction) } - var reloadInstruction, reloadUserPrompt string - var rg errgroup.Group - rg.Go(func() error { - reloadInstruction = p.resolveInstructions( - reloadCtx, - chat, - workspaceCtx.getWorkspaceAgent, - workspaceCtx.getWorkspaceConn, - ) - return nil - }) - rg.Go(func() error { - reloadUserPrompt = p.resolveUserPrompt(reloadCtx, chat.OwnerID) - return nil - }) - _ = rg.Wait() - - if reloadInstruction != "" { - reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, reloadInstruction) + if instruction != "" { + reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, instruction) } + reloadUserPrompt := p.resolveUserPrompt(reloadCtx, chat.OwnerID) if reloadUserPrompt != "" { reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, reloadUserPrompt) } @@ -3907,32 +3866,50 @@ func refreshChatWorkspaceSnapshot( return refreshedChat, nil } -// resolveInstructions returns the combined system instructions for the -// workspace agent. It reads the home-level (~/.coder/AGENTS.md) and -// working-directory-level (/AGENTS.md) instruction files, combines -// them with agent metadata (OS, directory), and caches the result. -func (p *Server) resolveInstructions( +// contextFileAgentID extracts the workspace agent ID from the most +// recent persisted context-file parts. Returns uuid.Nil, false if no +// context-file parts exist. +func contextFileAgentID(messages []database.ChatMessage) (uuid.UUID, bool) { + var lastID uuid.UUID + found := false + for _, msg := range messages { + if !msg.Content.Valid || !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) { + continue + } + var parts []codersdk.ChatMessagePart + if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { + continue + } + for _, p := range parts { + if p.Type == codersdk.ChatMessagePartTypeContextFile && p.ContextFileAgentID.Valid { + lastID = p.ContextFileAgentID.UUID + found = true + break + } + } + } + return lastID, found +} + +// persistInstructionFiles reads instruction files from the workspace +// agent and persists them as context-file message parts. This is called +// once when a workspace is first attached to a chat. Returns the +// formatted instruction string for injection into the current turn's +// prompt. +func (p *Server) persistInstructionFiles( ctx context.Context, chat database.Chat, + modelConfigID uuid.UUID, getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error), getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error), -) string { +) (string, error) { if !chat.WorkspaceID.Valid || getWorkspaceAgent == nil { - return "" + return "", nil } - agent, agentErr := getWorkspaceAgent(ctx) - if agentErr != nil { - return "" - } - agentID := agent.ID - - p.instructionCacheMu.RLock() - cached, ok := p.instructionCache[agentID] - p.instructionCacheMu.RUnlock() - - if ok && time.Since(cached.fetchedAt) < instructionCacheTTL { - return cached.instruction + agent, err := getWorkspaceAgent(ctx) + if err != nil { + return "", nil } directory := agent.ExpandedDirectory @@ -3953,19 +3930,17 @@ func (p *Server) resolveInstructions( slog.Error(connErr), ) } else { - // ~/.coder/AGENTS.md - if content, source, truncated, err := readHomeInstructionFile(instructionCtx, conn); err != nil { + if content, source, truncated, readErr := readHomeInstructionFile(instructionCtx, conn); readErr != nil { p.logger.Debug(ctx, "failed to load home instruction file", - slog.F("chat_id", chat.ID), slog.Error(err)) + slog.F("chat_id", chat.ID), slog.Error(readErr)) } else if content != "" { sections = append(sections, instructionFileSection{content, source, truncated}) } - // /AGENTS.md if pwdPath := pwdInstructionFilePath(directory); pwdPath != "" { - if content, source, truncated, err := readInstructionFile(instructionCtx, conn, pwdPath); err != nil { + if content, source, truncated, readErr := readInstructionFile(instructionCtx, conn, pwdPath); readErr != nil { p.logger.Debug(ctx, "failed to load working directory instruction file", - slog.F("chat_id", chat.ID), slog.F("directory", directory), slog.Error(err)) + slog.F("chat_id", chat.ID), slog.F("directory", directory), slog.Error(readErr)) } else if content != "" { sections = append(sections, instructionFileSection{content, source, truncated}) } @@ -3973,16 +3948,69 @@ func (p *Server) resolveInstructions( } } - instruction := formatSystemInstructions(agent.OperatingSystem, directory, sections) - - p.instructionCacheMu.Lock() - p.instructionCache[agentID] = cachedInstruction{ - instruction: instruction, - fetchedAt: time.Now(), + if len(sections) == 0 { + // Persist a sentinel so subsequent turns skip the + // workspace agent dial. + parts := []codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFilePath: "", + ContextFileAgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, + }} + content, err := chatprompt.MarshalParts(parts) + if err != nil { + return "", nil + } + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + ChatID: chat.ID, + } + appendChatMessage(&msgParams, newChatMessage( + database.ChatMessageRoleUser, + content, + database.ChatMessageVisibilityBoth, + modelConfigID, + chatprompt.CurrentContentVersion, + )) + _, _ = p.db.InsertChatMessages(ctx, msgParams) + return "", nil } - p.instructionCacheMu.Unlock() - return instruction + // Build context-file parts, one per instruction file. + parts := make([]codersdk.ChatMessagePart, 0, len(sections)) + for _, s := range sections { + parts = append(parts, codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFilePath: s.source, + ContextFileContent: s.content, + ContextFileTruncated: s.truncated, + ContextFileAgentID: uuid.NullUUID{UUID: agent.ID, Valid: true}, + ContextFileOS: agent.OperatingSystem, + ContextFileDirectory: directory, + }) + } + + content, err := chatprompt.MarshalParts(parts) + if err != nil { + return "", xerrors.Errorf("marshal context-file parts: %w", err) + } + + msgParams := database.InsertChatMessagesParams{ //nolint:exhaustruct // Fields populated by appendChatMessage. + ChatID: chat.ID, + } + appendChatMessage(&msgParams, newChatMessage( + database.ChatMessageRoleUser, + content, + database.ChatMessageVisibilityBoth, + modelConfigID, + chatprompt.CurrentContentVersion, + )) + if _, err := p.db.InsertChatMessages(ctx, msgParams); err != nil { + return "", xerrors.Errorf("persist instruction files: %w", err) + } + + // Return the formatted instruction text so the caller can inject + // it into this turn's prompt (since the prompt was built before + // we persisted). + return formatSystemInstructions(agent.OperatingSystem, directory, sections), nil } // resolveUserCompactionThreshold looks up the user's per-model @@ -4022,15 +4050,11 @@ func (p *Server) resolveUserPrompt(ctx context.Context, userID uuid.UUID) string // sql.ErrNoRows is the normal "not set" case. return "" } - sanitized := SanitizePromptText(raw) - if sanitized == "" { - if strings.TrimSpace(raw) != "" { - p.logger.Warn(ctx, "user custom prompt became empty after sanitization", - slog.F("user_id", userID)) - } + trimmed := strings.TrimSpace(raw) + if trimmed == "" { return "" } - return "\n" + sanitized + "\n" + return "\n" + trimmed + "\n" } func (p *Server) recoverStaleChats(ctx context.Context) { diff --git a/coderd/x/chatd/chatd_internal_test.go b/coderd/x/chatd/chatd_internal_test.go index 2946d29e65..dce239cc1f 100644 --- a/coderd/x/chatd/chatd_internal_test.go +++ b/coderd/x/chatd/chatd_internal_test.go @@ -3,11 +3,15 @@ package chatd import ( "context" "database/sql" + "encoding/json" + "io" + "strings" "sync" "testing" "time" "github.com/google/uuid" + "github.com/sqlc-dev/pqtype" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "golang.org/x/xerrors" @@ -100,7 +104,7 @@ func TestRefreshChatWorkspaceSnapshot_ReturnsReloadError(t *testing.T) { require.Equal(t, chat, refreshed) } -func TestResolveInstructionsReusesTurnLocalWorkspaceAgent(t *testing.T) { +func TestPersistInstructionFilesIncludesAgentMetadata(t *testing.T) { t.Parallel() ctx := context.Background() @@ -126,6 +130,7 @@ func TestResolveInstructionsReusesTurnLocalWorkspaceAgent(t *testing.T) { gomock.Any(), workspaceID, ).Return([]database.WorkspaceAgent{workspaceAgent}, nil).Times(1) + db.EXPECT().InsertChatMessages(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() conn := agentconnmock.NewMockAgentConn(ctrl) conn.EXPECT().SetExtraHeaders(gomock.Any()).Times(1) @@ -139,16 +144,15 @@ func TestResolveInstructionsReusesTurnLocalWorkspaceAgent(t *testing.T) { int64(0), int64(maxInstructionFileBytes+1), ).Return( - nil, + io.NopCloser(strings.NewReader("# Project instructions")), "", - codersdk.NewTestError(404, "GET", "/api/v0/read-file"), + nil, ).Times(1) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) server := &Server{ - db: db, - logger: logger, - instructionCache: make(map[uuid.UUID]cachedInstruction), + db: db, + logger: logger, agentConnFn: func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { return conn, func() {}, nil }, @@ -164,12 +168,14 @@ func TestResolveInstructionsReusesTurnLocalWorkspaceAgent(t *testing.T) { } t.Cleanup(workspaceCtx.close) - instruction := server.resolveInstructions( + instruction, err := server.persistInstructionFiles( ctx, chat, + uuid.New(), workspaceCtx.getWorkspaceAgent, workspaceCtx.getWorkspaceConn, ) + require.NoError(t, err) require.Contains(t, instruction, "Operating System: linux") require.Contains(t, instruction, "Working Directory: /home/coder/project") } @@ -792,3 +798,90 @@ func requireFieldValue(t *testing.T, entry slog.SinkEntry, name string, expected } t.Fatalf("field %q not found in log entry", name) } + +func TestContextFileAgentID(t *testing.T) { + t.Parallel() + + t.Run("EmptyMessages", func(t *testing.T) { + t.Parallel() + id, ok := contextFileAgentID(nil) + require.Equal(t, uuid.Nil, id) + require.False(t, ok) + }) + + t.Run("NoContextFileParts", func(t *testing.T) { + t.Parallel() + msgs := []database.ChatMessage{ + chatMessageWithParts([]codersdk.ChatMessagePart{ + {Type: codersdk.ChatMessagePartTypeText, Text: "hello"}, + }), + } + id, ok := contextFileAgentID(msgs) + require.Equal(t, uuid.Nil, id) + require.False(t, ok) + }) + + t.Run("SingleContextFile", func(t *testing.T) { + t.Parallel() + agentID := uuid.New() + msgs := []database.ChatMessage{ + chatMessageWithParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFilePath: "/some/path", + ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + }, + }), + } + id, ok := contextFileAgentID(msgs) + require.Equal(t, agentID, id) + require.True(t, ok) + }) + + t.Run("MultipleContextFiles", func(t *testing.T) { + t.Parallel() + agentID1 := uuid.New() + agentID2 := uuid.New() + msgs := []database.ChatMessage{ + chatMessageWithParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFilePath: "/first/path", + ContextFileAgentID: uuid.NullUUID{UUID: agentID1, Valid: true}, + }, + }), + chatMessageWithParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFilePath: "/second/path", + ContextFileAgentID: uuid.NullUUID{UUID: agentID2, Valid: true}, + }, + }), + } + id, ok := contextFileAgentID(msgs) + require.Equal(t, agentID2, id) + require.True(t, ok) + }) + + t.Run("SentinelWithoutAgentID", func(t *testing.T) { + t.Parallel() + msgs := []database.ChatMessage{ + chatMessageWithParts([]codersdk.ChatMessagePart{ + { + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFileAgentID: uuid.NullUUID{Valid: false}, + }, + }), + } + id, ok := contextFileAgentID(msgs) + require.Equal(t, uuid.Nil, id) + require.False(t, ok) + }) +} + +func chatMessageWithParts(parts []codersdk.ChatMessagePart) database.ChatMessage { + raw, _ := json.Marshal(parts) + return database.ChatMessage{ + Content: pqtype.NullRawMessage{RawMessage: raw, Valid: true}, + } +} diff --git a/coderd/x/chatd/chatprompt/chatprompt.go b/coderd/x/chatd/chatprompt/chatprompt.go index 244a693549..356d50c67b 100644 --- a/coderd/x/chatd/chatprompt/chatprompt.go +++ b/coderd/x/chatd/chatprompt/chatprompt.go @@ -1302,6 +1302,32 @@ func partsToMessageParts( result = append(result, fantasy.TextPart{ Text: fileReferencePartToText(part), }) + case codersdk.ChatMessagePartTypeContextFile: + if part.ContextFileContent == "" { + continue + } + var sb strings.Builder + _, _ = sb.WriteString("\n") + if part.ContextFileOS != "" { + _, _ = sb.WriteString("Operating System: ") + _, _ = sb.WriteString(part.ContextFileOS) + _, _ = sb.WriteString("\n") + } + if part.ContextFileDirectory != "" { + _, _ = sb.WriteString("Working Directory: ") + _, _ = sb.WriteString(part.ContextFileDirectory) + _, _ = sb.WriteString("\n") + } + source := part.ContextFilePath + if part.ContextFileTruncated { + source += " (truncated to 64KiB)" + } + _, _ = sb.WriteString("\nSource: ") + _, _ = sb.WriteString(source) + _, _ = sb.WriteString("\n") + _, _ = sb.WriteString(part.ContextFileContent) + _, _ = sb.WriteString("\n") + result = append(result, fantasy.TextPart{Text: sb.String()}) case codersdk.ChatMessagePartTypeSource: // Source parts are metadata-only, not sent to LLM. continue diff --git a/coderd/x/chatd/instruction.go b/coderd/x/chatd/instruction.go index cb931f1eb6..2a5087ad66 100644 --- a/coderd/x/chatd/instruction.go +++ b/coderd/x/chatd/instruction.go @@ -1,7 +1,9 @@ package chatd import ( + "bytes" "context" + "encoding/json" "io" "net/http" "path" @@ -10,6 +12,7 @@ import ( "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" ) @@ -100,10 +103,9 @@ func readInstructionFile( } func sanitizeInstructionMarkdown(content string) string { - // Remove Markdown comments first so that the subsequent newline - // collapsing in SanitizePromptText covers any gaps left behind. content = markdownCommentPattern.ReplaceAllString(content, "") - return SanitizePromptText(content) + content = SanitizePromptText(content) + return strings.TrimSpace(content) } // formatSystemInstructions builds the block from @@ -160,6 +162,46 @@ type instructionFileSection struct { truncated bool } +// instructionFromContextFiles reconstructs the formatted instruction +// string from persisted context-file parts. This is used on non-first +// turns so the instruction can be re-injected after compaction +// without re-dialing the workspace agent. +func instructionFromContextFiles( + messages []database.ChatMessage, +) string { + var sections []instructionFileSection + var os, dir string + for _, msg := range messages { + if !msg.Content.Valid || + !bytes.Contains(msg.Content.RawMessage, []byte(`"context-file"`)) { + continue + } + var parts []codersdk.ChatMessagePart + if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil { + continue + } + for _, part := range parts { + if part.Type != codersdk.ChatMessagePartTypeContextFile { + continue + } + if part.ContextFileOS != "" { + os = part.ContextFileOS + } + if part.ContextFileDirectory != "" { + dir = part.ContextFileDirectory + } + if part.ContextFileContent != "" { + sections = append(sections, instructionFileSection{ + content: part.ContextFileContent, + source: part.ContextFilePath, + truncated: part.ContextFileTruncated, + }) + } + } + } + return formatSystemInstructions(os, dir, sections) +} + // pwdInstructionFilePath returns the absolute path to the AGENTS.md // file in the given working directory, or empty if directory is empty. func pwdInstructionFilePath(directory string) string { diff --git a/codersdk/chats.go b/codersdk/chats.go index 5b31c3d9f8..1b2ed16a96 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -107,6 +107,7 @@ const ( ChatMessagePartTypeSource ChatMessagePartType = "source" ChatMessagePartTypeFile ChatMessagePartType = "file" ChatMessagePartTypeFileReference ChatMessagePartType = "file-reference" + ChatMessagePartTypeContextFile ChatMessagePartType = "context-file" ) // AllChatMessagePartTypes returns all known ChatMessagePartType values. @@ -119,6 +120,7 @@ func AllChatMessagePartTypes() []ChatMessagePartType { ChatMessagePartTypeSource, ChatMessagePartTypeFile, ChatMessagePartTypeFileReference, + ChatMessagePartTypeContextFile, } } @@ -175,6 +177,31 @@ type ChatMessagePart struct { // ProviderExecuted indicates the tool call was executed by // the provider (e.g. Anthropic computer use). ProviderExecuted bool `json:"provider_executed,omitempty" variants:"tool-call?,tool-result?"` + // ContextFilePath is the absolute path of a file loaded into + // the LLM context (e.g. an AGENTS.md instruction file). + ContextFilePath string `json:"context_file_path" variants:"context-file"` + // ContextFileContent holds the file content sent to the LLM. + // Internal only: stripped before API responses to keep + // payloads small. The backend reads it when building the + // prompt via partsToMessageParts. + ContextFileContent string `json:"context_file_content,omitempty" typescript:"-"` + // ContextFileTruncated indicates the file exceeded the 64KiB + // instruction file limit and was truncated. + ContextFileTruncated bool `json:"context_file_truncated,omitempty" variants:"context-file?"` + // ContextFileAgentID is the workspace agent that provided + // this context file. Used to detect when the agent changes + // (e.g. workspace rebuilt) so instruction files can be + // re-persisted with fresh content. + ContextFileAgentID uuid.NullUUID `json:"context_file_agent_id,omitempty" format:"uuid" variants:"context-file?"` + // ContextFileOS is the operating system of the workspace + // agent. Internal only: used during prompt expansion so + // the LLM knows the OS even on turns where InsertSystem + // is not called. + ContextFileOS string `json:"context_file_os,omitempty" typescript:"-"` + // ContextFileDirectory is the working directory of the + // workspace agent. Internal only: same purpose as + // ContextFileOS. + ContextFileDirectory string `json:"context_file_directory,omitempty" typescript:"-"` } // StripInternal removes internal-only fields that must not be @@ -188,6 +215,9 @@ func (p *ChatMessagePart) StripInternal() { if p.FileID.Valid { p.Data = nil } + p.ContextFileContent = "" + p.ContextFileOS = "" + p.ContextFileDirectory = "" } // ChatMessageText builds a text chat message part. diff --git a/codersdk/chats_test.go b/codersdk/chats_test.go index 01657d382b..9978828227 100644 --- a/codersdk/chats_test.go +++ b/codersdk/chats_test.go @@ -184,6 +184,28 @@ func TestChatMessagePart_StripInternal(t *testing.T) { assert.Equal(t, []byte("inline-data"), part.Data) }) + t.Run("StripsContextFileContent", func(t *testing.T) { + t.Parallel() + agentID := uuid.New() + part := codersdk.ChatMessagePart{ + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFilePath: "/home/coder/AGENTS.md", + ContextFileContent: "large content", + ContextFileAgentID: uuid.NullUUID{UUID: agentID, Valid: true}, + ContextFileOS: "linux", + ContextFileDirectory: "/home/coder/project", + } + part.StripInternal() + // Internal fields stripped. + assert.Empty(t, part.ContextFileContent) + assert.Empty(t, part.ContextFileOS) + assert.Empty(t, part.ContextFileDirectory) + // Public fields preserved. + assert.Equal(t, "/home/coder/AGENTS.md", part.ContextFilePath) + assert.Equal(t, agentID, part.ContextFileAgentID.UUID) + assert.True(t, part.ContextFileAgentID.Valid) + }) + t.Run("NoopOnCleanPart", func(t *testing.T) { t.Parallel() part := codersdk.ChatMessageText("hello") @@ -209,12 +231,14 @@ func TestChatMessagePartVariantTags(t *testing.T) { // If you add a new field to ChatMessagePart, either add a // variants tag or add it here with a comment explaining why. excludedFields := map[string]string{ - "type": "discriminant, added automatically by codegen", - "signature": "added in #22290, never populated by any code path", - "result_delta": "added in #22290, never populated by any code path", - "provider_metadata": "internal only, stripped by db2sdk before API responses", + "type": "discriminant, added automatically by codegen", + "signature": "added in #22290, never populated by any code path", + "result_delta": "added in #22290, never populated by any code path", + "provider_metadata": "internal only, stripped by db2sdk before API responses", + "context_file_content": "internal only, stripped before API responses (typescript:\"-\")", + "context_file_os": "internal only, used during prompt expansion (typescript:\"-\")", + "context_file_directory": "internal only, used during prompt expansion (typescript:\"-\")", } - knownTypes := make(map[codersdk.ChatMessagePartType]bool) for _, pt := range codersdk.AllChatMessagePartTypes() { knownTypes[pt] = true diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 30408821f2..228bcc5bb1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1115,6 +1115,28 @@ export interface ChatConfig { readonly acquire_batch_size: number; } +// From codersdk/chats.go +export interface ChatContextFilePart { + readonly type: "context-file"; + /** + * ContextFilePath is the absolute path of a file loaded into + * the LLM context (e.g. an AGENTS.md instruction file). + */ + readonly context_file_path: string; + /** + * ContextFileTruncated indicates the file exceeded the 64KiB + * instruction file limit and was truncated. + */ + readonly context_file_truncated?: boolean; + /** + * ContextFileAgentID is the workspace agent that provided + * this context file. Used to detect when the agent changes + * (e.g. workspace rebuilt) so instruction files can be + * re-persisted with fresh content. + */ + readonly context_file_agent_id?: string; +} + // From codersdk/chats.go /** * ChatCostChatBreakdown contains per-root-chat cost aggregation. @@ -1375,10 +1397,12 @@ export type ChatMessagePart = | ChatToolResultPart | ChatSourcePart | ChatFilePart - | ChatFileReferencePart; + | ChatFileReferencePart + | ChatContextFilePart; // From codersdk/chats.go export type ChatMessagePartType = + | "context-file" | "file" | "file-reference" | "reasoning" @@ -1388,6 +1412,7 @@ export type ChatMessagePartType = | "tool-result"; export const ChatMessagePartTypes: ChatMessagePartType[] = [ + "context-file", "file", "file-reference", "reasoning", diff --git a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx index 3574ec4285..df97c80a06 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/AgentDetail/ConversationTimeline.tsx @@ -437,6 +437,12 @@ const ChatMessageItem = memo<{ return null; } + // Hide messages that consist entirely of context-file parts. + // These are metadata for the context indicator, not + // conversation content. + if (parts.length > 0 && parts.every((p) => p.type === "context-file")) { + return null; + } const hasRenderableContent = parsed.blocks.length > 0 || parsed.tools.length > 0 || diff --git a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts index 14f9cf2da0..af488dde87 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/messageParsing.ts @@ -207,6 +207,11 @@ export const parseMessageContent = ( } break; } + case "context-file": { + // Context files are metadata for the context indicator; + // they are not rendered in the conversation timeline. + break; + } default: { const _exhaustive: never = part; break; diff --git a/site/src/pages/AgentsPage/components/AgentDetail/streamState.ts b/site/src/pages/AgentsPage/components/AgentDetail/streamState.ts index 5caf2a88ab..f538a18aa2 100644 --- a/site/src/pages/AgentsPage/components/AgentDetail/streamState.ts +++ b/site/src/pages/AgentsPage/components/AgentDetail/streamState.ts @@ -168,6 +168,9 @@ export const applyMessagePartToStreamState = ( // file-reference parts only appear in persisted messages // from user input, never via SSE streaming. case "file-reference": + // context-file parts are metadata-only; no streaming + // render needed. + case "context-file": return prev; default: { const _exhaustive: never = part;