Files
coder/coderd/x/chatd/context_prompt.go
T
Cian Johnston b21e0717d5 feat: remove chat chain mode (#26980)
Removes OpenAI Responses "chain mode" from chatd. Closes CODAGT-445.

- Deletes `chatopenai/responses.go` (chain detection, activation, prompt filtering, response ID extraction) and its tests.
- Deletes the `ChainBroken` classification in `chaterror` and the chatloop retry bookkeeping that disabled chain mode mid-generation.
- Drops the `chain_broken` label from the `coderd_chatd_stream_retries_total` metric.
- Stops reading and writing `chat_messages.provider_response_id`
- Deletes the dead `ClearChatMessageProviderResponseIDsByChatID` query. Dropping the column is a follow-up migration.
- Deletes three chatloop hooks no caller sets (`ReloadMessages`, `DisableChainMode`, `PrepareMessages`), the dead `const AgentChatContextSentinelPath`, and stale chain-mode comments.

🤖 Generated by Coder Agents on behalf of @johnstcn.
2026-07-06 11:57:12 +01:00

423 lines
16 KiB
Go

package chatd
import (
"context"
"encoding/json"
"strings"
"golang.org/x/xerrors"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
"cdr.dev/slog/v3"
agentproto "github.com/coder/coder/v2/agent/proto"
"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"
)
// contextBodyUnmarshalOptions reads the protojson resource bodies written by
// the agent context push (coderd/agentapi/context.go). DiscardUnknown keeps
// the reader forward compatible as new body fields are added to the proto.
var contextBodyUnmarshalOptions = protojson.UnmarshalOptions{DiscardUnknown: true}
// decodeInstructionFileBody decodes a protojson instruction-file resource
// body. ok is false when the body cannot be decoded, letting callers count it
// as malformed rather than silently treating it as empty.
func decodeInstructionFileBody(body json.RawMessage) (*agentproto.InstructionFileBody, bool) {
var decoded agentproto.InstructionFileBody
if err := contextBodyUnmarshalOptions.Unmarshal(body, &decoded); err != nil {
return nil, false
}
return &decoded, true
}
// decodeSkillMetaBody decodes a protojson skill resource body. ok is false
// when the body cannot be decoded.
func decodeSkillMetaBody(body json.RawMessage) (*agentproto.SkillMetaBody, bool) {
var decoded agentproto.SkillMetaBody
if err := contextBodyUnmarshalOptions.Unmarshal(body, &decoded); err != nil {
return nil, false
}
return &decoded, true
}
// mcpToolNameSeparator joins a server name and a tool name into the
// flattened "<server>__<tool>" form. The agent reports MCP tool names
// unprefixed alongside the server name; the workspace agent's MCP proxy
// expects this flattened form to route a call back to the owning server
// (see agent/x/agentmcp ToolNameSep).
const mcpToolNameSeparator = "__"
// mcpToolsFromServerBody decodes a stored mcp_server resource body and returns
// its tool list for the chat response. The agent prefixes each tool name with
// "<server>__"; that prefix is stripped so the name reads as the server
// exposes it. Returns nil when the body has no tools or cannot be decoded.
func mcpToolsFromServerBody(server string, body json.RawMessage) []codersdk.ChatContextTool {
var decoded agentproto.MCPServerBody
if err := contextBodyUnmarshalOptions.Unmarshal(body, &decoded); err != nil {
return nil
}
tools := decoded.GetTools()
if len(tools) == 0 {
return nil
}
prefix := server + mcpToolNameSeparator
out := make([]codersdk.ChatContextTool, 0, len(tools))
for _, t := range tools {
name := strings.TrimPrefix(t.GetName(), prefix)
if name == "" {
continue
}
out = append(out, codersdk.ChatContextTool{
Name: name,
Description: t.GetDescription(),
})
}
if len(out) == 0 {
return nil
}
return out
}
// workspaceMCPToolInfosFromResources decodes a chat's pinned mcp_server
// resources into execution-ready tool infos. Only OK mcp_server rows
// contribute. The agent reports tool names unprefixed alongside the server
// name, so each tool is re-prefixed to "<server>__<tool>", the model-facing
// and proxy-routable form the live discovery path also produces. The pushed
// input schema is a full JSON Schema object; its "properties" and "required"
// are split out to match the shape the workspace agent's live tool list
// produces (see agent/x/agentmcp). Tools with an empty name are skipped.
func workspaceMCPToolInfosFromResources(resources []database.ChatContextResource) []workspacesdk.MCPToolInfo {
var out []workspacesdk.MCPToolInfo
for _, r := range resources {
if r.BodyKind != database.WorkspaceAgentContextBodyKindMcpServer ||
r.Status != database.WorkspaceAgentContextResourceStatusOk {
continue
}
var decoded agentproto.MCPServerBody
if err := contextBodyUnmarshalOptions.Unmarshal(r.Body, &decoded); err != nil {
continue
}
server := decoded.GetServerName()
if server == "" {
server = r.Source
}
for _, t := range decoded.GetTools() {
name := t.GetName()
if name == "" {
continue
}
properties, required := splitMCPInputSchema(t.GetInputSchema())
out = append(out, workspacesdk.MCPToolInfo{
ServerName: server,
Name: server + mcpToolNameSeparator + name,
Description: t.GetDescription(),
Schema: properties,
Required: required,
})
}
}
return out
}
// splitMCPInputSchema splits a pushed JSON Schema object into the properties
// map and required list the workspace MCP tool wrapper expects. A nil schema,
// or one missing these keys, yields nil for the absent part.
func splitMCPInputSchema(schema *structpb.Struct) (properties map[string]any, required []string) {
if schema == nil {
return nil, nil
}
m := schema.AsMap()
if props, ok := m["properties"].(map[string]any); ok {
properties = props
}
if raw, ok := m["required"].([]any); ok {
for _, v := range raw {
if s, ok := v.(string); ok {
required = append(required, s)
}
}
}
return properties, required
}
// decodeInstructionContent decodes an instruction-file resource body and
// returns its sanitized content. decoded is false when the body cannot be
// decoded, letting the prompt path count it as malformed; content is empty
// when the file sanitizes to nothing, in which case callers skip it. Shared by
// the prompt builder and the API resource listing so both interpret an
// instruction file the same way.
func decodeInstructionContent(body json.RawMessage) (content string, decoded bool) {
decodedBody, ok := decodeInstructionFileBody(body)
if !ok {
return "", false
}
return SanitizePromptText(string(decodedBody.GetContent())), true
}
// decodeSkillIdentity decodes a skill resource body and returns its name and
// description. decoded is false when the body cannot be decoded, letting the
// prompt path count it as malformed; callers skip a skill with an empty name.
// Shared by the prompt builder and the API resource listing.
func decodeSkillIdentity(body json.RawMessage) (name, description string, decoded bool) {
decodedBody, ok := decodeSkillMetaBody(body)
if !ok {
return "", "", false
}
return decodedBody.GetName(), decodedBody.GetDescription(), true
}
// pinnedWorkspaceContext builds the system-prompt instruction block and
// workspace skills from the chat's pinned context resources
// (chat_context_resources), populated at hydrate and refresh time. A chat
// with no pinned rows yields no context. A read error is returned rather than
// swallowed, matching the other prompt-input reads in prepareGeneration.
//
// agent only decorates the instruction header with its OS and directory; an
// unresolved (zero-value) agent does not blank the context, so the pin keeps
// working when the workspace is unreachable.
func (server *Server) pinnedWorkspaceContext(
ctx context.Context,
chat database.Chat,
agent database.WorkspaceAgent,
) (instruction string, skills []chattool.SkillMeta, err error) {
resources, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID)
if err != nil {
return "", nil, xerrors.Errorf("list chat context resources: %w", err)
}
if len(resources) == 0 {
return "", nil, nil
}
directory := agent.ExpandedDirectory
if directory == "" {
directory = agent.Directory
}
instruction, skills, malformed := contextResourcesToPrompt(resources, agent.OperatingSystem, directory)
if malformed > 0 {
// A status-OK resource whose body cannot be decoded means the pin
// hydrated content that is now unreadable; surface it so a proto
// or encoding regression does not silently drop context.
server.logger.Warn(ctx, "skipped malformed pinned chat context resources",
slog.F("chat_id", chat.ID),
slog.F("malformed_count", malformed),
slog.F("resource_count", len(resources)),
)
}
server.logger.Debug(ctx, "built prompt context from pinned chat resources",
slog.F("chat_id", chat.ID),
slog.F("resource_count", len(resources)),
slog.F("skill_count", len(skills)),
slog.F("has_instruction", instruction != ""),
)
return instruction, skills, nil
}
// resolveTurnWorkspaceContext selects the instruction block and workspace
// skills for a turn from the chat's pinned context snapshot
// (chat_context_resources). agent is the chat's resolved workspace agent,
// used only to decorate the pinned instruction header. A non-workspace chat
// yields no context.
func (server *Server) resolveTurnWorkspaceContext(
ctx context.Context,
chat database.Chat,
agent database.WorkspaceAgent,
) (instruction string, skills []chattool.SkillMeta, err error) {
if !chat.WorkspaceID.Valid {
return "", nil, nil
}
return server.pinnedWorkspaceContext(ctx, chat, agent)
}
// contextResourcesToPrompt converts a chat's pinned context resources into
// the formatted instruction block and workspace skill metadata, the inverse
// of the protojson bodies written by the agent context push.
//
// operatingSystem and directory annotate the instruction header and are
// omitted when empty. Only OK resources of a prompt body kind contribute;
// other statuses, body kinds, and malformed bodies are skipped. malformed
// counts OK resources whose body failed to decode, so the caller can surface
// an otherwise silent drop. The header is emitted only when at least one
// instruction file has content, so a skill-only pin produces no instruction
// block, matching the per-turn path.
func contextResourcesToPrompt(
resources []database.ChatContextResource,
operatingSystem, directory string,
) (instruction string, skills []chattool.SkillMeta, malformed int) {
var contextFileParts []codersdk.ChatMessagePart
for _, r := range resources {
if r.Status != database.WorkspaceAgentContextResourceStatusOk {
continue
}
switch r.BodyKind {
case database.WorkspaceAgentContextBodyKindInstructionFile:
content, decoded := decodeInstructionContent(r.Body)
if !decoded {
malformed++
continue
}
if content == "" {
continue
}
contextFileParts = append(contextFileParts, codersdk.ChatMessagePart{
Type: codersdk.ChatMessagePartTypeContextFile,
ContextFilePath: r.Source,
ContextFileContent: content,
})
case database.WorkspaceAgentContextBodyKindSkill:
decodedBody, ok := decodeSkillMetaBody(r.Body)
if !ok {
malformed++
continue
}
if decodedBody.GetName() == "" {
continue
}
// source is the skill directory. MetaFile is left empty so
// chattool falls back to DefaultSkillMetaFile ("SKILL.md").
// SkillMetaBody carries no meta file name, so a non-default
// CODER_AGENT_EXP_SKILL_META_FILE is not preserved on this
// path, unlike the per-turn discovery path. Meta carries the
// verbatim SKILL.md so read_skill serves the body from the
// pin instead of dialing the workspace.
skills = append(skills, chattool.SkillMeta{
Name: decodedBody.GetName(),
Description: decodedBody.GetDescription(),
Dir: r.Source,
Meta: decodedBody.GetMeta(),
})
}
}
if len(contextFileParts) == 0 {
return "", skills, malformed
}
return formatSystemInstructions(operatingSystem, directory, contextFileParts), skills, malformed
}
// ContextResources returns the chat's pinned context resource list (metadata
// only). It is read-only and intended for the single-chat GET handler; list
// and watch payloads omit this detail to stay lightweight.
//
// The returned list is the chat's full pinned inventory (instruction files,
// skills, and MCP configs/servers), each stamped with its per-resource status
// so the UI can explain why a resource was dropped from the prompt instead of
// silently omitting it.
func (server *Server) ContextResources(
ctx context.Context,
chat database.Chat,
) ([]codersdk.ChatContextResource, error) {
pinned, err := server.db.ListChatContextResourcesByChatID(ctx, chat.ID)
if err != nil {
return nil, xerrors.Errorf("list chat context resources: %w", err)
}
resources := pinnedContextResources(pinned)
server.logger.Debug(ctx, "computed chat context resources",
slog.F("chat_id", chat.ID),
slog.F("resource_count", len(resources)),
)
return resources, nil
}
// pinnedContextResources converts a chat's pinned context rows into the
// metadata-only resource list reported on the chat. It is the reporting
// counterpart to contextResourcesToPrompt: both walk the same rows and share
// the body decoders, but where the prompt builder keeps only OK instruction
// files and skills (and ignores everything else), this surfaces the full
// inventory the user can act on, each stamped with its Status:
//
// - OK instruction files with non-empty (sanitized) content, OK skills with
// a name, and OK MCP configs/servers (mcp_server carries its tools).
// - Non-OK rows (invalid, unreadable, oversize, excluded) of a tracked kind,
// carrying Status and Error so the UI can explain why the resource was
// dropped from the prompt instead of silently omitting it. Their
// body-specific fields are empty.
//
// OK-but-empty instruction files, OK skills with no name, and untracked kinds
// (reserved plugin/hook/subagent/command) are skipped. Input order (source ASC
// from the query) is preserved.
func pinnedContextResources(resources []database.ChatContextResource) []codersdk.ChatContextResource {
var out []codersdk.ChatContextResource
for _, r := range resources {
kind, ok := contextResourceKind(r.BodyKind)
if !ok {
continue
}
if r.Status != database.WorkspaceAgentContextResourceStatusOk {
// Surface the failure (with its reason) rather than dropping it
// silently; the body is empty for non-OK rows.
out = append(out, codersdk.ChatContextResource{
Source: r.Source,
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatus(r.Status),
Error: r.Error,
})
continue
}
switch r.BodyKind {
case database.WorkspaceAgentContextBodyKindInstructionFile:
content, decoded := decodeInstructionContent(r.Body)
if !decoded || content == "" {
continue
}
out = append(out, codersdk.ChatContextResource{
Source: r.Source,
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatusOK,
})
case database.WorkspaceAgentContextBodyKindSkill:
name, description, decoded := decodeSkillIdentity(r.Body)
if !decoded || name == "" {
continue
}
out = append(out, codersdk.ChatContextResource{
Source: r.Source,
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatusOK,
SkillName: name,
SkillDescription: description,
})
case database.WorkspaceAgentContextBodyKindMcpConfig:
out = append(out, codersdk.ChatContextResource{
Source: r.Source,
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatusOK,
})
case database.WorkspaceAgentContextBodyKindMcpServer:
out = append(out, codersdk.ChatContextResource{
Source: r.Source,
Kind: kind,
SizeBytes: r.SizeBytes,
Status: codersdk.ChatContextResourceStatusOK,
Tools: mcpToolsFromServerBody(r.Source, r.Body),
})
}
}
return out
}
// contextResourceKind maps a database body kind to the codersdk kind reported
// on the chat. ok is false only for kinds chatd does not track yet (the
// reserved plugin/hook/subagent/command kinds), which are omitted from the
// resource list.
func contextResourceKind(kind database.WorkspaceAgentContextBodyKind) (codersdk.ChatContextResourceKind, bool) {
switch kind {
case database.WorkspaceAgentContextBodyKindInstructionFile:
return codersdk.ChatContextResourceKindInstructionFile, true
case database.WorkspaceAgentContextBodyKindSkill:
return codersdk.ChatContextResourceKindSkill, true
case database.WorkspaceAgentContextBodyKindMcpConfig:
return codersdk.ChatContextResourceKindMCPConfig, true
case database.WorkspaceAgentContextBodyKindMcpServer:
return codersdk.ChatContextResourceKindMCPServer, true
default:
return "", false
}
}