Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 120 additions & 30 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -4730,8 +4730,10 @@ func (p *Server) runChat(
}
}

var instructionInjected bool
if instruction != "" {
prompt = chatprompt.InsertSystem(prompt, instruction)
instructionInjected = true
}
prompt = renderPlanPathPrompt(prompt, resolvePlanPathBlock(ctx))
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
Expand Down Expand Up @@ -5077,6 +5079,33 @@ func (p *Server) runChat(
// start streaming build logs before the tool
// completes.
p.publishChatPubsubEvent(updatedChat, codersdk.ChatWatchEventKindStatusChange, nil)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 onChatUpdated fires before waitForBuild — mid-turn persist is a no-op for new workspaces (Edge Case Analyst P2, Contract Auditor P2)

Both create_workspace (createworkspace.go:252) and start_workspace (startworkspace.go:122) call OnChatUpdated before waitForBuild. At this point the agent is not online. fetchWorkspaceContext dials the agent, fails, and persistInstructionFiles returns ("", nil, nil). The comment at line 5060 promises AGENTS.md context for the remainder of the turn but this won’t happen for new workspaces.

The approved plan addresses this: step 2d adds a second OnChatUpdated call after waitForBuild in both createworkspace.go and startworkspace.go, and a PrepareMessages chatloop callback to inject the instruction into the running messages. Will implement in the next commit.

🤖 Generated by Coder Agents

// When a workspace is first attached mid-turn
// (e.g. via create_workspace), fetch and persist
// instruction files immediately so the LLM has
// AGENTS.md context for the remainder of this
// turn. The persisted marker prevents redundant
// fetches on subsequent turns.
if instruction == "" && updatedChat.WorkspaceID.Valid {
newInstruction, discoveredSkills, persistErr := p.persistInstructionFiles(
ctx,
updatedChat,
modelConfig.ID,
workspaceCtx.getWorkspaceAgent,
workspaceCtx.getWorkspaceConn,
)
if persistErr != nil {
p.logger.Warn(ctx, "failed to persist instruction files on workspace attach",
slog.F("chat_id", updatedChat.ID),
slog.Error(persistErr),
)
} else {
instruction = newInstruction
if len(discoveredSkills) > 0 {
skills = discoveredSkills
}
}
}
}
tools = append(tools,
chattool.ListTemplates(chat.OrganizationID, p.db, chattool.ListTemplatesOptions{
Expand Down Expand Up @@ -5311,11 +5340,27 @@ func (p *Server) runChat(
if chat.ParentChatID.Valid {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, defaultSubagentInstruction)
}
if instruction != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, instruction)
// Re-derive instruction and skills from the reloaded
// messages so that any context added during the
// chatloop (e.g. via persistInstructionFiles when
// the agent changes) is picked up after compaction.
// The captured instruction takes priority; fall
// back to persisted DB content otherwise.
reloadedInstruction := instruction
if reloadedInstruction == "" {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Double injection of instruction after compaction when instruction is acquired mid-turn (Edge Case Analyst, Contract Auditor)

When onChatUpdated sets instruction mid-turn (via create_workspace), instructionInjected stays false. If compaction fires at the end of that step, ReloadMessages injects reloadedInstruction (which equals the captured instruction) into the reloaded prompt via InsertSystem. Then on the next step, PrepareMessages sees instructionInjected == false && instruction != "" and calls InsertSystem again — duplicating AGENTS.md in the prompt.

This only manifests when compaction triggers on the same step as a workspace-creating tool call (conversation near context limit), so it's uncommon but not unrealistic for long-running chats.

Fix: set instructionInjected = true inside this ReloadMessages callback when reloadedInstruction != "".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a132d45 — added instructionInjected = true inside the ReloadMessages callback after InsertSystem, so PrepareMessages won't double-inject on the next step.

🤖 Generated by Coder Agents

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in a132d45. instructionInjected = true is now set inside the ReloadMessages callback right after InsertSystem, so PrepareMessages won't re-inject on the next step.

🤖 Generated by Coder Agents

reloadedInstruction = instructionFromContextFiles(reloadedMsgs)
}
if reloadedInstruction != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, reloadedInstruction)
instructionInjected = true
}
reloadedPrompt = renderPlanPathPrompt(reloadedPrompt, resolvePlanPathBlock(reloadCtx))
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
reloadedSkills := skillsFromParts(reloadedMsgs)
if len(reloadedSkills) == 0 {
reloadedSkills = skills
}

