gateway: a reply that is only a <tool_call> and DeepSeek's closing tags says nothing, and a <tool_call> wrapping DeepSeek's own invoke is the call (Dazzle-sys, #823 again: Trae CN's DeepSeek V4.1 Flash in Pi showed <tool_call> </|DSML|parameter> </|DSML|invoke> </ as its reply) — the reply (17 tokens out, the 04:23:54 Trae CN row in the request log) was the Hermes opener with no JSON in it, then DeepSeek's closes, cut short. A <tool_call> was read only for a JSON object, so the held text went to the agent as it was, and the model read it back. A <tool_call> whose content starts with a tag, or is nothing, now has its opener dropped and what follows read as it is: a DSML invoke in it is the call; held text that is tags and nothing else (a tag cut short at its end, </, too) is dropped, and the reply ends as it did (stop) when no call was in it. Text that only looks like a tag (x </tool_call> y) goes on whole. trae 0.1.13 does the same in the plugin. TestTextWrittenTagsOnly fails on the old code with the screenshot's text; go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 05:09:27 +08:00
1 parent 9e52a8d875
commit c353f7bd3f
2 files changed
+57 -5

No files matched your search

+34 -5
View File
@@ -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("<tool_call>"):], " \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("<tool_call>"):], 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: <tool_call></|DSML|parameter>
// </|DSML|invoke></ was all a reply said) are dropped too: shown to the
// agent and read back, they had the model write its next calls the same
// way
return calls, rest, len(calls) > 0 || rest == ""
}
// cutMark is the end of s that is a mark's beginning cut short (</ of
// </tool_call>), "" when there's none; a lone < may be text.
func cutMark(s string) string {
for _, m := range append([]string{"</tool_call>", "</|DSML|", "</|DSML|"}, textCallMarks...) {
for n := len(m) - 1; n >= 2; n-- {
if strings.HasSuffix(s, m[:n]) {
return m[:n]
}
}
}
return ""
}
// hermesCall reads the JSON object a <tool_call> 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()...)
}
+23
View File
@@ -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 <tool_call> with only DeepSeek's closing tags after it, cut
// short, reached the agent as that text. Tags and nothing else are
// dropped; a <tool_call> 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("<tool_call>\n</|DSML|parameter>\n"), chunkOf("</|DSML|invoke>\n</"), stopChunk)
if text != "" || len(calls) != 0 || finish != "stop" || strings.Contains(raw, "DSML") {
t.Fatalf("tags only: text %q calls %v finish %q\n%s", text, calls, finish, raw)
}
text, calls, finish, raw = relayedChat(t, body,
chunkOf("看一下。<tool_call>\n<|DSML|invoke name=\"bash\">\n<|DSML|parameter name=\"command\" string=\"true\">ls</|DSML|parameter>\n</|DSML|invoke>\n</tool_call>"), 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 </tool_call> y"), stopChunk)
if text != "x </tool_call> y" {
t.Fatalf("lookalike: %q", text)
}
}