From 7318d1f4a85b86d950246c245ffa64bf316ece75 Mon Sep 17 00:00:00 2001 From: huangxun Date: Fri, 13 Mar 2026 13:47:40 +0800 Subject: [PATCH 1/4] fix(toolcall): fix deepseek function calling bug and add json repair - Fix: Expand stream sieve keywords to support function.name: and [TOOL_CALL_HISTORY] - Fix: Add repairInvalidJSONBackslashes to handle unescaped backslashes in Windows paths - Sync: Update JS stream sieve to match Go implementation - Test: Add unit tests for backslash repair and deepseek format parsing - Tool: Move repair json test tool to tests/repair_json_tool.go --- internal/adapter/openai/tool_sieve_core.go | 23 +++++- .../js/helpers/stream-tool-sieve/sieve.js | 21 ++++- internal/util/toolcalls_parse.go | 55 +++++++++++++ internal/util/toolcalls_test.go | 42 ++++++++++ tests/repair_json_tool.go | 77 +++++++++++++++++++ 5 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 tests/repair_json_tool.go diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index e7e41f8..cdb2585 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -167,13 +167,28 @@ func findToolSegmentStart(s string) int { return -1 } lower := strings.ToLower(s) + keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"} offset := 0 for { - keyRel := strings.Index(lower[offset:], "tool_calls") - if keyRel < 0 { + bestKeyIdx := -1 + matchedKeyword := "" + + for _, kw := range keywords { + idx := strings.Index(lower[offset:], kw) + if idx >= 0 { + absIdx := offset + idx + if bestKeyIdx < 0 || absIdx < bestKeyIdx { + bestKeyIdx = absIdx + matchedKeyword = kw + } + } + } + + if bestKeyIdx < 0 { return -1 } - keyIdx := offset + keyRel + + keyIdx := bestKeyIdx start := strings.LastIndex(s[:keyIdx], "{") if start < 0 { start = keyIdx @@ -181,7 +196,7 @@ func findToolSegmentStart(s string) int { if !insideCodeFence(s[:start]) { return start } - offset = keyIdx + len("tool_calls") + offset = keyIdx + len(matchedKeyword) } } diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index c1b92a8..ae25fd4 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -165,19 +165,34 @@ function findToolSegmentStart(s) { return -1; } const lower = s.toLowerCase(); + const keywords = ['tool_calls', 'function.name:', '[tool_call_history]']; let offset = 0; // eslint-disable-next-line no-constant-condition while (true) { - const keyIdx = lower.indexOf('tool_calls', offset); - if (keyIdx < 0) { + let bestKeyIdx = -1; + let matchedKeyword = ''; + + for (const kw of keywords) { + const idx = lower.indexOf(kw, offset); + if (idx >= 0) { + if (bestKeyIdx < 0 || idx < bestKeyIdx) { + bestKeyIdx = idx; + matchedKeyword = kw; + } + } + } + + if (bestKeyIdx < 0) { return -1; } + + const keyIdx = bestKeyIdx; const start = s.slice(0, keyIdx).lastIndexOf('{'); const candidateStart = start >= 0 ? start : keyIdx; if (!insideCodeFence(s.slice(0, candidateStart))) { return candidateStart; } - offset = keyIdx + 'tool_calls'.length; + offset = keyIdx + matchedKeyword.length; } } diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index fb6d459..8c1a905 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -264,6 +264,13 @@ func parseToolCallInput(v any) map[string]any { if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil { return parsed } + // Try to repair invalid backslashes (common in Windows paths output by models) + repaired := repairInvalidJSONBackslashes(raw) + if repaired != raw { + if err := json.Unmarshal([]byte(repaired), &parsed); err == nil && parsed != nil { + return parsed + } + } return map[string]any{"_raw": raw} default: b, err := json.Marshal(x) @@ -277,3 +284,51 @@ func parseToolCallInput(v any) map[string]any { return map[string]any{} } } + +func repairInvalidJSONBackslashes(s string) string { + if !strings.Contains(s, "\\") { + return s + } + var out strings.Builder + out.Grow(len(s) + 10) + runes := []rune(s) + for i := 0; i < len(runes); i++ { + if runes[i] == '\\' { + if i+1 < len(runes) { + next := runes[i+1] + switch next { + case '"', '\\', '/', 'b', 'f', 'n', 'r', 't': + out.WriteRune('\\') + out.WriteRune(next) + i++ + continue + case 'u': + if i+5 < len(runes) { + isHex := true + for j := 1; j <= 4; j++ { + r := runes[i+1+j] + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { + isHex = false + break + } + } + if isHex { + out.WriteRune('\\') + out.WriteRune('u') + for j := 1; j <= 4; j++ { + out.WriteRune(runes[i+1+j]) + } + i += 5 + continue + } + } + } + } + // Not a valid escape sequence, double it + out.WriteString("\\\\") + } else { + out.WriteRune(runes[i]) + } + } + return out.String() +} diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index 3ace015..94417f3 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -279,3 +279,45 @@ func TestParseToolCallsDoesNotAcceptMismatchedMarkupTags(t *testing.T) { t.Fatalf("expected mismatched tags to be rejected, got %#v", calls) } } + +func TestRepairInvalidJSONBackslashes(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {`{"path": "C:\Users\name"}`, `{"path": "C:\\Users\name"}`}, + {`{"cmd": "cd D:\git_codes"}`, `{"cmd": "cd D:\\git_codes"}`}, + {`{"text": "line1\nline2"}`, `{"text": "line1\nline2"}`}, + {`{"path": "D:\\back\\slash"}`, `{"path": "D:\\back\\slash"}`}, + {`{"unicode": "\u2705"}`, `{"unicode": "\u2705"}`}, + {`{"invalid_u": "\u123"}`, `{"invalid_u": "\\u123"}`}, + } + + for _, tt := range tests { + got := repairInvalidJSONBackslashes(tt.input) + if got != tt.expected { + t.Errorf("repairInvalidJSONBackslashes(%s) = %s; want %s", tt.input, got, tt.expected) + } + } +} + +func TestParseToolCallsWithInvalidBackslashes(t *testing.T) { + // DeepSeek sometimes outputs Windows paths with single backslashes in JSON strings + text := `好的,执行以下命令:{"name": "execute_command", "input": "{\"command\": \"cd D:\git_codes && dir\"}"}` + availableTools := []string{"execute_command"} + + parsed := ParseToolCalls(text, availableTools) + if len(parsed) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(parsed)) + } + + cmd, ok := parsed[0].Input["command"].(string) + if !ok { + t.Fatalf("expected command string in input, got %v", parsed[0].Input) + } + + expected := "cd D:\\git_codes && dir" + if cmd != expected { + t.Errorf("expected command %q, got %q", expected, cmd) + } +} diff --git a/tests/repair_json_tool.go b/tests/repair_json_tool.go new file mode 100644 index 0000000..7abf952 --- /dev/null +++ b/tests/repair_json_tool.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "strings" +) + +func repairInvalidJSONBackslashes(s string) string { + if !strings.Contains(s, "\\") { + return s + } + var out strings.Builder + out.Grow(len(s) + 10) + runes := []rune(s) + for i := 0; i < len(runes); i++ { + if runes[i] == '\\' { + if i+1 < len(runes) { + next := runes[i+1] + switch next { + case '"', '\\', '/', 'b', 'f', 'n', 'r', 't': + out.WriteRune('\\') + out.WriteRune(next) + i++ + continue + case 'u': + if i+5 < len(runes) { + isHex := true + for j := 1; j <= 4; j++ { + r := runes[i+1+j] + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { + isHex = false + break + } + } + if isHex { + out.WriteRune('\\') + out.WriteRune('u') + for j := 1; j <= 4; j++ { + out.WriteRune(runes[i+1+j]) + } + i += 5 + continue + } + } + } + } + // Not a valid escape sequence, double it + out.WriteString("\\\\") + } else { + out.WriteRune(runes[i]) + } + } + return out.String() +} + +func main() { + tests := []struct { + input string + expected string + }{ + {`{"path": "C:\Users\name"}`, `{"path": "C:\\Users\\name"}`}, + {`{"cmd": "cd D:\git_codes"}`, `{"cmd": "cd D:\\git_codes"}`}, + {`{"text": "line1\nline2"}`, `{"text": "line1\nline2"}`}, + {`{"path": "D:\\back\\slash"}`, `{"path": "D:\\back\\slash"}`}, + {`{"unicode": "\u2705"}`, `{"unicode": "\u2705"}`}, + {`{"invalid_u": "\u123"}`, `{"invalid_u": "\\u123"}`}, + } + + for _, tt := range tests { + got := repairInvalidJSONBackslashes(tt.input) + if got != tt.expected { + fmt.Printf("FAIL: input=%s\n got=%s\n exp=%s\n", tt.input, got, tt.expected) + } else { + fmt.Printf("PASS: input=%s\n", tt.input) + } + } +} From 16216cc2ca60e8cddcab4c1c7a7f1cff28c1e5ae Mon Sep 17 00:00:00 2001 From: huangxun Date: Tue, 17 Mar 2026 16:24:16 +0800 Subject: [PATCH 2/4] fix(toolcalls): support nested objects in missing array brackets repair - Upgrade missingArrayBracketsPattern regex to support single-level nested {} objects - This fixes DeepSeek's list hallucination where tool call JSON objects contain nested fields like {"input": {"q": "value"}} - Add comprehensive test cases covering 2-5 nested objects, mixed nested/primitive fields, and real DeepSeek 8-queen output patterns - Add RepairLooseJSON function to repair unquoted keys and missing array brackets Fixes: DeepSeek tool call parsing with nested JSON objects --- internal/util/toolcalls_parse.go | 41 ++++++- internal/util/toolcalls_test.go | 191 +++++++++++++++++++++++++++++-- 2 files changed, 223 insertions(+), 9 deletions(-) diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index 8c1a905..4830e97 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -2,6 +2,7 @@ package util import ( "encoding/json" + "regexp" "strings" ) @@ -171,7 +172,13 @@ func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCan func parseToolCallsPayload(payload string) []ParsedToolCall { var decoded any if err := json.Unmarshal([]byte(payload), &decoded); err != nil { - return nil + // Try to repair backslashes first! Because LLMs often mix these two problems. + repaired := repairInvalidJSONBackslashes(payload) + // Try loose repair on top of that + repaired = RepairLooseJSON(repaired) + if err := json.Unmarshal([]byte(repaired), &decoded); err != nil { + return nil + } } switch v := decoded.(type) { case map[string]any: @@ -271,6 +278,13 @@ func parseToolCallInput(v any) map[string]any { return parsed } } + // Try to repair loose JSON in string argument as well + repairedLoose := RepairLooseJSON(raw) + if repairedLoose != raw { + if err := json.Unmarshal([]byte(repairedLoose), &parsed); err == nil && parsed != nil { + return parsed + } + } return map[string]any{"_raw": raw} default: b, err := json.Marshal(x) @@ -332,3 +346,28 @@ func repairInvalidJSONBackslashes(s string) string { } return out.String() } + +var unquotedKeyPattern = regexp.MustCompile(`([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:`) + +// missingArrayBracketsPattern identifies a sequence of two or more JSON objects separated by commas +// that immediately follow a colon, which indicates a missing array bracket `[` `]`. +// E.g., "key": {"a": 1}, {"b": 2} -> "key": [{"a": 1}, {"b": 2}] +// NOTE: The pattern uses (?:[^{}]|\{[^{}]*\})* to support single-level nested {} objects, +// which handles cases like {"content": "x", "input": {"q": "y"}} +var missingArrayBracketsPattern = regexp.MustCompile(`(:\s*)(\{(?:[^{}]|\{[^{}]*\})*\}(?:\s*,\s*\{(?:[^{}]|\{[^{}]*\})*\})+)`) + +func RepairLooseJSON(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return s + } + // 1. Replace unquoted keys: {key: -> {"key": + s = unquotedKeyPattern.ReplaceAllString(s, `$1"$2":`) + + // 2. Heuristic: Fix missing array brackets for list of objects + // e.g., : {obj1}, {obj2} -> : [{obj1}, {obj2}] + // This specifically addresses DeepSeek's "list hallucination" + s = missingArrayBracketsPattern.ReplaceAllString(s, `$1[$2]`) + + return s +} diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index 94417f3..e3fae5d 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -1,6 +1,9 @@ package util -import "testing" +import ( + "strings" + "testing" +) func TestParseToolCalls(t *testing.T) { text := `prefix {"tool_calls":[{"name":"search","input":{"q":"golang"}}]} suffix` @@ -301,23 +304,195 @@ func TestRepairInvalidJSONBackslashes(t *testing.T) { } } -func TestParseToolCallsWithInvalidBackslashes(t *testing.T) { - // DeepSeek sometimes outputs Windows paths with single backslashes in JSON strings - text := `好的,执行以下命令:{"name": "execute_command", "input": "{\"command\": \"cd D:\git_codes && dir\"}"}` - availableTools := []string{"execute_command"} - +func TestRepairLooseJSON(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {`{tool_calls: [{"name": "search", "input": {"q": "go"}}]}`, `{"tool_calls": [{"name": "search", "input": {"q": "go"}}]}`}, + {`{name: "search", input: {q: "go"}}`, `{"name": "search", "input": {"q": "go"}}`}, + } + + for _, tt := range tests { + got := RepairLooseJSON(tt.input) + if got != tt.expected { + t.Errorf("RepairLooseJSON(%s) = %s; want %s", tt.input, got, tt.expected) + } + } +} + +func TestParseToolCallsWithUnquotedKeys(t *testing.T) { + text := `这里是列表:{tool_calls: [{"name": "todowrite", "input": {"todos": "test"}}]}` + availableTools := []string{"todowrite"} + parsed := ParseToolCalls(text, availableTools) if len(parsed) != 1 { t.Fatalf("expected 1 tool call, got %d", len(parsed)) } - + if parsed[0].Name != "todowrite" { + t.Errorf("expected tool todowrite, got %s", parsed[0].Name) + } +} + +func TestParseToolCallsWithInvalidBackslashes(t *testing.T) { + // DeepSeek sometimes outputs Windows paths with single backslashes in JSON strings + // Note: using raw string to simulate what AI actually sends in the stream + text := `好的,执行以下命令:{"name": "execute_command", "input": "{\"command\": \"cd D:\git_codes && dir\"}"}` + availableTools := []string{"execute_command"} + + parsed := ParseToolCalls(text, availableTools) + // If standard JSON fails, buildToolCallCandidates should still extract the object, + // and parseToolCallsPayload should repair it. + if len(parsed) != 1 { + // If it still fails, let's see why + candidates := buildToolCallCandidates(text) + t.Logf("Candidates: %v", candidates) + t.Fatalf("expected 1 tool call, got %d", len(parsed)) + } + cmd, ok := parsed[0].Input["command"].(string) if !ok { t.Fatalf("expected command string in input, got %v", parsed[0].Input) } - + expected := "cd D:\\git_codes && dir" if cmd != expected { t.Errorf("expected command %q, got %q", expected, cmd) } } + +func TestParseToolCallsWithDeepSeekHallucination(t *testing.T) { + // 模拟 DeepSeek 典型的幻觉输出:未加引号的键名 + 包含 Windows 路径的嵌套 JSON 字符串 + 漏掉列表的方括号 + text := `检测到实施意图——实现经典算法。需在misc/目录创建Python文件。 +关键约束: +1. Windows UTF-8编码处理 +2. 必须用绝对路径导入 +3. 禁止write覆盖已有文件(misc/目录允许创建新文件) +将任务分解并委托: +- 研究8皇后算法模式(并行探索) +- 实现带可视化输出的解决方案(unspecified-high) +先创建todo列表追踪步骤。 +{tool_calls: [{"name": "todowrite", "input": {"todos": {"content": "研究8皇后问题算法模式(回溯法)和输出格式", "status": "pending", "priority": "high"}, {"content": "在misc/目录创建8皇后Python脚本,包含完整解决方案和可视化输出", "status": "pending", "priority": "high"}, {"content": "验证脚本正确性(运行测试)", "status": "pending", "priority": "medium"}}}]}` + + availableTools := []string{"todowrite"} + parsed := ParseToolCalls(text, availableTools) + + if len(parsed) != 1 { + cands := buildToolCallCandidates(text) + for i, c := range cands { + t.Logf("CAND %d: %s", i, c) + repaired := RepairLooseJSON(c) + t.Logf(" REPAIRED: %s", repaired) + } + t.Fatalf("expected 1 tool call, got %d. Candidates: %v", len(parsed), buildToolCallCandidates(text)) + } + + if parsed[0].Name != "todowrite" { + t.Errorf("expected tool name 'todowrite', got %q", parsed[0].Name) + } + + todos, ok := parsed[0].Input["todos"].([]any) + if !ok { + t.Fatalf("expected 'todos' to be parsed as a list, got %T: %#v", parsed[0].Input["todos"], parsed[0].Input["todos"]) + } + if len(todos) != 3 { + t.Errorf("expected 3 todo items, got %d", len(todos)) + } +} + +func TestParseToolCallsWithMixedWindowsPaths(t *testing.T) { + // 更复杂的案例:嵌套 JSON 字符串中的反斜杠未转义 + text := `关键约束: 1. Windows UTF-8编码处理 2. 必须用绝对路径导入 D:\git_codes\ds2api\misc +{tool_calls: [{"name": "write_file", "input": "{\"path\": \"D:\\git_codes\\ds2api\\misc\\queens.py\", \"content\": \"print('hello')\"}"}]}` + + availableTools := []string{"write_file"} + parsed := ParseToolCalls(text, availableTools) + + if len(parsed) != 1 { + t.Fatalf("expected 1 tool call from mixed text with paths, got %d", len(parsed)) + } + + path, _ := parsed[0].Input["path"].(string) + // 在解析后的 Go map 中,反斜杠应该被还原 + if !strings.Contains(path, "D:\\git_codes") && !strings.Contains(path, "D:/git_codes") { + t.Errorf("expected path to contain Windows style separators, got %q", path) + } +} + +func TestRepairLooseJSONWithNestedObjects(t *testing.T) { + // 测试嵌套对象的修复:DeepSeek 幻觉输出,每个元素内部包含嵌套 {} + // 注意:正则只支持单层嵌套,不支持更深层次的嵌套 + tests := []struct { + name string + input string + expected string + }{ + // 1. 单层嵌套对象(核心修复目标) + { + name: "单层嵌套 - 2个元素", + input: `"todos": {"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}`, + expected: `"todos": [{"content": "研究算法", "input": {"q": "8 queens"}}, {"content": "实现", "input": {"path": "queens.py"}}]`, + }, + // 2. 3个单层嵌套对象 + { + name: "3个单层嵌套对象", + input: `"items": {"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}`, + expected: `"items": [{"a": {"x":1}}, {"b": {"y":2}}, {"c": {"z":3}}]`, + }, + // 3. 混合嵌套:有些字段是对象,有些是原始值 + { + name: "混合嵌套 - 对象和原始值混合", + input: `"items": {"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}`, + expected: `"items": [{"name": "test", "config": {"timeout": 30}}, {"name": "test2", "config": {"timeout": 60}}]`, + }, + // 4. 4个嵌套对象(边界测试) + { + name: "4个嵌套对象", + input: `"todos": {"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}`, + expected: `"todos": [{"id": 1}, {"id": 2}, {"id": 3}, {"id": 4}]`, + }, + // 5. DeepSeek 典型幻觉:无空格逗号分隔 + { + name: "无空格逗号分隔", + input: `"results": {"name": "a"}, {"name": "b"}, {"name": "c"}`, + expected: `"results": [{"name": "a"}, {"name": "b"}, {"name": "c"}]`, + }, + // 6. 嵌套数组(数组在对象内,不是深层嵌套) + { + name: "对象内包含数组", + input: `"data": {"items": [1,2,3]}, {"items": [4,5,6]}`, + expected: `"data": [{"items": [1,2,3]}, {"items": [4,5,6]}]`, + }, + // 7. 真实的 DeepSeek 8皇后问题输出 + { + name: "DeepSeek 8皇后真实输出", + input: `"todos": {"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}`, + expected: `"todos": [{"content": "研究8皇后算法", "status": "pending"}, {"content": "实现Python脚本", "status": "pending"}, {"content": "验证结果", "status": "pending"}]`, + }, + // 8. 简单无嵌套对象(回归测试) + { + name: "简单无嵌套对象", + input: `"items": {"a": 1}, {"b": 2}`, + expected: `"items": [{"a": 1}, {"b": 2}]`, + }, + // 9. 更复杂的单层嵌套 + { + name: "复杂单层嵌套", + input: `"functions": {"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}`, + expected: `"functions": [{"name": "execute", "input": {"command": "ls"}}, {"name": "read", "input": {"file": "a.txt"}}]`, + }, + // 10. 5个嵌套对象 + { + name: "5个嵌套对象", + input: `"tasks": {"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}`, + expected: `"tasks": [{"id":1}, {"id":2}, {"id":3}, {"id":4}, {"id":5}]`, + }, + } + + for _, tt := range tests { + got := RepairLooseJSON(tt.input) + if got != tt.expected { + t.Errorf("[%s] RepairLooseJSON with nested objects:\n input: %s\n got: %s\n expected: %s", tt.name, tt.input, got, tt.expected) + } + } +} From c9c59f24906a139e4beddb8b82e17323d1342e8e Mon Sep 17 00:00:00 2001 From: huangxun Date: Tue, 17 Mar 2026 16:28:27 +0800 Subject: [PATCH 3/4] refactor(toolcall): enhance tool call extraction with multiple keywords and safety limits - Add support for multiple keywords: tool_calls, function.name:, [tool_call_history] - Add OOM protection with search limits in extractToolCallObjects - Add max scan length limit in extractJSONObject to prevent OOM on unclosed objects - Update tool_sieve to handle more tool call patterns - Add loose JSON repair in parseToolCallPayload for better error recovery This improves DeepSeek tool call parsing robustness. --- .../adapter/openai/chat_stream_runtime.go | 8 +-- .../adapter/openai/handler_toolcall_format.go | 2 +- internal/adapter/openai/tool_sieve_core.go | 16 ++++- internal/format/openai/render_chat.go | 6 +- internal/format/openai/render_responses.go | 6 +- .../js/helpers/stream-tool-sieve/sieve.js | 31 +++++---- internal/util/toolcalls_candidates.go | 65 ++++++++++++++++--- 7 files changed, 95 insertions(+), 39 deletions(-) diff --git a/internal/adapter/openai/chat_stream_runtime.go b/internal/adapter/openai/chat_stream_runtime.go index 5cd16da..1a81660 100644 --- a/internal/adapter/openai/chat_stream_runtime.go +++ b/internal/adapter/openai/chat_stream_runtime.go @@ -98,11 +98,11 @@ func (s *chatStreamRuntime) sendDone() { func (s *chatStreamRuntime) finalize(finishReason string) { finalThinking := s.thinking.String() finalText := s.text.String() - detected := util.ParseStandaloneToolCalls(finalText, s.toolNames) - if len(detected) > 0 && !s.toolCallsDoneEmitted { + detected := util.ParseStandaloneToolCallsDetailed(finalText, s.toolNames) + if len(detected.Calls) > 0 && !s.toolCallsDoneEmitted { finishReason = "tool_calls" delta := map[string]any{ - "tool_calls": formatFinalStreamToolCallsWithStableIDs(detected, s.streamToolCallIDs), + "tool_calls": formatFinalStreamToolCallsWithStableIDs(detected.Calls, s.streamToolCallIDs), } if !s.firstChunkSent { delta["role"] = "assistant" @@ -158,7 +158,7 @@ func (s *chatStreamRuntime) finalize(finishReason string) { } } - if len(detected) > 0 || s.toolCallsEmitted { + if len(detected.Calls) > 0 || s.toolCallsEmitted { finishReason = "tool_calls" } s.sendChunk(openaifmt.BuildChatStreamChunk( diff --git a/internal/adapter/openai/handler_toolcall_format.go b/internal/adapter/openai/handler_toolcall_format.go index 37ebaf9..3adfd15 100644 --- a/internal/adapter/openai/handler_toolcall_format.go +++ b/internal/adapter/openai/handler_toolcall_format.go @@ -53,7 +53,7 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh if len(toolSchemas) == 0 { return messages, names } - toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON format (no other text):\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON. The response must start with { and end with }.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block." + toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY a JSON code block like this:\n```json\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n```\n\n【EXAMPLE】\nUser: Please check the weather in Beijing and Shanghai, and update my todo list.\nAssistant:\n```json\n{\"tool_calls\": [\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Beijing\"}},\n {\"name\": \"get_weather\", \"input\": {\"city\": \"Shanghai\"}},\n {\"name\": \"update_todo\", \"input\": {\"todos\": [{\"content\": \"Buy milk\"}, {\"content\": \"Write report\"}]}}\n]}\n```\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON code block. The response must start with ```json and end with ```.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block.\n5) JSON SYNTAX STRICTLY REQUIRED: All property names MUST be enclosed in double quotes (e.g., \"name\", not name).\n6) ARRAY FORMAT: If providing a list of items, you MUST enclose them in square brackets `[]` (e.g., \"todos\": [{\"item\": \"a\"}, {\"item\": \"b\"}]). DO NOT output comma-separated objects without brackets." if policy.Mode == util.ToolChoiceRequired { toolPrompt += "\n5) For this response, you MUST call at least one tool from the allowed list." } diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index cdb2585..72628e9 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -206,13 +206,22 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix return "", nil, "", false } lower := strings.ToLower(captured) - keyIdx := strings.Index(lower, "tool_calls") + + keyIdx := -1 + keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"} + for _, kw := range keywords { + idx := strings.Index(lower, kw) + if idx >= 0 && (keyIdx < 0 || idx < keyIdx) { + keyIdx = idx + } + } + if keyIdx < 0 { return "", nil, "", false } start := strings.LastIndex(captured[:keyIdx], "{") if start < 0 { - return "", nil, "", false + start = keyIdx } obj, end, ok := extractJSONObjectFrom(captured, start) if !ok { @@ -230,6 +239,9 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix // consume it to avoid leaking raw tool_calls JSON to user content. return prefixPart, nil, suffixPart, true } + // If it has obvious keywords but failed to parse even after loose repair, + // we still might want to intercept it if it looks like an attempt at tool call. + // For now, keep the original logic but rely on loose JSON repair. return captured, nil, "", true } return prefixPart, parsed.Calls, suffixPart, true diff --git a/internal/format/openai/render_chat.go b/internal/format/openai/render_chat.go index 181e8b9..bdea9b5 100644 --- a/internal/format/openai/render_chat.go +++ b/internal/format/openai/render_chat.go @@ -8,15 +8,15 @@ import ( ) func BuildChatCompletion(completionID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { - detected := util.ParseStandaloneToolCalls(finalText, toolNames) + detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames) finishReason := "stop" messageObj := map[string]any{"role": "assistant", "content": finalText} if strings.TrimSpace(finalThinking) != "" { messageObj["reasoning_content"] = finalThinking } - if len(detected) > 0 { + if len(detected.Calls) > 0 { finishReason = "tool_calls" - messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected) + messageObj["tool_calls"] = util.FormatOpenAIToolCalls(detected.Calls) messageObj["content"] = nil } diff --git a/internal/format/openai/render_responses.go b/internal/format/openai/render_responses.go index 21df584..a3b37f0 100644 --- a/internal/format/openai/render_responses.go +++ b/internal/format/openai/render_responses.go @@ -13,12 +13,12 @@ import ( func BuildResponseObject(responseID, model, finalPrompt, finalThinking, finalText string, toolNames []string) map[string]any { // Strict mode: only standalone, structured tool-call payloads are treated // as executable tool calls. - detected := util.ParseStandaloneToolCalls(finalText, toolNames) + detected := util.ParseStandaloneToolCallsDetailed(finalText, toolNames) exposedOutputText := finalText output := make([]any, 0, 2) - if len(detected) > 0 { + if len(detected.Calls) > 0 { exposedOutputText = "" - output = append(output, toResponsesFunctionCallItems(detected)...) + output = append(output, toResponsesFunctionCallItems(detected.Calls)...) } else { content := make([]any, 0, 2) if finalThinking != "" { diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index ae25fd4..a3b7fd8 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -202,20 +202,28 @@ function consumeToolCapture(state, toolNames) { return { ready: false, prefix: '', calls: [], suffix: '' }; } const lower = captured.toLowerCase(); - const keyIdx = lower.indexOf('tool_calls'); + + let keyIdx = -1; + const keywords = ['tool_calls', 'function.name:', '[tool_call_history]']; + for (const kw of keywords) { + const idx = lower.indexOf(kw); + if (idx >= 0 && (keyIdx < 0 || idx < keyIdx)) { + keyIdx = idx; + } + } + if (keyIdx < 0) { return { ready: false, prefix: '', calls: [], suffix: '' }; } const start = captured.slice(0, keyIdx).lastIndexOf('{'); - if (start < 0) { - return { ready: false, prefix: '', calls: [], suffix: '' }; - } - const obj = extractJSONObjectFrom(captured, start); + const actualStart = start >= 0 ? start : keyIdx; + + const obj = extractJSONObjectFrom(captured, actualStart); if (!obj.ok) { return { ready: false, prefix: '', calls: [], suffix: '' }; } - const prefixPart = captured.slice(0, start); + const prefixPart = captured.slice(0, actualStart); const suffixPart = captured.slice(obj.end); if (insideCodeFence((state.recentTextTail || '') + prefixPart)) { @@ -227,16 +235,7 @@ function consumeToolCapture(state, toolNames) { }; } - if ((state.recentTextTail || '').trim() !== '' || prefixPart.trim() !== '' || suffixPart.trim() !== '') { - return { - ready: true, - prefix: captured, - calls: [], - suffix: '', - }; - } - - const parsed = parseStandaloneToolCallsDetailed(captured.slice(start, obj.end), toolNames); + const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames); if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) { if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) { return { diff --git a/internal/util/toolcalls_candidates.go b/internal/util/toolcalls_candidates.go index 4e8afc4..49db011 100644 --- a/internal/util/toolcalls_candidates.go +++ b/internal/util/toolcalls_candidates.go @@ -20,7 +20,7 @@ func buildToolCallCandidates(text string) []string { } } - // best-effort extraction around "tool_calls" key in mixed text payloads. + // best-effort extraction around tool call keywords in mixed text payloads. candidates = append(candidates, extractToolCallObjects(trimmed)...) // best-effort object slice: from first '{' to last '}' @@ -57,25 +57,65 @@ func extractToolCallObjects(text string) []string { lower := strings.ToLower(text) out := []string{} offset := 0 + keywords := []string{"tool_calls", "function.name:", "[tool_call_history]"} for { - idx := strings.Index(lower[offset:], "tool_calls") - if idx < 0 { + bestIdx := -1 + matchedKeyword := "" + for _, kw := range keywords { + idx := strings.Index(lower[offset:], kw) + if idx >= 0 { + absIdx := offset + idx + if bestIdx < 0 || absIdx < bestIdx { + bestIdx = absIdx + matchedKeyword = kw + } + } + } + + if bestIdx < 0 { break } - idx += offset - start := strings.LastIndex(text[:idx], "{") - for start >= 0 { + + idx := bestIdx + // Avoid backtracking too far to prevent OOM on malicious or very long strings + searchLimit := idx - 2000 + if searchLimit < offset { + searchLimit = offset + } + + start := strings.LastIndex(text[searchLimit:idx], "{") + if start >= 0 { + start += searchLimit + } + + if start < 0 { + offset = idx + len(matchedKeyword) + continue + } + + foundObj := false + for start >= searchLimit { candidate, end, ok := extractJSONObject(text, start) if ok { // Move forward to avoid repeatedly matching the same object. offset = end out = append(out, strings.TrimSpace(candidate)) + foundObj = true break } - start = strings.LastIndex(text[:start], "{") + // Try previous '{' + if start > searchLimit { + prevStart := strings.LastIndex(text[searchLimit:start], "{") + if prevStart >= 0 { + start = searchLimit + prevStart + continue + } + } + break } - if start < 0 { - offset = idx + len("tool_calls") + + if !foundObj { + offset = idx + len(matchedKeyword) } } return out @@ -88,7 +128,12 @@ func extractJSONObject(text string, start int) (string, int, bool) { depth := 0 quote := byte(0) escaped := false - for i := start; i < len(text); i++ { + // Limit scan length to avoid OOM on unclosed objects + maxLen := start + 50000 + if maxLen > len(text) { + maxLen = len(text) + } + for i := start; i < maxLen; i++ { ch := text[i] if quote != 0 { if escaped { From cf569f4749dec2e3a0732aebe7d56ddacd008ecf Mon Sep 17 00:00:00 2001 From: huangxun Date: Tue, 17 Mar 2026 16:41:16 +0800 Subject: [PATCH 4/4] docs: add testing documentation for tool call debugging - Add targeted test commands to TESTING.md for debugging tool call issues - Add quick test commands reference in README.md - Document specific test cases for DeepSeek tool call parsing --- README.MD | 17 +++++++++++++++++ TESTING.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/README.MD b/README.MD index d3dc05d..6b9383a 100644 --- a/README.MD +++ b/README.MD @@ -476,6 +476,23 @@ go run ./cmd/ds2api-tests \ npm ci --prefix webui && npm run build --prefix webui ``` +## 测试 + +详细测试指南请参阅 [TESTING.md](TESTING.md)。 + +### 快速测试命令 + +```bash +# 运行所有单元测试 +go test ./... + +# 运行 tool calls 相关测试(调试工具调用问题) +go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/ + +# 运行端到端测试 +./tests/scripts/run-live.sh +``` + ## Release 自动构建(GitHub Actions) 工作流文件:`.github/workflows/release-artifacts.yml` diff --git a/TESTING.md b/TESTING.md index c5e13e6..8d1a309 100644 --- a/TESTING.md +++ b/TESTING.md @@ -173,6 +173,50 @@ rg "" artifacts/testsuite//server.log go test ./... ``` +### 运行特定模块的单元测试 + +```bash +# 运行 tool calls 相关测试(推荐用于调试 tool call 解析问题) +go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/ + +# 运行单个测试用例 +go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ + +# 运行 format 相关测试 +go test -v ./internal/format/... + +# 运行 adapter 相关测试 +go test -v ./internal/adapter/openai/... +``` + +### 调试 Tool Call 问题 | Debugging Tool Call Issues + +当遇到 DeepSeek 工具调用解析问题时,可以使用以下方法: + +```bash +# 1. 运行 tool calls 相关的所有测试 +go test -v -run 'TestParseToolCalls|TestRepair' ./internal/util/ + +# 2. 查看测试输出中的详细调试信息 +go test -v -run TestParseToolCallsWithDeepSeekHallucination ./internal/util/ 2>&1 + +# 3. 检查具体测试用例的修复效果 +# 测试用例位于 internal/util/toolcalls_test.go,包含: +# - TestParseToolCallsWithDeepSeekHallucination: DeepSeek 典型幻觉输出 +# - TestRepairLooseJSONWithNestedObjects: 嵌套对象的方括号修复 +# - TestParseToolCallsWithMixedWindowsPaths: Windows 路径处理 +``` + +### 运行 Node.js 测试 + +```bash +# 运行 Node 测试 +node --test tests/node/stream-tool-sieve.test.js + +# 或使用脚本 +./tests/scripts/run-unit-node.sh +``` + ### 跑端到端测试(跳过 preflight) ```bash