chore(hooks): 输出门禁三脚本更新 + 新增技能加载闸门 skill-load-guard.py
This commit is contained in:
1 parent
c8b514832f
commit
bda9b10161
4 files changed
+385
-10
No files matched your search
@@ -152,6 +152,79 @@ def segments(cmd):
|
||||
return [s.strip() for s in re.split(r'(?:&&|\|\||;|\n|\|)', cmd) if s.strip()]
|
||||
|
||||
|
||||
# ── 批量活检测(2026-09-15 用户选 A)─────────────────────────────
|
||||
# 要打断的是:**"一句话需求 → 上百次工具调用"** —— 每次调用都把「命令 + 结果」永久留在上下文里。
|
||||
# 实测(会话 7057685c):逐处改注释 ⇒ Edit 142 次、痕迹 13.4 万字符;若改用一个脚本 ⇒ 约 8 千字符(省 94%)。
|
||||
# 实现:`PreToolUse` 里给 Edit/Write/Bash 记账;到档位且**同质** ⇒ deny 一刀并给出替代做法(我改路子)。
|
||||
BATCH_N = 20
|
||||
BATCH_TOOLS = ('Edit', 'Write', 'Bash')
|
||||
|
||||
|
||||
def _batch_state(root):
|
||||
p = os.path.join(root, '.workbuddy', 'batch-guard.json')
|
||||
try:
|
||||
d = json.load(io.open(p, encoding='utf-8'))
|
||||
except Exception:
|
||||
d = {}
|
||||
return p, d
|
||||
|
||||
|
||||
def _homogeneous(tool, st):
|
||||
"""同质性:Edit ⇒ 最近 20 次里同一文件出现 ≥3 次(= 同一文件反复改);Bash ⇒ 命令都很短。"""
|
||||
if tool == 'Edit':
|
||||
paths = st.get('paths') or []
|
||||
if not paths:
|
||||
return False
|
||||
c = {}
|
||||
for x in paths[-20:]:
|
||||
c[x] = c.get(x, 0) + 1
|
||||
return max(c.values()) >= 3
|
||||
if tool == 'Bash':
|
||||
lens = st.get('lens') or []
|
||||
return bool(lens) and (sum(lens[-20:]) / max(len(lens[-20:]), 1)) < 300
|
||||
return True
|
||||
|
||||
|
||||
def batch_check(root, payload, log_fn):
|
||||
"""到档位 + 同质 ⇒ 返回提示串(并记账,每档只提示一次);否则 ''。"""
|
||||
sid = str(payload.get('session_id') or '')
|
||||
tool = payload.get('tool_name') or ''
|
||||
if not sid or tool not in BATCH_TOOLS:
|
||||
return ''
|
||||
p, d = _batch_state(root)
|
||||
st = d.setdefault(sid, {})
|
||||
st[tool] = int(st.get(tool, 0)) + 1
|
||||
n = st[tool]
|
||||
ti = payload.get('tool_input') or {}
|
||||
if tool == 'Edit':
|
||||
fp = str(ti.get('file_path') or '')
|
||||
if fp:
|
||||
st['paths'] = (st.get('paths') or [])[-40:] + [fp]
|
||||
elif tool == 'Bash':
|
||||
st['lens'] = (st.get('lens') or [])[-40:] + [len(str(ti.get('command') or ''))]
|
||||
fired = st.setdefault('fired', [])
|
||||
hit = ''
|
||||
if n in (BATCH_N, BATCH_N * 2, BATCH_N * 4) and ('%s@%d' % (tool, n)) not in fired and _homogeneous(tool, st):
|
||||
fired.append('%s@%d' % (tool, n))
|
||||
files = len(set(st.get('paths') or []))
|
||||
hit = (
|
||||
'🛠【批量活提示】本会话 `%s` 已调用 **%d 次**%s ⇒ 这更像**批量活**。\n'
|
||||
'✅ 改用**一个脚本一次做完**,只回「改了 N 处 / 失败 M 处」—— **别逐处调用**:'
|
||||
'逐次调用会把 N 份「命令 + 结果」**永久留在会话上下文里、之后每轮全量重发**。\n'
|
||||
'ℹ️ 实测参照:某会话逐处改 142 次 ⇒ 痕迹 **13.4 万字符**;改用一个脚本 ⇒ 约 **8 千字符(省 94%%)**。'
|
||||
'改完**统一验一次**即可,不必改一处验一处。'
|
||||
% (tool, n, ('(改到的文件只有 %d 个)' % files) if (tool == 'Edit' and files) else ''))
|
||||
log_fn(str(root), 'DENY|批量活提示 %s@%d' % (tool, n))
|
||||
try:
|
||||
if len(d) > 30:
|
||||
for k in list(d)[:-30]:
|
||||
d.pop(k, None)
|
||||
io.open(p, 'w', encoding='utf-8', newline='\n').write(json.dumps(d, ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
return hit
|
||||
|
||||
|
||||
def reason_for(cmd, hard=False):
|
||||
segs = segments(cmd)
|
||||
for core, pat, why, fix in RULES:
|
||||
@@ -186,7 +259,7 @@ def main():
|
||||
if (payload.get('hook_event_name') or '') != 'PreToolUse':
|
||||
return
|
||||
tool = payload.get('tool_name') or ''
|
||||
if tool not in ('Bash', 'Read'): # 覆盖**所有**会灌大输出的工具(2026-09-15 由用户点出补 Read)
|
||||
if tool not in ('Bash', 'Read', 'Edit', 'Write'): # Bash/Read=拦大输出;Edit/Write=批量活记账
|
||||
return
|
||||
if os.environ.get('DSH_OUTPUT_GUARD_OFF'):
|
||||
return
|
||||
@@ -195,6 +268,13 @@ def main():
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if tool in ('Edit', 'Write'): # 批量活检测:deny 一刀,换掉"上百次逐处调用"
|
||||
chk = batch_check(str(root), payload, log)
|
||||
if chk:
|
||||
_emit({'hookSpecificOutput': {'hookEventName': 'PreToolUse',
|
||||
'permissionDecision': 'deny',
|
||||
'permissionDecisionReason': chk}})
|
||||
return
|
||||
if tool == 'Read':
|
||||
if read_too_big(payload):
|
||||
fp = str(((payload.get('tool_input') or {}).get('file_path')) or '')
|
||||
|
||||
@@ -78,8 +78,26 @@ def lock_owner() -> str:
|
||||
|
||||
|
||||
def out(obj: dict) -> None:
|
||||
sys.stdout.write(json.dumps(obj, ensure_ascii=False))
|
||||
sys.stdout.flush()
|
||||
# ⚠️ 2026-09-16 修复(**根因**):必须走 **`sys.stdout.buffer` 写 bytes**,⛔ 不能走文本层
|
||||
# (`sys.stdout.write(str)`)。
|
||||
# 实证:宿主 spawn 本脚本时 stdout 编码**不保证是 UTF-8**(本机 `PYTHONUTF8=1` 在 hook 环境下
|
||||
# 不保证生效),而 deny 文案里含 `⛔` / `🔐` 等字符 ⇒ 文本层写抛 `UnicodeEncodeError` ⇒
|
||||
# 异常冒泡、**stdout 为空** ⇒ 宿主**收不到 deny** ⇒ **静默放行**。
|
||||
# 而 `hook_log()` 已经把 `PreToolUse-deny` 写进日志 ⇒ 表现成 **「日志里有 DENY,但拦不住」**
|
||||
# (2026-09-16 受控自测:无锁时 Write 文档库 ⇒ 文件真被创建)。
|
||||
# 对照:`bash-output-guard.py` 用 `sys.stdout.buffer.write(bytes)` ⇒ **实测拦得住**。
|
||||
# 📌 教训:本脚本的 `stdin` 早前已按 buffer 加固(见 main 顶部注释),**stdout 漏了** ——
|
||||
# 「读写要同时加固」,只修一半等于没修。
|
||||
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||
try:
|
||||
sys.stdout.buffer.write(data)
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception:
|
||||
try:
|
||||
sys.stdout.write(data.decode('utf-8', 'replace'))
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
LOG_FILE = os.environ.get(
|
||||
@@ -120,6 +138,15 @@ def main() -> None:
|
||||
tool = payload.get("tool_name")
|
||||
event = payload.get("hook_event_name") or payload.get("hook_event") or ""
|
||||
|
||||
# ★ 入口即留痕(2026-09-16 加)—— 与 stop-dialog-guard.py / skill-load-guard.py 同款。
|
||||
# 为什么不只记 SessionStart 与 deny:2026-09-16 实测 —— 重启后本钩子**一行都没写**,
|
||||
# 当时无法区分"没被宿主调用"与"被静默放行",只能靠猜(最后查明:SessionStart 的
|
||||
# `matcher: "startup"` 不匹配"恢复会话",钩子压根没被执行)。
|
||||
# 留痕里带上**实际收到的字段名**(keys):下次宿主字段一变,日志里立刻能看出来,
|
||||
# 而不是等某天发现守卫早已失效。低频:每次调用一行。
|
||||
hook_log("entry", "event=%s|tool=%s|keys=%s" % (
|
||||
event or "?", tool or "-", ",".join(sorted(payload.keys()))[:220]))
|
||||
|
||||
# ── SessionStart:只提示(该事件的输出只给用户看,不会进 Agent 上下文)──
|
||||
if event == "SessionStart" or (tool is None and "source" in payload):
|
||||
src = payload.get("source", "?")
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""skill-load-guard.py —— 「用户点名了方法 ⇒ 强制加载技能」的 UserPromptSubmit 钩子
|
||||
|
||||
为什么需要它
|
||||
────────────
|
||||
2026-09-16 复盘会话 `ddea70b7`(「确认guest用户数据迁移到106服务器」):
|
||||
用户 U6 明确说「D1–D6 先做哪些 **按照你的规划执行**,中间有问题**参考决策方法**」,
|
||||
但该会话 265 次工具调用里 **`Skill` 计数 = 0** —— 那份方法论**从未进入上下文**。
|
||||
AI 仍按常驻判据把两个**非门禁**问题("106 旧控制面要不要停" / "D1–D6 先做哪些")上抛给用户,
|
||||
而用户随后 45 秒 / 58 秒内自己给出了答案(Q1 的答案还超出了 AI 给的选项空间)。
|
||||
|
||||
⇒ 与 `stop-dialog-guard.py` 同族问题:**规则写了,但没有任何机制保证它在正确的时机被取用**。
|
||||
(那个钩子处理"回复收尾时的征询句",本钩子处理"用户点名时的方法加载",两者互补。)
|
||||
|
||||
本钩子 = 覆盖"用户点名"这条面:**每次用户提交时**扫输入,命中点名关键词 ⇒
|
||||
通过 `additionalContext` 注入一条**紧邻用户消息**的强制指令
|
||||
(位置比静态规则文件显著得多 —— 长会话里 CODEBUDDY.md 的硬要求容易被"稀释")。
|
||||
|
||||
安全设计(都不许省)
|
||||
────────────────────
|
||||
1. **自作用域**:只在 cwd 落在本工作区(`aliyun-dsh-server`)时生效,其他项目一律放行。
|
||||
2. **绝不添乱**:任何异常 → 静默放行(exit 0);本钩子**从不拦用户**,只在命中时**追加一段提醒**。
|
||||
3. **防跑飞**:同一会话 300 秒内最多注入一次。
|
||||
4. **急停双闸**(无需卸载/重启):env `DSH_SKILL_GUARD_OFF=1`,或 `<工作区>/.workbuddy/skill-guard.disabled`。
|
||||
5. **低频自证日志**:命中才写一行到 `<工作区>/.workbuddy/skill-load-guard.log` —— 用来回答"到底有没有触发"。
|
||||
6. **显式 UTF-8**:读写都走 `buffer`,不依赖 `PYTHONUTF8`(`-E` 会屏蔽它 ⇒ cp936 ⇒ 含中文静默炸)。
|
||||
|
||||
退出码:始终 0;决策通过 stdout 的 JSON 表达。
|
||||
安装(`settings.json` 的 hooks 段 · 与既有钩子同族,**并列挂在同一个 UserPromptSubmit 的 hooks 数组里**):
|
||||
"UserPromptSubmit": [{ "hooks": [
|
||||
{ "type": "command", "command": "\"<python>\" \"<此脚本>\"", "timeout": 10 },
|
||||
{ "type": "command", "command": "\"<python>\" -S -E \"<stop-dialog-guard.py>\"", "timeout": 10 }
|
||||
]}]
|
||||
⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**(关窗 ≠ 退出)才加载。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
SCOPE = 'aliyun-dsh-server' # 只对本工作区生效
|
||||
LOG_REL = os.path.join('.workbuddy', 'skill-load-guard.log')
|
||||
STATE_REL = os.path.join('.workbuddy', '.skill-load-guard.state')
|
||||
DISABLE_REL = os.path.join('.workbuddy', 'skill-guard.disabled')
|
||||
COOLDOWN = 300 # 同会话注入冷却(秒)
|
||||
|
||||
# 用户「点名方法」的词表 —— 命中任一即注入。
|
||||
# 「决策方法」是最稳的一条(覆盖"参考/按/用决策方法")。
|
||||
TRIGGERS = (
|
||||
'决策方法',
|
||||
'自行决策', '自主决策', '自己决策', '自己拿主意',
|
||||
'别问我', '不要问我', '不用问我', '按你的规划', '按你的判断',
|
||||
'参考决策', '决策方法论',
|
||||
)
|
||||
|
||||
|
||||
def _read_stdin():
|
||||
try:
|
||||
raw = sys.stdin.buffer.read()
|
||||
except Exception:
|
||||
return {}
|
||||
if not raw:
|
||||
return {}
|
||||
for enc in ('utf-8', 'utf-8-sig', 'gbk'):
|
||||
try:
|
||||
return json.loads(raw.decode(enc))
|
||||
except Exception:
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def _emit(obj):
|
||||
try:
|
||||
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode('utf-8'))
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _workdir(obj):
|
||||
for k in ('cwd', 'project_dir', 'workspace', 'projectDir'):
|
||||
v = obj.get(k)
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
for k in ('CODEBUDDY_PROJECT_DIR', 'WORKSPACE_FOLDER', 'PWD'):
|
||||
v = os.environ.get(k)
|
||||
if v:
|
||||
return v
|
||||
# 兜底:本脚本位于 <工作区>/dsh-server-docs/scripts/
|
||||
try:
|
||||
return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _log(workdir, line):
|
||||
try:
|
||||
p = os.path.join(workdir, LOG_REL)
|
||||
with io.open(p, 'a', encoding='utf-8', newline='\n') as f:
|
||||
f.write('%s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), line))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cooling(workdir, sid):
|
||||
"""同一会话 COOLDOWN 秒内不重复注入。"""
|
||||
try:
|
||||
p = os.path.join(workdir, STATE_REL)
|
||||
now = time.time()
|
||||
last_sid, last_ts = '', 0.0
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with io.open(p, 'r', encoding='utf-8') as f:
|
||||
parts = f.read().strip().split('\t')
|
||||
last_sid, last_ts = parts[0], float(parts[1] or 0)
|
||||
except Exception:
|
||||
pass
|
||||
if last_sid == sid and (now - last_ts) < COOLDOWN:
|
||||
return True
|
||||
with io.open(p, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('%s\t%.3f\n' % (sid, now))
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if os.environ.get('DSH_SKILL_GUARD_OFF') == '1':
|
||||
return
|
||||
obj = _read_stdin()
|
||||
if not obj:
|
||||
return
|
||||
|
||||
workdir = _workdir(obj)
|
||||
if not workdir or SCOPE not in workdir:
|
||||
return # 自作用域:别的工作区一律放行
|
||||
if os.path.exists(os.path.join(workdir, DISABLE_REL)):
|
||||
return
|
||||
|
||||
prompt = ''
|
||||
for k in ('prompt', 'user_prompt', 'userPrompt', 'message', 'text'):
|
||||
v = obj.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
prompt = v
|
||||
break
|
||||
|
||||
sid = str(obj.get('session_id') or obj.get('sessionId') or 'unknown')
|
||||
# ★ 入口即留痕(2026-09-16 加)—— 否则"没命中就没日志",永远无法回答
|
||||
# "它到底有没有被宿主调用"。与 stop-dialog-guard.py 同款设计;每次用户提交一行。
|
||||
_log(workdir, 'entry|event=UserPromptSubmit|in_scope=True|sid=%s|prompt_len=%d'
|
||||
% (sid[:8], len(prompt)))
|
||||
if not prompt:
|
||||
return
|
||||
|
||||
hits = [w for w in TRIGGERS if w in prompt]
|
||||
if not hits:
|
||||
return
|
||||
|
||||
if _cooling(workdir, sid):
|
||||
return
|
||||
|
||||
seg = re.sub(r'\s+', ' ', prompt)
|
||||
pos = min(seg.find(w) for w in hits)
|
||||
excerpt = seg[max(0, pos - 40): pos + 80].strip()
|
||||
|
||||
ctx = (
|
||||
'【技能加载闸门 · 机制层强制】检测到用户本轮**点名了方法**(命中:%s)。\n'
|
||||
'原文片段:「…%s…」\n\n'
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道判据"为由跳过**:\n'
|
||||
' **先调用 Skill 工具加载 `dsh-decision-method`**(决策方法论 · 含 §4.5 规则冲突裁决顺序 + 上抛前三问),\n'
|
||||
' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该上抛,**同时加载 `dsh-feature-first`**。\n\n'
|
||||
'为什么强制:2026-09-16 实测(会话 ddea70b7)—— 用户点名「参考决策方法」后,'
|
||||
'AI 全程 `Skill` 调用 **0 次**,仍按旧判据把两个**非门禁**问题上抛给用户,'
|
||||
'用户 45 / 58 秒后自己给出了答案。**规则写在文件里 ≠ 会在正确的时机被取用。**\n'
|
||||
'(若判定本轮确实与决策无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
|
||||
) % ('、'.join(hits), excerpt)
|
||||
|
||||
_emit({'hookSpecificOutput': {
|
||||
'hookEventName': 'UserPromptSubmit',
|
||||
'additionalContext': ctx,
|
||||
}})
|
||||
_log(workdir, 'HIT sid=%s hits=%s' % (sid[:8], ','.join(hits)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -245,14 +245,77 @@ CONTEXT = (
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# 会话预算(2026-09-15 用户选 A 案):到 ~15 万 token / ~120 次工具调用 ⇒ 提醒开新会话
|
||||
# 会话预算(2026-09-15 用户选 A 案 · 提交 03c8363):到 **12 万 token / 80 次工具调用** ⇒ 提醒开新会话
|
||||
# ⚠️ 2026-09-16 更正:本行原写「~15 万 token / ~120 次工具调用」,与代码值(`BUDGET_TOKENS=120000` /
|
||||
# `BUDGET_TOOLS=80`)**不符** —— 提交 `03c8363` 的 message 里就写的是 `120000/80`,代码从未用过 15 万/120
|
||||
# ⇒ 判定为注释笔误,已按代码更正(避免后人据注释误判告警点)。
|
||||
# 数值本身由 AI 在实现层自定(属 §1「性能与资源调参」= 边界内);**方案(A 案四招)才是用户 09-15 拍板的**。
|
||||
# 为什么加进本脚本、而不新装一个 hook:`settings.json` 的 hooks 是**应用启动时快照**(新增条目要重启),
|
||||
# 而**脚本内容每次调用现读** ⇒ 改这里即刻生效。数据源 = 转录 `type=function_call` → `message.usage.input_tokens`
|
||||
# (最近一条 usage 即"当前上下文体量",精确,不靠估算)。
|
||||
# 依据(实测某会话):上下文 5.2 万 → 59.2 万;累计 input 1.93 亿 / output 51.9 万(**371:1**);
|
||||
# 其中 33 次缓存失效,每次都把 ~50 万 token **按全价**重算 ⇒ 会话越长,单次失效越贵。
|
||||
BUDGET_TOKENS = 120000
|
||||
# ★ 2026-09-16 改为**分级 + 去重**(原为单一阈值 120000/80,超了之后每轮都报 ⇒ 变成"狼来了")
|
||||
# 设计要点:**同一级别只报一次** ⇒ 跨级才再提醒,既不麻木也不失警。
|
||||
BUDGET_TOKENS = 120000 # 一级:轻提示(保留原名,兼容既有引用)
|
||||
BUDGET_STRONG = 200000 # 二级:建议收口
|
||||
BUDGET_FORCE = 300000 # 三级:**强制收口**(先落盘、出接续包,再开新会话)
|
||||
BUDGET_TOOLS = 80
|
||||
ALERT_LEVEL_REL = os.path.join('.workbuddy', '.budget-alert-level')
|
||||
|
||||
# 为什么三级不是"自动开新会话"(2026-09-16 与用户讨论后定):
|
||||
# ① **技术上做不到** —— hook 只有 `additionalContext`(注入)与 `permissionDecision`(拦工具)两种输出,
|
||||
# 事件只有 SessionStart / PreToolUse / UserPromptSubmit / Stop,**没有"创建/切换会话"的能力**;
|
||||
# AI 自身也只能在会话内行动。⇒ "自动开"这一半无法实现。
|
||||
# ② **设计上不该做** —— 新会话 = 上下文清零 ⇒ **在途状态全丢**;而"该带走什么"只有 AI 判断得了
|
||||
# ⇒ 顺序必须是 **先收口(落盘 + 接续包)→ 再由用户开新会话**,反了就是"突然失忆"。
|
||||
# ③ 因此本级**不做"自动开"**,做**"强制收口"**:把状态固化成文档,让下一个会话能无损接上。
|
||||
LV_PREFIX = {
|
||||
1: '',
|
||||
2: '⚠️ 上下文已过 **20 万**,本轮收口后建议开新会话。\n',
|
||||
3: ('🔴 **已过 30 万 ⇒ 进入强制收口模式**(先落盘、再接续):\n'
|
||||
' ① 把在途状态写进 `.workbuddy/memory/`(今日日志 + 必要的 MEMORY.md 条目);\n'
|
||||
' ② 产出**接续包**:目标 / 已完成 / 在途 / 未完成 / 下一步 / 关键决定 / 回滚点;\n'
|
||||
' ③ 然后明确告知用户「请开新会话,接续点在 X」,**由用户开**(钩子无法自动创建会话)。\n'
|
||||
' ⛔ 不要在本会话继续开新任务 —— 每多跑一轮,成本按当前水位线性放大。\n'),
|
||||
}
|
||||
|
||||
|
||||
def _alert_level(tokens, ncalls):
|
||||
"""0=未超 1=轻 2=建议收口 3=强制收口"""
|
||||
t = tokens or 0
|
||||
if t >= BUDGET_FORCE:
|
||||
return 3
|
||||
if t >= BUDGET_STRONG:
|
||||
return 2
|
||||
if t >= BUDGET_TOKENS:
|
||||
return 1
|
||||
if ncalls is not None and ncalls >= BUDGET_TOOLS:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _last_level(root, sid):
|
||||
"""读「本会话」已报到的级别。**按 sid 区分** —— 否则新会话会被上一会话的状态挡住 ⇒ **漏报**。"""
|
||||
try:
|
||||
with io.open(os.path.join(root, ALERT_LEVEL_REL), encoding='utf-8') as f:
|
||||
s = (f.read() or '').strip()
|
||||
ps = s.split('\t')
|
||||
if len(ps) == 2 and ps[0] == sid:
|
||||
return int(ps[1] or 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _set_level(root, sid, lv):
|
||||
try:
|
||||
p = os.path.join(root, ALERT_LEVEL_REL)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with io.open(p, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write('%s\t%d' % (sid, lv))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def session_budget(path):
|
||||
@@ -286,8 +349,15 @@ def session_budget(path):
|
||||
return last_in, n_calls, prev_in
|
||||
|
||||
|
||||
def budget_note(tokens, ncalls, prev=None):
|
||||
"""超预算 ⇒ 返回注入用的提醒串;未超 ⇒ 但增量异常也提醒(<1 万 token 的增量不打扰)。"""
|
||||
def budget_note(tokens, ncalls, prev=None, root='', sid=''):
|
||||
"""分级提醒;**同一级别只注入一次**(跨级才再报)—— 治"超阈值后每轮都报"导致的麻木。
|
||||
未超阈值时,单轮增量异常(≥4 万 token)仍提醒。"""
|
||||
# ★ 分级 + 去重(2026-09-16):本级已报过 ⇒ 静默(**按会话 id 区分**,新会话重新计数)
|
||||
lv = _alert_level(tokens, ncalls)
|
||||
if lv and lv <= _last_level(root, sid):
|
||||
return ''
|
||||
if lv:
|
||||
_set_level(root, sid, lv)
|
||||
over_t = tokens is not None and tokens >= BUDGET_TOKENS
|
||||
over_n = ncalls is not None and ncalls >= BUDGET_TOOLS
|
||||
grew = (tokens is not None and prev is not None and tokens - prev >= 40000) if not (over_t or over_n) else False
|
||||
@@ -296,7 +366,8 @@ def budget_note(tokens, ncalls, prev=None):
|
||||
which = ('上下文 %s token' % tokens) if over_t else (
|
||||
'工具调用 %s 次' % ncalls) if over_n else ('**上一轮新增 %s token**' % (tokens - prev))
|
||||
return (
|
||||
'💰 【会话预算告警】本会话已到 **%s**(阈值 %s token / %s 次工具调用)。'
|
||||
LV_PREFIX.get(lv, '') +
|
||||
'💰 【会话预算告警】本会话已到 **%s**(一级阈值 %s token / %s 次工具调用)。'
|
||||
'代价机制(2026-09-15 实测):对话历史是**追加式**的 —— 一次工具调用的输出(`function_call_result`)'
|
||||
'**永久留在历史里、每轮全量重发**(某会话 553 轮 × 平均 42 万 = **2.33 亿 input**,output 仅 0.35%%)。'
|
||||
'**处置:本轮收口后开新会话**(新会话起点约 5 万 ⇒ 每轮降到 1/12);'
|
||||
@@ -404,13 +475,17 @@ def user_prompt_mode(payload):
|
||||
tail = tail_lines(text, 1) if text else ''
|
||||
hit = bool(tail) and not RE_QUOTE.search(tail) and bool(RE_BAN.search(tail))
|
||||
toks, ncalls, prev = session_budget(tp)
|
||||
note = budget_note(toks, ncalls, prev)
|
||||
note = budget_note(toks, ncalls, prev, root0 or '.', str(payload.get('session_id') or ''))
|
||||
gh = guard_health(root0 or '.')
|
||||
ph = path_health(root0 or '.')
|
||||
log(root0 or '.', 'invoked(user-prompt)|mode=%s|上轮收尾=征询句:%s|上下文=%s tok(+%s)|工具=%s 次|预算告警=%s|门禁自检=%s|路径自检=%s|%s'
|
||||
% (mode, hit, toks, (toks - prev) if (toks and prev) else '-', ncalls, bool(note), bool(gh), bool(ph),
|
||||
(tail.replace('\n', ' ')[:60] if tail else '(取不到上一轮文本)')))
|
||||
if mode == 'inject' and (hit or note or gh or ph):
|
||||
# ⚠️ 2026-09-15 用户选 C:**取消**「会话预算」注入 —— 当时的症结是"每轮都报、太吵"。
|
||||
# ★ 2026-09-16 恢复**分级注入**:`budget_note` 已改为「分级 + 同会话跨级才报一次」(见其 docstring),
|
||||
# 噪音症结已消解;且用户当日提出「上下文超 30 万要不要自动收口」⇒
|
||||
# **AI 必须先知道水位才谈得上收口** ⇒ 恢复注入(一级静默无关、二级建议、三级强制收口)。
|
||||
if mode == 'inject' and (hit or gh or ph or note):
|
||||
ctx = ''
|
||||
for seg in (CONTEXT if hit else '', note, gh, ph):
|
||||
if seg:
|
||||
|
||||
Reference in new issue
Block a user