diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 7499aa0..421117a 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: "git status", description, runInTerminal}`처럼 shell command 문자열을 command 객체에 담은 형태는 Cline의 direct-exec 경로로 오해되지 않도록 `commands: ["git status"]` 형태로 정규화한다. +`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 어디에도 출력하지 않는다. -단, `{command: "git", args: ["status", "--short"]}`처럼 명시적 argv direct-exec 형태는 schema 검증을 통과하도록 `description`/`runInTerminal`만 제거하고 유지한다. +단, `cd`, `command -v`, `&&`, pipe, redirect, quote 등 shell 해석이 필요한 명령은 `commands: ["cd /work && git status"]` 같은 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 6e7efb9..96bafe7 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -366,13 +366,17 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(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][]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] != "git status" { + if len(args["commands"]) != 1 || args["commands"][0]["command"] != "git" { t.Fatalf("commands args: %+v", args["commands"]) } + commandArgs := args["commands"][0]["args"].([]any) + if len(commandArgs) != 1 || commandArgs[0] != "status" { + t.Fatalf("command args: %+v", commandArgs) + } } func TestSynthesizesTemplateToolCallConvertsCommandObjectsToShellStrings(t *testing.T) { @@ -428,9 +432,14 @@ func TestSynthesizesTemplateToolCallStripsTopLevelExecutionHints(t *testing.T) { t.Fatalf("arguments JSON: %v", err) } commands := args["commands"].([]any) - if commands[0] != "git status" { + command := commands[0].(map[string]any) + if command["command"] != "git" { t.Fatalf("commands args: %+v", commands) } + commandArgs := command["args"].([]any) + if len(commandArgs) != 1 || commandArgs[0] != "status" { + t.Fatalf("command args: %+v", commandArgs) + } if _, ok := args["runInTerminal"]; ok { t.Fatalf("runInTerminal should not be emitted: %+v", args) } @@ -718,7 +727,8 @@ 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\":[\"git status\"]`) { + if !strings.Contains(body, `\"commands\":[{\"args\":[\"status\"],\"command\":\"git\"}]`) && + !strings.Contains(body, `\"commands\":[{\"command\":\"git\",\"args\":[\"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 5c69a11..c5ca22c 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -550,7 +550,8 @@ func normalizeCommandArgument(command any) (any, bool) { return command, false } if args, ok := stringSliceArgument(m["args"]); ok { - if commandStringNeedsShell(commandText) { + commandText = strings.TrimSpace(commandText) + if commandExecutableNeedsShell(commandText) { return strings.TrimSpace(commandText) + shellJoinArgs(args), true } cleaned := map[string]any{ @@ -561,10 +562,14 @@ func normalizeCommandArgument(command any) (any, bool) { } return cleaned, !commandStructuredArgsAlreadyClean(m, args) } - if strings.TrimSpace(commandText) != commandText { - return strings.TrimSpace(commandText), true - } + commandText = strings.TrimSpace(commandText) if commandText != "" { + if commandStringNeedsShell(commandText) { + return commandText, true + } + if structured, ok := structuredCommandFromShellWords(commandText); ok { + return structured, true + } return commandText, true } return command, false @@ -626,12 +631,31 @@ func shellQuoteArg(arg string) string { return "'" + strings.ReplaceAll(arg, "'", "'\\''") + "'" } +func structuredCommandFromShellWords(text string) (map[string]any, bool) { + words := strings.Fields(text) + if len(words) == 0 { + return nil, false + } + out := map[string]any{ + "command": words[0], + } + if len(words) > 1 { + out["args"] = words[1:] + } + return out, true +} + +func commandExecutableNeedsShell(text string) bool { + text = strings.TrimSpace(text) + return text == "" || strings.ContainsAny(text, " \t\n\r|&;<>(){}[]*$`'\"\\") +} + func commandStringNeedsShell(text string) bool { text = strings.TrimSpace(text) if text == "" { return false } - if strings.ContainsAny(text, " \t\n\r|&;<>(){}[]*$`'\"\\") { + if strings.ContainsAny(text, "\n\r|&;<>(){}[]*$`'\"\\") { return true } first := firstShellWord(text)