fix(openai): 단순 명령 툴 인자를 객체로 유지한다

This commit is contained in:
toki 2026-06-27 20:04:05 +09:00
parent daa49793b3
commit 37ac90299f
3 changed files with 9 additions and 19 deletions

View file

@ -169,8 +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`이 포함되면 tool schema 검증을 위해 제거한다.
`commands` 배열 내부의 `{command: "...", description, runInTerminal}` 형태는 tool schema와 기존 shell 실행 경로를 맞추기 위해 `commands: ["..."]` 형태로 정규화하며, `runInTerminal`은 출력하지 않는다.
`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로 정규화한다.
호환성을 위해 `tool_choice`가 생략되었거나 `auto`/강제 tool 선택 형태로 들어오면 내부 실행 입력에서는 `tool_choice: "none"`으로 낮춰 백엔드의 auto tool-calling 요구 조건에 의해 요청 전체가 실패하지 않게 한다.
`parallel_tool_calls``stream_options`는 클라이언트 호환성을 위해 수신하지만 현재 Edge 실행 의미에는 반영하지 않는다.

View file

@ -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][]string
var args map[string][]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] != "git status" {
if len(args["commands"]) != 1 || args["commands"][0]["command"] != "git status" {
t.Fatalf("commands args: %+v", args["commands"])
}
}
func TestSynthesizesTemplateToolCallConvertsCommandObjectsToStrings(t *testing.T) {
func TestSynthesizesTemplateToolCallConvertsShellCommandObjectsToStrings(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",
@ -652,7 +652,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\":[\"git status\"]`) {
if !strings.Contains(body, `\"commands\":[{\"command\":\"git status\"}]`) {
t.Fatalf("stream tool_call arguments should include command:\n%s", body)
}
if strings.Contains(body, "runInTerminal") || strings.Contains(body, "description") {

View file

@ -528,7 +528,7 @@ func normalizeCommandArgument(command any) (any, bool) {
if !ok {
return command, false
}
if commandObjectHasOnlyTextAndHints(m) || commandStringNeedsShell(commandText) {
if commandStringNeedsShell(commandText) {
return commandText, true
}
if _, ok := m["description"]; !ok {
@ -548,23 +548,12 @@ func normalizeCommandArgument(command any) (any, bool) {
return cleaned, true
}
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
}
if strings.ContainsAny(text, " \t\n\r|&;<>(){}[]*$`'\"\\") {
if strings.ContainsAny(text, "\n\r|&;<>(){}[]*$`'\"\\") {
return true
}
first := firstShellWord(text)