Files
AskaEth 15181e7063
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(model): Anthropic 并行工具结果合并为同一条 user 消息(修真机 400)
根因(agent-193,文件:行):loop 的 tool_history 按调用逐条产出 RoleTool 消息,
而 anthropic_encode 把每条各自编码成一条独立 user 消息 ⇒ 上游只认紧邻 assistant
的那一条,第 2..N 个 tool_use id 被判「没有 tool_result」⇒
`messages.1: tool_use ids were found without tool_result blocks immediately after`。
单调用回合只有 1 条结果消息,所以长期潜伏;KB1 让 knowledge 真正成功后它才被暴露。
修法:编码器在**线格式**层把相邻的 RoleTool 合并进同一条 user 消息(块序 = 调用序),
遇到任何非工具结果回合即复位(不同 assistant 回合的结果绝不混入同一条)。
未碰 openai_encode(OpenAI 要求每个 tool_call_id 一条独立 tool 消息)。
测试 3 个新文件:编码器线格式(非流式 + 流式)/ loop 端到端(真适配器打假上游)/
回填侧契约;含配对断言(每个 tool_use 在下一条消息里有同 id 的 tool_result)。
变异:还原旧编码 ⇒ 4 条红,端到端逐字复现真机 400。
是否本轮引入:否(编码与回填两文件在本轮零改动,属基线潜伏缺陷)。
2026-10-04 22:30:04 +08:00

