mirror of
https://github.com/coder/coder.git
synced 2026-06-07 23:18:20 +00:00
ee855f9618
Replace hardcoded paths for instruction files, skills, and MCP config
with
values read from `CODER_AGENT_EXP_*` environment variables. Template
authors
configure paths via the existing `coder_agent` `env` block. The agent
resolves `~`, relative, and absolute paths locally, then serves the
resolved config over `GET /api/v0/context-config`. `chatd` fetches this
once per workspace attach and falls back to today's defaults for older
agents.
All path env vars are comma-separated, allowing multiple directories:
| Env Var | Default | Controls |
|---|---|---|
| `CODER_AGENT_EXP_INSTRUCTIONS_DIRS` | `~/.coder` | Dirs containing the
instruction file |
| `CODER_AGENT_EXP_INSTRUCTIONS_FILE` | `AGENTS.md` | Instruction file
name |
| `CODER_AGENT_EXP_SKILLS_DIRS` | `.agents/skills` | Skills directories
|
| `CODER_AGENT_EXP_SKILL_META_FILE` | `SKILL.md` | Skill metadata file
name |
| `CODER_AGENT_EXP_MCP_CONFIG_FILES` | `.mcp.json` | MCP config files |
### Example
```hcl
resource "coder_agent" "main" {
os = "linux"
arch = "amd64"
env = {
CODER_AGENT_EXP_INSTRUCTIONS_DIRS = "/opt/company/agent-config,~/.coder"
CODER_AGENT_EXP_INSTRUCTIONS_FILE = "CLAUDE.md"
CODER_AGENT_EXP_SKILLS_DIRS = "/opt/company/ai-skills,.agents/skills"
CODER_AGENT_EXP_MCP_CONFIG_FILES = "/opt/company/mcp.json,.mcp.json"
}
}
```
<details>
<summary>Implementation Details</summary>
### Architecture
Follows the same pattern as MCP tool discovery:
agent resolves locally → exposes via HTTP → chatd consumes.
**Agent-side** (`agent/agentcontextconfig/`):
- `ResolvePath` / `ResolvePaths` handle `~`, relative, and absolute path
forms; returns `""` for relative paths when baseDir is empty
- `Config` reads env vars, falls back to defaults, resolves all paths
- `GET /api/v0/context-config` serves the resolved config as JSON
**chatd-side** (`coderd/x/chatd/`):
- Calls `conn.ContextConfig()` once on first workspace attach
- Falls back to hardcoded defaults on 404 (older agents)
- Iterates instruction dirs, skills dirs using resolved absolute paths
- `LSRelativityRoot` everywhere — no more home/root juggling
### Key design decisions
- **`EXP_` prefix**: env vars use `CODER_AGENT_EXP_*` to indicate
experimental status
- **Plural names**: comma-separated vars use plural names (`DIRS`,
`FILES`); single-value vars use singular (`FILE`)
- **Defaults in `workspacesdk`**: default constants live in
`codersdk/workspacesdk/` so both agent and server reference them without
cross-layer imports
- **`skillMetaFile` persistence**: stored on context-file parts via
`ContextFileSkillMetaFile` and restored on subsequent chat turns so
custom values survive across turns
- **Working dir dedup**: `slices.Contains` guard prevents reading the
same instruction file from both `InstructionsDirs` and the working
directory
- **MCP server dedup**: first-occurrence-wins dedup prevents leaking
duplicate connections from overlapping config files
- **ResolvePath safety**: returns `""` for relative paths when `baseDir`
is empty, so `ResolvePaths` filters them out
### Files changed
| File | Change |
|---|---|
| `agent/agentcontextconfig/` | New package — path resolution + HTTP
endpoint |
| `codersdk/workspacesdk/agentconn.go` | `ContextConfigResponse` type,
default constants, client method |
| `agent/agent.go` + `agent/api.go` | Wire up endpoint, pass config to
MCP |
| `agent/x/agentmcp/manager.go` | Accept `[]string` MCP config paths,
dedup by name |
| `coderd/x/chatd/chatd.go` | Fetch config, thread through, named
returns |
| `coderd/x/chatd/instruction.go` | Accept configurable dir + file name,
`skillMetaFileFromParts` |
| `coderd/x/chatd/chattool/skill.go` | Accept configurable dirs + meta
file |
| `codersdk/chats.go` | `ContextFileSkillMetaFile` field for persistence
|
### Test coverage
- `TestConfig` (4 cases): defaults, custom env vars, whitespace
trimming, comma-separated dirs
- `TestResolvePath` / `TestResolvePaths`: including empty baseDir edge
case
- `TestPersistInstructionFilesFallbackOnOlderAgent`: backward-compat
path when `ContextConfig` returns 404
- `TestChatMessagePartVariantTags`: updated exclusion list for new
internal field
### Backward compatibility
Older agents return 404 for the new endpoint. `chatd` catches this and
falls back to today's defaults via `readHomeInstructionFile` (using
`LSRelativityHome`). Existing workspaces work with no changes.
</details>
326 lines
8.4 KiB
Go
326 lines
8.4 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/coderd/x/chatd/chattool"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
const (
|
|
maxInstructionFileBytes = 64 * 1024
|
|
)
|
|
|
|
var markdownCommentPattern = regexp.MustCompile(`<!--[\s\S]*?-->`)
|
|
|
|
// readHomeInstructionFile reads an instruction file from the
|
|
// agent's home directory using home-relative path resolution.
|
|
// This is the fallback for older agents that don't support
|
|
// the context-config endpoint.
|
|
func readHomeInstructionFile(
|
|
ctx context.Context,
|
|
conn workspacesdk.AgentConn,
|
|
homeDir string,
|
|
fileName string,
|
|
) (content string, sourcePath string, truncated bool, err error) {
|
|
if conn == nil {
|
|
return "", "", false, nil
|
|
}
|
|
|
|
dirListing, err := conn.LS(ctx, "", workspacesdk.LSRequest{
|
|
Path: []string{homeDir},
|
|
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 dirListing.Contents {
|
|
if entry.IsDir {
|
|
continue
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(entry.Name), fileName) {
|
|
filePath = strings.TrimSpace(entry.AbsolutePathString)
|
|
break
|
|
}
|
|
}
|
|
if filePath == "" {
|
|
return "", "", false, nil
|
|
}
|
|
|
|
return readInstructionFile(ctx, conn, filePath)
|
|
}
|
|
|
|
// readInstructionDirFile reads an instruction file from the given
|
|
// absolute directory path. The directory is listed and scanned
|
|
// for a file matching fileName (case-insensitive).
|
|
func readInstructionDirFile(
|
|
ctx context.Context,
|
|
conn workspacesdk.AgentConn,
|
|
absDir string,
|
|
fileName string,
|
|
) (content string, sourcePath string, truncated bool, err error) {
|
|
if conn == nil {
|
|
return "", "", false, nil
|
|
}
|
|
|
|
dirListing, err := conn.LS(ctx, "", workspacesdk.LSRequest{
|
|
Path: []string{absDir},
|
|
Relativity: workspacesdk.LSRelativityRoot,
|
|
})
|
|
if err != nil {
|
|
if isCodersdkStatusCode(err, http.StatusNotFound) {
|
|
return "", "", false, nil
|
|
}
|
|
return "", "", false, xerrors.Errorf("list instruction directory: %w", err)
|
|
}
|
|
|
|
var filePath string
|
|
for _, entry := range dirListing.Contents {
|
|
if entry.IsDir {
|
|
continue
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(entry.Name), fileName) {
|
|
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)
|
|
}
|
|
|
|
// skillsFromParts reconstructs skill metadata from persisted
|
|
// skill parts. This is analogous to instructionFromContextFiles
|
|
// so the skill index can be re-injected after compaction without
|
|
// re-dialing the workspace agent.
|
|
func skillsFromParts(
|
|
messages []database.ChatMessage,
|
|
) []chattool.SkillMeta {
|
|
var skills []chattool.SkillMeta
|
|
for _, msg := range messages {
|
|
if !msg.Content.Valid ||
|
|
!bytes.Contains(msg.Content.RawMessage, []byte(`"skill"`)) {
|
|
continue
|
|
}
|
|
var parts []codersdk.ChatMessagePart
|
|
if err := json.Unmarshal(msg.Content.RawMessage, &parts); err != nil {
|
|
continue
|
|
}
|
|
for _, part := range parts {
|
|
if part.Type != codersdk.ChatMessagePartTypeSkill {
|
|
continue
|
|
}
|
|
skills = append(skills, chattool.SkillMeta{
|
|
Name: part.SkillName,
|
|
Description: part.SkillDescription,
|
|
Dir: part.SkillDir,
|
|
})
|
|
}
|
|
}
|
|
return skills
|
|
}
|
|
|
|
// pwdInstructionFilePath returns the absolute path to the
|
|
// instruction file in the given working directory, or empty if
|
|
// directory is empty.
|
|
func pwdInstructionFilePath(directory, fileName string) string {
|
|
if directory == "" || fileName == "" {
|
|
return ""
|
|
}
|
|
return path.Join(directory, fileName)
|
|
}
|
|
|
|
// skillMetaFileFromParts scans persisted context-file parts for
|
|
// the stored skill meta file name. Uses last-wins semantics to
|
|
// match contextFileAgentID, so after an agent change the newest
|
|
// agent's value is returned.
|
|
func skillMetaFileFromParts(
|
|
messages []database.ChatMessage,
|
|
) string {
|
|
var result 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.ContextFileSkillMetaFile != "" {
|
|
result = part.ContextFileSkillMetaFile
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func isCodersdkStatusCode(err error, statusCode int) bool {
|
|
var sdkErr *codersdk.Error
|
|
if !xerrors.As(err, &sdkErr) {
|
|
return false
|
|
}
|
|
return sdkErr.StatusCode() == statusCode
|
|
}
|