Files
homeclaw/tests/system-prompt.test.ts
T
kimandClaude Opus 5 d4e8750400 refactor: VERIFY CONCRETE FACTS를 web_search 보유 턴에만 붙도록 도구 게이팅
이 문장은 245토큰으로 무조건 블록 중 단일 최대 항목인데, 내용이 통째로
"web_search를 먼저 불러라"다. 그 도구가 이번 턴 스키마에 없으면 실행할 수
없는 지시이고, 무엇보다 executeTool()이 스키마 멤버십을 확인하지 않고
이름으로만 디스패치하기 때문에 프롬프트가 도구 이름을 언급하는 것만으로도
모델이 파라미터 문서 없이 그 도구를 호출해버린다. meteorologist 스킬을 끈
사용자에게 weather_kma가 계속 돌던 그 경로다.

조건이 규칙의 실행 가능성과 정확히 일치하므로, 이건 이 파일 규칙 1이 금지하는
메시지 게이팅이 아니라 정상적인 도구 게이팅이다. ANTI-HALLUCINATION 앞부분
(도구 출력을 그대로 옮겨 적으라는 지침)은 도구와 무관하므로 무조건 유지된다.

토큰 절감은 없다. 처음엔 절감을 기대했으나, web_search가 어떤 스킬 게이트에도
속하지 않아 사실상 모든 일반 턴에 스키마에 들어간다는 걸 뒤늦게 확인했다.
실제로 줄어드는 건 부팅 턴(coder_list_files/coder_read_file만 있는 턴)뿐이고,
1687 → 1442 토큰이다. 그 턴에서 스키마에 없는 web_search를 프롬프트가 부르던
누출 벡터가 사라진 것이 이 커밋의 실질적 이득이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:41:27 +09:00

175 lines
9.1 KiB
TypeScript

