diff --git a/internal/gateway/textcalls.go b/internal/gateway/textcalls.go index f41efd82..38863d74 100644 --- a/internal/gateway/textcalls.go +++ b/internal/gateway/textcalls.go @@ -114,6 +114,13 @@ func parseTextCalls(s string, names map[string]bool) (calls []writtenCall, rest switch { case h >= 0 && (d == nil || h < d[0]): left.WriteString(s[:h]) + if t := strings.TrimLeft(s[h+len(""):], " \t\r\n"); t == "" || strings.HasPrefix(t, "<") { + // no JSON after it: DeepSeek's own tags, an invoke or the + // closes of one (#823), or nothing; the opener is dropped + // and what follows read as it is + s = t + continue + } c, n, good := hermesCall(s[h+len(""):], names) if !good { return nil, "", false @@ -134,7 +141,25 @@ func parseTextCalls(s string, names map[string]bool) (calls []writtenCall, rest } } rest = strings.TrimSpace(textCallJunk.ReplaceAllString(left.String(), "")) - return calls, rest, len(calls) > 0 + rest = strings.TrimSpace(strings.TrimSuffix(rest, cutMark(rest))) + // tags and nothing else (#823, Dazzle-sys: + // 0 || rest == "" +} + +// cutMark is the end of s that is a mark's beginning cut short (), "" when there's none; a lone < may be text. +func cutMark(s string) string { + for _, m := range append([]string{"", "= 2; n-- { + if strings.HasSuffix(s, m[:n]) { + return m[:n] + } + } + } + return "" } // hermesCall reads the JSON object a opens, and says how much @@ -320,7 +345,9 @@ func (t *textCallSee) event(ev Event) { t.see(Event{Kind: KToolStart, ID: textCallID(), Name: c.Name}) t.see(Event{Kind: KToolArgs, Text: string(c.Args)}) } - ev.Stop = "tool" + if len(calls) > 0 { + ev.Stop = "tool" + } } } t.release() @@ -455,9 +482,11 @@ func (t *textCallTidy) line(line []byte) []byte { "function": map[string]any{"name": c.Name, "arguments": string(c.Args)}} } pre = append(pre, t.release()...) - pre = append(pre, t.chunk(map[string]any{"tool_calls": tcs}, nil)...) - ch["finish_reason"] = "tool_calls" - changed = true + if len(tcs) > 0 { + pre = append(pre, t.chunk(map[string]any{"tool_calls": tcs}, nil)...) + ch["finish_reason"] = "tool_calls" + changed = true + } } else { pre = append(pre, t.release()...) } diff --git a/internal/gateway/textcalls_test.go b/internal/gateway/textcalls_test.go index 988860c8..16dbad38 100644 --- a/internal/gateway/textcalls_test.go +++ b/internal/gateway/textcalls_test.go @@ -185,3 +185,26 @@ func TestTextWrittenDSMLCallTranslated(t *testing.T) { } } } + +// #823 again (Dazzle-sys, Trae CN's DeepSeek V4.1 Flash in Pi): a reply +// that was a with only DeepSeek's closing tags after it, cut +// short, reached the agent as that text. Tags and nothing else are +// dropped; a wrapping DeepSeek's own invoke is the call. +func TestTextWrittenTagsOnly(t *testing.T) { + body := `{"model":"a/m","stream":true,"messages":[{"role":"user","content":"hi"}],` + textCallTools + `}` + text, calls, finish, raw := relayedChat(t, body, + chunkOf("\n\n"), chunkOf("\n\n<|DSML|invoke name=\"bash\">\n<|DSML|parameter name=\"command\" string=\"true\">ls\n\n"), stopChunk) + if text != "看一下。" || calls["bash"] != `{"command":"ls"}` || finish != "tool_calls" || strings.Contains(raw, "DSML") || strings.Contains(raw, "tool_call>") { + t.Fatalf("wrapped invoke: text %q calls %v finish %q\n%s", text, calls, finish, raw) + } + // text with a tag-like end stays + text, _, _, _ = relayedChat(t, body, chunkOf("x y"), stopChunk) + if text != "x y" { + t.Fatalf("lookalike: %q", text) + } +}