From 048931ed7371b461a53ad352ea600752f927760f Mon Sep 17 00:00:00 2001 From: Smith-106 Date: Wed, 23 Sep 2026 02:07:15 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BD=92=E4=B8=80=E5=8C=96=E9=9D=9E?= =?UTF-8?q?=E6=B3=95=20tool=5Fuse=20id=EF=BC=8C=E9=81=BF=E5=85=8D=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=9C=A8=E6=9C=AC=E5=9C=B0=E6=A0=A1=E9=AA=8C=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E8=A2=AB=E6=8B=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 部分上游返回的 tool_use id 含 '#'、':' 等字符,而 litellm 的请求校验只接受 [A-Za-z0-9_-];默认策略 RepairNone 会在请求发出前直接判非法,使整轮会话反复失败(messages[i]: tool use id "..." is invalid)。 createModelFromConfig 为所有角色模型统一开启 litellm 的消息修复策略:id 在发送前归一化,tool_use / tool_result 由同一映射成对改写,缺失 id 的 tool call 也会补生成合法 id。所有角色与 fallback 共用该工厂,一处生效。 新增 internal/bootstrap/models_toolid_test.go:以 stub provider 断言真正到达 provider 的 id 合法且成对;撤下该策略时测试失败并复现原报错。 --- internal/bootstrap/models.go | 5 + internal/bootstrap/models_toolid_test.go | 113 +++++++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 internal/bootstrap/models_toolid_test.go diff --git a/internal/bootstrap/models.go b/internal/bootstrap/models.go index e345b07d4..137017791 100644 --- a/internal/bootstrap/models.go +++ b/internal/bootstrap/models.go @@ -12,6 +12,7 @@ import ( "github.com/voocel/agentcore/llm" "github.com/voocel/ainovel-cli/internal/errs" "github.com/voocel/ainovel-cli/internal/llmcontract" + "github.com/voocel/litellm" ) // FailoverEvent 表示一次显式 provider 切换。 @@ -386,6 +387,10 @@ func createModelFromConfig(providerKey, model string, pc ProviderConfig, cache m llm.WithStreamIdleTimeout(streamIdle), llm.WithProviderExtra(providerExtra), llm.WithExtra(pc.ExtraBody), + // 部分上游返回的 tool_use id 会带 '#'、':' 等字符,而请求校验只接受 + // [A-Za-z0-9_-];默认策略 RepairNone 会在发出前直接判非法(ErrorTypeValidation), + // 使整轮会话反复失败。此处开启归一化,tool_result 侧由同一映射成对改写。 + llm.WithClientOptions(litellm.WithMessageRepair(litellm.RepairToolUseIDs)), ) if err != nil { return nil, fmt.Errorf("provider %s (%s): %w: %w", providerKey, providerType, errs.ErrProvider, err) diff --git a/internal/bootstrap/models_toolid_test.go b/internal/bootstrap/models_toolid_test.go new file mode 100644 index 000000000..e26acfab8 --- /dev/null +++ b/internal/bootstrap/models_toolid_test.go @@ -0,0 +1,113 @@ +package bootstrap + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "sync" + "testing" + + "github.com/voocel/agentcore" +) + +// rawToolUseID 取自真实故障日志:tool_use id 中带 '#',而请求校验只接受 [A-Za-z0-9_-]。 +const rawToolUseID = "call_3543acedc27c4404bfe7317c#235532d85de245bda8ff64f6a683623a" + +var validToolUseIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +// TestCreateModelNormalizesInvalidToolUseIDs 锁定模型工厂的行为:含非法字符的 tool_use id +// 必须在发出前归一化为合法形态,且 tool_use / tool_result 两侧成对改写,否则整轮会话会在 +// 本地校验阶段反复失败(messages[i]: tool use id "..." is invalid)。 +func TestCreateModelNormalizesInvalidToolUseIDs(t *testing.T) { + var ( + mu sync.Mutex + body string + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if raw, err := io.ReadAll(r.Body); err == nil { + mu.Lock() + body = string(raw) + mu.Unlock() + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"stub","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`)) + })) + defer srv.Close() + + model, err := createModelFromConfig("stub", "stub-model", ProviderConfig{ + Type: "openai", + APIKey: "stub-key", + BaseURL: srv.URL, + }, map[string]agentcore.ChatModel{}) + if err != nil { + t.Fatalf("createModelFromConfig: %v", err) + } + + messages := []agentcore.Message{ + agentcore.UserMsg("hi"), + { + Role: agentcore.RoleAssistant, + Content: []agentcore.ContentBlock{ + agentcore.ToolCallBlock(agentcore.ToolCall{ + ID: rawToolUseID, + Name: "novel_context", + Args: json.RawMessage(`{}`), + }), + }, + }, + agentcore.ToolResultMsg(rawToolUseID, json.RawMessage(`"ok"`), false), + } + + if _, err := model.Generate(context.Background(), messages, nil); err != nil { + t.Fatalf("请求未发出(若为 tool use id ... is invalid 则说明归一化未生效): %v", err) + } + + mu.Lock() + got := body + mu.Unlock() + if got == "" { + t.Fatal("provider 未收到请求体") + } + if strings.Contains(got, rawToolUseID) { + t.Fatalf("请求体仍带非法 id: %s", got) + } + + var payload struct { + Messages []struct { + ToolCalls []struct { + ID string `json:"id"` + } `json:"tool_calls"` + ToolCallID string `json:"tool_call_id"` + } `json:"messages"` + } + if err := json.Unmarshal([]byte(got), &payload); err != nil { + t.Fatalf("请求体解析失败: %v", err) + } + + var useIDs, resultIDs []string + for _, m := range payload.Messages { + for _, tc := range m.ToolCalls { + if tc.ID != "" { + useIDs = append(useIDs, tc.ID) + } + } + if m.ToolCallID != "" { + resultIDs = append(resultIDs, m.ToolCallID) + } + } + if len(useIDs) == 0 || len(resultIDs) == 0 { + t.Fatalf("请求体缺少 tool_use / tool_result: %s", got) + } + for _, id := range append(append([]string{}, useIDs...), resultIDs...) { + if id == rawToolUseID || !validToolUseIDPattern.MatchString(id) { + t.Fatalf("tool id 仍不合法: %q", id) + } + } + if useIDs[0] != resultIDs[0] { + t.Fatalf("tool_use 与 tool_result 的 id 未成对改写: %v / %v", useIDs, resultIDs) + } +}