Skip to content

Commit c2522fc

Browse files
AVADSA25Mikarina13claude
authored
fix(chat): memory-injection hygiene + degenerate-loop circuit breaker (#232)
Root-caused the 2026-07-10 01:13 "nonsense loop" on a pasted movie-trailer transcript. Three compounding problems, all fixed: 1. FALSE MEMORY TRIGGER: the pasted trailer DIALOGUE contained "I remembered something" and "human history" — substring-matching fired the 'remember' + 'history' memory triggers, so a creative-writing question was treated as a memory-recall question. Triggers now match on word boundaries and only scan the first 300 chars (the user's intent lives at the front of the message, not inside pasted content). 2. MEMORY POLLUTION: the targeted qchat lookup (LIKE %first-80-chars%) matched the user's OWN message, so the model received its question echoed back 3-4x wrapped in [MEMORY] tags, plus agent-status chrome ("running Agent started...", "Plan approved...") replayed as "recent conversation". New _mem_noise filter drops self-echo rows and agent-status noise from all memory blocks; blocks that end up empty are not injected at all. Verified against the actual trailer message: injected context shrank ~2000 -> 760 chars with zero echo and zero agent noise. 3. NO RUNAWAY GUARD: when the 4-bit model does collapse into repetition, the stream would grind toward the 28k-token cap in front of the user. New _degenerate_tail breaker in the SSE loop (checked every ~40 deltas) cuts the stream and says honestly that the model glitched and to re-ask. Unit-tested: trips on a repeating movie-list loop, does NOT trip on the real (clean) 4k-char reply reproduced from the incident prompt, nor on varied enumerations. Frontend hardening: if a cut/degenerate stream leaves an UNCLOSED <thinking> block, the fallback no longer dumps the raw reasoning into the chat bubble — reasoning stays in the reveal panel with an honest glitch notice (both stream handlers). Note: reproducing the exact incident prompt against the live model produced a clean, high-quality analysis — the collapse was transient model behavior; these fixes remove the confusion fuel (echo/noise/false trigger) and guarantee any future collapse is cut short with an honest message instead of minutes of garbage. Verified: full suite 2469 passed, ruff clean, node --check on inline JS, enrichment re-simulated on the real incident message. Co-authored-by: Mickael Farina <farina.mickael@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 24b441f commit c2522fc

2 files changed

Lines changed: 105 additions & 12 deletions

File tree

codec_chat.html

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,12 @@ <h1><a href="/" style="color:inherit;text-decoration:none">CODEC</a></h1>
672672
}
673673
}
674674
var pf=parseScaffold(raw);
675-
var answer=(pf.answer&&pf.answer.trim())||raw.replace(/<thinking>[\s\S]*?<\/thinking>/i,'').replace(/###\s*FINAL ANSWER:?/i,'').trim()||raw;
675+
// Fallback order: real FINAL ANSWER > text outside the thinking block
676+
// (also stripping an UNCLOSED <thinking> tail — a cut/degenerate stream
677+
// must never dump raw reasoning into the bubble) > honest glitch notice
678+
// with the reasoning kept in the reveal panel.
679+
var answer=(pf.answer&&pf.answer.trim())||raw.replace(/<thinking>[\s\S]*?<\/thinking>/i,'').replace(/<thinking>[\s\S]*$/i,'').replace(/###\s*FINAL ANSWER:?/i,'').trim();
680+
if(!answer&&pf.think&&pf.think.trim()){answer='*My reasoning ran on without reaching a final answer — a local-model glitch, not a real reply. The train of thought is below; please ask again.*'}
676681
if(answer){
677682
div.remove();var md=addMessage('assistant',answer);
678683
if(thinkingEnabled&&pf.think&&pf.think.trim()&&md){var fp=makeToT(md,'Train of thought',false);totSet(fp,pf.think.trim())}
@@ -1433,7 +1438,10 @@ <h1><a href="/" style="color:inherit;text-decoration:none">CODEC</a></h1>
14331438
}
14341439
}
14351440
var pf2=parseScaffold(raw);
1436-
var answer2=(pf2.answer&&pf2.answer.trim())||raw.replace(/<thinking>[\s\S]*?<\/thinking>/i,'').replace(/###\s*FINAL ANSWER:?/i,'').trim()||raw;
1441+
// Same fallback order as the primary handler: never dump an unclosed
1442+
// <thinking> tail into the bubble; keep reasoning in the reveal panel.
1443+
var answer2=(pf2.answer&&pf2.answer.trim())||raw.replace(/<thinking>[\s\S]*?<\/thinking>/i,'').replace(/<thinking>[\s\S]*$/i,'').replace(/###\s*FINAL ANSWER:?/i,'').trim();
1444+
if(!answer2&&pf2.think&&pf2.think.trim()){answer2='*My reasoning ran on without reaching a final answer — a local-model glitch, not a real reply. The train of thought is below; please ask again.*'}
14371445
if(answer2){div.remove();var md2=addMessage('assistant',answer2);if(thinkingEnabled&&pf2.think&&pf2.think.trim()&&md2){var fp2=makeToT(md2,'Train of thought',false);totSet(fp2,pf2.think.trim())}chatHist.push({role:'assistant',content:answer2});saveMessages([{role:'assistant',content:answer2}])}
14381446
else{bubble.innerHTML='<em style="color:var(--text-dim)">No response</em>'}
14391447
}

routes/chat.py

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import asyncio
3232
import json
3333
import logging
34+
import re
3435
import secrets
3536

3637
from fastapi import APIRouter, Request
@@ -150,6 +151,49 @@ def handle_data(self, data):
150151

151152

152153

154+
# Memory-injection hygiene (2026-07-10 trailer incident). Two pollution classes
155+
# were being injected as "memory" and derailing replies:
156+
# 1. SELF-ECHO — the FTS/LIKE lookups matched the user's OWN current message
157+
# (and its re-sends), so the model saw its question repeated 3-4x wrapped
158+
# in [MEMORY] tags, a hall-of-mirrors that reads as "this matters a lot".
159+
# 2. AGENT-STATUS NOISE — Project/crew status lines ("running Agent started…",
160+
# "Plan approved…") saved to chat history got replayed as conversational
161+
# memory, contexts that have nothing to do with the user's question.
162+
_AGENT_NOISE_PREFIXES = (
163+
"running ", "done ", "granted ", "plan approved", "project drafted",
164+
"here's my plan", "here’s my plan", "[codec_agent_plan", "paused:",
165+
"task stopped", "agent error", "blocked:",
166+
)
167+
168+
169+
def _mem_noise(content: str, last_text: str) -> bool:
170+
"""True when a candidate memory row should NOT be injected: empty,
171+
a near-duplicate of the current message (self-echo), or agent-status
172+
chrome rather than real conversation."""
173+
c = (content or "").strip()
174+
if not c:
175+
return True
176+
if c.lower()[:60] == (last_text or "").strip().lower()[:60]:
177+
return True
178+
return c.lower().startswith(_AGENT_NOISE_PREFIXES)
179+
180+
181+
def _degenerate_tail(text: str) -> bool:
182+
"""True when the tail of `text` is stuck repeating itself — the signature
183+
of a 4-bit sampling collapse (the same phrase/list item emitted over and
184+
over, e.g. an endless list of movie titles). Cheap deterministic check:
185+
probe = the last 64 chars; degenerate when that exact probe already occurs
186+
5+ times within the trailing window. Used by the chat SSE stream to cut a
187+
runaway generation with an honest message instead of letting it grind out
188+
28k tokens of garbage in front of the user (2026-07-10 trailer incident)."""
189+
if len(text) < 900:
190+
return False
191+
probe = text[-64:]
192+
if len(probe.strip()) < 12:
193+
return False
194+
return text[-2400:].count(probe) >= 5
195+
196+
153197
def _enrich_messages(messages: list, config: dict, force_search: bool = False) -> list:
154198
"""
155199
Auto-detect URLs, search intent, and memory recall in the last user message.
@@ -188,7 +232,17 @@ def _enrich_messages(messages: list, config: dict, force_search: bool = False) -
188232
'past conversation', 'history', 'do you know my',
189233
'what was', 'what did', 'when did',
190234
]
191-
has_memory_trigger = any(t in lower for t in memory_triggers)
235+
# Word-boundary match, and only scan the FIRST 300 chars — the user's own
236+
# intent lives at the front of the message, not inside pasted content.
237+
# (2026-07-10 trailer incident: a pasted movie-trailer transcript contained
238+
# "I remembered something" + "human history", substring-fired 'remember' +
239+
# 'history', and the resulting memory dump of old film chats derailed the
240+
# model into rambling about other movies instead of the pasted script.)
241+
_trigger_zone = lower[:300]
242+
has_memory_trigger = any(
243+
re.search(r"\b" + re.escape(t) + r"\b", _trigger_zone)
244+
for t in memory_triggers
245+
)
192246

193247
# 1. Voice memory (FTS5 via CodecMemory) — always inject recent, targeted on trigger
194248
try:
@@ -203,12 +257,15 @@ def _enrich_messages(messages: list, config: dict, force_search: bool = False) -
203257
if recent:
204258
lines = ["[RECENT MEMORY — VOICE (LAST 3 DAYS)]"]
205259
for r in recent:
260+
if _mem_noise(r["content"], last_text):
261+
continue
206262
ts = r["timestamp"][:16].replace("T", " ")
207263
snippet = r["content"][:200].replace("\n", " ")
208264
lines.append(f" [{ts}] {r['role'].upper()}: {snippet}")
209-
lines.append("[END RECENT MEMORY]")
210-
memory_parts.append("\n".join(lines))
211-
log.info(f"Recent memory injected: {len(recent)} messages")
265+
if len(lines) > 1:
266+
lines.append("[END RECENT MEMORY]")
267+
memory_parts.append("\n".join(lines))
268+
log.info(f"Recent memory injected: {len(lines) - 2} messages")
212269
except Exception as e:
213270
log.warning(f"Memory enrichment (voice) failed: {e}")
214271

@@ -225,25 +282,31 @@ def _enrich_messages(messages: list, config: dict, force_search: bool = False) -
225282
if qrows:
226283
lines = ["[MEMORY — RELEVANT PAST CHATS]"]
227284
for r in qrows:
285+
if _mem_noise(r[1], last_text):
286+
continue
228287
ts = (r[2] or "")[:16].replace("T", " ")
229288
snippet = (r[1] or "")[:200].replace("\n", " ")
230289
lines.append(f" [{ts}] {(r[0] or '').upper()}: {snippet}")
231-
lines.append("[END MEMORY]")
232-
memory_parts.append("\n".join(lines))
233-
log.info(f"Memory recall injected (chat targeted): {len(qrows)} msgs")
290+
if len(lines) > 1:
291+
lines.append("[END MEMORY]")
292+
memory_parts.append("\n".join(lines))
293+
log.info(f"Memory recall injected (chat targeted): {len(lines) - 2} msgs")
234294
# Recent chat messages for continuity
235295
qrecent = _qc.execute(
236296
"SELECT role, content, timestamp FROM qchat_messages ORDER BY id DESC LIMIT 5"
237297
).fetchall()
238298
if qrecent:
239299
lines = ["[RECENT MEMORY — CHAT]"]
240300
for r in qrecent:
301+
if _mem_noise(r[1], last_text):
302+
continue
241303
ts = (r[2] or "")[:16].replace("T", " ")
242304
snippet = (r[1] or "")[:200].replace("\n", " ")
243305
lines.append(f" [{ts}] {(r[0] or '').upper()}: {snippet}")
244-
lines.append("[END RECENT MEMORY]")
245-
memory_parts.append("\n".join(lines))
246-
log.info(f"Recent chat memory injected: {len(qrecent)} messages")
306+
if len(lines) > 1:
307+
lines.append("[END RECENT MEMORY]")
308+
memory_parts.append("\n".join(lines))
309+
log.info(f"Recent chat memory injected: {len(lines) - 2} messages")
247310
except Exception as e:
248311
log.warning(f"Memory enrichment (chat) failed: {e}")
249312

@@ -903,6 +966,11 @@ def _resolve_skill_tag(raw_tag):
903966
# as an empty / mid-sentence bubble.
904967
stream_died = False
905968
hit_token_cap = False
969+
degenerate = False
970+
# Degeneracy circuit-breaker state: raw deltas accumulated
971+
# (tail only) and re-checked every ~40 deltas.
972+
_acc = ""
973+
_since_check = 0
906974
for item in codec_llm.stream(messages, **_common,
907975
keepalive=True,
908976
error_sentinel=True,
@@ -921,12 +989,29 @@ def _resolve_skill_tag(raw_tag):
921989
if show_thoughts:
922990
for t in buf.drain_think():
923991
yield f"data: {json.dumps({'think': t})}\n\n"
992+
_acc += item
993+
_since_check += 1
994+
if _since_check >= 40:
995+
_since_check = 0
996+
if len(_acc) > 6000:
997+
_acc = _acc[-4000:] # tail is all the check needs
998+
if _degenerate_tail(_acc):
999+
degenerate = True
1000+
log.warning("[Chat] degenerate repetition loop detected — cutting stream")
1001+
break
9241002
# Stream ended ([DONE] or close): flush, then blank-bubble net.
9251003
for s in buf.finish():
9261004
yield _frame(s)
9271005
if show_thoughts:
9281006
for t in buf.drain_think():
9291007
yield f"data: {json.dumps({'think': t})}\n\n"
1008+
if degenerate:
1009+
yield _frame(
1010+
"\n\n*I caught myself repeating the same text in a "
1011+
"loop and stopped — that was a local-model glitch, not "
1012+
"a real answer. Please ask again (rephrasing slightly "
1013+
"usually fixes it).*"
1014+
)
9301015
if hit_token_cap:
9311016
yield _frame(
9321017
"\n\n⚠️ *Reply truncated — the model hit the "

0 commit comments

Comments
 (0)