FBZ_20 — 조기 종료 차단 훅 (finish-the-work)

한 줄 요약

에이전트가 “이제 ~하겠습니다”라고 말만 하고 멈추는 순간을 정규식으로 잡아내 다시 일하게 떠미는 결정론적 Stop 훅이다.

왜 배우나: AI가 “할게요”만 하고 끝내는 게으름은 가장 흔한 실패다. 이걸 LLM 판단 없이 기계적으로 막는 법을 배운다.

그림

flowchart TD
    A["모델이 턴을 끝내려 함<br/>Stop 이벤트 발화"] --> B{"이미 한 번<br/>강제 재개했나?<br/>stop_hook_active"}
    B -- 예 --> Z["통과: 정상 종료 허용"]
    B -- 아니오 --> C["대화기록 jsonl에서<br/>마지막 어시스턴트 메시지 추출"]
    C --> D{"툴콜로 끝났거나<br/>텍스트가 없나?"}
    D -- 예 --> Z
    D -- 아니오 --> E[꼬리 400자만 검사]
    E --> F{"미래형 약속<br/>패턴이 있나?"}
    F -- 없음 --> Z
    F -- 있음 --> G{"질문으로<br/>끝났나?"}
    G -- 예 정당한 종료 --> Z
    G -- 아니오 --> H["block 출력<br/>턴 강제 재개"]
    H --> I["모델에게 reason 주입<br/>지금 툴콜로 실제로 하라"]

쉽게 풀기

상황 비유. 일을 시켰더니 직원이 “네, 지금 바로 보고서 작성하겠습니다!”라고 말하고는 그대로 퇴근해 버립니다. 다음 날 와 보니 보고서는 없습니다. 말은 했지만 행동은 안 한 것이죠. 이 훅은 그 직원이 퇴근하려는 문 앞에 선 경비원입니다. “방금 ‘하겠다’고 했는데 실제로 한 게 없네요. 다시 자리로 돌아가서 지금 하세요.” 하고 돌려보냅니다.

왜 LLM에게 안 물어보고 정규식을 쓰나? “이 응답이 게으른 응답인가요?”를 다시 AI에게 물어보면, 그 판단도 매번 들쭉날쭉합니다. 대신 글자 패턴을 기계적으로 검사합니다. 같은 입력이면 언제나 같은 결과가 나옵니다(결정론적). 예측 가능하고 디버깅이 쉽습니다.

경비원이 검사하는 4가지 단계.

  1. 무한루프 방지 도장 확인 — 이미 한 번 “돌려보냈던” 응답이면(stop_hook_active=True) 또 막지 않습니다. 한 번 떠밀었는데도 또 막으면 영원히 못 끝내니까요. 딱 1회만 개입합니다.
  2. 마지막 말만 꺼내기 — 대화 기록 파일에서 모델이 가장 마지막에 한 어시스턴트 메시지의 텍스트와 “툴(도구)을 호출하며 끝났는지”를 봅니다. 툴콜로 끝났다면 아직 일하는 중이니 통과.
  3. 꼬리 400자만 검사 — 보고서 전체가 아니라 닫는 문단만 봅니다. 본문에 “예전에 작성했던 코드”처럼 과거형 서술이 있어도 오해(오탐)하지 않으려고요. 약속은 항상 마지막에 나오니까요.
  4. 약속 잡고, 질문은 봐주기 — “I’ll ~ now / let me ~ implement” 같은 미래형 의도 표현이 있으면 약속으로 봅니다. 단, 문장이 물음표나 “원하시나요?”로 끝나면 사용자에게 결정을 넘긴 정당한 종료이므로 통과시킵니다.

이 네 단계를 다 통과해 “약속만 하고 질문도 안 했다”면, 경비원은 block을 외치고 모델에게 “말 말고 지금 툴콜로 해라”라는 메시지를 다시 밀어 넣습니다.

핵심 정리

검사 항목무엇을 보나통과/차단
stop_hook_active이미 1회 떠밀었나True면 통과
마지막 메시지툴콜로 끝났나·텍스트 있나둘 중 하나면 통과
약속 패턴미래형 의도 표현없으면 통과
질문 패턴물음표·“원하시나요”있으면 통과

판정 진리표 (마지막 4가지 조합)

  • 툴콜로 끝남 → 통과 (아직 작업 중)
  • 텍스트 없음 → 통과 (빈 응답)
  • 약속 없음 → 통과 (게으른 약속 아님)
  • 약속 있음 + 질문으로 끝남 → 통과 (사용자에게 공을 넘긴 정당한 종료)
  • 약속 있음 + 질문 아님 → 차단(block) → 턴 강제 재개

자동 등록되지 않는다

이 Stop 훅은 hooks.json에 자동 등록되지 않습니다. router.sh만 자동 등록되고, 이 훅은 “전역에 이미 등록돼 있는 경우가 많아 중복 방지를 위해 자동 등록하지 않는다”는 setup.sh 안내가 있습니다. 필요하면 직접 Stop 훅으로 등록해야 합니다.

