diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 7d5a76f96ec04..f342a9b3e48d2 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -984,6 +984,7 @@ platform_toolsets: # reactions: true # Show processing reactions (default: true) # history_backfill: true # Recover missed channel messages on mention (default: true) # history_backfill_limit: 50 # Max messages to scan backwards (default: 50) +# history_full_thread: false # Walk full thread past prior bot replies (default: false) # ───────────────────────────────────────────────────────────────────────────── # Available toolsets (use these names in platform_toolsets or the toolsets list) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6c492d818a93c..19577424744ef 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2677,6 +2677,7 @@ def _ensure_hermes_home_managed(home: Path): "limit": 100, # Global cap on messages scanned per reconnect "max_dispatches": 10, # Cap on recovered messages dispatched per reconnect }, + "history_full_thread": False, # If True, walk the entire thread instead of stopping at the bot's most recent self-message (default: False — preserves existing partition behaviour) "reactions": True, # Add 👀/✅/❌ reactions to messages during processing # Discord Gateway transport health. These settings inspect the active # WebSocket's ready/open/heartbeat state; they never use Discord REST as diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 8fcbcf3b8ce2f..9677df9534e3e 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -6188,10 +6188,14 @@ def _discord_history_backfill(self) -> bool: def _discord_history_backfill_limit(self) -> int: """Return the max number of messages to scan backwards for context. - In practice the scan usually stops much earlier — at the bot's own - last message in the channel (the natural partition point). This - limit is a safety cap for cold starts and long gaps where no prior - bot message exists in recent history. + In the default (partition) mode, the scan usually stops much earlier — + at the bot's own last message in the channel (the natural partition + point). This limit is a safety cap for cold starts and long gaps + where no prior bot message exists in recent history. + + When ``full_thread`` mode is enabled, the scan walks the entire + thread up to this limit without stopping at the partition point, so + the agent sees the full conversation surrounding the trigger. """ configured = self.config.extra.get("history_backfill_limit") if configured is not None: @@ -6205,6 +6209,31 @@ def _discord_history_backfill_limit(self) -> int: except (ValueError, TypeError): return 50 + def _discord_history_full_thread(self) -> bool: + """Return whether the bot should fetch the full thread instead of + stopping at the most recent self-message partition point. + + Default: ``False`` (preserves existing behaviour — only the messages + since the bot's last reply are surfaced, matching the conversation + transcript window). + + When enabled, the scan walks the entire thread (up to + ``history_backfill_limit``) without stopping at the partition point. + Useful when the user wants the bot to see the full thread context — + e.g. when resuming a long-running investigation in a thread where + the bot has replied multiple times. + + Trade-off: the bot sees more context, but messages that are already + in the session transcript are duplicated in the prompt. Token cost + scales linearly with the active window. + """ + configured = self.config.extra.get("history_full_thread") + if configured is not None: + if isinstance(configured, str): + return configured.lower() in {"true", "1", "yes", "on"} + return bool(configured) + return os.getenv("DISCORD_HISTORY_FULL_THREAD", "false").lower() in {"true", "1", "yes", "on"} + async def _fetch_channel_context( self, channel: Any, @@ -6217,6 +6246,13 @@ async def _fetch_channel_context( a message sent by this bot (the natural partition point between bot turns) or reaches ``history_backfill_limit``. + When ``history_full_thread`` is enabled, the partition-stop on the + bot's own messages is skipped: the scan walks the entire thread up + to ``history_backfill_limit`` so the agent sees the full thread + context. This is useful for resuming a long thread where the bot + has already replied multiple times — without this mode, only the + messages since the most recent bot reply are surfaced. + When ``reply_target`` is provided (the user replied to a specific message), a second backward scan is run ending at that target so the agent sees the conversation surrounding what the user pointed at — @@ -6235,6 +6271,7 @@ async def _fetch_channel_context( limit = self._discord_history_backfill_limit() if limit <= 0: return "" + full_thread = self._discord_history_full_thread() # Determine which bot messages to include in context allow_bots_raw = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() @@ -6247,14 +6284,22 @@ async def _fetch_channel_context( # Guard: only use the cache when it's chronologically before the # trigger — Discord snowflake IDs are monotonically increasing, so # a simple int comparison suffices. + # + # full_thread mode bypasses the cache entirely: the user wants the + # entire thread context, including messages that preceded prior bot + # replies. Passing `after=_last_self_message_id` here would silently + # drop everything before our last response, defeating the feature on + # the hot path. See regression test + # `test_fetch_channel_context_full_thread_ignores_last_self_cache`. channel_id = str(getattr(channel, "id", "")) - _cached_id = self._last_self_message_id.get(channel_id) _after_obj = None - try: - if _cached_id and int(_cached_id) < int(before.id): - _after_obj = discord.Object(id=int(_cached_id)) - except (ValueError, TypeError): - pass # Malformed cache entry — fall back to cold-start scan + if not full_thread: + _cached_id = self._last_self_message_id.get(channel_id) + try: + if _cached_id and int(_cached_id) < int(before.id): + _after_obj = discord.Object(id=int(_cached_id)) + except (ValueError, TypeError): + pass # Malformed cache entry — fall back to cold-start scan is_thread_channel = isinstance(channel, discord.Thread) has_unverified = False @@ -6347,7 +6392,10 @@ def _keep(msg) -> Optional[str]: # partition point. Everything before this is already in the # session transcript. (Redundant when _after_obj is set, but # needed for cold start.) - if msg.author == self._client.user: + # Skip this stop when full_thread mode is enabled — the user + # wants the entire thread context, even messages preceding + # prior bot replies. + if not full_thread and msg.author == self._client.user: break line = _keep(msg) if line is None: @@ -9718,6 +9766,12 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: hbl = discord_cfg.get("history_backfill_limit") if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"): os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl) + # history_full_thread: opt-in override that walks the entire thread + # instead of stopping at the bot's most recent self-message partition. + # Default in the adapter is False, so existing deployments see no + # behaviour change unless they explicitly set this in config.yaml. + if "history_full_thread" in discord_cfg and not os.getenv("DISCORD_HISTORY_FULL_THREAD"): + os.environ["DISCORD_HISTORY_FULL_THREAD"] = str(discord_cfg["history_full_thread"]).lower() # allow_mentions: granular control over what the bot can ping. # Safe defaults (no @everyone/roles) are applied in the adapter; # these YAML keys only override when set and let users opt back diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 451ad7012b056..8dea06674078d 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -1781,6 +1781,74 @@ def test_bridges_discord_history_backfill_settings_from_config_yaml(self, tmp_pa assert os.getenv("DISCORD_HISTORY_BACKFILL") == "true" assert os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT") == "17" + def test_bridges_discord_history_full_thread_from_config_yaml(self, tmp_path, monkeypatch): + """Regression: discord.history_full_thread in config.yaml must seed + DISCORD_HISTORY_FULL_THREAD so the adapter sees the user opt-in. + + Without this bridge the YAML key documented at + website/docs/user-guide/messaging/discord.md is a dead letter — the + adapter only reads os.getenv("DISCORD_HISTORY_FULL_THREAD"). Teknium1 + flagged this on PR #51414; without coverage the bridge could silently + regress and the documented feature would no-op. + """ + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " history_full_thread: true\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_HISTORY_FULL_THREAD", raising=False) + + load_gateway_config() + + assert os.getenv("DISCORD_HISTORY_FULL_THREAD") == "true" + + def test_history_full_thread_yaml_bridge_respects_existing_env_var(self, tmp_path, monkeypatch): + """Env-var precedence: a pre-set DISCORD_HISTORY_FULL_THREAD wins over + the YAML key. Mirrors the contract used for the other discord.* keys. + """ + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " history_full_thread: true\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.setenv("DISCORD_HISTORY_FULL_THREAD", "false") + + load_gateway_config() + + # Env var pre-set by the operator wins — YAML did not overwrite it. + assert os.getenv("DISCORD_HISTORY_FULL_THREAD") == "false" + + def test_history_full_thread_yaml_bridge_accepts_false_value(self, tmp_path, monkeypatch): + """Explicit YAML `false` is propagated (not silently dropped) so users + who set it explicitly get the documented behaviour instead of relying + on absence. + """ + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " history_full_thread: false\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("DISCORD_HISTORY_FULL_THREAD", raising=False) + + load_gateway_config() + + assert os.getenv("DISCORD_HISTORY_FULL_THREAD") == "false" + def test_bridges_telegram_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index 6b0c4c32753dd..9b5c9e2db6550 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -115,13 +115,14 @@ def adapter(monkeypatch): "DISCORD_IGNORED_CHANNELS", "DISCORD_HISTORY_BACKFILL", "DISCORD_HISTORY_BACKFILL_LIMIT", + "DISCORD_HISTORY_FULL_THREAD", "DISCORD_ALLOW_BOTS", ): monkeypatch.delenv(_var, raising=False) config = PlatformConfig(enabled=True, token="fake-token") adapter = DiscordAdapter(config) - adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) + adapter._client = SimpleNamespace(user=SimpleNamespace(id=999, display_name="Hermes", name="hermes")) adapter._text_batch_delay_seconds = 0 # disable batching for tests adapter.handle_message = AsyncMock() return adapter @@ -1486,3 +1487,269 @@ async def test_discord_non_reply_free_channel_skips_backfill(adapter, monkeypatc adapter._fetch_channel_context.assert_not_awaited() + +# --------------------------------------------------------------------------- +# history_full_thread mode (feature: discord-full-thread-history) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_channel_context_full_thread_walks_past_self_messages(adapter, monkeypatch): + """history_full_thread=True skips the self-message partition and returns + the entire thread up to history_backfill_limit. + + Without the flag, the scan would stop at the bot's most recent reply + (msg_id=2) and only surface msg_id=3 — here we assert it walks through + both prior bot replies and includes all human messages around them. + """ + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 10 + adapter.config.extra["history_full_thread"] = True + + bot_user = adapter._client.user + human_a = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + human_b = SimpleNamespace(id=57, display_name="Bob", name="Bob", bot=False) + + channel = FakeHistoryChannel( + [ + # Newest first — this is what channel.history() yields with + # oldest_first=False. + make_history_message(author=human_a, content="question after latest reply", msg_id=6), + make_history_message(author=bot_user, content="second bot reply", msg_id=5), + make_history_message(author=human_b, content="middle human note", msg_id=4), + make_history_message(author=bot_user, content="first bot reply", msg_id=3), + make_history_message(author=human_a, content="initial question", msg_id=2), + make_history_message(author=human_b, content="earliest note", msg_id=1), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + # Both bot messages must appear (not partition), all four humans must appear, + # and the output must be chronologically ordered. + assert result == ( + "[Recent channel messages]\n" + "[Bob] earliest note\n" + "[Alice] initial question\n" + "[Hermes] first bot reply\n" + "[Bob] middle human note\n" + "[Hermes] second bot reply\n" + "[Alice] question after latest reply" + ) + + +@pytest.mark.asyncio +async def test_fetch_channel_context_full_thread_default_is_partition_mode(adapter, monkeypatch): + """Default behaviour (no flag set) MUST stop at the bot's last message — + the feature must be opt-in, not silent-on-by-default. + + Regression guard: a future refactor that flips the default would break + every existing user's context window and prompt-cache layout. + """ + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + monkeypatch.delenv("DISCORD_HISTORY_FULL_THREAD", raising=False) + adapter.config.extra["history_backfill_limit"] = 10 + # Explicitly do NOT set history_full_thread — neither config nor env. + + bot_user = adapter._client.user + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=human, content="after", msg_id=4), + make_history_message(author=bot_user, content="our prior response", msg_id=3), + make_history_message(author=human, content="before partition", msg_id=2), + make_history_message(author=bot_user, content="earlier bot", msg_id=1), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + # Same as test_fetch_channel_context_stops_at_self_message... — partition + # still works. The "before partition" and "earlier bot" messages are + # excluded because msg_id=3 is our last reply. + assert result == "[Recent channel messages]\n[Alice] after" + + +@pytest.mark.asyncio +async def test_fetch_channel_context_full_thread_env_var_overrides_default(adapter, monkeypatch): + """DISCORD_HISTORY_FULL_THREAD=1 enables the feature without config edit. + + Useful for runtime toggles and ops verification without restarting with + a new config.yaml. + """ + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + monkeypatch.setenv("DISCORD_HISTORY_FULL_THREAD", "1") + adapter.config.extra["history_backfill_limit"] = 10 + + bot_user = adapter._client.user + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=human, content="after", msg_id=3), + make_history_message(author=bot_user, content="prior reply", msg_id=2), + make_history_message(author=human, content="before", msg_id=1), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + assert result == ( + "[Recent channel messages]\n" + "[Alice] before\n" + "[Hermes] prior reply\n" + "[Alice] after" + ) + + +@pytest.mark.asyncio +async def test_fetch_channel_context_full_thread_respects_limit(adapter, monkeypatch): + """history_full_thread mode honours history_backfill_limit — no infinite walk.""" + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 2 + adapter.config.extra["history_full_thread"] = True + + bot_user = adapter._client.user + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + channel = FakeHistoryChannel( + [ + make_history_message(author=human, content="msg-5", msg_id=5), + make_history_message(author=bot_user, content="msg-4", msg_id=4), + make_history_message(author=human, content="msg-3", msg_id=3), + make_history_message(author=bot_user, content="msg-2", msg_id=2), + make_history_message(author=human, content="msg-1", msg_id=1), + ], + channel_id=123, + ) + + result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content="trigger")) + + # FakeHistoryChannel honours `limit` — only 2 messages survive. In + # newest-first order those are msg-5 and msg-4, which after reversing + # are [msg-4, msg-5]. + assert result == "[Recent channel messages]\n[Hermes] msg-4\n[Alice] msg-5" + + +@pytest.mark.asyncio +async def test_fetch_channel_context_full_thread_ignores_last_self_cache(adapter, monkeypatch): + """Regression: full_thread mode MUST bypass the _last_self_message_id cache. + + The hot-path cache narrows the window by passing after=_last_self_message_id + into channel.history() — which on the usual send path means everything + before the bot's prior reply is excluded. When the user opts into + full_thread mode, that cache boundary is the wrong behaviour: they want + the entire thread, not the slice since the bot's last turn. A future + refactor that re-enables the cache in full_thread mode would silently + break the feature for every production deployment. + + Seeds _last_self_message_id with a real value and asserts channel.history() + is called with after=None while full_thread is on. Without the fix in + `_fetch_channel_context`, recorded_after would be a discord.Object(id=100). + """ + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 50 + adapter.config.extra["history_full_thread"] = True + + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + recorded_after = {} + + class CacheTrackingChannel(FakeHistoryChannel): + def history(self, *, limit, before, after=None, oldest_first=None): + recorded_after["value"] = after + return super().history( + limit=limit, + before=before, + after=after, + oldest_first=oldest_first, + ) + + channel = CacheTrackingChannel( + [ + make_history_message(author=human, content="msg after bot reply", msg_id=200), + make_history_message(author=adapter._client.user, content="our prior reply", msg_id=150), + make_history_message(author=human, content="msg before bot reply", msg_id=120), + ], + channel_id=888, + ) + + # Seed the cache with a real-looking prior self-message ID. In a real + # deployment this gets written by the send() path on every successful + # bot reply — it is the normal hot-path state, not a contrived fixture. + adapter._last_self_message_id["888"] = "150" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 300 + + result = await adapter._fetch_channel_context(channel, before=trigger) + + # The cache boundary must be skipped — channel.history() was called + # without `after=`, so the cold-start scan ran and surfaced the message + # from before the bot's prior reply. + assert recorded_after["value"] is None, ( + f"full_thread mode must bypass the _last_self_message_id cache; " + f"got after={recorded_after['value']!r}" + ) + assert "[Alice] msg before bot reply" in result, ( + "full_thread mode should surface messages before the bot's prior reply" + ) + assert "[Hermes] our prior reply" in result + assert "[Alice] msg after bot reply" in result + + +@pytest.mark.asyncio +async def test_fetch_channel_context_partition_mode_still_uses_cache(adapter, monkeypatch): + """Counterpart regression: default (partition) mode MUST still use the cache. + + Teknium1's review notes both halves of the contract — full_thread bypasses + the cache, partition mode keeps using it. A future refactor that disables + the cache globally would burn an extra Discord API round-trip on every + trigger in every existing deployment. Asserting the negative path keeps + the optimisation intact. + """ + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") + adapter.config.extra["history_backfill_limit"] = 50 + # history_full_thread is NOT set — default partition mode applies. + + human = SimpleNamespace(id=56, display_name="Alice", name="Alice", bot=False) + + recorded_after = {} + + class CacheTrackingChannel(FakeHistoryChannel): + def history(self, *, limit, before, after=None, oldest_first=None): + recorded_after["value"] = after + return super().history( + limit=limit, + before=before, + after=after, + oldest_first=oldest_first, + ) + + channel = CacheTrackingChannel( + [make_history_message(author=human, content="hello", msg_id=200)], + channel_id=999, + ) + + adapter._last_self_message_id["999"] = "100" + + trigger = make_message(channel=channel, content="trigger") + trigger.id = 300 + + await adapter._fetch_channel_context(channel, before=trigger) + + # Partition mode still narrows the fetch with after=_last_self_message_id. + # In this test env discord.Object is mocked, so we cannot inspect .id + # against the literal — only assert that the cache was applied (after is + # non-None). The complementary half (full_thread = None) is covered by + # test_fetch_channel_context_full_thread_ignores_last_self_cache. + assert recorded_after["value"] is not None, ( + "partition mode must seed after= from _last_self_message_id; " + f"got after={recorded_after['value']!r}" + ) + + diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 80bf88b716e91..5f6febfc054a0 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -347,6 +347,7 @@ discord: window_seconds: 21600 # Look back at most 6 hours limit: 100 # Global scan cap per reconnect max_dispatches: 10 # Recovery dispatch cap per reconnect + history_full_thread: false # Walk the full thread past prior bot replies (default: false) channel_prompts: {} # Per-channel ephemeral system prompts voice_channel_inactivity_timeout_seconds: 300 # Set 0 to stay in VC until explicit /voice leave voice_playback_timeout_seconds: 120 # Minimum playback watchdog; long clips get duration+padding @@ -536,6 +537,25 @@ discord: If `channels` is empty, Hermes uses `discord.free_response_channels`. Set it to `"*"` only when the bot should inspect every reachable server text channel. The recovery ledger is stored per profile under `gateway/discord_message_recovery.db`, preventing a successfully answered message from being replayed again after a later restart. +#### `discord.history_full_thread` + +**Type:** boolean — **Default:** `false` + +Opt-in override that walks the **entire thread** instead of stopping at the bot's most recent self-message. The default partition behaviour is what you want most of the time: only the messages since the bot's last reply are surfaced, which keeps the context window tight and matches the conversation transcript. Enable `history_full_thread` when you want the bot to see the full thread context — for example, when resuming a long investigation in a thread where the bot has already replied multiple times. + +```yaml +discord: + history_full_thread: true +``` + +Trade-offs: + +- **Token cost scales linearly with the active window.** Messages that are already in the session transcript are duplicated in the prompt, so a long-running thread with `history_full_thread: true` will pay more per turn. Pair this flag with `history_backfill_limit` to cap the walk — e.g. `history_backfill_limit: 100` keeps the cold-start scan bounded even when full-thread mode is on. +- **Hot-path cache bypass.** When `history_full_thread` is on, the in-memory `_last_self_message_id` cache is intentionally skipped so the scan reaches back past prior bot replies. The cache is what normally narrows the Discord API window on the hot path; full-thread mode trades that optimisation for visibility. +- **Default is safe.** Without this flag the existing partition behaviour is preserved exactly. Set it explicitly when you want the wider window. + +Env-var override for ops toggles without a config edit: `DISCORD_HISTORY_FULL_THREAD=true`. The env var takes precedence over the YAML key, matching the convention used by the other `discord.*` settings. + #### `group_sessions_per_user` **Type:** boolean — **Default:** `true` diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/discord.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/discord.md index 8df87a340e2c6..140dacc17e319 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/discord.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/discord.md @@ -338,6 +338,7 @@ discord: window_seconds: 21600 # 最多回溯 6 小时 limit: 100 # 每次重连的全局扫描上限 max_dispatches: 10 # 每次重连的恢复分发上限 + history_full_thread: false # 走完整个线程,包括早于机器人上次回复的消息(默认:false) channel_prompts: {} # 每个频道的临时系统 prompt(提示词) allow_mentions: # 机器人允许 ping 的内容(安全默认值) everyone: false # @everyone / @here ping(默认:false) @@ -525,6 +526,25 @@ discord: 如果 `channels` 留空,Hermes 会使用 `discord.free_response_channels`。只有当机器人确实需要检查所有可访问的服务器文字频道时才设置为 `"*"`。恢复账本按配置文件存储在 `gateway/discord_message_recovery.db`,避免已成功回复的消息在后续重启时再次执行。 +#### `discord.history_full_thread` + +**类型:** 布尔值 — **默认值:** `false` + +可选开启,覆盖默认的"在机器人最近一条回复处停下"行为,走完整个线程。默认的分区行为在大多数情况下是正确的:只暴露机器人上次回复之后的消息,保持上下文窗口紧凑并与会话记录对齐。当你希望机器人看到完整线程上下文时——例如在长跑调查中机器人已多次回复的线程里恢复工作时——启用 `history_full_thread`。 + +```yaml +discord: + history_full_thread: true +``` + +权衡: + +- **token 成本随活动窗口线性增长。** 会话记录中已有的消息会在 prompt 中重复出现,因此开启 `history_full_thread: true` 的长跑线程每轮都要付出更多成本。建议与 `history_backfill_limit` 配合以限制扫描范围——例如 `history_backfill_limit: 100` 可以让冷启动扫描即使在全线程模式下也有界。 +- **绕过热路径缓存。** `history_full_thread` 开启时,会刻意跳过 `_last_self_message_id` 内存缓存,让扫描能够越过机器人此前的回复。缓存本来会在热路径上收窄 Discord API 的窗口;全线程模式用这层优化换取可见性。 +- **默认行为安全。** 不设置这个开关时,保留原有分区行为完全不变。需要更宽的窗口时显式开启。 + +运维场景下不修改 config 也可通过环境变量覆盖:`DISCORD_HISTORY_FULL_THREAD=true`。环境变量优先级高于 YAML key,与其它 `discord.*` 设置的约定一致。 + #### `group_sessions_per_user` **类型:** 布尔值 — **默认值:** `true`