hermes-connect-services 1.0.1 → 1.2.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 +93 -8
- package/bin/patch-hermes-stall-gate.mjs +349 -0
- package/bin/sync-hermes-config.mjs +33 -14
- package/lib/stall_detector.py +256 -0
- package/package.json +8 -4
- package/src/hermes-home.mjs +122 -0
- package/src/roster.mjs +23 -14
- package/src/yaml-patch.mjs +22 -1
- package/tests/probe_stall_gate.py +73 -0
- package/tests/test-stall-gate.mjs +107 -0
- package/tests/test-yaml-patch.mjs +26 -0
package/README.md
CHANGED
|
@@ -21,6 +21,15 @@ node hermes-connect-services/bin/sync-hermes-config.mjs --install-hooks
|
|
|
21
21
|
|
|
22
22
|
两步可以合成一次带 `--install-hooks` 的运行。
|
|
23
23
|
|
|
24
|
+
`--install-hooks` 顺手把 hook 真正跑起来所需的两件东西一起办了(只写 config.yaml 是不够的):
|
|
25
|
+
|
|
26
|
+
| 目标文件 | 写入内容 | 少了它会怎样 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `shell-hooks-allowlist.json` | `on_session_start` 的审批条目 | hook 每次会话都被跳过,端点从不启动 |
|
|
29
|
+
| `.env` | 托管块 `NO_PROXY=127.0.0.1,…` | 代理软件会把回环调用吞掉 |
|
|
30
|
+
|
|
31
|
+
写完**重启 hermes**:hook 只在网关启动时注册。
|
|
32
|
+
|
|
24
33
|
也可以走 npm(四个端点包 + 本包装在同一项目里,脚本会自动在 `node_modules/` 找兄弟包):
|
|
25
34
|
|
|
26
35
|
```sh
|
|
@@ -109,34 +118,110 @@ model:
|
|
|
109
118
|
| `… --no-reasoning` | 全部模型标 `supports_reasoning: false` |
|
|
110
119
|
| `… --config <path>` | 指定配置文件 |
|
|
111
120
|
| `node bin/ensure-services.mjs [--verbose]` | 单独执行懒启动(也是 hook 本体) |
|
|
121
|
+
| `node bin/patch-hermes-stall-gate.mjs` | 给 hermes 补「宣布下一步却没动手」的续跑闸门 |
|
|
122
|
+
| `… --check` | 只报告补丁状态(升级后跑一次看是否失效) |
|
|
123
|
+
| `… --revert` | 撤销补丁,恢复到原始字节 |
|
|
124
|
+
|
|
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` 会告诉你当前状态。
|
|
112
159
|
|
|
113
|
-
|
|
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
|
+
## 六、排障
|
|
114
181
|
|
|
115
182
|
**401 / 模型不在了** — 端点 token 轮换或上游名册变了,重跑 `sync-hermes-config.mjs`。
|
|
116
183
|
|
|
117
|
-
**hook
|
|
184
|
+
**hook 被静默跳过 —— 这是最常见的"provider temporarily unavailable"真因。** hermes 只运行 `(event, command)` 出现在 `shell-hooks-allowlist.json` 里的 hook;没列入的会被日志记一句 `not allowlisted … — skipped` 然后**永不执行**,于是四个端点从未启动,hermes 侧只看到 connection refused,一路重试到 5 个 cycle。
|
|
185
|
+
|
|
186
|
+
`--install-hooks` 现在会自己把这一对写进 allowlist(位于 hermes home,与 config.yaml 同级),并保留你其他的 hook 审批。检查:
|
|
187
|
+
|
|
188
|
+
```sh
|
|
189
|
+
node bin/sync-hermes-config.mjs --install-hooks --verbose # 应输出 hook allowlist: added|present
|
|
190
|
+
grep -c allowlisted ~/AppData/Local/hermes/logs/agent.log # 不该再增长
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
手工兜底(可选,范围更大):
|
|
118
194
|
|
|
119
195
|
```yaml
|
|
120
196
|
hooks_auto_accept: true
|
|
121
197
|
```
|
|
122
198
|
|
|
123
|
-
|
|
199
|
+
**改完必须重启 hermes** — hook 在网关启动时注册,已运行的会话不会补挂。
|
|
200
|
+
|
|
201
|
+
**连不上回环地址(`404 no such route` 带绝对 URL,或 connection refused)** — hermes 是 Python + httpx,会读 `HTTP_PROXY` / `HTTPS_PROXY`。历史上这条会把手打回环的请求送去代理,`--install-hooks` 因此会在 hermes home 的 `.env` 里托管一段:
|
|
124
202
|
|
|
125
|
-
```sh
|
|
126
|
-
NO_PROXY=127.0.0.1,localhost # PowerShell: $env:NO_PROXY="127.0.0.1,localhost"
|
|
127
203
|
```
|
|
204
|
+
# >>> zcode-ai-connect >>>
|
|
205
|
+
NO_PROXY=127.0.0.1,localhost,::1,0.0.0.0
|
|
206
|
+
no_proxy=127.0.0.1,localhost,::1,0.0.0.0
|
|
207
|
+
# <<< zcode-ai-connect <<<
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
实测 httpx + openai SDK 通常已自行放行 `127.0.0.1`,这段属于兜底:机器换代理软件、或代理换成强制拦截型时能少一次排查。(`.env` 是 `override=True` 载入的,所以它会顶掉系统里同名的 `NO_PROXY`;要在代理侧仍走别的例外host,把它们的域名补进这四行。)
|
|
128
211
|
|
|
129
212
|
**Thoughts 太长被压缩** — `model_overrides` 里的 `context_window` 来自端点实时 roster;换模型名后重跑同步脚本同步上下文窗口。
|
|
130
213
|
|
|
131
214
|
**思考档位** — hermes 全局 `agent.reasoning_effort` 默认 `ultra`,这些端点会原样转发 OpenAI 的 `reasoning_effort`。上游不认 `ultra` 时把它降到 `high`。不想让任何模型思考就加 `--no-reasoning`。
|
|
132
215
|
|
|
133
|
-
##
|
|
216
|
+
## 七、测试
|
|
134
217
|
|
|
135
218
|
```sh
|
|
136
|
-
node
|
|
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)
|
|
137
222
|
```
|
|
138
223
|
|
|
139
|
-
|
|
224
|
+
`test-stall-gate.mjs` 直接 import 宿主的 `apply_stop_gates`,让整条闸门链真实跑一遍 —— 不是 mock 一个函数名对得上就算过。
|
|
140
225
|
|
|
141
226
|
---
|
|
142
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();
|
|
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
27
27
|
import { SERVICES, HOME, SUITE_ROOT, ensure as ensureService } from '../src/services.mjs';
|
|
28
28
|
import { loadRoster, describe } from '../src/roster.mjs';
|
|
29
29
|
import { patchManagedBlock, readManagedBlock, readManagedChildren } from '../src/yaml-patch.mjs';
|
|
30
|
+
import { allowHook, revokeHooks, ensureNoProxy, removeNoProxy } from '../src/hermes-home.mjs';
|
|
30
31
|
|
|
31
32
|
const HERE = pathDirname(fileURLToPath(import.meta.url));
|
|
32
33
|
const MARKER = 'zcode-ai-connect';
|
|
@@ -118,15 +119,20 @@ function overridesBlockYaml(service, models) {
|
|
|
118
119
|
function hooksBlockYaml() {
|
|
119
120
|
const nodeExe = process.execPath.replaceAll('\\', '/');
|
|
120
121
|
const script = join(HERE, 'ensure-services.mjs').replaceAll('\\', '/');
|
|
122
|
+
// The raw string hermes shlex-splits into argv; also the key it matches
|
|
123
|
+
// against shell-hooks-allowlist.json, so the two must stay byte-identical.
|
|
124
|
+
const command = `"${nodeExe}" "${script}"`;
|
|
121
125
|
// shlex.split + shell=False: the token that reaches Popen must keep both
|
|
122
126
|
// paths quoted, so the whole value is single-quoted YAML rather than two
|
|
123
127
|
// adjacent double-quoted scalars (which YAML rejects outright).
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
return {
|
|
129
|
+
command,
|
|
130
|
+
lines: [
|
|
131
|
+
`on_session_start:`,
|
|
132
|
+
` - command: '${command}'`,
|
|
133
|
+
` timeout: 30`,
|
|
134
|
+
],
|
|
135
|
+
};
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
// --- collect rosters --------------------------------------------------------
|
|
@@ -192,6 +198,10 @@ if (options.remove) {
|
|
|
192
198
|
const result = patchManagedBlock(text, key, '', { marker: MARKER });
|
|
193
199
|
text = result.text;
|
|
194
200
|
}
|
|
201
|
+
const HOME_DIR = pathDirname(CONFIG_PATH);
|
|
202
|
+
const revoked = revokeHooks(HOME_DIR, [hooksBlockYaml().command], { dryRun: options.dryRun });
|
|
203
|
+
if (revoked) console.log(` hook allowlist: removed ${revoked}`);
|
|
204
|
+
if (removeNoProxy(HOME_DIR, { dryRun: options.dryRun })) console.log(' no_proxy: removed');
|
|
195
205
|
} else {
|
|
196
206
|
const providerUpdates = [];
|
|
197
207
|
const overrideUpdates = [];
|
|
@@ -213,7 +223,8 @@ if (options.remove) {
|
|
|
213
223
|
}
|
|
214
224
|
|
|
215
225
|
if (options.installHooks) {
|
|
216
|
-
const
|
|
226
|
+
const hook = hooksBlockYaml();
|
|
227
|
+
const body = hook.lines.join('\n');
|
|
217
228
|
if (readManagedBlock(text, 'hooks', { marker: MARKER }).trim() !== '') {
|
|
218
229
|
text = patchManagedBlock(text, 'hooks', body, { marker: MARKER }).text;
|
|
219
230
|
changes.push('hooks: replaced');
|
|
@@ -226,6 +237,16 @@ if (options.remove) {
|
|
|
226
237
|
text = result.text;
|
|
227
238
|
changes.push(`hooks: ${result.action}`);
|
|
228
239
|
}
|
|
240
|
+
|
|
241
|
+
// Writing the hook is only half of it: hermes refuses to run any
|
|
242
|
+
// (event, command) it has not approved, and silently skips it each session.
|
|
243
|
+
const HOME_DIR = pathDirname(CONFIG_PATH);
|
|
244
|
+
const allowed = allowHook(HOME_DIR, 'on_session_start', hook.command, { dryRun: options.dryRun });
|
|
245
|
+
changes.push(`hook allowlist: ${allowed}`);
|
|
246
|
+
// httpx honours HTTP_PROXY; without this the loopback calls die as
|
|
247
|
+
// "Provider temporarily unavailable".
|
|
248
|
+
const proxied = ensureNoProxy(HOME_DIR, { dryRun: options.dryRun });
|
|
249
|
+
changes.push(`no_proxy: ${proxied}`);
|
|
229
250
|
}
|
|
230
251
|
console.log(changes.map((line) => ` ${line}`).join('\n'));
|
|
231
252
|
}
|
|
@@ -253,13 +274,11 @@ console.log(` hermes --provider ${collected[0].service.id} --model <id>`);
|
|
|
253
274
|
console.log('');
|
|
254
275
|
console.log('Token 轮换(报 401)或上游名册变化时,重跑本脚本即可。');
|
|
255
276
|
|
|
256
|
-
if (options.installHooks && !options.remove
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
console.log(' (当前值: ' + (autoAccept ?? '未设置') + ')');
|
|
262
|
-
}
|
|
277
|
+
if (options.installHooks && !options.remove) {
|
|
278
|
+
console.log('');
|
|
279
|
+
console.log('⚠ 重启 hermes 让两件事生效:');
|
|
280
|
+
console.log(' 1. on_session_start hook 重新注册(已写入 allowlist,不会再被跳过)');
|
|
281
|
+
console.log(' 2. .env 里的 NO_PROXY(否则 127.0.0.1 的请求会被 HTTP_PROXY 吞掉)');
|
|
263
282
|
}
|
|
264
283
|
|
|
265
284
|
if (options.remove) {
|
|
@@ -0,0 +1,256 @@
|
|
|
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"你(?:先|来)(?:确认|决定|操作|选)",
|
|
44
|
+
r"确认后(?:我)?再",
|
|
45
|
+
r"(?:你|您)看(?:看|一下)?(?:是否|要不要|行不行)",
|
|
46
|
+
# English: handing the decision back
|
|
47
|
+
r"\blet me know\b",
|
|
48
|
+
r"\bwould you like\b",
|
|
49
|
+
r"\bdo you want\b",
|
|
50
|
+
r"\bshall i\b",
|
|
51
|
+
r"\bplease (?:confirm|advise|provide|choose)\b",
|
|
52
|
+
r"\byour call\b",
|
|
53
|
+
r"\bwhich (?:one|option|approach) (?:do you|would you)\b",
|
|
54
|
+
r"\bcan you (?:confirm|provide|share|tell)\b",
|
|
55
|
+
r"\bwaiting (?:for|on) your\b",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# 「已经做完」的收尾。命中且无未来标记时否决。
|
|
59
|
+
DONE_PATTERNS = [
|
|
60
|
+
r"(?:已|已经)(?:经)?(?:完成|修好|改好|搞定|处理完|就绪|生效|上线|发布|通过)",
|
|
61
|
+
r"(?:全部|都)(?:完成|通过|搞定|就绪)",
|
|
62
|
+
r"(?:搞定|收工|完事)(?:了)?[。.!!]?$",
|
|
63
|
+
r"(?:到|就)这里(?:为止|结束)?",
|
|
64
|
+
r"(?:没有|无需)(?:其他|更多|别的)(?:改动|问题|需要)",
|
|
65
|
+
r"\b(?:all )?done\b",
|
|
66
|
+
r"\b(?:finished|completed|ready to go)\b",
|
|
67
|
+
r"\bnothing (?:else|more) (?:to do|needed)\b",
|
|
68
|
+
r"\ball (?:tests? )?pass(?:ed|ing)?\b",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# 强未来标记:明确宣布「我即将做」。单独出现即高置信。
|
|
72
|
+
FUTURE_STRONG = [
|
|
73
|
+
# 「我先…」「我这就…」是中文里最典型的「宣布即将动手」,不必再跟动词。
|
|
74
|
+
r"我(?:先|这就|马上|立刻|接下来|现在)",
|
|
75
|
+
r"我(?:来|将|会|要|去)(?:先|先来)?",
|
|
76
|
+
r"先让我",
|
|
77
|
+
r"让(?:我们|我)先",
|
|
78
|
+
r"(?:接下来|下面|现在)我(?:来|将|会|要)",
|
|
79
|
+
r"开始(?:执行|处理|修改|实现|写|改|跑|检查)",
|
|
80
|
+
r"\blet me\b",
|
|
81
|
+
r"\bi'?ll\b",
|
|
82
|
+
r"\bi will\b",
|
|
83
|
+
r"\bi'?m going to\b",
|
|
84
|
+
r"\bnow i'?ll\b",
|
|
85
|
+
r"\blet'?s\b",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# 弱未来标记:推进语,但单独不足以说明「要动手」—— 也可能是继续讲解。
|
|
89
|
+
FUTURE_WEAK = [
|
|
90
|
+
r"接下来",
|
|
91
|
+
r"下一步",
|
|
92
|
+
r"下面(?:我|我们)?",
|
|
93
|
+
r"然后(?:我|我们)?",
|
|
94
|
+
r"首先",
|
|
95
|
+
r"现在(?:我|我们)?",
|
|
96
|
+
r"\bnext,?\b",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
# 动作动词:把「未来时」落到「要做一件具体的事」上。
|
|
100
|
+
ACTION_VERB = [
|
|
101
|
+
r"(?:检查|查看|看看|查一下|读取|打开|搜索|定位)",
|
|
102
|
+
r"(?:修改|改成|改掉|编辑|写入|创建|新建|删除|移除|重写)",
|
|
103
|
+
r"(?:执行|运行|跑一下|启动|重启|安装|部署|发布|提交)",
|
|
104
|
+
r"(?:修复|实现|添加|增加|补充|更新|替换|迁移|重构)",
|
|
105
|
+
r"(?:验证|测试|校验|确认一下|跑通|编译)",
|
|
106
|
+
r"\b(?:check|read|open|inspect|look at|search|grep)\b",
|
|
107
|
+
r"\b(?:edit|write|create|add|remove|delete|rewrite|patch|update)\b",
|
|
108
|
+
r"\b(?:run|execute|install|deploy|build|start|restart|publish)\b",
|
|
109
|
+
r"\b(?:fix|implement|refactor|migrate|verify|test)\b",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
# 尾部形态加权。冒号不足以独立触发。
|
|
113
|
+
COLON_TAIL = re.compile(r"[::]\s*\Z")
|
|
114
|
+
ELLIPSIS_TAIL = re.compile(r"(?:\.{3,}|…)\s*\Z")
|
|
115
|
+
QUESTION_TAIL = re.compile(r"[??]\s*\Z")
|
|
116
|
+
|
|
117
|
+
# 阈值。只有弱未来标记时必须靠额外信号才能过线。
|
|
118
|
+
THRESHOLD_STRONG = 3.0
|
|
119
|
+
THRESHOLD_WEAK_ONLY = 4.5
|
|
120
|
+
|
|
121
|
+
_CJK_CHAR = re.compile(r"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
|
|
122
|
+
|
|
123
|
+
_COMPILED: Dict[str, list] = {}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _compile(group: str, patterns: list) -> list:
|
|
127
|
+
"""每个模式组只编译一次,且大小写不敏感(与 JS 版逐条带 ``i`` 等价)。
|
|
128
|
+
|
|
129
|
+
注意:JS 版里只有英文模式带 ``i``,中文模式没有。这里统一用 IGNORECASE
|
|
130
|
+
是安全的 —— 中文模式不含字母,加不加都一样。
|
|
131
|
+
"""
|
|
132
|
+
if group not in _COMPILED:
|
|
133
|
+
_COMPILED[group] = [re.compile(p, re.IGNORECASE) for p in patterns]
|
|
134
|
+
return _COMPILED[group]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _any_match(group: str, text: str) -> bool:
|
|
138
|
+
return any(rx.search(text) for rx in _compile(group, _PATTERN_GROUPS[group]))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_PATTERN_GROUPS = {
|
|
142
|
+
"request": REQUEST_PATTERNS,
|
|
143
|
+
"done": DONE_PATTERNS,
|
|
144
|
+
"future_strong": FUTURE_STRONG,
|
|
145
|
+
"future_weak": FUTURE_WEAK,
|
|
146
|
+
"action_verb": ACTION_VERB,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _strip_noise(text: str) -> str:
|
|
151
|
+
"""去掉代码围栏/行内代码/标签 —— 代码里的冒号和动词不是模型的收尾意图。"""
|
|
152
|
+
text = re.sub(r"```[\s\S]*?```", " ", text)
|
|
153
|
+
text = re.sub(r"`[^`\n]*`", " ", text)
|
|
154
|
+
text = re.sub(r"</?[a-zA-Z][^>]{0,200}>", " ", text)
|
|
155
|
+
text = re.sub(r"\[[^\]]{0,80}\]\([^)]{0,300}\)", " ", text)
|
|
156
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
157
|
+
return text.strip()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _tail_of(text: str) -> str:
|
|
161
|
+
"""最后两块非空段落,按字符上限截尾。"""
|
|
162
|
+
blocks = [b.strip() for b in re.split(r"\n{2,}", text) if b.strip()]
|
|
163
|
+
if not blocks:
|
|
164
|
+
return ""
|
|
165
|
+
joined = "\n".join(blocks[-TAIL_BLOCK_COUNT:]).strip()
|
|
166
|
+
return joined[-TAIL_CHAR_LIMIT:] if len(joined) > TAIL_CHAR_LIMIT else joined
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _weighted_length(text: str) -> float:
|
|
170
|
+
"""中日韩字符按 1.6 计权后的长度,见 MIN_WEIGHTED_CHARS。"""
|
|
171
|
+
return sum(1.6 if _CJK_CHAR.match(ch) else 1.0 for ch in text)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _result(verdict: str, score: float = 0.0, signals=None, stall: bool = False) -> Dict[str, Any]:
|
|
175
|
+
return {
|
|
176
|
+
"stall": stall,
|
|
177
|
+
"verdict": verdict,
|
|
178
|
+
"score": score,
|
|
179
|
+
"signals": list(signals or []),
|
|
180
|
+
"prompt": CONTINUE_PROMPT if stall else None,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def detect_stall(raw_text: Any, tool_call_count: int = 0) -> Dict[str, Any]:
|
|
185
|
+
"""判定一条 assistant 收尾文本是否「宣布了下一步却没动手」。
|
|
186
|
+
|
|
187
|
+
``tool_call_count > 0`` 一律不判 —— 已经有工具调用的轮次不存在「只说不做」。
|
|
188
|
+
"""
|
|
189
|
+
if tool_call_count > 0:
|
|
190
|
+
return _result("had-tools")
|
|
191
|
+
|
|
192
|
+
raw = "" if raw_text is None else str(raw_text).strip()
|
|
193
|
+
if raw == "":
|
|
194
|
+
return _result("empty")
|
|
195
|
+
|
|
196
|
+
text = _strip_noise(raw)
|
|
197
|
+
if _weighted_length(text) < MIN_WEIGHTED_CHARS:
|
|
198
|
+
return _result("too-short")
|
|
199
|
+
|
|
200
|
+
tail = _tail_of(text)
|
|
201
|
+
|
|
202
|
+
# ── 否决 ①:以问句收尾 ────────────────────────────────────────────────
|
|
203
|
+
if QUESTION_TAIL.search(raw):
|
|
204
|
+
return _result("question")
|
|
205
|
+
|
|
206
|
+
# ── 否决 ②:把决定权交回用户 ──────────────────────────────────────────
|
|
207
|
+
if _any_match("request", tail):
|
|
208
|
+
return _result("asked-user")
|
|
209
|
+
|
|
210
|
+
has_strong = _any_match("future_strong", tail)
|
|
211
|
+
has_weak = _any_match("future_weak", tail)
|
|
212
|
+
|
|
213
|
+
# ── 否决 ③:明确宣告完成且没有未来时 ──────────────────────────────────
|
|
214
|
+
if not has_strong and not has_weak and _any_match("done", tail):
|
|
215
|
+
return _result("already-done")
|
|
216
|
+
|
|
217
|
+
# ── 打分 ──────────────────────────────────────────────────────────────
|
|
218
|
+
score = 0.0
|
|
219
|
+
signals = []
|
|
220
|
+
|
|
221
|
+
if has_strong:
|
|
222
|
+
score += 2.0
|
|
223
|
+
signals.append("future-strong")
|
|
224
|
+
if has_weak:
|
|
225
|
+
score += 1.5
|
|
226
|
+
signals.append("future-weak")
|
|
227
|
+
if _any_match("action_verb", tail):
|
|
228
|
+
score += 1.0
|
|
229
|
+
signals.append("action-verb")
|
|
230
|
+
if COLON_TAIL.search(raw):
|
|
231
|
+
score += 1.5
|
|
232
|
+
signals.append("colon-tail")
|
|
233
|
+
if ELLIPSIS_TAIL.search(raw):
|
|
234
|
+
score += 0.5
|
|
235
|
+
signals.append("ellipsis-tail")
|
|
236
|
+
|
|
237
|
+
if not has_strong and not has_weak:
|
|
238
|
+
return _result("no-future-cue", score, signals)
|
|
239
|
+
|
|
240
|
+
threshold = THRESHOLD_STRONG if has_strong else THRESHOLD_WEAK_ONLY
|
|
241
|
+
if score < threshold:
|
|
242
|
+
return _result("weak-only", score, signals)
|
|
243
|
+
|
|
244
|
+
return _result("stall", score, signals, stall=True)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# 回灌给模型的续跑提示。
|
|
248
|
+
#
|
|
249
|
+
# 刻意不命令「继续」,而是给出三个出口:执行 / 说明为何不需要执行 / 提出具体问题。
|
|
250
|
+
# 只写「继续」会让模型重复一遍刚才那段计划。
|
|
251
|
+
CONTINUE_PROMPT = "\n".join([
|
|
252
|
+
"你上一条回复宣布了下一步操作(例如「我先…」「接下来…」「Let me…」),但没有调用任何工具就结束了这一轮。",
|
|
253
|
+
"请直接执行这个操作。",
|
|
254
|
+
"如果它其实已经完成,或不需要执行,用一句话说明结论即可,不要重述计划。",
|
|
255
|
+
"如果需要用户先决定,提出一个具体的问题,不要继续描述待办。",
|
|
256
|
+
])
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermes-connect-services",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Bring zcode-ai-connect's four loopback endpoints to hermes
|
|
3
|
+
"version": "1.2.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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hermes-home.mjs — the two side files a shell hook needs before it will run.
|
|
3
|
+
*
|
|
4
|
+
* `config.yaml` alone is not enough to make `hooks.on_session_start` fire:
|
|
5
|
+
*
|
|
6
|
+
* 1. shell-hooks-allowlist.json — hermes only runs a hook whose exact
|
|
7
|
+
* (event, command) pair is approved. Without it the gateway logs
|
|
8
|
+
* "not allowlisted ... — skipped" on every session and the endpoints are
|
|
9
|
+
* never started. We append the pair, keeping other hooks' approvals.
|
|
10
|
+
*
|
|
11
|
+
* 2. .env NO_PROXY — the OpenAI SDK underneath hermes is httpx, which honours
|
|
12
|
+
* HTTP_PROXY. A machine-wide proxy therefore swallows every 127.0.0.1 call
|
|
13
|
+
* and the provider dies with ECONNREFUSED. The loopback endpoints must be
|
|
14
|
+
* excluded from proxying.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const ALLOWLIST_FILENAME = 'shell-hooks-allowlist.json';
|
|
21
|
+
const ENV_MARKER_START = '# >>> zcode-ai-connect >>>';
|
|
22
|
+
const ENV_MARKER_END = '# <<< zcode-ai-connect <<<';
|
|
23
|
+
const LOOPBACK = '127.0.0.1,localhost,::1,0.0.0.0';
|
|
24
|
+
|
|
25
|
+
function readJson(path) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
return parsed && typeof parsed === 'object' ? parsed : { approvals: [] };
|
|
29
|
+
} catch {
|
|
30
|
+
return { approvals: [] };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Approve one (event, command) pair. Idempotent: an identical pair is left
|
|
36
|
+
* alone so the approved_at timestamp does not churn on every sync.
|
|
37
|
+
*
|
|
38
|
+
* @returns {'added' | 'present' | 'skipped-dry-run'}
|
|
39
|
+
*/
|
|
40
|
+
export function allowHook(homeDir, event, command, { dryRun = false } = {}) {
|
|
41
|
+
const path = join(homeDir, ALLOWLIST_FILENAME);
|
|
42
|
+
const data = readJson(path);
|
|
43
|
+
const approvals = Array.isArray(data.approvals) ? data.approvals : [];
|
|
44
|
+
if (approvals.some((e) => e && e.event === event && e.command === command)) return 'present';
|
|
45
|
+
if (dryRun) return 'skipped-dry-run';
|
|
46
|
+
|
|
47
|
+
if (existsSync(path)) copyFileSync(path, `${path}.bak`);
|
|
48
|
+
data.approvals = [...approvals, {
|
|
49
|
+
event,
|
|
50
|
+
command,
|
|
51
|
+
approved_at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
52
|
+
script_mtime_at_approval: null,
|
|
53
|
+
}];
|
|
54
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
55
|
+
return 'added';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Drop our own approvals (used by --remove). Other hooks stay approved. */
|
|
59
|
+
export function revokeHooks(homeDir, commands, { dryRun = false } = {}) {
|
|
60
|
+
const path = join(homeDir, ALLOWLIST_FILENAME);
|
|
61
|
+
if (!existsSync(path)) return 0;
|
|
62
|
+
const data = readJson(path);
|
|
63
|
+
const approvals = Array.isArray(data.approvals) ? data.approvals : [];
|
|
64
|
+
const kept = approvals.filter((e) => !(e && commands.includes(e.command)));
|
|
65
|
+
const removed = approvals.length - kept.length;
|
|
66
|
+
if (removed === 0 || dryRun) return removed;
|
|
67
|
+
copyFileSync(path, `${path}.bak`);
|
|
68
|
+
data.approvals = kept;
|
|
69
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
70
|
+
return removed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Make sure the loopback hosts bypass the machine proxy. The managed block is
|
|
75
|
+
* rewritten in place so repeated runs stay byte-stable.
|
|
76
|
+
*
|
|
77
|
+
* @returns {'wrote' | 'updated' | 'present' | 'skipped-dry-run'}
|
|
78
|
+
*/
|
|
79
|
+
export function ensureNoProxy(homeDir, { dryRun = false } = {}) {
|
|
80
|
+
const path = join(homeDir, '.env');
|
|
81
|
+
const body = [
|
|
82
|
+
ENV_MARKER_START,
|
|
83
|
+
`NO_PROXY=${LOOPBACK}`,
|
|
84
|
+
`no_proxy=${LOOPBACK}`,
|
|
85
|
+
ENV_MARKER_END,
|
|
86
|
+
].join('\n');
|
|
87
|
+
|
|
88
|
+
const text = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
89
|
+
const start = text.indexOf(ENV_MARKER_START);
|
|
90
|
+
const end = text.indexOf(ENV_MARKER_END);
|
|
91
|
+
|
|
92
|
+
if (start !== -1 && end !== -1 && end > start) {
|
|
93
|
+
const current = text.slice(start, end + ENV_MARKER_END.length);
|
|
94
|
+
if (current === body) return 'present';
|
|
95
|
+
if (dryRun) return 'skipped-dry-run';
|
|
96
|
+
copyFileSync(path, `${path}.bak`);
|
|
97
|
+
writeFileSync(path, text.slice(0, start) + body + text.slice(end + ENV_MARKER_END.length), 'utf8');
|
|
98
|
+
return 'updated';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (dryRun) return 'skipped-dry-run';
|
|
102
|
+
if (existsSync(path)) copyFileSync(path, `${path}.bak`);
|
|
103
|
+
const prefix = text === '' ? '' : (text.endsWith('\n') ? '\n' : '\n\n');
|
|
104
|
+
writeFileSync(path, `${text}${prefix}${body}\n`, 'utf8');
|
|
105
|
+
return 'wrote';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Remove the managed NO_PROXY block (used by --remove). */
|
|
109
|
+
export function removeNoProxy(homeDir, { dryRun = false } = {}) {
|
|
110
|
+
const path = join(homeDir, '.env');
|
|
111
|
+
if (!existsSync(path)) return false;
|
|
112
|
+
const text = readFileSync(path, 'utf8');
|
|
113
|
+
const start = text.indexOf(ENV_MARKER_START);
|
|
114
|
+
const end = text.indexOf(ENV_MARKER_END);
|
|
115
|
+
if (start === -1 || end === -1) return false;
|
|
116
|
+
if (dryRun) return true;
|
|
117
|
+
copyFileSync(path, `${path}.bak`);
|
|
118
|
+
const before = text.slice(0, start).replace(/\n+$/, '\n');
|
|
119
|
+
const after = text.slice(end + ENV_MARKER_END.length).replace(/^\n+/, '');
|
|
120
|
+
writeFileSync(path, `${before}${after}`, 'utf8');
|
|
121
|
+
return true;
|
|
122
|
+
}
|
package/src/roster.mjs
CHANGED
|
@@ -16,22 +16,31 @@ import { readEndpoint } from './services.mjs';
|
|
|
16
16
|
* @returns {Promise<{ baseUrl: string, token: string, port: number, rows: Array<object> }>}
|
|
17
17
|
* @throws when the endpoint is unreachable or answers non-200.
|
|
18
18
|
*/
|
|
19
|
-
export async function loadRoster(service, { timeoutMs = 5000 } = {}) {
|
|
19
|
+
export async function loadRoster(service, { timeoutMs = 5000, attempts = 3, delayMs = 1500 } = {}) {
|
|
20
20
|
const { token, port } = await readEndpoint(service);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
// A sidecar that `ensure` just spawned answers /healthz before its roster is
|
|
22
|
+
// populated; reading once here writes a provider with zero models and hermes
|
|
23
|
+
// then shows nothing under it. Retry the empty answer, not the error.
|
|
24
|
+
let last = { baseUrl: `http://127.0.0.1:${port}/v1`, token, port, rows: [] };
|
|
25
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
26
|
+
const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
|
27
|
+
headers: token === '' ? {} : { Authorization: `Bearer ${token}` },
|
|
28
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
29
|
+
});
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
throw new Error(`/v1/models -> ${response.status} (端点没跑?先 serve)`);
|
|
32
|
+
}
|
|
33
|
+
const json = await response.json();
|
|
34
|
+
last = {
|
|
35
|
+
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
36
|
+
token,
|
|
37
|
+
port,
|
|
38
|
+
rows: Array.isArray(json?.data) ? json.data : [],
|
|
39
|
+
};
|
|
40
|
+
if (last.rows.length > 0 || attempt === attempts) break;
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
27
42
|
}
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
31
|
-
token,
|
|
32
|
-
port,
|
|
33
|
-
rows: Array.isArray(json?.data) ? json.data : [],
|
|
34
|
-
};
|
|
43
|
+
return last;
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
/**
|
package/src/yaml-patch.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
const START = (marker) => `# >>> ${marker} >>>`;
|
|
21
21
|
const END = (marker) => `# <<< ${marker} <<<`;
|
|
22
|
+
/** A child key this suite owns — used to find an orphaned region's real start. */
|
|
23
|
+
const OWNS_CHILD = /^\s{0,4}zc-[A-Za-z0-9._-]+\s*:\s*$/;
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Replace the managed region inside one top-level mapping key.
|
|
@@ -156,6 +158,18 @@ function mappingRegion(lines, keyIndex) {
|
|
|
156
158
|
return { start: keyIndex + 1, end };
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Locate the managed region inside one mapping.
|
|
163
|
+
*
|
|
164
|
+
* `orphan` covers the case that actually bites in the field: hermes rewrites
|
|
165
|
+
* config.yaml from its own model (GUI model switch, `hermes config set`, …) and
|
|
166
|
+
* drops the opening marker comment while keeping the body and the closing one.
|
|
167
|
+
* Treating that as "no region" makes the next sync insert a second copy of every
|
|
168
|
+
* provider — duplicate top-level keys, and PyYAML silently keeps the last. So an
|
|
169
|
+
* orphan is claimed from its first owned child (`zc-*`) to the stray end marker
|
|
170
|
+
* and rewritten whole. Starting at the child—not at the mapping—is deliberate:
|
|
171
|
+
* a hand-written provider above ours must never be swallowed.
|
|
172
|
+
*/
|
|
159
173
|
function findManagedRegion(lines, region, startToken, endToken) {
|
|
160
174
|
let start = -1;
|
|
161
175
|
for (let i = region.start; i < region.end; i += 1) {
|
|
@@ -164,7 +178,14 @@ function findManagedRegion(lines, region, startToken, endToken) {
|
|
|
164
178
|
break;
|
|
165
179
|
}
|
|
166
180
|
}
|
|
167
|
-
if (start === -1)
|
|
181
|
+
if (start === -1) {
|
|
182
|
+
const endIndex = lines.findIndex((line, i) => i >= region.start && i < region.end && line.trim() === endToken);
|
|
183
|
+
if (endIndex === -1) return null;
|
|
184
|
+
for (let i = region.start; i < endIndex; i += 1) {
|
|
185
|
+
if (OWNS_CHILD.test(lines[i])) return { start: i, end: endIndex, orphan: true };
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
168
189
|
for (let i = start; i < region.end; i += 1) {
|
|
169
190
|
if (lines[i].trim() === endToken) return { start, end: i };
|
|
170
191
|
}
|
|
@@ -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);
|
|
@@ -106,4 +106,30 @@ check('survives an unterminated marker (hand-edit gone wrong)', () => {
|
|
|
106
106
|
assert.match(repaired.text, /# <<< zcode-ai-connect <<</);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
check('reclaims an orphaned region whose opening marker hermes ate', () => {
|
|
110
|
+
// Real-world shape: hermes rewrote config.yaml from its own model and kept the
|
|
111
|
+
// body plus the closing marker, losing only the opening one. A naive patcher
|
|
112
|
+
// reads "no region" and inserts a second copy of every provider — duplicate
|
|
113
|
+
// top-level keys that PyYAML silently collapses.
|
|
114
|
+
const orphan = [
|
|
115
|
+
'providers:',
|
|
116
|
+
' xiaoxin:',
|
|
117
|
+
' name: xiaoxin',
|
|
118
|
+
' zc-trae:',
|
|
119
|
+
' base_url: "a"',
|
|
120
|
+
` # <<< ${MARKER} <<<`,
|
|
121
|
+
'fallback_providers: []',
|
|
122
|
+
'',
|
|
123
|
+
].join('\n');
|
|
124
|
+
const fixed = patchManagedBlock(orphan, 'providers', 'zc-trae:\n base_url: "b"', { marker: MARKER });
|
|
125
|
+
assert.equal(fixed.action, 'replaced');
|
|
126
|
+
assert.equal(fixed.text.match(new RegExp(`# >>> ${MARKER} >>>`, 'g')).length, 1);
|
|
127
|
+
assert.equal(fixed.text.match(new RegExp(`# <<< ${MARKER} <<<`, 'g')).length, 1);
|
|
128
|
+
// The hand-written provider above ours survives; ours is rewritten.
|
|
129
|
+
assert.match(fixed.text, / {2}xiaoxin:\n {4}name: xiaoxin/);
|
|
130
|
+
assert.match(fixed.text, /base_url: "b"/);
|
|
131
|
+
// And it is stable from now on.
|
|
132
|
+
assert.equal(patchManagedBlock(fixed.text, 'providers', 'zc-trae:\n base_url: "b"', { marker: MARKER }).changed, false);
|
|
133
|
+
});
|
|
134
|
+
|
|
109
135
|
console.log(`\n${passed} passed`);
|