From 4aa0867ca24d930ee44093e78e80c1b2f35b912e Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 27 Jun 2026 22:40:58 +0900 Subject: [PATCH] =?UTF-8?q?fix(openai):=20text=20tool=20fallback=20system?= =?UTF-8?q?=20=EB=B3=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../inner/edge-node-runtime-wire.md | 2 +- agent-contract/outer/openai-compatible-api.md | 2 +- .../adapters/openai_compat/openai_compat.go | 55 +++++++++++++++++-- .../openai_compat/openai_compat_test.go | 11 +++- apps/node/internal/adapters/vllm/vllm.go | 30 +++++++++- apps/node/internal/adapters/vllm/vllm_test.go | 11 +++- 6 files changed, 98 insertions(+), 13 deletions(-) diff --git a/agent-contract/inner/edge-node-runtime-wire.md b/agent-contract/inner/edge-node-runtime-wire.md index f4a2500..a5a5127 100644 --- a/agent-contract/inner/edge-node-runtime-wire.md +++ b/agent-contract/inner/edge-node-runtime-wire.md @@ -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 이름과 동등하다. diff --git a/agent-contract/outer/openai-compatible-api.md b/agent-contract/outer/openai-compatible-api.md index 047dff9..77b4b37 100644 --- a/agent-contract/outer/openai-compatible-api.md +++ b/agent-contract/outer/openai-compatible-api.md @@ -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에 ``, `{{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`로 복원한다. diff --git a/apps/node/internal/adapters/openai_compat/openai_compat.go b/apps/node/internal/adapters/openai_compat/openai_compat.go index 34452f0..6972476 100644 --- a/apps/node/internal/adapters/openai_compat/openai_compat.go +++ b/apps/node/internal/adapters/openai_compat/openai_compat.go @@ -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 { diff --git a/apps/node/internal/adapters/openai_compat/openai_compat_test.go b/apps/node/internal/adapters/openai_compat/openai_compat_test.go index 7289689..9c0948a 100644 --- a/apps/node/internal/adapters/openai_compat/openai_compat_test.go +++ b/apps/node/internal/adapters/openai_compat/openai_compat_test.go @@ -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), "") || !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" { diff --git a/apps/node/internal/adapters/vllm/vllm.go b/apps/node/internal/adapters/vllm/vllm.go index 2d46f36..8a15b25 100644 --- a/apps/node/internal/adapters/vllm/vllm.go +++ b/apps/node/internal/adapters/vllm/vllm.go @@ -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 { diff --git a/apps/node/internal/adapters/vllm/vllm_test.go b/apps/node/internal/adapters/vllm/vllm_test.go index 0988cd4..26d266c 100644 --- a/apps/node/internal/adapters/vllm/vllm_test.go +++ b/apps/node/internal/adapters/vllm/vllm_test.go @@ -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), "") || !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" {