diff --git a/src/gateway/chat/handle-chat.ts b/src/gateway/chat/handle-chat.ts index fed2e4d..72fcf86 100644 --- a/src/gateway/chat/handle-chat.ts +++ b/src/gateway/chat/handle-chat.ts @@ -22,6 +22,7 @@ import { hasConcreteCompletion, isIntentOnlyReply, isDeferralPromiseReply, + isActionPromiseNoTool, hasUnversionedNewestIntent, assumedGenerationTokenInQuery, isMessagingRequest, @@ -444,6 +445,7 @@ async function handleChat( // 직전 라운드가 가드 재프롬프트였는지 — 예고만 한 응답을 걸러낼 때 이 조건으로 좁힌다. let lastRoundWasGuardReprompt = false; let intentOnlyNudges = 0; + let actionPromiseNudges = 0; const MAX_NUMERIC_GROUNDING_FORCED_RETRIES = 1; let searchAbandonmentForcedRetries = 0; const MAX_SEARCH_ABANDONMENT_FORCED_RETRIES = 1; @@ -1906,6 +1908,28 @@ async function handleChat( } + // ACTION-PROMISE POST-CHECK (2026-09-25). 작업실 실측(로봇 프로젝트 채팅): "먼저 GitHub에서 + // 관련 리포지토리를 찾겠습니다"만 내놓고 도구 0개로 턴 종료 — 사용자가 "?"로 되물어야 사과 + // 또 예고만 반복. deferral 넛지는 "잠시만 기다려"류만 잡고 실행형 요청은 아예 제외라 이 + // 케이스가 두 망 다 통과했다. 행동 예고로 끝났는데 이번 턴 도구 호출이 0개면 1회 넛지 — + // 지금 실행하게 하거나, 못 하는 사정이 있으면 예고 대신 지금 말하게. + const shouldNudgeActionPromise = + actionPromiseNudges < 1 + && allToolResults.length === 0 + && isActionPromiseNoTool(candidateText); + if (shouldNudgeActionPromise) { + actionPromiseNudges++; + console.log(`[v2] ACTION-PROMISE POST-CHECK: promised an action ("...${candidateText.slice(-70).trim()}") but made 0 tool calls this turn. Nudging once...`); + sendSSE('info', { message: '모델이 실행 예고만 하고 멈췄습니다 — 실제 실행을 요청합니다...' }); + messages.push({ role: 'assistant', content: candidateText }); + messages.push({ + role: 'user', + content: 'You announced an action (search/download/create/…) but the turn ended with zero tool calls — the user only sees the promise, which reads as if you stopped mid-answer. Do it now: make the real tool call, then report the result. If you cannot or should not perform it, say so plainly right now instead of promising.', + }); + lastRoundWasGuardReprompt = false; + continue; + } + const shouldForceBrowserRetry = orchestrationSkillEnabled && browserContinuationPending diff --git a/src/gateway/guards/prompt-gates.ts b/src/gateway/guards/prompt-gates.ts index 30cccc7..c5ceb97 100644 --- a/src/gateway/guards/prompt-gates.ts +++ b/src/gateway/guards/prompt-gates.ts @@ -513,6 +513,28 @@ export function isDeferralPromiseReply(text: string): boolean { return /잠(시|깐)\s*(만)?\s*(기다려|대기|후에|뒤에|만요)|기다려\s*주(세요|십시오)|곧\s*(알려|말씀|정리해|공유)|잠시\s*후\s*(다시|알려)|조금만\s*기다|hold on\b|one moment\b|give me a (moment|sec|minute)|bear with me|be right back|will (get back|come back) to you|let me get back to you/i.test(s); } +/** + * 행동 예고("먼저 검색을 시작합니다", "찾아보겠습니다", "Let me search…")로 끝나는 답변. + * + * 2026-09-25 작업실 실측(로봇 프로젝트 채팅): 사용자가 "필요한 파일들 다운로드해서 프로젝트를 + * 채우자"라고 하자 모델이 "먼저 GitHub 등에서 관련 리포지토리를 찾겠습니다"만 내놓고 도구 + * 0개로 턴 종료 — 사용자가 "?"로 되물어서야 사과하고 또 예고만 반복. isDeferralPromiseReply는 + * "잠시만 기다려" 류만 잡고, 호출부의 deferral 넛지는 isExecutionLikeRequest를 아예 제외하므로 + * 이 케이스(실행형 요청 + 명시적 실행 예고)가 두 망 다 통과했다. + * + * 잡는 범위: 답변 **끝부분**이 행동 동사+미래 서약(~하겠습니다/~해 보겠습니다/~시작합니다) 또는 + * 영어 let me / I'll … 로 끝난다. 호출부(handleChat)가 "이번 턴 도구 호출 0개"를 보장한다 — + * 도구를 이미 썼다면 예고 문구가 남아도 실행한 것이므로 건드리지 않는다. + */ +export function isActionPromiseNoTool(text: string): boolean { + const s = String(text || '').trim(); + if (!s || s.length > 800) return false; + const tail = s.slice(-220); + return ( + /(검색|찾|조사|다운로드|받아|불러|만들|생성|저장|정리|수집|모으|확인|시작|진행|시도|실행)[가-힣\s]{0,5}겠(습니다|다)|시작합니다|(?:let\s+me|i'?ll|i\s+will)\s+(?:search|find|check|look|download|fetch|grab|read|create|make|try)\b/i.test(tail) + ); +} + /** * 사용자가 던진 추측을 근거 없이 확인해 주는 답변인가. * diff --git a/tests/prompt-gates.test.ts b/tests/prompt-gates.test.ts index 6f0a2e0..cb8378f 100644 --- a/tests/prompt-gates.test.ts +++ b/tests/prompt-gates.test.ts @@ -31,6 +31,7 @@ import { isDisasterRequest, isFactualThreadFollowUp, confirmsUserGuessWithoutGrounding, + isActionPromiseNoTool, } from '../src/gateway/guards/prompt-gates'; describe('looksLikeUnverifiedSpecClaim — 출력에서 검증 안 된 수치 감지', () => { @@ -503,3 +504,32 @@ describe('도달률 감사 회귀 (2026-09-04)', () => { }); }); + +// ── isActionPromiseNoTool (2026-09-25) ───────────────────────────────────── +// 작업실 실측(로봇 프로젝트 채팅): "먼저 GitHub에서 관련 리포지토리를 찾겠습니다"만 내놓고 +// 도구 0개로 턴 종료. deferral 넛지가 "잠시만 기다려"류만 잡고 실행형 요청은 제외라 두 망 다 +// 통과한 케이스. 아래 실측 문구는 로그에서 그대로 가져왔다. +describe('isActionPromiseNoTool — 행동 예고 후 도구 0개 턴', () => { + test('오늘 실측 문구들', () => { + assert.equal(isActionPromiseNoTool( + '죄송합니다. 말만 하고 실제 행동(도구 호출)을 하지 않았습니다. 바로 LanderPi의 오픈소스 자료(URDF, ROS2 패키지, 설계 파일 등)를 검색해서 프로젝트 폴더에 채워 넣겠습니다.\n\n먼저 GitHub 등에서 관련 리포지토리를 찾겠습니다.', + ), true); + assert.equal(isActionPromiseNoTool('먼저 검색을 시작합니다.'), true); + assert.equal(isActionPromiseNoTool('Let me search GitHub for the repository.'), true); + assert.equal(isActionPromiseNoTool("I'll download the URDF files into the project folder."), true); + assert.equal(isActionPromiseNoTool(' STL을 컴파일해서 CAD/에 저장하겠습니다'), true); + }); + + test('오탐 방지: 과거형·의문형·의례적 맺음말', () => { + assert.equal(isActionPromiseNoTool('네, 방금 전 턴에 이미 저장했습니다.'), false); + assert.equal(isActionPromiseNoTool('어떤 파일을 찾아드릴까요?'), false); + assert.equal(isActionPromiseNoTool('더 필요하시면 말씀드리겠습니다.'), false); + assert.equal(isActionPromiseNoTool('검색 결과가 없습니다. 다른 키워드를 시도해 보세요.'), false); + assert.equal(isActionPromiseNoTool('다운로드는 완료됐습니다. 파일은 CAD/에 있습니다.'), false); + }); + + test('긴 답변(>800자)은 대상 아님 — 완성된 답을 넛지하지 않는다', () => { + const long = '정상 답변 본문. '.repeat(95) + '먼저 검색을 시작합니다.'; + assert.equal(isActionPromiseNoTool(long), false); + }); +});