diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 1bab53d..ebedc1f 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -170,7 +170,7 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa 지원하는 텍스트 블록의 최소 형태는 `>>JSON-or-text` 또는 `{{function_name(key=Python/JSON-like-literal)}}`이다. 템플릿 호출 형태의 인자는 single-quoted string, `True`/`False`/`None`, 배열, 객체를 JSON `arguments` 문자열로 정규화한다. `commands` 배열의 command 객체에 UI 설명용 `description`이 포함되면 tool schema 검증을 위해 제거한다. -`commands` 배열 내부의 `runInTerminal`은 top-level 실행 힌트로 올리고, shell 연산자/리다이렉션/quote/공백 인자 등 shell 해석이 필요한 명령 문자열은 top-level `runInTerminal: true`를 부여한다. +`commands` 배열 내부의 `{command: "...", description, runInTerminal}` 형태는 tool schema와 기존 shell 실행 경로를 맞추기 위해 `commands: ["..."]` 형태로 정규화하며, `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 7081475..8f5614c 100644 --- a/apps/edge/internal/openai/server_test.go +++ b/apps/edge/internal/openai/server_test.go @@ -313,17 +313,13 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTextBlock(t *testing.T) { if fn["name"] != "run_commands" { t.Fatalf("function name: got %+v", fn["name"]) } - var args map[string]any + var args map[string][]string if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { t.Fatalf("arguments JSON: %v", err) } - commands, ok := args["commands"].([]any) - if !ok || len(commands) != 1 || commands[0] != "cd /config/workspace/iop && git status" { + if len(args["commands"]) != 1 || args["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) { @@ -370,36 +366,16 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) if fn["name"] != "run_commands" { t.Fatalf("function name: got %+v", fn["name"]) } - var args map[string]any + var args map[string][]string if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { t.Fatalf("arguments JSON: %v", err) } - 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)) - } - command, ok := commands[0].(map[string]any) - if !ok { - t.Fatalf("command shape: %+v", commands[0]) - } - if command["command"] != "git status" { - t.Fatalf("commands args: %+v", command) - } - 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) + if len(args["commands"]) != 1 || args["commands"][0] != "git status" { + t.Fatalf("commands args: %+v", args["commands"]) } } -func TestSynthesizesTemplateToolCallHoistsTerminalExecutionHint(t *testing.T) { +func TestSynthesizesTemplateToolCallConvertsCommandObjectsToStrings(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", @@ -422,20 +398,16 @@ func TestSynthesizesTemplateToolCallHoistsTerminalExecutionHint(t *testing.T) { 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 commands[0] != `command -v git && echo "found" || echo "not found"` { + t.Fatalf("commands args: %+v", commands) } - if _, ok := command["runInTerminal"]; ok { - t.Fatalf("runInTerminal should not remain inside command item: %+v", command) + if _, ok := args["runInTerminal"]; ok { + t.Fatalf("runInTerminal should not be emitted: %+v", args) } } -func TestSynthesizesShellCommandStringRequestsTerminalExecution(t *testing.T) { +func TestSynthesizesShellCommandStringWithoutExtraExecutionHints(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=['command -v git && echo \"found\" || echo \"not found\"'])}}" tools := []any{map[string]any{ "type": "function", @@ -458,13 +430,13 @@ func TestSynthesizesShellCommandStringRequestsTerminalExecution(t *testing.T) { 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) } + if _, ok := args["runInTerminal"]; ok { + t.Fatalf("runInTerminal should not be emitted: %+v", args) + } } func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) { @@ -492,33 +464,12 @@ func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) { if !ok { t.Fatalf("function shape: %+v", call["function"]) } - var args map[string]any + var args map[string][]string if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { t.Fatalf("arguments JSON: %v", err) } - 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)) - } - if commands[0]["command"] != "printf ')}}'" { - t.Fatalf("commands args: %+v", commands[0]) - } - 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) + if len(args["commands"]) != 1 || args["commands"][0] != "printf ')}}'" { + t.Fatalf("commands args: %+v", args["commands"]) } } @@ -701,14 +652,11 @@ 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, `\"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\":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, "runInTerminal") || strings.Contains(body, "description") { + t.Fatalf("stream tool_call arguments should strip execution metadata:\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 885f3e0..aa32a6d 100644 --- a/apps/edge/internal/openai/types.go +++ b/apps/edge/internal/openai/types.go @@ -494,15 +494,8 @@ func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]an return args } var normalized []any - runInTerminal := boolValue(args["runInTerminal"]) for i, command := range commands { - if commandStringNeedsShell(command) { - runInTerminal = true - } - cleaned, commandRunInTerminal, changed := normalizeCommandArgument(command) - if commandRunInTerminal { - runInTerminal = true - } + cleaned, changed := normalizeCommandArgument(command) if !changed { if normalized != nil { normalized[i] = command @@ -516,39 +509,31 @@ func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]an normalized[i] = cleaned } if normalized == nil { - 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 + return args } 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, bool) { +func normalizeCommandArgument(command any) (any, bool) { m, ok := command.(map[string]any) if !ok { - return command, false, false + return command, false } - if _, ok := m["command"]; !ok { - return command, false, false + commandText, ok := m["command"].(string) + if !ok { + return command, false + } + if commandObjectHasOnlyTextAndHints(m) || commandStringNeedsShell(commandText) { + return commandText, true } - runInTerminal := boolValue(m["runInTerminal"]) if _, ok := m["description"]; !ok { if _, ok := m["runInTerminal"]; !ok { - return command, runInTerminal, false + return command, false } } cleaned := make(map[string]any, len(m)) @@ -560,19 +545,21 @@ func normalizeCommandArgument(command any) (any, bool, bool) { cleaned[key] = value } } - return cleaned, runInTerminal, true + return cleaned, 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 +func commandObjectHasOnlyTextAndHints(m map[string]any) bool { + for key := range m { + switch key { + case "command", "description", "runInTerminal": + default: + return false + } } + return true +} + +func commandStringNeedsShell(text string) bool { text = strings.TrimSpace(text) if text == "" { return false @@ -589,18 +576,6 @@ func commandStringNeedsShell(command any) bool { } } -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 {