fix(model): Anthropic 并行工具结果合并为同一条 user 消息(修真机 400)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

根因(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。
是否本轮引入:否(编码与回填两文件在本轮零改动,属基线潜伏缺陷)。
This commit is contained in:
AskaEth committed 2026-10-04 22:30:04 +08:00
1 parent fd13b49b24
commit 15181e7063
4 files changed
+652

No files matched your search

@@ -0,0 +1,213 @@
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)
}
}