From 16dadef70435e72c23a390e70b5782783095e895 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 27 Jun 2026 20:13:32 +0900 Subject: [PATCH] =?UTF-8?q?fix(openai):=20Cline=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=84=20shell=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EC=97=B4=EB=A1=9C=20=EC=A0=95=EA=B7=9C=ED=99=94=ED=95=9C?= =?UTF-8?q?=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 | 6 +- apps/edge/internal/openai/server_test.go | 74 ++++++++++- apps/edge/internal/openai/types.go | 116 +++++++++++++++--- 3 files changed, 170 insertions(+), 26 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 7370c46..7499aa0 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -169,9 +169,9 @@ 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 객체에 UI 설명용 `description`이나 실행 위치 힌트용 `runInTerminal`이 포함되면 tool schema 검증을 위해 제거하고, 최상위 `runInTerminal`도 출력하지 않는다. -쉘 문법이 없는 단순 command 객체는 `{command: "git status"}` 형태를 유지한다. -단, `cd`, `command` 같은 shell builtin 또는 `&&`, `||`, pipe, redirect, quote 등 shell 해석이 필요한 command 객체는 기존 shell 실행 경로를 유지하기 위해 `"command -v git && ..."` 같은 문자열 command로 정규화한다. +`commands` 배열의 `{command: "git status", description, runInTerminal}`처럼 shell command 문자열을 command 객체에 담은 형태는 Cline의 direct-exec 경로로 오해되지 않도록 `commands: ["git status"]` 형태로 정규화한다. +UI 설명용 `description`이나 실행 위치 힌트용 `runInTerminal`은 command 객체와 최상위 args 어디에도 출력하지 않는다. +단, `{command: "git", args: ["status", "--short"]}`처럼 명시적 argv direct-exec 형태는 schema 검증을 통과하도록 `description`/`runInTerminal`만 제거하고 유지한다. 호환성을 위해 `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 f8b2a85..6e7efb9 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -366,16 +366,16 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) if fn["name"] != "run_commands" { t.Fatalf("function name: got %+v", fn["name"]) } - var args map[string][]map[string]string + 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]["command"] != "git status" { + if len(args["commands"]) != 1 || args["commands"][0] != "git status" { t.Fatalf("commands args: %+v", args["commands"]) } } -func TestSynthesizesTemplateToolCallConvertsShellCommandObjectsToStrings(t *testing.T) { +func TestSynthesizesTemplateToolCallConvertsCommandObjectsToShellStrings(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'command -v git && echo \"found\" || echo \"not found\"', 'runInTerminal': True}])}}" tools := []any{map[string]any{ "type": "function", @@ -407,6 +407,72 @@ func TestSynthesizesTemplateToolCallConvertsShellCommandObjectsToStrings(t *test } } +func TestSynthesizesTemplateToolCallStripsTopLevelExecutionHints(t *testing.T) { + content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git status'}], runInTerminal=True, description='status')}}" + tools := []any{map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "run_commands", + }, + }} + + _, 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]any + if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments JSON: %v", err) + } + commands := args["commands"].([]any) + if commands[0] != "git status" { + t.Fatalf("commands args: %+v", commands) + } + if _, ok := args["runInTerminal"]; ok { + t.Fatalf("runInTerminal should not be emitted: %+v", args) + } + if _, ok := args["description"]; ok { + t.Fatalf("description should not be emitted: %+v", args) + } +} + +func TestSynthesizesTemplateToolCallKeepsStructuredCommandArgs(t *testing.T) { + content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git', 'args': ['status', '--short'], 'description': 'status'}])}}" + tools := []any{map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "run_commands", + }, + }} + + _, 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]any + if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { + t.Fatalf("arguments JSON: %v", err) + } + commands := args["commands"].([]any) + command := commands[0].(map[string]any) + if command["command"] != "git" { + t.Fatalf("command: %+v", command) + } + commandArgs := command["args"].([]any) + if len(commandArgs) != 2 || commandArgs[0] != "status" || commandArgs[1] != "--short" { + t.Fatalf("command args: %+v", commandArgs) + } + if _, ok := command["description"]; ok { + t.Fatalf("description should not be emitted: %+v", command) + } +} + func TestSynthesizesShellCommandStringWithoutExtraExecutionHints(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=['command -v git && echo \"found\" || echo \"not found\"'])}}" tools := []any{map[string]any{ @@ -652,7 +718,7 @@ func TestChatCompletionsStreamSynthesizesToolCallsFromClineTemplateCall(t *testi if !strings.Contains(body, `"index":0`) { t.Fatalf("stream tool_call delta should include index:\n%s", body) } - if !strings.Contains(body, `\"commands\":[{\"command\":\"git status\"}]`) { + if !strings.Contains(body, `\"commands\":[\"git status\"]`) { t.Fatalf("stream tool_call arguments should include command:\n%s", body) } if strings.Contains(body, "runInTerminal") || strings.Contains(body, "description") { diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index 6145d14..5c69a11 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -493,6 +493,28 @@ func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]an if !ok { return args } + var out map[string]any + ensureOut := func() map[string]any { + if out != nil { + return out + } + out = make(map[string]any, len(args)) + for key, value := range args { + switch key { + case "description", "runInTerminal": + continue + default: + out[key] = value + } + } + return out + } + if _, ok := args["description"]; ok { + ensureOut() + } + if _, ok := args["runInTerminal"]; ok { + ensureOut() + } var normalized []any for i, command := range commands { cleaned, changed := normalizeCommandArgument(command) @@ -509,13 +531,12 @@ func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]an normalized[i] = cleaned } if normalized == nil { + if out != nil { + return out + } return args } - out := make(map[string]any, len(args)) - for key, value := range args { - out[key] = value - } - out["commands"] = normalized + ensureOut()["commands"] = normalized return out } @@ -528,24 +549,81 @@ func normalizeCommandArgument(command any) (any, bool) { if !ok { return command, false } - if commandStringNeedsShell(commandText) { + if args, ok := stringSliceArgument(m["args"]); ok { + if commandStringNeedsShell(commandText) { + return strings.TrimSpace(commandText) + shellJoinArgs(args), true + } + cleaned := map[string]any{ + "command": commandText, + } + if len(args) > 0 { + cleaned["args"] = args + } + return cleaned, !commandStructuredArgsAlreadyClean(m, args) + } + if strings.TrimSpace(commandText) != commandText { + return strings.TrimSpace(commandText), true + } + if commandText != "" { return commandText, true } - if _, ok := m["description"]; !ok { - if _, ok := m["runInTerminal"]; !ok { - return command, false + return command, false +} + +func stringSliceArgument(value any) ([]string, bool) { + if value == nil { + return nil, false + } + raw, ok := value.([]any) + if !ok { + return nil, false + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + s, ok := item.(string) + if !ok { + return nil, false + } + out = append(out, s) + } + return out, true +} + +func commandStructuredArgsAlreadyClean(m map[string]any, args []string) bool { + if len(m) != 2 { + return false + } + rawArgs, ok := m["args"].([]any) + if !ok || len(rawArgs) != len(args) { + return false + } + for i, arg := range args { + if rawArgs[i] != arg { + return false } } - cleaned := make(map[string]any, len(m)) - for key, value := range m { - switch key { - case "description", "runInTerminal": - continue - default: - cleaned[key] = value - } + return true +} + +func shellJoinArgs(args []string) string { + if len(args) == 0 { + return "" } - return cleaned, true + quoted := make([]string, 0, len(args)) + for _, arg := range args { + quoted = append(quoted, shellQuoteArg(arg)) + } + return " " + strings.Join(quoted, " ") +} + +func shellQuoteArg(arg string) string { + if arg == "" { + return "''" + } + if !strings.ContainsAny(arg, " \t\n\r|&;<>(){}[]*$`'\"\\") { + return arg + } + return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" } func commandStringNeedsShell(text string) bool { @@ -553,7 +631,7 @@ func commandStringNeedsShell(text string) bool { if text == "" { return false } - if strings.ContainsAny(text, "\n\r|&;<>(){}[]*$`'\"\\") { + if strings.ContainsAny(text, " \t\n\r|&;<>(){}[]*$`'\"\\") { return true } first := firstShellWord(text)