根因(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。 是否本轮引入:否(编码与回填两文件在本轮零改动,属基线潜伏缺陷)。
214 lines
7.0 KiB
Go
214 lines
7.0 KiB
Go
package loop
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"reflect"
|
||
"sync"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// 本文件是并行工具调用的**端到端**回归:用真适配器(AnthropicChatAdapter)
|
||
// 打一个按 Anthropic 契约校验请求的假上游,跑完整回合。真机 P0 的原文
|
||
// 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.
|
||
//
|
||
// 在这里被逐字复刻:上游只放行「assistant 回合里的每个 tool_use 都在紧随其后的
|
||
// 那一条消息里被同 id 的 tool_result 应答」的请求。因此修复一旦回退,本用例会
|
||
// 以同一个 400 失败 —— 而不是靠对内部结构做断言。
|
||
|
||
// strictAnthropicUpstream 是一个按 Anthropic 配对契约校验的假上游。
|
||
// 它按顺序逐次回放 responses(超出则重复最后一条)。
|
||
type strictAnthropicUpstream struct {
|
||
mu sync.Mutex
|
||
bodies []map[string]any
|
||
responses []string
|
||
srv *httptest.Server
|
||
}
|
||
|
||
func newStrictAnthropicUpstream(t *testing.T, responses ...string) *strictAnthropicUpstream {
|
||
t.Helper()
|
||
up := &strictAnthropicUpstream{responses: responses}
|
||
up.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
var body map[string]any
|
||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||
|
||
up.mu.Lock()
|
||
up.bodies = append(up.bodies, body)
|
||
idx := len(up.bodies) - 1
|
||
if idx >= len(up.responses) {
|
||
idx = len(up.responses) - 1
|
||
}
|
||
payload := up.responses[idx]
|
||
up.mu.Unlock()
|
||
|
||
if bad := unpairedToolUse(body); bad != "" {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusBadRequest)
|
||
_, _ = fmt.Fprintf(w, `{"error":{"message":%q,"type":"invalid_request_error"}}`, bad)
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_, _ = io.WriteString(w, payload)
|
||
}))
|
||
t.Cleanup(up.srv.Close)
|
||
return up
|
||
}
|
||
|
||
// requests 返回假上游收到的请求体快照数量。
|
||
func (up *strictAnthropicUpstream) requests() int {
|
||
up.mu.Lock()
|
||
defer up.mu.Unlock()
|
||
return len(up.bodies)
|
||
}
|
||
|
||
// bodyAt 返回第 i 个请求体。
|
||
func (up *strictAnthropicUpstream) bodyAt(i int) map[string]any {
|
||
up.mu.Lock()
|
||
defer up.mu.Unlock()
|
||
return up.bodies[i]
|
||
}
|
||
|
||
// unpairedToolUse 复刻上游的校验:对每条 assistant 消息,取出它声明的全部
|
||
// tool_use id,要求紧随其后的那一条消息里出现**同 id、同数量、同顺序**的
|
||
// tool_result 块;违反时返回与上游同形的错误文案(空串表示通过)。
|
||
func unpairedToolUse(body map[string]any) string {
|
||
raw, _ := body["messages"].([]any)
|
||
for i, m := range raw {
|
||
msg, _ := m.(map[string]any)
|
||
uses := wireBlockIDs(msg, "tool_use")
|
||
if len(uses) == 0 {
|
||
continue
|
||
}
|
||
if i+1 >= len(raw) {
|
||
return fmt.Sprintf("messages.%d:`tool_use` ids were found without `tool_result` blocks immediately after: %s",
|
||
i, joinIDs(uses))
|
||
}
|
||
next, _ := raw[i+1].(map[string]any)
|
||
results := wireBlockIDs(next, "tool_result")
|
||
if !reflect.DeepEqual(uses, results) {
|
||
return fmt.Sprintf("messages.%d:`tool_use` ids were found without `tool_result` blocks immediately after: %s",
|
||
i, joinIDs(missingIDs(uses, results)))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// wireBlockIDs 按出现顺序取出 assistant 的 tool_use id / 结果的 tool_result id。
|
||
func wireBlockIDs(msg map[string]any, blockType string) []string {
|
||
blocks, _ := msg["content"].([]any)
|
||
ids := make([]string, 0, len(blocks))
|
||
for _, b := range blocks {
|
||
blk, _ := b.(map[string]any)
|
||
if blk["type"] != blockType {
|
||
continue
|
||
}
|
||
key := "id"
|
||
if blockType == "tool_result" {
|
||
key = "tool_use_id"
|
||
}
|
||
if s, ok := blk[key].(string); ok {
|
||
ids = append(ids, s)
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
// joinIDs 渲染 id 列表,供错误文案使用。
|
||
func joinIDs(ids []string) string {
|
||
out := ""
|
||
for i, id := range ids {
|
||
if i > 0 {
|
||
out += ", "
|
||
}
|
||
out += id
|
||
}
|
||
return out
|
||
}
|
||
|
||
// missingIDs 返回 uses 里没有在 results 中被一一应答的 id。
|
||
func missingIDs(uses, results []string) []string {
|
||
answered := make(map[string]int, len(results))
|
||
for _, r := range results {
|
||
answered[r]++
|
||
}
|
||
missing := make([]string, 0, len(uses))
|
||
for _, u := range uses {
|
||
if answered[u] > 0 {
|
||
answered[u]--
|
||
continue
|
||
}
|
||
missing = append(missing, u)
|
||
}
|
||
return missing
|
||
}
|
||
|
||
// parallelToolUseResponse 是首步模型响应:**同一步并行发出两个** tool_use
|
||
// (真机那次是两个 knowledge 调用)。id 采用 OpenAI 风格的
|
||
// call_01_*,与真机日志一致。
|
||
func parallelToolUseResponse() string {
|
||
return `{
|
||
"id":"msg_1","model":"claude-test","role":"assistant",
|
||
"content":[
|
||
{"type":"tool_use","id":"call_01_a","name":"knowledge","input":{"query":"Cyrene"}},
|
||
{"type":"tool_use","id":"call_01_b","name":"datetime","input":{"timezone":"Asia/Shanghai"}}
|
||
],
|
||
"stop_reason":"tool_use",
|
||
"usage":{"input_tokens":10,"output_tokens":8}
|
||
}`
|
||
}
|
||
|
||
const parallelFollowUpResponse = `{
|
||
"id":"msg_2","model":"claude-test","role":"assistant",
|
||
"content":[{"type":"text","text":"两条记录都在。"}],
|
||
"stop_reason":"end_turn",
|
||
"usage":{"input_tokens":20,"output_tokens":5}
|
||
}`
|
||
|
||
// TestRun_ParallelToolCalls_SecondStepAccepted 端到端复现真机 P0 的场景:
|
||
// 同一步并行两个 tool_use,回灌结果后必须能完成第二步模型调用并给出最终答复。
|
||
// 修复回退时上游按原样回 400,本用例即以那条错误失败。
|
||
func TestRun_ParallelToolCalls_SecondStepAccepted(t *testing.T) {
|
||
t.Parallel()
|
||
up := newStrictAnthropicUpstream(t, parallelToolUseResponse(), parallelFollowUpResponse)
|
||
ad := model.NewAnthropicChat(config.LLMConfig{
|
||
AnthropicBaseURL: up.srv.URL, AnthropicModel: "claude-test",
|
||
})
|
||
td := dispatcherWith(t,
|
||
&mockTool{name: "knowledge", result: map[string]any{"count": 10}},
|
||
&mockTool{name: "datetime", result: map[string]any{"now": "2026-10-04 22:17"}},
|
||
)
|
||
l := New(Options{Agent: newTestAgent(t, ad, td), MaxRounds: 8})
|
||
|
||
res, err := l.Run(context.Background(), "sess", []model.Message{
|
||
model.TextMessage(model.RoleUser, "请调用 knowledge 工具检索关于「Cyrene」的记录"),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Run: %v(旧行为下这里是上游 400: tool_use ids were found without tool_result blocks)", err)
|
||
}
|
||
if res.Text != "两条记录都在。" {
|
||
t.Fatalf("Text = %q, want 两条记录都在。", res.Text)
|
||
}
|
||
if res.ToolCallsExecuted != 2 {
|
||
t.Fatalf("ToolCallsExecuted = %d, want 2(两个并行调用都要执行)", res.ToolCallsExecuted)
|
||
}
|
||
if up.requests() != 2 {
|
||
t.Fatalf("上游请求数 = %d, want 2(第二步必须真的发出去)", up.requests())
|
||
}
|
||
// 第二步请求的配对形态也要成立(不只是「没报错」)。
|
||
if bad := unpairedToolUse(up.bodyAt(1)); bad != "" {
|
||
t.Fatalf("第二步请求仍违反配对契约: %s", bad)
|
||
}
|
||
}
|