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