Files
Cyrene_v2/internal/runtime/model/anthropic_parallel_toolresult_test.go
T
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

300 lines
11 KiB
Go
Raw 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 (
"context"
"net/http"
"reflect"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// 本文件锁定一个真机抓到过的 P0:同一步里模型**并行**发出 ≥2 个 tool_use 时,
// 上游对该请求判 400 ——
//
// messages.1: `tool_use` ids were found without `tool_result` blocks
// immediately after: call_01_1RBBKboRTJCl2S80t0FJ3155.
// Each `tool_use` block must have a corresponding `tool_result` block in the next message.
//
// Anthropic 协议要求:assistant 回合里**每一个** tool_use 的应答块,都必须出现在
// **紧接其后的那一条**消息里。所以一批并行调用的全部 tool_result 必须落在同一条
// user 消息中;分散成多条 user 消息时,校验器只在下一条里看到其中一个 id,
// 其余 id 一律被判「没有 tool_result」,整个请求失败(单调用回合因此永远看不出
// 问题——回填侧恰好只产出一条 user 回合)。下面三组用例把这条契约钉死在
// **线格式**上(回填侧 loop/tool_history.go 的形态即 parallelToolTurnMessages)。
// parallelToolTurnMessages 复刻 loop 在一个「同一步并行发出 ≥2 个 tool_use」的
// 回合里回填的内部消息形态(loop/tool_history.go 的 appendToolCallHistory):
// 一条 assistant 声明全部调用,其后**每个调用一条** RoleTool 消息,顺序与调用
// 顺序一致。这正是真机那次请求编码前的输入。
func parallelToolTurnMessages(ids []string) []Message {
calls := make([]ToolCall, 0, len(ids))
for _, id := range ids {
calls = append(calls, ToolCall{
ID: id, Name: "knowledge", Args: map[string]any{"query": "Cyrene"},
})
}
msgs := []Message{
TextMessage(RoleUser, "请调用 knowledge 工具检索关于「Cyrene」的记录"),
AssistantToolCallMessage(calls),
}
for _, id := range ids {
msgs = append(msgs, ToolResultMessage(id, `{"count":10}`, false))
}
return msgs
}
// wireMessages 从抓到的请求体里取出 messages 数组。
func wireMessages(t *testing.T, body map[string]any) []map[string]any {
t.Helper()
raw, ok := body["messages"].([]any)
if !ok {
t.Fatalf("body.messages = %#v, want an array", body["messages"])
}
out := make([]map[string]any, 0, len(raw))
for _, m := range raw {
msg, ok := m.(map[string]any)
if !ok {
t.Fatalf("message = %#v, want an object", m)
}
out = append(out, msg)
}
return out
}
// wireBlocks 取出一条线格式消息的内容块。
func wireBlocks(t *testing.T, msg map[string]any) []map[string]any {
t.Helper()
raw, ok := msg["content"].([]any)
if !ok {
t.Fatalf("message content = %#v, want an array of blocks", msg["content"])
}
out := make([]map[string]any, 0, len(raw))
for _, b := range raw {
blk, ok := b.(map[string]any)
if !ok {
t.Fatalf("content block = %#v, want an object", b)
}
out = append(out, blk)
}
return out
}
// blockIDs 按出现顺序取出某类块的 id:tool_use 取 "id",
// tool_result 取 "tool_use_id"(它应答的那个 tool_use id)。
func blockIDs(blocks []map[string]any, blockType string) []string {
ids := make([]string, 0, len(blocks))
for _, b := range blocks {
if b["type"] != blockType {
continue
}
key := "id"
if blockType == "tool_result" {
key = "tool_use_id"
}
s, _ := b[key].(string)
ids = append(ids, s)
}
return ids
}
// assertToolResultPairing 断言线格式满足 Anthropic 的配对契约:assistant
// 回合里的每个 tool_use id,都要在下一条消息里被一条同 id 的 tool_result
// 应答,且**数量与顺序完全一致**。这正是上游 400
// 「ids were found without `tool_result` blocks immediately after」所判定的东西。
func assertToolResultPairing(t *testing.T, body map[string]any) {
t.Helper()
msgs := wireMessages(t, body)
for i, m := range msgs {
uses := blockIDs(wireBlocks(t, m), "tool_use")
if len(uses) == 0 {
continue
}
if i+1 >= len(msgs) {
t.Fatalf("messages.%d 声明了 tool_use %v,但之后没有任何消息应答它", i, uses)
}
results := blockIDs(wireBlocks(t, msgs[i+1]), "tool_result")
if !reflect.DeepEqual(uses, results) {
t.Fatalf("messages.%d 的 tool_use %v 在 messages.%d 里没有一一对应(实际 tool_result %v);"+
"上游会判 400(每个 tool_use 都必须在下一条消息里有同 id 的 tool_result)",
i, uses, i+1, results)
}
}
}
// TestAnthropicBuild_ParallelToolResults_OneUserTurn 是三组用例里最直接的一组:
// 直接对编码器断言「串行回填的多条 RoleTool 消息被合并成**一条** user 回合,
// 块顺序与调用顺序一致」。旧行为(每条 RoleTool 各自一条 user 消息)在这里必红。
func TestAnthropicBuild_ParallelToolResults_OneUserTurn(t *testing.T) {
t.Parallel()
ids := []string{"call_01_a", "call_01_b", "call_01_c"}
_, out, err := buildAnthropicMessages(parallelToolTurnMessages(ids))
if err != nil {
t.Fatalf("buildAnthropicMessages error: %v", err)
}
wantRoles := []string{"user", "assistant", "user"}
if len(out) != len(wantRoles) {
t.Fatalf("messages = %d, want %d (并行工具结果必须合并为同一条 user 回合): roles=%v",
len(out), len(wantRoles), anthropicRolesOf(out))
}
for i, want := range wantRoles {
if out[i].Role != want {
t.Fatalf("messages.%d role = %q, want %q (roles=%v)", i, out[i].Role, want, anthropicRolesOf(out))
}
}
blocks, ok := out[2].Content.([]any)
if !ok {
t.Fatalf("messages.2 content = %#v, want a block array", out[2].Content)
}
if len(blocks) != len(ids) {
t.Fatalf("messages.2 blocks = %d, want %d(一个 tool_use 一个应答块)", len(blocks), len(ids))
}
got := make([]string, 0, len(blocks))
for _, b := range blocks {
blk, ok := b.(map[string]any)
if !ok {
t.Fatalf("block = %#v, want an object", b)
}
if blk["type"] != "tool_result" {
t.Fatalf("messages.2 里出现非 tool_result 块: %#v", blk)
}
s, _ := blk["tool_use_id"].(string)
got = append(got, s)
}
if !reflect.DeepEqual(got, ids) {
t.Fatalf("messages.2 的 tool_use_id 顺序 = %v, want %v(顺序必须与调用顺序一致)", got, ids)
}
}
// TestAnthropicBuild_ToolResults_SeparateTurnsNotMerged 守住合并的边界:
// 只有**相邻**的工具结果才合并;两个不同 assistant 回合的结果之间隔着别的
// 回合时绝不能并进同一条 user 消息(否则会把 A 批的结果塞到 B 批的
// assistant 回合之后,反而制造出更坏的配对错乱)。
func TestAnthropicBuild_ToolResults_SeparateTurnsNotMerged(t *testing.T) {
t.Parallel()
msgs := []Message{
TextMessage(RoleUser, "first"),
AssistantToolCallMessage([]ToolCall{{ID: "call_a", Name: "knowledge", Args: map[string]any{}}}),
ToolResultMessage("call_a", `{"count":1}`, false),
TextMessage(RoleUser, "second"),
AssistantToolCallMessage([]ToolCall{{ID: "call_b", Name: "knowledge", Args: map[string]any{}}}),
ToolResultMessage("call_b", `{"count":2}`, false),
}
_, out, err := buildAnthropicMessages(msgs)
if err != nil {
t.Fatalf("buildAnthropicMessages error: %v", err)
}
if len(out) != 6 {
t.Fatalf("messages = %d, want 6 (两个回合各自一条结果回合): roles=%v", len(out), anthropicRolesOf(out))
}
wantRoles := []string{"user", "assistant", "user", "user", "assistant", "user"}
for i, want := range wantRoles {
if out[i].Role != want {
t.Fatalf("messages.%d role = %q, want %q (roles=%v)", i, out[i].Role, want, anthropicRolesOf(out))
}
}
}
// anthropicRolesOf 只在断言失败时渲染角色序列,便于一眼看出错误形态。
func anthropicRolesOf(msgs []anthropicMessage) []string {
roles := make([]string, 0, len(msgs))
for _, m := range msgs {
roles = append(roles, m.Role)
}
return roles
}
// TestAnthropicChat_ParallelToolResults_WirePairing 走真机同一条路(非流式
// Chat → 抓请求体),断言并行回合在**线格式**上被一一配对。
func TestAnthropicChat_ParallelToolResults_WirePairing(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
ad := NewAnthropicChat(config.LLMConfig{
AnthropicBaseURL: srv.URL, AnthropicAPIKey: "ak-test", AnthropicModel: "claude-test",
})
if _, err := ad.Chat(context.Background(), Request{
Messages: parallelToolTurnMessages([]string{"call_01_a", "call_01_b"}),
Model: "claude-test", MaxTokens: 64,
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
got := <-ch
assertToolResultPairing(t, got.body)
msgs := wireMessages(t, got.body)
if len(msgs) != 3 {
t.Fatalf("messages = %d, want 3 (user / assistant / 合并后的 user)", len(msgs))
}
if msgs[1]["role"] != "assistant" || msgs[2]["role"] != "user" {
t.Fatalf("roles = %v, want [user assistant user]", []any{msgs[0]["role"], msgs[1]["role"], msgs[2]["role"]})
}
uses := blockIDs(wireBlocks(t, msgs[1]), "tool_use")
if !reflect.DeepEqual(uses, []string{"call_01_a", "call_01_b"}) {
t.Fatalf("messages.1 tool_use ids = %v, want [call_01_a call_01_b]", uses)
}
}
// TestAnthropicChat_ParallelToolResults_StreamWirePairing 覆盖真机那次
// 实际走的路:Web 聊天是流式,流式与非流式共用 buildRequest,因此也必须
// 满足同一条契约。
func TestAnthropicChat_ParallelToolResults_StreamWirePairing(t *testing.T) {
t.Parallel()
sse := strings.Join([]string{
`data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":5}}}`,
``,
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
``,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`,
``,
`data: {"type":"content_block_stop","index":0}`,
``,
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`,
``,
`data: {"type":"message_stop"}`,
``,
}, "\n")
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "text/event-stream", sse, ch)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
if _, err := ad.ChatStream(context.Background(), Request{
Messages: parallelToolTurnMessages([]string{"call_01_a", "call_01_b"}),
Model: "claude-test", Purpose: "chat",
}, func(StreamEvent) {}); err != nil {
t.Fatalf("ChatStream error: %v", err)
}
captured := <-ch
assertToolResultPairing(t, captured.body)
}
// TestAnthropicChat_SingleToolResult_StillPaired 守住单调用形态:它本来就
// 正常,合并逻辑不得把它改坏(既不能多出回合,也不能丢块)。
func TestAnthropicChat_SingleToolResult_StillPaired(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
ad := NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "claude-test"})
if _, err := ad.Chat(context.Background(), Request{
Messages: parallelToolTurnMessages([]string{"call_01_only"}),
Model: "claude-test", MaxTokens: 64,
}, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
got := <-ch
assertToolResultPairing(t, got.body)
msgs := wireMessages(t, got.body)
if len(msgs) != 3 {
t.Fatalf("messages = %d, want 3 (user / assistant / user)", len(msgs))
}
results := blockIDs(wireBlocks(t, msgs[2]), "tool_result")
if !reflect.DeepEqual(results, []string{"call_01_only"}) {
t.Fatalf("messages.2 tool_result ids = %v, want [call_01_only]", results)
}
}