From 4b59b70f149832731c013f471249b6f5f6ac3002 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 27 Jun 2026 19:10:03 +0900 Subject: [PATCH] =?UTF-8?q?fix(openai):=20shell=20=EB=AA=85=EB=A0=B9=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=ED=9E=8C=ED=8A=B8=EB=A5=BC=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=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 | 3 +- apps/edge/internal/openai/server_test.go | 127 ++++++++++++++++-- apps/edge/internal/openai/types.go | 81 ++++++++++- 3 files changed, 189 insertions(+), 22 deletions(-) diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 5bee986..1bab53d 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -169,7 +169,8 @@ 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 객체에 Cline 실행 힌트인 `description`, `runInTerminal`이 포함되면 tool schema 검증을 위해 제거한다. +`commands` 배열의 command 객체에 UI 설명용 `description`이 포함되면 tool schema 검증을 위해 제거한다. +`commands` 배열 내부의 `runInTerminal`은 top-level 실행 힌트로 올리고, shell 연산자/리다이렉션/quote/공백 인자 등 shell 해석이 필요한 명령 문자열은 top-level `runInTerminal: true`를 부여한다. 호환성을 위해 `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 37c1a72..7081475 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -313,13 +313,17 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTextBlock(t *testing.T) { if fn["name"] != "run_commands" { t.Fatalf("function name: got %+v", fn["name"]) } - var args map[string][]string + var args map[string]any 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] != "cd /config/workspace/iop && git status" { + commands, ok := args["commands"].([]any) + if !ok || len(commands) != 1 || commands[0] != "cd /config/workspace/iop && git status" { t.Fatalf("commands args: %+v", args["commands"]) } + if args["runInTerminal"] != true { + t.Fatalf("shell command should request terminal execution: %+v", args) + } } func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) { @@ -366,22 +370,100 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) if fn["name"] != "run_commands" { t.Fatalf("function name: got %+v", fn["name"]) } - var args map[string][]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"] + commands, ok := args["commands"].([]any) + if !ok { + t.Fatalf("commands shape: %+v", args["commands"]) + } if len(commands) != 1 { t.Fatalf("commands len: got %d", len(commands)) } - if commands[0]["command"] != "git status" { - t.Fatalf("commands args: %+v", commands[0]) + command, ok := commands[0].(map[string]any) + if !ok { + t.Fatalf("command shape: %+v", commands[0]) } - if _, ok := commands[0]["description"]; ok { - t.Fatalf("description should be stripped from command args: %+v", commands[0]) + if command["command"] != "git status" { + t.Fatalf("commands args: %+v", command) } - if _, ok := commands[0]["runInTerminal"]; ok { - t.Fatalf("runInTerminal should be stripped from command args: %+v", commands[0]) + if _, ok := command["description"]; ok { + t.Fatalf("description should be stripped from command args: %+v", command) + } + if _, ok := command["runInTerminal"]; ok { + t.Fatalf("runInTerminal should be stripped from command args: %+v", command) + } + if args["runInTerminal"] != true { + t.Fatalf("runInTerminal should be hoisted to top-level args: %+v", args) + } +} + +func TestSynthesizesTemplateToolCallHoistsTerminalExecutionHint(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", + "function": map[string]any{ + "name": "run_commands", + }, + }} + + cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") + + if cleaned != "확인합니다." { + t.Fatalf("content: got %q", cleaned) + } + 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) + } + if args["runInTerminal"] != true { + t.Fatalf("runInTerminal should be hoisted to top-level args: %+v", args) + } + commands := args["commands"].([]any) + command := commands[0].(map[string]any) + if command["command"] != `command -v git && echo "found" || echo "not found"` { + t.Fatalf("command: %+v", command) + } + if _, ok := command["runInTerminal"]; ok { + t.Fatalf("runInTerminal should not remain inside command item: %+v", command) + } +} + +func TestSynthesizesShellCommandStringRequestsTerminalExecution(t *testing.T) { + content := "확인합니다.\n\n{{run_commands(commands=['command -v git && echo \"found\" || echo \"not found\"'])}}" + tools := []any{map[string]any{ + "type": "function", + "function": map[string]any{ + "name": "run_commands", + }, + }} + + cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") + + if cleaned != "확인합니다." { + t.Fatalf("content: got %q", cleaned) + } + 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) + } + if args["runInTerminal"] != true { + t.Fatalf("shell command should request terminal execution: %+v", args) + } + commands := args["commands"].([]any) + if commands[0] != `command -v git && echo "found" || echo "not found"` { + t.Fatalf("commands args: %+v", commands) } } @@ -410,11 +492,22 @@ func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) { if !ok { t.Fatalf("function shape: %+v", call["function"]) } - var args map[string][]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"] + rawCommands, ok := args["commands"].([]any) + if !ok { + t.Fatalf("commands shape: %+v", args["commands"]) + } + commands := make([]map[string]any, 0, len(rawCommands)) + for _, rawCommand := range rawCommands { + command, ok := rawCommand.(map[string]any) + if !ok { + t.Fatalf("command shape: %+v", rawCommand) + } + commands = append(commands, command) + } if len(commands) != 1 { t.Fatalf("commands len: got %d", len(commands)) } @@ -424,6 +517,9 @@ func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) { if _, ok := commands[0]["runInTerminal"]; ok { t.Fatalf("runInTerminal should be stripped from command args: %+v", commands[0]) } + if args["runInTerminal"] != true { + t.Fatalf("quoted shell command should request terminal execution: %+v", args) + } } func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) { @@ -608,8 +704,11 @@ func TestChatCompletionsStreamSynthesizesToolCallsFromClineTemplateCall(t *testi if !strings.Contains(body, `\"command\":\"git status\"`) { t.Fatalf("stream tool_call arguments should include command:\n%s", body) } - if strings.Contains(body, "runInTerminal") || strings.Contains(body, "description") { - t.Fatalf("stream tool_call arguments should strip Cline-only metadata:\n%s", body) + if !strings.Contains(body, `\"runInTerminal\":true`) { + t.Fatalf("stream tool_call arguments should hoist terminal hint:\n%s", body) + } + if strings.Contains(body, "description") { + t.Fatalf("stream tool_call arguments should strip descriptions:\n%s", body) } if !strings.Contains(body, `"content":"먼저 현재 git 상태를 확인하겠습니다."`) { t.Fatalf("stream should preserve text before tool call:\n%s", body) diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index ef65c87..885f3e0 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -494,8 +494,15 @@ func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]an return args } var normalized []any + runInTerminal := boolValue(args["runInTerminal"]) for i, command := range commands { - cleaned, changed := normalizeCommandArgument(command) + if commandStringNeedsShell(command) { + runInTerminal = true + } + cleaned, commandRunInTerminal, changed := normalizeCommandArgument(command) + if commandRunInTerminal { + runInTerminal = true + } if !changed { if normalized != nil { normalized[i] = command @@ -509,27 +516,39 @@ func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]an normalized[i] = cleaned } if normalized == nil { - return args + if !runInTerminal || boolValue(args["runInTerminal"]) { + return args + } + out := make(map[string]any, len(args)+1) + for key, value := range args { + out[key] = value + } + out["runInTerminal"] = true + return out } out := make(map[string]any, len(args)) for key, value := range args { out[key] = value } out["commands"] = normalized + if runInTerminal { + out["runInTerminal"] = true + } return out } -func normalizeCommandArgument(command any) (any, bool) { +func normalizeCommandArgument(command any) (any, bool, bool) { m, ok := command.(map[string]any) if !ok { - return command, false + return command, false, false } if _, ok := m["command"]; !ok { - return command, false + return command, false, false } + runInTerminal := boolValue(m["runInTerminal"]) if _, ok := m["description"]; !ok { if _, ok := m["runInTerminal"]; !ok { - return command, false + return command, runInTerminal, false } } cleaned := make(map[string]any, len(m)) @@ -541,7 +560,55 @@ func normalizeCommandArgument(command any) (any, bool) { cleaned[key] = value } } - return cleaned, true + return cleaned, runInTerminal, true +} + +func boolValue(v any) bool { + value, ok := v.(bool) + return ok && value +} + +func commandStringNeedsShell(command any) bool { + text, ok := commandText(command) + if !ok { + return false + } + text = strings.TrimSpace(text) + if text == "" { + return false + } + if strings.ContainsAny(text, " \t\n\r|&;<>(){}[]*$`'\"\\") { + return true + } + first := firstShellWord(text) + switch first { + case "alias", "bg", "cd", "command", "export", "fg", "hash", "jobs", "read", "set", "source", "test", "type", "ulimit", "umask", "unalias", "unset", "[", "[[", ":": + return true + default: + return false + } +} + +func commandText(command any) (string, bool) { + switch t := command.(type) { + case string: + return t, true + case map[string]any: + text, ok := t["command"].(string) + return text, ok + default: + return "", false + } +} + +func firstShellWord(s string) string { + for i, r := range s { + switch r { + case ' ', '\t', '\n', '\r': + return s[:i] + } + } + return s } func parseMustacheToolCallArguments(raw string) (map[string]any, bool) {