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;