Files
Cyrene_v2/internal/runtime/loop/tool_history_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

112 lines
4.3 KiB
Go

package loop
import (
"reflect"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件钉死工具回填侧的形态契约(appendToolCallHistory):一条 assistant
// 回合声明**本步的全部** tool_use,其后**每个调用一条** RoleTool 消息,
// 顺序与调用顺序一致。真机 P0(并行 tool_use 的原生回填让上游判 400
// 「tool_use ids were found without tool_result blocks immediately after」)
// 的修复落在 Anthropic 编码器(model/anthropic_encode.go:相邻的 RoleTool
// 回合合并成同一条 user 消息);这里守住的是编码器的输入 —— 回填侧既不能
// 漏掉某个调用的结果,也不能打乱顺序或丢失 id 配对,否则编码器无从补救。
// nativeCalls 构造一批带 provider id 的调用(原生路径)。
func nativeCalls(ids ...string) []agent.ToolCall {
calls := make([]agent.ToolCall, 0, len(ids))
for _, id := range ids {
calls = append(calls, agent.ToolCall{ID: id, Name: "knowledge", Args: map[string]any{"query": "Cyrene"}})
}
return calls
}
// toolResultIDs 按消息顺序取出 RoleTool 回合应答的 provider id。
func toolResultIDs(msgs []model.Message) []string {
ids := make([]string, 0, len(msgs))
for _, m := range msgs {
if m.Role != model.RoleTool {
continue
}
ids = append(ids, m.ToolCallID)
}
return ids
}
// TestAppendToolCallHistory_ParallelBatch_PairsEveryCall 覆盖同一步里
// **≥2 个并行调用**:assistant 回合必须声明全部调用,且每个 id 都要在紧随其后的
// 结果里出现恰好一次、顺序一致。
func TestAppendToolCallHistory_ParallelBatch_PairsEveryCall(t *testing.T) {
t.Parallel()
ids := []string{"call_01_a", "call_01_b", "call_01_c"}
outcomes := make([]ToolOutcome, len(ids))
for i := range outcomes {
outcomes[i] = ToolOutcome{Result: map[string]any{"count": 10}}
}
msgs := appendToolCallHistory(nil, nativeCalls(ids...), outcomes)
if len(msgs) != len(ids)+1 {
t.Fatalf("messages = %d, want %d (1 条 assistant + 每调用 1 条结果)", len(msgs), len(ids)+1)
}
if msgs[0].Role != model.RoleAssistant {
t.Fatalf("messages[0].role = %q, want assistant(结果必须先有调用可关联)", msgs[0].Role)
}
if got := toolCallIDs(msgs[0].Content); !reflect.DeepEqual(got, ids) {
t.Fatalf("assistant tool_use id = %v, want %v", got, ids)
}
if got := toolResultIDs(msgs[1:]); !reflect.DeepEqual(got, ids) {
t.Fatalf("tool_result id = %v, want %v(顺序与调用一致、不得去重或丢失)", got, ids)
}
}
// TestAppendToolCallHistory_SingleCall_Pairs 覆盖单调用形态,防止并行修复
// 把单调用路径改坏。
func TestAppendToolCallHistory_SingleCall_Pairs(t *testing.T) {
t.Parallel()
msgs := appendToolCallHistory(nil, nativeCalls("call_01_only"), []ToolOutcome{{Result: "ok"}})
if len(msgs) != 2 {
t.Fatalf("messages = %d, want 2", len(msgs))
}
if got := toolCallIDs(msgs[0].Content); !reflect.DeepEqual(got, []string{"call_01_only"}) {
t.Fatalf("assistant tool_use id = %v, want [call_01_only]", got)
}
if got := toolResultIDs(msgs[1:]); !reflect.DeepEqual(got, []string{"call_01_only"}) {
t.Fatalf("tool_result id = %v, want [call_01_only]", got)
}
}
// TestAppendToolCallHistory_LegacyEnvelope_NoNativeTurn 守住旧式 JSON 信封
// 路径(调用不带 provider id):它没有可关联的原生 tool_use,因此保持纯 RoleTool
// 文本回灌,绝不凭空造一条 assistant 回合。
func TestAppendToolCallHistory_LegacyEnvelope_NoNativeTurn(t *testing.T) {
t.Parallel()
calls := []agent.ToolCall{{Name: "echo", Args: map[string]any{"q": "x"}}, {Name: "echo2", Args: map[string]any{"q": "y"}}}
msgs := appendToolCallHistory(nil, calls, []ToolOutcome{{Result: "a"}, {Result: "b"}})
if len(msgs) != 2 {
t.Fatalf("messages = %d, want 2(每个调用一条纯文本回灌)", len(msgs))
}
for i, m := range msgs {
if m.Role != model.RoleTool {
t.Fatalf("messages[%d].role = %q, want tool", i, m.Role)
}
}
}
// toolCallIDs 按内容块顺序取出 assistant 回合声明的 tool_use id。
func toolCallIDs(parts []model.Content) []string {
ids := make([]string, 0, len(parts))
for _, p := range parts {
if p.Type == model.ContentTypeToolCall {
ids = append(ids, p.ToolCallID)
}
}
return ids
}