mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
## 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
221 lines
5.5 KiB
Go
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
|
|
}
|