/**
* system-prompt.test.ts
*
* The system message is the single biggest lever on model behaviour, and until 2026-07-29 it
* was assembled inline inside handleChat() where the only way to see the result was to send a
* real chat message and read tool_overhead off an SSE event.
*
* The property these tests protect is narrow but important: a rule that suppresses a tool must
* be present whenever that tool is callable. Dropping one is silent — nothing errors, the model
* just loses a guardrail on exactly the turn that needed it.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { buildChatSystemPrompt, type SystemPromptInput } from '../src/gateway/chat/system-prompt';
const build = (over: Partial<SystemPromptInput> = {}) => buildChatSystemPrompt({
toolNames: [],
dateStr: 'Tuesday, July 29, 2026',
timeStr: '03:00 PM',
...over,
});
describe('무조건 들어가는 블록', () => {
test('도구가 하나도 없어도 핵심 규칙은 남는다', () => {
const p = build();
for (const block of ['ANTI-HALLUCINATION', 'TEMPORAL CONSISTENCY', 'OUTPUT FORMAT']) {
assert.ok(p.includes(block), `${block} 누락`);
}
});
// [2026-08-10] 470토큰까지 커진 반면 실제 사용은 로그 83일 중 3일(~4%)뿐이라 메시지 게이팅으로
// 전환. 출력 형식 제약이라 키로 삼을 도구가 없어서, 이 파일의 "도구로만 게이팅" 원칙에 대한
// 유일한 의도적 예외다 — 키워드를 놓쳐도 대가가 깨진 다이어그램 하나뿐이라 허용된다.
test('CHEMISTRY NOTATION은 화학 맥락일 때만 붙는다', () => {
assert.ok(!build({ message: '안녕' }).includes('CHEMISTRY NOTATION'));
assert.ok(!build({ message: '이 코드 리팩터링해줘', toolNames: ['coder_read_file'] }).includes('CHEMISTRY NOTATION'));
assert.ok(build({ message: '벤젠 화학식이 뭐야' }).includes('CHEMISTRY NOTATION'));
assert.ok(build({ message: '아스피린 분자구조 그려줘' }).includes('CHEMISTRY NOTATION'));
});
// 게이팅을 무조건 유지에서 조건부로 바꾼 이유가 치과/의료 어휘였으므로, 그쪽이 빠지면
// 애초에 게이팅한 의미가 없다 — 이 사용자의 실제 도메인이다.
test('치과/의료 어휘도 CHEMISTRY NOTATION을 켠다', () => {
for (const m of ['치과 레진 성분이 뭐지', '국소마취제 리도카인 어떻게 작용해', '불소도포 효과 있나']) {
assert.ok(build({ message: m }).includes('CHEMISTRY NOTATION'), `"${m}"에서 누락`);
}
});
test('현재 날짜/시각이 주입된다 — TEMPORAL CONSISTENCY의 근거값', () => {
const p = build({ dateStr: 'Monday, January 5, 2026', timeStr: '09:30 AM' });
assert.ok(p.includes('Monday, January 5, 2026'));
assert.ok(p.includes('09:30 AM'));
});
test('불확실성 표현은 길이 제한에서 면제된다는 조항이 살아있다', () => {
// 이 조항이 빠지면 1-2문장 압박이 "모른다"를 밀어내고 환각을 확신형 한 줄로 포장한다.
const p = build();
assert.ok(/Stating uncertainty NEVER counts against the length/i.test(p));
});
// [2026-08-10] 실제 사고(세션 papa@2d4f3f6e): "지금 비오는 곳이 있나?"에 web_search를 부르긴
// 했으나, 결과가 URL에 "tm=2024.12.13.20:00"이 박힌 2024년 캐시 페이지였는데도 "현재"라며
// 소개했다. 게다가 그 표를 옮겨 적으면서 열도 잘못 읽어 동두천의 강수량(2.9mm)을 파주 것으로,
// 양평의 풍속(0.6m/s)을 강수량으로 뒤바꿨다. 도구를 불렀다는 사실만으로는(hasGroundingToolCall)
// 결과를 정확히 옮겨 썼는지 전혀 검증되지 않는다 — 이 조항은 그 결과 자체를 의심하라는 지침이다.
test('검색결과의 캐시 날짜/표 오독을 의심하라는 지침이 TEMPORAL CONSISTENCY에 있다', () => {
const p = build();
assert.ok(/tm=2024\.12\.13\.20:00/.test(p), '실제 사고 사례의 URL 패턴이 예시로 남아있어야 함');
assert.ok(/misaligning one city|misaligning one row/i.test(p), '표 오독(열 뒤바뀜) 경고가 있어야 함');
});
});
describe('도구 게이팅 — 도구가 있을 때만 규칙이 붙는다', () => {
const cases: Array<[string, string[], string]> = [
['IMAGE EDITING RULE', ['image_edit'], 'image_edit'],
['IMAGE/VIDEO GENERATION', ['image_generate'], 'image_generate'],
['IMAGE/VIDEO GENERATION', ['video_generate'], 'video_generate'],
['CODE OUTPUT', ['coder_read_file'], 'coder_*'],
['PACKAGE INSTALL', ['shell'], 'shell'],
['PACKAGE INSTALL', ['run_command'], 'run_command'],
['BROWSER RULE', ['browser_open'], 'browser_*'],
];
// [2026-08-10] VERIFY CONCRETE FACTS는 무조건 블록 중 단일 최대(245토큰)였는데, 내용이
// 전부 "web_search를 먼저 불러라"다 — 그 도구가 없는 턴에는 실행 불가능한 지시였고, 게다가
// executeTool()이 이름으로만 디스패치하므로 스키마에 없는 도구를 프롬프트가 언급하는 것 자체가
// 위험하다([[project_tool_schema_leak]]). 조건이 규칙의 실행 가능성과 정확히 일치하므로
// 이건 메시지 게이팅이 아니라 정상적인 도구 게이팅이다.
test('VERIFY CONCRETE FACTS — web_search 있을 때만', () => {
const p = build({ toolNames: ['web_search'] });
assert.ok(p.includes('VERIFY CONCRETE FACTS'));
assert.ok(p.includes('ANTI-HALLUCINATION'), '앞부분(도구 출력 충실)은 무조건 남아야 한다');
const noSearch = build({ toolNames: ['coder_read_file'] });
assert.ok(!noSearch.includes('VERIFY CONCRETE FACTS'));
assert.ok(!/web_search/.test(noSearch), '스키마에 없는 도구 이름이 프롬프트에 새면 안 된다');
assert.ok(noSearch.includes('ANTI-HALLUCINATION'));
});
for (const [block, toolNames, label] of cases) {
test(`${block} — ${label} 있을 때만`, () => {
assert.ok(build({ toolNames }).includes(block), `${label} 있는데 ${block} 없음`);
assert.ok(!build({ toolNames: ['web_search'] }).includes(block), `${label} 없는데 ${block} 붙음`);
});
}
test('억제 규칙은 도구가 호출 가능한 한 반드시 함께 온다', () => {
// 핵심 불변식: image_edit이 스키마에 있으면 "함부로 편집하지 말라"가 없을 수 없다.
// 사용자 문구로 게이팅했다면 정규식이 놓친 표현에서 이 규칙이 사라졌을 것.
const p = build({ toolNames: ['image_edit'] });
assert.ok(p.includes('IMAGE EDITING RULE'));
assert.ok(/NEVER call image_edit/i.test(p));
});
});
describe('전용 세션 — 엄격한 출력 계약', () => {
test('번역 세션은 번역 지시만 받는다', () => {
const p = build({ isTranslateSession: true, toolNames: ['image_edit', 'shell'] });
assert.ok(/medical translator/i.test(p));
// 행동 규칙 블록이 섞이면 출력 형식이 오염된다.
assert.ok(!p.includes('ANTI-HALLUCINATION'));
assert.ok(!p.includes('IMAGE EDITING RULE'));
});
test('프로젝트 파일 세션도 마찬가지', () => {
const p = build({ isProjSession: true });
assert.ok(/project file designer/i.test(p));
assert.ok(!p.includes('OUTPUT FORMAT'));
});
});
describe('실행 모드 프리앰블', () => {
test('배경 작업/하트비트/크론은 자율 실행 지시가 앞에 붙는다', () => {
assert.ok(/Autonomous background task/i.test(build({ executionMode: 'background_task' })));
assert.ok(/Heartbeat check/i.test(build({ executionMode: 'heartbeat' })));
assert.ok(/Scheduled cron task/i.test(build({ executionMode: 'cron' })));
});
test('일반 대화에는 붙지 않는다', () => {
assert.ok(!/EXECUTION MODE/i.test(build({ executionMode: 'interactive' })));
assert.ok(!/EXECUTION MODE/i.test(build()));
});
});
describe('주입되는 컨텍스트', () => {
test('모델 프로필/호출자/성격/스킬 컨텍스트가 그대로 실린다', () => {
const p = build({
modelProfileBlock: '\nMODEL-SPECIFIC NOTE: 테스트 노트',
callerContext: 'CALLER_MARK',
personalityCtx: 'PERSONA_MARK',
skillsCtx: 'SKILL_MARK',
browserStateCtx: 'BROWSER_MARK',
});
for (const m of ['테스트 노트', 'CALLER_MARK', 'PERSONA_MARK', 'SKILL_MARK', 'BROWSER_MARK']) {
assert.ok(p.includes(m), `${m} 누락`);
}
});
test('선택 입력이 없어도 undefined 문자열이 새지 않는다', () => {
const p = build();
assert.ok(!p.includes('undefined'), '프롬프트에 "undefined" 유출');
});
});
describe('비용', () => {
test('도구 없는 최소 프롬프트가 조건부 블록 전부 켠 것보다 확실히 작다', () => {
const bare = build().length;
const full = build({ toolNames: ['image_edit', 'image_generate', 'coder_read_file', 'shell', 'browser_open'] }).length;
assert.ok(full > bare, '조건부 블록이 전혀 늘지 않음 — 게이팅이 깨졌을 가능성');
// 게이팅이 통째로 무력화되면(항상 켜짐) 이 차이가 0이 된다.
assert.ok(full - bare > 1500, `조건부 블록 총량이 예상보다 작음 (${full - bare}자)`);
});
});