fix(agent): v2.2.62 — 출력 degeneration 방어

- 재시작 감지: auto-continuation이 "이어쓰기" 대신 답변을 처음부터
  재생성하면 버림 → 분석이 두 번 나오던 문제 제거
- degeneration 정리 패스(cleanDegeneratedOutput): 문자 벽(같은 기호 8개+),
  (Note:…) 메타 노트, Candidate records 내부 지시문 누출,
  (질문 의도:…)/[핵심 확인 질문] 누출, 연속 중복 문단 제거

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 16:13:39 +09:00
parent 745ebc57f6
commit 9cddf2aabc
12 changed files with 114 additions and 39 deletions
+56 -1
View File
@@ -269,7 +269,7 @@ export class AgentExecutor {
}
private sanitizeAssistantContent(text: string): string {
return text
const stripped = text
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
@@ -283,6 +283,54 @@ export class AgentExecutor {
.replace(/<\|?channel\|?>\s*final\b\s*(?:<\|?message\|?>)?/gi, '')
.replace(/<\|?(?:end|return|start|message)\|?>/gi, '')
.trim();
return this.cleanDegeneratedOutput(stripped);
}
/**
* Contain the blast radius when a small local model degenerates on a long /
* near-full context: runaway character walls, leaked English meta-notes,
* leaked internal directives, stuttered duplicate paragraphs. This does not
* fix the model failure — it just keeps a bad answer readable instead of a
* wall of noise.
*/
private cleanDegeneratedOutput(text: string): string {
let s = text;
// Runaway character walls — 8+ of the same separator. A real markdown rule
// is exactly 3 (`---`/`***`/`___`); nothing legitimate repeats a char 8+ times.
s = s.replace(/([_=~.*])\1{7,}/g, '');
// Leaked English meta-commentary the model narrates about itself.
s = s.replace(/\(Note:[^)]*\)/gi, '');
// Leaked project-chronicle directive (inline form — the `## ` heading form is stripped elsewhere).
s = s.replace(/\n?Candidate records for this discussion[^\n]*/gi, '');
// Leaked follow-up-question scaffolding the system prompt forbids.
s = s.replace(/\(\s*질문\s*의도\s*[:][^)]*\)/g, '');
s = s.replace(/\[\s*핵심\s*확인\s*질문\s*\]\s*/g, '');
// Collapse consecutive duplicate paragraphs (model stutter).
const paras = s.split(/\n{2,}/);
const deduped: string[] = [];
for (const p of paras) {
const norm = p.trim().replace(/\s+/g, ' ');
const prevNorm = deduped.length ? deduped[deduped.length - 1].trim().replace(/\s+/g, ' ') : '';
if (norm && norm === prevNorm) { continue; }
deduped.push(p);
}
return deduped.join('\n\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
/**
* True when a continuation round restarted the whole answer from the top
* instead of continuing it — a small model commonly ignores "continue from
* here". Detected by a shared leading prefix with the answer so far.
*/
private isRestartedAnswer(soFar: string, cont: string): boolean {
const norm = (x: string) => x.trim().replace(/\s+/g, ' ').toLowerCase();
const a = norm(soFar);
const b = norm(cont);
if (a.length < 40 || b.length < 40) { return false; }
let i = 0;
const max = Math.min(a.length, b.length, 80);
while (i < max && a[i] === b[i]) { i++; }
return i >= 12;
}
private async restoreLastSession() {
@@ -1103,6 +1151,13 @@ export class AgentExecutor {
logInfo('Continuation produced no visible text — stopping.', { model: actualModel, round: continuationCount });
break;
}
// A weak model often ignores "continue from here" and re-generates the
// whole answer from the top. Discard such a restart instead of merging
// it — otherwise the user gets the entire analysis twice.
if (this.isRestartedAnswer(cleaned.visible, ccl.visible)) {
logInfo('Continuation restarted the answer instead of continuing — discarding it.', { model: actualModel, round: continuationCount });
break;
}
const before = cleaned.visible;
cleaned = { ...cleaned, visible: mergeContinuationParts(cleaned.visible, ccl.visible), wasThoughtOnly: false };
lastOutputTokens = estimateTokens(ccl.visible);