fix(openai): text tool fallback system 병합
This commit is contained in:
parent
ea7388cfe9
commit
4aa0867ca2
6 changed files with 98 additions and 13 deletions
|
|
@ -45,7 +45,7 @@ Edge는 Node 연결을 수락하고, Node는 연결 직후 등록 요청을 보
|
|||
- `RunRequest.metadata`: caller-defined 실행 metadata다. workspace 자체는 별도 `workspace` 필드로 전달한다.
|
||||
- `RunEvent.type`: `start`, `delta`, `complete`, `error`, `cancelled` 같은 실행 이벤트 종류다.
|
||||
- `RunEvent.metadata["openai_tool_calls"]`: OpenAI-compatible provider adapter가 native `tool_calls`를 반환했을 때 완료 이벤트에 싣는 JSON 배열이다. Edge OpenAI-compatible 표면은 이 값을 `message.tool_calls` 또는 stream `delta.tool_calls`로 복원한다. provider assistant content 텍스트를 이 값으로 파싱/합성하지 않는다.
|
||||
- `RunEvent.metadata["openai_text_tool_fallback"]`: OpenAI-compatible provider adapter가 backend native tool API 거부 후 `tools`/`tool_choice`를 제거하고 text tool-call instruction으로 재시도했을 때 `"true"`를 싣는다. Edge는 이 표시가 있는 실행에서만 assistant content의 text tool-call을 OpenAI-compatible `tool_calls`로 복원할 수 있다.
|
||||
- `RunEvent.metadata["openai_text_tool_fallback"]`: OpenAI-compatible provider adapter가 backend native tool API 거부 후 `tools`/`tool_choice`를 제거하고 text tool-call instruction으로 재시도했을 때 `"true"`를 싣는다. 이 instruction은 backend가 system role 위치를 거부하지 않도록 leading system message에 병합한다. Edge는 이 표시가 있는 실행에서만 assistant content의 text tool-call을 OpenAI-compatible `tool_calls`로 복원할 수 있다.
|
||||
- `NodeCommandRequest.type`: 실행이 아닌 조회/제어성 명령이다. adapter execution 요청과 섞지 않는다.
|
||||
- `NodeConfigPayload.adapters`: Edge가 Node에 내려주는 adapter instance 설정이다.
|
||||
- `AdapterConfig.name`: node 내부 stable adapter instance identity다. 비어 있으면 legacy single-instance type 이름과 동등하다.
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ Workspace-bound route는 workspace가 없거나 상대 경로이면 OpenAI-compa
|
|||
- `parallel_tool_calls`
|
||||
- `stream_options`
|
||||
|
||||
`tools`가 있는 Chat Completions 요청에서 provider route(`openai_compat`, `vllm`, `ollama`, provider pool)는 forced tool 선택 객체와 `"none"` 같은 명시적 `tool_choice`를 backend에 전달한다. 단, `"auto"`는 OpenAI-compatible 기본값과 같으므로 provider request에서는 생략한다. 일부 vLLM 계열 backend는 explicit/default `"auto"`를 `--enable-auto-tool-choice`/`--tool-call-parser` 없이 400으로 거부한다. 이 400이 발생하고 요청 tool이 정확히 1개이면 Node adapter는 해당 tool에 대한 forced `tool_choice`로 1회 재시도한다. forced tool도 `--tool-call-parser` 요구로 거부되거나 여러 tool이라 forced를 고를 수 없으면, Node adapter는 `tools`/`tool_choice`를 제거하고 text tool-call system instruction을 붙여 1회 재시도하며 완료 metadata에 `openai_text_tool_fallback: "true"`를 싣는다.
|
||||
`tools`가 있는 Chat Completions 요청에서 provider route(`openai_compat`, `vllm`, `ollama`, provider pool)는 forced tool 선택 객체와 `"none"` 같은 명시적 `tool_choice`를 backend에 전달한다. 단, `"auto"`는 OpenAI-compatible 기본값과 같으므로 provider request에서는 생략한다. 일부 vLLM 계열 backend는 explicit/default `"auto"`를 `--enable-auto-tool-choice`/`--tool-call-parser` 없이 400으로 거부한다. 이 400이 발생하고 요청 tool이 정확히 1개이면 Node adapter는 해당 tool에 대한 forced `tool_choice`로 1회 재시도한다. forced tool도 `--tool-call-parser` 요구로 거부되거나 여러 tool이라 forced를 고를 수 없으면, Node adapter는 `tools`/`tool_choice`를 제거하고 text tool-call system instruction을 leading system message에 병합해 1회 재시도하며 완료 metadata에 `openai_text_tool_fallback: "true"`를 싣는다.
|
||||
provider가 native OpenAI-compatible `tool_calls`를 반환하면 Node는 내부 `RunEvent.metadata["openai_tool_calls"]` JSON으로 보존하고, Edge는 이를 OpenAI-compatible `message.tool_calls` 또는 stream `delta.tool_calls`로 반환하며 `finish_reason: "tool_calls"`를 사용한다.
|
||||
provider native `tool_calls[].function.arguments`는 OpenAI 계약에 맞는 JSON string으로 반환한다. 단, provider가 요청 `tools[].function.parameters` schema상 배열/객체여야 하는 값을 JSON 문자열로 이중 인코딩한 경우 Edge는 해당 `arguments` JSON만 schema 기준으로 복원해 다시 JSON string으로 직렬화한다.
|
||||
provider route에서 assistant content에 `<tool_call>`, `{{function(...)}}`, `[Calling tool: ...]` 같은 텍스트가 들어와도 Edge는 기본적으로 이를 파싱하거나 OpenAI `tool_calls`로 합성하지 않는다. 해당 텍스트는 backend가 반환한 content로 그대로 둔다. 예외적으로 Node adapter가 native tool API unsupported fallback을 수행해 `openai_text_tool_fallback: "true"` metadata를 붙인 실행에 한해서만 Edge가 text tool-call을 OpenAI `tool_calls`로 복원한다.
|
||||
|
|
|
|||
|
|
@ -460,20 +460,63 @@ func retryBodyWithTextToolFallback(body map[string]any, errorBody string) (map[s
|
|||
func prependTextToolFallbackInstruction(value any, instruction string) (any, bool) {
|
||||
switch messages := value.(type) {
|
||||
case []chatMessage:
|
||||
system := chatMessage{Role: "system", Content: instruction}
|
||||
out := make([]chatMessage, 0, len(messages)+1)
|
||||
out = append(out, chatMessage{Role: "system", Content: instruction})
|
||||
out = append(out, messages...)
|
||||
return out, true
|
||||
for _, msg := range messages {
|
||||
if strings.EqualFold(strings.TrimSpace(msg.Role), "system") {
|
||||
system.Content = joinTextToolFallbackSystemContent(system.Content, msg.Content)
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return append([]chatMessage{system}, out...), true
|
||||
case []any:
|
||||
systemContent := instruction
|
||||
out := make([]any, 0, len(messages)+1)
|
||||
out = append(out, map[string]any{"role": "system", "content": instruction})
|
||||
out = append(out, messages...)
|
||||
return out, true
|
||||
for _, item := range messages {
|
||||
msg, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
out = append(out, item)
|
||||
continue
|
||||
}
|
||||
role, _ := msg["role"].(string)
|
||||
if strings.EqualFold(strings.TrimSpace(role), "system") {
|
||||
systemContent = joinTextToolFallbackSystemContent(systemContent, stringFromAny(msg["content"]))
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return append([]any{map[string]any{"role": "system", "content": systemContent}}, out...), true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func joinTextToolFallbackSystemContent(first, next string) string {
|
||||
first = strings.TrimSpace(first)
|
||||
next = strings.TrimSpace(next)
|
||||
switch {
|
||||
case first == "":
|
||||
return next
|
||||
case next == "":
|
||||
return first
|
||||
default:
|
||||
return first + "\n\n" + next
|
||||
}
|
||||
}
|
||||
|
||||
func stringFromAny(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
encoded, _ := json.Marshal(v)
|
||||
return string(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func textToolFallbackInstruction(tools any) (string, bool) {
|
||||
items, ok := anyItems(tools)
|
||||
if !ok || len(items) == 0 {
|
||||
|
|
|
|||
|
|
@ -400,7 +400,10 @@ func TestOpenAICompatExecuteFallsBackToTextToolsWhenNativeToolsUnsupported(t *te
|
|||
RunID: "run-text-fallback",
|
||||
Target: "qwen3.6:35b",
|
||||
Input: map[string]any{
|
||||
"prompt": "status",
|
||||
"messages": []any{
|
||||
map[string]any{"role": "system", "content": "Existing Cline system prompt."},
|
||||
map[string]any{"role": "user", "content": "status"},
|
||||
},
|
||||
"tools": []any{map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
|
|
@ -431,6 +434,12 @@ func TestOpenAICompatExecuteFallsBackToTextToolsWhenNativeToolsUnsupported(t *te
|
|||
if first["role"] != "system" || !strings.Contains(first["content"].(string), "<tool_call>") || !strings.Contains(first["content"].(string), "run_commands") {
|
||||
t.Fatalf("fallback system instruction missing tool format/name: %+v", first)
|
||||
}
|
||||
if !strings.Contains(first["content"].(string), "Existing Cline system prompt.") {
|
||||
t.Fatalf("fallback system instruction did not preserve existing system content: %+v", first)
|
||||
}
|
||||
if len(messages) != 2 || messages[1].(map[string]any)["role"] != "user" {
|
||||
t.Fatalf("fallback should keep a single leading system message: %+v", messages)
|
||||
}
|
||||
events := sink.all()
|
||||
complete := events[len(events)-1]
|
||||
if complete.Metadata[runtimeMetadataOpenAITextToolFallback] != "true" {
|
||||
|
|
|
|||
|
|
@ -422,12 +422,36 @@ func textToolFallbackChatRequest(req vllmChatRequest, errorBody string) (vllmCha
|
|||
next := req
|
||||
next.Tools = nil
|
||||
next.ToolChoice = nil
|
||||
next.Messages = make([]vllmMessage, 0, len(req.Messages)+1)
|
||||
next.Messages = append(next.Messages, vllmMessage{Role: "system", Content: instruction})
|
||||
next.Messages = append(next.Messages, req.Messages...)
|
||||
next.Messages = prependTextToolFallbackInstruction(req.Messages, instruction)
|
||||
return next, true
|
||||
}
|
||||
|
||||
func prependTextToolFallbackInstruction(messages []vllmMessage, instruction string) []vllmMessage {
|
||||
system := vllmMessage{Role: "system", Content: instruction}
|
||||
out := make([]vllmMessage, 0, len(messages)+1)
|
||||
for _, msg := range messages {
|
||||
if strings.EqualFold(strings.TrimSpace(msg.Role), "system") {
|
||||
system.Content = joinTextToolFallbackSystemContent(system.Content, msg.Content)
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return append([]vllmMessage{system}, out...)
|
||||
}
|
||||
|
||||
func joinTextToolFallbackSystemContent(first, next string) string {
|
||||
first = strings.TrimSpace(first)
|
||||
next = strings.TrimSpace(next)
|
||||
switch {
|
||||
case first == "":
|
||||
return next
|
||||
case next == "":
|
||||
return first
|
||||
default:
|
||||
return first + "\n\n" + next
|
||||
}
|
||||
}
|
||||
|
||||
func textToolFallbackInstruction(tools any) (string, bool) {
|
||||
items, ok := anyItems(tools)
|
||||
if !ok || len(items) == 0 {
|
||||
|
|
|
|||
|
|
@ -271,7 +271,10 @@ func TestVllmExecuteFallsBackToTextToolsWhenNativeToolsUnsupported(t *testing.T)
|
|||
RunID: "run-text-fallback",
|
||||
Target: "llama-3",
|
||||
Input: map[string]any{
|
||||
"prompt": "status",
|
||||
"messages": []any{
|
||||
map[string]any{"role": "system", "content": "Existing Cline system prompt."},
|
||||
map[string]any{"role": "user", "content": "status"},
|
||||
},
|
||||
"tools": []any{map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
|
|
@ -303,6 +306,12 @@ func TestVllmExecuteFallsBackToTextToolsWhenNativeToolsUnsupported(t *testing.T)
|
|||
if first["role"] != "system" || !strings.Contains(first["content"].(string), "<tool_call>") || !strings.Contains(first["content"].(string), "run_commands") {
|
||||
t.Fatalf("fallback system instruction missing tool format/name: %+v", first)
|
||||
}
|
||||
if !strings.Contains(first["content"].(string), "Existing Cline system prompt.") {
|
||||
t.Fatalf("fallback system instruction did not preserve existing system content: %+v", first)
|
||||
}
|
||||
if len(messages) != 2 || messages[1].(map[string]any)["role"] != "user" {
|
||||
t.Fatalf("fallback should keep a single leading system message: %+v", messages)
|
||||
}
|
||||
events := sink.all()
|
||||
complete := events[len(events)-1]
|
||||
if complete.Metadata[runtimeMetadataOpenAITextToolFallback] != "true" {
|
||||
|
|
|
|||
Loading…
Reference in a new issue