mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: agent reads context files and discovers skills locally (#23935)
Piggybacks on #23878. Moves instruction file reading and skill discovery from `chatd` (server-side, via multiple `LS`/`ReadFile` round-trips through the agent connection) to the agent itself (local filesystem access). This intentionally drops backward compatibility with older agents that don't support the context-config endpoint. Agents and server are deployed together; there is no rolling-update contract to maintain here. ## What changed The agent's `GET /api/v0/context-config` response now returns `[]ChatMessagePart` directly — the same types chatd persists. This eliminates intermediate type conversions and makes the protocol extensible. | Field | Type | Description | |---|---|---| | `parts` | `[]ChatMessagePart` | Context-file and skill parts, ready to persist | | `working_dir` | `string` | Agent's resolved working directory | Removed from the response: `instructions_dirs`, `instructions_file`, `skills_dirs`, `skill_meta_file`, `mcp_config_files` — the agent reads files locally and returns their content as parts. Removed from chatd: all legacy `LS`/`ReadFile` fallback code (`readHomeInstructionFile`, `readInstructionDirFile`, `DiscoverSkills` via LS, etc). ## Why The previous architecture had the agent resolve paths, serve them over HTTP, then `chatd` make N+1 round-trips back through the agent connection to read files. The agent has direct filesystem access and should just read the files. ## Key design decisions - **Agent returns `ChatMessagePart` directly** — same types chatd persists. No intermediate `InstructionFileEntry`/`SkillEntry` types needed. - **`SkillMeta.MetaFile`** — persisted via `ContextFileSkillMetaFile` on the skill part, so custom meta file names (`CODER_AGENT_EXP_SKILL_META_FILE`) survive across chat turns. - **No pre-read body** — `read_skill` always dials the workspace to fetch the skill body on demand. Simpler than caching the body in the response. - **MCP config paths kept agent-internal** — `MCPConfigFiles()` getter, not sent over the wire. - **No backward compat fallback** — old agents that don't support context-config get no instruction files. This is acceptable since agent and server deploy together.
This commit is contained in:
+1
-1
@@ -1366,7 +1366,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
// lifecycle transition to avoid delaying Ready.
|
||||
// This runs inside the tracked goroutine so it
|
||||
// is properly awaited on shutdown.
|
||||
if mcpErr := a.mcpManager.Connect(a.gracefulCtx, a.contextConfigAPI.Config().MCPConfigFiles); mcpErr != nil {
|
||||
if mcpErr := a.mcpManager.Connect(a.gracefulCtx, a.contextConfigAPI.MCPConfigFiles()); mcpErr != nil {
|
||||
a.logger.Warn(ctx, "failed to connect to workspace MCP servers", slog.Error(mcpErr))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -83,14 +83,14 @@ func TestContextConfigAPI_InitOnce(t *testing.T) {
|
||||
return ""
|
||||
})
|
||||
|
||||
cfg1 := a.contextConfigAPI.Config()
|
||||
require.NotEmpty(t, cfg1.MCPConfigFiles)
|
||||
require.Contains(t, cfg1.MCPConfigFiles[0], dir1)
|
||||
mcpFiles1 := a.contextConfigAPI.MCPConfigFiles()
|
||||
require.NotEmpty(t, mcpFiles1)
|
||||
require.Contains(t, mcpFiles1[0], dir1)
|
||||
|
||||
// Simulate manifest update on reconnection — no field
|
||||
// Simulate manifest update on reconnection -- no field
|
||||
// reassignment needed, the lazy closure picks it up.
|
||||
a.manifest.Store(&agentsdk.Manifest{Directory: dir2})
|
||||
cfg2 := a.contextConfigAPI.Config()
|
||||
require.NotEmpty(t, cfg2.MCPConfigFiles)
|
||||
require.Contains(t, cfg2.MCPConfigFiles[0], dir2)
|
||||
mcpFiles2 := a.contextConfigAPI.MCPConfigFiles()
|
||||
require.NotEmpty(t, mcpFiles2)
|
||||
require.Contains(t, mcpFiles2[0], dir2)
|
||||
}
|
||||
|
||||
+251
-22
@@ -2,13 +2,17 @@ package agentcontextconfig
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
)
|
||||
|
||||
@@ -22,9 +26,47 @@ const (
|
||||
EnvMCPConfigFiles = "CODER_AGENT_EXP_MCP_CONFIG_FILES"
|
||||
)
|
||||
|
||||
// Defaults are defined in codersdk/workspacesdk so both
|
||||
// the agent and server can reference them without a
|
||||
// cross-layer import.
|
||||
const (
|
||||
maxInstructionFileBytes = 64 * 1024
|
||||
maxSkillMetaBytes = 64 * 1024
|
||||
)
|
||||
|
||||
// markdownCommentPattern strips HTML comments from instruction
|
||||
// file content for security (prevents hidden prompt injection).
|
||||
var markdownCommentPattern = regexp.MustCompile(`<!--[\s\S]*?-->`)
|
||||
|
||||
// invisibleRunePattern strips invisible Unicode characters that
|
||||
// could be used for prompt injection.
|
||||
//
|
||||
//nolint:gocritic // Non-ASCII char ranges are intentional for invisible Unicode stripping.
|
||||
var invisibleRunePattern = regexp.MustCompile(
|
||||
"[\u00ad\u034f\u061c\u070f" +
|
||||
"\u115f\u1160\u17b4\u17b5" +
|
||||
"\u180b-\u180f" +
|
||||
"\u200b\u200d\u200e\u200f" +
|
||||
"\u202a-\u202e" +
|
||||
"\u2060-\u206f" +
|
||||
"\u3164" +
|
||||
"\ufe00-\ufe0f" +
|
||||
"\ufeff" +
|
||||
"\uffa0" +
|
||||
"\ufff0-\ufff8]",
|
||||
)
|
||||
|
||||
// skillNamePattern validates kebab-case skill names.
|
||||
var skillNamePattern = regexp.MustCompile(
|
||||
`^[a-z0-9]+(-[a-z0-9]+)*$`,
|
||||
)
|
||||
|
||||
// Default values for agent-internal configuration. These are
|
||||
// used when the corresponding env vars are unset.
|
||||
const (
|
||||
DefaultInstructionsDir = "~/.coder"
|
||||
DefaultInstructionsFile = "AGENTS.md"
|
||||
DefaultSkillsDir = ".agents/skills"
|
||||
DefaultSkillMetaFile = "SKILL.md"
|
||||
DefaultMCPConfigFile = ".mcp.json"
|
||||
)
|
||||
|
||||
// API exposes the resolved context configuration through the
|
||||
// agent's HTTP API.
|
||||
@@ -42,33 +84,61 @@ func NewAPI(workingDir func() string) *API {
|
||||
return &API{workingDir: workingDir}
|
||||
}
|
||||
|
||||
// Config reads env vars and resolves paths. Exported for use
|
||||
// by the MCP manager and tests.
|
||||
func Config(workingDir string) workspacesdk.ContextConfigResponse {
|
||||
// Config reads env vars, resolves paths, reads instruction files,
|
||||
// and discovers skills. Returns the HTTP response and the resolved
|
||||
// MCP config file paths (used only agent-internally). Exported
|
||||
// for use by tests.
|
||||
func Config(workingDir string) (workspacesdk.ContextConfigResponse, []string) {
|
||||
// TrimSpace all env vars before cmp.Or so that a
|
||||
// whitespace-only value falls through to the default
|
||||
// consistently. ResolvePaths also trims each comma-
|
||||
// separated entry, but without pre-trimming here a
|
||||
// bare " " would bypass cmp.Or and produce nil.
|
||||
instructionsDir := cmp.Or(strings.TrimSpace(os.Getenv(EnvInstructionsDirs)), workspacesdk.DefaultInstructionsDir)
|
||||
instructionsFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvInstructionsFile)), workspacesdk.DefaultInstructionsFile)
|
||||
skillsDir := cmp.Or(strings.TrimSpace(os.Getenv(EnvSkillsDirs)), workspacesdk.DefaultSkillsDir)
|
||||
skillMetaFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvSkillMetaFile)), workspacesdk.DefaultSkillMetaFile)
|
||||
mcpConfigFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvMCPConfigFiles)), workspacesdk.DefaultMCPConfigFile)
|
||||
instructionsDir := cmp.Or(strings.TrimSpace(os.Getenv(EnvInstructionsDirs)), DefaultInstructionsDir)
|
||||
instructionsFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvInstructionsFile)), DefaultInstructionsFile)
|
||||
skillsDir := cmp.Or(strings.TrimSpace(os.Getenv(EnvSkillsDirs)), DefaultSkillsDir)
|
||||
skillMetaFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvSkillMetaFile)), DefaultSkillMetaFile)
|
||||
mcpConfigFile := cmp.Or(strings.TrimSpace(os.Getenv(EnvMCPConfigFiles)), DefaultMCPConfigFile)
|
||||
|
||||
resolvedInstructionsDirs := ResolvePaths(instructionsDir, workingDir)
|
||||
resolvedSkillsDirs := ResolvePaths(skillsDir, workingDir)
|
||||
|
||||
// Read instruction files from each configured directory.
|
||||
parts := readInstructionFiles(resolvedInstructionsDirs, instructionsFile)
|
||||
|
||||
// Also check the working directory for the instruction file,
|
||||
// unless it was already covered by InstructionsDirs.
|
||||
if workingDir != "" {
|
||||
seenDirs := make(map[string]struct{}, len(resolvedInstructionsDirs))
|
||||
for _, d := range resolvedInstructionsDirs {
|
||||
seenDirs[d] = struct{}{}
|
||||
}
|
||||
if _, ok := seenDirs[workingDir]; !ok {
|
||||
if entry, found := readInstructionFileFromDir(workingDir, instructionsFile); found {
|
||||
parts = append(parts, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discover skills from each configured skills directory.
|
||||
skillParts := discoverSkills(resolvedSkillsDirs, skillMetaFile)
|
||||
parts = append(parts, skillParts...)
|
||||
|
||||
// Guarantee non-nil slice to signal agent support.
|
||||
if parts == nil {
|
||||
parts = []codersdk.ChatMessagePart{}
|
||||
}
|
||||
|
||||
return workspacesdk.ContextConfigResponse{
|
||||
InstructionsDirs: ResolvePaths(instructionsDir, workingDir),
|
||||
InstructionsFile: instructionsFile,
|
||||
SkillsDirs: ResolvePaths(skillsDir, workingDir),
|
||||
SkillMetaFile: skillMetaFile,
|
||||
MCPConfigFiles: ResolvePaths(mcpConfigFile, workingDir),
|
||||
}
|
||||
Parts: parts,
|
||||
}, ResolvePaths(mcpConfigFile, workingDir)
|
||||
}
|
||||
|
||||
// Config returns the resolved config for use by other agent
|
||||
// components (e.g. MCP manager).
|
||||
func (api *API) Config() workspacesdk.ContextConfigResponse {
|
||||
return Config(api.workingDir())
|
||||
// MCPConfigFiles returns the resolved MCP configuration file
|
||||
// paths for the agent's MCP manager.
|
||||
func (api *API) MCPConfigFiles() []string {
|
||||
_, mcpFiles := Config(api.workingDir())
|
||||
return mcpFiles
|
||||
}
|
||||
|
||||
// Routes returns the HTTP handler for the context config
|
||||
@@ -80,5 +150,164 @@ func (api *API) Routes() http.Handler {
|
||||
}
|
||||
|
||||
func (api *API) handleGet(rw http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, api.Config())
|
||||
response, _ := Config(api.workingDir())
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// readInstructionFiles reads instruction files from each given
|
||||
// directory. Missing directories are silently skipped. Duplicate
|
||||
// directories are deduplicated.
|
||||
func readInstructionFiles(dirs []string, fileName string) []codersdk.ChatMessagePart {
|
||||
var parts []codersdk.ChatMessagePart
|
||||
seen := make(map[string]struct{}, len(dirs))
|
||||
for _, dir := range dirs {
|
||||
if _, ok := seen[dir]; ok {
|
||||
continue
|
||||
}
|
||||
seen[dir] = struct{}{}
|
||||
if part, found := readInstructionFileFromDir(dir, fileName); found {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// readInstructionFileFromDir scans a directory for a file matching
|
||||
// fileName (case-insensitive) and reads its contents.
|
||||
func readInstructionFileFromDir(dir, fileName string) (codersdk.ChatMessagePart, bool) {
|
||||
dirEntries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return codersdk.ChatMessagePart{}, false
|
||||
}
|
||||
|
||||
for _, e := range dirEntries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(e.Name()), fileName) {
|
||||
filePath := filepath.Join(dir, e.Name())
|
||||
content, truncated, ok := readAndSanitizeFile(filePath, maxInstructionFileBytes)
|
||||
if !ok {
|
||||
return codersdk.ChatMessagePart{}, false
|
||||
}
|
||||
if content == "" {
|
||||
return codersdk.ChatMessagePart{}, false
|
||||
}
|
||||
return codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeContextFile,
|
||||
ContextFilePath: filePath,
|
||||
ContextFileContent: content,
|
||||
ContextFileTruncated: truncated,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
return codersdk.ChatMessagePart{}, false
|
||||
}
|
||||
|
||||
// readAndSanitizeFile reads the file at path, capping the read
|
||||
// at maxBytes to avoid unbounded memory allocation. It sanitizes
|
||||
// the content (strips HTML comments and invisible Unicode) and
|
||||
// returns the result. Returns false if the file cannot be read.
|
||||
func readAndSanitizeFile(path string, maxBytes int64) (content string, truncated bool, ok bool) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Read at most maxBytes+1 to detect truncation without
|
||||
// allocating the entire file into memory.
|
||||
raw, err := io.ReadAll(io.LimitReader(f, maxBytes+1))
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
truncated = int64(len(raw)) > maxBytes
|
||||
if truncated {
|
||||
raw = raw[:maxBytes]
|
||||
}
|
||||
|
||||
s := sanitizeInstructionMarkdown(string(raw))
|
||||
if s == "" {
|
||||
return "", truncated, true
|
||||
}
|
||||
return s, truncated, true
|
||||
}
|
||||
|
||||
// sanitizeInstructionMarkdown strips HTML comments, invisible
|
||||
// Unicode characters, and CRLF line endings from instruction
|
||||
// file content.
|
||||
func sanitizeInstructionMarkdown(content string) string {
|
||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
content = strings.ReplaceAll(content, "\r", "\n")
|
||||
content = markdownCommentPattern.ReplaceAllString(content, "")
|
||||
content = invisibleRunePattern.ReplaceAllString(content, "")
|
||||
return strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
// discoverSkills walks the given skills directories and returns
|
||||
// metadata for every valid skill it finds. Body and supporting
|
||||
// file lists are NOT included; chatd fetches those on demand
|
||||
// via read_skill. Missing directories or individual errors are
|
||||
// silently skipped.
|
||||
func discoverSkills(skillsDirs []string, metaFile string) []codersdk.ChatMessagePart {
|
||||
seen := make(map[string]struct{})
|
||||
var parts []codersdk.ChatMessagePart
|
||||
|
||||
for _, skillsDir := range skillsDirs {
|
||||
entries, err := os.ReadDir(skillsDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
metaPath := filepath.Join(skillsDir, entry.Name(), metaFile)
|
||||
f, err := os.Open(metaPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(f, maxSkillMetaBytes+1))
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if int64(len(raw)) > maxSkillMetaBytes {
|
||||
raw = raw[:maxSkillMetaBytes]
|
||||
}
|
||||
|
||||
name, description, _, err := workspacesdk.ParseSkillFrontmatter(string(raw))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// The directory name must match the declared name.
|
||||
if name != entry.Name() {
|
||||
continue
|
||||
}
|
||||
if !skillNamePattern.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
// First occurrence wins across directories.
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
|
||||
skillDir := filepath.Join(skillsDir, entry.Name())
|
||||
parts = append(parts, codersdk.ChatMessagePart{
|
||||
Type: codersdk.ChatMessagePartTypeSkill,
|
||||
SkillName: name,
|
||||
SkillDescription: description,
|
||||
SkillDir: skillDir,
|
||||
ContextFileSkillMetaFile: metaFile,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
package agentcontextconfig_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentcontextconfig"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
// filterParts returns only the parts matching the given type.
|
||||
func filterParts(parts []codersdk.ChatMessagePart, t codersdk.ChatMessagePartType) []codersdk.ChatMessagePart {
|
||||
var out []codersdk.ChatMessagePart
|
||||
for _, p := range parts {
|
||||
if p.Type == t {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestConfig(t *testing.T) {
|
||||
t.Run("Defaults", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
@@ -24,19 +37,13 @@ func TestConfig(t *testing.T) {
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := platformAbsPath("work")
|
||||
cfg := agentcontextconfig.Config(workDir)
|
||||
cfg, mcpFiles := agentcontextconfig.Config(workDir)
|
||||
|
||||
require.Equal(t, workspacesdk.DefaultInstructionsFile, cfg.InstructionsFile)
|
||||
require.Equal(t, workspacesdk.DefaultSkillMetaFile, cfg.SkillMetaFile)
|
||||
// Default instructions dir is "~/.coder" which resolves
|
||||
// to the home directory.
|
||||
require.Equal(t, []string{filepath.Join(fakeHome, ".coder")}, cfg.InstructionsDirs)
|
||||
// Default skills dir is ".agents/skills" (relative),
|
||||
// resolved against the working directory.
|
||||
require.Equal(t, []string{filepath.Join(workDir, ".agents", "skills")}, cfg.SkillsDirs)
|
||||
// Parts is always non-nil.
|
||||
require.NotNil(t, cfg.Parts)
|
||||
// Default MCP config file is ".mcp.json" (relative),
|
||||
// resolved against the working directory.
|
||||
require.Equal(t, []string{filepath.Join(workDir, ".mcp.json")}, cfg.MCPConfigFiles)
|
||||
require.Equal(t, []string{filepath.Join(workDir, ".mcp.json")}, mcpFiles)
|
||||
})
|
||||
|
||||
t.Run("CustomEnvVars", func(t *testing.T) {
|
||||
@@ -44,8 +51,8 @@ func TestConfig(t *testing.T) {
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
|
||||
optInstructions := platformAbsPath("opt", "instructions")
|
||||
optSkills := platformAbsPath("opt", "skills")
|
||||
optInstructions := t.TempDir()
|
||||
optSkills := t.TempDir()
|
||||
optMCP := platformAbsPath("opt", "mcp.json")
|
||||
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, optInstructions)
|
||||
@@ -54,32 +61,58 @@ func TestConfig(t *testing.T) {
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "META.yaml")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, optMCP)
|
||||
|
||||
workDir := platformAbsPath("work")
|
||||
cfg := agentcontextconfig.Config(workDir)
|
||||
// Create files matching the custom names so we can
|
||||
// verify the env vars actually change lookup behavior.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(optInstructions, "CUSTOM.md"), []byte("custom instructions"), 0o600))
|
||||
skillDir := filepath.Join(optSkills, "my-skill")
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(skillDir, "META.yaml"),
|
||||
[]byte("---\nname: my-skill\ndescription: custom meta\n---\n"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
require.Equal(t, "CUSTOM.md", cfg.InstructionsFile)
|
||||
require.Equal(t, "META.yaml", cfg.SkillMetaFile)
|
||||
require.Equal(t, []string{optInstructions}, cfg.InstructionsDirs)
|
||||
require.Equal(t, []string{optSkills}, cfg.SkillsDirs)
|
||||
require.Equal(t, []string{optMCP}, cfg.MCPConfigFiles)
|
||||
workDir := platformAbsPath("work")
|
||||
cfg, mcpFiles := agentcontextconfig.Config(workDir)
|
||||
|
||||
require.Equal(t, []string{optMCP}, mcpFiles)
|
||||
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
|
||||
require.Len(t, ctxFiles, 1)
|
||||
require.Equal(t, "custom instructions", ctxFiles[0].ContextFileContent)
|
||||
skillParts := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeSkill)
|
||||
require.Len(t, skillParts, 1)
|
||||
require.Equal(t, "my-skill", skillParts[0].SkillName)
|
||||
require.Equal(t, "META.yaml", skillParts[0].ContextFileSkillMetaFile)
|
||||
})
|
||||
|
||||
t.Run("WhitespaceInFileNames", func(t *testing.T) {
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, " CLAUDE.md ")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := platformAbsPath("work")
|
||||
cfg := agentcontextconfig.Config(workDir)
|
||||
workDir := t.TempDir()
|
||||
// Create a file matching the trimmed name.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(fakeHome, "CLAUDE.md"), []byte("hello"), 0o600))
|
||||
|
||||
require.Equal(t, "CLAUDE.md", cfg.InstructionsFile)
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
|
||||
require.Len(t, ctxFiles, 1)
|
||||
require.Equal(t, "hello", ctxFiles[0].ContextFileContent)
|
||||
})
|
||||
|
||||
t.Run("CommaSeparatedDirs", func(t *testing.T) {
|
||||
a := platformAbsPath("opt", "a")
|
||||
b := platformAbsPath("opt", "b")
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
|
||||
a := t.TempDir()
|
||||
b := t.TempDir()
|
||||
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, a+","+b)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
@@ -87,10 +120,300 @@ func TestConfig(t *testing.T) {
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := platformAbsPath("work")
|
||||
cfg := agentcontextconfig.Config(workDir)
|
||||
// Put instruction files in both dirs.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(a, "AGENTS.md"), []byte("from a"), 0o600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(b, "AGENTS.md"), []byte("from b"), 0o600))
|
||||
|
||||
require.Equal(t, []string{a, b}, cfg.InstructionsDirs)
|
||||
workDir := t.TempDir()
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
|
||||
require.Len(t, ctxFiles, 2)
|
||||
require.Equal(t, "from a", ctxFiles[0].ContextFileContent)
|
||||
require.Equal(t, "from b", ctxFiles[1].ContextFileContent)
|
||||
})
|
||||
|
||||
t.Run("ReadsInstructionFiles", func(t *testing.T) {
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
|
||||
// Create ~/.coder/AGENTS.md
|
||||
coderDir := filepath.Join(fakeHome, ".coder")
|
||||
require.NoError(t, os.MkdirAll(coderDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(coderDir, "AGENTS.md"),
|
||||
[]byte("home instructions"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
|
||||
require.NotNil(t, cfg.Parts)
|
||||
require.Len(t, ctxFiles, 1)
|
||||
require.Equal(t, "home instructions", ctxFiles[0].ContextFileContent)
|
||||
require.Equal(t, filepath.Join(coderDir, "AGENTS.md"), ctxFiles[0].ContextFilePath)
|
||||
require.False(t, ctxFiles[0].ContextFileTruncated)
|
||||
})
|
||||
|
||||
t.Run("ReadsWorkingDirInstructionFile", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
|
||||
// Create AGENTS.md in the working directory.
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(workDir, "AGENTS.md"),
|
||||
[]byte("project instructions"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
// Should find the working dir file (not in instruction dirs).
|
||||
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
|
||||
require.NotNil(t, cfg.Parts)
|
||||
require.Len(t, ctxFiles, 1)
|
||||
require.Equal(t, "project instructions", ctxFiles[0].ContextFileContent)
|
||||
require.Equal(t, filepath.Join(workDir, "AGENTS.md"), ctxFiles[0].ContextFilePath)
|
||||
})
|
||||
|
||||
t.Run("TruncatesLargeInstructionFile", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
largeContent := strings.Repeat("a", 64*1024+100)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(largeContent), 0o600))
|
||||
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
|
||||
require.Len(t, ctxFiles, 1)
|
||||
require.True(t, ctxFiles[0].ContextFileTruncated)
|
||||
require.Len(t, ctxFiles[0].ContextFileContent, 64*1024)
|
||||
})
|
||||
|
||||
t.Run("SanitizesHTMLComments", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(workDir, "AGENTS.md"),
|
||||
[]byte("visible\n<!-- hidden -->content"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
|
||||
require.Len(t, ctxFiles, 1)
|
||||
require.Equal(t, "visible\ncontent", ctxFiles[0].ContextFileContent)
|
||||
})
|
||||
|
||||
t.Run("SanitizesInvisibleUnicode", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
// U+200B (zero-width space) should be stripped.
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(workDir, "AGENTS.md"),
|
||||
[]byte("before\u200bafter"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
|
||||
require.Len(t, ctxFiles, 1)
|
||||
require.Equal(t, "beforeafter", ctxFiles[0].ContextFileContent)
|
||||
})
|
||||
|
||||
t.Run("NormalizesCRLF", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(workDir, "AGENTS.md"),
|
||||
[]byte("line1\r\nline2\rline3"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
ctxFiles := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeContextFile)
|
||||
require.Len(t, ctxFiles, 1)
|
||||
require.Equal(t, "line1\nline2\nline3", ctxFiles[0].ContextFileContent)
|
||||
})
|
||||
|
||||
t.Run("DiscoversSkills", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
skillsDir := filepath.Join(workDir, ".agents", "skills")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, skillsDir)
|
||||
|
||||
// Create a valid skill.
|
||||
skillDir := filepath.Join(skillsDir, "my-skill")
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(skillDir, "SKILL.md"),
|
||||
[]byte("---\nname: my-skill\ndescription: A test skill\n---\nSkill body"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
skillParts := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeSkill)
|
||||
require.Len(t, skillParts, 1)
|
||||
require.Equal(t, "my-skill", skillParts[0].SkillName)
|
||||
require.Equal(t, "A test skill", skillParts[0].SkillDescription)
|
||||
require.Equal(t, skillDir, skillParts[0].SkillDir)
|
||||
require.Equal(t, "SKILL.md", skillParts[0].ContextFileSkillMetaFile)
|
||||
})
|
||||
|
||||
t.Run("SkipsMissingDirs", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
|
||||
nonExistent := filepath.Join(t.TempDir(), "does-not-exist")
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, nonExistent)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, nonExistent)
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
|
||||
// Non-nil empty slice (signals agent supports new format).
|
||||
require.NotNil(t, cfg.Parts)
|
||||
require.Empty(t, cfg.Parts)
|
||||
})
|
||||
|
||||
t.Run("MCPConfigFilesResolvedSeparately", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
|
||||
optMCP := platformAbsPath("opt", "custom.json")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, optMCP)
|
||||
|
||||
workDir := t.TempDir()
|
||||
_, mcpFiles := agentcontextconfig.Config(workDir)
|
||||
|
||||
require.Equal(t, []string{optMCP}, mcpFiles)
|
||||
})
|
||||
|
||||
t.Run("SkillNameMustMatchDir", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
skillsDir := filepath.Join(workDir, "skills")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, skillsDir)
|
||||
|
||||
// Skill name in frontmatter doesn't match directory name.
|
||||
skillDir := filepath.Join(skillsDir, "wrong-dir-name")
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(skillDir, "SKILL.md"),
|
||||
[]byte("---\nname: actual-name\ndescription: mismatch\n---\n"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
skillParts := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeSkill)
|
||||
require.Empty(t, skillParts)
|
||||
})
|
||||
|
||||
t.Run("DuplicateSkillsFirstWins", func(t *testing.T) {
|
||||
fakeHome := t.TempDir()
|
||||
t.Setenv("HOME", fakeHome)
|
||||
t.Setenv("USERPROFILE", fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsDirs, fakeHome)
|
||||
t.Setenv(agentcontextconfig.EnvInstructionsFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvSkillMetaFile, "")
|
||||
t.Setenv(agentcontextconfig.EnvMCPConfigFiles, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
skillsDir1 := filepath.Join(workDir, "skills1")
|
||||
skillsDir2 := filepath.Join(workDir, "skills2")
|
||||
t.Setenv(agentcontextconfig.EnvSkillsDirs, skillsDir1+","+skillsDir2)
|
||||
|
||||
// Same skill name in both directories.
|
||||
for _, dir := range []string{skillsDir1, skillsDir2} {
|
||||
skillDir := filepath.Join(dir, "dup-skill")
|
||||
require.NoError(t, os.MkdirAll(skillDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(skillDir, "SKILL.md"),
|
||||
[]byte("---\nname: dup-skill\ndescription: from "+filepath.Base(dir)+"\n---\n"),
|
||||
0o600,
|
||||
))
|
||||
}
|
||||
|
||||
cfg, _ := agentcontextconfig.Config(workDir)
|
||||
skillParts := filterParts(cfg.Parts, codersdk.ChatMessagePartTypeSkill)
|
||||
require.Len(t, skillParts, 1)
|
||||
require.Equal(t, "from skills1", skillParts[0].SkillDescription)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -104,14 +427,13 @@ func TestNewAPI_LazyDirectory(t *testing.T) {
|
||||
dir := ""
|
||||
api := agentcontextconfig.NewAPI(func() string { return dir })
|
||||
|
||||
// Before directory is set, relative paths resolve to nothing.
|
||||
cfg := api.Config()
|
||||
require.Empty(t, cfg.SkillsDirs)
|
||||
require.Empty(t, cfg.MCPConfigFiles)
|
||||
// Before directory is set, MCP paths resolve to nothing.
|
||||
mcpFiles := api.MCPConfigFiles()
|
||||
require.Empty(t, mcpFiles)
|
||||
|
||||
// After setting the directory, Config() picks it up lazily.
|
||||
// After setting the directory, MCPConfigFiles() picks it up.
|
||||
dir = platformAbsPath("work")
|
||||
cfg = api.Config()
|
||||
require.NotEmpty(t, cfg.SkillsDirs)
|
||||
require.Equal(t, []string{filepath.Join(dir, ".agents", "skills")}, cfg.SkillsDirs)
|
||||
mcpFiles = api.MCPConfigFiles()
|
||||
require.NotEmpty(t, mcpFiles)
|
||||
require.Equal(t, []string{filepath.Join(dir, ".mcp.json")}, mcpFiles)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user