if skillIndex := chattool.FormatSkillIndex(reloadedSkills); skillIndex != "" {
reloadedPrompt = chatprompt.InsertSystem(reloadedPrompt, skillIndex)
}
reloadUserPrompt := p.resolveUserPrompt(reloadCtx, chat.OwnerID)
Expand All @@ -5333,7 +5378,17 @@ func (p *Server) runChat(
DisableChainMode: func() {
chainModeActive = false
},

PrepareMessages: func(msgs []fantasy.Message) []fantasy.Message {
if instructionInjected || instruction == "" {
return nil
}
instructionInjected = true
result := chatprompt.InsertSystem(msgs, instruction)
if skillIndex := chattool.FormatSkillIndex(skills); skillIndex != "" {
result = chatprompt.InsertSystem(result, skillIndex)
}
return result
},
OnRetry: func(
attempt int,
retryErr error,
Expand Down Expand Up @@ -5726,39 +5781,37 @@ func contextFileAgentID(messages []database.ChatMessage) (uuid.UUID, bool) {
return lastID, found
}

// persistInstructionFiles reads instruction files and discovers
// skills from the workspace agent, persisting both as message
// parts. This is called once when a workspace is first attached
// to a chat (or when the agent changes). Returns the formatted
// instruction string and skill index for injection into the
// current turn's prompt.
func (p *Server) persistInstructionFiles(
// fetchWorkspaceContext retrieves fresh instruction files and
// skills from the workspace agent without persisting. It handles
// agent connection, context configuration fetching, content
// sanitization, and metadata stamping. Returns the workspace
// agent, the stamped parts, discovered skills, and whether the
// workspace connection succeeded. A nil agent means the chat has
// no valid workspace or the agent lookup failed;
// workspaceConnOK is false in that case.
func (p *Server) fetchWorkspaceContext(
ctx context.Context,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 fetchWorkspaceContext never returns a non-nil error, making the err return dead code (Contract Auditor P2, Go Architect P2, Edge Case Analyst P3)

All three return paths return nil for the error. The caller captures fetchErr and branches on it, but it’s always nil. persistInstructionFiles (line 5857) also checks err != nil — dead code.

The function signals failures via nil agent / false workspaceConnOK instead, which happens to work because the switch conditions discriminate correctly. But the signature promises error reporting that never fires. Either drop the error return (and document the nil-agent/false-connOK contract), or propagate agentErr/connErr so callers can log them distinctly.

Carried forward from round 1 (Obs, no response) — upgrading to P2 given convergence across three reviewers.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped the err error return entirely. Updated both callers and the docstring to document the nil-agent / false-workspaceConnOK contract instead.

🤖 Generated by Coder Agents

chat database.Chat,
modelConfigID uuid.UUID,
getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error),
getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error),
) (instruction string, skills []chattool.SkillMeta, err error) {
) (agent *database.WorkspaceAgent, agentParts []codersdk.ChatMessagePart, discoveredSkills []chattool.SkillMeta, workspaceConnOK bool) {
if !chat.WorkspaceID.Valid || getWorkspaceAgent == nil {
return "", nil, nil
return nil, nil, nil, false
}

agent, err := getWorkspaceAgent(ctx)
if err != nil {
return "", nil, nil
loadedAgent, agentErr := getWorkspaceAgent(ctx)
if agentErr != nil {
return nil, nil, nil, false
}

directory := agent.ExpandedDirectory
directory := loadedAgent.ExpandedDirectory
if directory == "" {
directory = agent.Directory
directory = loadedAgent.Directory
}

// Fetch context configuration from the agent. Parts
// arrive pre-populated with context-file and skill entries
// so we don't need additional round-trips.
var workspaceConnOK bool
var agentParts []codersdk.ChatMessagePart

if getWorkspaceConn != nil {
instructionCtx, cancel := context.WithTimeout(ctx, p.instructionLookupTimeout)
defer cancel()
Expand Down Expand Up @@ -5789,21 +5842,15 @@ func (p *Server) persistInstructionFiles(
// Stamp server-side fields and sanitize content. The
// agent cannot know its own UUID, OS metadata, or
// directory — those are added here at the trust boundary.
var discoveredSkills []chattool.SkillMeta
var hasContent, hasContextFilePart bool
agentID := uuid.NullUUID{UUID: agent.ID, Valid: true}
agentID := uuid.NullUUID{UUID: loadedAgent.ID, Valid: true}

for i := range agentParts {
agentParts[i].ContextFileAgentID = agentID
switch agentParts[i].Type {
case codersdk.ChatMessagePartTypeContextFile:
hasContextFilePart = true
agentParts[i].ContextFileContent = SanitizePromptText(agentParts[i].ContextFileContent)
agentParts[i].ContextFileOS = agent.OperatingSystem
agentParts[i].ContextFileOS = loadedAgent.OperatingSystem
agentParts[i].ContextFileDirectory = directory
if agentParts[i].ContextFileContent != "" {
hasContent = true
}
case codersdk.ChatMessagePartTypeSkill:
discoveredSkills = append(discoveredSkills, chattool.SkillMeta{
Name: agentParts[i].SkillName,
Expand All @@ -5814,6 +5861,49 @@ func (p *Server) persistInstructionFiles(
}
}

return &loadedAgent, agentParts, discoveredSkills, workspaceConnOK
}

// persistInstructionFiles fetches AGENTS.md instruction files and
// skills from the workspace agent, persisting both as message
// parts. This is called once when a workspace is first attached
// to a chat (or when the agent changes). Returns the formatted
// instruction string and skill index for injection into the
// current turn's prompt.
func (p *Server) persistInstructionFiles(
ctx context.Context,
chat database.Chat,
modelConfigID uuid.UUID,
getWorkspaceAgent func(context.Context) (database.WorkspaceAgent, error),
getWorkspaceConn func(context.Context) (workspacesdk.AgentConn, error),
) (instruction string, skills []chattool.SkillMeta, err error) {
agent, agentParts, discoveredSkills, workspaceConnOK := p.fetchWorkspaceContext(
ctx, chat, getWorkspaceAgent, getWorkspaceConn,
)
// Defensive guard: fetchWorkspaceContext returns nil when the
// chat has no valid workspace or the agent lookup fails. It's
// cheaper to guard here than push the precondition up to all
// callers.
if agent == nil {
return "", nil, nil
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't be calling this if there is no workspace agent?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment explaining this is a defensive guard. fetchWorkspaceContext returns nil when the chat has no valid workspace or the agent lookup fails — it's cheaper to guard here than push the precondition up to all callers.

🤖 Generated by Coder Agents

}

agentID := uuid.NullUUID{UUID: agent.ID, Valid: true}
hasContent := false
hasContextFilePart := false
for _, part := range agentParts {
if part.Type == codersdk.ChatMessagePartTypeContextFile {
hasContextFilePart = true
if part.ContextFileContent != "" {
hasContent = true
}
}
}
directory := agent.ExpandedDirectory
if directory == "" {
directory = agent.Directory
}

if !hasContent {
if !workspaceConnOK {
return "", nil, nil
Expand Down
12 changes: 12 additions & 0 deletions coderd/x/chatd/chatloop/chatloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ type RunOptions struct {
Compaction *CompactionOptions
ReloadMessages func(context.Context) ([]fantasy.Message, error)
DisableChainMode func()
// PrepareMessages is called before each LLM step with the
// current message history. If it returns non-nil, the returned
// slice replaces messages for this and all subsequent steps.
// Used to inject system context that becomes available mid-loop
// (e.g. AGENTS.md after create_workspace).
PrepareMessages func([]fantasy.Message) []fantasy.Message

// OnRetry is called before each retry attempt when the LLM
// stream fails with a retryable error. It provides the attempt
Expand Down Expand Up @@ -363,13 +369,19 @@ func Run(ctx context.Context, opts RunOptions) error {
// copy copies Message structs by value, so field
// reassignments in addAnthropicPromptCaching only
// affect the prepared slice.
if opts.PrepareMessages != nil {
if updated := opts.PrepareMessages(messages); updated != nil {
messages = updated
}
}
prepared := make([]fantasy.Message, len(messages))
copy(prepared, messages)
if applyAnthropicCaching {
addAnthropicPromptCaching(prepared)
}
opts.Metrics.MessageCount.WithLabelValues(provider).Observe(float64(len(prepared)))
opts.Metrics.PromptSizeBytes.WithLabelValues(provider).Observe(float64(EstimatePromptSize(prepared)))

call := fantasy.Call{
Prompt: prepared,
Tools: tools,
Expand Down
Loading
Loading