diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 421117a..4b14f91 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -172,6 +172,7 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa `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으로 유지한다. +`{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 실행 의미에는 반영하지 않는다. diff --git a/apps/edge/internal/openai/server_test.go b/apps/edge/internal/openai/server_test.go index 96bafe7..70e0524 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -482,6 +482,35 @@ func TestSynthesizesTemplateToolCallKeepsStructuredCommandArgs(t *testing.T) { } } +func TestSynthesizesTemplateToolCallConvertsStructuredShellOperatorsToString(t *testing.T) { + content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'cd', 'args': ['/config/workspace/iop', '&&', 'git', 'status', '&&', 'git', 'diff', '--stat']}, {'command': 'ls', 'args': ['-la', '/config/workspace/iop', '&&', 'pwd']}])}}" + 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] != "cd /config/workspace/iop && git status && git diff --stat" { + t.Fatalf("first command: %+v", commands[0]) + } + if commands[1] != "ls -la /config/workspace/iop && pwd" { + t.Fatalf("second command: %+v", commands[1]) + } +} + func TestSynthesizesShellCommandStringWithoutExtraExecutionHints(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=['command -v git && echo \"found\" || echo \"not found\"'])}}" tools := []any{map[string]any{ diff --git a/apps/edge/internal/openai/types.go b/apps/edge/internal/openai/types.go index c5ca22c..8aafb22 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -551,8 +551,8 @@ func normalizeCommandArgument(command any) (any, bool) { } if args, ok := stringSliceArgument(m["args"]); ok { commandText = strings.TrimSpace(commandText) - if commandExecutableNeedsShell(commandText) { - return strings.TrimSpace(commandText) + shellJoinArgs(args), true + if commandExecutableNeedsShell(commandText) || commandArgsNeedShell(args) { + return renderShellCommand(commandText, args), true } cleaned := map[string]any{ "command": commandText, @@ -621,10 +621,17 @@ func shellJoinArgs(args []string) string { return " " + strings.Join(quoted, " ") } +func renderShellCommand(command string, args []string) string { + return strings.TrimSpace(command) + shellJoinArgs(args) +} + func shellQuoteArg(arg string) string { if arg == "" { return "''" } + if isShellOperatorToken(arg) { + return arg + } if !strings.ContainsAny(arg, " \t\n\r|&;<>(){}[]*$`'\"\\") { return arg } @@ -647,7 +654,25 @@ func structuredCommandFromShellWords(text string) (map[string]any, bool) { func commandExecutableNeedsShell(text string) bool { text = strings.TrimSpace(text) - return text == "" || strings.ContainsAny(text, " \t\n\r|&;<>(){}[]*$`'\"\\") + return text == "" || strings.ContainsAny(text, " \t\n\r|&;<>(){}[]*$`'\"\\") || commandStringNeedsShell(text) +} + +func commandArgsNeedShell(args []string) bool { + for _, arg := range args { + if isShellOperatorToken(arg) { + return true + } + } + return false +} + +func isShellOperatorToken(arg string) bool { + switch arg { + case "&&", "||", "|", "|&", ";", "&", ">", ">>", "<", "<<", "<<<", "2>", "2>>", "&>", "2>&1": + return true + default: + return false + } } func commandStringNeedsShell(text string) bool {