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