Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
76 changes: 65 additions & 11 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR's documented discord.history_full_thread key is not seeded into PlatformConfig.extra: this PR does not update _apply_yaml_config, DEFAULT_CONFIG, or the Discord docs. Wire the YAML key through the existing config bridge; behavioral settings must be user-facing in config.yaml rather than only in a new environment variable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — the docstring on _discord_history_full_thread advertised config.extra["history_full_thread"] but the YAML→env bridge in _apply_yaml_config only knew about history_backfill and history_backfill_limit. Setting the documented YAML key was a dead letter.

Fixed in this push:

  • Wired discord.history_full_thread through _apply_yaml_config (adapter.py, alongside the existing history_backfill_limit block) — same not os.getenv(...) precedence guard as the neighbouring keys, so env var still wins over YAML when set.
  • Added the default to hermes_cli/config.py DEFAULT_CONFIG["discord"] ("history_full_thread": False) so hermes config show / hermes setup reflect it.
  • Added regression coverage in tests/gateway/test_config.py:
    • test_bridges_discord_history_full_thread_from_config_yaml — happy path.
    • test_history_full_thread_yaml_bridge_respects_existing_env_var — env-var precedence contract.
    • test_history_full_thread_yaml_bridge_accepts_false_value — explicit false propagates instead of being silently dropped.
  • Documented at website/docs/user-guide/messaging/discord.md (and the zh-Hans translation): new #### discord.history_full_thread section with the type, default, YAML example, trade-offs (token cost + cache bypass), and the env-var override. Quick-block at the top of the file's discord: snippet also lists the key.
  • cli-config.yaml.example got the matching commented line in the platform block.

The PR's headline behaviour is unchanged — history_full_thread defaults to False, partition mode stays as-is, env var remains a fallback — but the YAML key is now actually load-bearing.

Re-review on both threads welcome.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-ping after rebase: this concern is now resolved — the YAML wiring the previous version added is preserved through the rebase. history_full_thread is still seeded into DEFAULT_CONFIG in hermes_cli/config.py, threaded through _apply_yaml_config in plugins/platforms/discord/adapter.py, and documented in both website/docs/user-guide/messaging/discord.md and the ZH translation. Re-requesting your re-review on head 723212debd.

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,
Expand All @@ -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 —
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

full_thread still uses the earlier _last_self_message_id cache as channel.history(after=...) (lines 4350-4354 and 4410). On the usual hot path, messages before the last bot response are therefore excluded before this condition runs. Disable that cache boundary in full-thread mode and add a regression test that seeds _last_self_message_id.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid catch — thanks. The local break-skip on its own was a no-op on the hot path: by the time control reached the loop, channel.history() had already been narrowed by after=self._last_self_message_id[...] (adapter.py:4350-4354 and 4410), so the messages I wanted to walk past were filtered out before the partition check ever ran. Cold start works as advertised, hot path didn't.

Fixed in this push:

  • Moved the bypass to the cache-construction site: in _fetch_channel_context, the _after_obj assignment is now wrapped in if not full_thread: so the cache is only consulted in partition mode. Same shape as the existing if not full_thread and msg.author == self._client.user: break guard, just applied at the right layer.
  • Added test_fetch_channel_context_full_thread_ignores_last_self_cache in tests/gateway/test_discord_free_response.py — seeds _last_self_message_id["888"] = "150" and asserts channel.history() is called with after=None while history_full_thread: True. Without the fix the recorded after is a discord.Object(id=150).
  • Added a counterpart test_fetch_channel_context_partition_mode_still_uses_cache so a future refactor that disables the cache globally (in either direction) gets caught — partition mode keeps the optimisation intact.

Env var fallback (DISCORD_HISTORY_FULL_THREAD) is kept as the ops-escape hatch, with explicit env-var precedence over YAML in the bridge — same convention as DISCORD_HISTORY_BACKFILL and friends.

Ready for re-review on this thread.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-ping after rebase: this concern is now resolved at the new location of the cache-construction guard. See commit 477e3f01a6 (rebased as feat(discord): add history_full_thread option) which still wraps the _after_obj assignment in if not full_thread:. The regression test test_fetch_channel_context_full_thread_ignores_last_self_cache (still passing on the rebased branch) seeds _last_self_message_id and asserts the cache is bypassed in full-thread mode. Re-requesting your re-review on the rebased head 723212debd.

break
line = _keep(msg)
if line is None:
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions tests/gateway/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading