Files
homeclaw/tests/tool-scope.test.ts
kimandClaude Sonnet 5 58a079fcb1 fix: nvr_snapshot 후속턴에 도구 사라지던 문제 + 캡처 이미지 채팅에 안 뜨던 문제
실사용(papa, 2026-09-07)에서 드러난 2건:
- "cctv확인해봐"로 도구가 돌았는데 다음 "아니다 cam2"/"사진 보여줘" 후속턴엔
  hasCctvKeyword가 현재 메시지만 보느라 도구가 빠졌고, 모델이 "저는 그런 툴이
  없습니다"를 3턴 반복 → topicText(직전 2턴 포함)로 검사, cam N·스냅샷·캡처 등 추가
- 캡처된 프레임을 모델이 프로즈로만 설명하고 이미지 마크다운을 안 뽑아 사용자가
  사진을 못 봄 → satellite_snapshot처럼 execute-tool에서 SSE로 이미지 직접 push
- 도구 결과 지시문을 "이전 대화 짐작 말고 이 프레임에 보이는 것만 구체적으로"로 강화
  (gemma4가 첫 응답에서 맥락에 기대 뭉뚱그린 사례)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rfme1WVEPkpNwnf5oVNXXc
2026-09-07 11:14:24 +09:00

470 lines
25 KiB
TypeScript

