mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
## 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.
147 lines
4.2 KiB
Go
147 lines
4.2 KiB
Go
package chatsanitize
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"charm.land/fantasy"
|
|
fantasyanthropic "charm.land/fantasy/providers/anthropic"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func textMessageForTest(role fantasy.MessageRole, text string) fantasy.Message {
|
|
return fantasy.Message{
|
|
Role: role,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: text},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestProviderExecutedToolMessageIndexes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
messages := []fantasy.Message{
|
|
textMessageForTest(fantasy.MessageRoleUser, "plain"),
|
|
{
|
|
Role: fantasy.MessageRoleAssistant,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.ToolResultPart{
|
|
ToolCallID: "ws-result-only",
|
|
ProviderExecuted: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Role: fantasy.MessageRoleAssistant,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.ToolCallPart{
|
|
ToolCallID: "ws-call",
|
|
ToolName: "web_search",
|
|
Input: `{"query":"coder"}`,
|
|
ProviderExecuted: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Role: fantasy.MessageRoleAssistant,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.ToolCallPart{
|
|
ToolCallID: "local-call",
|
|
ToolName: "read_file",
|
|
Input: `{"path":"main.go"}`,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
require.Equal(t, map[int]struct{}{1: {}, 2: {}}, providerExecutedToolMessageIndexes(messages))
|
|
}
|
|
|
|
func TestAnthropicProviderToolFallbackStripHelpers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
providerCall := fantasy.ToolCallPart{
|
|
ToolCallID: "ws-strip",
|
|
ToolName: "web_search",
|
|
Input: `{"query":"coder"}`,
|
|
ProviderExecuted: true,
|
|
}
|
|
providerResult := fantasy.ToolResultPart{
|
|
ToolCallID: "ws-strip",
|
|
Output: fantasy.ToolResultOutputContentText{Text: "ok"},
|
|
ProviderExecuted: true,
|
|
}
|
|
messages := []fantasy.Message{
|
|
textMessageForTest(fantasy.MessageRoleAssistant, "first"),
|
|
{
|
|
Role: fantasy.MessageRoleAssistant,
|
|
Content: []fantasy.MessagePart{
|
|
providerCall,
|
|
providerResult,
|
|
},
|
|
},
|
|
textMessageForTest(fantasy.MessageRoleAssistant, "second"),
|
|
{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{Text: "keep"},
|
|
fantasy.ToolResultPart{
|
|
ToolCallID: "ws-user",
|
|
ProviderExecuted: true,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
stripped, stats := stripAnthropicProviderToolHistoryFromMessages(
|
|
messages,
|
|
map[int]struct{}{1: {}, 3: {}},
|
|
)
|
|
require.Equal(t, 1, stats.RemovedToolCalls)
|
|
require.Equal(t, 2, stats.RemovedToolResults)
|
|
require.Zero(t, stats.DroppedMessages)
|
|
|
|
sanitized, sanitizeStats := SanitizeAnthropicProviderToolHistory(
|
|
fantasyanthropic.Name,
|
|
stripped,
|
|
)
|
|
require.Zero(t, sanitizeStats.RemovedToolCalls)
|
|
require.Zero(t, sanitizeStats.RemovedToolResults)
|
|
require.Empty(t, ValidateAnthropicProviderToolHistory(sanitized))
|
|
require.Len(t, sanitized, 2)
|
|
require.Equal(t, fantasy.MessageRoleAssistant, sanitized[0].Role)
|
|
require.Len(t, sanitized[0].Content, 3)
|
|
firstText, ok := fantasy.AsMessagePart[fantasy.TextPart](sanitized[0].Content[0])
|
|
require.True(t, ok)
|
|
require.Equal(t, "first", firstText.Text)
|
|
stripText, ok := fantasy.AsMessagePart[fantasy.TextPart](sanitized[0].Content[1])
|
|
require.True(t, ok)
|
|
require.Equal(t, "ok", stripText.Text)
|
|
secondText, ok := fantasy.AsMessagePart[fantasy.TextPart](sanitized[0].Content[2])
|
|
require.True(t, ok)
|
|
require.Equal(t, "second", secondText.Text)
|
|
require.Equal(t, fantasy.MessageRoleUser, sanitized[1].Role)
|
|
require.Len(t, sanitized[1].Content, 1)
|
|
keepText, ok := fantasy.AsMessagePart[fantasy.TextPart](sanitized[1].Content[0])
|
|
require.True(t, ok)
|
|
require.Equal(t, "keep", keepText.Text)
|
|
|
|
violations := make([]AnthropicProviderToolHistoryViolation, 33)
|
|
for i := range violations {
|
|
violations[i] = AnthropicProviderToolHistoryViolation{
|
|
MessageIndex: i,
|
|
PartIndex: i + 1,
|
|
ID: "ws-detail",
|
|
Reason: "test_reason",
|
|
}
|
|
}
|
|
details, truncated := anthropicProviderToolViolationLogDetails(violations)
|
|
require.True(t, truncated)
|
|
require.Len(t, details, maxAnthropicProviderToolViolationLogDetails)
|
|
require.Len(t, details[0], 4)
|
|
require.Equal(t, 0, details[0]["message_index"])
|
|
require.Equal(t, 1, details[0]["part_index"])
|
|
require.Equal(t, "ws-detail", details[0]["id"])
|
|
require.Equal(t, "test_reason", details[0]["reason"])
|
|
}
|