From c57d06e8a6bdab71ef0019f5c6d77a00170434ec Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5" Date: Wed, 4 Nov 2026 19:38:23 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E8=B6=85=E8=BF=87=206?= =?UTF-8?q?3=20=E5=AD=97=E7=AC=A6=E7=9A=84=E5=B7=A5=E5=85=B7=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mag1cFall <107487022+Mag1cFall@users.noreply.github.com> --- docs/protocol.md | 2 ++ internal/aistudio/generate.go | 2 +- internal/aistudio/request.go | 4 +-- internal/aistudio/tool_schema_test.go | 46 +++++++++++++++++++++++++++ internal/aistudio/tools.go | 39 +++++++++++++++++++++++ 5 files changed, 90 insertions(+), 3 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 2edff80b..cdf1f23f 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -831,6 +831,8 @@ Interactions 的 `google_search`、`google_maps`、`computer_use`、`mcp_server` 编码器将全部函数声明合并为一个 Tool entry;Google Search 与 Image Search 合并为一个 search entry 并分别占用 `searchTypes` 索引 `0/1`;Code Execution、URL Context 与 Maps 各占一个 entry。Google Maps 与 Code Execution/URL Context 构成互斥工具组,每个请求选择其中一组。Anthropic server tool 的定义规则见“Anthropic Messages”节。 +Playground 的 `GenerateContent` 与 `CountTokens` 接受最长 63 字符的函数名。更长的名称在函数声明、历史函数调用与函数结果中统一编码为原名的前 50 个字符、`_` 与原名 SHA-256 前 6 字节的十六进制,共 63 字符;上游返回的函数调用在交给公开协议前换回声明的原名。写代码助手与 Live 会话按原名发送。 + 函数 JSON Struct 使用 protobuf `Struct/Value` 数组:map 为 `[[[key,value],...]]`;Value oneof 索引 `0..5` 分别表示 null、number、string、bool、Struct、ListValue。对象键排序后编码。 例如以下函数参数: diff --git a/internal/aistudio/generate.go b/internal/aistudio/generate.go index 3c51869b..4a9bdf4d 100644 --- a/internal/aistudio/generate.go +++ b/internal/aistudio/generate.go @@ -1151,7 +1151,7 @@ func (c *Client) sendPlayground(ctx context.Context, request GenerateRequest, en return nil, nil, err } return response, func(source io.Reader, emit func(Event) error) error { - return c.decodePlaygroundRounds(ctx, request, entry.defaults, runtime, source, emit) + return c.decodePlaygroundRounds(ctx, request, entry.defaults, runtime, source, restoreFunctionNames(request.Tools.Functions, emit)) }, nil } diff --git a/internal/aistudio/request.go b/internal/aistudio/request.go index a24b2367..7fe78212 100644 --- a/internal/aistudio/request.go +++ b/internal/aistudio/request.go @@ -368,7 +368,7 @@ func encodePart(part Part) ([]any, error) { if err != nil { return nil, fmt.Errorf("function call arguments: %w", err) } - call := []any{part.FunctionCall.Name, arguments} + call := []any{wireFunctionName(part.FunctionCall.Name), arguments} if part.FunctionCall.ID != "" { call = append(call, part.FunctionCall.ID) } @@ -388,7 +388,7 @@ func encodePart(part Part) ([]any, error) { if err != nil { return nil, fmt.Errorf("function result content: %w", err) } - result := []any{part.FunctionResult.Name, response} + result := []any{wireFunctionName(part.FunctionResult.Name), response} if part.FunctionResult.ID != "" { result = append(result, part.FunctionResult.ID) } diff --git a/internal/aistudio/tool_schema_test.go b/internal/aistudio/tool_schema_test.go index 5d9d61c3..78a0d883 100644 --- a/internal/aistudio/tool_schema_test.go +++ b/internal/aistudio/tool_schema_test.go @@ -125,3 +125,49 @@ func TestToolSchemaKeepsFileTools(t *testing.T) { t.Fatal("原始工具集发生变化") } } + +// TestToolSchemaLongFunctionNames 验证超过 63 字符的函数名以不超过 63 字符的同一名称声明与回传历史,上游调用以原名交给客户端 +func TestToolSchemaLongFunctionNames(t *testing.T) { + prefix := "mcp__codex_apps__codex_document_control___execute_document_" + names := []string{prefix + "command", prefix + "commands", "Read"} + functions := make([]FunctionDeclaration, 0, len(names)) + for _, name := range names { + functions = append(functions, FunctionDeclaration{Name: name, Parameters: json.RawMessage(`{"type":"object"}`)}) + } + wire, _, err := encodeRequestedTools(Tools{Functions: functions}) + if err != nil { + t.Fatal(err) + } + sent := make([]string, 0, len(names)) + for _, declaration := range wire[0].([]any)[1].([]any) { + sent = append(sent, declaration.([]any)[0].(string)) + } + if len(sent[0]) > 63 || len(sent[1]) > 63 || sent[0] == sent[1] || sent[2] != "Read" { + t.Fatalf("声明的函数名 %q", sent) + } + contents, err := encodeContents([]Content{ + {Role: RoleUser, Parts: []Part{{Text: "run"}}}, + {Role: RoleAssistant, Parts: []Part{{FunctionCall: &FunctionCall{ID: "call-1", Name: names[0], Arguments: json.RawMessage(`{}`)}}}}, + {Role: RoleUser, Parts: []Part{{FunctionResult: &FunctionResult{ID: "call-1", Name: names[0], Content: json.RawMessage(`{"ok":true}`)}}}}, + }) + if err != nil { + t.Fatal(err) + } + history, _ := json.Marshal(contents) + if strings.Count(string(history), `"`+sent[0]+`"`) != 2 || strings.Contains(string(history), names[0]) { + t.Fatalf("历史中的函数名与声明不一致: %s", history) + } + var received []string + emit := restoreFunctionNames(functions, func(event Event) error { + received = append(received, event.FunctionCall.Name) + return nil + }) + for _, name := range sent { + if err := emit(Event{Kind: EventToolCall, FunctionCall: &FunctionCall{Name: name}}); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(received, names) { + t.Fatalf("回传的函数名 %q", received) + } +} diff --git a/internal/aistudio/tools.go b/internal/aistudio/tools.go index 6ce9d32a..c503d15b 100644 --- a/internal/aistudio/tools.go +++ b/internal/aistudio/tools.go @@ -1,6 +1,8 @@ package aistudio import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "math" @@ -25,6 +27,7 @@ func encodeRequestedTools(tools Tools) ([]any, bool, error) { if len(tools.Functions) > 0 { declarations := make([]any, 0, len(tools.Functions)) for index, declaration := range tools.Functions { + declaration.Name = wireFunctionName(declaration.Name) encoded, err := encodeFunctionDeclaration(declaration) if err != nil { return nil, false, fmt.Errorf("编码 function declaration %d: %w", index, err) @@ -191,6 +194,42 @@ func functionBehavior(declaration FunctionDeclaration) (string, int64, error) { return name, value, nil } +// maxWireFunctionNameLength 是 Playground GenerateContent 与 CountTokens 接受的最长函数名 +const maxWireFunctionNameLength = 63 + +// wireFunctionName 返回发往 Playground 的函数名,超长时截短并以原名的哈希结尾 +func wireFunctionName(name string) string { + if len(name) <= maxWireFunctionNameLength { + return name + } + digest := sha256.Sum256([]byte(name)) + suffix := "_" + hex.EncodeToString(digest[:6]) + return name[:maxWireFunctionNameLength-len(suffix)] + suffix +} + +// restoreFunctionNames 在 emit 前把函数调用中的 Playground 函数名换回声明的原名 +func restoreFunctionNames(functions []FunctionDeclaration, emit func(Event) error) func(Event) error { + originals := map[string]string{} + for _, declaration := range functions { + if wire := wireFunctionName(declaration.Name); wire != declaration.Name { + originals[wire] = declaration.Name + } + } + if len(originals) == 0 { + return emit + } + return func(event Event) error { + if event.FunctionCall != nil { + if name, ok := originals[event.FunctionCall.Name]; ok { + call := *event.FunctionCall + call.Name = name + event.FunctionCall = &call + } + } + return emit(event) + } +} + func encodeFunctionDeclaration(declaration FunctionDeclaration) ([]any, error) { if declaration.Name == "" { return nil, fmt.Errorf("function declaration 缺少名称")