hermes-connect-services 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -118,8 +118,66 @@ model:
118
118
  | `… --no-reasoning` | 全部模型标 `supports_reasoning: false` |
119
119
  | `… --config <path>` | 指定配置文件 |
120
120
  | `node bin/ensure-services.mjs [--verbose]` | 单独执行懒启动(也是 hook 本体) |
121
+ | `node bin/patch-hermes-stall-gate.mjs` | 给 hermes 补「宣布下一步却没动手」的续跑闸门 |
122
+ | `… --check` | 只报告补丁状态(升级后跑一次看是否失效) |
123
+ | `… --revert` | 撤销补丁,恢复到原始字节 |
121
124
 
122
- ## 五、排障
125
+ ## 五、续跑闸门:模型说要做什么,然后就停了
126
+
127
+ 模型常见的收尾是「我先检查配置,再修改这里:」——宣布了下一步,零工具调用就结束这一轮。这不是完成,是卡住。
128
+
129
+ ZCode / pi / dsh 三端各有一个官方钩子点可以兜住(Stop hook、`turn_end`、`agent/turn-stopping`)。**hermes 没有**:
130
+
131
+ - `hermes_cli/plugins.py` 的 `VALID_HOOKS` 里没有任何「回合正常结束」事件(`agent_loop_stopped` 是 `/stop` 打断,不是收尾)。
132
+ - 唯一能续跑的闸门是 `pre_verify`,但它的前置条件是 `if _edited and has_hook("pre_verify")` —— 本轮**写过文件**才触发。而「我先检查配置」这种收尾往往只读不写,或一个工具都没调,正好落在门控之外。
133
+ - 内置的 `_dropped_toolcall_nudge` 只处理 `finish_reason == "tool_calls"` 但 `tool_calls` 为空 —— 协议层信号,精确但覆盖面窄。
134
+
135
+ 所以只能往宿主的收尾闸门链里加第 5 道。
136
+
137
+ ```sh
138
+ node bin/patch-hermes-stall-gate.mjs # 打补丁
139
+ node bin/patch-hermes-stall-gate.mjs --check # 看状态
140
+ node bin/patch-hermes-stall-gate.mjs --revert # 干净回退
141
+ ```
142
+
143
+ 改动面刻意压到最小 —— 宿主原有行一个字节不动:
144
+
145
+ | 文件 | 改动 |
146
+ |---|---|
147
+ | `agent/turn_stop_gates.py` | 两个 marker 块:模块级判定函数 + 一处调用 |
148
+ | `agent/stall_detector.py` | 新增,判定逻辑本体(与 JS 版逐条对齐) |
149
+
150
+ `--revert` 会从首次打补丁时留下的快照还原 `turn_stop_gates.py`,实测与原始文件**逐字节相同**。
151
+
152
+ ### 生效方式
153
+
154
+ **改完必须重启 hermes gateway** —— 已 import 的模块不会自动重载,不重启就等于没打。
155
+
156
+ ### hermes 升级会覆盖
157
+
158
+ 自更新会重置 `hermes-agent/` 下的源码,两块一起消失。表现是「续跑不再发生」而不是报错:模块级 import 包在 `try/except` 里,`stall_detector.py` 不在时闸门静默失效,不影响正常结束。升级后重跑一次脚本即可,`--check` 会告诉你当前状态。
159
+
160
+ ### 开关与防护
161
+
162
+ ```yaml
163
+ agent:
164
+ stall_guard: false # 或环境变量 HERMES_STALL_GUARD=0
165
+ ```
166
+
167
+ - **同一段回复只续一次**:按文本 sha1 指纹去重,模型被提示后原样复读不会二次触发。
168
+ - **一个会话上限 3 次**:与 ZCode 侧一致。连续 3 次都唤不醒,再提示也只是复读计划。
169
+ - **本轮有工具调用就不判**:`_zc_tool_calls_this_turn()` 只看最后一条 user 消息之后的 `tool_calls`,`> 0` 一律跳过。
170
+ - 闸门内部全部 fail-open:任何异常只写 debug 日志,绝不让一次正常收尾因为插件出错而崩。
171
+
172
+ ### 阈值不是「看到冒号就续跑」
173
+
174
+ 判定是打分制,否决规则先于肯定规则求值:问句收尾、把决定权交回用户、明确完成宣告 —— 一律不续跑。冒号只是加权项(说明性文字大量以冒号收尾)。规则表与 ZCode / pi / dsh 完全同源,`tests/test-detector-parity.mjs` 会拿同一批文本喂给 JS 与 Python 两份实现,逐字段比对 `verdict` / `score` / `signals` —— 任一边改了规则而另一边没跟上,这个测试就红。
175
+
176
+ ### 一个已知差异
177
+
178
+ ZCode 的 Stop hook 由宿主保证「只在一轮零工具调用时执行」,所以那边按**每轮**计数。hermes 这边没有可靠的轮次标识(续跑本身会追加一条 user 消息),所以上限按**会话**计:同一会话累计续跑 3 次后不再触发。保守方向是一致的 —— 只能少续,不会多续。
179
+
180
+ ## 六、排障
123
181
 
124
182
  **401 / 模型不在了** — 端点 token 轮换或上游名册变了,重跑 `sync-hermes-config.mjs`。
125
183
 
@@ -155,13 +213,15 @@ no_proxy=127.0.0.1,localhost,::1,0.0.0.0
155
213
 
156
214
  **思考档位** — hermes 全局 `agent.reasoning_effort` 默认 `ultra`,这些端点会原样转发 OpenAI 的 `reasoning_effort`。上游不认 `ultra` 时把它降到 `high`。不想让任何模型思考就加 `--no-reasoning`。
157
215
 
158
- ## 六、测试
216
+ ## 七、测试
159
217
 
160
218
  ```sh
161
- node hermes-connect-services/tests/test-yaml-patch.mjs
219
+ node tests/test-yaml-patch.mjs # 托管块插入/替换/卸载、幂等、--only、孤儿标记自愈
220
+ node ../tests/test-detector-parity.mjs # JS ↔ Python 判定器逐字段对齐
221
+ node tests/test-stall-gate.mjs # 闸门行为(需要本机装有 hermes)
162
222
  ```
163
223
 
164
- 覆盖的是最危险的那部分:托管块插入/替换/卸载、幂等性、`--only` 不删邻居、孤儿标记自愈。
224
+ `test-stall-gate.mjs` 直接 import 宿主的 `apply_stop_gates`,让整条闸门链真实跑一遍 —— 不是 mock 一个函数名对得上就算过。
165
225
 
