fix(openai): Cline 명령 툴 인자를 정규화한다
This commit is contained in:
parent
65172918ed
commit
8db69d5cba
3 changed files with 75 additions and 4 deletions
|
|
@ -169,6 +169,7 @@ 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 객체에 Cline 실행 힌트인 `description`, `runInTerminal`이 포함되면 tool schema 검증을 위해 제거한다.
|
||||
호환성을 위해 `tool_choice`가 생략되었거나 `auto`/강제 tool 선택 형태로 들어오면 내부 실행 입력에서는 `tool_choice: "none"`으로 낮춰 백엔드의 auto tool-calling 요구 조건에 의해 요청 전체가 실패하지 않게 한다.
|
||||
`parallel_tool_calls`와 `stream_options`는 클라이언트 호환성을 위해 수신하지만 현재 Edge 실행 의미에는 반영하지 않는다.
|
||||
|
||||
|
|
|
|||
|
|
@ -374,9 +374,15 @@ func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T)
|
|||
if len(commands) != 1 {
|
||||
t.Fatalf("commands len: got %d", len(commands))
|
||||
}
|
||||
if commands[0]["command"] != "git status" || commands[0]["description"] != "현재 git 상태 확인" || commands[0]["runInTerminal"] != true {
|
||||
if commands[0]["command"] != "git status" {
|
||||
t.Fatalf("commands args: %+v", commands[0])
|
||||
}
|
||||
if _, ok := commands[0]["description"]; ok {
|
||||
t.Fatalf("description should be stripped from command args: %+v", commands[0])
|
||||
}
|
||||
if _, ok := commands[0]["runInTerminal"]; ok {
|
||||
t.Fatalf("runInTerminal should be stripped from command args: %+v", commands[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) {
|
||||
|
|
@ -412,9 +418,12 @@ func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) {
|
|||
if len(commands) != 1 {
|
||||
t.Fatalf("commands len: got %d", len(commands))
|
||||
}
|
||||
if commands[0]["command"] != "printf ')}}'" || commands[0]["runInTerminal"] != false {
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) {
|
||||
|
|
@ -596,8 +605,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, `\"runInTerminal\":true`) {
|
||||
t.Fatalf("stream tool_call arguments should include parsed boolean:\n%s", body)
|
||||
if !strings.Contains(body, `\"command\":\"git status\"`) {
|
||||
t.Fatalf("stream tool_call arguments should include command:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "runInTerminal") || strings.Contains(body, "description") {
|
||||
t.Fatalf("stream tool_call arguments should strip Cline-only metadata:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, `"content":"먼저 현재 git 상태를 확인하겠습니다."`) {
|
||||
t.Fatalf("stream should preserve text before tool call:\n%s", body)
|
||||
|
|
|
|||
|
|
@ -286,6 +286,7 @@ func collectMustacheTextToolCallMatches(content string, allowed map[string]struc
|
|||
if !ok {
|
||||
continue
|
||||
}
|
||||
args = normalizeTextToolCallArguments(block.name, args)
|
||||
encoded, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
continue
|
||||
|
|
@ -467,6 +468,7 @@ func parseTextToolCallBlock(block string, allowed map[string]struct{}) (string,
|
|||
}
|
||||
args[key] = parseTextToolParameterValue(body[param[4]:param[5]])
|
||||
}
|
||||
args = normalizeTextToolCallArguments(name, args)
|
||||
encoded, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
|
|
@ -486,6 +488,62 @@ func parseTextToolParameterValue(raw string) any {
|
|||
return trimmed
|
||||
}
|
||||
|
||||
func normalizeTextToolCallArguments(_ string, args map[string]any) map[string]any {
|
||||
commands, ok := args["commands"].([]any)
|
||||
if !ok {
|
||||
return args
|
||||
}
|
||||
var normalized []any
|
||||
for i, command := range commands {
|
||||
cleaned, changed := normalizeCommandArgument(command)
|
||||
if !changed {
|
||||
if normalized != nil {
|
||||
normalized[i] = command
|
||||
}
|
||||
continue
|
||||
}
|
||||
if normalized == nil {
|
||||
normalized = make([]any, len(commands))
|
||||
copy(normalized, commands)
|
||||
}
|
||||
normalized[i] = cleaned
|
||||
}
|
||||
if normalized == nil {
|
||||
return args
|
||||
}
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
out[key] = value
|
||||
}
|
||||
out["commands"] = normalized
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCommandArgument(command any) (any, bool) {
|
||||
m, ok := command.(map[string]any)
|
||||
if !ok {
|
||||
return command, false
|
||||
}
|
||||
if _, ok := m["command"]; !ok {
|
||||
return command, false
|
||||
}
|
||||
if _, ok := m["description"]; !ok {
|
||||
if _, ok := m["runInTerminal"]; !ok {
|
||||
return command, false
|
||||
}
|
||||
}
|
||||
cleaned := make(map[string]any, len(m))
|
||||
for key, value := range m {
|
||||
switch key {
|
||||
case "description", "runInTerminal":
|
||||
continue
|
||||
default:
|
||||
cleaned[key] = value
|
||||
}
|
||||
}
|
||||
return cleaned, true
|
||||
}
|
||||
|
||||
func parseMustacheToolCallArguments(raw string) (map[string]any, bool) {
|
||||
if args, ok := parseMustacheToolCallArgumentsStrict(raw); ok {
|
||||
return args, true
|
||||
|
|
|
|||
Loading…
Reference in a new issue