fix(openai): tool arguments를 schema 기준으로 정규화한다

This commit is contained in:
toki 2026-06-27 21:19:16 +09:00
parent 1c03ee7c3c
commit 8a17e3a057
3 changed files with 377 additions and 39 deletions

View file

@ -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"`를 반환한다.
지원하는 텍스트 블록의 최소 형태는 `<tool_call><function=<name>><parameter=<key>>JSON-or-text</parameter></function></tool_call>` 또는 `{{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 실행 의미에는 반영하지 않는다.

View file

@ -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{

View file

@ -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