holdWriter.Write, which every streamed try goes through (passed through or translated; a key's, a built-in subscription's, or a plugin's fetch, as a moved WorkBuddy's), feeds a loopGuard (loop_guard.go). It reads the reasoning and the text of each protocol's deltas (Anthropic, Chat, Responses, Gemini) and cuts each into units: lines and sentences, compared without case or a closing stop. A list's "1." or a decimal doesn't end a sentence. A unit with no word in it (braces, rules, 0x00, hashes, rows of numbers) isn't counted. When the last 256 counted units of either are among 24 or fewer distinct ones, the try is cut. What came with that write goes out, then the protocol's stream error (Anthropic error event, Chat error chunk, Responses response.failed server_error, Gemini 502 UNAVAILABLE), naming the loop and quoting its units, and the vendor's request is cancelled. The cycle needn't be exact; the reporter's has 9 units in two variants. It falls back to nobody and rests nobody (fail "loop"), and the usage record's err_type is reply_loop. A reply sent whole (no stream, or Gemini without alt=sse) isn't read. Settings › Models › Replies › Stop looping replies (noLoopGuard, on by default) turns it off; new strings in zh, zh-TW, ja and de. Tested: - loop_guard_test.go, with the screenshot's 28 lines cut into random 1–7 byte pieces, as one line, and in Chinese. It trips within the window. It reads every protocol's shapes, and tool-call arguments never trip it. - No trip on magpie's own sources fed as both reasoning and text: gateway.go, fallback.go, anthropic.go, app.js, i18n.js, app.css, routing.js, sessions.js, index.html, docs, LESSONS.md and go.sum. Nor on generated numbered lists, logs, CSV, hex, rules and base64. - Through the gateway on all 4 protocols with a fake DeepSeek, and on WorkBuddy both built-in and through the community plugin's fetch in the plugin host (TestLoopEndsWorkBuddysReply). In each case the agent gets the reasoning and then the error, the vendor is let go within 2000 lines, nobody rests, and usage keeps reply_loop. - gateway.go as 4,900 lines of reasoning passes whole on every protocol. With the setting off, a loop runs to its end. - With the guard removed, the end-to-end and WorkBuddy tests fail on every route. - The built binary (magpie serve on 127.0.0.1:3592, temp HOME, fake DeepSeek upstream), checked with curl on chat, messages, responses and gemini alt=sse. Each loop ended with that protocol's error, the upstream was let go after 256–271 lines, and the usage log had err_type reply_loop and routing fail "loop". The long gateway.go reasoning came through to [DONE], message_stop, response.completed and STOP. With noLoopGuard, all 100,000 lines passed. - GUI: loop-guard-setting.test.cjs in Chromium and WebKit, en/zh/ja/de, at 1100 and 420 wide. gui-ja, gui-de, gui-zh-tw, i18n-keys, member-model-setting, click-scroll and settings-sections/groups pass. - go vet, GOOS=linux/windows vet and builds, go test -race ./internal/gateway ./internal/settings, and the full go test -tags nogui ./... all pass. Not tried with the real WorkBuddy or DeepSeek model.
354 lines
9.5 KiB
Go
354 lines
9.5 KiB
Go
package gateway
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"hash/fnv"
|
||
"sort"
|
||
"strings"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// A model can fall into a loop it never leaves: DeepSeek's reasoning went
|
||
// on as "Now." "Writing." "Go." "Let me write." "OK." "Producing." … over
|
||
// and over, with a word or two changing between rounds, until the reply's
|
||
// token limit, and dsh and Pi waited on it the whole time (#1359,
|
||
// Moody-Sin). loopGuard reads the reply as the agent is sent it, in any of
|
||
// the protocols, and says when its reasoning or its text has become such a
|
||
// loop, for the gateway to end the reply with an error the agent can act
|
||
// on (holdWriter.cutLoop) rather than let it run on.
|
||
//
|
||
// The reply is cut into units, its lines and sentences. A loop is a long
|
||
// run of units that are nearly all the same few: of the last loopWindow
|
||
// units of reasoning, or of text, no more than loopDistinct different
|
||
// ones. Ordinary writing doesn't come near it, even when lines repeat:
|
||
// code, a table, a list or a log differs from line to line in its names,
|
||
// numbers and cells, and a unit with no word in it (a brace, a rule, a
|
||
// row of 0x00 or of numbers) isn't counted at all. The cycle need not be exact,
|
||
// as the reporter's wasn't ("Here." "Produce." one round, "Final." or
|
||
// "Producing." the next).
|
||
type loopGuard struct {
|
||
pending []byte // what came of an event not yet whole
|
||
think unitRun
|
||
text unitRun
|
||
}
|
||
|
||
const (
|
||
// loopWindow is how many units of a channel are looked at: 256 of the
|
||
// reporter's lines are about 600 tokens, a moment of a reply that runs
|
||
// to its limit of tens of thousands.
|
||
loopWindow = 256
|
||
// loopDistinct is the most different units the window may hold and
|
||
// still be a loop: the reporter's has 9.
|
||
loopDistinct = 24
|
||
// unitMost is how long a unit grows without a break before it is
|
||
// taken whole, and unitKept how much of it is told apart.
|
||
unitMost = 4096
|
||
unitKept = 256
|
||
// pendingMost bounds an event that never ends, which isn't read.
|
||
pendingMost = 1 << 20
|
||
)
|
||
|
||
// unitRun is one channel's units: its reasoning's, or its text's.
|
||
type unitRun struct {
|
||
cur strings.Builder // the unit being written
|
||
stop bool // cur ends in a sentence's stop: a space after it ends the unit
|
||
// lettered: cur has a letter in it, for a stop after it to end a
|
||
// sentence
|
||
lettered bool
|
||
ring [loopWindow]uint64
|
||
n int // units seen
|
||
counts map[uint64]*unitCount
|
||
}
|
||
|
||
type unitCount struct {
|
||
n int
|
||
text string // as it was first written, for the error to quote
|
||
at int // when it first came, to quote them in order
|
||
}
|
||
|
||
// loopTrip is what loopGuard found: which channel looped, and on what.
|
||
type loopTrip struct {
|
||
reasoning bool
|
||
units []string // the different units, the most frequent first
|
||
window int
|
||
}
|
||
|
||
// message is what the agent and the logs are told.
|
||
func (t loopTrip) message() string {
|
||
what := "text"
|
||
if t.reasoning {
|
||
what = "reasoning"
|
||
}
|
||
quoted := make([]string, 0, 6)
|
||
for i, u := range t.units {
|
||
if i == 6 {
|
||
quoted = append(quoted, "…")
|
||
break
|
||
}
|
||
quoted = append(quoted, fmt.Sprintf("%q", u))
|
||
}
|
||
return fmt.Sprintf("magpie ended this reply: the model's %s was stuck in a loop, its last %d lines and sentences all among the same %d (%s), and would have run on to its output limit. Ask again, or set Settings › Models › Replies › Stop looping replies off",
|
||
what, t.window, len(t.units), strings.Join(quoted, " "))
|
||
}
|
||
|
||
// feed reads b, the next of the reply as the agent is sent it.
|
||
func (g *loopGuard) feed(b []byte) (loopTrip, bool) {
|
||
g.pending = append(g.pending, b...)
|
||
for {
|
||
end := eventEnd(g.pending)
|
||
if end < 0 {
|
||
break
|
||
}
|
||
ev := g.pending[:end]
|
||
think, text := eventWords(ev)
|
||
g.pending = g.pending[end:]
|
||
if think != "" {
|
||
if t, ok := g.think.add(think); ok {
|
||
t.reasoning = true
|
||
return t, true
|
||
}
|
||
}
|
||
if text != "" {
|
||
if t, ok := g.text.add(text); ok {
|
||
return t, true
|
||
}
|
||
}
|
||
}
|
||
if len(g.pending) == 0 {
|
||
g.pending = nil
|
||
} else if len(g.pending) > pendingMost {
|
||
g.pending = nil
|
||
}
|
||
return loopTrip{}, false
|
||
}
|
||
|
||
// eventWords is what one event of a reply says, in any protocol: its
|
||
// reasoning and its text. Calls, starts, stops and usage say nothing here.
|
||
func eventWords(ev []byte) (think, text string) {
|
||
if !bytes.Contains(ev, []byte(`delta`)) && !bytes.Contains(ev, []byte(`candidates`)) {
|
||
return "", "" // most events that aren't content, without reading them
|
||
}
|
||
var data []byte
|
||
for _, ln := range bytes.Split(ev, []byte("\n")) {
|
||
ln = bytes.TrimRight(ln, "\r")
|
||
if bytes.HasPrefix(ln, []byte("data:")) {
|
||
data = append(data, bytes.TrimSpace(ln[5:])...)
|
||
}
|
||
}
|
||
if len(data) == 0 || data[0] != '{' {
|
||
return "", ""
|
||
}
|
||
var v struct {
|
||
Type string `json:"type"`
|
||
Delta json.RawMessage `json:"delta"`
|
||
Choices []struct {
|
||
Delta struct {
|
||
Content json.RawMessage `json:"content"`
|
||
ReasoningContent json.RawMessage `json:"reasoning_content"`
|
||
Reasoning json.RawMessage `json:"reasoning"`
|
||
} `json:"delta"`
|
||
} `json:"choices"`
|
||
Candidates []struct {
|
||
Content struct {
|
||
Parts []struct {
|
||
Text string `json:"text"`
|
||
Thought bool `json:"thought"`
|
||
} `json:"parts"`
|
||
} `json:"content"`
|
||
} `json:"candidates"`
|
||
}
|
||
if json.Unmarshal(data, &v) != nil {
|
||
return "", ""
|
||
}
|
||
str := func(raw json.RawMessage) string {
|
||
var s string
|
||
if len(raw) > 0 && raw[0] == '"' && json.Unmarshal(raw, &s) == nil {
|
||
return s
|
||
}
|
||
return ""
|
||
}
|
||
switch v.Type {
|
||
case "content_block_delta": // Anthropic
|
||
var d struct {
|
||
Type string `json:"type"`
|
||
Text string `json:"text"`
|
||
Thinking string `json:"thinking"`
|
||
}
|
||
if json.Unmarshal(v.Delta, &d) == nil {
|
||
switch d.Type {
|
||
case "text_delta":
|
||
return "", d.Text
|
||
case "thinking_delta":
|
||
return d.Thinking, ""
|
||
}
|
||
}
|
||
return "", ""
|
||
case "response.output_text.delta": // Responses
|
||
return "", str(v.Delta)
|
||
case "response.reasoning_text.delta", "response.reasoning_summary_text.delta":
|
||
return str(v.Delta), ""
|
||
case "":
|
||
default:
|
||
return "", ""
|
||
}
|
||
var th, tx strings.Builder
|
||
for _, c := range v.Choices { // Chat Completions
|
||
th.WriteString(cmpStr(str(c.Delta.ReasoningContent), str(c.Delta.Reasoning)))
|
||
tx.WriteString(str(c.Delta.Content))
|
||
}
|
||
for _, c := range v.Candidates { // Gemini
|
||
for _, p := range c.Content.Parts {
|
||
if p.Thought {
|
||
th.WriteString(p.Text)
|
||
} else {
|
||
tx.WriteString(p.Text)
|
||
}
|
||
}
|
||
}
|
||
return th.String(), tx.String()
|
||
}
|
||
|
||
// cmpStr is the first of a and b that isn't empty: a Chat delta carries
|
||
// its reasoning in one field or the other, as the vendor names it.
|
||
func cmpStr(a, b string) string {
|
||
if a != "" {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// add reads s, the next of the channel.
|
||
func (r *unitRun) add(s string) (loopTrip, bool) {
|
||
for _, c := range s {
|
||
switch {
|
||
case c == '\n':
|
||
if t, ok := r.end(); ok {
|
||
return t, true
|
||
}
|
||
continue
|
||
case r.stop && unicode.IsSpace(c):
|
||
if t, ok := r.end(); ok {
|
||
return t, true
|
||
}
|
||
continue
|
||
}
|
||
r.cur.WriteRune(c)
|
||
switch c {
|
||
case '.', '!', '?':
|
||
// a sentence's stop, not a list's number (1.) or a decimal
|
||
r.stop = r.lettered
|
||
case '。', '!', '?':
|
||
if t, ok := r.end(); ok {
|
||
return t, true
|
||
}
|
||
continue
|
||
default:
|
||
r.stop = false
|
||
r.lettered = r.lettered || unicode.IsLetter(c)
|
||
}
|
||
if r.cur.Len() >= unitMost {
|
||
if t, ok := r.end(); ok {
|
||
return t, true
|
||
}
|
||
}
|
||
}
|
||
return loopTrip{}, false
|
||
}
|
||
|
||
// end takes the unit written so far, and says whether the window has
|
||
// become a loop with it.
|
||
func (r *unitRun) end() (loopTrip, bool) {
|
||
raw := r.cur.String()
|
||
r.cur.Reset()
|
||
r.stop, r.lettered = false, false
|
||
key, ok := unitKey(raw)
|
||
if !ok {
|
||
return loopTrip{}, false
|
||
}
|
||
h := fnv.New64a()
|
||
h.Write([]byte(key))
|
||
sum := h.Sum64()
|
||
if r.counts == nil {
|
||
r.counts = map[uint64]*unitCount{}
|
||
}
|
||
slot := r.n % loopWindow
|
||
if r.n >= loopWindow {
|
||
old := r.ring[slot]
|
||
if c := r.counts[old]; c != nil {
|
||
if c.n--; c.n == 0 {
|
||
delete(r.counts, old)
|
||
}
|
||
}
|
||
}
|
||
r.ring[slot] = sum
|
||
c := r.counts[sum]
|
||
if c == nil {
|
||
text := strings.TrimSpace(raw)
|
||
if utf8.RuneCountInString(text) > 40 {
|
||
text = string([]rune(text)[:40]) + "…"
|
||
}
|
||
c = &unitCount{text: text, at: r.n}
|
||
r.counts[sum] = c
|
||
}
|
||
c.n++
|
||
r.n++
|
||
if r.n < loopWindow || len(r.counts) > loopDistinct {
|
||
return loopTrip{}, false
|
||
}
|
||
all := make([]*unitCount, 0, len(r.counts))
|
||
for _, c := range r.counts {
|
||
all = append(all, c)
|
||
}
|
||
sort.Slice(all, func(i, j int) bool {
|
||
if all[i].n != all[j].n {
|
||
return all[i].n > all[j].n
|
||
}
|
||
return all[i].at < all[j].at
|
||
})
|
||
t := loopTrip{window: loopWindow}
|
||
for _, c := range all {
|
||
t.units = append(t.units, c.text)
|
||
}
|
||
return t, true
|
||
}
|
||
|
||
// unitKey is what tells a unit apart from others, and whether it counts:
|
||
// its words, without case or its closing stop. A unit with no word in it
|
||
// — a brace, a rule, box drawing, a row of numbers or hex — is no
|
||
// sentence, and is left out.
|
||
func unitKey(raw string) (string, bool) {
|
||
s := strings.TrimSpace(raw)
|
||
s = strings.TrimRightFunc(s, func(c rune) bool {
|
||
return strings.ContainsRune(".!?。!?,,;;::…", c) || unicode.IsSpace(c)
|
||
})
|
||
if !hasWord(s) {
|
||
return "", false
|
||
}
|
||
s = strings.ToLower(s)
|
||
if len(s) > unitKept {
|
||
s = s[:unitKept]
|
||
}
|
||
return s, true
|
||
}
|
||
|
||
// hasWord says whether s has a word in it: a run of letters with no digit
|
||
// in it (Now, Writing, 好), not 0x00, v2 or a hash.
|
||
func hasWord(s string) bool {
|
||
letters, digits := false, false
|
||
for _, c := range s + " " {
|
||
if unicode.IsLetter(c) || unicode.IsDigit(c) || c == '_' {
|
||
letters = letters || unicode.IsLetter(c)
|
||
digits = digits || unicode.IsDigit(c)
|
||
continue
|
||
}
|
||
if letters && !digits {
|
||
return true
|
||
}
|
||
letters, digits = false, false
|
||
}
|
||
return false
|
||
}
|