Files
dsh_ai1net_server/.workbuddy/待落地/stop-dialog-guard.py
T

205 lines
9.6 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""stop-dialog-guard.py —— 「禁止用征询句收尾」的 Stop 钩子(WorkBuddy / CodeBuddy)
为什么需要它
────────────
2026-09-15 实测:本工作区日志里 `tool=AskUserQuestion` 调用数 = 09-12: 43 / 09-13: 3 / **09-14: 0 / 09-15: 0**
⇒ 既有「提问闸门」(PreToolUse + matcher ^AskUserQuestion$)**拦的是几乎不走的工具面**,
而真实的上抛("要我接着做吗 / 请确认 / 说一声即可")发生在**正文里** —— 没有任何机制覆盖。
本钩子 = 覆盖那条面:**每次回复结束时**读 transcript 的**最后一条 assistant 文本**,
只扫**收尾段**(最后两行有效内容)里的征询句式;命中 → 返回 `{"continue": false, "reason": …}`
让 Agent **继续一轮并自我纠正**(把该自己做的事做掉,或改写成「需要你拍板」一节)。
安全设计(都不许省)
────────────────────
1. **自作用域**:只在 `transcript_path` 落在本工作区(`aliyun-dsh-server`)时生效,其他项目一律放行。
2. **防死循环**:输入里的 `stop_hook_active == true` 时**不再阻拦**(官方语义:本次停止已由 stop hook 触发过)。
3. **绝不添乱**:任何异常 → 静默放行(exit 0)。判定只在**收尾段**做,避免正文引用规则时误伤。
4. **性能**:只读转录**末尾 256 KB**(实测整库最大转录 31.9 MB、全文读 14 MB ≈ 832 ms ⇒ 不可接受),只看 stdin + 该文件。
5. **防跑飞**:同一会话 600 秒内最多拦**一次**。
6. **急停双闸**(无需卸载/重启):env `DSH_STOP_GUARD_OFF=1`,或新建 `<工作区>/.workbuddy/stop-guard.disabled`。
7. **低频自证日志**:命中才写一行(`<工作区>/.workbuddy/stop-dialog-guard.log`),用来回答"到底有没有触发"。
退出码:始终 0;决策通过 stdout 的 JSON 表达。
安装(settings.json 的 hooks 段 · 见档案 73 / 99):
"Stop": [{ "hooks": [{ "type": "command",
"command": "\"<python>\" \"<此脚本>\"", "timeout": 10 }] }]
⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**;桌面版无 /hooks 面板,等效。
"""
import io
import json
import os
import re
import sys
import time
SCOPE = 'aliyun-dsh-server' # 只对本工作区生效
LOG_REL = os.path.join('.workbuddy', 'stop-dialog-guard.log')
# 只扫「收尾段」:出现这些就是"把该自己做的事甩回给用户"
PATTERNS = [
r'要我(再|接着|继续|现在)?[^。!?\n]{0,12}吗',
r'要不要我[^。!?\n]{0,12}',
r'是否要我[^。!?\n]{0,12}',
r'需要我[^。!?\n]{0,12}吗',
r'请确认[^。!?\n]{0,16}',
r'要不要(继续|现在做|我来)[^。!?\n]{0,12}',
r'是否(继续|需要我)[^。!?\n]{0,12}',
r'说一声即可',
r'你看(怎么办|怎么弄|要不要)',
r'你(决定|拍板)一下',
]
RE_BAN = re.compile('|'.join(PATTERNS))
REASON = (
'⛔ 收尾句是**征询句**,但按本平台规则(`CODEBUDDY.md §1`「回话前自检」+ `dsh-feature-first §5.3` 铁律 3)'
'先重判三问:① 命中**真门禁**吗(不可逆破坏性操作 / 边界外六类)?没命中 → **删掉这句,自己做完,改成陈述句**("我接着做 X");'
'② 是不是在把已经定下来的事再问一遍?是 → 删;③ 这件事用户有客观可判的优劣吗?没有 → 才允许问,且**一轮只问这一句**,'
'并写进 `dsh-feature-first §5.1` 结论骨架的「**需要你拍板**」一节(一句话 + 可感知差别),不要在结尾甩问题。'
)
TAIL_BYTES = 262144
MAX_BYTES = 4194304 # 扩窗上限 4 MB(防"巨行"时无限读) # 只读末尾 256 KB(实测:整库最大转录 31.9 MB;全文读 14 MB = 832 ms/轮,不可接受)
def transcribe_last_assistant(path):
"""返回最后一条 assistant 文本(**从尾部向后分块读**;读不到返回 '')。
⚠️ 为什么不是"一次读末尾 256 KB":一条 assistant 记录可能本身就 > 256 KB
(长回复 / 被回显的工具输出),此时尾窗会切在 JSON 行中间 ⇒ `json.loads` 失败 ⇒ **静默漏判**。
做法:从尾部按 TAIL_BYTES 递增扩窗(上限 MAX_BYTES),**直到至少解析出一条 assistant 记录**。
常见情形(小消息)只花一次 256 KB 读,成本可忽略。
"""
try:
size = os.path.getsize(path)
except OSError:
return ''
with io.open(path, 'rb') as f:
window = TAIL_BYTES
while True:
start = max(0, size - window)
f.seek(start)
raw = f.read().decode('utf-8', 'replace')
lines = raw.split('\n')
if start > 0:
lines = lines[1:] # 丢弃被截断的首行
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
if rec.get('type') != 'message' or rec.get('role') != 'assistant':
continue
chunks = [c['text'] for c in (rec.get('content') or [])
if isinstance(c, dict) and isinstance(c.get('text'), str)]
chunks += [c for c in (rec.get('content') or []) if isinstance(c, str)]
if chunks:
return '\n'.join(chunks)
if start == 0 or window >= MAX_BYTES:
# 放行,但**留痕**(A18:静默失败是负债)——可能是一条 >MAX_BYTES 的巨型记录
try:
os.environ.setdefault('_DSH_SG_MISS', '1')
r0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
if r0:
log(r0, '未能解析(窗口 %d 字节仍无 assistant 记录)' % window)
except Exception:
pass
return ''
window = min(window * 4, MAX_BYTES)
def tail_lines(text, n=2):
out = [l.strip() for l in text.strip().split('\n') if l.strip()]
return '\n'.join(out[-n:])
# 转述/引用豁免:收尾行里带引号或"引用/规则/写着/禁"等词 ⇒ 是在复述规则,不是在问用户
RE_QUOTE = re.compile(r'[「」“”"\']|引用|规则|写着|禁')
RATE_WINDOW = 600 # 秒;同一会话两次「阻止停止」的最小间隔
def _rate_limited(root, sid, peek=False):
"""同一会话 RATE_WINDOW 秒内已拦过 ⇒ 本次直接放行(防连续多轮被拦)。"""
if not root or not sid:
return False
p = os.path.join(root, '.workbuddy', 'cache', 'stop-guard-fires.json')
try:
d = json.loads(io.open(p, encoding='utf-8').read()) if os.path.exists(p) else {}
except Exception:
d = {}
now = time.time()
if now - float(d.get(sid, 0) or 0) < RATE_WINDOW:
return True
d = {k: v for k, v in d.items() if now - float(v or 0) < 86400} # 只留 1 天
d[sid] = now
try:
os.makedirs(os.path.dirname(p), exist_ok=True)
io.open(p, 'w', encoding='utf-8', newline='\n').write(json.dumps(d))
except Exception:
pass
return False
def log(root, detail):
try:
p = os.path.join(root, LOG_REL)
os.makedirs(os.path.dirname(p), exist_ok=True)
with io.open(p, 'a', encoding='utf-8') as f:
f.write('%s\t%s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), detail))
except Exception:
pass
def main():
raw = sys.stdin.read()
if not raw.strip():
return
try:
payload = json.loads(raw)
except ValueError:
return
tp = str(payload.get('transcript_path') or '')
if SCOPE not in tp: # 作用域外 → 放行
return
if os.environ.get('DSH_STOP_GUARD_OFF'): # 急停(环境变量)→ 放行
return
root0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
if root0 and os.path.exists(os.path.join(root0, '.workbuddy', 'stop-guard.disabled')):
return # 急停(闸刀文件)→ 放行
if payload.get('stop_hook_active'): # 防死循环 → 放行
return
text = transcribe_last_assistant(tp)
if not text:
return
sid = str(payload.get('session_id') or '')
if os.environ.get('DSH_SG_DEBUG'): # 调试:每次调用都留痕(用于验证宿主是否真的调用本钩子)
log(root0 or '.', 'invoked|scope=%s|tail_active=%s' % (SCOPE in tp, bool(payload.get('stop_hook_active'))))
if _rate_limited(root0, sid, peek=True): # 只查不记账
return
tail = tail_lines(text, 1) # 只看**最后一行**:命中面越窄,误报越少
if RE_QUOTE.search(tail): # 复述/引用规则 → 不是收尾提问
return
m = RE_BAN.search(tail)
if not m:
return
root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '')
_rate_limited(root0, sid) # 命中才记账(同一会话 10 分钟最多拦 1 次)
log(root if os.path.isdir(root) else '.', 'stop-dialog-guard 命中:%s | 收尾:%s'
% (m.group(0), tail.replace('\n', ' ')[:80]))
sys.stdout.write(json.dumps({'continue': False, 'reason': REASON}, ensure_ascii=False))
if __name__ == '__main__':
try:
main()
except Exception:
pass
sys.exit(0)