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

214 lines
7.0 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 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)
}
}