/**
* tool-scope.test.ts
*
* Which tools the model is offered on a given turn. Until 2026-07-29 this logic lived as a
* closure inside handleChat() and could only be exercised by booting the gateway and reading
* a token count out of an SSE event — so it had never been tested at all, and a mis-gated tool
* showed up only as a slightly larger prompt or a mysteriously missing capability.
*
* Two properties matter and they pull in opposite directions:
* 1. cost — a tool nobody needs must not ride along (that is why the gates exist)
* 2. reach — a tool the user clearly asked for must be there (a gate that is too tight
* silently removes a feature, which is far worse than a few wasted tokens)
* Every test below pins one or the other.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { buildSkillToolFilter, selectToolsForTurn, codeAiBlockedTools, bootAllowedTools } from '../src/gateway/chat/tool-scope';
const base = {
sessionId: 'default',
userWorkspace: null as string | null,
isSkillEnabledForUser: () => false,
};
/** 모든 스킬이 켜진 상태 — 스킬+키워드 두 조건을 함께 요구하는 게이트를 검증할 때 사용. */
const allSkills = { ...base, isSkillEnabledForUser: () => true };
const filterFor = (message: string, extra: Partial<typeof base> = {}) =>
buildSkillToolFilter({ ...base, ...extra, message });
describe('키워드 게이트 — 관련 없는 턴에서 도구가 따라붙지 않는다', () => {
test('평범한 인사에는 앱 전용 도구가 전부 빠진다', () => {
const f = filterFor('안녕');
for (const name of ['weather_kma', 'news_search', 'email_list', 'create_presentation',
'korean_law_search', 'sqlite_query', 'image_edit', 'schedule_job',
'coder_read_file', 'shell', 'excel_read', 'eonet_events']) {
assert.equal(f(name), false, `${name} 이(가) 인사말 턴에 노출됨`);
}
});
test('핵심 도구는 항상 남는다', () => {
const f = filterFor('안녕');
// 2026-09-04: memory_read를 memory_stats로 바꿨다. memory_read는 이제 기억 의도가 있는
// 턴에만 실린다(사용량 기준 게이팅). memory_stats는 벡터 스토어를 보므로 계속 무조건 남는다
// — 실제로 13회 호출된 유일한 메모리 도구다.
for (const name of ['web_search', 'web_fetch', 'memory_stats', 'python_eval']) {
assert.equal(f(name), true, `${name} 이(가) 사라짐`);
}
});
});
describe('키워드 게이트 — 필요할 때 확실히 나타난다', () => {
// 2026-08-25: 논문 도구가 pptx 키워드에만 묶여 있어서 "논문 찾아줘"로는 안 켜졌다.
// 실사용 로그에서 모델이 web_search로 때우는 게 확인돼 학술 의도를 게이트로 추가했다.
test('논문 검색 — 학술 키워드만으로 열린다 (발표자료 언급 없이도)', () => {
for (const msg of ['코로나 백신 논문 찾아줘', '환자 중심 치과 진료 관련 선행연구 있나',
'find papers on periodontitis', 'DOI로 이 문헌 좀 찾아줘']) {
const f = filterFor(msg);
assert.equal(f('openalex_search'), true, `openalex_search 가 "${msg}" 에서 안 열림`);
assert.equal(f('pubmed_search'), true, `pubmed_search 가 "${msg}" 에서 안 열림`);
}
});
test('발표자료 경로는 그대로 — 기존 pptx 게이트가 여전히 논문 도구를 연다', () => {
const f = filterFor('논문 모아서 발표자료 만들어줘', allSkills);
assert.equal(f('openalex_search'), true);
assert.equal(f('create_presentation'), true);
});
test('학술과 무관한 턴에는 논문 도구가 안 붙는다', () => {
for (const msg of ['오늘 날씨 어때', '안녕', '이메일 확인해줘']) {
const f = filterFor(msg);
assert.equal(f('openalex_search'), false, `openalex_search 가 "${msg}" 에 노출됨`);
assert.equal(f('pubmed_fulltext'), false, `pubmed_fulltext 가 "${msg}" 에 노출됨`);
}
});
test('뉴스 요청', () => {
assert.equal(filterFor('오늘 미국 주요 뉴스')('news_search'), true);
});
test('기본 날씨 2종은 스킬 없이 키워드만으로 열린다 — 일상 질문이므로', () => {
// "내일 날씨"는 전문가 기능이 아니다. 이 둘이 한국(관측)+해외(강수확률)를 커버한다.
const f = filterFor('내일 날씨 어때');
assert.equal(f('weather_kma'), true);
assert.equal(f('weather_openmeteo'), true);
});
test('"날씨"라는 단어 없이 묻는 날씨 질문 — 2026-08-12 실사고', () => {
// 실제 사고: "향후 비소식은 없나?"에 날씨 키워드가 하나도 안 걸려 weather_* 가 스키마에서
// 빠졌고, 모델은 web_search로 2019년 기사를 물어와 "예보를 확인할 수 없다"고 답했다.
// 이어서 "기상청에 알아봐"라고 명시했는데 그것도 안 걸려 두 번째 턴까지 같은 실패.
for (const msg of ['향후 비소식은 없나?', '오늘 영남지역 비소식 있나?', '기상청에 알아봐', '내일 비 와요?', '주말에 소나기 오나']) {
assert.equal(filterFor(msg)('weather_kma'), true, msg);
}
});
test('"기상현상 + 소식/예보/전망" 은 한 패턴으로 잡는다', () => {
// 현상별로 흩어 놓으면 다음 현상에서 또 빠진다 — 실제로 우박·안개·서리가 빠져 있었다.
for (const msg of ['비소식', '눈 소식', '태풍소식', '우박 소식', '안개 소식', '서리 소식', '바람 전망', '강수 상황']) {
assert.equal(filterFor(msg)('weather_kma'), true, msg);
}
});
test('"소식"·"전망" 단독은 날씨가 아니다', () => {
for (const msg of ['회사 소식 있어?', '업계 소식 알려줘', '새로운 소식 없나', '실적 전망 어때']) {
assert.equal(filterFor(msg)('weather_kma'), false, msg);
}
});
test('"비"는 문맥형으로만 잡는다 — 비용·비교·준비에 걸리면 안 된다', () => {
for (const msg of ['서버 비용이 얼마야', 'A와 B 비교해줘', '발표 준비 도와줘', '비트코인 시세']) {
assert.equal(filterFor(msg)('weather_kma'), false, msg);
}
});
// [2026-09-02] 스킬 토글은 레거시가 됐다 — UI가 앱별 게이팅으로 넘어가면서 아무도
// skills_state.json을 갱신하지 않는데, 이 토글이 도구를 **막는** 유일한 소비자로 남아
// 있었다. papa의 meteorologist:false 때문에 "오늘 미세먼지 어때?"가 weather_kma/openmeteo
// 만 받았고, weather_kma엔 대기질 데이터가 아예 없어서 실패 아니면 창작밖에 안 나왔다.
// 이제 키워드만으로 결정한다.
test('대기질 도구는 스킬 토글과 무관하게 날씨 키워드로 열린다', () => {
const f = filterFor('오늘 서울 미세먼지 어때?');
for (const n of ['weather_airkorea', 'weather_airpollution', 'weather_kma', 'weather_openmeteo']) {
assert.equal(f(n), true, `${n} 이(가) 레거시 토글 때문에 막힘`);
}
// 기후 아카이브는 여전히 별도 키워드를 요구한다
assert.equal(f('weather_era5'), false);
});
// [2026-09-02 효율 감사] ERA5/CDS/CMIP6/NASA POWER는 "오늘 날씨"가 아니라 기후 재분석·시나리오
// 아카이브다. 평범한 날씨 키워드에 같이 실려 일상 질문마다 2,878자 ≈ 820토큰을 태우고 있었다.
// 기후/평년/과거/추세 같은 별도 키워드를 요구한다 — 스킬이 켜져 있어도 마찬가지.
test('기후 아카이브는 기후 키워드까지 있어야 열린다', () => {
const climate = ['weather_era5', 'weather_cmip6', 'weather_cds', 'weather_nasa_power'];
const daily = buildSkillToolFilter({ ...allSkills, message: '오늘 서울 날씨 어때?' });
for (const n of climate) assert.equal(daily(n), false, `${n} 이(가) 일상 날씨 질문에 노출됨`);
// 일상 예보 도구는 그대로 열려야 한다
assert.equal(daily('weather_kma'), true);
assert.equal(daily('weather_openmeteo'), true);
const research = buildSkillToolFilter({ ...allSkills, message: '서울 평년 기온 추세랑 기후변화 영향 알려줘' });
for (const n of climate) assert.equal(research(n), true, `${n} 이(가) 기후 질문에 안 열림`);
});
test('날씨 키워드가 없으면 기본 날씨 도구도 안 열린다', () => {
assert.equal(filterFor('안녕')('weather_kma'), false);
assert.equal(filterFor('안녕')('weather_openmeteo'), false);
});
test('코드 관련 표현이면 편집/셸 도구가 열린다', () => {
const f = filterFor('이 파이썬 스크립트 디버그해줘');
assert.equal(f('coder_read_file'), true);
assert.equal(f('shell'), true);
});
test('이메일 / DB / 엑셀 / 일정', () => {
assert.equal(filterFor('메일 확인해줘')('email_list'), true);
assert.equal(filterFor('sqlite 쿼리 돌려줘')('sqlite_query'), true);
assert.equal(filterFor('엑셀 파일 읽어줘')('excel_read'), true);
assert.equal(filterFor('매일 아침에 반복 실행해줘')('schedule_job'), true);
});
test('첨부가 있어야 열리는 도구', () => {
assert.equal(filterFor('이거 읽어줘')('image_read'), false);
assert.equal(filterFor('![x](/api/files/a.png) 이거 읽어줘')('image_read'), true);
assert.equal(filterFor('report.pdf 요약해줘')('pdf_read'), true);
});
test('[2026-07-29] ollama_web_* 는 명시 요청에만 — 평소엔 214토큰 낭비였다', () => {
assert.equal(filterFor('RTX 5090 검색해줘')('ollama_web_search'), false);
assert.equal(filterFor('올라마로 검색해줘')('ollama_web_search'), true);
assert.equal(filterFor('ollama web search 해줘')('ollama_web_fetch'), true);
});
test('위성/재난 도구', () => {
assert.equal(filterFor('안녕')('satellite_snapshot'), false);
assert.equal(filterFor('산불 위성사진 보여줘')('satellite_snapshot'), true);
});
test('nvr_snapshot — CCTV 확인 요청에만 열린다', () => {
assert.equal(filterFor('안녕')('nvr_snapshot'), false);
assert.equal(filterFor('오늘 뭐 먹지')('nvr_snapshot'), false);
assert.equal(filterFor('마당에 누구 있어?')('nvr_snapshot'), true);
assert.equal(filterFor('택배 왔나 봐줘')('nvr_snapshot'), true);
assert.equal(filterFor('현관 카메라 확인해줘')('nvr_snapshot'), true);
assert.equal(filterFor('CCTV 좀 보자')('nvr_snapshot'), true);
});
// [2026-09-07] 실사고: "cctv확인해봐"로 도구가 한 번 돌았는데 다음 "아니다 cam2" / "사진
// 보여줘" 후속턴에 키워드가 없어 도구가 사라지자 모델이 "그런 툴 없습니다"를 3턴 반복.
test('nvr_snapshot — 직전 턴이 CCTV였으면 후속턴에도 열린 채로 둔다', () => {
const f = (msg: string, recentUserText: string) =>
buildSkillToolFilter({ ...base, message: msg, recentUserText })('nvr_snapshot');
assert.equal(f('아니다 cam2', '두번째 채널이 옥상인데 cctv확인해봐\n아니다 cam2'), true);
assert.equal(f('사진 보여줘', '두번째 채널이 옥상인데 cctv확인해봐\n아니다 cam2\n사진 보여줘'), true);
// CCTV 맥락이 전혀 없으면 여전히 닫혀 있다
assert.equal(f('사진 보여줘', '점심 뭐 먹었어\n사진 보여줘'), false);
});
// [2026-08-10] 실제 사고: "태풍 돌핀 지금 어디 있지?"에 일반 채팅에서 news_search와
// weather_map_screenshot(기상청/JMA 태풍 진로 지도) 둘 다 안 열려 있어서 web_search만
// 쓰다가 검색결과를 놓쳤다(실제로는 중국 상륙·100만명 대피 중이었는데 "이미 소멸했을
// 가능성" 답변). 위치/경로(weather_map_screenshot)와 피해상황(news_search) 둘 다
// 재해 키워드만으로 일반 채팅에서 열리게 했다 — "뉴스"라는 단어가 없어도.
test('재해 질문엔 일반 채팅에서도 weather_map_screenshot과 news_search가 열린다', () => {
const f = filterFor('태풍 돌핀 지금 어디 있지?');
assert.equal(f('weather_map_screenshot'), true, 'KMA/JMA 태풍 진로 지도가 안 열림');
assert.equal(f('news_search'), true, '"뉴스" 단어 없이도 재해 키워드로 news_search가 열려야 함');
});
test('재해 키워드 없는 일반 채팅에서는 여전히 닫혀 있다', () => {
const f = filterFor('오늘 날씨 어때');
assert.equal(f('weather_map_screenshot'), false);
assert.equal(f('news_search'), false);
});
});
describe('앱 세션 게이트 — 탭 안에서는 키워드 없이도 열려 있어야 한다', () => {
test('날씨 앱 탭에서는 후속 질문에 키워드가 없어도 도구가 유지된다', () => {
// "내일은?" 같은 후속엔 날씨 키워드가 없다 — 세션 접두사가 그걸 메운다.
const f = buildSkillToolFilter({ ...base, sessionId: 'wt_abc123', message: '내일은?' });
assert.equal(f('weather_kma'), true);
assert.equal(f('weather_map_screenshot'), true);
});
test('일반 세션에서는 windy 스크린샷이 열리지 않는다', () => {
assert.equal(buildSkillToolFilter({ ...allSkills, message: '날씨 알려줘' })('weather_map_screenshot'), false);
});
test('변호사 / 회계 앱 탭', () => {
assert.equal(buildSkillToolFilter({ ...base, sessionId: 'lw_1', message: '이거 봐줘' })('korean_law_search'), true);
assert.equal(buildSkillToolFilter({ ...base, sessionId: 'ac_1', message: '이거 봐줘' })('excel_read'), true);
});
});
describe('휴면 도구 — 실사용 0건이거나 이 플랫폼에서 동작하지 않는 것들', () => {
test('오케스트레이션/브라우저 도구는 어떤 문구로도 열리지 않는다', () => {
const f = filterFor('브라우저 열어서 작업 시작해줘');
assert.equal(f('spawn_subagent'), false);
assert.equal(f('start_task'), false);
assert.equal(f('browser_open'), false);
});
});
describe('selectToolsForTurn — 세션 종류별 분기', () => {
const tools = ['web_search', 'coder_read_file', 'image_edit', 'start_task', 'shell', 'memory_read']
.map(n => ({ type: 'function', function: { name: n } }));
const names = (r: any[]) => r.map(t => t.function.name).sort();
const flags = { isBootStartupTurn: false, isCodeAiSession: false, isTranslateSession: false, isProjSession: false };
test('부팅 턴은 읽기 전용 두 개만', () => {
const r = selectToolsForTurn(tools, { ...base, message: 'boot' }, { ...flags, isBootStartupTurn: true });
assert.deepEqual(names(r), ['coder_read_file']);
assert.ok(bootAllowedTools.has('coder_list_files'));
});
test('번역/프로젝트 세션은 도구 없음 — 엄격한 출력 형식을 도구 호출이 깨뜨리지 않게', () => {
assert.deepEqual(selectToolsForTurn(tools, { ...base, message: 'x' }, { ...flags, isTranslateSession: true }), []);
assert.deepEqual(selectToolsForTurn(tools, { ...base, message: 'x' }, { ...flags, isProjSession: true }), []);
});
test('코드 에디터 세션은 차단 목록만 제외하고 전부 통과 (키워드 게이트 미적용)', () => {
const r = selectToolsForTurn(tools, { ...base, message: '아무 말' }, { ...flags, isCodeAiSession: true });
// 키워드가 없어도 coder_/shell 이 살아있어야 한다 — 에디터에선 매 턴이 코드 맥락이다.
assert.ok(names(r).includes('coder_read_file'));
assert.ok(names(r).includes('shell'));
assert.ok(!names(r).includes('start_task'));
assert.ok(!names(r).includes('image_edit'));
assert.ok(codeAiBlockedTools.has('image_edit'));
});
test('일반 채팅은 키워드 필터를 탄다', () => {
const r = selectToolsForTurn(tools, { ...base, message: '안녕' }, flags);
// memory_read는 2026-09-04부터 기억 의도가 있는 턴에만 실린다 — 위 게이트 주석 참고.
assert.deepEqual(names(r), ['web_search']);
});
test('기억을 지시하면 memory_read가 다시 나타난다', () => {
const r = selectToolsForTurn(tools, { ...base, message: '이거 기억해둬' }, flags);
assert.ok(names(r).includes('memory_read'));
});
});
describe('후속 요청은 앞 턴의 주제를 물려받는다 — 날씨·뉴스 게이트만', () => {
// 2026-08-12 사용자 요청. "그럼 모레는?"에는 날씨 단어가 없지만 앞 턴이 날씨였으면
// 도구가 남아 있어야 한다. 이 게이트들은 "도구를 제공할까"만 정하므로, 오래된 매치가
// 남더라도 스키마 토큰 몇백 개가 낭비될 뿐 답이 틀리지는 않는다.
const withHistory = (recentUserText: string) =>
buildSkillToolFilter({ ...base, message: '그럼 모레는?', recentUserText });
test('앞 턴이 날씨면 후속 요청에도 날씨 도구가 남는다', () => {
assert.equal(withHistory('내일 날씨 어때\n그럼 모레는?')('weather_kma'), true);
});
test('앞 턴이 뉴스면 후속 요청에도 news_search가 남는다', () => {
assert.equal(withHistory('오늘 미국 뉴스\n더 보여줘')('news_search'), true);
});
test('앞 턴도 무관하면 열리지 않는다', () => {
assert.equal(withHistory('이 코드 고쳐줘\n그럼 모레는?')('weather_kma'), false);
});
test('recentUserText가 없으면 이전과 똑같이 동작한다', () => {
assert.equal(filterFor('그럼 모레는?')('weather_kma'), false);
assert.equal(filterFor('내일 날씨 어때')('weather_kma'), true);
});
test('날씨·뉴스 외의 게이트는 넓히지 않는다 — 두 턴 전 코딩 단어가 도구를 끌고 오면 안 된다', () => {
const f = buildSkillToolFilter({
...base, message: '오늘 날씨 어때', recentUserText: '파이썬 함수 버그 고쳐줘\n오늘 날씨 어때',
});
assert.equal(f('weather_kma'), true);
assert.equal(f('run_command'), false);
});
});
// [2026-09-02] 앱 탭은 세션 접두사로 무조건 게이팅한다 — 탭 안의 후속 질문("그럼 작년은?")은
// 키워드가 없어도 그 앱의 맥락이기 때문이다. detective(dt_)와 investor(iv_)는 접두사를
// 쓰면서도 게이트가 없어, 탭 안에서 전문 도구가 하나도 안 나왔다.
describe('앱 세션 게이트 — 탭 안에서는 키워드 없이도 열린다', () => {
const inApp = (sid: string, msg: string) =>
buildSkillToolFilter({ ...allSkills, message: msg, recentUserText: msg, sessionId: sid });
test('탐정 탭은 판례·법령 도구를 키워드 없이 받는다', () => {
const f = inApp('dt_case_1', '이 사건 관련 자료 더 찾아줘');
for (const n of ['korean_law_search', 'korean_law_fetch', 'us_case_search', 'news_search']) {
assert.equal(f(n), true, `${n} 누락`);
}
});
test('투자 탭은 뉴스·엑셀 도구를 키워드 없이 받는다', () => {
const f = inApp('iv_x1', '그럼 작년 대비 어떻게 됐지?');
for (const n of ['news_search', 'excel_read', 'excel_write']) {
assert.equal(f(n), true, `${n} 누락`);
}
});
test('닥터 탭은 학술 도구 + DUR 도구를 키워드 없이 받는다', () => {
for (const sid of ['dr_main', 'dr_case_42', 'dr_a1b2c3']) {
const f = inApp(sid, '이 약 장기복용해도 괜찮을까요?');
for (const n of ['pubmed_search', 'pubmed_fetch', 'pubmed_fulltext', 'openalex_search', 'semantic_search', 'drug_dur_check']) {
assert.equal(f(n), true, `${sid}: ${n} 누락`);
}
}
});
test('메인챗에서도 약물 상호작용 문맥이면 drug_dur_check는 열린다', () => {
assert.equal(inApp('sess_main', '와파린이랑 아스피린 같이 먹어도 되나요?')('drug_dur_check'), true);
assert.equal(inApp('sess_main', '이 약 병용금기 있어?')('drug_dur_check'), true);
// 학술 도구는 닥터 탭 밖에서는 여전히 키워드 필요
assert.equal(inApp('sess_main', '와파린이랑 아스피린 같이 먹어도 되나요?')('pubmed_search'), false);
});
test('메인챗은 영향받지 않는다 — 키워드 없으면 그대로 닫힘', () => {
const f = inApp('sess_main', '오늘 기분 어때?');
for (const n of ['korean_law_search', 'news_search', 'excel_read', 'pubmed_search', 'openalex_search', 'drug_dur_check']) {
assert.equal(f(n), false, `${n} 이(가) 메인챗에 새어나옴`);
}
});
});
/**
* cms_hospital_compare — 2026-09-04 세션 감사에서 나온 도달 실패.
*
* 사용자가 "미국의 영리의료법인 현황"으로 시작해 소유형태·비율·법인세율까지 8턴을 물었는데
* 이 도구는 한 번도 스코프에 들지 않았다. 게이트가 "병원"이라는 낱말에만 걸려 있었고, 사용자는
* 내내 "의료법인"이라고 썼기 때문이다("영리 병원의 비율" 한 턴만 우연히 통과했다). 그 사이
* 모델은 공공 비중을 45~50% → 39~40% → 약 50%로 턴마다 다르게 답했다 — 1차 데이터를 쓸 수
* 있었던 질문이다.
*/
describe('cms_hospital_compare — 소유형태 질문에 닿는가', () => {
test('사용자가 실제로 쓴 "의료법인" 어법으로도 켜진다', () => {
for (const m of [
'미국의 영리의료법인 현황',
'영리의료법인의 법인세율?',
'영리 병원의 비율이 얼마나 될까?',
'비영리 의료기관 성과 비교',
'for-profit hospital ownership',
]) {
assert.equal(filterFor(m)('cms_hospital_compare'), true, m);
}
});
test('관련 없는 병원 언급에는 여전히 안 붙는다', () => {
for (const m of ['병원 언제 가지', '치과 예약해줘', '주말 구미 날씨 어때?']) {
assert.equal(filterFor(m)('cms_hospital_compare'), false, m);
}
});
});
/**
* 2026-09-04 도달률 감사 — 실사용 1,011건 대조에서 나온 tool-scope 오탐 2건.
*/
describe('게이트 오탐 (2026-09-04 감사)', () => {
test('climate: "추세"/"장기"만으로 기후 아카이브 4종을 싣지 않는다', () => {
// "메모리 가격 추세"가 통과해 재분석 아카이브(~820토큰)를 통째로 실었다.
for (const m of ['메모리 가격 추세', 'GPU 가격 장기 전망']) {
assert.equal(filterFor(m)('weather_era5'), false, m);
}
});
test('climate: 기후 맥락이 있으면 그대로 켜진다', () => {
for (const m of ['서울 기온 장기 추세', '평년 대비 올해 강수량', '기후의 변동 소주기가 1500년쯤 되는 게 있나?']) {
assert.equal(filterFor(m)('weather_era5'), true, m);
}
});
test('kakao: 첨부 파일명이 메신저 발송 도구를 켜면 안 된다', () => {
// 카카오맵 캡처를 올렸을 뿐인데 kakao_send_message가 붙었다. executeTool은 스키마 소속을
// 확인하지 않으므로, 프롬프트가 이름만 언급해도 실제로 호출될 수 있는 종류의 도구다.
assert.equal(
filterFor('![KakaoMap_20260903_162614.png](/api/files/uploads/KakaoMap_20260903_162614.png)')('kakao_send_message'),
false,
);
assert.equal(filterFor('카카오맵으로 위치 보여줘')('kakao_send_message'), false);
});
test('kakao: 진짜 발송 요청은 계속 켜진다', () => {
assert.equal(filterFor('카카오톡으로 보내줘')('kakao_send_message'), true);
});
});
/**
* 수동 메모리 도구(USER.md/SOUL.md) — 2026-09-04 효율 감사.
*
* 근거는 순수하게 사용량이다: 실사용 1,011턴에서 browse 0 / read 2 / write 1회인데 매 턴
* 무조건 실려 357토큰을 냈다. 도구 자체는 멀쩡하다 — USER.md·SOUL.md는 workspace/prompts/
* 아래에 실제로 있고 resolvePromptPath가 거기서 찾는다.
*
* 제거가 아니라 게이팅인 이유: "기억해둬"라고 명시하는 턴에는 반드시 있어야 한다. 평상시
* 장기기억은 Chroma 자동 추출이 담당하지만 그건 하루 뒤 반영되는 배치라 대체재가 아니다.
*/
describe('수동 메모리 도구는 기억 의도가 있을 때만 (2026-09-04 감사)', () => {
const memTools = ['memory_browse', 'memory_write', 'memory_read'];
test('평범한 턴에는 셋 다 빠진다 — 매 턴 357토큰이던 자리', () => {
for (const m of ['안녕하세요', '주말 구미 날씨 어때?', '오늘 미국 뉴스', '소설 한 편 써줘']) {
const f = filterFor(m);
for (const t of memTools) assert.equal(f(t), false, `${m} / ${t}`);
}
});
test('기억을 지시하거나 조회하면 실린다', () => {
for (const m of [
'요거 우리집 위성사진인데 기억해둬', // 실사용 발화 — 이 턴에 없으면 안 된다
'이거 잊지 마',
'내 프로필에 저장해줘',
'제 생일 기억나?',
'remember that I prefer dark mode',
]) {
const f = filterFor(m);
for (const t of memTools) assert.equal(f(t), true, `${m} / ${t}`);
}
});
test('memory_stats는 벡터 스토어를 보므로 이 게이트와 무관하게 항상 남는다', () => {
// 실제로 13회 호출된 유일한 메모리 도구다("얼마나 기억하고 있어?" 류).
assert.equal(filterFor('안녕하세요')('memory_stats'), true);
});
test('앞 턴 주제를 잇는 후속 발화도 잡는다', () => {
assert.equal(
buildSkillToolFilter({ ...base, message: '그것도', recentUserText: '이거 기억해둬\n그것도' })('memory_write'),
true,
);
});
});