fix(openai): Cline 명령 호출을 shell 문자열로 정규화한다
This commit is contained in:
parent
37ac90299f
commit
16dadef704
3 changed files with 170 additions and 26 deletions
|
|
@ -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"`를 반환한다.
|
||||
지원하는 텍스트 블록의 최소 형태는 `<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 객체에 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 실행 의미에는 반영하지 않는다.
|
||||
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue