From eaa34714cf2d5d7ec5470f5184258d5c24a4e7c5 Mon Sep 17 00:00:00 2001 From: kim Date: Thu, 24 Sep 2026 09:15:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20WORKSHOP=20SAVE=20=EA=B0=80=EB=93=9C=20?= =?UTF-8?q?=E2=80=94=20=EB=8F=84=EA=B5=AC=20=EC=97=86=EC=9D=B4=20"?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=96=88=EC=8A=B5=EB=8B=88=EB=8B=A4"=20?= =?UTF-8?q?=ED=97=9B=EB=B3=B4=EA=B3=A0=EB=A5=BC=20=EA=B0=95=EC=A0=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=9C=A0=EB=8F=84=EB=A1=9C=20=EC=B0=A8=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 09-24 실측 2건: get(읽기)만 하고 add_note 없이 "메모탭에 저장했습니다" 보고. 프롬프트 규칙(b327eef)으로도 안 잡혀 MESSAGING POST-CHECK과 같은 패턴으로 가드 승격. ws_ 세션에서 저장 요청(메모/개요/부품+변경동사) + 답변이 저장 클레임(종결접미사 필수 — "저장했너?"/"저장했던" 제외) + 이번 턴에 변경 action(get/list 제외) 없음 → 1회 강제 재유도. "이미 이전 턴에 저장돼 있습니다"는 재유도 답변에서 명시적으로 허용해 오탐(이전 턴 저장 상태 보고)이 무한루프 안 걸리게 함. 회귀테스트 7건. Co-Authored-By: Claude Code --- src/gateway/chat/handle-chat.ts | 22 ++++++++++- src/gateway/chat/retry-decisions.ts | 32 ++++++++++++++++ src/gateway/guards/prompt-gates.ts | 17 +++++++++ tests/retry-decisions.test.ts | 57 +++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/gateway/chat/handle-chat.ts b/src/gateway/chat/handle-chat.ts index eafa0f9..a8f9563 100644 --- a/src/gateway/chat/handle-chat.ts +++ b/src/gateway/chat/handle-chat.ts @@ -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 || '')), ); diff --git a/src/gateway/chat/retry-decisions.ts b/src/gateway/chat/retry-decisions.ts index 5443b32..2e7e03d 100644 --- a/src/gateway/chat/retry-decisions.ts +++ b/src/gateway/chat/retry-decisions.ts @@ -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. */ diff --git a/src/gateway/guards/prompt-gates.ts b/src/gateway/guards/prompt-gates.ts index dddc2c3..6e43df4 100644 --- a/src/gateway/guards/prompt-gates.ts +++ b/src/gateway/guards/prompt-gates.ts @@ -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. diff --git a/tests/retry-decisions.test.ts b/tests/retry-decisions.test.ts index 186f9f3..21d69ba 100644 --- a/tests/retry-decisions.test.ts +++ b/tests/retry-decisions.test.ts @@ -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({