216 lines
7.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"fmt"
"strings"
)
// buildAnthropicMessages 把归一化消息列表拆成顶层的 system
// 字符串与其余按 Anthropic 内容块形式表示的消息。所有
// RoleSystem 消息拼接成单个 system 提示词(Anthropic
// 把它放在请求根而非 messages 里);没有内容片段的消息
// 会被丢弃,因为 Anthropic 拒绝空内容。挂在非用户回合上的
// 文档部件在这里就被拒绝(design/24 §2.3-③):文档本期永不
// 抵达编码器,漏网即编程错误,绝不静默丢块。
//
// 连续的 RoleTool 回合在这里被合并成**一条** user 消息。这是
// Anthropic 线格式的硬要求,不是风格取舍:assistant 回合里每个
// tool_use 的应答块都必须落在**紧接其后的那一条**消息里,而回填侧
// (loop/tool_history.go)是按调用逐条产出 RoleTool 消息的,因此
// 一批并行调用的多个结果不合并就会分散在多条 user 消息中,上游
// 只在下一条里看到其中一个 id,其余 id 被判「没有 tool_result」,
// 整个请求 400(真机 P0;单调用回合因此永远看不出问题)。
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
if err := ValidateDocumentParts(msgs); err != nil {
return "", nil, err
}
var sys strings.Builder
out := make([]anthropicMessage, 0, len(msgs))
// pendingToolResult 是 out 中正在累积的工具结果回合下标,-1 表示
// 没有可合并的目标。每追加一条非工具结果回合就复位它,因此只有
// **真正相邻**的结果才会被并入同一条消息 —— 两批不同 assistant
// 回合的结果绝不会被混在一起。RoleSystem 回合被抽到顶层 system
// 字段、不进 messages,不打断相邻性,所以不参与复位。
pendingToolResult := -1
for _, m := range msgs {
if m.Role == RoleSystem {
if t := messageText(m); t != "" {
if sys.Len() > 0 {
sys.WriteString("\n")
}
sys.WriteString(t)
}
continue
}
blocks, err := encodeAnthropicMessage(m)
if err != nil {
return "", nil, err
}
if len(blocks) == 0 {
continue
}
if m.Role != RoleTool {
pendingToolResult = -1
out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks})
continue
}
if pendingToolResult >= 0 {
// 合并进上一条工具结果回合。类型断言必然成立(上面只写
// []any);不成立时故意**落到下面**再开一条消息,而不是
// 静默丢弃已有块 —— 丢块会让请求更坏,且无从察觉。
if merged, ok := out[pendingToolResult].Content.([]any); ok {
out[pendingToolResult].Content = append(merged, blocks...)
continue
}
}
out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks})
pendingToolResult = len(out) - 1
}
return sys.String(), out, nil
}
// messageText 拼接消息中带文本的片段(system 提示词
// 与工具结果正文)。工具结果片段把正文放在 Text 里,
// 因此 RoleTool 回合的内容能正确序列化为 OpenAI 的 tool message。
func messageText(m Message) string {
var b strings.Builder
for _, part := range m.Content {
if part.Type == ContentTypeText || part.Type == ContentTypeToolResult {
b.WriteString(part.Text)
}
}
return b.String()
}
// anthropicRole 把归一化的 Role 映射到 Anthropic 的角色词汇。
// system 消息已被抽取到顶层 system 字段,不会走到这里。
func anthropicRole(r Role) string {
switch r {
case RoleAssistant:
return "assistant"
default:
// user、tool 以及未来任何角色都落在 user 回合上;
// tool_result 块(M2)在 Anthropic 里属于 user 角色。
return "user"
}
}
// encodeAnthropicMessage 把归一化消息转成 Anthropic 内容块。
// RoleTool 回合永远渲染为原生 tool_result 块
// (绝不是 user 纯文本回合),因为 Anthropic 要求工具结果以
// 携带 tool_use_id 与 is_error 标志的 tool_result 块形式到达。
func encodeAnthropicMessage(m Message) ([]any, error) {
if m.Role == RoleTool {
return encodeAnthropicToolResult(m)
}
return encodeAnthropicContent(m.Content)
}
// encodeAnthropicToolResult 把 RoleTool 回合渲染为 tool_result 块。
// 每个 ContentTypeToolResult 片段变成一个块;遗留的文本片段也会
// 被包成 tool_result 块,使该回合永远不会退化成普通 user 消息。
// 当片段自身没有携带 tool_use_id 时,回退到消息级的
// ToolCallID。
func encodeAnthropicToolResult(m Message) ([]any, error) {
blocks := make([]any, 0, len(m.Content))
for _, p := range m.Content {
switch p.Type {
case ContentTypeToolResult, ContentTypeText:
id := p.ToolCallID
if id == "" {
id = m.ToolCallID
}
blocks = append(blocks, AnthropicToolResult(id, p.Text, p.IsError))
}
}
if len(blocks) == 0 {
blocks = append(blocks, AnthropicToolResult(m.ToolCallID, "", false))
}
return blocks, nil
}
// encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。
// 文本 / 图片 / tool_use(助手)片段在此处理;tool_result
// 片段由 encodeAnthropicToolResult 在消息级渲染。文档片段不走
// 这里:本期 DeepSeek 的 anthropic 兼容端点明确不支持 document
// 块,所以文档部件落到 default 分支被拒绝(design/24 §2.3-②,
// fail-loud 绝不静默丢块)。
func encodeAnthropicContent(parts []Content) ([]any, error) {
blocks := make([]any, 0, len(parts))
for _, p := range parts {
switch p.Type {
case ContentTypeText:
blocks = append(blocks, map[string]any{"type": "text", "text": p.Text})
case ContentTypeImage:
mediaType, data, err := parseAnthropicImageData(p.ImageURL)
if err != nil {
return nil, err
}
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": mediaType,
"data": data,
},
})
case ContentTypeToolCall:
args := p.ToolArgs
if args == nil {
args = map[string]any{}
}
blocks = append(blocks, map[string]any{
"type": "tool_use",
"id": p.ToolCallID,
"name": p.ToolCallName,
"input": args,
})
case ContentTypeToolResult:
blocks = append(blocks, AnthropicToolResult(p.ToolCallID, p.Text, p.IsError))
default:
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
}
}
return blocks, nil
}
// parseAnthropicImageData 从 data: URI 中提取媒体类型与 base64
// 负载。Anthropic 要求 base64 图片源、无法引用远程
// URL,因此其它任何 URI 形式都按错误请求拒绝。
func parseAnthropicImageData(uri string) (string, string, error) {
if !strings.HasPrefix(uri, anthropicDataPrefix) {
return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri)
}
rest := strings.TrimPrefix(uri, anthropicDataPrefix)
comma := strings.Index(rest, ",")
if comma < 0 {
return "", "", fmt.Errorf("model: %w: malformed data: URI %q", ErrBadRequest, uri)
}
meta := rest[:comma]
data := rest[comma+1:]
mediaType := meta
if idx := strings.Index(meta, ";"); idx >= 0 {
mediaType = meta[:idx]
}
if mediaType == "" {
return "", "", fmt.Errorf("model: %w: data: URI missing media type in %q", ErrBadRequest, uri)
}
return mediaType, data, nil
}
// buildAnthropicTools 把 ToolSpec schema 转换为 Anthropic 工具。
func buildAnthropicTools(tools []ToolSpec) []anthropicTool {
if len(tools) == 0 {
return nil
}
out := make([]anthropicTool, 0, len(tools))
for _, t := range tools {
out = append(out, anthropicTool{
Name: t.Name,
Description: t.Description,
InputSchema: t.Parameters,
})
}
return out
}