Files
coder/coderd/x/chatd/instruction.go
T
Kyle Carberry d9fc5a5be1 feat: persist chat instruction files as context-file message parts (#23592)
## 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
`<workspace-context>` 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
2026-03-25 17:08:27 +00:00

221 lines
5.5 KiB
Go

package chatd
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"path"
"regexp"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
)
const (
coderHomeInstructionDir = ".coder"
coderHomeInstructionFile = "AGENTS.md"
maxInstructionFileBytes = 64 * 1024
)
var markdownCommentPattern = regexp.MustCompile(`<!--[\s\S]*?-->`)
// readHomeInstructionFile reads the ~/.coder/AGENTS.md file from the
// workspace agent's home directory.
func readHomeInstructionFile(
ctx context.Context,
conn workspacesdk.AgentConn,
) (content string, sourcePath string, truncated bool, err error) {
if conn == nil {
return "", "", false, nil
}
coderDir, err := conn.LS(ctx, "", workspacesdk.LSRequest{
Path: []string{coderHomeInstructionDir},
Relativity: workspacesdk.LSRelativityHome,
})
if err != nil {
if isCodersdkStatusCode(err, http.StatusNotFound) {
return "", "", false, nil
}
return "", "", false, xerrors.Errorf("list home instruction directory: %w", err)
}
var filePath string
for _, entry := range coderDir.Contents {
if entry.IsDir {
continue
}
if strings.EqualFold(strings.TrimSpace(entry.Name), coderHomeInstructionFile) {
filePath = strings.TrimSpace(entry.AbsolutePathString)
break
}
}
if filePath == "" {
return "", "", false, nil
}
return readInstructionFile(ctx, conn, filePath)
}
// readInstructionFile reads and sanitizes an instruction file at the
// given absolute path.
func readInstructionFile(
ctx context.Context,
conn workspacesdk.AgentConn,
filePath string,
) (content string, sourcePath string, truncated bool, err error) {
reader, _, err := conn.ReadFile(
ctx,
filePath,
0,
maxInstructionFileBytes+1,
)
if err != nil {
if isCodersdkStatusCode(err, http.StatusNotFound) {
return "", "", false, nil
}
return "", "", false, xerrors.Errorf("read instruction file: %w", err)
}
defer reader.Close()
raw, err := io.ReadAll(reader)
if err != nil {
return "", "", false, xerrors.Errorf("read instruction bytes: %w", err)
}
truncated = int64(len(raw)) > maxInstructionFileBytes
if truncated {
raw = raw[:maxInstructionFileBytes]
}
content = sanitizeInstructionMarkdown(string(raw))
if content == "" {
return "", "", truncated, nil
}
return content, filePath, truncated, nil
}
func sanitizeInstructionMarkdown(content string) string {
content = markdownCommentPattern.ReplaceAllString(content, "")
content = SanitizePromptText(content)
return strings.TrimSpace(content)
}
// formatSystemInstructions builds the <workspace-context> block from
// agent metadata and zero or more instruction file sections.
func formatSystemInstructions(
operatingSystem, directory string,
sections []instructionFileSection,
) string {
hasSections := false
for _, s := range sections {
if s.content != "" {
hasSections = true
break
}
}
if !hasSections && operatingSystem == "" && directory == "" {
return ""
}
var b strings.Builder
_, _ = b.WriteString("<workspace-context>\n")
if operatingSystem != "" {
_, _ = b.WriteString("Operating System: ")
_, _ = b.WriteString(operatingSystem)
_, _ = b.WriteString("\n")
}
if directory != "" {
_, _ = b.WriteString("Working Directory: ")
_, _ = b.WriteString(directory)
_, _ = b.WriteString("\n")
}
for _, s := range sections {
if s.content == "" {
continue
}
_, _ = b.WriteString("\nSource: ")
_, _ = b.WriteString(s.source)
if s.truncated {
_, _ = b.WriteString(" (truncated to 64KiB)")
}
_, _ = b.WriteString("\n")
_, _ = b.WriteString(s.content)
_, _ = b.WriteString("\n")
}
_, _ = b.WriteString("</workspace-context>")
return b.String()
}
// instructionFileSection is a single instruction file's content and
// source path for rendering inside <workspace-context>.
type instructionFileSection struct {
content string
source string
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 {
if directory == "" {
return ""
}
return path.Join(directory, coderHomeInstructionFile)
}
func isCodersdkStatusCode(err error, statusCode int) bool {
var sdkErr *codersdk.Error
if !xerrors.As(err, &sdkErr) {
return false
}
return sdkErr.StatusCode() == statusCode
}