nonebot-plugin-hermes 0.2.1__tar.gz → 0.2.2__tar.gz
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.
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/PKG-INFO +1 -2
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/README.md +0 -1
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/__init__.py +1 -1
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/config.py +1 -7
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/hermes_client.py +118 -7
- nonebot_plugin_hermes-0.2.2/nonebot_plugin_hermes/core/inflight.py +71 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/handlers/commands.py +0 -1
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/handlers/message.py +263 -8
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/__init__.py +6 -2
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/PKG-INFO +1 -2
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/SOURCES.txt +3 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/pyproject.toml +1 -1
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_hermes_client_structured.py +119 -2
- nonebot_plugin_hermes-0.2.2/tests/test_inflight.py +91 -0
- nonebot_plugin_hermes-0.2.2/tests/test_message_handler_coalesce.py +543 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/hermes_install_skill.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/__init__.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/active_session.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/bot_registry.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/message_buffer.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/outbound.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/prompt_builder.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/session.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/handlers/__init__.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/auth.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/server.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/__init__.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/get_recent_messages.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/list_active_sessions.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/push_message.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/scripts/__init__.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/scripts/install_skill.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/skill/SKILL.md +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/skill/__init__.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/tasks/__init__.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/tasks/expire_active_sessions.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/utils.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/dependency_links.txt +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/entry_points.txt +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/requires.txt +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/top_level.txt +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/setup.cfg +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_active_session.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_bot_registry.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_mcp_auth.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_mcp_push_message.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_mcp_read_tools.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_message_buffer.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_prompt_builder.py +0 -0
- {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_session_manager.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-hermes
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: NoneBot plugin for Hermes Agent — multi-platform AI chatbot via Hermes API Server
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/gsskk/nonebot-plugin-hermes
|
|
@@ -304,7 +304,6 @@ mcp_servers:
|
|
|
304
304
|
| `HERMES_MCP_HOST` | `127.0.0.1` | MCP server 绑定地址。改成公开地址前请阅读上文「群活跃态 + 反向通道」节的安全注意 |
|
|
305
305
|
| `HERMES_MCP_PORT` | `8643` | MCP server 绑定端口 |
|
|
306
306
|
| `HERMES_MCP_RECENT_LIMIT_MAX` | `50` | `get_recent_messages` 工具单次最大返回条数 |
|
|
307
|
-
| `HERMES_STRUCTURED_PATH` | `prompt` | reactive 结构化输出路径: `prompt`(JSON5 解析) / `tools`(OpenAI tool_choice) |
|
|
308
307
|
|
|
309
308
|
## 限制
|
|
310
309
|
|
|
@@ -285,7 +285,6 @@ mcp_servers:
|
|
|
285
285
|
| `HERMES_MCP_HOST` | `127.0.0.1` | MCP server 绑定地址。改成公开地址前请阅读上文「群活跃态 + 反向通道」节的安全注意 |
|
|
286
286
|
| `HERMES_MCP_PORT` | `8643` | MCP server 绑定端口 |
|
|
287
287
|
| `HERMES_MCP_RECENT_LIMIT_MAX` | `50` | `get_recent_messages` 工具单次最大返回条数 |
|
|
288
|
-
| `HERMES_STRUCTURED_PATH` | `prompt` | reactive 结构化输出路径: `prompt`(JSON5 解析) / `tools`(OpenAI tool_choice) |
|
|
289
288
|
|
|
290
289
|
## 限制
|
|
291
290
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
所有配置项通过 NoneBot 的 .env 文件读取,前缀为 HERMES_。
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from typing import
|
|
7
|
+
from typing import Set
|
|
8
8
|
|
|
9
9
|
from nonebot import get_plugin_config
|
|
10
10
|
from pydantic import BaseModel, Field
|
|
@@ -106,11 +106,5 @@ class Config(BaseModel):
|
|
|
106
106
|
"""get_recent_messages 工具单次返回上限。最小 1——0/负值会让工具静默返空,
|
|
107
107
|
Pydantic 在启动期校验防 misconfig。"""
|
|
108
108
|
|
|
109
|
-
# --- M1: 结构化输出路径(由 P0-spike 决定) ---
|
|
110
|
-
hermes_structured_path: Literal["tools", "prompt"] = "prompt"
|
|
111
|
-
"""tools = 路径 A(tools+tool_choice);prompt = 路径 B(JSON5)。
|
|
112
|
-
Task 3 spike (2026-05-09) 结论:Hermes 不透传 tools/tool_choice 给底层 LLM,
|
|
113
|
-
必须用 prompt 强约束 + JSON5 容错解析。"""
|
|
114
|
-
|
|
115
109
|
|
|
116
110
|
plugin_config = get_plugin_config(Config)
|
|
@@ -7,6 +7,7 @@ M1-mem 路径 B(P0-spike 决策):tools/tool_choice 被 Hermes 吞掉,改用 syst
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import json
|
|
10
11
|
import re
|
|
11
12
|
from dataclasses import dataclass, field
|
|
12
13
|
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
|
@@ -23,6 +24,16 @@ UserContent = Union[str, List[Dict[str, Any]]]
|
|
|
23
24
|
_MD_IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
|
|
24
25
|
_MEDIA_TAG_PATTERN = re.compile(r"MEDIA:(\S+)")
|
|
25
26
|
|
|
27
|
+
# Hermes apiserver 把 provider 端错误包成 502, body 形如:
|
|
28
|
+
# {"error": {"message": "Error code: 400 - {'error': {'message': '...'}}"}}
|
|
29
|
+
# 把外层 502 当真因显示会误导, 因此抠出内层 status 与 reason。
|
|
30
|
+
_INNER_STATUS_RE = re.compile(r"Error code:\s*(\d+)")
|
|
31
|
+
_VISION_UNSUPPORTED_RE = re.compile(
|
|
32
|
+
r"unknown variant\s+[`'\"]image_url|image_url.*not.*support|does not support.*image|"
|
|
33
|
+
r"input_image.*not.*support|multimodal.*not.*support",
|
|
34
|
+
re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
|
|
26
37
|
# 提取首个 {...} 块。
|
|
27
38
|
# 当前正则只支持嵌套一层(`\{[^{}]*\}` 出现在外层 `\{...\}` 中)。
|
|
28
39
|
# M1 submit_decision schema 全平,够用。如未来 schema 加 nested object,
|
|
@@ -36,10 +47,101 @@ _DECISION_HINT = (
|
|
|
36
47
|
" reply_text (string, optional, required when should_reply=true)\n"
|
|
37
48
|
" topic_hint (string, optional)\n"
|
|
38
49
|
" should_exit_active (boolean, optional)\n"
|
|
39
|
-
"Output ONLY the JSON object, no preamble, no postscript, no markdown fences
|
|
50
|
+
"Output ONLY the JSON object, no preamble, no postscript, no markdown fences.\n"
|
|
51
|
+
"All string values MUST be single-line; escape line breaks inside strings as \\n (no raw newlines)."
|
|
40
52
|
)
|
|
41
53
|
|
|
42
54
|
|
|
55
|
+
def _escape_raw_newlines_in_strings(s: str) -> str:
|
|
56
|
+
"""把 JSON 字符串字面量内部的裸 \\n/\\r/\\t 转义掉,让 json5 能解析。
|
|
57
|
+
|
|
58
|
+
LLM 经常在 reply_text 里嵌真换行(段落分隔),JSON5 字符串不允许;首发 json5
|
|
59
|
+
抛 `Unexpected "\\n"` 后我们走这一遍状态机重试一次。状态:跟踪 " / ' 进出
|
|
60
|
+
string、`\\X` 整对透传(不参与 quote 计数),quoted 区里把裸控制字符替换。
|
|
61
|
+
"""
|
|
62
|
+
out: List[str] = []
|
|
63
|
+
in_string = False
|
|
64
|
+
quote = ""
|
|
65
|
+
i = 0
|
|
66
|
+
n = len(s)
|
|
67
|
+
while i < n:
|
|
68
|
+
c = s[i]
|
|
69
|
+
if not in_string:
|
|
70
|
+
if c == '"' or c == "'":
|
|
71
|
+
in_string = True
|
|
72
|
+
quote = c
|
|
73
|
+
out.append(c)
|
|
74
|
+
i += 1
|
|
75
|
+
continue
|
|
76
|
+
# 在 string 内
|
|
77
|
+
if c == "\\" and i + 1 < n:
|
|
78
|
+
# 转义序列整对透传(含 \" / \' / \\ / \n 等),不参与 quote 计数
|
|
79
|
+
out.append(s[i : i + 2])
|
|
80
|
+
i += 2
|
|
81
|
+
continue
|
|
82
|
+
if c == quote:
|
|
83
|
+
in_string = False
|
|
84
|
+
quote = ""
|
|
85
|
+
out.append(c)
|
|
86
|
+
i += 1
|
|
87
|
+
continue
|
|
88
|
+
if c == "\n":
|
|
89
|
+
out.append("\\n")
|
|
90
|
+
elif c == "\r":
|
|
91
|
+
out.append("\\r")
|
|
92
|
+
elif c == "\t":
|
|
93
|
+
out.append("\\t")
|
|
94
|
+
else:
|
|
95
|
+
out.append(c)
|
|
96
|
+
i += 1
|
|
97
|
+
return "".join(out)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _summarize_error_body(body: str) -> Tuple[str, Optional[int]]:
|
|
101
|
+
"""从 Hermes 错误响应体里抠出可读 reason 与内层 status。
|
|
102
|
+
|
|
103
|
+
Hermes apiserver 习惯把 provider 端错误外包成 502, body 是 JSON, `error.message`
|
|
104
|
+
形如 `"Error code: 400 - {...}"`。直接把 200 字符 body 倒进日志噪音大、
|
|
105
|
+
用户看到只剩外层 502 完全不知道发生了什么, 所以这里先剥一层。
|
|
106
|
+
|
|
107
|
+
返回 (reason_snippet, inner_status_or_None);body 不可解析时退化到 body 截断。
|
|
108
|
+
"""
|
|
109
|
+
if not body:
|
|
110
|
+
return "(empty body)", None
|
|
111
|
+
raw = body.strip()
|
|
112
|
+
try:
|
|
113
|
+
parsed = json.loads(raw)
|
|
114
|
+
except (json.JSONDecodeError, ValueError):
|
|
115
|
+
return raw[:200], None
|
|
116
|
+
err = parsed.get("error") if isinstance(parsed, dict) else None
|
|
117
|
+
if isinstance(err, dict):
|
|
118
|
+
msg = str(err.get("message") or err).strip()
|
|
119
|
+
elif isinstance(err, str):
|
|
120
|
+
msg = err.strip()
|
|
121
|
+
else:
|
|
122
|
+
msg = raw[:200]
|
|
123
|
+
inner_status: Optional[int] = None
|
|
124
|
+
m = _INNER_STATUS_RE.search(msg)
|
|
125
|
+
if m:
|
|
126
|
+
try:
|
|
127
|
+
inner_status = int(m.group(1))
|
|
128
|
+
except ValueError:
|
|
129
|
+
inner_status = None
|
|
130
|
+
return msg[:300], inner_status
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _user_facing_error(reason: str) -> str:
|
|
134
|
+
"""把 _summarize_error_body 的 reason 翻译成给群里发的简短提示。
|
|
135
|
+
|
|
136
|
+
命中已知模式(目前: 图片输入被非 vision 模型拒)就给精准提示;否则带 reason 片段
|
|
137
|
+
让用户能直接看到真因, 不再只露一个误导性的 502。
|
|
138
|
+
"""
|
|
139
|
+
if _VISION_UNSUPPORTED_RE.search(reason):
|
|
140
|
+
return "⚠️ 当前主模型不支持图片识别,请改用文字提问或换用 vision 模型"
|
|
141
|
+
snippet = reason.strip().splitlines()[0][:140] if reason.strip() else "未知错误"
|
|
142
|
+
return f"⚠️ AI 服务异常: {snippet}"
|
|
143
|
+
|
|
144
|
+
|
|
43
145
|
def extract_response_media(text: str) -> Tuple[str, List[str]]:
|
|
44
146
|
"""从 Hermes 回复中提取 markdown 图片 / MEDIA: 标签 URL,返回 (清洗后文本, URL 列表)。"""
|
|
45
147
|
media_urls: List[str] = []
|
|
@@ -55,16 +157,24 @@ def extract_response_media(text: str) -> Tuple[str, List[str]]:
|
|
|
55
157
|
|
|
56
158
|
|
|
57
159
|
def _try_parse_first_json_block(text: str) -> Optional[Dict[str, Any]]:
|
|
58
|
-
"""从模型回复中提取首个 {...} 块并 JSON5 解析。失败返回 None,调用方记 parse_failed。
|
|
160
|
+
"""从模型回复中提取首个 {...} 块并 JSON5 解析。失败返回 None,调用方记 parse_failed。
|
|
161
|
+
|
|
162
|
+
两段式回退:json5 首发失败 → 走 _escape_raw_newlines_in_strings 把字符串内
|
|
163
|
+
的裸控制字符转义后重试。两次都失败才返回 None。
|
|
164
|
+
"""
|
|
59
165
|
if not text:
|
|
60
166
|
return None
|
|
61
167
|
m = _FIRST_JSON_BLOCK.search(text)
|
|
62
168
|
if not m:
|
|
63
169
|
return None
|
|
170
|
+
candidate = m.group(0)
|
|
64
171
|
try:
|
|
65
|
-
parsed = json5.loads(
|
|
172
|
+
parsed = json5.loads(candidate)
|
|
66
173
|
except Exception:
|
|
67
|
-
|
|
174
|
+
try:
|
|
175
|
+
parsed = json5.loads(_escape_raw_newlines_in_strings(candidate))
|
|
176
|
+
except Exception:
|
|
177
|
+
return None
|
|
68
178
|
if not isinstance(parsed, dict):
|
|
69
179
|
return None
|
|
70
180
|
return parsed
|
|
@@ -212,10 +322,11 @@ class HermesClient:
|
|
|
212
322
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
213
323
|
resp = await client.post(url, json=payload, headers=headers)
|
|
214
324
|
if resp.status_code != 200:
|
|
215
|
-
|
|
216
|
-
|
|
325
|
+
reason, inner_status = _summarize_error_body(resp.text)
|
|
326
|
+
inner_tag = f" inner={inner_status}" if inner_status else ""
|
|
327
|
+
logger.error(f"[HERMES] upstream HTTP {resp.status_code}{inner_tag}: {reason}")
|
|
217
328
|
return ChatResult(
|
|
218
|
-
raw_text=
|
|
329
|
+
raw_text=_user_facing_error(reason),
|
|
219
330
|
parse_failed=True,
|
|
220
331
|
is_transport_error=True,
|
|
221
332
|
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""In-flight 调用追踪 + coalesce 重燃支持。
|
|
2
|
+
|
|
3
|
+
修同一 (adapter, group_id|user_id) 上事件 task 并发调 chat() 的 bug:
|
|
4
|
+
in-flight 时新消息只更新 pending 单元,等当前一发完成后再合并跑一次。
|
|
5
|
+
|
|
6
|
+
线程安全:**否**。预设单线程 asyncio 事件循环,与 ActiveSessionManager 一致。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Dict, Literal, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .message_buffer import BufferedMessage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Refire 链最大深度。超过则丢 pending、warn,等下一个新触发。
|
|
18
|
+
# 一次 burst 最多产出 1(主回) + MAX_REFIRE_DEPTH(链尾)= 4 发回复。
|
|
19
|
+
MAX_REFIRE_DEPTH = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class InflightSlot:
|
|
24
|
+
started_at: int
|
|
25
|
+
pending: Optional[BufferedMessage] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InflightRegistry:
|
|
29
|
+
"""per-target 非阻塞 busy 标记 + pending 单元。
|
|
30
|
+
|
|
31
|
+
Key 约定:
|
|
32
|
+
- 群: ("adapter", "group:" + group_id)
|
|
33
|
+
- 私聊: ("adapter", "private:" + user_id)
|
|
34
|
+
|
|
35
|
+
不持有任何 asyncio.Task 引用 —— 重燃由 caller 用 create_task 自己接手,
|
|
36
|
+
registry 只负责「现在有没有人在跑」+「跑完后是否要再跑一次」两个状态。
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._slots: Dict[Tuple[str, str], InflightSlot] = {}
|
|
41
|
+
|
|
42
|
+
def try_enter(
|
|
43
|
+
self,
|
|
44
|
+
key: Tuple[str, str],
|
|
45
|
+
current_msg: BufferedMessage,
|
|
46
|
+
now_ms: int,
|
|
47
|
+
) -> Literal["entered", "pending_set"]:
|
|
48
|
+
"""无 slot → 占位 started_at=now_ms,返回 'entered'。
|
|
49
|
+
有 slot → 把 current_msg 写进 pending(覆盖旧 pending),返回 'pending_set'。
|
|
50
|
+
"""
|
|
51
|
+
slot = self._slots.get(key)
|
|
52
|
+
if slot is None:
|
|
53
|
+
self._slots[key] = InflightSlot(started_at=now_ms)
|
|
54
|
+
return "entered"
|
|
55
|
+
slot.pending = current_msg
|
|
56
|
+
return "pending_set"
|
|
57
|
+
|
|
58
|
+
def take_pending(self, key: Tuple[str, str]) -> Optional[BufferedMessage]:
|
|
59
|
+
"""Destructive read。无 slot 或 pending 为 None 都返回 None。"""
|
|
60
|
+
slot = self._slots.get(key)
|
|
61
|
+
if slot is None:
|
|
62
|
+
return None
|
|
63
|
+
msg = slot.pending
|
|
64
|
+
slot.pending = None
|
|
65
|
+
return msg
|
|
66
|
+
|
|
67
|
+
def exit(self, key: Tuple[str, str]) -> None:
|
|
68
|
+
"""释放 slot。pending 仍在的话由调用方自行先 take_pending。
|
|
69
|
+
slot 不存在则 no-op。
|
|
70
|
+
"""
|
|
71
|
+
self._slots.pop(key, None)
|
|
@@ -168,7 +168,6 @@ async def handle_status(bot: Bot, event: Event, matcher: Matcher):
|
|
|
168
168
|
"🔍 Hermes Plugin M1-mem 状态",
|
|
169
169
|
f"MCP: {mcp_line}",
|
|
170
170
|
f"active_session: {active_line}",
|
|
171
|
-
f"structured_path: {plugin_config.hermes_structured_path}",
|
|
172
171
|
f"hermes_api: {plugin_config.hermes_api_url}",
|
|
173
172
|
"",
|
|
174
173
|
f"📊 ActiveSessions: {active_count} 个活跃",
|
|
@@ -7,6 +7,7 @@ priority=98 main:触发判断 → reactive 决策 → 出向
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import asyncio
|
|
10
11
|
import time
|
|
11
12
|
from typing import List, Optional
|
|
12
13
|
|
|
@@ -264,7 +265,7 @@ async def handle_message(bot: Bot, event: Event, matcher: Matcher):
|
|
|
264
265
|
)
|
|
265
266
|
|
|
266
267
|
|
|
267
|
-
async def
|
|
268
|
+
async def _run_passive_turn(
|
|
268
269
|
*,
|
|
269
270
|
bot: Bot,
|
|
270
271
|
target,
|
|
@@ -276,6 +277,7 @@ async def _handle_passive_path(
|
|
|
276
277
|
is_private: bool,
|
|
277
278
|
now_ms: int,
|
|
278
279
|
):
|
|
280
|
+
"""跑一发 passive turn,返回 ChatResult 或 None(被 submit_decision 静默兜底等情况)。"""
|
|
279
281
|
session_key = session_manager.get_session_key(
|
|
280
282
|
adapter_name=adapter_name,
|
|
281
283
|
is_private=is_private,
|
|
@@ -323,12 +325,12 @@ async def _handle_passive_path(
|
|
|
323
325
|
if extracted is not None:
|
|
324
326
|
if extracted == "":
|
|
325
327
|
logger.info(f"[HERMES passive] LLM 返回 should_reply=false 结构,静默(group={group_id})")
|
|
326
|
-
return
|
|
328
|
+
return result
|
|
327
329
|
logger.warning(f"[HERMES passive] 检测到 submit_decision 形 JSON 残留,抠 reply_text 后发送(group={group_id})")
|
|
328
330
|
reply_text = extracted
|
|
329
331
|
|
|
330
332
|
if not reply_text and not result.media_urls:
|
|
331
|
-
return
|
|
333
|
+
return result
|
|
332
334
|
await send_text_with_media(
|
|
333
335
|
bot=bot,
|
|
334
336
|
target=target,
|
|
@@ -336,9 +338,10 @@ async def _handle_passive_path(
|
|
|
336
338
|
media_urls=result.media_urls,
|
|
337
339
|
at_user_id=None if is_private else user_id,
|
|
338
340
|
)
|
|
341
|
+
return result
|
|
339
342
|
|
|
340
343
|
|
|
341
|
-
async def
|
|
344
|
+
async def _run_reactive_turn(
|
|
342
345
|
*,
|
|
343
346
|
bot: Bot,
|
|
344
347
|
target,
|
|
@@ -350,13 +353,18 @@ async def _handle_reactive_path(
|
|
|
350
353
|
is_explicit_trigger: bool,
|
|
351
354
|
now_ms: int,
|
|
352
355
|
):
|
|
356
|
+
"""跑一发 reactive turn,返回 hermes_client.chat() 的 ChatResult,或 None 表示提前 return。
|
|
357
|
+
|
|
358
|
+
外壳 _handle_reactive_path 负责 inflight + 图片门控,这里只管:
|
|
359
|
+
拉 recent → 组 prompt → 调 chat → 解析 decision → 发出向 → 回写 buffer。
|
|
360
|
+
"""
|
|
353
361
|
assert _mcp.message_buffer is not None and _mcp.active_sessions is not None
|
|
354
362
|
|
|
355
363
|
# 用 get_if_active 而非 get():get() 是 debug-only 裸访问,可能返回已过期 session;
|
|
356
364
|
# get_if_active 与 is_active(handle_message 入口处用过)同口径。
|
|
357
365
|
session = _mcp.active_sessions.get_if_active(adapter_name, group_id, now_ms)
|
|
358
366
|
if session is None:
|
|
359
|
-
return # 防御:窗口刚刚过期 / 被外部 end()
|
|
367
|
+
return None # 防御:窗口刚刚过期 / 被外部 end()
|
|
360
368
|
|
|
361
369
|
recent = _mcp.message_buffer.get_recent(
|
|
362
370
|
adapter=adapter_name,
|
|
@@ -418,7 +426,7 @@ async def _handle_reactive_path(
|
|
|
418
426
|
media_urls=result.media_urls,
|
|
419
427
|
at_user_id=user_id,
|
|
420
428
|
)
|
|
421
|
-
return
|
|
429
|
+
return result
|
|
422
430
|
|
|
423
431
|
decision_summary = (
|
|
424
432
|
f"should_reply={result.structured.get('should_reply')} "
|
|
@@ -435,11 +443,11 @@ async def _handle_reactive_path(
|
|
|
435
443
|
|
|
436
444
|
if not decision.get("should_reply"):
|
|
437
445
|
logger.debug(f"[HERMES reactive] should_reply=false (group={group_id})")
|
|
438
|
-
return
|
|
446
|
+
return result
|
|
439
447
|
|
|
440
448
|
reply_text = str(decision.get("reply_text") or "").strip()
|
|
441
449
|
if not reply_text:
|
|
442
|
-
return
|
|
450
|
+
return result
|
|
443
451
|
|
|
444
452
|
# 群里明确说话给某人 → at;主动插话 → 不 at
|
|
445
453
|
at_user = user_id if is_explicit_trigger else None
|
|
@@ -469,3 +477,250 @@ async def _handle_reactive_path(
|
|
|
469
477
|
# 注:若 should_exit_active=True,session 已在上方 end(),touch 是安全 no-op
|
|
470
478
|
# (ActiveSessionManager.touch 文档:session 缺失则 no-op)。
|
|
471
479
|
_mcp.active_sessions.touch(adapter_name, group_id, now_ms=now_ms)
|
|
480
|
+
|
|
481
|
+
return result
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
async def _handle_passive_path(
|
|
485
|
+
*,
|
|
486
|
+
bot: Bot,
|
|
487
|
+
target,
|
|
488
|
+
adapter_name: str,
|
|
489
|
+
user_id: str,
|
|
490
|
+
group_id: Optional[str],
|
|
491
|
+
text: str,
|
|
492
|
+
image_urls: List[str],
|
|
493
|
+
is_private: bool,
|
|
494
|
+
now_ms: int,
|
|
495
|
+
):
|
|
496
|
+
"""Passive 外壳:inflight 占位 → _run_passive_turn → 合并重燃。
|
|
497
|
+
|
|
498
|
+
与 reactive 同形,key 含 private/group 前缀区分。
|
|
499
|
+
"""
|
|
500
|
+
assert _mcp.inflight is not None
|
|
501
|
+
|
|
502
|
+
scope_id = user_id if is_private else (group_id or "")
|
|
503
|
+
scope_prefix = "private" if is_private else "group"
|
|
504
|
+
key = (adapter_name, f"{scope_prefix}:{scope_id}")
|
|
505
|
+
|
|
506
|
+
current_buffered = BufferedMessage(
|
|
507
|
+
ts=now_ms,
|
|
508
|
+
adapter=adapter_name,
|
|
509
|
+
group_id=group_id,
|
|
510
|
+
user_id=user_id,
|
|
511
|
+
nickname=user_id,
|
|
512
|
+
content=text,
|
|
513
|
+
image_urls=list(image_urls),
|
|
514
|
+
reply_to_ts=None,
|
|
515
|
+
is_bot=False,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if _mcp.inflight.try_enter(key, current_buffered, now_ms) == "pending_set":
|
|
519
|
+
return
|
|
520
|
+
|
|
521
|
+
should_refire = False
|
|
522
|
+
try:
|
|
523
|
+
result = await _run_passive_turn(
|
|
524
|
+
bot=bot,
|
|
525
|
+
target=target,
|
|
526
|
+
adapter_name=adapter_name,
|
|
527
|
+
user_id=user_id,
|
|
528
|
+
group_id=group_id,
|
|
529
|
+
text=text,
|
|
530
|
+
image_urls=image_urls,
|
|
531
|
+
is_private=is_private,
|
|
532
|
+
now_ms=now_ms,
|
|
533
|
+
)
|
|
534
|
+
should_refire = not (result is not None and result.is_transport_error)
|
|
535
|
+
except Exception:
|
|
536
|
+
logger.exception(f"[HERMES] passive turn raised; dropping pending for {key}")
|
|
537
|
+
should_refire = False
|
|
538
|
+
raise
|
|
539
|
+
finally:
|
|
540
|
+
if not should_refire:
|
|
541
|
+
_mcp.inflight.exit(key)
|
|
542
|
+
else:
|
|
543
|
+
pending = _mcp.inflight.take_pending(key)
|
|
544
|
+
if pending is None or pending.ts <= current_buffered.ts:
|
|
545
|
+
_mcp.inflight.exit(key)
|
|
546
|
+
else:
|
|
547
|
+
asyncio.create_task(
|
|
548
|
+
_refire(
|
|
549
|
+
key=key,
|
|
550
|
+
trigger_msg=pending,
|
|
551
|
+
depth=1,
|
|
552
|
+
mode="passive",
|
|
553
|
+
bot=bot,
|
|
554
|
+
target=target,
|
|
555
|
+
adapter_name=adapter_name,
|
|
556
|
+
group_id=group_id,
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
async def _handle_reactive_path(
|
|
562
|
+
*,
|
|
563
|
+
bot: Bot,
|
|
564
|
+
target,
|
|
565
|
+
adapter_name: str,
|
|
566
|
+
user_id: str,
|
|
567
|
+
group_id: str,
|
|
568
|
+
text: str,
|
|
569
|
+
image_urls: List[str],
|
|
570
|
+
is_explicit_trigger: bool,
|
|
571
|
+
now_ms: int,
|
|
572
|
+
):
|
|
573
|
+
"""Reactive 外壳:inflight 占位 → 调 _run_reactive_turn → finally 合并重燃。
|
|
574
|
+
|
|
575
|
+
coalesce 语义:in-flight 期间到来的新触发不并发跑,只覆盖 pending 单元;
|
|
576
|
+
本发完成后 take_pending,如有则用 create_task 起一个 _refire 接力,
|
|
577
|
+
本 task 立即 return,不阻塞 NoneBot 事件循环。
|
|
578
|
+
"""
|
|
579
|
+
assert _mcp.inflight is not None and _mcp.active_sessions is not None
|
|
580
|
+
|
|
581
|
+
# 图片门控:active window + 非显式触发 + 纯图无文本 → 跳过 chat()
|
|
582
|
+
# 理由:LLM 自己的 should_reply 决策对图片要先看完才能定,而看图本身慢。
|
|
583
|
+
# 这种「旁观纯图」最大概率是 should_reply=false,跳过它就是省一次多模态调用。
|
|
584
|
+
# 消息已被 priority=1 perception 写入 MessageBuffer,等下次文本触发能看到。
|
|
585
|
+
in_active = _mcp.active_sessions.is_active(adapter_name, group_id, now_ms)
|
|
586
|
+
if in_active and not is_explicit_trigger and image_urls and not text.strip():
|
|
587
|
+
logger.debug(
|
|
588
|
+
f"[HERMES reactive] skip image-only passive in-window msg "
|
|
589
|
+
f"(group={group_id} user={user_id}); buffered for next text trigger"
|
|
590
|
+
)
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
key = (adapter_name, f"group:{group_id}")
|
|
594
|
+
current_buffered = BufferedMessage(
|
|
595
|
+
ts=now_ms,
|
|
596
|
+
adapter=adapter_name,
|
|
597
|
+
group_id=group_id,
|
|
598
|
+
user_id=user_id,
|
|
599
|
+
nickname=user_id,
|
|
600
|
+
content=text,
|
|
601
|
+
image_urls=list(image_urls),
|
|
602
|
+
reply_to_ts=None,
|
|
603
|
+
is_bot=False,
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
if _mcp.inflight.try_enter(key, current_buffered, now_ms) == "pending_set":
|
|
607
|
+
return
|
|
608
|
+
|
|
609
|
+
should_refire = False
|
|
610
|
+
try:
|
|
611
|
+
result = await _run_reactive_turn(
|
|
612
|
+
bot=bot,
|
|
613
|
+
target=target,
|
|
614
|
+
adapter_name=adapter_name,
|
|
615
|
+
user_id=user_id,
|
|
616
|
+
group_id=group_id,
|
|
617
|
+
text=text,
|
|
618
|
+
image_urls=image_urls,
|
|
619
|
+
is_explicit_trigger=is_explicit_trigger,
|
|
620
|
+
now_ms=now_ms,
|
|
621
|
+
)
|
|
622
|
+
should_refire = not (result is not None and result.is_transport_error)
|
|
623
|
+
except Exception:
|
|
624
|
+
logger.exception(f"[HERMES] reactive turn raised; dropping pending for {key}")
|
|
625
|
+
should_refire = False
|
|
626
|
+
raise
|
|
627
|
+
finally:
|
|
628
|
+
if not should_refire:
|
|
629
|
+
_mcp.inflight.exit(key)
|
|
630
|
+
else:
|
|
631
|
+
pending = _mcp.inflight.take_pending(key)
|
|
632
|
+
if pending is None or pending.ts <= current_buffered.ts:
|
|
633
|
+
_mcp.inflight.exit(key)
|
|
634
|
+
else:
|
|
635
|
+
asyncio.create_task(
|
|
636
|
+
_refire(
|
|
637
|
+
key=key,
|
|
638
|
+
trigger_msg=pending,
|
|
639
|
+
depth=1,
|
|
640
|
+
mode="reactive",
|
|
641
|
+
bot=bot,
|
|
642
|
+
target=target,
|
|
643
|
+
adapter_name=adapter_name,
|
|
644
|
+
group_id=group_id,
|
|
645
|
+
)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
async def _refire(
|
|
650
|
+
*,
|
|
651
|
+
key,
|
|
652
|
+
trigger_msg: BufferedMessage,
|
|
653
|
+
depth: int,
|
|
654
|
+
mode: str,
|
|
655
|
+
bot: Bot,
|
|
656
|
+
target,
|
|
657
|
+
adapter_name: str,
|
|
658
|
+
group_id,
|
|
659
|
+
):
|
|
660
|
+
"""链式重燃。fire-and-forget,深度上限 MAX_REFIRE_DEPTH。"""
|
|
661
|
+
from ..core.inflight import MAX_REFIRE_DEPTH
|
|
662
|
+
|
|
663
|
+
assert _mcp.inflight is not None
|
|
664
|
+
|
|
665
|
+
if depth > MAX_REFIRE_DEPTH:
|
|
666
|
+
logger.warning(f"[HERMES] refire depth exceeded ({depth}); dropping pending {key}")
|
|
667
|
+
_mcp.inflight.exit(key)
|
|
668
|
+
return
|
|
669
|
+
|
|
670
|
+
# now_ms 用 wall-clock 而不是 trigger_msg.ts:_run_*_turn 内部用它做
|
|
671
|
+
# get_if_active 的 TTL 校验、active_sessions.touch 的滑动续期、以及 bot
|
|
672
|
+
# 自己回复的 BufferedMessage.ts。如果用 trigger 时间会导致 touch 后窗口
|
|
673
|
+
# 比预期早 N 秒过期、bot 回复时间戳倒退。trigger_msg.ts 只在 finally 的
|
|
674
|
+
# pending.ts 比对里用,那是消息到达时序而非「当前是几点」。
|
|
675
|
+
refire_now_ms = _now_ms()
|
|
676
|
+
should_refire = False
|
|
677
|
+
try:
|
|
678
|
+
if mode == "reactive":
|
|
679
|
+
assert group_id is not None
|
|
680
|
+
result = await _run_reactive_turn(
|
|
681
|
+
bot=bot,
|
|
682
|
+
target=target,
|
|
683
|
+
adapter_name=adapter_name,
|
|
684
|
+
user_id=trigger_msg.user_id,
|
|
685
|
+
group_id=group_id,
|
|
686
|
+
text=trigger_msg.content,
|
|
687
|
+
image_urls=list(trigger_msg.image_urls),
|
|
688
|
+
is_explicit_trigger=False, # 重燃总是 passive 旁观;显式触发已是初发那一发
|
|
689
|
+
now_ms=refire_now_ms,
|
|
690
|
+
)
|
|
691
|
+
else:
|
|
692
|
+
result = await _run_passive_turn(
|
|
693
|
+
bot=bot,
|
|
694
|
+
target=target,
|
|
695
|
+
adapter_name=adapter_name,
|
|
696
|
+
user_id=trigger_msg.user_id,
|
|
697
|
+
group_id=trigger_msg.group_id,
|
|
698
|
+
text=trigger_msg.content,
|
|
699
|
+
image_urls=list(trigger_msg.image_urls),
|
|
700
|
+
is_private=trigger_msg.group_id is None,
|
|
701
|
+
now_ms=refire_now_ms,
|
|
702
|
+
)
|
|
703
|
+
should_refire = not (result is not None and result.is_transport_error)
|
|
704
|
+
except Exception:
|
|
705
|
+
logger.exception(f"[HERMES] refire raised at depth {depth}; dropping pending for {key}")
|
|
706
|
+
should_refire = False
|
|
707
|
+
finally:
|
|
708
|
+
if not should_refire:
|
|
709
|
+
_mcp.inflight.exit(key)
|
|
710
|
+
return
|
|
711
|
+
pending = _mcp.inflight.take_pending(key)
|
|
712
|
+
if pending and pending.ts > trigger_msg.ts:
|
|
713
|
+
asyncio.create_task(
|
|
714
|
+
_refire(
|
|
715
|
+
key=key,
|
|
716
|
+
trigger_msg=pending,
|
|
717
|
+
depth=depth + 1,
|
|
718
|
+
mode=mode,
|
|
719
|
+
bot=bot,
|
|
720
|
+
target=target,
|
|
721
|
+
adapter_name=adapter_name,
|
|
722
|
+
group_id=group_id,
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
else:
|
|
726
|
+
_mcp.inflight.exit(key)
|
{nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/__init__.py
RENAMED
|
@@ -13,6 +13,7 @@ from nonebot import get_driver, logger
|
|
|
13
13
|
from ..config import plugin_config
|
|
14
14
|
from ..core.active_session import ActiveSessionManager
|
|
15
15
|
from ..core.bot_registry import BotRegistry
|
|
16
|
+
from ..core.inflight import InflightRegistry
|
|
16
17
|
from ..core.message_buffer import MessageBuffer
|
|
17
18
|
from .server import build_mcp_app
|
|
18
19
|
|
|
@@ -66,14 +67,15 @@ if not any(isinstance(f, _ToolValidationLogRedirect) for f in _FASTMCP_TOOL_LOGG
|
|
|
66
67
|
message_buffer: MessageBuffer | None = None
|
|
67
68
|
active_sessions: ActiveSessionManager | None = None
|
|
68
69
|
bot_registry: BotRegistry | None = None
|
|
70
|
+
inflight: InflightRegistry | None = None
|
|
69
71
|
|
|
70
72
|
_server_task: Optional[asyncio.Task] = None
|
|
71
73
|
_uvicorn_server: Optional[uvicorn.Server] = None
|
|
72
74
|
|
|
73
75
|
|
|
74
76
|
def init_runtime_state() -> None:
|
|
75
|
-
"""由 plugin __init__.py 在 import
|
|
76
|
-
global message_buffer, active_sessions, bot_registry
|
|
77
|
+
"""由 plugin __init__.py 在 import 时调用,装配全局对象。"""
|
|
78
|
+
global message_buffer, active_sessions, bot_registry, inflight
|
|
77
79
|
if message_buffer is None:
|
|
78
80
|
message_buffer = MessageBuffer(
|
|
79
81
|
per_group_cap=plugin_config.hermes_buffer_per_group_cap,
|
|
@@ -85,6 +87,8 @@ def init_runtime_state() -> None:
|
|
|
85
87
|
)
|
|
86
88
|
if bot_registry is None:
|
|
87
89
|
bot_registry = BotRegistry()
|
|
90
|
+
if inflight is None:
|
|
91
|
+
inflight = InflightRegistry()
|
|
88
92
|
|
|
89
93
|
|
|
90
94
|
def _on_server_task_done(task: asyncio.Task) -> None:
|