Skip to content
Open
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
8 changes: 8 additions & 0 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -6166,6 +6166,10 @@ func (p *Server) runChat(
chatprompt.LogAnthropicProviderToolSanitization(
ctx, logger, "persisted_history_replay", model.Provider(), model.Model(), sanitizeStats,
)
prompt, openAISanitizeStats := chatprompt.SanitizeOpenAIOrphanToolMessages(model.Provider(), prompt)
chatprompt.LogOpenAIOrphanToolSanitization(
ctx, logger, "persisted_history_replay", model.Provider(), model.Model(), openAISanitizeStats,
)
subagentInstruction := ""
if !isRootChat {
subagentInstruction = defaultSubagentInstruction
Expand Down Expand Up @@ -6793,6 +6797,10 @@ func (p *Server) runChat(
chatprompt.LogAnthropicProviderToolSanitization(
reloadCtx, logger, "reload_messages", model.Provider(), model.Model(), sanitizeStats,
)
reloadedPrompt, openAISanitizeStats := chatprompt.SanitizeOpenAIOrphanToolMessages(model.Provider(), reloadedPrompt)
chatprompt.LogOpenAIOrphanToolSanitization(
reloadCtx, logger, "reload_messages", model.Provider(), model.Model(), openAISanitizeStats,
)
// Re-derive instruction and skills from the reloaded
// messages so that any context added during the
// chatloop (e.g. via persistInstructionFiles when
Expand Down
6 changes: 6 additions & 0 deletions coderd/x/chatd/chatloop/chatloop.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,12 @@ func Run(ctx context.Context, opts RunOptions) error {
slog.F("step_index", step),
slog.F("total_steps", totalSteps),
)
prepared, openAISanitizeStats := chatprompt.SanitizeOpenAIOrphanToolMessages(provider, prepared)
chatprompt.LogOpenAIOrphanToolSanitization(
ctx, opts.Logger, "pre_request", provider, modelName, openAISanitizeStats,
slog.F("step_index", step),
slog.F("total_steps", totalSteps),
)
if applyAnthropicCaching {
addAnthropicPromptCaching(prepared)
}
Expand Down
149 changes: 149 additions & 0 deletions coderd/x/chatd/chatprompt/chatprompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import (

"charm.land/fantasy"
fantasyanthropic "charm.land/fantasy/providers/anthropic"
fantasyazure "charm.land/fantasy/providers/azure"
fantasyopenai "charm.land/fantasy/providers/openai"
fantasyopenaicompat "charm.land/fantasy/providers/openaicompat"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
Expand Down Expand Up @@ -126,6 +129,152 @@ func SanitizeAnthropicProviderToolCalls(
return out, stats
}

// OpenAIOrphanToolSanitizationStats summarizes how many
// reasoning-only assistant messages and orphaned tool messages were
// dropped by SanitizeOpenAIOrphanToolMessages.
type OpenAIOrphanToolSanitizationStats struct {
DroppedAssistantMessages int
DroppedToolMessages int
}

// LogOpenAIOrphanToolSanitization logs prompt changes made while
// removing orphan tool messages that follow reasoning-only assistant
// messages.
func LogOpenAIOrphanToolSanitization(
ctx context.Context,
logger slog.Logger,
phase string,
provider string,
modelName string,
stats OpenAIOrphanToolSanitizationStats,
extra ...slog.Field,
) {
if stats.DroppedAssistantMessages == 0 && stats.DroppedToolMessages == 0 {
return
}
fields := []slog.Field{
slog.F("phase", phase),
slog.F("provider", provider),
slog.F("model", modelName),
slog.F("dropped_assistant_messages", stats.DroppedAssistantMessages),
slog.F("dropped_tool_messages", stats.DroppedToolMessages),
}
fields = append(fields, extra...)
logger.Warn(ctx, "dropped reasoning-only assistant and orphan tool messages", fields...)
}

// SanitizeOpenAIOrphanToolMessages drops tool messages whose preceding
// assistant message contains only reasoning content. The OpenAI
// Responses API replay layer skips such messages when the reasoning
// item lacks a stored ItemID, leaving any function_call_output
// orphaned and producing:
//
// No tool output found for function call call_xxx.
//
// This is a defensive belt-and-suspenders complement to the fantasy
// reasoning-replay fix in providers/openai. It runs for every
// provider that routes through the same Responses API code path
// (openai, azure, openai-compat) and is a no-op elsewhere.
//
// Returns the input slice unchanged when there is nothing to drop
// (early return on the no-op path), matching
// SanitizeAnthropicProviderToolCalls.
func SanitizeOpenAIOrphanToolMessages(
provider string,
messages []fantasy.Message,
) ([]fantasy.Message, OpenAIOrphanToolSanitizationStats) {
var stats OpenAIOrphanToolSanitizationStats
if !isOpenAIResponsesProvider(provider) || len(messages) == 0 {
return messages, stats
}

// Pre-scan: find indices to drop. When a reasoning-only assistant
// is found, drop it together with every contiguous tool message
// that follows. A function_call_output without its matching
// function_call (which lived on the dropped assistant turn) would
// otherwise reach OpenAI and trigger 'No tool output found'.
drop := make([]bool, len(messages))
for i, msg := range messages {
if msg.Role != fantasy.MessageRoleAssistant {
continue
}
if !isReasoningOnlyAssistantMessage(msg) {
continue
}
// Look ahead at the run of tool messages immediately
// following this assistant. If at least one exists, the
// orphan shape is present and we drop the whole pair.
j := i + 1
for j < len(messages) && messages[j].Role == fantasy.MessageRoleTool {
j++
}
if j == i+1 {
continue
}
drop[i] = true
stats.DroppedAssistantMessages++
for k := i + 1; k < j; k++ {
drop[k] = true
stats.DroppedToolMessages++
}
}

if stats.DroppedAssistantMessages == 0 {
return messages, stats
}

// Use a plain append (not appendSanitizedMessage) so we do not
// merge the two non-tool messages that bracket the dropped run
// into a single message. Merging would erase turn boundaries from
// the transcript sent to the model.
out := make([]fantasy.Message, 0, len(messages))
for i, msg := range messages {
if drop[i] {
continue
}
out = append(out, msg)
}
return out, stats
}

// isOpenAIResponsesProvider reports whether the given provider name
// uses the OpenAI Responses API. Azure OpenAI and openai-compat both
// route through the same fantasy openai package and therefore hit the
// same Responses API pairing rules. The sanitizer must run for all
// of them, not just the canonical "openai" name.
func isOpenAIResponsesProvider(provider string) bool {
switch provider {
case fantasyopenai.Name, fantasyazure.Name, fantasyopenaicompat.Name:
return true
default:
return false
}
}

// isReasoningOnlyAssistantMessage reports whether an assistant message
// has at least one reasoning part and every other part is also
// reasoning. Any non-reasoning part (text, tool call, tool result,
// file, or unknown type added in a future fantasy release) makes the
// message NOT reasoning-only so the sanitizer leaves it alone.
func isReasoningOnlyAssistantMessage(msg fantasy.Message) bool {
if msg.Role != fantasy.MessageRoleAssistant || len(msg.Content) == 0 {
return false
}
hasReasoning := false
for _, part := range msg.Content {
// AsMessagePart normalizes value and pointer variants, so
// *fantasy.ReasoningPart matches too. Fall through to
// 'return false' on every non-reasoning part, including any
// type added later that this code does not enumerate.
if _, ok := fantasy.AsMessagePart[fantasy.ReasoningPart](part); ok {
hasReasoning = true
continue
}
return false
}
return hasReasoning
}

func appendSanitizedMessage(out []fantasy.Message, msg fantasy.Message) []fantasy.Message {
if len(out) == 0 || out[len(out)-1].Role != msg.Role {
return append(out, msg)
Expand Down
Loading
Loading