Resolve conflicts by preferring remote changes
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Reflection → Lesson persistence
|
||||
*
|
||||
* Take the Reflector agent's structured critique and persist any substantive
|
||||
* findings as a `lesson` card in `<brainDir>/lessons/auto-reflector/`. The
|
||||
* existing brain retrieval pipeline (see `retrieval/brainIndex.ts` +
|
||||
* `retrieval/lessonHelpers.ts`) then automatically boosts these cards and
|
||||
* injects them as an `[⚠ ACTIVE LESSONS — verify these BEFORE finalizing your
|
||||
* answer]` block in *future* missions' Planner/Researcher/Writer context, so a
|
||||
* critique caught in mission N becomes a guardrail in mission N+1.
|
||||
*
|
||||
* Recurrence handling: if a similarly-titled auto-reflector lesson already
|
||||
* exists, we bump `occurrences:` and escalate `severity` (low→medium→high)
|
||||
* instead of producing a duplicate card. Same pattern reappearing 3+ times
|
||||
* surfaces as severity:high, which the lesson retrieval/scoring layer
|
||||
* propagates as a stronger guardrail.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logInfo, logError } from '../utils';
|
||||
import {
|
||||
lessonSlug,
|
||||
parseLessonFrontmatter,
|
||||
bumpLessonOccurrences,
|
||||
normalizeLessonTitle,
|
||||
} from '../retrieval/lessonHelpers';
|
||||
|
||||
interface ReflectionSections {
|
||||
alignment: string;
|
||||
gaps: string;
|
||||
contradictions: string;
|
||||
unsupported: string;
|
||||
guidance: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the body of a `## …<keyword>…` section out of the Reflector's markdown. Line-scan rather
|
||||
* than a multi-line regex so it survives emoji headers and trailing whitespace without
|
||||
* leaning on JS-unsupported regex features.
|
||||
*/
|
||||
function extractSection(text: string, headerKeyword: string): string {
|
||||
if (!text) return '';
|
||||
const kw = headerKeyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const headerRe = new RegExp(`^##[^\\n]*${kw}[^\\n]*$`, 'i');
|
||||
const lines = text.split(/\r?\n/);
|
||||
const buf: string[] = [];
|
||||
let inSection = false;
|
||||
for (const line of lines) {
|
||||
if (headerRe.test(line)) { inSection = true; continue; }
|
||||
if (inSection && /^##\s/.test(line)) break;
|
||||
if (inSection) buf.push(line);
|
||||
}
|
||||
return buf.join('\n').trim();
|
||||
}
|
||||
|
||||
function parseReflection(text: string): ReflectionSections {
|
||||
return {
|
||||
alignment: extractSection(text, 'Alignment'),
|
||||
gaps: extractSection(text, 'Gaps'),
|
||||
contradictions: extractSection(text, 'Contradictions'),
|
||||
unsupported: extractSection(text, 'Unsupported'),
|
||||
guidance: extractSection(text, 'Guidance'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "Trivial" = the Reflector explicitly said nothing was found, or the section is too short to be
|
||||
* meaningful. We don't want to spam the brain with `발견되지 않음` cards.
|
||||
*/
|
||||
function isTrivial(section: string): boolean {
|
||||
if (!section) return true;
|
||||
const stripped = section.replace(/[-*•·\s\n]+/g, '').toLowerCase();
|
||||
if (!stripped) return true;
|
||||
if (/^(없음|발견되지않음|해당없음|na|nothing|none|n\/a)$/.test(stripped)) return true;
|
||||
if (stripped.length < 12) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasSubstantiveContent(sections: ReflectionSections): boolean {
|
||||
return !isTrivial(sections.gaps)
|
||||
|| !isTrivial(sections.contradictions)
|
||||
|| !isTrivial(sections.unsupported)
|
||||
|| !isTrivial(sections.guidance);
|
||||
}
|
||||
|
||||
/** Pick a short (≤80 char) title from the first actionable bullet across the substantive sections. */
|
||||
function deriveTitle(sections: ReflectionSections): string {
|
||||
const order = [sections.guidance, sections.gaps, sections.unsupported, sections.contradictions];
|
||||
for (const sec of order) {
|
||||
if (isTrivial(sec)) continue;
|
||||
const firstBullet = sec.split('\n').find((l) => /^\s*[-*•]/.test(l));
|
||||
const raw = (firstBullet || sec.split('\n')[0] || '').replace(/^\s*[-*•]\s*/, '').trim();
|
||||
if (raw.length >= 10) {
|
||||
return raw.length > 80 ? raw.slice(0, 77) + '…' : raw;
|
||||
}
|
||||
}
|
||||
return 'Reflector finding (auto)';
|
||||
}
|
||||
|
||||
function severityFor(occurrences: number): 'low' | 'medium' | 'high' {
|
||||
if (occurrences >= 3) return 'high';
|
||||
if (occurrences >= 2) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function buildLessonCard(params: {
|
||||
title: string;
|
||||
today: string;
|
||||
situation: string;
|
||||
sections: ReflectionSections;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}): string {
|
||||
const { title, today, situation, sections, severity } = params;
|
||||
const safeTitle = title.replace(/\n/g, ' ').trim();
|
||||
|
||||
const riskParts = [
|
||||
isTrivial(sections.gaps) ? '' : `**Gaps**\n${sections.gaps.trim()}`,
|
||||
isTrivial(sections.unsupported) ? '' : `**Unsupported claims**\n${sections.unsupported.trim()}`,
|
||||
isTrivial(sections.contradictions) ? '' : `**Contradictions**\n${sections.contradictions.trim()}`,
|
||||
].filter(Boolean);
|
||||
const risk = riskParts.length ? riskParts.join('\n\n') : '<critique 본문 비어 있음>';
|
||||
|
||||
const rootCause = isTrivial(sections.alignment)
|
||||
? '<원본 요청 대비 이탈/근본 원인이 critique에 명시되지 않음 — 회고 시 보강>'
|
||||
: sections.alignment.trim();
|
||||
|
||||
const fix = isTrivial(sections.guidance)
|
||||
? '<Reflector가 Writer 보정 지시(Guidance)를 비워뒀음 — 다음 미션 시 수동 보강 권장>'
|
||||
: sections.guidance.trim();
|
||||
|
||||
const bullets = isTrivial(sections.guidance)
|
||||
? []
|
||||
: sections.guidance
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^[-*•]/.test(l))
|
||||
.map((l) => l.replace(/^[-*•]\s*/, ''))
|
||||
.filter((l) => l.length > 0)
|
||||
.slice(0, 6);
|
||||
const checklistBlock = (bullets.length
|
||||
? bullets
|
||||
: ['<다음 유사 mission 전에 위 Gaps / Unsupported 항목을 사전 점검>']
|
||||
).map((c) => `- ${c}`).join('\n');
|
||||
|
||||
return [
|
||||
'---',
|
||||
'type: lesson',
|
||||
`title: ${safeTitle}`,
|
||||
'applies-to: []',
|
||||
`severity: ${severity}`,
|
||||
'source: auto-reflector',
|
||||
'occurrences: 1',
|
||||
`last-seen: ${today}`,
|
||||
'---',
|
||||
'',
|
||||
`# Lesson: ${safeTitle}`,
|
||||
'',
|
||||
'## Situation',
|
||||
situation,
|
||||
'',
|
||||
'## Mistake / Risk',
|
||||
risk,
|
||||
'',
|
||||
'## Root Cause',
|
||||
rootCause,
|
||||
'',
|
||||
'## Fix',
|
||||
fix,
|
||||
'',
|
||||
'## Prevention Checklist',
|
||||
checklistBlock,
|
||||
'',
|
||||
'## Applies To',
|
||||
'- auto-reflector',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap title-overlap match against existing auto-reflector cards. Exact-normalized-title hit wins
|
||||
* outright; otherwise the best ≥60% term-overlap candidate is returned (or none).
|
||||
*/
|
||||
function findExistingLesson(autoDir: string, newTitle: string): { filePath: string; content: string } | undefined {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(autoDir).filter((f) => f.endsWith('.md'));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const newNorm = normalizeLessonTitle(newTitle);
|
||||
if (!newNorm) return undefined;
|
||||
const tokenize = (norm: string) => new Set(norm.match(/[a-z0-9]{3,}|[가-힣]{2,}/g) || []);
|
||||
const newTerms = tokenize(newNorm);
|
||||
|
||||
let best: { filePath: string; content: string; score: number } | undefined;
|
||||
for (const f of entries) {
|
||||
const filePath = path.join(autoDir, f);
|
||||
let content = '';
|
||||
try {
|
||||
content = fs.readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const fm = parseLessonFrontmatter(content);
|
||||
const existingTitle = (fm.title || '').trim();
|
||||
const existingNorm = normalizeLessonTitle(existingTitle);
|
||||
if (!existingNorm) continue;
|
||||
if (existingNorm === newNorm) return { filePath, content };
|
||||
if (newTerms.size === 0) continue;
|
||||
const existingTerms = tokenize(existingNorm);
|
||||
let overlap = 0;
|
||||
for (const t of newTerms) if (existingTerms.has(t)) overlap++;
|
||||
const score = overlap / newTerms.size;
|
||||
if (score >= 0.6 && (!best || score > best.score)) {
|
||||
best = { filePath, content, score };
|
||||
}
|
||||
}
|
||||
return best ? { filePath: best.filePath, content: best.content } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace (or insert) the `severity:` line in a lesson's frontmatter. Returns content unchanged if
|
||||
* there is no frontmatter block — caller is responsible for not calling on free-form notes.
|
||||
*/
|
||||
function setSeverityInFrontmatter(content: string, severity: 'low' | 'medium' | 'high'): string {
|
||||
if (!/^?---\s*\n/.test(content)) return content;
|
||||
const end = content.indexOf('\n---', 4);
|
||||
if (end < 0) return content;
|
||||
let block = content.slice(0, end);
|
||||
const rest = content.slice(end);
|
||||
if (/^\s*severity\s*:/m.test(block)) {
|
||||
block = block.replace(/^(\s*severity\s*:\s*).*$/m, `$1${severity}`);
|
||||
} else {
|
||||
block += `\nseverity: ${severity}`;
|
||||
}
|
||||
return block + rest;
|
||||
}
|
||||
|
||||
export interface PersistResult {
|
||||
/** Absolute path of the file written or bumped. */
|
||||
filePath: string;
|
||||
/** True if an existing lesson was updated (occurrences++); false for a new card. */
|
||||
bumped: boolean;
|
||||
/** Current occurrences value after the operation. */
|
||||
occurrences: number;
|
||||
/** Current severity after the operation. */
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the Reflector's critique as a lesson card. Returns `undefined` when nothing was written
|
||||
* (no brain path, critique trivial, IO failure — all soft-fail by design; never throws).
|
||||
*/
|
||||
export function persistReflectionAsLesson(params: {
|
||||
reflection: string;
|
||||
originalPrompt: string;
|
||||
brainDir: string;
|
||||
}): PersistResult | undefined {
|
||||
const { reflection, originalPrompt, brainDir } = params;
|
||||
if (!reflection || !brainDir || !path.isAbsolute(brainDir)) return undefined;
|
||||
|
||||
try {
|
||||
const sections = parseReflection(reflection);
|
||||
if (!hasSubstantiveContent(sections)) {
|
||||
logInfo('[reflectionPersister] critique is trivial — skipping lesson dump.');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const title = deriveTitle(sections);
|
||||
const autoDir = path.join(brainDir, 'lessons', 'auto-reflector');
|
||||
try {
|
||||
fs.mkdirSync(autoDir, { recursive: true });
|
||||
} catch {
|
||||
// Fall through; the write below will surface the real failure.
|
||||
}
|
||||
|
||||
const existing = findExistingLesson(autoDir, title);
|
||||
if (existing) {
|
||||
let bumped = bumpLessonOccurrences(existing.content, today);
|
||||
const newOcc = parseLessonFrontmatter(bumped).occurrences ?? 1;
|
||||
const sev = severityFor(newOcc);
|
||||
bumped = setSeverityInFrontmatter(bumped, sev);
|
||||
fs.writeFileSync(existing.filePath, bumped, 'utf8');
|
||||
logInfo(`[reflectionPersister] bumped existing lesson (occ=${newOcc}, severity=${sev}): ${existing.filePath}`);
|
||||
return { filePath: existing.filePath, bumped: true, occurrences: newOcc, severity: sev };
|
||||
}
|
||||
|
||||
const situation = (originalPrompt || '').slice(0, 400).replace(/\s+/g, ' ').trim()
|
||||
|| '<original prompt unavailable>';
|
||||
const card = buildLessonCard({ title, today, situation, sections, severity: 'low' });
|
||||
|
||||
let filePath = path.join(autoDir, `${today}-${lessonSlug(title)}.md`);
|
||||
let n = 2;
|
||||
while (fs.existsSync(filePath)) {
|
||||
filePath = path.join(autoDir, `${today}-${lessonSlug(title)}-${n++}.md`);
|
||||
}
|
||||
fs.writeFileSync(filePath, card, 'utf8');
|
||||
logInfo(`[reflectionPersister] new lesson saved: ${filePath}`);
|
||||
return { filePath, bumped: false, occurrences: 1, severity: 'low' };
|
||||
} catch (e: any) {
|
||||
logError('[reflectionPersister] failed to persist lesson.', { error: e?.message ?? String(e) });
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,13 @@ export interface IAgentConfig {
|
||||
* false: 기존 3단계(Planner→Researcher→Writer) 그대로 — 1 LLM 호출 절약 (저성능 모델/저지연 우선 시).
|
||||
*/
|
||||
enableReflection: boolean;
|
||||
/**
|
||||
* [Self-Reflection → Knowledge] Reflector critique 중 의미 있는 발견을 brain의
|
||||
* `lessons/auto-reflector/`에 lesson 카드로 영속화할지 여부. true(기본)이면 동일/유사 패턴이
|
||||
* 다음 미션에서 retrieval로 자동 주입되고, 같은 critique이 반복될수록 occurrences/severity가
|
||||
* 누적됨. false면 critique은 그 미션 한정으로만 사용되고 사라짐.
|
||||
*/
|
||||
autoLessonFromReflection: boolean;
|
||||
}
|
||||
|
||||
// ─── 경로 정규화 유틸리티 ───
|
||||
@@ -160,6 +167,7 @@ export function getConfig(): IAgentConfig {
|
||||
cfg.get<number>('knowledgeMix.secondBrainWeight', 50)
|
||||
))),
|
||||
enableReflection: cfg.get<boolean>('enableReflection', true),
|
||||
autoLessonFromReflection: cfg.get<boolean>('autoLessonFromReflection', true),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+27
-1
@@ -4,10 +4,12 @@ import * as path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { lockManager } from '../core/lock';
|
||||
import { actionQueue } from '../core/queue';
|
||||
import { logInfo, logError } from '../utils';
|
||||
import { logInfo, logError, getActiveBrainProfile } from '../utils';
|
||||
import { AgentDataValidator, PerformanceProfiler, CognitionAudit } from './diagnostics';
|
||||
import { WikiFormatter } from './formatter';
|
||||
import { ErrorType, RecoveryRule } from '../types/interfaces';
|
||||
import { getConfig } from '../config';
|
||||
import { persistReflectionAsLesson } from '../agents/reflectionPersister';
|
||||
export { ErrorType, RecoveryRule };
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
@@ -557,6 +559,30 @@ export class AgentEngine {
|
||||
logError(`[AgentEngine] Reflector soft-fail — Writer 계속 진행: ${reflErr?.message || reflErr}`);
|
||||
reflection = '';
|
||||
}
|
||||
|
||||
// [Self-Reflection → Knowledge] Reflector가 의미 있는 critique을 내놓았으면
|
||||
// brain에 lesson 카드로 영속화한다. 다음 미션의 Planner/Researcher/Writer는
|
||||
// 기존 lesson retrieval 경로를 통해 이 카드를 자동으로 inject받는다.
|
||||
// 동일 패턴 재발 시 카드를 새로 만들지 않고 occurrences를 증가시키며 severity를
|
||||
// low→medium→high로 가중. fire-and-forget으로 미션 흐름을 막지 않는다.
|
||||
if (reflection && getConfig().autoLessonFromReflection !== false) {
|
||||
try {
|
||||
const brainDir = getActiveBrainProfile()?.localBrainPath;
|
||||
if (brainDir) {
|
||||
const result = persistReflectionAsLesson({
|
||||
reflection,
|
||||
originalPrompt: prompt,
|
||||
brainDir,
|
||||
});
|
||||
if (result) {
|
||||
logInfo(`[AgentEngine] Reflector critique → lesson (${result.bumped ? 'bumped' : 'new'}, severity=${result.severity}, occ=${result.occurrences}).`);
|
||||
}
|
||||
}
|
||||
} catch (persistErr: any) {
|
||||
// Lesson 영속화 실패는 미션 결과에 영향 없음 — 로그만 남기고 계속 진행.
|
||||
logError(`[AgentEngine] lesson 영속화 실패 (무시): ${persistErr?.message || persistErr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 4: Writer ---
|
||||
|
||||
Reference in New Issue
Block a user