根因(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。 是否本轮引入:否(编码与回填两文件在本轮零改动,属基线潜伏缺陷)。
216 lines
7.7 KiB
Go
216 lines
7.7 KiB
Go
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
|
||
}
|