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.
Files changed (50) hide show
  1. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/PKG-INFO +1 -2
  2. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/README.md +0 -1
  3. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/__init__.py +1 -1
  4. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/config.py +1 -7
  5. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/hermes_client.py +118 -7
  6. nonebot_plugin_hermes-0.2.2/nonebot_plugin_hermes/core/inflight.py +71 -0
  7. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/handlers/commands.py +0 -1
  8. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/handlers/message.py +263 -8
  9. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/__init__.py +6 -2
  10. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/PKG-INFO +1 -2
  11. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/SOURCES.txt +3 -0
  12. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/pyproject.toml +1 -1
  13. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_hermes_client_structured.py +119 -2
  14. nonebot_plugin_hermes-0.2.2/tests/test_inflight.py +91 -0
  15. nonebot_plugin_hermes-0.2.2/tests/test_message_handler_coalesce.py +543 -0
  16. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/hermes_install_skill.py +0 -0
  17. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/__init__.py +0 -0
  18. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/active_session.py +0 -0
  19. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/bot_registry.py +0 -0
  20. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/message_buffer.py +0 -0
  21. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/outbound.py +0 -0
  22. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/prompt_builder.py +0 -0
  23. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/core/session.py +0 -0
  24. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/handlers/__init__.py +0 -0
  25. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/auth.py +0 -0
  26. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/server.py +0 -0
  27. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/__init__.py +0 -0
  28. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/get_recent_messages.py +0 -0
  29. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/list_active_sessions.py +0 -0
  30. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/mcp/tools/push_message.py +0 -0
  31. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/scripts/__init__.py +0 -0
  32. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/scripts/install_skill.py +0 -0
  33. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/skill/SKILL.md +0 -0
  34. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/skill/__init__.py +0 -0
  35. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/tasks/__init__.py +0 -0
  36. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/tasks/expire_active_sessions.py +0 -0
  37. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes/utils.py +0 -0
  38. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/dependency_links.txt +0 -0
  39. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/entry_points.txt +0 -0
  40. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/requires.txt +0 -0
  41. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/nonebot_plugin_hermes.egg-info/top_level.txt +0 -0
  42. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/setup.cfg +0 -0
  43. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_active_session.py +0 -0
  44. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_bot_registry.py +0 -0
  45. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_mcp_auth.py +0 -0
  46. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_mcp_push_message.py +0 -0
  47. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_mcp_read_tools.py +0 -0
  48. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_message_buffer.py +0 -0
  49. {nonebot_plugin_hermes-0.2.1 → nonebot_plugin_hermes-0.2.2}/tests/test_prompt_builder.py +0 -0
  50. {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.1
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
 
@@ -13,7 +13,7 @@ require("nonebot_plugin_apscheduler")
13
13
 
14
14
  from .config import Config, plugin_config
15
15
 
16
- __version__ = "0.2.1"
16
+ __version__ = "0.2.2"
17
17
 
18
18
  __plugin_meta__ = PluginMetadata(
19
19
  name="Hermes Agent",
@@ -4,7 +4,7 @@
4
4
  所有配置项通过 NoneBot 的 .env 文件读取,前缀为 HERMES_。
5
5
  """
6
6
 
7
- from typing import Literal, Set
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(m.group(0))
172
+ parsed = json5.loads(candidate)
66
173
  except Exception:
67
- return None
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
- body = resp.text[:200]
216
- logger.error(f"[HERMES] API 返回 {resp.status_code}: {body}")
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=f"⚠️ AI 服务返回错误 ({resp.status_code})",
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 _handle_passive_path(
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 _handle_reactive_path(
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)
@@ -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: