feat: ACTION-PROMISE 가드 — 실행 예고만 하고 도구 0개로 끝난 턴 1회 넛지
- 2026-09-25 작업실 실측(로봇 프로젝트 채팅): 모델이 '먼저 GitHub에서 관련 리포지토리를 찾겠습니다'만 내놓고 도구 0개로 턴 종료, 사용자가 '?'로 되물어도 사과+예고만 반복. deferral 넛지는 '잠시만 기다려'류만 잡고 실행형 요청은 아예 제외라 이 케이스가 두 망 다 통과했다. - isActionPromiseNoTool: 답 끝부분(마지막 220자)이 행동 동사+미래 서약 (~하겠습니다/~해 보겠습니다/~시작합니다) 또는 let me / I'll … 로 끝나는지. 과거형·의문형·의례적 맺음말은 제외, 800자 이상 완성 답변도 제외. - handleChat: 이번 턴 도구 호출 0개일 때만 1회 넛지(라운드 소진 방지), 로그줄 '[v2] ACTION-PROMISE POST-CHECK'가 발동 증거. 테스트 3건(실측 문구 포함) 추가. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 던진 추측을 근거 없이 확인해 주는 답변인가.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user