166
226
  ---
167
227
 
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * patch-hermes-stall-gate.mjs — 给 hermes 补一个「宣布下一步却没动手」的续跑闸门。
4
+ *
5
+ * ## 为什么这里必须打补丁,而 ZCode / pi / dsh 不用
6
+ *
7
+ * 另外三端各有一处「回合结束后仍可续跑」的官方钩子点:
8
+ * ZCode Stop hook(宿主保证只在一轮零工具调用时触发)
9
+ * pi turn_end 返回 {continue:true, entries:[...]}
10
+ * dsh agent/turn-stopping 里 agent.steer()
11
+ *
12
+ * hermes 没有。`hermes_cli/plugins.py` 的 VALID_HOOKS 里没有任何「回合正常结束」事件
13
+ * (`agent_loop_stopped` 是 /stop 打断,不是收尾)。唯一能续跑的闸门是 `pre_verify`,
14
+ * 但 `agent/turn_stop_gates.py` 里它的前置条件是 `if _edited and has_hook("pre_verify")`
15
+ * —— 本轮**写过文件**才触发。而「我先检查配置,再修改这里:」这种收尾往往一个工具都
16
+ * 没调,或者只读不写,正好落在门控之外。
17
+ *
18
+ * 所以只能往宿主的收尾闸门链里加第 5 道。改动面刻意压到最小:
19
+ *
20
+ * agent/turn_stop_gates.py 两个托管块(模块级判定函数 + 一处调用),marker 包围
21
+ * agent/stall_detector.py 新增文件,判定逻辑本体(从 shared/stall_detector.py 分发)
22
+ *
23
+ * 宿主自带的行一个字节不动,revert 时两条 marker 之间的内容整段删除即可。
24
+ *
25
+ * ## 升级会覆盖
26
+ *
27
+ * hermes 自更新会重置 hermes-agent/ 下的源码,这两个块会一起消失。表现是「续跑不再
28
+ * 发生」而不是报错(模块级 import 有 try/except,文件不在时 `_zc_detect_stall` 为 None,
29
+ * 闸门静默失效)。升级后重跑本脚本即可恢复 —— `--check` 会告诉你当前状态。
30
+ *
31
+ * @module patch-hermes-stall-gate
32
+ */
33
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync, unlinkSync } from 'node:fs';
34
+ import { dirname, join, resolve } from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+ import { spawnSync } from 'node:child_process';
37
+
38
+ const HERE = dirname(fileURLToPath(import.meta.url));
39
+ const PKG_ROOT = resolve(HERE, '..');
40
+ const HOME = process.env.HOME ?? process.env.USERPROFILE ?? '';
41
+ const MARKER = 'zcode-ai-connect stall-guard';
42
+ const START = `# >>> ${MARKER} >>>`;
43
+ const END = `# <<< ${MARKER} <<<`;
44
+
45
+ const argv = process.argv.slice(2);
46
+ const has = (name) => argv.includes(name);
47
+ const valueOf = (name) => {
48
+ const i = argv.indexOf(name);
49
+ return i === -1 ? undefined : argv[i + 1];
50
+ };
51
+
52
+ const options = {
53
+ check: has('--check'),
54
+ revert: has('--revert'),
55
+ dryRun: has('--dry-run'),
56
+ verbose: has('--verbose') || has('-v'),
57
+ home: valueOf('--home') ?? process.env.HERMES_HOME ?? null,
58
+ };
59
+
60
+ /** hermes 的 home:Windows 是 %LOCALAPPDATA%/hermes,其他地方是 ~/.hermes。 */
61
+ function locateHome() {
62
+ const candidates = [
63
+ options.home,
64
+ process.env.HERMES_HOME,
65
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'hermes') : null,
66
+ join(HOME, '.hermes'),
67
+ ].filter(Boolean);
68
+ return candidates.find((p) => existsSync(join(p, 'config.yaml'))) ?? null;
69
+ }
70
+
71
+ /** 运行时的源码树。installs/<id>/environments/<id>/workspace 是项目沙箱副本,不是主进程加载的那份。 */
72
+ function locateAgentRoot(home) {
73
+ const candidates = [
74
+ join(home, 'hermes-agent'),
75
+ join(HOME, '.hermes', 'hermes-agent'),
76
+ ];
77
+ return candidates.find((p) => existsSync(join(p, 'agent', 'turn_stop_gates.py'))) ?? null;
78
+ }
79
+
80
+ /** 找 hermes 自带的解释器,用来做语法检查。 */
81
+ function findPython(home) {
82
+ const candidates = [
83
+ process.env.HERMES_PYTHON,
84
+ join(home, 'tools', `python-3.14.9+20261021-win32-x64`, 'python.exe'),
85
+ 'python',
86
+ 'python3',
87
+ ].filter(Boolean);
88
+ for (const exe of candidates) {
89
+ const probe = spawnSync(exe, ['-c', 'print(1)'], { encoding: 'utf8', timeout: 15000 });
90
+ if (probe.status === 0 && probe.stdout.trim() === '1') return exe;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ const MODULE_BLOCK = (source) => [
96
+ START,
97
+ '# 由 hermes-connect-services/bin/patch-hermes-stall-gate.mjs 写入 —— 勿手改。',
98
+ '#',
99
+ '# 第 5 道收尾闸:模型只宣布下一步、本轮零工具调用就结束,追加一条 user 提示把本轮续上。',
100
+ '# 判定逻辑在 agent/stall_detector.py,与 ZCode / pi / dsh 三端同一套规则。',
101
+ 'import hashlib as _zc_hashlib',
102
+ '',
103
+ '# 一个会话内最多续跑几次。与三端一致:连续 3 次都唤不醒,再提示也只是复读计划。',
104
+ '_ZC_STALL_MAX_NUDGES = 3',
105
+ '',
106
+ 'try:',
107
+ ' from agent.stall_detector import detect_stall as _zc_detect_stall',
108
+ 'except Exception: # 升级后 stall_detector.py 可能不在:静默降级,绝不阻断正常结束',
109
+ ' _zc_detect_stall = None',
110
+ '',
111
+ '',
112
+ 'def _zc_stall_guard_enabled() -> bool:',
113
+ ' env = os.environ.get("HERMES_STALL_GUARD")',
114
+ ' if env is not None:',
115
+ ' return env.strip().lower() not in {"0", "false", "off", "no", "disabled"}',
116
+ ' try:',
117
+ ' from hermes_cli.config import load_config_readonly',
118
+ '',
119
+ ' cfg = (load_config_readonly() or {}).get("agent") or {}',
120
+ ' except Exception:',
121
+ ' return True',
122
+ ' value = cfg.get("stall_guard", True)',
123
+ ' if isinstance(value, bool):',
124
+ ' return value',
125
+ ' return str(value).strip().lower() not in {"0", "false", "off", "no", "disabled"}',
126
+ '',
127
+ '',
128
+ 'def _zc_tool_calls_this_turn(messages) -> int:',
129
+ ' """本轮(最后一条 user 消息之后)发出的 tool_call 总数。',
130
+ '',
131
+ ' 与 ZCode 侧的 toolCallCount 同义:> 0 就不存在「只说不做」。',
132
+ ' """',
133
+ ' start = 0',
134
+ ' for i in range(len(messages) - 1, -1, -1):',
135
+ ' msg = messages[i]',
136
+ ' if isinstance(msg, dict) and msg.get("role") == "user":',
137
+ ' start = i + 1',
138
+ ' break',
139
+ ' total = 0',
140
+ ' for msg in messages[start:]:',
141
+ ' if not isinstance(msg, dict):',
142
+ ' continue',
143
+ ' calls = msg.get("tool_calls")',
144
+ ' if isinstance(calls, list):',
145
+ ' total += len(calls)',
146
+ ' return total',
147
+ '',
148
+ '',
149
+ 'def _zc_stall_guard_nudge(agent, messages, final_response):',
150
+ ' """命中则返回要回灌的提示,否则 None。',
151
+ '',
152
+ ' 收尾闸门里抛异常会打断本轮的正常结束,所以整个函数是 fail-open 的。',
153
+ ' """',
154
+ ' try:',
155
+ ' if _zc_detect_stall is None or not _zc_stall_guard_enabled():',
156
+ ' return None',
157
+ ' text = final_response if isinstance(final_response, str) else ""',
158
+ ' if not text.strip():',
159
+ ' return None',
160
+ ' digest = _zc_hashlib.sha1(text.encode("utf-8", "ignore")).hexdigest()',
161
+ ' seen = getattr(agent, "_zc_stall_guard_seen", None)',
162
+ ' if not isinstance(seen, list):',
163
+ ' seen = []',
164
+ ' if digest in seen or len(seen) >= _ZC_STALL_MAX_NUDGES:',
165
+ ' return None',
166
+ ' verdict = _zc_detect_stall(text, _zc_tool_calls_this_turn(messages))',
167
+ ' if not isinstance(verdict, dict) or not verdict.get("stall"):',
168
+ ' return None',
169
+ ' agent._zc_stall_guard_seen = seen + [digest]',
170
+ ' logger.debug(',
171
+ ' "stall-guard matched (verdict=%s score=%s signals=%s)",',
172
+ ' verdict.get("verdict"), verdict.get("score"), verdict.get("signals"),',
173
+ ' )',
174
+ ' return verdict.get("prompt")',
175
+ ' except Exception:',
176
+ ' logger.debug("stall-guard check failed", exc_info=True)',
177
+ ' return None',
178
+ END,
179
+ ].join('\n');
180
+
181
+ const CALL_BLOCK = [
182
+ ` ${START}`,
183
+ ' # 放在最后一道:前面的闸门(late-LSP / verify-on-stop / pre_verify / kanban)',
184
+ ' # 已经各自 return 了,走到这里说明没有别的理由继续这一轮。',
185
+ ' _zc_nudge = _zc_stall_guard_nudge(agent, messages, final_response)',
186
+ ' if _zc_nudge:',
187
+ ' final_msg["finish_reason"] = "stall_guard_continue"',
188
+ ' _append_interim_answer(',
189
+ ' agent, final_msg, messages, conversation_history, "stall-guard interim flush failed"',
190
+ ' )',
191
+ ' logger.debug(',
192
+ ' "stall-guard nudge issued (%d/%d)",',
193
+ ' len(getattr(agent, "_zc_stall_guard_seen", []) or []),',
194
+ ' _ZC_STALL_MAX_NUDGES,',
195
+ ' )',
196
+ ' return _continue(_zc_nudge, "_zc_stall_guard_synthetic")',
197
+ ` ${END}`,
198
+ ].join('\n');
199
+
200
+ /**
201
+ * 删掉**所有** marker 块。
202
+ *
203
+ * 必须按整行删除:调用块是缩进在函数体里的,若只从 `#` 开始切,会留下那 4 个
204
+ * 空格残行,导致结果变成 ` return StopGateVerdict(`(12 空格)—— 语法
205
+ * 合法但缩进错误,Python 会把它当缩进块的一部分。所以按行首/行尾切。
206
+ */
207
+ function stripBlock(text) {
208
+ let lines = text.split('\n');
209
+ for (;;) {
210
+ const s = lines.findIndex((line) => line.includes(START));
211
+ if (s === -1) break;
212
+ let e = lines.findIndex((line, i) => i > s && line.includes(END));
213
+ if (e === -1) break;
214
+ lines.splice(s, e - s + 1);
215
+ // 块两侧原本各有一个空行分隔;删完如果两边都是空行,会多出一个。
216
+ if (lines[s - 1] !== undefined && lines[s] !== undefined
217
+ && lines[s - 1].trim() === '' && lines[s].trim() === '') {
218
+ lines.splice(s, 1);
219
+ }
220
+ }
221
+ return lines.join('\n');
222
+ }
223
+
224
+ /** 锚点必须唯一,否则宁可报错也不猜。 */
225
+ function assertUniqueAnchor(text, anchor, label) {
226
+ const first = text.indexOf(anchor);
227
+ if (first === -1) throw new Error(`找不到插入锚点: ${label}`);
228
+ if (text.indexOf(anchor, first + 1) !== -1) throw new Error(`插入锚点不唯一: ${label}`);
229
+ return first;
230
+ }
231
+
232
+ function main() {
233
+ const home = locateHome();
234
+ if (!home) {
235
+ console.error('找不到 hermes home(可设 HERMES_HOME 或用 --home 指定)');
236
+ process.exit(1);
237
+ }
238
+ const agentRoot = locateAgentRoot(home);
239
+ if (!agentRoot) {
240
+ console.error(`找不到 ${join(home, 'hermes-agent')}/agent/turn_stop_gates.py`);
241
+ process.exit(1);
242
+ }
243
+
244
+ const gatePath = join(agentRoot, 'agent', 'turn_stop_gates.py');
245
+ const detectorPath = join(agentRoot, 'agent', 'stall_detector.py');
246
+ const sourceDetector = join(PKG_ROOT, 'lib', 'stall_detector.py');
247
+
248
+ if (!existsSync(sourceDetector)) {
249
+ console.error(`包里缺 lib/stall_detector.py —— 先在仓库根跑 node scripts/sync-stall-guard.mjs`);
250
+ process.exit(1);
251
+ }
252
+
253
+ let gate = readFileSync(gatePath, 'utf8');
254
+ const patched = gate.includes(START);
255
+ const detectorPresent = existsSync(detectorPath);
256
+
257
+ if (options.check) {
258
+ console.log(`home: ${home}`);
259
+ console.log(`gate file: ${gatePath}`);
260
+ console.log(`patched: ${patched ? 'yes' : 'no'}`);
261
+ console.log(`detector: ${detectorPresent ? 'yes' : 'no'}`);
262
+ if (patched && detectorPresent) {
263
+ const same = readFileSync(detectorPath, 'utf8') === readFileSync(sourceDetector, 'utf8');
264
+ console.log(`detector in sync: ${same ? 'yes' : 'NO (重跑本脚本)'}`);
265
+ if (!same) process.exit(2);
266
+ } else {
267
+ process.exit(2);
268
+ }
269
+ return;
270
+ }
271
+
272
+ if (options.revert) {
273
+ if (!patched && !detectorPresent) {
274
+ console.log('没有需要撤销的改动');
275
+ return;
276
+ }
277
+ if (options.dryRun) {
278
+ console.log(`--dry-run: 会移除 ${gatePath} 的托管块${detectorPresent ? ' 与 stall_detector.py' : ''}`);
279
+ return;
280
+ }
281
+ const bakPath = `${gatePath}.bak-zcode-stall-guard`;
282
+ // 首次打补丁时留下的原始快照优先 —— 它比"按 marker 反删"更能保证零字节还原。
283
+ const bakIsPristine = existsSync(bakPath) && !readFileSync(bakPath, 'utf8').includes(START);
284
+ if (bakIsPristine) {
285
+ copyFileSync(bakPath, gatePath);
286
+ console.log(` 从原始快照恢复 ${bakPath}`);
287
+ } else {
288
+ writeFileSync(gatePath, stripBlock(gate), 'utf8');
289
+ }
290
+ if (detectorPresent) {
291
+ const current = readFileSync(detectorPath, 'utf8');
292
+ if (current === readFileSync(sourceDetector, 'utf8')) {
293
+ unlinkSync(detectorPath);
294
+ } else {
295
+ console.log(`stall_detector.py 已被改动过,保留不删: ${detectorPath}`);
296
+ }
297
+ }
298
+ console.log(`已撤销${bakIsPristine ? '' : `(备份: ${bakPath})`}`);
299
+ return;
300
+ }
301
+
302
+ // ── 应用补丁(幂等:先清旧块再按锚点插新块) ─────────────────────────────
303
+ gate = stripBlock(gate);
304
+
305
+ const moduleAnchor = '\ndef _verify_on_stop_nudge(agent) -> Optional[str]:';
306
+ const callAnchor = '\n return StopGateVerdict(\n continue_turn=False, final_response=final_response,';
307
+
308
+ const moduleAt = assertUniqueAnchor(gate, moduleAnchor, 'def _verify_on_stop_nudge');
309
+ gate = `${gate.slice(0, moduleAt)}\n${MODULE_BLOCK()}\n${gate.slice(moduleAt)}`;
310
+
311
+ const callAt = assertUniqueAnchor(gate, callAnchor, 'return StopGateVerdict(continue_turn=False');
312
+ gate = `${gate.slice(0, callAt)}\n${CALL_BLOCK}\n${gate.slice(callAt)}`;
313
+
314
+ if (options.dryRun) {
315
+ console.log(`--dry-run: 会给 ${gatePath} 插入 ${START} / ${END} 两块`);
316
+ if (options.verbose) console.log(MODULE_BLOCK().split('\n').slice(0, 4).join('\n'));
317
+ return;
318
+ }
319
+
320
+ // 只有在文件还是原样时才留快照。重打补丁时覆盖快照,会让 --revert 失去干净的回退点。
321
+ if (!patched) copyFileSync(gatePath, `${gatePath}.bak-zcode-stall-guard`);
322
+ writeFileSync(gatePath, gate, 'utf8');
323
+ mkdirSync(dirname(detectorPath), { recursive: true });
324
+ writeFileSync(detectorPath, readFileSync(sourceDetector, 'utf8'), 'utf8');
325
+
326
+ // ── 语法检查:补丁后宿主必须仍然可 import ─────────────────────────────────
327
+ const python = findPython(home);
328
+ if (python) {
329
+ const check = spawnSync(python, ['-m', 'py_compile', gatePath, detectorPath], {
330
+ encoding: 'utf8', timeout: 60000, cwd: agentRoot,
331
+ });
332
+ if (check.status !== 0) {
333
+ copyFileSync(`${gatePath}.bak-zcode-stall-guard`, gatePath);
334
+ console.error('语法检查失败,已回滚:');
335
+ console.error((check.stderr || check.stdout || '').slice(0, 1200));
336
+ process.exit(1);
337
+ }
338
+ console.log(` syntax ok (${python})`);
339
+ } else {
340
+ console.log(' (没找到 python,跳过语法检查)');
341
+ }
342
+
343
+ console.log(` patched ${gatePath}`);
344
+ console.log(` wrote ${detectorPath}`);
345
+ console.log('');
346
+ console.log('生效需要重启 hermes gateway —— 已 import 的模块不会自动重载。');
347
+ }
348
+
349
+ main();
@@ -0,0 +1,301 @@
1
+ """stall_detector —— 「宣布了下一步却没调用工具」的启发式判定(Python 版)。
2
+
3
+ 与 ``shared/stall-detector.mjs`` 逐条对齐,供 hermes 的回合结束闸门使用。
4
+ **两份实现必须同步**:改任意一份都要跑 ``node tests/test-detector-parity.mjs``,
5
+ 它会把同一批用例喂给两边的实现并逐字段比对(verdict / score / signals)。
6
+
7
+ 设计约束与 JS 版一致:
8
+
9
+ 1. 这是启发式,不是判定器。宁可漏掉一次续跑,也不要打断一条正常收尾的回复。
10
+ 2. 否决(veto)规则先于肯定(cue)规则求值。
11
+ 3. 冒号只是加权项,不足以独立触发。
12
+ 4. 零依赖、纯函数、无 IO —— 会被复制进宿主包,不能 import 本项目其他模块。
13
+
14
+ 与 JS 版的唯一刻意差异:返回值是 dict 而不是对象字面量。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Any, Dict
21
+
22
+ __all__ = ["detect_stall", "CONTINUE_PROMPT"]
23
+
24
+ # 上限:只看最后两块 + 最多 600 字符。更早的内容是背景,不是收尾意图。
25
+ TAIL_BLOCK_COUNT = 2
26
+ TAIL_CHAR_LIMIT = 600
27
+
28
+ # 低于这个(加权)长度的回复不判 —— 「好的。」「Done.」没有可续跑的动作。
29
+ # 中日韩文字按 1.6 字计权:同样表达一个完整动作,中文比英文短得多。
30
+ MIN_WEIGHTED_CHARS = 14.0
31
+
32
+ # 请求用户裁决 / 提供输入的措辞。命中即否决。
33
+ REQUEST_PATTERNS = [
34
+ # 中文:把决定权交回用户
35
+ r"请你?(?:确认|批准|授权|选择|决定|提供|粘贴|发送|告知|告诉|现在|立刻|马上|配合|扫码|扫|在)",
36
+ r"你(?:可以|需要|来)(?:确认|决定|选择|提供|先)",
37
+ r"需要你(?:确认|决定|操作|提供|回复|选择)",
38
+ r"(?:是否|要不要|需不需要|要不要我|需要我)(?:继续|执行|我|帮|先|再)?",
39
+ r"等你(?:确认|回复|决定|操作|安排)",
40
+ r"告诉我(?:你|是否|要|结果|选择|一声|什么时候)",
41
+ r"(?:说|吱|喊)一声",
42
+ r"你(?:点个?头|说[「“\"']?开[」”\"']?)(?:我就|我立刻|我马上)?",
43
+ r"要我[^。!?\n]{0,24}(?:就|的话|吗)",
44
+ r"扫完(?:不用回复|告诉我|说一声)",
45
+ r"让我知道",
46
+ r"由你(?:决定|来|选|定)",
47
+ r"你(?:先|来)(?:确认|决定|操作|选|说)",
48
+ r"确认后(?:我)?再",
49
+ r"(?:你|您)看(?:看|一下)?(?:是否|要不要|行不行)",
50
+ # English: handing the decision back
51
+ r"\blet me know\b",
52
+ r"\bwould you like\b",
53
+ r"\bdo you want\b",
54
+ r"\bshall i\b",
55
+ r"\bplease (?:confirm|advise|provide|choose)\b",
56
+ r"\byour call\b",
57
+ r"\bwhich (?:one|option|approach) (?:do you|would you)\b",
58
+ r"\bcan you (?:confirm|provide|share|tell)\b",
59
+ r"\bwaiting (?:for|on) your\b",
60
+ ]
61
+
62
+ # 确认/复述长期规则或未来条件约定(「以后遇到…我会先…」「可以,主人。以后…」)。
63
+ # 这是回答用户的规则设定,不是当前回合宣布下一步动作后漏调工具。
64
+ STANDING_RULE_PATTERNS = [
65
+ r"(?:以后|今后|之后|下次|后续|往后|接下来每次|每当|每次)[^。!?\n]{0,40}(?:提到|遇到|收到|让我|你让我|要求|需要|开始|做|时|的时候)[^。!?\n]{0,30}我(?:会|将|都会|先|就)",
66
+ r"^(?:可以|好的?|明白|没问题|收到|记下了|知道了)[,,。!!\s]*(?:主人|小主|老板)?[,,。!!\s]*(?:以后|今后|之后|下次|后续|往后|每当|每次)",
67
+ ]
68
+
69
+ # 「已经做完」的收尾。命中且无未来标记时否决。
70
+ DONE_PATTERNS = [
71
+ r"(?:已|已经)(?:经)?(?:完成|修好|改好|搞定|处理完|就绪|生效|上线|发布|通过)",
72
+ r"(?:全部|都)(?:完成|通过|搞定|就绪|闭合)",
73
+ r"(?:搞定|收工|完事|可以收尾)(?:了)?[。.!!]?$",
74
+ r"(?:到|就)这里(?:为止|结束)?",
75
+ r"(?:没有|无需)(?:其他|更多|别的)(?:改动|问题|需要)",
76
+ r"\b(?:all )?done\b",
77
+ r"\b(?:finished|completed|ready to go)\b",
78
+ r"\bnothing (?:else|more) (?:to do|needed)\b",
79
+ r"\ball (?:tests? )?pass(?:ed|ing)?\b",
80
+ ]
81
+
82
+ # 强未来标记:明确宣布「我即将做」。单独出现即高置信。
83
+ FUTURE_STRONG = [
84
+ # 「我先…」「我这就…」是中文里最典型的「宣布即将动手」,不必再跟动词。
85
+ r"我(?:先|这就|马上|立刻|接下来|现在)",
86
+ r"我(?:来|将|会|要|去)(?:先|先来)?",
87
+ r"先让我",
88
+ r"让(?:我们|我)先",
89
+ r"(?:接下来|下面|现在)我(?:来|将|会|要)",
90
+ r"开始(?:执行|处理|修改|实现|写|改|跑|检查)",
91
+ r"(?:^|[。!?\n]\s*)(?:开始[。,,\s]*)?先(?:把|将|从|去|给|对|用|跑|看|查|改|写|读|登记|核对|整理|创建|执行|检查)[^。!?\n]{1,60}再",
92
+ r"(?:^|[。!?\n]\s*)现在(?:重新|开始|把|将|先|去|来|给|对|跑|开|改|写|整理|执行|检查)",
93
+ r"\blet me\b",
94
+ r"\bi'?ll\b",
95
+ r"\bi will\b",
96
+ r"\bi'?m going to\b",
97
+ r"\bnow i'?ll\b",
98
+ r"\blet'?s\b",
99
+ ]
100
+
101
+ # 弱未来标记:推进语,但单独不足以说明「要动手」—— 也可能是继续讲解。
102
+ FUTURE_WEAK = [
103
+ r"接下来",
104
+ r"下一步",
105
+ r"下面(?:我|我们)?",
106
+ r"然后(?:我|我们)?",
107
+ r"首先",
108
+ r"现在(?:我|我们)?",
109
+ r"\bnext,?\b",
110
+ ]
111
+
112
+ # 动作动词:把「未来时」落到「要做一件具体的事」上。
113
+ ACTION_VERB = [
114
+ r"(?:检查|查看|看看|查一下|读取|打开|搜索|定位|核对|登记|整理)",
115
+ r"(?:修改|改成|改掉|编辑|写入|创建|新建|删除|移除|重写|写进)",
116
+ r"(?:执行|运行|跑一下|跑起|启动|重启|安装|部署|发布|提交|开窗口|重新开)",
117
+ r"(?:修复|实现|添加|增加|补充|更新|替换|迁移|重构)",
118
+ r"(?:验证|测试|校验|确认一下|跑通|编译|上机)",
119
+ r"\b(?:check|read|open|inspect|look at|search|grep)\b",
120
+ r"\b(?:edit|write|create|add|remove|delete|rewrite|patch|update)\b",
121
+ r"\b(?:run|execute|install|deploy|build|start|restart|publish)\b",
122
+ r"\b(?:fix|implement|refactor|migrate|verify|test)\b",
123
+ ]
124
+
125
+ # 尾部形态加权。冒号不足以独立触发。
126
+ COLON_TAIL = re.compile(r"[::]\s*\Z")
127
+ ELLIPSIS_TAIL = re.compile(r"(?:\.{3,}|…)\s*\Z")
128
+ QUESTION_TAIL = re.compile(r"[??]\s*\Z")
129
+
130
+ # action-colon 判定用:末行收尾的冒号、分句切分、列表项前缀。
131
+ _COLON_AT_END = re.compile(r"[::]\s*\Z")
132
+ _CLAUSE_SPLIT = re.compile(r"[,,。;;!!??]")
133
+ _LIST_ITEM = re.compile(r"^[-*•\d|]")
134
+
135
+ # 阈值。只有弱未来标记时必须靠额外信号才能过线。
136
+ THRESHOLD_STRONG = 3.0
137
+ THRESHOLD_WEAK_ONLY = 4.5
138
+
139
+ _CJK_CHAR = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
140
+
141
+ _COMPILED: Dict[str, list] = {}
142
+
143
+
144
+ def _compile(group: str, patterns: list) -> list:
145
+ """每个模式组只编译一次,且大小写不敏感(与 JS 版逐条带 ``i`` 等价)。
146
+
147
+ 注意:JS 版里只有英文模式带 ``i``,中文模式没有。这里统一用 IGNORECASE
148
+ 是安全的 —— 中文模式不含字母,加不加都一样。
149
+ """
150
+ if group not in _COMPILED:
151
+ _COMPILED[group] = [re.compile(p, re.IGNORECASE) for p in patterns]
152
+ return _COMPILED[group]
153
+
154
+
155
+ def _any_match(group: str, text: str) -> bool:
156
+ return any(rx.search(text) for rx in _compile(group, _PATTERN_GROUPS[group]))
157
+
158
+
159
+ _PATTERN_GROUPS = {
160
+ "request": REQUEST_PATTERNS,
161
+ "standing_rule": STANDING_RULE_PATTERNS,
162
+ "done": DONE_PATTERNS,
163
+ "future_strong": FUTURE_STRONG,
164
+ "future_weak": FUTURE_WEAK,
165
+ "action_verb": ACTION_VERB,
166
+ }
167
+
168
+
169
+ def _strip_noise(text: str) -> str:
170
+ """去掉代码围栏/行内代码/标签 —— 代码里的冒号和动词不是模型的收尾意图。"""
171
+ text = re.sub(r"```[\s\S]*?```", " ", text)
172
+ text = re.sub(r"`[^`\n]*`", " ", text)
173
+ text = re.sub(r"</?[a-zA-Z][^>]{0,200}>", " ", text)
174
+ text = re.sub(r"\[[^\]]{0,80}\]\([^)]{0,300}\)", " ", text)
175
+ text = re.sub(r"[ \t]+", " ", text)
176
+ return text.strip()
177
+
178
+
179
+ def _tail_of(text: str) -> str:
180
+ """最后两块非空段落,按字符上限截尾。"""
181
+ blocks = [b.strip() for b in re.split(r"\n{2,}", text) if b.strip()]
182
+ if not blocks:
183
+ return ""
184
+ joined = "\n".join(blocks[-TAIL_BLOCK_COUNT:]).strip()
185
+ return joined[-TAIL_CHAR_LIMIT:] if len(joined) > TAIL_CHAR_LIMIT else joined
186
+
187
+
188
+ def _weighted_length(text: str) -> float:
189
+ """中日韩字符按 1.6 计权后的长度,见 MIN_WEIGHTED_CHARS。"""
190
+ return sum(1.6 if _CJK_CHAR.match(ch) else 1.0 for ch in text)
191
+
192
+
193
+ def _result(verdict: str, score: float = 0.0, signals=None, stall: bool = False) -> Dict[str, Any]:
194
+ return {
195
+ "stall": stall,
196
+ "verdict": verdict,
197
+ "score": score,
198
+ "signals": list(signals or []),
199
+ "prompt": CONTINUE_PROMPT if stall else None,
200
+ }
201
+
202
+
203
+ def detect_stall(raw_text: Any, tool_call_count: int = 0) -> Dict[str, Any]:
204
+ """判定一条 assistant 收尾文本是否「宣布了下一步却没动手」。
205
+
206
+ ``tool_call_count > 0`` 一律不判 —— 已经有工具调用的轮次不存在「只说不做」。
207
+ """
208
+ if tool_call_count > 0:
209
+ return _result("had-tools")
210
+
211
+ raw = "" if raw_text is None else str(raw_text).strip()
212
+ if raw == "":
213
+ return _result("empty")
214
+
215
+ text = _strip_noise(raw)
216
+ if _weighted_length(text) < MIN_WEIGHTED_CHARS:
217
+ return _result("too-short")
218
+
219
+ tail = _tail_of(text)
220
+
221
+ # ── 否决 ①:以问句收尾 ────────────────────────────────────────────────
222
+ if QUESTION_TAIL.search(raw):
223
+ return _result("question")
224
+
225
+ # ── 否决 ②:把决定权交回用户 ──────────────────────────────────────────
226
+ if _any_match("request", tail):
227
+ return _result("asked-user")
228
+
229
+ # ── 否决 ③:确认/复述长期规则约定(「以后遇到…我会先…」)──────────────
230
+ if _any_match("standing_rule", tail):
231
+ return _result("standing-rule")
232
+
233
+ has_strong = _any_match("future_strong", tail)
234
+ has_weak = _any_match("future_weak", tail)
235
+
236
+ # ── 否决 ④:明确宣告完成且没有未来时 ──────────────────────────────────
237
+ if not has_strong and not has_weak and _any_match("done", tail):
238
+ return _result("already-done")
239
+
240
+ # ── 打分 ──────────────────────────────────────────────────────────────
241
+ score = 0.0
242
+ signals = []
243
+
244
+ if has_strong:
245
+ score += 2.0
246
+ signals.append("future-strong")
247
+ if has_weak:
248
+ score += 1.5
249
+ signals.append("future-weak")
250
+ has_verb = _any_match("action_verb", tail)
251
+ if has_verb:
252
+ score += 1.0
253
+ signals.append("action-verb")
254
+ has_colon = bool(COLON_TAIL.search(raw))
255
+ if has_colon:
256
+ score += 1.5
257
+ signals.append("colon-tail")
258
+ if ELLIPSIS_TAIL.search(raw):
259
+ score += 0.5
260
+ signals.append("ellipsis-tail")
261
+
262
+ # 末行非列表项以「动作动词 + 冒号」收尾(例如「……整份重写干净:」):
263
+ # 即使省略了显式「我先/接下来」代词,也是典型的即将发起工具调用。
264
+ #
265
+ # 动词必须落在冒号前的**最后一个分句**里,否则「已定位问题并修好,根因有
266
+ # 三层:」这类说明性收尾会被误判 —— 它的动词讲的是已完成的事,冒号引出的
267
+ # 是名词性解释。宁可漏掉一次续跑,也不要把说明性收尾当成漏执行。
268
+ lines = [line.strip() for line in raw.split("\n") if line.strip()]
269
+ last_line = lines[-1] if lines else ""
270
+ tail_clause = _CLAUSE_SPLIT.split(_COLON_AT_END.sub("", last_line))[-1] if last_line else ""
271
+ is_action_colon = (
272
+ has_colon
273
+ and has_verb
274
+ and _any_match("action_verb", tail_clause)
275
+ and not _LIST_ITEM.match(last_line)
276
+ and len(last_line) <= 160
277
+ )
278
+ if not has_strong and not has_weak and is_action_colon:
279
+ score += 1.0
280
+ signals.append("action-colon")
281
+
282
+ if not has_strong and not has_weak and not is_action_colon:
283
+ return _result("no-future-cue", score, signals)
284
+
285
+ threshold = THRESHOLD_STRONG if (has_strong or is_action_colon) else THRESHOLD_WEAK_ONLY
286
+ if score < threshold:
287
+ return _result("weak-only", score, signals)
288
+
289
+ return _result("stall", score, signals, stall=True)
290
+
291
+
292
+ # 回灌给模型的续跑提示。
293
+ #
294
+ # 刻意不命令「继续」,而是给出三个出口:执行 / 说明为何不需要执行 / 提出具体问题。
295
+ # 只写「继续」会让模型重复一遍刚才那段计划。
296
+ CONTINUE_PROMPT = "\n".join([
297
+ "你上一条回复宣布了下一步操作(例如「我先…」「接下来…」「Let me…」),但没有调用任何工具就结束了这一轮。",
298
+ "请直接执行这个操作。",
299
+ "如果它其实已经完成,或不需要执行,用一句话说明结论即可,不要重述计划。",
300
+ "如果需要用户先决定,提出一个具体的问题,不要继续描述待办。",
301
+ ])
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hermes-connect-services",
3
- "version": "1.1.0",
4
- "description": "Bring zcode-ai-connect's four loopback endpoints to hermes: lazy-start them on session start and sync them into hermes' config.yaml as OpenAI-compatible providers.",
3
+ "version": "1.3.0",
4
+ "description": "Bring zcode-ai-connect's four loopback endpoints to hermes, and add the missing turn-end gate that re-runs a turn when the model announces a next step but calls no tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": {
@@ -23,13 +23,15 @@
23
23
  "files": [
24
24
  "bin",
25
25
  "src",
26
+ "lib",
26
27
  "tests",
27
28
  "README.md",
28
29
  "LICENSE"
29
30
  ],
30
31
  "bin": {
31
32
  "hermes-connect-ensure": "bin/ensure-services.mjs",
32
- "hermes-connect-sync": "bin/sync-hermes-config.mjs"
33
+ "hermes-connect-sync": "bin/sync-hermes-config.mjs",
34
+ "hermes-connect-stall-gate": "bin/patch-hermes-stall-gate.mjs"
33
35
  },
34
36
  "dependencies": {},
35
37
  "keywords": [
@@ -39,6 +41,8 @@
39
41
  "trae",
40
42
  "workbuddy",
41
43
  "qoder",
42
- "qwenwork"
44
+ "qwenwork",
45
+ "stall-guard",
46
+ "hook"
43
47
  ]
44
48
  }
@@ -0,0 +1,73 @@
1
+ """给 tests/test-stall-gate.mjs 调用的探针:在宿主进程里跑真实闸门链。
2
+
3
+ 输出 JSON 行,一行一个用例,便于 Node 侧断言。找不到 hermes 时由调用方 SKIP,
4
+ 这里不做探测。
5
+ """
6
+ import json
7
+ import sys
8
+
9
+ agent_root = sys.argv[1]
10
+ sys.path.insert(0, agent_root)
11
+
12
+ from agent.turn_stop_gates import _ZC_STALL_MAX_NUDGES, apply_stop_gates # noqa: E402
13
+
14
+
15
+ class FakeAgent:
16
+ def __init__(self):
17
+ self._session_messages = []
18
+ self._turn_file_mutation_paths = set()
19
+ self.platform = "cli"
20
+ self.session_id = "stall-gate-test"
21
+ self.model = "test/model"
22
+
23
+ def _interim_content_was_streamed(self, text):
24
+ return True
25
+
26
+ def _emit_interim_assistant_message(self, msg):
27
+ pass
28
+
29
+ def _flush_messages_to_session_db(self, messages, history):
30
+ pass
31
+
32
+ def _emit_diagnostic_status(self, text):
33
+ pass
34
+
35
+
36
+ def run(text, tools=0):
37
+ agent = FakeAgent()
38
+ messages = [{"role": "user", "content": "帮我看看那个配置文件"}]
39
+ for i in range(tools):
40
+ messages.append({
41
+ "role": "assistant", "content": "",
42
+ "tool_calls": [{"id": f"c{i}", "type": "function",
43
+ "function": {"name": "read_file", "arguments": "{}"}}],
44
+ })
45
+ messages.append({"role": "tool", "content": "ok", "tool_call_id": f"c{i}"})
46
+ verdict = apply_stop_gates(
47
+ agent, {"role": "assistant", "content": text},
48
+ final_response=text, messages=messages, conversation_history=[],
49
+ pending_verification_response=None, pending_verification_response_previewed=False,
50
+ )
51
+ return {
52
+ "continue_turn": bool(verdict.continue_turn),
53
+ "nudge_role": messages[-1].get("role") if isinstance(messages[-1], dict) else None,
54
+ "nudge": messages[-1].get("content") if verdict.continue_turn else None,
55
+ "fallback": verdict.pending_verification_response,
56
+ }
57
+
58
+
59
+ CASES = [
60
+ {"name": "announce+colon", "text": "我先检查配置,再修改这里:", "tools": 0, "expect": True},
61
+ {"name": "done", "text": "已完成全部修改,测试通过。", "tools": 0, "expect": False},
62
+ {"name": "question", "text": "需要我先确认一下端口状态吗?", "tools": 0, "expect": False},
63
+ {"name": "handed-back", "text": "接下来需要你决定用哪个方案,我再动手。", "tools": 0, "expect": False},
64
+ {"name": "had-tools", "text": "我先检查配置,再修改这里:", "tools": 2, "expect": False},
65
+ {"name": "english", "text": "Let me check the config first, then patch the providers block:", "tools": 0, "expect": True},
66
+ ]
67
+
68
+ print(json.dumps({"max_nudges": _ZC_STALL_MAX_NUDGES}))
69
+ for case in CASES:
70
+ result = run(case["text"], case["tools"])
71
+ result["name"] = case["name"]
72
+ result["expect"] = case["expect"]
73
+ print(json.dumps(result, ensure_ascii=False))
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hermes 侧续跑闸门的回归测试。
4
+ *
5
+ * 直接 import 宿主的 `apply_stop_gates`,让**整条闸门链**真实跑一遍 —— late-LSP /
6
+ * verify-on-stop / pre_verify / kanban 四道闸都会执行,只是触发条件不成立。这比
7
+ * mock 一个函数名对得上要有说服力:它证明我们的闸门插在了正确的位置,而且返回值
8
+ * 形状能让 turn loop 接受。
9
+ *
10
+ * 本机没装 hermes 时 SKIP(不判失败),这样 CI 上也能通过。
11
+ *
12
+ * Run: node hermes-connect-services/tests/test-stall-gate.mjs
13
+ */
14
+ import { spawnSync } from 'node:child_process';
15
+ import { existsSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const HERE = dirname(fileURLToPath(import.meta.url));
20
+ const HOME_DIR = process.env.HOME ?? process.env.USERPROFILE ?? '';
21
+
22
+ console.log('test-stall-gate (hermes)');
23
+
24
+ function locateAgentRoot() {
25
+ const homes = [
26
+ process.env.HERMES_HOME,
27
+ process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, 'hermes') : null,
28
+ join(HOME_DIR, '.hermes'),
29
+ ].filter(Boolean);
30
+ for (const home of homes) {
31
+ const root = join(home, 'hermes-agent');
32
+ if (existsSync(join(root, 'agent', 'turn_stop_gates.py'))) return { home, root };
33
+ }
34
+ return null;
35
+ }
36
+
37
+ function findPython(home) {
38
+ const candidates = [
39
+ process.env.HERMES_PYTHON,
40
+ join(home, 'tools', 'python-3.14.9+20261021-win32-x64', 'python.exe'),
41
+ 'python',
42
+ 'python3',
43
+ ].filter(Boolean);
44
+ for (const exe of candidates) {
45
+ const probe = spawnSync(exe, ['-c', 'print(1)'], { encoding: 'utf8', timeout: 15000 });
46
+ if (probe.status === 0 && probe.stdout.trim() === '1') return exe;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ const located = locateAgentRoot();
52
+ if (!located) {
53
+ console.log(' SKIP 本机没有 hermes(找过 HERMES_HOME / LOCALAPPDATA / ~/.hermes)');
54
+ process.exit(0);
55
+ }
56
+
57
+ const gatePath = join(located.root, 'agent', 'turn_stop_gates.py');
58
+ const patched = existsSync(gatePath)
59
+ && spawnSync(process.execPath, ['-e', `process.exit(require('fs').readFileSync(${JSON.stringify(gatePath)},'utf8').includes('zcode-ai-connect stall-guard')?0:1)`]).status === 0;
60
+ if (!patched) {
61
+ console.log(' SKIP 补丁未打(先跑 bin/patch-hermes-stall-gate.mjs)');
62
+ process.exit(0);
63
+ }
64
+
65
+ const python = findPython(located.home);
66
+ if (!python) {
67
+ console.log(' SKIP 找不到 Python 解释器');
68
+ process.exit(0);
69
+ }
70
+
71
+ const probe = spawnSync(python, [join(HERE, 'probe_stall_gate.py'), located.root], {
72
+ encoding: 'utf8', timeout: 60000, cwd: located.root,
73
+ });
74
+ if (probe.status !== 0) {
75
+ console.log(` FAIL 探针执行失败(status=${probe.status})`);
76
+ console.log((probe.stderr || probe.stdout || '').slice(0, 1500));
77
+ process.exit(1);
78
+ }
79
+
80
+ const lines = probe.stdout.split('\n').map((l) => l.trim()).filter(Boolean);
81
+ let passed = 0;
82
+ let failed = 0;
83
+
84
+ const meta = JSON.parse(lines[0]);
85
+ console.log(` max nudges = ${meta.max_nudges}`);
86
+
87
+ for (const line of lines.slice(1)) {
88
+ const r = JSON.parse(line);
89
+ const ok = r.continue_turn === r.expect;
90
+ if (ok) {
91
+ passed += 1;
92
+ const extra = r.continue_turn ? ` nudge→${r.nudge_role} fallback=${r.fallback ? 'kept' : 'LOST'}` : '';
93
+ console.log(` ok ${r.name} → continue_turn=${r.continue_turn}${extra}`);
94
+ // 续跑时必须把候选答案留作 fallback,否则预算耗尽时用户什么都看不到
95
+ if (r.continue_turn && !r.fallback) {
96
+ failed += 1;
97
+ passed -= 1;
98
+ console.log(` FAIL ${r.name}: pending_verification_response 丢了`);
99
+ }
100
+ } else {
101
+ failed += 1;
102
+ console.log(` FAIL ${r.name} → continue_turn=${r.continue_turn} (expect ${r.expect})`);
103
+ }
104
+ }
105
+
106
+ console.log(`\n${passed} passed, ${failed} failed`);
107
+ process.exit(failed === 0 ? 0 : 1);