Files
coder/coderd/x/chatd/instruction.go
T
Kyle Carberry 919dc299fc 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.
2026-04-04 12:45:46 -04:00

143 lines
4.0 KiB
Go

package chatd
import (
"bytes"
"encoding/json"
"strings"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
"github.com/coder/coder/v2/codersdk"
)
// formatSystemInstructions builds the <workspace-context> block from
// agent metadata and zero or more context-file parts. Non-context-file
// parts in the slice are silently skipped.
func formatSystemInstructions(
operatingSystem, directory string,
parts []codersdk.ChatMessagePart,
) string {
hasContent := false
for _, part := range parts {
if part.Type == codersdk.ChatMessagePartTypeContextFile && part.ContextFileContent != "" {
hasContent = true
break
}
}
if !hasContent && 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 _, part := range parts {
if part.Type != codersdk.ChatMessagePartTypeContextFile || part.ContextFileContent == "" {
continue
}
_, _ = b.WriteString("\nSource: ")
_, _ = b.WriteString(part.ContextFilePath)
if part.ContextFileTruncated {
_, _ = b.WriteString(" (truncated to 64KiB)")
}
_, _ = b.WriteString("\n")
_, _ = b.WriteString(part.ContextFileContent)
_, _ = b.WriteString("\n")
}
_, _ = b.WriteString("</workspace-context>")
return b.String()
}
// 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 contextParts []codersdk.ChatMessagePart
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 != "" {
contextParts = append(contextParts, part)
}
}
}
return formatSystemInstructions(os, dir, contextParts)
}
// 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,
MetaFile: part.ContextFileSkillMetaFile,
})
}
}
return skills
}
// filterSkillParts returns stripped copies of skill-type parts from
// the given slice. Internal fields are removed so the result is safe
// for the cache column. Returns nil when no skill parts exist.
func filterSkillParts(parts []codersdk.ChatMessagePart) []codersdk.ChatMessagePart {
var out []codersdk.ChatMessagePart
for _, p := range parts {
if p.Type != codersdk.ChatMessagePartTypeSkill {
continue
}
cp := p
cp.StripInternal()
out = append(out, cp)
}
return out
}