Files
coder/coderd/chatd/instruction.go
T
Kyle Carberry 344d11fa22 feat: include OS and working directory in workspace agent prompt injection (#22399)
When injecting system instructions into the chat prompt, include:

1. **Operating system** and **working directory** from the
`workspace_agents` table
2. **Home-level instructions** from `~/.coder/AGENTS.md` (existing
behavior)
3. **Project-level instructions** from `<pwd>/AGENTS.md` (new)

The XML tag is renamed from `<coder-home-instructions>` to
`<system-instructions>` since it now carries more than just the home
instruction file.

### Example output (both files present)

```xml
<system-instructions>
Operating System: linux
Working Directory: /home/coder/coder

Source: /home/coder/.coder/AGENTS.md
... home instructions ...

Source: /home/coder/coder/AGENTS.md
... project instructions ...
</system-instructions>
```

### Example output (no AGENTS.md files)

```xml
<system-instructions>
Operating System: linux
Working Directory: /home/coder/coder
</system-instructions>
```

### Changes

- **`coderd/chatd/instruction.go`**:
- Renamed types: `homeInstructionContext` → `agentContext`, added
`instructionFile` struct
  - Extracted `readInstructionFileAtPath` shared helper
- Added `readWorkingDirectoryInstructionFile` to read `<pwd>/AGENTS.md`
- Replaced `formatHomeInstruction` with `formatInstructions` that
renders both files under `<system-instructions>`
- **`coderd/chatd/chatd.go`**:
- Renamed `resolveHomeInstruction` → `resolveInstructions`; now reads
both home and pwd instruction files
- `resolveAgentContext` returns `agentContext` (renamed from
`homeInstructionContext`)
- pwd file read is skipped gracefully if directory is empty or file
doesn't exist
- **`coderd/chatd/instruction_test.go`**:
- Added `TestReadWorkingDirectoryInstructionFile` (success, not-found,
empty-directory)
- Replaced `TestFormatHomeInstruction` with `TestFormatInstructions`
covering all combinations
- Added ordering test (`AgentContextBeforeFiles`) to verify OS/pwd
appear before file sources
2026-02-27 14:21:23 -05:00

179 lines
4.4 KiB
Go

package chatd
import (
"context"
"io"
"net/http"
"path"
"regexp"
"strings"
"golang.org/x/xerrors"
"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 = strings.ReplaceAll(content, "\r\n", "\n")
content = strings.ReplaceAll(content, "\r", "\n")
content = markdownCommentPattern.ReplaceAllString(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
}
// 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
}