fix(openai): text tool fallback system 병합

This commit is contained in:
toki 2026-06-27 22:40:58 +09:00
parent ea7388cfe9
commit 4aa0867ca2
6 changed files with 98 additions and 13 deletions

View file

@ -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 이름과 동등하다.

View file

@ -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`로 복원한다.

View file

@ -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 {

View file

@ -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" {

View file

@ -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 {

View file

@ -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" {