Merge pull request #92 from valkryhx/main

fix(toolcall): fix deepseek function calling bug and add json repair
This commit is contained in:
CJACK.
2026-03-18 00:15:47 +08:00
committed by GitHub
12 changed files with 583 additions and 48 deletions

View File

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

View File

@@ -173,6 +173,50 @@ rg "<trace_id>" artifacts/testsuite/<run_id>/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

View File

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

View File

@@ -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."
}

View File

@@ -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)
}
}
@@ -191,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 {
@@ -215,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

View File

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

View File

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

View File

@@ -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;
}
}
@@ -187,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)) {
@@ -212,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 {

View File

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

View File

@@ -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:
@@ -264,6 +271,20 @@ 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
}
}
// 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)
@@ -277,3 +298,76 @@ 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()
}
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
}

View File

@@ -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`
@@ -279,3 +282,217 @@ 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 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)
}
}
}

77
tests/repair_json_tool.go Normal file
View File

@@ -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)
}
}
}