알려진 한계

평서문 제안(“원하시면 보고서를 작성하겠습니다”)을 약속으로 오인할 수 있습니다. 이 경우 제안을 물음표로 끝내면 질문 패턴(asks_user)에 걸려 통과됩니다.

실제 예시

입출력 계약과 루프 가드, 트랜스크립트 추출:

# hooks/finish-the-work.sh — Stop 훅
# stdin: JSON { transcript_path, stop_hook_active, ... }
# stdout: {"decision":"block","reason":"..."} → 턴 계속(재개) / 그 외 빈 출력(exit 0) → 정상 종료 허용
set -e
input=$(cat)
# 이미 한 번 재개를 강제했으면 다시 막지 않음(무한루프 방지)
active=$(printf '%s' "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('stop_hook_active', False))" 2>/dev/null || echo "False")
if [ "$active" = "True" ]; then exit 0; fi
tpath=$(printf '%s' "$input" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null || echo "")
if [ -z "$tpath" ] || [ ! -f "$tpath" ]; then exit 0; fi

판정 로직 (Python 휴리스틱):

# hooks/finish-the-work.sh 내부 — 마지막 assistant 메시지의 텍스트와 "툴콜로 끝났는지" 추출
# ... (transcript jsonl 라인별 파싱, last_text / last_had_tool 채움) ...
 
# 툴콜로 끝났거나(아직 작업중) 텍스트가 없으면 → 조기종료 아님
if last_had_tool or not last_text:
    sys.exit(0)
 
tail = last_text[-400:]   # 보고서 전체가 아니라 "닫는 문단"만 검사
 
# 미이행 약속 패턴(영어+한국어). 미래/의도만, 과거형은 제외.
promise = re.search(
    r"\b(I'?ll|I will|let me|next,? I|now I'?ll)\b[^.]{0,60}\b(now|next|then|implement|create|write|add|run|fix|save|build|start|proceed)\b",
    tail, re.IGNORECASE)
 
# 사용자에게 질문하며 끝나는 정당한 종료는 통과
asks_user = re.search(
    r"(\?|shall i|would you like|do you want|let me know|which option)",
    tail, re.IGNORECASE)
 
if promise and not asks_user:
    out = {"decision": "block",
           "reason": "Your previous response ended by stating an intent to do work without actually doing it. "
                     "Do that work now with tool calls. End the turn only when the task is complete or you are "
                     "blocked on input that only the user can provide."}
    print(json.dumps(out, ensure_ascii=False))
sys.exit(0)

직접 만들 때 쓰는 최소 템플릿:

#!/bin/bash
# my-stop-guard.sh
set -e; input=$(cat)
active=$(printf '%s' "$input" | python3 -c "import sys,json;print(json.load(sys.stdin).get('stop_hook_active',False))" 2>/dev/null||echo False)
[ "$active" = "True" ] && exit 0
tpath=$(printf '%s' "$input" | python3 -c "import sys,json;print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null||echo "")
[ -z "$tpath" ] || [ ! -f "$tpath" ] && exit 0
python3 - "$tpath" <<'PY'
import sys,json,re
# ... 마지막 assistant 텍스트/툴콜 추출 ...
tail = last_text[-400:]
if (re.search(r"\b(I'?ll|let me|next,? I)\b.{0,60}\b(now|implement|run|fix)\b", tail, re.I)
        and not re.search(r"(\?|shall i|would you like)", tail, re.I)):
    print(json.dumps({"decision":"block","reason":"지금 툴콜로 실제로 수행하세요."}, ensure_ascii=False))
PY

직접 만들 때 체크리스트

  • stop_hook_active로 무한루프 차단 (1회만 개입)
  • 꼬리 N자만 검사 (본문 과거형 오탐 ↓)
  • 의도(미래형)만 매칭, 과거형은 제외
  • 질문으로 끝나는 종료는 예외 처리
  • 어떤 경우든 항상 exit 0

요약 & 셀프체크

  1. 이 훅은 “하겠다”고 말만 하고 멈추는 조기 종료를 정규식으로 잡아 모델을 다시 일하게 만든다.
  2. 마지막 응답의 꼬리 400자만 보고, 미래형 약속이 있으면서 질문으로 끝나지 않을 때만 block을 낸다.
  3. stop_hook_active1회만 개입해 무한루프를 막고, 자동 등록은 하지 않는다.
  • 왜 LLM에게 “이거 게으른 응답이야?”라고 묻지 않고 정규식을 쓸까?
  • 본문 전체가 아니라 마지막 400자만 검사하는 이유는?
  • “원하시면 보고서를 작성하겠습니다”가 차단되지 않으려면 어떻게 쓰면 되나?

연결

FBZ_개요 · _분석축_루브릭 · FBZ_10_훅-라우터-주입 · FBZ_50_멀티스토리-증거게이트


근거 파일

  • /home/seunghyeong/harness-fablize/hooks/finish-the-work.sh
  • 등록 정책 안내: /home/seunghyeong/harness-fablize/setup/setup.sh (55–57행)