feat: WORKSHOP SAVE 가드 — 도구 없이 "저장했습니다" 헛보고를 강제 재유도로 차단
09-24 실측 2건: get(읽기)만 하고 add_note 없이 "메모탭에 저장했습니다" 보고.
프롬프트 규칙(b327eef)으로도 안 잡혀 MESSAGING POST-CHECK과 같은 패턴으로
가드 승격. ws_ 세션에서 저장 요청(메모/개요/부품+변경동사) + 답변이 저장
클레임(종결접미사 필수 — "저장했너?"/"저장했던" 제외) + 이번 턴에 변경
action(get/list 제외) 없음 → 1회 강제 재유도.
"이미 이전 턴에 저장돼 있습니다"는 재유도 답변에서 명시적으로 허용해
오탐(이전 턴 저장 상태 보고)이 무한루프 안 걸리게 함. 회귀테스트 7건.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -82,7 +82,7 @@ import { selectToolsForTurn, bootAllowedTools, codeAiBlockedTools } from './tool
|
||||
import { buildChatSystemPrompt, groundingResultReadingRule } from './system-prompt';
|
||||
import { replyLooksEmpty, appendDroppedSearchImages } from './reply-content';
|
||||
import { evaluateSearchBudget } from './search-budget';
|
||||
import { decideAutoRecover, shouldForceMessagingRetry as decideMessagingRetry, shouldForceEmptyGroundingRetry as decideEmptyGroundingRetry, GROUNDING_TOOL_PATTERN } from './retry-decisions';
|
||||
import { decideAutoRecover, shouldForceMessagingRetry as decideMessagingRetry, shouldForceWorkshopSaveRetry, shouldForceEmptyGroundingRetry as decideEmptyGroundingRetry, GROUNDING_TOOL_PATTERN } from './retry-decisions';
|
||||
import { logToolSkip } from './tool-skip-log';
|
||||
import { checkNumericGrounding } from './numeric-grounding';
|
||||
import { decideSearchAbandonmentRetry } from './search-abandonment';
|
||||
@@ -412,6 +412,8 @@ async function handleChat(
|
||||
let browserAdvisorCallsThisTurn = 0;
|
||||
let messagingForcedRetries = 0;
|
||||
const MAX_MESSAGING_FORCED_RETRIES = 2;
|
||||
let workshopSaveForcedRetries = 0;
|
||||
const MAX_WORKSHOP_SAVE_FORCED_RETRIES = 1;
|
||||
let emptyGroundingForcedRetries = 0;
|
||||
const MAX_EMPTY_GROUNDING_FORCED_RETRIES = 1;
|
||||
let numericGroundingForcedRetries = 0;
|
||||
@@ -1909,6 +1911,24 @@ async function handleChat(
|
||||
continue;
|
||||
}
|
||||
|
||||
// WORKSHOP SAVE POST-CHECK (2026-09-24). 메시징 가드와 같은 계열 — 도구 없이
|
||||
// "저장했습니다" 헛보고. 09-24 실측 2건(get만 하고 add_note 없이 저장 보고)이
|
||||
// 프롬프트 규칙(b327eef)으로도 안 잡혀 가드로 승격. 1회 강제 재유도로 묶어
|
||||
// 오탐 비용도 한 라운드로 한정한다.
|
||||
if (isWorkshopAppSession
|
||||
&& shouldForceWorkshopSaveRetry({ message, candidateText, toolResults: allToolResults, workshopSession: true })
|
||||
&& workshopSaveForcedRetries < MAX_WORKSHOP_SAVE_FORCED_RETRIES) {
|
||||
workshopSaveForcedRetries++;
|
||||
console.log(`[v2] WORKSHOP SAVE POST-CHECK (${workshopSaveForcedRetries}/${MAX_WORKSHOP_SAVE_FORCED_RETRIES}): claimed a dashboard save without a mutating workshop_project call. Re-prompting...`);
|
||||
sendSSE('info', { message: 'Re-prompting model: verify the dashboard save with a real tool call...' });
|
||||
messages.push({ role: 'assistant', content: candidateText });
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: 'You claimed the workshop dashboard was updated, but this turn you never made a mutating workshop_project call (add_note/add_part/update_part/…). A get/list call does not save anything. Either make the real call now, or — if the content was already saved in an earlier turn — say exactly that ("이미 이전 턴에 저장돼 있습니다") without phrasing it as a fresh save.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const groundingResultsThisTurn = allToolResults.filter((r) =>
|
||||
GROUNDING_TOOL_PATTERN.test(String(r?.name || '')),
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
isDesktopAutomationRequest,
|
||||
isMessagingRequest,
|
||||
claimsMessageSent,
|
||||
isWorkshopMutationRequest,
|
||||
claimsWorkshopSaved,
|
||||
isUsableGroundingResult,
|
||||
} from '../guards/prompt-gates';
|
||||
|
||||
@@ -170,6 +172,36 @@ export function shouldForceMessagingRetry(input: MessagingRetryInput): boolean {
|
||||
&& !sendToolCalled;
|
||||
}
|
||||
|
||||
// --- Workshop 저장 헛보고 가드 (2026-09-24) -------------------------------------------
|
||||
// 09-24 실측 2건: 모델이 get(읽기)만 하고 add_note 없이 "메모탭에 저장했습니다"라고 보고.
|
||||
// workshop_project 호출 자체가 있는지가 아니라 "변경 action"이었는지를 본다 — get/list는
|
||||
// 읽기라서 저장을 대변하지 못한다.
|
||||
export interface WorkshopSaveRetryInput {
|
||||
message: string;
|
||||
candidateText: string;
|
||||
toolResults: readonly { name?: string; error?: boolean; args?: any }[];
|
||||
/** ws_ 세션(작업실 채팅)에서만 발동. */
|
||||
workshopSession: boolean;
|
||||
}
|
||||
|
||||
export const WORKSHOP_MUTATING_ACTIONS = new Set([
|
||||
'create_project', 'set_active_project', 'set_description',
|
||||
'add_part', 'update_part', 'delete_part',
|
||||
'add_task', 'set_task_done', 'add_phase',
|
||||
'add_note', 'add_link', 'delete_link',
|
||||
]);
|
||||
|
||||
export function shouldForceWorkshopSaveRetry(input: WorkshopSaveRetryInput): boolean {
|
||||
if (!input.workshopSession) return false;
|
||||
const mutatingCalled = input.toolResults.some(
|
||||
r => r?.name === 'workshop_project' && !r?.error
|
||||
&& WORKSHOP_MUTATING_ACTIONS.has(String(r?.args?.action || '')),
|
||||
);
|
||||
return isWorkshopMutationRequest(String(input.message || ''))
|
||||
&& claimsWorkshopSaved(String(input.candidateText || ''))
|
||||
&& !mutatingCalled;
|
||||
}
|
||||
|
||||
export interface EmptyGroundingInput {
|
||||
message: string;
|
||||
/** Tool results this turn; only grounding tools are considered. */
|
||||
|
||||
@@ -372,6 +372,23 @@ export function claimsMessageSent(text: string): boolean {
|
||||
|| /\b(sent the (email|message)|email (has been|was) sent|message (has been|was) sent)\b/i.test(s);
|
||||
}
|
||||
|
||||
// --- Workshop 저장 헛보고 가드 (2026-09-24) -------------------------------------------
|
||||
// 메시징 가드와 같은 계열: 모델이 workshop_project를 호출하지 않고 "메모탭에 저장했습니다"
|
||||
// 라고 보고하면 사용자는 실제로 저장됐다고 믿고 끝내버린다. 09-24 실측 2건(도표 정리 턴이
|
||||
// get만 하고 add_note 없이 저장 보고). 프롬프트 규칙(b327eef)으로도 안 잡혀 가드로 승격.
|
||||
export function isWorkshopMutationRequest(message: string): boolean {
|
||||
const m = String(message || '');
|
||||
return /(메모|개요|부품|작업|단계|프로젝트|작업실|readme)/i.test(m)
|
||||
&& /(저장|추가|등록|업데이트|수정|삭제|옮기|정리|바꿔|변경|넣어|체크)/.test(m);
|
||||
}
|
||||
|
||||
export function claimsWorkshopSaved(text: string): boolean {
|
||||
const s = String(text || '');
|
||||
// 종결접미사(습니다/어요/어/다) 필수 — "저장했너?"/"저장했던" 같은 질문형·수식형은 안 걸린다.
|
||||
return /(저장|등록|추가|업데이트|수정|반영)(했|됐|되었)(습니다|어요|어\b|다\b)|저장\s*완료|반영\s*완료|저장해\s?드렸습니다|넣어\s?드렸습니다|등록\s*완료/.test(s)
|
||||
|| /\b(saved (it|to the)|has been saved|was saved)\b/i.test(s);
|
||||
}
|
||||
|
||||
// --- Step 3 (2026-07-25): task-control intent gates, moved here from server-v2.ts for the
|
||||
// same one-place-to-scan reason as Step 2.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
decideAutoRecover,
|
||||
shouldForceMessagingRetry,
|
||||
shouldForceWorkshopSaveRetry,
|
||||
shouldForceEmptyGroundingRetry,
|
||||
} from '../src/gateway/chat/retry-decisions';
|
||||
|
||||
@@ -191,6 +192,62 @@ describe('MESSAGING — 보냈다는 거짓 주장 차단', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('WORKSHOP SAVE — 저장했다는 거짓 주장 차단 (09-24 실측 2건)', () => {
|
||||
// 실측: get만 하고 add_note 없이 "메모탭에 저장했습니다"라고 보고한 도표 정리 턴.
|
||||
const read = [{ name: 'workshop_project', args: { action: 'get' }, error: false }];
|
||||
const ws = { workshopSession: true };
|
||||
|
||||
test('get만 하고 저장했다 주장 → 재시도 (09-24 실측 그대로)', () => {
|
||||
assert.equal(shouldForceWorkshopSaveRetry({
|
||||
...ws, message: '메모를 보기 쉽게 도표 형태로 다시 정리',
|
||||
candidateText: '방금 정리해 드린 도표 내용을 프로젝트 메모 탭에 추가 저장했습니다.',
|
||||
toolResults: read,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('에러난 add_note도 저장이 아니다 — 재시도', () => {
|
||||
assert.equal(shouldForceWorkshopSaveRetry({
|
||||
...ws, message: '메모 저장해줘', candidateText: '저장했습니다.',
|
||||
toolResults: [{ name: 'workshop_project', args: { action: 'add_note' }, error: true }],
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('실제로 add_note를 호출했으면 재시도 없음', () => {
|
||||
assert.equal(shouldForceWorkshopSaveRetry({
|
||||
...ws, message: '메모 저장해줘', candidateText: '저장했습니다.',
|
||||
toolResults: [{ name: 'workshop_project', args: { action: 'add_note' }, error: false }],
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('저장 클레임 없는 답변은 재시도 없음', () => {
|
||||
assert.equal(shouldForceWorkshopSaveRetry({
|
||||
...ws, message: '메모를 도표 형태로 정리해줘', candidateText: '도표로 정리했습니다.',
|
||||
toolResults: read,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('질문형/수식형("저장했너?", "저장했던")은 클레임이 아니다', () => {
|
||||
assert.equal(shouldForceWorkshopSaveRetry({
|
||||
...ws, message: '메모를 정리해줘', candidateText: '메모탭에 저장했너? 저장했던 메모를 보여드릴까요?',
|
||||
toolResults: read,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('미래시제("저장하겠습니다")는 클레임이 아니다', () => {
|
||||
assert.equal(shouldForceWorkshopSaveRetry({
|
||||
...ws, message: '메모를 정리해줘', candidateText: '도표 형태로 정리해서 저장하겠습니다.',
|
||||
toolResults: read,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('ws_ 세션이 아니면 발동하지 않는다', () => {
|
||||
assert.equal(shouldForceWorkshopSaveRetry({
|
||||
workshopSession: false, message: '메모를 정리해서 저장해줘',
|
||||
candidateText: '저장했습니다.', toolResults: read,
|
||||
}), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EMPTY-GROUNDING — 빈 검색결과로 답한 경우', () => {
|
||||
test('검색은 했지만 전부 빈 결과면 재시도', () => {
|
||||
assert.equal(shouldForceEmptyGroundingRetry({
|
||||
|
||||
Reference in New Issue
Block a user