nonebot-plugin-hermes 0.3.2__tar.gz → 0.3.3__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.3.2 → nonebot_plugin_hermes-0.3.3}/PKG-INFO +5 -1
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/README.md +3 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/__init__.py +1 -1
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/config.py +17 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/handlers/__init__.py +1 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/handlers/message.py +137 -11
- nonebot_plugin_hermes-0.3.3/nonebot_plugin_hermes/handlers/notices.py +140 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/mcp/tools/push_message.py +5 -5
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/utils.py +11 -3
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes.egg-info/PKG-INFO +5 -1
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes.egg-info/SOURCES.txt +6 -1
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes.egg-info/requires.txt +1 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/pyproject.toml +2 -1
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_mcp_push_message.py +7 -8
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_message_handler_coalesce.py +230 -16
- nonebot_plugin_hermes-0.3.3/tests/test_notices.py +250 -0
- nonebot_plugin_hermes-0.3.3/tests/test_notices_config.py +15 -0
- nonebot_plugin_hermes-0.3.3/tests/test_route_synthesized_input.py +160 -0
- nonebot_plugin_hermes-0.3.3/tests/test_utils_adapter_name.py +57 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/hermes_install_skill.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/__init__.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/active_session.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/bot_registry.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/hermes_client.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/inflight.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/message_buffer.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/outbound.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/prompt_builder.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/session.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/storage/__init__.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/storage/image_cache.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/storage/image_fetcher.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/core/storage/message_store.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/handlers/commands.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/mcp/__init__.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/mcp/auth.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/mcp/server.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/mcp/tools/__init__.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/mcp/tools/get_message_images.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/mcp/tools/get_recent_messages.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/mcp/tools/list_active_sessions.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/scripts/__init__.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/scripts/install_skill.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/skill/SKILL.md +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/skill/__init__.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/tasks/__init__.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/tasks/expire_active_sessions.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes/tasks/storage_vacuum.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes.egg-info/dependency_links.txt +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes.egg-info/entry_points.txt +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes.egg-info/top_level.txt +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/setup.cfg +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_active_session.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_bot_registry.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_handlers_at_filter.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_handlers_nickname.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_handlers_telegram_url_cache.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_hermes_client_structured.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_image_cache.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_image_fetcher.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_inflight.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_mcp_auth.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_mcp_get_message_images.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_mcp_read_tools.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_message_buffer.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_message_store.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_prompt_builder.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_session_manager.py +0 -0
- {nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/tests/test_tasks_storage_vacuum.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-hermes
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -18,6 +18,7 @@ Provides-Extra: dev
|
|
|
18
18
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
19
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
20
20
|
Requires-Dist: ruff>=0.15; extra == "dev"
|
|
21
|
+
Requires-Dist: nonebot-adapter-onebot>=2.4.6; extra == "dev"
|
|
21
22
|
|
|
22
23
|
# nonebot-plugin-hermes
|
|
23
24
|
|
|
@@ -61,6 +62,7 @@ Requires-Dist: ruff>=0.15; extra == "dev"
|
|
|
61
62
|
- 🧪 **群活跃态 (M1, 实验性)**:@bot 后 5 分钟内主动监听群对话,由 Hermes 通过结构化决策判断是否插话
|
|
62
63
|
- 🧪 **反向通道 (M1, 实验性)**:内嵌本地 MCP server,让 Hermes 主动 push 消息进群(延迟回复 / 异步通知)
|
|
63
64
|
- 🧪 **历史图片召回 (0.3+, 实验性)**:SQLite 持久化消息日志 + 文件系统图字节缓存 + MCP 工具 `get_message_images`,让 Hermes 在用户说"上图"/"刚才那张"时按消息 id 精确取回历史图字节
|
|
65
|
+
- 🧪 **OneBot v11 Notice 触发 (0.3.3+, 实验性)**:戳一戳作为第二种 @ 等价触发;有人入群时让 Hermes 自决要不要欢迎(noop 合法,不做模板欢迎语)
|
|
64
66
|
|
|
65
67
|
## 快速开始
|
|
66
68
|
|
|
@@ -325,6 +327,8 @@ T+5s 用户 B: @bot 评价下上图
|
|
|
325
327
|
| `HERMES_ACTIVE_SESSION_ENABLED` | `false` | 启用群活跃态(M1)。`false` 时退化为 v0.1.6 等价行为 |
|
|
326
328
|
| `HERMES_ACTIVE_SESSION_TTL_SEC` | `300` | 活跃窗口 TTL(秒),每次插话滑动续期 |
|
|
327
329
|
| `HERMES_ACTIVE_SWEEP_INTERVAL_SEC` | `30` | 活跃态过期清扫 cron 频率(秒) |
|
|
330
|
+
| `HERMES_POKE_TRIGGER_ENABLED` | `false` | OneBot v11:被戳一戳时触发对话(私聊 / 群都生效,等价于被 @)。其他适配器静默忽略 |
|
|
331
|
+
| `HERMES_GREET_ON_JOIN` | `false` | OneBot v11:有人加入群且 `HERMES_ACTIVE_SESSION_ENABLED=true` 时,触发一次 reactive turn 让 Hermes 自决是否欢迎(`noop` 是合法返回)。active 关时不触发 |
|
|
328
332
|
| `HERMES_BUFFER_PER_GROUP_CAP` | `200` | ⚠️ **0.3 起空转**——MessageBuffer 改为 SQLite 后端,无内存 per-group 上限;消息淘汰由 `HERMES_STORAGE_MESSAGE_*` 控制。下一个 major 版本会移除 |
|
|
329
333
|
| `HERMES_BUFFER_TOTAL_GROUPS_CAP` | `50` | ⚠️ **0.3 起空转**——同上,SQLite 后端无 LRU,改为 retention + 行数上限 |
|
|
330
334
|
| `HERMES_MCP_ENABLED` | `false` | 启动内嵌 FastMCP server(M1 反向通道) |
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
- 🧪 **群活跃态 (M1, 实验性)**:@bot 后 5 分钟内主动监听群对话,由 Hermes 通过结构化决策判断是否插话
|
|
41
41
|
- 🧪 **反向通道 (M1, 实验性)**:内嵌本地 MCP server,让 Hermes 主动 push 消息进群(延迟回复 / 异步通知)
|
|
42
42
|
- 🧪 **历史图片召回 (0.3+, 实验性)**:SQLite 持久化消息日志 + 文件系统图字节缓存 + MCP 工具 `get_message_images`,让 Hermes 在用户说"上图"/"刚才那张"时按消息 id 精确取回历史图字节
|
|
43
|
+
- 🧪 **OneBot v11 Notice 触发 (0.3.3+, 实验性)**:戳一戳作为第二种 @ 等价触发;有人入群时让 Hermes 自决要不要欢迎(noop 合法,不做模板欢迎语)
|
|
43
44
|
|
|
44
45
|
## 快速开始
|
|
45
46
|
|
|
@@ -304,6 +305,8 @@ T+5s 用户 B: @bot 评价下上图
|
|
|
304
305
|
| `HERMES_ACTIVE_SESSION_ENABLED` | `false` | 启用群活跃态(M1)。`false` 时退化为 v0.1.6 等价行为 |
|
|
305
306
|
| `HERMES_ACTIVE_SESSION_TTL_SEC` | `300` | 活跃窗口 TTL(秒),每次插话滑动续期 |
|
|
306
307
|
| `HERMES_ACTIVE_SWEEP_INTERVAL_SEC` | `30` | 活跃态过期清扫 cron 频率(秒) |
|
|
308
|
+
| `HERMES_POKE_TRIGGER_ENABLED` | `false` | OneBot v11:被戳一戳时触发对话(私聊 / 群都生效,等价于被 @)。其他适配器静默忽略 |
|
|
309
|
+
| `HERMES_GREET_ON_JOIN` | `false` | OneBot v11:有人加入群且 `HERMES_ACTIVE_SESSION_ENABLED=true` 时,触发一次 reactive turn 让 Hermes 自决是否欢迎(`noop` 是合法返回)。active 关时不触发 |
|
|
307
310
|
| `HERMES_BUFFER_PER_GROUP_CAP` | `200` | ⚠️ **0.3 起空转**——MessageBuffer 改为 SQLite 后端,无内存 per-group 上限;消息淘汰由 `HERMES_STORAGE_MESSAGE_*` 控制。下一个 major 版本会移除 |
|
|
308
311
|
| `HERMES_BUFFER_TOTAL_GROUPS_CAP` | `50` | ⚠️ **0.3 起空转**——同上,SQLite 后端无 LRU,改为 retention + 行数上限 |
|
|
309
312
|
| `HERMES_MCP_ENABLED` | `false` | 启动内嵌 FastMCP server(M1 反向通道) |
|
|
@@ -92,11 +92,28 @@ class Config(BaseModel):
|
|
|
92
92
|
hermes_active_sweep_interval_sec: int = 30
|
|
93
93
|
"""expire_active_sessions cron 频率(秒)"""
|
|
94
94
|
|
|
95
|
+
# --- Notice 事件触发 (Phase A) ---
|
|
96
|
+
hermes_poke_trigger_enabled: bool = False
|
|
97
|
+
"""OneBot v11: 被戳一戳时触发对话(私聊/群无差别),等价 @。
|
|
98
|
+
其他适配器无效,缺省全关 = 老用户行为零变化。"""
|
|
99
|
+
|
|
100
|
+
hermes_greet_on_join: bool = False
|
|
101
|
+
"""OneBot v11: 群里有人加入时,在 active_session 开启的群触发一次 reactive turn
|
|
102
|
+
让 Hermes 自决是否欢迎(decision_protocol 的 noop 是合法选择)。
|
|
103
|
+
active_session 关时不触发——passive 是 1:1 Q&A 语义,不适用欢迎场景。"""
|
|
104
|
+
|
|
95
105
|
hermes_reactive_post_reply_cooldown_sec: int = 8
|
|
96
106
|
"""reactive 模式下,bot 刚回复完群里 N 秒内,非显式 @ 触发的新消息直接静默。
|
|
97
107
|
用来阻断「我刚说完别人接话→我又凑一句」类型的过触发。
|
|
98
108
|
0 = 关闭(回退到旧行为)。显式 @bot 不受影响,任何时候都会立刻进入决策。"""
|
|
99
109
|
|
|
110
|
+
hermes_transport_error_fallback_text: str = "嗯…我这边遇到点状况,稍后再问一次"
|
|
111
|
+
"""Hermes 上游返 5xx / 传输错误时, 替代 LLM raw_text 的兜底文本。
|
|
112
|
+
没有这条兜底, 服务端英文错误信息(如 "Model generated invalid tool call: ...")
|
|
113
|
+
会被原文当 raw_text 发到群里,体验差也泄露内部信息。
|
|
114
|
+
空串 → 不发任何文本(等价于 silent)。仅在 reactive 显式触发 / passive 路径生效——
|
|
115
|
+
非显式触发本来就静默,不受影响。"""
|
|
116
|
+
|
|
100
117
|
# --- M1: 反向 MCP 通道 ---
|
|
101
118
|
hermes_mcp_enabled: bool = False
|
|
102
119
|
"""是否启动内嵌 FastMCP server(False 时 Hermes 反向调用全失败,出向不影响)"""
|
|
@@ -513,6 +513,27 @@ async def _run_passive_turn(
|
|
|
513
513
|
user_content_override=user_content,
|
|
514
514
|
)
|
|
515
515
|
|
|
516
|
+
# 上游 transport_error 同款保护(见 _run_reactive_turn 同名分支注释)。
|
|
517
|
+
# passive 路径下私聊总是显式对话,群聊已通过触发判断进得来,两边都该有可见反馈;
|
|
518
|
+
# 配空 fallback_text 时静默,保留逃生口。
|
|
519
|
+
if result.is_transport_error:
|
|
520
|
+
fallback_text = plugin_config.hermes_transport_error_fallback_text
|
|
521
|
+
logger.warning(
|
|
522
|
+
f"[HERMES passive] transport error fallback "
|
|
523
|
+
f"(group={group_id}, is_private={is_private}, "
|
|
524
|
+
f"fallback={'silent' if not fallback_text else 'friendly_text'}); "
|
|
525
|
+
f"upstream raw_text suppressed (len={len(result.raw_text or '')})"
|
|
526
|
+
)
|
|
527
|
+
if fallback_text:
|
|
528
|
+
await send_text_with_media(
|
|
529
|
+
bot=bot,
|
|
530
|
+
target=target,
|
|
531
|
+
text=fallback_text,
|
|
532
|
+
media_urls=[],
|
|
533
|
+
at_user_id=None if is_private else user_id,
|
|
534
|
+
)
|
|
535
|
+
return result
|
|
536
|
+
|
|
516
537
|
# 防御:同一 Hermes session 之前跑过 reactive 时学到 submit_decision 契约,
|
|
517
538
|
# 切回 passive 后仍可能吐 JSON。检测并抠 reply_text;不命中则用原 raw_text。
|
|
518
539
|
reply_text = result.raw_text
|
|
@@ -562,11 +583,9 @@ async def _run_reactive_turn(
|
|
|
562
583
|
if session is None:
|
|
563
584
|
return None # 防御:窗口刚刚过期 / 被外部 end()
|
|
564
585
|
|
|
565
|
-
# B.3: 快照本 turn 入口时的 last_bot_reply_at
|
|
566
|
-
# 期间是否有外部(MCP push_message)推过 bot
|
|
567
|
-
# should_reply=True 也必须抑制本路 send
|
|
568
|
-
# 2026-05-18 17:48 事件原型:Hermes 在 reactive agent loop 内既调 push_message
|
|
569
|
-
# 又在 submit_decision 里把同样答案再吐了一遍,plugin 接下来都发了一遍 → 双答。
|
|
586
|
+
# B.3: 快照本 turn 入口时的 last_bot_reply_at, 供 chat() 返回后判定 agent loop
|
|
587
|
+
# 期间是否有外部(MCP push_message)推过 bot 自己的回复。 若发生, 即使 LLM 返
|
|
588
|
+
# should_reply=True 也必须抑制本路 send, 否则同 turn 内双答。
|
|
570
589
|
last_bot_reply_at_at_entry = session.last_bot_reply_at
|
|
571
590
|
|
|
572
591
|
recent = _mcp.message_buffer.get_recent(
|
|
@@ -614,6 +633,28 @@ async def _run_reactive_turn(
|
|
|
614
633
|
)
|
|
615
634
|
|
|
616
635
|
if result.parse_failed or result.structured is None:
|
|
636
|
+
# 上游 transport_error(5xx / 网络断 / 流被掐):raw_text 是服务端错误信息原文
|
|
637
|
+
# (如 "Model generated invalid tool call: ..."),原文转发会把内部错误丢到群里
|
|
638
|
+
# 既泄密又难看。换成 config 里的友好兜底文本;空串则静默。
|
|
639
|
+
# parse_failed 但非 transport(LLM 真的回了点啥但结构错):原样转发 raw_text,
|
|
640
|
+
# 仍可能是用户想要的回答。
|
|
641
|
+
if result.is_transport_error and is_explicit_trigger:
|
|
642
|
+
fallback_text = plugin_config.hermes_transport_error_fallback_text
|
|
643
|
+
logger.warning(
|
|
644
|
+
f"[HERMES reactive] transport error fallback "
|
|
645
|
+
f"(group={group_id}, fallback={'silent' if not fallback_text else 'friendly_text'}); "
|
|
646
|
+
f"upstream raw_text suppressed (len={len(result.raw_text or '')})"
|
|
647
|
+
)
|
|
648
|
+
if fallback_text:
|
|
649
|
+
await send_text_with_media(
|
|
650
|
+
bot=bot,
|
|
651
|
+
target=target,
|
|
652
|
+
text=fallback_text,
|
|
653
|
+
media_urls=[],
|
|
654
|
+
at_user_id=user_id,
|
|
655
|
+
)
|
|
656
|
+
return result
|
|
657
|
+
|
|
617
658
|
logger.warning(
|
|
618
659
|
f"[HERMES reactive] structured parse failed (group={group_id}, "
|
|
619
660
|
f"transport_error={result.is_transport_error}); fallback="
|
|
@@ -686,12 +727,18 @@ async def _run_reactive_turn(
|
|
|
686
727
|
f"ok={sent} text_len={len(reply_text)} at_user={at_user}"
|
|
687
728
|
)
|
|
688
729
|
|
|
689
|
-
#
|
|
690
|
-
#
|
|
730
|
+
# 用 send 完成时的 wall clock,不复用入参 now_ms。
|
|
731
|
+
# 入参 now_ms 是 _handle_reactive_path 进函数那一刻抓的, chat() + send 可能耗时
|
|
732
|
+
# 任意长(上游重试 / 上下文压缩 / 工具调用累加),复用入参会让 last_bot_reply_at
|
|
733
|
+
# 远早于真实 send 时间, 让下游 cooldown 闸门(_in_post_reply_cooldown)算出来的
|
|
734
|
+
# elapsed 失真,把窗内的 refire 误判成窗外放过去。
|
|
735
|
+
# 调一次 _now_ms() 拿到 reply_now_ms,三个写操作复用这一个快照,既校正 stale
|
|
736
|
+
# 入参,又避免多次读时钟在三个字段间引入毫秒级偏差。
|
|
691
737
|
if sent and _mcp.message_buffer is not None:
|
|
738
|
+
reply_now_ms = _now_ms()
|
|
692
739
|
_mcp.message_buffer.append(
|
|
693
740
|
BufferedMessage(
|
|
694
|
-
ts=
|
|
741
|
+
ts=reply_now_ms,
|
|
695
742
|
adapter=adapter_name,
|
|
696
743
|
group_id=group_id,
|
|
697
744
|
user_id=str(bot.self_id),
|
|
@@ -703,9 +750,10 @@ async def _run_reactive_turn(
|
|
|
703
750
|
)
|
|
704
751
|
# 注:若 should_exit_active=True,session 已在上方 end(),touch / mark_bot_replied
|
|
705
752
|
# 都是安全 no-op(两者文档统一:session 缺失则 no-op)。
|
|
706
|
-
_mcp.active_sessions.touch(adapter_name, group_id, now_ms=
|
|
707
|
-
# B.2: 记下「bot 刚回过」时间戳,供 _handle_reactive_path
|
|
708
|
-
|
|
753
|
+
_mcp.active_sessions.touch(adapter_name, group_id, now_ms=reply_now_ms)
|
|
754
|
+
# B.2: 记下「bot 刚回过」时间戳,供 _handle_reactive_path 入口 + _refire 入口的
|
|
755
|
+
# cooldown 闸门判定。
|
|
756
|
+
_mcp.active_sessions.mark_bot_replied(adapter_name, group_id, now_ms=reply_now_ms)
|
|
709
757
|
|
|
710
758
|
return result
|
|
711
759
|
|
|
@@ -1020,3 +1068,81 @@ async def _refire(
|
|
|
1020
1068
|
)
|
|
1021
1069
|
else:
|
|
1022
1070
|
_mcp.inflight.exit(key)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
async def route_synthesized_input(
|
|
1074
|
+
*,
|
|
1075
|
+
bot: Bot,
|
|
1076
|
+
target,
|
|
1077
|
+
adapter_name: str,
|
|
1078
|
+
user_id: str,
|
|
1079
|
+
group_id: Optional[str],
|
|
1080
|
+
nickname: Optional[str],
|
|
1081
|
+
text: str,
|
|
1082
|
+
allow_passive: bool,
|
|
1083
|
+
now_ms: int,
|
|
1084
|
+
):
|
|
1085
|
+
"""合成消息的统一入口,供 notice handler 复用既有 message routing。
|
|
1086
|
+
|
|
1087
|
+
派发规则:
|
|
1088
|
+
- private (target.private=True) → 仅 allow_passive=True 才走 passive,否则跳过
|
|
1089
|
+
- group + active_session 开 → 触发 active session 并走 reactive
|
|
1090
|
+
(synth 始终算 is_explicit_trigger=True)
|
|
1091
|
+
- group + active_session 关 → 仅 allow_passive=True 才走 passive
|
|
1092
|
+
|
|
1093
|
+
`allow_passive` 控制无 active session 时的兜底:
|
|
1094
|
+
- 戳一戳: True (任何 mode 都开口)
|
|
1095
|
+
- 入群: False (仅 active 开时通过 reactive 让 Hermes 自决,否则不打扰)
|
|
1096
|
+
"""
|
|
1097
|
+
if target.private:
|
|
1098
|
+
if not allow_passive:
|
|
1099
|
+
return
|
|
1100
|
+
await _handle_passive_path(
|
|
1101
|
+
bot=bot,
|
|
1102
|
+
target=target,
|
|
1103
|
+
adapter_name=adapter_name,
|
|
1104
|
+
user_id=user_id,
|
|
1105
|
+
nickname=nickname,
|
|
1106
|
+
group_id=None,
|
|
1107
|
+
text=text,
|
|
1108
|
+
image_urls=[],
|
|
1109
|
+
is_private=True,
|
|
1110
|
+
now_ms=now_ms,
|
|
1111
|
+
)
|
|
1112
|
+
return
|
|
1113
|
+
|
|
1114
|
+
# 群聊
|
|
1115
|
+
if not plugin_config.hermes_active_session_enabled:
|
|
1116
|
+
if not allow_passive:
|
|
1117
|
+
return
|
|
1118
|
+
await _handle_passive_path(
|
|
1119
|
+
bot=bot,
|
|
1120
|
+
target=target,
|
|
1121
|
+
adapter_name=adapter_name,
|
|
1122
|
+
user_id=user_id,
|
|
1123
|
+
nickname=nickname,
|
|
1124
|
+
group_id=group_id,
|
|
1125
|
+
text=text,
|
|
1126
|
+
image_urls=[],
|
|
1127
|
+
is_private=False,
|
|
1128
|
+
now_ms=now_ms,
|
|
1129
|
+
)
|
|
1130
|
+
return
|
|
1131
|
+
|
|
1132
|
+
# 群 + active_session 开 → 显式触发 + reactive
|
|
1133
|
+
# (与 handle_message 显式触发同语义: 先 trigger,再 _handle_reactive_path)
|
|
1134
|
+
assert _mcp.active_sessions is not None
|
|
1135
|
+
_mcp.active_sessions.trigger(adapter_name, group_id or "", user_id, now_ms=now_ms)
|
|
1136
|
+
logger.info(f"[HERMES notice] synthesized reactive trigger: {adapter_name}/{group_id} by {user_id}")
|
|
1137
|
+
await _handle_reactive_path(
|
|
1138
|
+
bot=bot,
|
|
1139
|
+
target=target,
|
|
1140
|
+
adapter_name=adapter_name,
|
|
1141
|
+
user_id=user_id,
|
|
1142
|
+
nickname=nickname,
|
|
1143
|
+
group_id=group_id,
|
|
1144
|
+
text=text,
|
|
1145
|
+
image_urls=[],
|
|
1146
|
+
is_explicit_trigger=True,
|
|
1147
|
+
now_ms=now_ms,
|
|
1148
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""通知事件处理器
|
|
2
|
+
|
|
3
|
+
priority=1 dispatch: 适配器特定 notice 事件 → 合成伪消息 → 复用 message.py 路由。
|
|
4
|
+
|
|
5
|
+
**CLAUDE.md 规则例外**: 本文件是仅有的允许 import 适配器特定类型的位置。
|
|
6
|
+
- 仅在 dispatch 函数内部 try-import (惰性)
|
|
7
|
+
- 失败 (ImportError) 即 no-op,不影响其他平台
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import nonebot_plugin_alconna as alconna
|
|
15
|
+
from nonebot import logger, on_notice
|
|
16
|
+
from nonebot.adapters import Bot, Event
|
|
17
|
+
|
|
18
|
+
from ..config import plugin_config
|
|
19
|
+
from ..utils import check_isolation, get_adapter_name
|
|
20
|
+
from .message import route_synthesized_input
|
|
21
|
+
|
|
22
|
+
notice_handler = on_notice(priority=1, block=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _now_ms() -> int:
|
|
26
|
+
return int(time.time() * 1000)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@notice_handler.handle()
|
|
30
|
+
async def dispatch(bot: Bot, event: Event):
|
|
31
|
+
"""OneBot v11 notice 事件分发入口。
|
|
32
|
+
|
|
33
|
+
早期 return:
|
|
34
|
+
- 两个开关都关
|
|
35
|
+
- adapter != OneBot V11
|
|
36
|
+
- ImportError (onebot adapter 未装)
|
|
37
|
+
"""
|
|
38
|
+
if not (plugin_config.hermes_poke_trigger_enabled or plugin_config.hermes_greet_on_join):
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
adapter_name = get_adapter_name(bot)
|
|
42
|
+
if adapter_name != "onebotv11":
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
from nonebot.adapters.onebot.v11 import (
|
|
47
|
+
GroupIncreaseNoticeEvent,
|
|
48
|
+
PokeNotifyEvent,
|
|
49
|
+
)
|
|
50
|
+
except ImportError:
|
|
51
|
+
logger.debug("[HERMES notice] OneBot v11 adapter not installed; skip")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if isinstance(event, PokeNotifyEvent) and plugin_config.hermes_poke_trigger_enabled:
|
|
55
|
+
await _handle_poke(bot, event)
|
|
56
|
+
elif isinstance(event, GroupIncreaseNoticeEvent) and plugin_config.hermes_greet_on_join:
|
|
57
|
+
await _handle_member_join(bot, event)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _handle_poke(bot: Bot, event) -> None:
|
|
61
|
+
"""戳一戳: 仅戳 bot 自己时触发,合成 `[poke] 戳了你一下`。"""
|
|
62
|
+
# OneBot v11 的 target_id/self_id/user_id 都是 int,统一字符串后比较
|
|
63
|
+
if str(event.target_id) != str(bot.self_id):
|
|
64
|
+
return
|
|
65
|
+
if str(event.user_id) == str(bot.self_id):
|
|
66
|
+
return # bot 戳自己(罕见),跳过
|
|
67
|
+
|
|
68
|
+
user_id = str(event.user_id)
|
|
69
|
+
group_id = str(event.group_id) if event.group_id else None
|
|
70
|
+
|
|
71
|
+
target = _build_target(adapter_name="onebotv11", user_id=user_id, group_id=group_id)
|
|
72
|
+
if not check_isolation(event, target):
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
nickname = await _resolve_nickname(bot, user_id=user_id, group_id=group_id)
|
|
76
|
+
|
|
77
|
+
await route_synthesized_input(
|
|
78
|
+
bot=bot,
|
|
79
|
+
target=target,
|
|
80
|
+
adapter_name="onebotv11",
|
|
81
|
+
user_id=user_id,
|
|
82
|
+
group_id=group_id,
|
|
83
|
+
nickname=nickname,
|
|
84
|
+
text="[poke] 戳了你一下",
|
|
85
|
+
allow_passive=True,
|
|
86
|
+
now_ms=_now_ms(),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _handle_member_join(bot: Bot, event) -> None:
|
|
91
|
+
"""入群: 合成 `[event=member_join] {nickname} 加入了群`。
|
|
92
|
+
active_session 关时由 route_synthesized_input 自检并跳过。"""
|
|
93
|
+
if str(event.user_id) == str(bot.self_id):
|
|
94
|
+
return # bot 自己被拉进新群,buffer 空,跳过
|
|
95
|
+
|
|
96
|
+
user_id = str(event.user_id)
|
|
97
|
+
group_id = str(event.group_id)
|
|
98
|
+
|
|
99
|
+
target = _build_target(adapter_name="onebotv11", user_id=user_id, group_id=group_id)
|
|
100
|
+
if not check_isolation(event, target):
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
nickname = await _resolve_nickname(bot, user_id=user_id, group_id=group_id)
|
|
104
|
+
|
|
105
|
+
await route_synthesized_input(
|
|
106
|
+
bot=bot,
|
|
107
|
+
target=target,
|
|
108
|
+
adapter_name="onebotv11",
|
|
109
|
+
user_id=user_id,
|
|
110
|
+
group_id=group_id,
|
|
111
|
+
nickname=nickname,
|
|
112
|
+
text=f"[event=member_join] {nickname} 加入了群",
|
|
113
|
+
allow_passive=False,
|
|
114
|
+
now_ms=_now_ms(),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _resolve_nickname(bot: Bot, *, user_id: str, group_id):
|
|
119
|
+
"""OneBot v11 API 拿昵称,失败 fallback 用 user_id 字符串。"""
|
|
120
|
+
try:
|
|
121
|
+
if group_id:
|
|
122
|
+
info = await bot.call_api(
|
|
123
|
+
"get_group_member_info",
|
|
124
|
+
group_id=int(group_id),
|
|
125
|
+
user_id=int(user_id),
|
|
126
|
+
no_cache=True,
|
|
127
|
+
)
|
|
128
|
+
return info.get("card") or info.get("nickname") or user_id
|
|
129
|
+
info = await bot.call_api("get_stranger_info", user_id=int(user_id))
|
|
130
|
+
return info.get("nickname") or user_id
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logger.debug(f"[HERMES notice] nickname resolve failed for {user_id}: {e}")
|
|
133
|
+
return user_id
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _build_target(*, adapter_name: str, user_id: str, group_id):
|
|
137
|
+
"""构造 alconna.Target——notice handler 不在 alconna 消息上下文里,要自己造。"""
|
|
138
|
+
if group_id:
|
|
139
|
+
return alconna.Target(id=group_id, private=False, adapter=adapter_name)
|
|
140
|
+
return alconna.Target(id=user_id, private=True, adapter=adapter_name)
|
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
- BotRegistry 必须有该 (adapter, group_id) 的 Target
|
|
6
6
|
不满足任一条件返回 422 等价错误(由 FastMCP 序列化为 isError=true)。
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
1. mark_bot_replied — 写 ActiveSession.last_bot_reply_at
|
|
8
|
+
成功路径的副作用, 与 reactive submit_decision 回复路径(_run_reactive_turn 末段)等价:
|
|
9
|
+
1. mark_bot_replied — 写 ActiveSession.last_bot_reply_at, 供 post-reply cooldown 闸门
|
|
10
10
|
在后续 reactive turn / refire 入口判定
|
|
11
11
|
2. message_buffer.append(is_bot=True) — 让后续 _run_reactive_turn 拉到的
|
|
12
|
-
<recent_messages> 里能看见 bot 这条 push 出去的话,LLM 不会"以为自己没说"
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
<recent_messages> 里能看见 bot 这条 push 出去的话, LLM 不会"以为自己没说"
|
|
13
|
+
两件都做才能避免 Hermes 用 push_message 当主回复后, 后续 refire / 同 turn submit_decision
|
|
14
|
+
又答一遍同主题。
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
@@ -4,9 +4,17 @@ from nonebot.adapters import Event
|
|
|
4
4
|
from .config import plugin_config
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
def get_adapter_name(
|
|
8
|
-
"""
|
|
9
|
-
|
|
7
|
+
def get_adapter_name(source) -> str:
|
|
8
|
+
"""提取并归一化 adapter 名。
|
|
9
|
+
|
|
10
|
+
支持两种输入:
|
|
11
|
+
- `alconna.Target` (`.adapter` 是 str) — 消息处理路径主用
|
|
12
|
+
- `nonebot.adapters.Bot` (`.adapter` 是 Adapter 实例,有 classmethod `get_name()`)
|
|
13
|
+
— notice handler 路径用,因为还没有 alconna 消息上下文构造 Target
|
|
14
|
+
"""
|
|
15
|
+
adapter = getattr(source, "adapter", "") or ""
|
|
16
|
+
if hasattr(adapter, "get_name"):
|
|
17
|
+
adapter = adapter.get_name()
|
|
10
18
|
return adapter.lower().replace(" ", "").replace(".", "") or "unknown"
|
|
11
19
|
|
|
12
20
|
|
{nonebot_plugin_hermes-0.3.2 → nonebot_plugin_hermes-0.3.3}/nonebot_plugin_hermes.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-hermes
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -18,6 +18,7 @@ Provides-Extra: dev
|
|
|
18
18
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
19
|
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
20
20
|
Requires-Dist: ruff>=0.15; extra == "dev"
|
|
21
|
+
Requires-Dist: nonebot-adapter-onebot>=2.4.6; extra == "dev"
|
|
21
22
|
|
|
22
23
|
# nonebot-plugin-hermes
|
|
23
24
|
|
|
@@ -61,6 +62,7 @@ Requires-Dist: ruff>=0.15; extra == "dev"
|
|
|
61
62
|
- 🧪 **群活跃态 (M1, 实验性)**:@bot 后 5 分钟内主动监听群对话,由 Hermes 通过结构化决策判断是否插话
|
|
62
63
|
- 🧪 **反向通道 (M1, 实验性)**:内嵌本地 MCP server,让 Hermes 主动 push 消息进群(延迟回复 / 异步通知)
|
|
63
64
|
- 🧪 **历史图片召回 (0.3+, 实验性)**:SQLite 持久化消息日志 + 文件系统图字节缓存 + MCP 工具 `get_message_images`,让 Hermes 在用户说"上图"/"刚才那张"时按消息 id 精确取回历史图字节
|
|
65
|
+
- 🧪 **OneBot v11 Notice 触发 (0.3.3+, 实验性)**:戳一戳作为第二种 @ 等价触发;有人入群时让 Hermes 自决要不要欢迎(noop 合法,不做模板欢迎语)
|
|
64
66
|
|
|
65
67
|
## 快速开始
|
|
66
68
|
|
|
@@ -325,6 +327,8 @@ T+5s 用户 B: @bot 评价下上图
|
|
|
325
327
|
| `HERMES_ACTIVE_SESSION_ENABLED` | `false` | 启用群活跃态(M1)。`false` 时退化为 v0.1.6 等价行为 |
|
|
326
328
|
| `HERMES_ACTIVE_SESSION_TTL_SEC` | `300` | 活跃窗口 TTL(秒),每次插话滑动续期 |
|
|
327
329
|
| `HERMES_ACTIVE_SWEEP_INTERVAL_SEC` | `30` | 活跃态过期清扫 cron 频率(秒) |
|
|
330
|
+
| `HERMES_POKE_TRIGGER_ENABLED` | `false` | OneBot v11:被戳一戳时触发对话(私聊 / 群都生效,等价于被 @)。其他适配器静默忽略 |
|
|
331
|
+
| `HERMES_GREET_ON_JOIN` | `false` | OneBot v11:有人加入群且 `HERMES_ACTIVE_SESSION_ENABLED=true` 时,触发一次 reactive turn 让 Hermes 自决是否欢迎(`noop` 是合法返回)。active 关时不触发 |
|
|
328
332
|
| `HERMES_BUFFER_PER_GROUP_CAP` | `200` | ⚠️ **0.3 起空转**——MessageBuffer 改为 SQLite 后端,无内存 per-group 上限;消息淘汰由 `HERMES_STORAGE_MESSAGE_*` 控制。下一个 major 版本会移除 |
|
|
329
333
|
| `HERMES_BUFFER_TOTAL_GROUPS_CAP` | `50` | ⚠️ **0.3 起空转**——同上,SQLite 后端无 LRU,改为 retention + 行数上限 |
|
|
330
334
|
| `HERMES_MCP_ENABLED` | `false` | 启动内嵌 FastMCP server(M1 反向通道) |
|
|
@@ -26,6 +26,7 @@ nonebot_plugin_hermes/core/storage/message_store.py
|
|
|
26
26
|
nonebot_plugin_hermes/handlers/__init__.py
|
|
27
27
|
nonebot_plugin_hermes/handlers/commands.py
|
|
28
28
|
nonebot_plugin_hermes/handlers/message.py
|
|
29
|
+
nonebot_plugin_hermes/handlers/notices.py
|
|
29
30
|
nonebot_plugin_hermes/mcp/__init__.py
|
|
30
31
|
nonebot_plugin_hermes/mcp/auth.py
|
|
31
32
|
nonebot_plugin_hermes/mcp/server.py
|
|
@@ -57,6 +58,10 @@ tests/test_mcp_read_tools.py
|
|
|
57
58
|
tests/test_message_buffer.py
|
|
58
59
|
tests/test_message_handler_coalesce.py
|
|
59
60
|
tests/test_message_store.py
|
|
61
|
+
tests/test_notices.py
|
|
62
|
+
tests/test_notices_config.py
|
|
60
63
|
tests/test_prompt_builder.py
|
|
64
|
+
tests/test_route_synthesized_input.py
|
|
61
65
|
tests/test_session_manager.py
|
|
62
|
-
tests/test_tasks_storage_vacuum.py
|
|
66
|
+
tests/test_tasks_storage_vacuum.py
|
|
67
|
+
tests/test_utils_adapter_name.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nonebot-plugin-hermes"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.3"
|
|
4
4
|
description = "NoneBot plugin for Hermes Agent — multi-platform AI chatbot via Hermes API Server"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -22,6 +22,7 @@ dev = [
|
|
|
22
22
|
"pytest>=8.0",
|
|
23
23
|
"pytest-asyncio>=0.23",
|
|
24
24
|
"ruff>=0.15",
|
|
25
|
+
"nonebot-adapter-onebot>=2.4.6",
|
|
25
26
|
]
|
|
26
27
|
|
|
27
28
|
[project.urls]
|
|
@@ -236,15 +236,14 @@ async def test_push_accepts_image_only_message():
|
|
|
236
236
|
# Side-effects on success: mark_bot_replied + buffer append
|
|
237
237
|
# ---------------------------------------------------------------------------
|
|
238
238
|
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
# 2. 后续
|
|
244
|
-
# 自决 should_reply=false
|
|
239
|
+
# push_message 成功后必须把它当成"bot 在群里发了一句"来记账, 与
|
|
240
|
+
# _run_reactive_turn 末段写 last_bot_reply_at / append BufferedMessage(is_bot=True)
|
|
241
|
+
# 等价。 否则:
|
|
242
|
+
# 1. cooldown 闸门读不到 last_bot_reply_at,后续非显式触发会被放过去送进 LLM 决策
|
|
243
|
+
# 2. 后续 turn 拉到的 <recent_messages> 看不见 bot 已答,LLM 无从自决 should_reply=false
|
|
245
244
|
#
|
|
246
|
-
# 这里只钉死 push_message_impl 自己的副作用契约;_refire
|
|
247
|
-
#
|
|
245
|
+
# 这里只钉死 push_message_impl 自己的副作用契约;_refire 入口、_run_reactive_turn
|
|
246
|
+
# 末段的 cooldown 闸门在 test_message_handler_coalesce.py 里有独立测试。
|
|
248
247
|
|
|
249
248
|
|
|
250
249
|
@pytest.mark.asyncio
|