fix(openai): structured shell 명령을 문자열로 되돌린다
This commit is contained in:
parent
9f4fc6f683
commit
1c03ee7c3c
3 changed files with 58 additions and 3 deletions
|
|
@ -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 실행 의미에는 반영하지 않는다.
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue