From 8a17e3a057a3ba9384c2f40619ed2f27330dc176 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 27 Jun 2026 21:19:16 +0900 Subject: [PATCH] =?UTF-8?q?fix(openai):=20tool=20arguments=EB=A5=BC=20sche?= =?UTF-8?q?ma=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agent-contract/outer/openai-compatible-api.md | 8 +- apps/edge/internal/openai/server_test.go | 75 ++++ apps/edge/internal/openai/types.go | 333 ++++++++++++++++-- 3 files changed, 377 insertions(+), 39 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 4b14f91..9b27c31 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -169,9 +169,11 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa 단, backend가 assistant content에 Cline-style 텍스트 tool call 블록을 출력하고 그 function name이 요청의 `tools[].function.name`에 포함되어 있으면 Edge는 해당 블록을 OpenAI-compatible `message.tool_calls` 또는 stream `delta.tool_calls`로 변환하고 `finish_reason: "tool_calls"`를 반환한다. 지원하는 텍스트 블록의 최소 형태는 `>>JSON-or-text` 또는 `{{function_name(key=Python/JSON-like-literal)}}`이다. 템플릿 호출 형태의 인자는 single-quoted string, `True`/`False`/`None`, 배열, 객체를 JSON `arguments` 문자열로 정규화한다. -`commands` 배열의 `{command: "git status", description, runInTerminal}`처럼 shell command 문자열을 command 객체에 담은 형태는 Cline의 direct-exec 경로로 오해되지 않도록 최신 Cline의 structured argv 형태인 `commands: [{"command":"git","args":["status"]}]`로 정규화한다. -UI 설명용 `description`이나 실행 위치 힌트용 `runInTerminal`은 command 객체와 최상위 args 어디에도 출력하지 않는다. -단, `cd`, `command -v`, `&&`, pipe, redirect, quote 등 shell 해석이 필요한 명령은 `commands: ["cd /work && git status"]` 같은 shell string으로 유지한다. +텍스트 tool call을 구조화할 때 Edge는 요청의 `tools[].function.parameters` schema를 기준으로 arguments를 정규화한다. +예를 들어 tool schema가 `commands: string[]`만 허용하면 command 객체 입력도 shell string 배열로 접고, `commands: {command,args}[]`를 허용하면 shell 문법이 없는 명령을 structured argv로 만든다. +tool schema가 top-level `command`/`cmd` string만 허용하면 `commands` 배열의 첫 명령을 해당 field로 접는다. +schema에 없는 UI 설명용 `description`이나 실행 위치 힌트용 `runInTerminal`은 command 객체와 최상위 args에서 제거한다. +단, `cd`, `command -v`, `&&`, pipe, redirect, quote 등 shell 해석이 필요한 명령은 schema가 허용할 때 `commands: ["cd /work && git status"]` 같은 shell string으로 유지한다. `{command:"cd", args:["/work","&&","git","status"]}`처럼 shell builtin이나 shell operator가 structured argv에 섞인 형태도 direct-exec로 실행되지 않도록 shell string으로 정규화한다. 호환성을 위해 `tool_choice`가 생략되었거나 `auto`/강제 tool 선택 형태로 들어오면 내부 실행 입력에서는 `tool_choice: "none"`으로 낮춰 백엔드의 auto tool-calling 요구 조건에 의해 요청 전체가 실패하지 않게 한다. `parallel_tool_calls`와 `stream_options`는 클라이언트 호환성을 위해 수신하지만 현재 Edge 실행 의미에는 반영하지 않는다. diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 70e0524..ec3d6e7 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -511,6 +511,81 @@ func TestSynthesizesTemplateToolCallConvertsStructuredShellOperatorsToString(t * } } +func TestSynthesizesTemplateToolCallFollowsStringArrayCommandSchema(t *testing.T) { + content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': 'status'}])}}" + tools := []any{map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "run_commands", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "commands": map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + }, + }, + }, + }, + }} + + _, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") + + if len(toolCalls) != 1 { + t.Fatalf("tool_calls len: got %d", len(toolCalls)) + } + call := toolCalls[0].(map[string]any) + fn := call["function"].(map[string]any) + var args map[string][]string + if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments JSON: %v", err) + } + if len(args["commands"]) != 1 || args["commands"][0] != "git status" { + t.Fatalf("commands args: %+v", args["commands"]) + } +} + +func TestSynthesizesTemplateToolCallFollowsSingleCommandSchema(t *testing.T) { + content := "확인합니다.\n\n{{bash(commands=[{'command': 'cd', 'args': ['/config/workspace/iop', '&&', 'git', 'status']}], runInTerminal=True)}}" + tools := []any{map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "bash", + "parameters": map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + }, + }, + }, + }, + }} + + _, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") + + if len(toolCalls) != 1 { + t.Fatalf("tool_calls len: got %d", len(toolCalls)) + } + call := toolCalls[0].(map[string]any) + fn := call["function"].(map[string]any) + var args map[string]string + if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments JSON: %v", err) + } + if args["command"] != "cd /config/workspace/iop && git status" { + t.Fatalf("command arg: %+v", args) + } + if _, ok := args["commands"]; ok { + t.Fatalf("commands should not be emitted: %+v", args) + } + if _, ok := args["runInTerminal"]; ok { + t.Fatalf("runInTerminal should not be emitted: %+v", args) + } +} + func TestSynthesizesShellCommandStringWithoutExtraExecutionHints(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=['command -v git && echo \"found\" || echo \"not found\"'])}}" tools := []any{map[string]any{ diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index 8aafb22..aa03935 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -196,12 +196,17 @@ type mustacheTextToolCallBlock struct { args string } +type textToolSpec struct { + name string + parameters map[string]any +} + func synthesizeToolCallsFromText(content string, tools []any, runID string) (string, []any) { - allowed := requestedToolNames(tools) - if len(allowed) == 0 { + specs := requestedToolSpecs(tools) + if len(specs) == 0 { return content, nil } - matches := collectTextToolCallMatches(content, allowed) + matches := collectTextToolCallMatches(content, specs) if len(matches) == 0 { return content, nil } @@ -231,10 +236,10 @@ func synthesizeToolCallsFromText(content string, tools []any, runID string) (str return strings.TrimSpace(cleaned.String()), toolCalls } -func collectTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch { +func collectTextToolCallMatches(content string, specs map[string]textToolSpec) []textToolCallMatch { var matches []textToolCallMatch - matches = append(matches, collectXMLTextToolCallMatches(content, allowed)...) - matches = append(matches, collectMustacheTextToolCallMatches(content, allowed)...) + matches = append(matches, collectXMLTextToolCallMatches(content, specs)...) + matches = append(matches, collectMustacheTextToolCallMatches(content, specs)...) sort.SliceStable(matches, func(i, j int) bool { if matches[i].start == matches[j].start { return matches[i].end > matches[j].end @@ -244,7 +249,7 @@ func collectTextToolCallMatches(content string, allowed map[string]struct{}) []t return matches } -func collectXMLTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch { +func collectXMLTextToolCallMatches(content string, specs map[string]textToolSpec) []textToolCallMatch { if !strings.Contains(strings.ToLower(content), textToolCallOpeningHint) { return nil } @@ -255,7 +260,7 @@ func collectXMLTextToolCallMatches(content string, allowed map[string]struct{}) out := make([]textToolCallMatch, 0, len(rawMatches)) for _, rawMatch := range rawMatches { block := content[rawMatch[0]:rawMatch[1]] - name, arguments, ok := parseTextToolCallBlock(block, allowed) + name, arguments, ok := parseTextToolCallBlock(block, specs) if !ok { continue } @@ -269,7 +274,7 @@ func collectXMLTextToolCallMatches(content string, allowed map[string]struct{}) return out } -func collectMustacheTextToolCallMatches(content string, allowed map[string]struct{}) []textToolCallMatch { +func collectMustacheTextToolCallMatches(content string, specs map[string]textToolSpec) []textToolCallMatch { if !strings.Contains(content, textMustacheToolCallOpenHint) { return nil } @@ -279,14 +284,15 @@ func collectMustacheTextToolCallMatches(content string, allowed map[string]struc } out := make([]textToolCallMatch, 0, len(blocks)) for _, block := range blocks { - if _, ok := allowed[block.name]; !ok { + spec, ok := specs[block.name] + if !ok { continue } args, ok := parseMustacheToolCallArguments(block.args) if !ok { continue } - args = normalizeTextToolCallArguments(block.name, args) + args = normalizeTextToolCallArguments(block.name, args, spec) encoded, err := json.Marshal(args) if err != nil { continue @@ -450,13 +456,42 @@ func requestedToolNames(tools []any) map[string]struct{} { return names } -func parseTextToolCallBlock(block string, allowed map[string]struct{}) (string, string, bool) { +func requestedToolSpecs(tools []any) map[string]textToolSpec { + specs := make(map[string]textToolSpec, len(tools)) + for _, tool := range tools { + m, ok := tool.(map[string]any) + if !ok { + continue + } + if name, ok := m["name"].(string); ok && strings.TrimSpace(name) != "" { + name = strings.TrimSpace(name) + specs[name] = textToolSpec{name: name, parameters: mapValue(m["parameters"])} + } + fn, ok := m["function"].(map[string]any) + if !ok { + continue + } + if name, ok := fn["name"].(string); ok && strings.TrimSpace(name) != "" { + name = strings.TrimSpace(name) + specs[name] = textToolSpec{name: name, parameters: mapValue(fn["parameters"])} + } + } + return specs +} + +func mapValue(value any) map[string]any { + m, _ := value.(map[string]any) + return m +} + +func parseTextToolCallBlock(block string, specs map[string]textToolSpec) (string, string, bool) { match := textToolFunctionRE.FindStringSubmatchIndex(block) if match == nil { return "", "", false } name := strings.TrimSpace(block[match[2]:match[3]]) - if _, ok := allowed[name]; !ok { + spec, ok := specs[name] + if !ok { return "", "", false } body := block[match[4]:match[5]] @@ -468,7 +503,7 @@ func parseTextToolCallBlock(block string, allowed map[string]struct{}) (string, } args[key] = parseTextToolParameterValue(body[param[4]:param[5]]) } - args = normalizeTextToolCallArguments(name, args) + args = normalizeTextToolCallArguments(name, args, spec) encoded, err := json.Marshal(args) if err != nil { return "", "", false @@ -488,11 +523,11 @@ func parseTextToolParameterValue(raw string) any { return trimmed } -func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]any { +func normalizeTextToolCallArguments(_ string, args map[string]any, spec textToolSpec) map[string]any { + topProps := schemaObjectProperties(spec.parameters) + commandsSchema := topProps["commands"] + commandField := schemaSingleCommandField(topProps) commands, ok := args["commands"].([]any) - if !ok { - return args - } var out map[string]any ensureOut := func() map[string]any { if out != nil { @@ -500,24 +535,46 @@ func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]an } out = make(map[string]any, len(args)) for key, value := range args { - switch key { - case "description", "runInTerminal": + if shouldStripToolArgumentKey(key, topProps) { continue - default: - out[key] = value } + out[key] = value } return out } - if _, ok := args["description"]; ok { - ensureOut() + for key := range args { + if shouldStripToolArgumentKey(key, topProps) { + ensureOut() + break + } } - if _, ok := args["runInTerminal"]; ok { - ensureOut() + if !ok { + if out != nil { + return out + } + return args } + + if commandField != "" && !schemaHasProperty(topProps, "commands") { + if rendered, ok := firstCommandAsShellString(commands); ok { + collapsed := ensureOut() + delete(collapsed, "commands") + collapsed[commandField] = rendered + return collapsed + } + } + + itemSchema := schemaArrayItemSchema(commandsSchema) + itemAllowsString := schemaAllowsType(itemSchema, "string") + itemAllowsObject := schemaAllowsType(itemSchema, "object") || len(schemaObjectProperties(itemSchema)) > 0 + itemObjectProps := schemaObjectProperties(itemSchema) var normalized []any for i, command := range commands { - cleaned, changed := normalizeCommandArgument(command) + cleaned, changed := normalizeCommandArgument(command, commandArgSchema{ + allowsString: itemAllowsString, + allowsObject: itemAllowsObject, + objectProps: itemObjectProps, + }) if !changed { if normalized != nil { normalized[i] = command @@ -540,7 +597,25 @@ func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]an return out } -func normalizeCommandArgument(command any) (any, bool) { +type commandArgSchema struct { + allowsString bool + allowsObject bool + objectProps map[string]any +} + +func normalizeCommandArgument(command any, schema commandArgSchema) (any, bool) { + if text, ok := command.(string); ok { + text = strings.TrimSpace(text) + if text == "" { + return command, false + } + if schema.allowsObject && !schema.allowsString && !commandStringNeedsShell(text) { + if structured, ok := structuredCommandFromShellWords(text); ok { + return structured, true + } + } + return text, text != command + } m, ok := command.(map[string]any) if !ok { return command, false @@ -552,22 +627,32 @@ func normalizeCommandArgument(command any) (any, bool) { if args, ok := stringSliceArgument(m["args"]); ok { commandText = strings.TrimSpace(commandText) if commandExecutableNeedsShell(commandText) || commandArgsNeedShell(args) { - return renderShellCommand(commandText, args), true + if schema.allowsString || !schema.allowsObject { + return renderShellCommand(commandText, args), true + } + return cleanedStructuredCommand(commandText, args, schema.objectProps), true } - cleaned := map[string]any{ - "command": commandText, + if schema.allowsObject || !schema.allowsString { + cleaned := cleanedStructuredCommand(commandText, args, schema.objectProps) + return cleaned, !commandStructuredArgsAlreadyClean(m, args) || commandObjectHasStrippedKeys(m, schema.objectProps) } - if len(args) > 0 { - cleaned["args"] = args - } - return cleaned, !commandStructuredArgsAlreadyClean(m, args) + return renderShellCommand(commandText, args), true } commandText = strings.TrimSpace(commandText) if commandText != "" { if commandStringNeedsShell(commandText) { return commandText, true } - if structured, ok := structuredCommandFromShellWords(commandText); ok { + if (schema.allowsObject || !schema.allowsString) && !commandStringNeedsShell(commandText) { + structured, ok := structuredCommandFromShellWords(commandText) + if ok { + if len(schema.objectProps) > 0 { + structured = filterMapToSchemaProperties(structured, schema.objectProps) + } + return structured, true + } + } + if structured, ok := structuredCommandFromShellWords(commandText); ok && schema.allowsObject && !schema.allowsString { return structured, true } return commandText, true @@ -575,6 +660,89 @@ func normalizeCommandArgument(command any) (any, bool) { return command, false } +func shouldStripToolArgumentKey(key string, props map[string]any) bool { + if len(props) > 0 { + return !schemaHasProperty(props, key) + } + return key == "description" || key == "runInTerminal" +} + +func schemaHasProperty(props map[string]any, key string) bool { + _, ok := props[key] + return ok +} + +func schemaSingleCommandField(props map[string]any) string { + for _, key := range []string{"command", "cmd"} { + if schemaAllowsType(props[key], "string") { + return key + } + } + return "" +} + +func firstCommandAsShellString(commands []any) (string, bool) { + if len(commands) == 0 { + return "", false + } + return commandAsShellString(commands[0]) +} + +func commandAsShellString(command any) (string, bool) { + switch v := command.(type) { + case string: + text := strings.TrimSpace(v) + return text, text != "" + case map[string]any: + commandText, ok := v["command"].(string) + if !ok { + return "", false + } + args, _ := stringSliceArgument(v["args"]) + text := renderShellCommand(commandText, args) + return text, strings.TrimSpace(text) != "" + default: + return "", false + } +} + +func cleanedStructuredCommand(commandText string, args []string, props map[string]any) map[string]any { + cleaned := map[string]any{ + "command": commandText, + } + if len(args) > 0 { + cleaned["args"] = args + } + if len(props) > 0 { + return filterMapToSchemaProperties(cleaned, props) + } + return cleaned +} + +func filterMapToSchemaProperties(m map[string]any, props map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for key, value := range m { + if schemaHasProperty(props, key) { + out[key] = value + } + } + return out +} + +func commandObjectHasStrippedKeys(m map[string]any, props map[string]any) bool { + for key := range m { + switch key { + case "command", "args": + continue + default: + if len(props) == 0 || !schemaHasProperty(props, key) { + return true + } + } + } + return false +} + func stringSliceArgument(value any) ([]string, bool) { if value == nil { return nil, false @@ -702,6 +870,99 @@ func firstShellWord(s string) string { return s } +func schemaObjectProperties(schema any) map[string]any { + m, ok := schema.(map[string]any) + if !ok { + return nil + } + props, _ := m["properties"].(map[string]any) + if len(props) > 0 { + return props + } + for _, key := range []string{"oneOf", "anyOf", "allOf"} { + for _, branch := range schemaBranches(m[key]) { + if branchProps := schemaObjectProperties(branch); len(branchProps) > 0 { + if props == nil { + props = map[string]any{} + } + for propKey, propValue := range branchProps { + props[propKey] = propValue + } + } + } + } + return props +} + +func schemaArrayItemSchema(schema any) any { + m, ok := schema.(map[string]any) + if !ok { + return nil + } + if items, ok := m["items"]; ok { + return items + } + for _, key := range []string{"oneOf", "anyOf", "allOf"} { + for _, branch := range schemaBranches(m[key]) { + if item := schemaArrayItemSchema(branch); item != nil { + return item + } + } + } + return nil +} + +func schemaAllowsType(schema any, want string) bool { + if schema == nil { + return false + } + m, ok := schema.(map[string]any) + if !ok { + return false + } + switch t := m["type"].(type) { + case string: + if t == want { + return true + } + case []any: + for _, item := range t { + if item == want { + return true + } + } + } + if want == "object" && len(schemaObjectProperties(schema)) > 0 { + return true + } + if want == "array" && schemaArrayItemSchema(schema) != nil { + return true + } + for _, key := range []string{"oneOf", "anyOf"} { + for _, branch := range schemaBranches(m[key]) { + if schemaAllowsType(branch, want) { + return true + } + } + } + if branches := schemaBranches(m["allOf"]); len(branches) > 0 { + for _, branch := range branches { + if schemaAllowsType(branch, want) { + return true + } + } + } + return false +} + +func schemaBranches(value any) []any { + raw, ok := value.([]any) + if !ok { + return nil + } + return raw +} + func parseMustacheToolCallArguments(raw string) (map[string]any, bool) { if args, ok := parseMustacheToolCallArgumentsStrict(raw); ok { return args, true