mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(coderd/x/chatd): repair Anthropic provider tool history (#24744)
## Problem Anthropic returns HTTP 400 when an assistant message contains a `web_search_tool_result` block whose `tool_use_id` has no matching earlier `server_tool_use` block in the same assistant message. A previous fix (#24706) sanitized provider-executed tool calls without matching results, but the opposite direction, orphaned or misordered provider-executed results, could still slip through both the prompt sanitizer and the persistence path. ## Fix Tighten Anthropic provider-executed tool history handling while preserving the useful result payload as normal assistant text when the provider-tool metadata is unsafe. 1. Extract Anthropic provider-tool sanitization into `coderd/x/chatd/chatsanitize` so provider-specific repair logic is no longer spread through `chatprompt` and `chatloop`. 2. `chatsanitize.SanitizeAnthropicProviderToolHistory` removes invalid provider-executed tool structure for Anthropic prompts: orphans in either direction, result-before-call, duplicate IDs, invalid JSON inputs, empty IDs and tool names, unsupported tool names, mismatched `ProviderExecuted` flags, provider-executed blocks outside assistant messages, and web-search results without serializable Anthropic result metadata. Provider-executed result payloads are textified instead of being discarded when there is text to preserve. 3. `chatsanitize.SanitizeAnthropicProviderToolContent` mirrors the same rule at the streamed step content level. Persisted history no longer carries invalid provider-tool blocks forward, but it keeps the result text for future turns. 4. `chatsanitize.ApplyAnthropicProviderToolGuard` only repairs structurally invalid Anthropic provider-tool history. It no longer strips otherwise-valid historical `web_search` blocks just because web search is disabled for the current request. The fail-closed fallback also textifies provider results before removing provider-tool metadata. Tests cover prompt sanitization, validation reason strings, result payload textification, content-level persistence sanitization, disabled web-search history preservation, direct pre-request guard behavior, and the fallback strip path. > Mux is acting on Mike's behalf.
This commit is contained in:
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chaterror"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatprompt"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatretry"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chatsanitize"
|
||||
"github.com/coder/coder/v2/coderd/x/chatd/chattool"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/quartz"
|
||||
@@ -391,12 +392,15 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
}
|
||||
prepared := make([]fantasy.Message, len(messages))
|
||||
copy(prepared, messages)
|
||||
prepared, sanitizeStats := chatprompt.SanitizeAnthropicProviderToolCalls(provider, prepared)
|
||||
chatprompt.LogAnthropicProviderToolSanitization(
|
||||
prepared, sanitizeStats := chatsanitize.SanitizeAnthropicProviderToolHistory(provider, prepared)
|
||||
chatsanitize.LogAnthropicProviderToolSanitization(
|
||||
ctx, opts.Logger, "pre_request", provider, modelName, sanitizeStats,
|
||||
slog.F("step_index", step),
|
||||
slog.F("total_steps", totalSteps),
|
||||
)
|
||||
prepared = chatsanitize.ApplyAnthropicProviderToolGuard(
|
||||
ctx, opts.Logger, provider, modelName, prepared,
|
||||
)
|
||||
if applyAnthropicCaching {
|
||||
addAnthropicPromptCaching(prepared)
|
||||
}
|
||||
@@ -529,7 +533,7 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
opts.ContextLimitFallback,
|
||||
)
|
||||
|
||||
result.content = sanitizeAnthropicProviderToolStepContent(
|
||||
result.content = chatsanitize.SanitizeAnthropicProviderToolStepContent(
|
||||
ctx, opts.Logger, provider, modelName,
|
||||
"dynamic_tool_persist", step, result.finishReason, result.content,
|
||||
)
|
||||
@@ -576,7 +580,7 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
result.providerMetadata,
|
||||
opts.ContextLimitFallback,
|
||||
)
|
||||
result.content = sanitizeAnthropicProviderToolStepContent(
|
||||
result.content = chatsanitize.SanitizeAnthropicProviderToolStepContent(
|
||||
ctx, opts.Logger, provider, modelName,
|
||||
"normal_persist", step, result.finishReason, result.content,
|
||||
)
|
||||
@@ -734,67 +738,6 @@ func Run(ctx context.Context, opts RunOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeAnthropicProviderToolStepContent(
|
||||
ctx context.Context,
|
||||
logger slog.Logger,
|
||||
provider string,
|
||||
modelName string,
|
||||
phase string,
|
||||
step int,
|
||||
finishReason fantasy.FinishReason,
|
||||
content []fantasy.Content,
|
||||
) []fantasy.Content {
|
||||
sanitized, stats := sanitizeAnthropicProviderToolContent(provider, content)
|
||||
chatprompt.LogAnthropicProviderToolSanitization(
|
||||
ctx, logger, phase, provider, modelName, stats,
|
||||
slog.F("step_index", step),
|
||||
slog.F("finish_reason", finishReason),
|
||||
)
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func sanitizeAnthropicProviderToolContent(
|
||||
provider string,
|
||||
content []fantasy.Content,
|
||||
) ([]fantasy.Content, chatprompt.AnthropicProviderToolSanitizationStats) {
|
||||
var stats chatprompt.AnthropicProviderToolSanitizationStats
|
||||
if provider != fantasyanthropic.Name || len(content) == 0 {
|
||||
return content, stats
|
||||
}
|
||||
|
||||
matchedResultIDs := make(map[string]struct{})
|
||||
for _, block := range content {
|
||||
result, ok := fantasy.AsContentType[fantasy.ToolResultContent](block)
|
||||
if !ok || !result.ProviderExecuted || result.ToolCallID == "" {
|
||||
continue
|
||||
}
|
||||
matchedResultIDs[result.ToolCallID] = struct{}{}
|
||||
}
|
||||
|
||||
out := make([]fantasy.Content, 0, len(content))
|
||||
for _, block := range content {
|
||||
toolCall, ok := fantasy.AsContentType[fantasy.ToolCallContent](block)
|
||||
if ok && isAnthropicProviderExecutedToolCall(provider, toolCall) {
|
||||
if _, hasResult := matchedResultIDs[toolCall.ToolCallID]; !hasResult {
|
||||
stats.RemovedToolCalls++
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, block)
|
||||
}
|
||||
if stats.RemovedToolCalls == 0 {
|
||||
return content, stats
|
||||
}
|
||||
return out, stats
|
||||
}
|
||||
|
||||
func isAnthropicProviderExecutedToolCall(
|
||||
provider string,
|
||||
toolCall fantasy.ToolCallContent,
|
||||
) bool {
|
||||
return provider == fantasyanthropic.Name && toolCall.ProviderExecuted
|
||||
}
|
||||
|
||||
// guardedAttempt owns an attempt-scoped context and startup guard
|
||||
// around a provider stream. release is idempotent and frees the
|
||||
// attempt-scoped timer/context. finish canonicalizes startup timeout
|
||||
@@ -1380,9 +1323,9 @@ func persistInterruptedStep(
|
||||
provider = opts.Model.Provider()
|
||||
modelName = opts.Model.Model()
|
||||
}
|
||||
var sanitizeStats chatprompt.AnthropicProviderToolSanitizationStats
|
||||
result.content, sanitizeStats = sanitizeAnthropicProviderToolContent(provider, result.content)
|
||||
chatprompt.LogAnthropicProviderToolSanitization(
|
||||
var sanitizeStats chatsanitize.AnthropicProviderToolSanitizationStats
|
||||
result.content, sanitizeStats = chatsanitize.SanitizeAnthropicProviderToolContent(provider, result.content)
|
||||
chatsanitize.LogAnthropicProviderToolSanitization(
|
||||
ctx, opts.Logger, "interrupted_persist", provider, modelName, sanitizeStats,
|
||||
)
|
||||
|
||||
@@ -1420,7 +1363,7 @@ func persistInterruptedStep(
|
||||
if _, exists := answeredToolCalls[tc.ToolCallID]; exists {
|
||||
continue
|
||||
}
|
||||
if isAnthropicProviderExecutedToolCall(provider, tc) {
|
||||
if chatsanitize.IsAnthropicProviderExecutedToolCall(provider, tc) {
|
||||
continue
|
||||
}
|
||||
content = append(content, fantasy.ToolResultContent{
|
||||
|
||||
Reference in New Issue
Block a user