Files
coder/coderd/x/chatd/instruction.go
T
Kyle Carberry 391b22aef7 feat: add CLI commands for managing chat context from workspaces (#24105)
Adds `coder exp chat context add` and `coder exp chat context clear`
commands that run inside a workspace to manage chat context files via
the agent token.

`add` reads instruction and skill files from a directory (defaulting to
cwd) and inserts them as context-file messages into an active chat.
Multiple calls are additive — `instructionFromContextFiles` already
accumulates all context-file parts across messages.

`clear` soft-deletes all context-file messages, causing
`contextFileAgentID()` to return `!found` on the next turn, which
triggers `needsInstructionPersist=true` and re-fetches defaults from the
agent.

Both commands auto-detect the target chat via `CODER_CHAT_ID` (already
set by `agentproc` on chat-spawned processes), or fall back to
single-active-chat resolution for the agent. The `--chat` flag overrides
both.

Also adds sub-agent context inheritance: `createChildSubagentChat` now
copies parent context-file messages to child chats at spawn time, so
delegated sub-agents share the same instruction context without
independently re-fetching from the workspace agent.

<details><summary>Implementation details</summary>

**New files:**
- `cli/exp_chat.go` — CLI command tree under `coder exp chat context`

**Modified files:**
- `agent/agentcontextconfig/api.go` — `ConfigFromDir()` reads context
from an arbitrary directory without env vars
- `codersdk/agentsdk/agentsdk.go` — `AddChatContext`/`ClearChatContext`
SDK methods
- `coderd/workspaceagents.go` — POST/DELETE handlers on
`/workspaceagents/me/chat-context`
- `coderd/coderd.go` — Route registration
- `coderd/database/queries/chats.sql` — `GetActiveChatsByAgentID`,
`SoftDeleteContextFileMessages`
- `coderd/database/dbauthz/dbauthz.go` — RBAC implementations for new
queries
- `coderd/x/chatd/subagent.go` — `copyParentContextFiles` for sub-agent
inheritance
- `cli/root.go` — Register `chatCommand()` in `AGPLExperimental()`

**Auth pattern:** Uses `AgentAuth` (same as `coder external-auth`) —
agent token via `CODER_AGENT_TOKEN` + `CODER_AGENT_URL` env vars.

</details>

> 🤖 Generated by Coder Agents

---------

Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
2026-04-09 16:33:00 +02:00

257 lines
7.3 KiB
Go

package chatd
import (
"bytes"
"encoding/json"
"strings"
"github.com/google/uuid"
"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()
}
// latestContextAgentID returns the most recent workspace-agent ID seen
// on any persisted context-file part, including the skill-only sentinel.
// Returns uuid.Nil, false when no stamped context-file parts exist.
func latestContextAgentID(messages []database.ChatMessage) (uuid.UUID, bool) {
var lastID uuid.UUID
found := false
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 ||
!part.ContextFileAgentID.Valid {
continue
}
lastID = part.ContextFileAgentID.UUID
found = true
break
}
}
return lastID, found
}
// 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 {
filterAgentID, filterByAgent := latestContextAgentID(messages)
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 filterByAgent && part.ContextFileAgentID.Valid &&
part.ContextFileAgentID.UUID != filterAgentID {
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)
}
// hasPersistedInstructionFiles reports whether messages include a
// persisted context-file part that should suppress another baseline
// instruction-file lookup. The workspace-agent skill-only sentinel is
// ignored so default instructions still load on fresh chats.
func hasPersistedInstructionFiles(
messages []database.ChatMessage,
) bool {
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 ||
!part.ContextFileAgentID.Valid ||
part.ContextFilePath == AgentChatContextSentinelPath {
continue
}
return true
}
}
return false
}
func mergeSkillMetas(
persisted []chattool.SkillMeta,
discovered []chattool.SkillMeta,
) []chattool.SkillMeta {
if len(persisted) == 0 {
return discovered
}
if len(discovered) == 0 {
return persisted
}
seen := make(map[string]struct{}, len(persisted)+len(discovered))
merged := make([]chattool.SkillMeta, 0, len(persisted)+len(discovered))
appendUnique := func(skill chattool.SkillMeta) {
if _, ok := seen[skill.Name]; ok {
return
}
seen[skill.Name] = struct{}{}
merged = append(merged, skill)
}
for _, skill := range discovered {
appendUnique(skill)
}
for _, skill := range persisted {
appendUnique(skill)
}
return merged
}
// selectSkillMetasForInstructionRefresh chooses which skill metadata
// should be injected on a turn that refreshes instruction files.
func selectSkillMetasForInstructionRefresh(
persisted []chattool.SkillMeta,
discovered []chattool.SkillMeta,
currentAgentID uuid.NullUUID,
latestInjectedAgentID uuid.NullUUID,
) []chattool.SkillMeta {
if currentAgentID.Valid && latestInjectedAgentID.Valid && latestInjectedAgentID.UUID == currentAgentID.UUID {
return mergeSkillMetas(persisted, discovered)
}
if !currentAgentID.Valid && len(discovered) == 0 {
return persisted
}
return discovered
}
// 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 {
filterAgentID, filterByAgent := latestContextAgentID(messages)
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
}
if filterByAgent && part.ContextFileAgentID.Valid &&
part.ContextFileAgentID.UUID != filterAgentID {
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
}