fix(openai): 명령 툴 인자를 문자열 배열로 정규화한다
This commit is contained in:
parent
4b59b70f14
commit
daa49793b3
3 changed files with 44 additions and 121 deletions
|
|
@ -170,7 +170,7 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa
|
|||
지원하는 텍스트 블록의 최소 형태는 `<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` 배열 내부의 `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 실행 의미에는 반영하지 않는다.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue