Commit graph

16951 commits

Author SHA1 Message Date
Pavel Tajduš
c2d306c4c2
fix(slack): preserve typed command integrity across enrichment paths
Commands typed in Slack could be mangled by every enrichment layer the
adapter applies to normal messages:

- Block Kit / unfurl / attachment-notice / text-file injection could
  prepend or append content around a command, moving the command token
  away from character zero or polluting its arguments. Commands are now
  restored from canonical authored input after all enrichment
  (final is_command_text guard before MessageEvent construction).
- @bot /cmd (typed slash behind a mention) was never classified as a
  command; the mention-strip branch now re-probes for both slash and
  bang forms.
- The Slack Agent-view context label ([Slack app context: ...]) was
  prepended to command events too; now command-exempt.
- Native slash payload arguments were strip()ed, destroying meaningful
  spacing inside/after arguments; only the command delimiter is
  nonsemantic now.
- Slash payload thread identity (thread_ts/message_ts, top-level or
  nested in message/container) is preserved onto SessionSource so
  session-scoped commands hit the same thread session.
- /queue and /steer queued fallbacks now propagate channel_context so a
  command that triggered first-entry thread backfill doesn't lose the
  history when re-queued.

Adapted from PR #66310 to the post-#69320 channel_context design (thread
history already rides MessageEvent.channel_context, never text).
2026-07-22 08:37:52 -07:00
srojk34
e7ef57f8c3
fix(slack): surface retryable + Retry-After on send() rate-limit errors (#46762)
Slack's send() caught all exceptions and returned a bare
SendResult(success=False) — never setting retryable=True or extracting
the server's Retry-After header.  When Slack returned a 429 rate-limit
error, the base _send_with_retry() layer saw retryable=False and did
not retry, silently dropping remaining message chunks.

Reuse the existing _is_retryable_upload_error() helper (which already
detects 429, 500+, and connection-type errors) to set retryable=True,
and extract the Retry-After header from the SlackApiError response
when present so the base retry layer honors Slack's backoff schedule
instead of its own default.

Sibling of the Telegram FloodWait fix (PR #46762 / commit 404b06ac4)
which added the SendResult.retry_after plumbing to the base layer.

Adds five regression tests covering 429 with/without Retry-After,
500 server errors, 403 non-retryable errors, and connection errors.
2026-07-22 08:37:52 -07:00
luyifan
7a2bfd4b7a
Bound Slack response_url error reads 2026-07-22 08:37:52 -07:00
Soynchux
bd707103ef
fix(slack): stop consuming slash reply context outside slash sends
_pop_slash_context fell back to a channel-only scan when the
_slash_user_id ContextVar was unset (i.e. send() invoked from a
non-slash code path such as a cron delivery or a normal channel reply).
That scan could steal another user's pending slash reply context: the
normal message got swallowed into an ephemeral response_url POST that
replaces the invoker's ack, and the slash invoker's actual reply then
posted publicly. Remove the fallback — when the ContextVar is unset,
match nothing.

Surgical reapply of PR #26788 (originally against gateway/platforms/slack.py).
2026-07-22 08:37:52 -07:00
drafish
5c27b9b19b
fix(slack): pass event-derived chat_type to _has_active_session_for_thread
_has_active_session_for_thread() hardcoded chat_type='group', causing
session key mismatch for DM and MPIM threads. DM sessions key as
agent:main:slack:dm:{chat_id}:{thread_ts} but the lookup built
agent:main:slack:group:{chat_id}:{user_id}:{thread_ts}.

Impact: _has_active_session_for_thread always returned False for DM
threads, causing thread context to be prepended on every message. The
prepended context broke slash command detection (get_command() checks
text.startswith('/')), so /cmd and !cmd never worked in DM threads.

Fix: accept event-derived chat_type parameter instead of hardcoding
'group'. Both call sites pass chat_type='dm' if is_dm else 'group',
where is_dm is already computed from channel_type in {'im', 'mpim'}.

This correctly handles:
- IM channels (D-prefix): chat_type='dm'
- MPIM channels (G-prefix): chat_type='dm' (was missed by D-prefix heuristic)
- Channel messages (C-prefix): chat_type='group' (unchanged)

Added regression tests covering DM thread lookup, MPIM thread lookup,
and negative cases verifying the old hardcoded 'group' behavior fails.
2026-07-22 08:37:52 -07:00
Greg Duraj
fe201dd56c
fix(slack): avoid rich-text duplication in commands + keep slash thread identity
Slack rich_text blocks mirror the original message text. When bang
commands are rewritten from !model to /model, appending block text makes
the command arguments include a duplicate payload, so the model switcher
sees spaces in the model name and rejects valid commands like:

  !model qwen3.7-plus --provider opencode-go

Skip block extraction for command messages while preserving it for
normal messages. Also preserve Slack thread_ts (top-level or nested in
message/container payload shapes) on native slash-command payloads so
session-scoped commands like /model apply to the intended thread instead
of a channel+user key the next threaded message never matches.

Surgical reapply of PR #43533 (originally against gateway/platforms/slack.py,
now plugins/platforms/slack/adapter.py). Thread-shape widening credit also
to #66310.
2026-07-22 08:37:52 -07:00
cypres0099
8fc1f578bb
fix(slack): dispatch mentioned bang commands in threads 2026-07-22 08:37:52 -07:00
nu
3e05b39e4b
fix(slack): handle leading-space text commands 2026-07-22 08:37:52 -07:00
Teknium
cbc1054e23 fix: adapt compression attempt logging to current main aux-call contract
- aux summary call on main intentionally omits max_tokens; use .get() in the
  telemetry hook (and widen the param type) so the hook never breaks the call
- update test expectation: aux_output_reservation is None on main
- record no_progress failure_class in the no-progress boundary branch

Follow-up for salvaged PR #60444.
2026-07-22 08:13:41 -07:00
Gabriele Di Gesù
356ff99030 feat: log compression attempt telemetry 2026-07-22 08:13:41 -07:00
Teknium
5a3ee3c537 fix(compression): let handoff-strip supersede the head-copy skip
The summary_idx head-copy skip (from #69302) dropped the entire merged
handoff message, deleting the genuine prior-tail user content that
#47274's _strip_context_summary_handoff_message correctly unwraps.
Strip handles both shapes: standalone handoffs drop, merged handoffs
keep their real content. Caught by
test_recompression_of_current_merged_handoff_preserves_prior_tail_once
when both PRs landed together.
2026-07-22 08:12:45 -07:00
WXBR
2b84ed921c fix: dedupe persisted compaction handoffs 2026-07-22 08:12:45 -07:00
Teknium
020bd1ba0a test(compression): behavioral + config wiring coverage for threshold_tokens
Follow-up for salvaged #24279:
- cli-config.yaml.example: document compression.threshold_tokens
  (commented-out, default null = disabled)
- contributors/emails: map maly.dan@gmail.com -> DanielMaly
- tests: should_compress() fires at the absolute cap below the pct
  threshold (first-fires-wins); DEFAULT_CONFIG ships None and 0/None
  are behavior-neutral incl. across update_model(); the small-context
  pct floor is unaffected by the cap and re-derives correctly on
  model switch
2026-07-22 08:12:14 -07:00
DanielMaly
e5078e3152 feat(compression): add absolute token threshold via compression.threshold_tokens
Add compression.threshold_tokens config option that sets an absolute
token cap for auto-compaction. When configured alongside the existing
ratio-based threshold, the effective trigger point is the lower of the
two, so compression never fires later than the user's preferred token
count regardless of which model is active.

This solves the problem where switching between models with different
context windows (e.g. 1M → 400K) shifts the absolute trigger point,
causing premature or delayed compression.

Rework from PR #24279 addressing sweeper feedback:
- The cap is now a first-class compressor configuration value
  (threshold_tokens_cap parameter on ContextCompressor.__init__),
  not a post-construction patch on the live instance.
- Applied in both __init__ and update_model() so it survives model
  switches and fallback activations (the old approach was undone by
  update_model() restoring _configured_threshold_percent).
- Clamped to the model's context length so a cap above the window is
  a no-op (ratio-based threshold wins).
- Works with max_tokens output-token reservations.
- Added 9 tests covering cap-vs-ratio selection, model switch survival,
  context-length clamping, max_tokens interaction, and invalid values.
- Updated user-facing configuration docs.
- Removed unrelated background-review/curator/Honcho changes (main
  already contains background-review memory isolation in 973f27e95).

Config example:
  compression:
    threshold: 0.50
    threshold_tokens: 200000   # never compress later than 200K tokens
2026-07-22 08:12:14 -07:00
Teknium
0acdf1d8c8
fix(compression): apply strict redaction at every compaction text boundary (#69294)
Compaction summaries persist across sessions and re-enter every subsequent
summarizer prompt, but every redact_sensitive_text() call in
context_compressor.py used default mode: a no-op under
security.redact_secrets:false, and opaque OAuth-callback / URL-userinfo
credentials passed through even when enabled. The stored _previous_summary
also re-entered the iterative-update prompt unredacted.

Add _redact_compaction_text() — redact_sensitive_text(force=True,
redact_url_credentials=True) — and thread it through all compaction text
boundaries: serializer input (content + tool args), deterministic fallback
summary, summarizer LLM output, manual + auto focus topics, the latest-user
task snapshot, and _previous_summary re-entry.

Note: force=True at this boundary intentionally overrides
security.redact_secrets:false — that opt-out targets live tool output, not
persisted summaries.

Salvages the compaction half of #49556 (the redact.py strict-URL half
landed independently via 75af6dc57/62a00a739). Addresses #43666 item 2.

Co-authored-by: AndrewMoryakov <topazd2@gmail.com>
2026-07-22 08:11:40 -07:00
Teknium
2ee50c69d3 docs(compression): note blank-echo removal survives summary abort 2026-07-22 08:10:47 -07:00
John Lussier
97cd0d98f1 test(compression): cover leading and input-text blanks 2026-07-22 08:10:47 -07:00
John Lussier
bc4824167d fix(compression): preserve latest actionable user turn 2026-07-22 08:10:47 -07:00
Teknium
f13f845116 feat(state): messages_fts_cjk — CJK-bigram index on the v23 external-content layout
Integration layer for the cjk_unicode61 tokenizer, rebuilt on the v23
schema (the contributed integration in PR #65544 predated it):

- messages_fts_cjk: external-content FTS5 over a tool-row-excluding view
  (same v23 storage discipline as the trigram index it supersedes — zero
  inline text copies). Serves EVERY CJK query shape the legacy routing
  split between trigram (>=3 chars/token) and LIKE full scans (1-2 char
  tokens). Lone 1-char CJK runs and role_filter=['tool'] queries keep
  their legacy routes.
- Dedicated marker pair (fts_cjk_rebuild_high_water/progress) gates the
  id-scoped triggers, so a cjk-only backfill never gates the complete
  messages_fts/trigram triggers.
- Transitions ride  (the existing
  throttled/resumable chunk engine): fresh DBs are born with the index;
  legacy v22 DBs land on v23+cjk in one run; already-optimized v23 DBs
  gaining the tokenizer get a marker-gated backfill; live writes are
  indexed immediately in every case.
- Tokenizer-loss self-heal: a process that can't load the extension drops
  the cjk triggers (writes keep working), leaves a stale breadcrumb, and
  the index is rebuilt from scratch on the next optimize run — triggers
  are never reinstalled over a gap (external-content 'delete' on an
  unindexed rowid is the FTS5 corruption hazard the marker gating exists
  to prevent).
- Capability classification: 'no such tokenizer: cjk_unicode61' joins the
  degraded-runtime error class everywhere (read probe, write probe,
  repair) so tokenizer absence is never misclassified as corruption.
- Config: sessions.cjk_fts (default on, inert without the .so) and
  sessions.search_slow_ms in config.yaml, bridged to env by CLI + gateway
  (startup + per-turn reload). build.sh falls back to vendored SQLite
  headers so no libsqlite3-dev is needed.

Slow-query log path attribution updated: fts_cjk / fts5 / trigram /
like_scan. Tests: 14 lifecycle tests (fresh/legacy/stale/backfill paths,
tokenizer-loss round-trip) + 5 config-bridge tests + slow-log suite.
2026-07-22 07:56:47 -07:00
Soju06
8364576e33 feat(state): slow-query log for session search with routing-path attribution
One INFO line per slow search naming the path taken (fts_cjk / fts5 /
trigram / like_scan), elapsed time, row count, and the query. The 2026-07
session_search investigation needed turn-trace archaeology plus workload
replay to discover that short-CJK queries were full-scanning the table —
with this line the next routing regression is a journalctl grep.

Threshold: sessions.search_slow_ms (default 1000ms; 0 logs every call),
bridged to HERMES_SEARCH_SLOW_MS.

Salvaged from PR #65544 (adapted to the v23 schema in follow-up commits).
2026-07-22 07:56:47 -07:00
Soju06
b10952e9c6 feat(state): cjk_unicode61 FTS5 tokenizer — unicode61 + CJK bigrams (native extension)
unicode61 indexes a CJK run as ONE token, so 2-char Korean terms (일본,
구글, 우리, ...) can never match it and the trigram tokenizer needs >=3
chars per term — any query containing a 1-2 char CJK token falls through
to a LIKE full-table scan (measured 3-6.4s CPU per query on a 6.8GB
production state.db; the #1 base cost behind a 12.4s session_search
average on CJK workloads).

This ships a ~250-line loadable FTS5 tokenizer (no deps) that wraps
unicode61: maximal CJK runs inside its tokens are re-emitted as
overlapping character bigrams (Lucene CJKAnalyzer semantics), everything
else passes through unchanged. FTS5 phrase semantics turn consecutive
sub-tokens into exact substring matching down to 2-char terms at index
speed.

Build: native/fts5_cjk/build.sh -> ~/.hermes/lib/libfts5_cjk.so
(override: HERMES_FTS5_CJK_SO).

Salvaged from PR #65544; the schema integration lands separately on the
v23 external-content layout.
2026-07-22 07:56:47 -07:00
Teknium
17155e3ae0 chore(contributors): add email mappings for slack thread-lifecycle salvage
LevSky22 (#66069), vexclawx31 (#33215), knoal (#64067), kaiyisg (#24848).
2026-07-22 07:22:55 -07:00
Ben
5d747a91c4 fix(slack): humanize inbound user mentions + ground bot identity
Slack delivers user mentions as opaque IDs (<@U123>). The agent had no
way to tell one participant from another — or from itself — so it could
misread a mention of a human as a self-mention and answer messages
addressed to that person (the "bot thinks it's @someone-else" bug).

Two cooperating fixes:
- _humanize_user_mentions rewrites remaining <@UID> tokens (the bot's
  own mention is stripped earlier) to @DisplayName in the trigger text
  and reply_to_text — the Slack equivalent of Discord's clean_content.
  Handles the labelled <@UID|handle> form; unresolvable IDs fall back
  to the raw ID.
- _build_identity_prompt injects an ephemeral per-turn system-prompt
  line via the channel_prompt seam (applied at API-call time, never
  persisted — prompt caching preserved) naming the bot's own workspace
  handle (per-team in multi-workspace installs) so the agent has a
  positive "that's me" anchor.

Salvaged from #55340 by @benbarclay, rebased over the workspace-scoped
user-name cache (team_id-aware resolution) on main.
2026-07-22 07:22:55 -07:00
LeonSGP43
503c0c0e51 fix(slack): expose shared-thread author mention target
In shared Slack threads the model saw only [sender name] prefixes, with
no verifiable current-author Slack user ID — so 'mention me again'
requests could bind to a stale or unrelated <@U...> pulled from names,
memory, or prior history (#17916).

Two cooperating changes:
- The shared-session sender prefix on Slack now carries the current
  author's ID from the event envelope:
  '[Alice | Slack user <@U123>] ...' — per-turn data, so it does not
  touch the cached system prompt.
- The Slack platform notes gain a shared-thread instruction to use the
  current turn's sender prefix as the only verified mention target and
  never guess or reuse historical mentions.

Fixes #17916.

Salvaged from #18711 by @LeonSGP43 (asdigitos), rebased over the
sender-name neutralization added on main (the ID is appended after
neutralizing the display name; the ID itself comes from the Slack
event, not user-editable text).
2026-07-22 07:22:55 -07:00
Kai Yi
73d5c896ee fix(slack): route replies from mentioned thread parents
Two mention-tracking gaps around thread parents (#24848):

1. When a thread PARENT @-mentioned the bot (e.g. '<@bot> check this
   and ask me before running'), a later bare reply like 'run' fell
   through every wake check if the mention event predated this process
   (restart) — _mentioned_threads is in-memory only. Add a 5th wake
   check that fetches the parent text (with the bot mention preserved
   via strip_bot_mention=False) and wakes when the parent addressed the
   bot, registering the thread so later replies skip the fetch.

2. A TOP-LEVEL @mention starts a thread (session keying falls back to
   the message ts), but only the raw event thread_ts was registered in
   _mentioned_threads — so replies to a top-level mention did not
   auto-trigger. Register the session-scoped thread_ts instead.

_fetch_thread_parent_text reuses the shared thread-context cache (raw
payloads) so the parent check costs at most one conversations.replies
call per thread; _register_mentioned_thread centralizes the bounded-set
eviction.

Salvaged from #24848 by @kaiyisg, rebased onto the extracted
_should_wake_on_unmentioned_message helper.
2026-07-22 07:22:55 -07:00
knoal
fee392fee1 fix(slack): wake on human replies in threads whose root we authored
The un-mentioned wake decision relied on three checks: thread root in
_bot_message_ts (populated only by the adapter's own send() path),
_mentioned_threads (populated on @mention), and an existing session.
Two gaps (#63530):

- Gap A: bot messages posted OUTSIDE gateway send() — skills/scripts
  calling chat.postMessage directly, cron/API posts — never enter
  _bot_message_ts, so human replies in those threads were silently
  dropped.
- Gap B: _bot_message_ts is process memory; after a gateway restart the
  bot stopped waking on replies to threads it started before the
  restart.

Fix: add a 4th, API-derived check — _bot_authored_thread_root — which
resolves the thread root's author via conversations.replies (cached in
_thread_context_cache via the new parent_user_id field, TTL-bounded).
Root authorship comes from Slack itself, so it covers outside-send
posts and survives restarts, unlike in-memory ts tracking. The wake
decision is extracted into _should_wake_on_unmentioned_message for
direct unit testing; the legacy checks remain first (cheap, additive).

Fixes #63530.

Salvaged from #64067 by @knoal (author metadata normalized to their
GitHub identity), rebased over the thread-context formatter split and
extended with per-team bot-id resolution for multi-workspace installs.
2026-07-22 07:22:55 -07:00
vexclawx31
fc0009b9ba fix(slack): rehydrate thread context after gateway restart
Persistent sessions survive gateway restarts, but thread replies posted
while the gateway was DOWN never reached the session — and the adapter
had no way to notice, so the conversation silently resumed with a hole
in it.

On the first ordinary reply per thread after a restart (tracked by a
fresh-process _thread_rehydration_checked set), fetch the thread delta
past the persisted per-session watermark and inject any missed messages
as part of the new turn via channel_context. Exactly-once per thread
per process; when the watermark is empty (pre-feature sessions) the
check is a no-op. Steady-state replies keep advancing the watermark so
rehydration never re-injects messages the session already carries as
ordinary turns. Prior history is never rewritten (prompt caching safe).

Builds on the persisted watermark introduced for #23918.

Salvaged from #33215 by @vexclawx31, reworked from a repeated
full-thread injection guard into a watermark-delta injection so
rehydration adds only what the session actually missed.
2026-07-22 07:22:55 -07:00
heathley
ad4034711d fix(slack): refresh active thread context on explicit mention
Once a thread has an active session, a later reply that explicitly
@mentions the bot did not re-fetch Slack thread context, so the agent
missed messages added to the thread after the initial hydrate (e.g.
other bots/integrations replying in multi-agent workflows). The
explicit mention is a fresh intent signal and now triggers a refresh.

Mechanics:
- SessionEntry gains a small persisted metadata dict, with
  SessionStore.get/set_session_metadata accessors (survives gateway
  restarts via the routing index).
- The adapter stores a per-thread consumption watermark
  (slack_thread_watermark:<channel>:<thread>) recording the last
  thread ts the session consumed.
- On explicit mention in an active thread, _fetch_thread_context runs
  with force_refresh=True (bypassing the TTL cache) and after_ts=<the
  watermark>, so only NOT-yet-seen messages are injected — as part of
  the new turn via channel_context. Prior conversation history is
  never rewritten, preserving prompt caching.
- _fetch_thread_context caches raw conversations.replies payloads so
  watermark-scoped re-formatting needs no extra API call; formatting
  is split into _format_thread_context.
- Thread session keys are built once in _build_thread_session_key
  (shared by the wake gate and the watermark accessors), still via
  build_session_key().

Fixes #23918. Supersedes #62299 (keyword-triggered refresh limited to
'investigate' prompts — the mention signal is the general fix).

Salvaged from #23927 by @heathley, rebased onto the plugin adapter
layout and rerouted through channel_context instead of text-prepend.
2026-07-22 07:22:55 -07:00
LevSky22
fd433e046a fix(slack): preserve thread context for commands via channel_context
Prepending cold-start thread backfill directly onto the message text
moved a recognized command (e.g. a bang-normalized "!queue ...") away
from character zero, so downstream command routing misclassified it as
conversational text and the command silently didn't run.

Route the backfill through MessageEvent.channel_context instead —
gateway.run already prepends channel_context after command dispatch
("[New message]" framing), so commands keep their COMMAND type while
the recovered history stays available to the agent.

Supersedes #68020, which dropped the fetched context entirely for
commands instead of preserving it out-of-band.

Salvaged from #66069 by @LevSky22.
2026-07-22 07:22:55 -07:00
Ted Malone
c8089dabcd fix(slack): include bot's own prior replies in cold-start thread context
_fetch_thread_context unconditionally filtered out the bot's own prior
replies, so cold-start sessions (bot posts a thread root, user replies
later, no active session) lost every assistant turn and the agent could
not reconstruct the prior conversation.

The circular-context concern the filter guarded against does not apply
here: the call site is gated by _has_active_session_for_thread, so this
method only runs when there is no session history to duplicate.

Self-bot replies are now kept and labelled with an explicit [assistant]
prefix (skipping user-name resolution — the label already communicates
authorship). Third-party bot posts and the bot-authored thread parent
keep their existing treatment.

Fixes #38861.

Salvaged from #38936 by @temalo, rebased from the pre-plugin
gateway/platforms/slack.py layout onto plugins/platforms/slack/adapter.py
(preserving the [unverified] trust-tag handling added on main since).
2026-07-22 07:22:55 -07:00
luyifan
2d71e9e9bc fix(slack): ignore stale thread sessions when gating thread-context reseed
A session key that exists in the store but would be rolled to a fresh
session by the reset policy (daily/idle/suspended) is not an active
session. Treating it as active suppressed the first-turn Slack
thread-history reseed after reset (#55239).

_has_active_session_for_thread() now consults SessionStore._should_reset
so a stale entry gates like a missing one, letting _fetch_thread_context
reseed the fresh session with recent thread history.

Fixes #55239.

Salvaged from #55240 by @ooiuuii.
2026-07-22 07:22:55 -07:00
Teknium
40c3b62b30 chore(contributors): map MrAbsaroka and 87degrees emails 2026-07-22 07:14:42 -07:00
Matt Ferguson
3c5c389f18 fix(slack): widen Socket Mode dedup TTL to cover reconnect redelivery
Slack buffers un-acked Socket Mode events and replays them when the
websocket reconnects; the replay can arrive several minutes later —
past the 300s default dedup TTL — producing a duplicate bot reply.
Default the Slack dedup window to 1 hour (memory stays bounded by the
deduplicator's max_size LRU pruning) and allow overriding via
SLACK_DEDUP_TTL_SECONDS.

Salvaged from PR #40064 by @MrAbsaroka (reapplied onto the
plugin-migrated adapter path). Fixes #4777.
2026-07-22 07:14:42 -07:00
LeonSGP43
45556b71ce fix(slack): close clients on gateway shutdown 2026-07-22 07:14:42 -07:00
87
caf8e2f214 fix(slack): heal wedged Socket Mode via ping/pong staleness
The Socket Mode watchdog only reconnects when is_connected() returns False
or the receiver task dies. When the underlying aiohttp ClientSession is
closed (e.g. after a network blip), slack_sdk gets stuck retrying
"Session is closed" while is_connected() can still report healthy and the
receiver task stays alive — so the watchdog never fires and the process is
alive but deaf to Slack indefinitely.

Add a ping/pong staleness probe: Slack sends a ping roughly every
ping_interval seconds even on an idle socket, so a stale/missing
last_ping_pong_time (past a first-ping grace window) is a reliable signal
the transport is wedged. The watchdog now also reconnects on staleness,
which rebuilds the handler with a fresh session. Guards non-numeric
attributes so a mocked/partial client never triggers a spurious reconnect.

7 new tests; full test_slack.py (216) green.
2026-07-22 07:14:42 -07:00
Juniper Bevensee
7bbdabbef2 fix(slack): stop client tasks before closing the Socket Mode session
SocketModeClient.connect() is a "while True" retry loop that never checks
the client's closed flag, so anything still inside it when the shared
aiohttp session is closed keeps retrying against a session that can never
work again. That is the "Failed to connect (error: Session is closed);
Retrying..." spam in #46990, at a steady ping_interval cadence that only
a process restart clears.

_stop_socket_mode_handler closed the handler first and cancelled
afterwards, which loses the race. close_async() closes that shared
session, and three things can be inside connect() when it does: our own
start_async task, monitor_current_session() (on staleness) and
receive_messages() (on a CLOSE frame), the latter two reaching it
independently through connect_to_new_endpoint(). connect() also rebinds
current_session_monitor and message_receiver to fresh tasks when it
succeeds, so the set of live tasks changes across the awaits inside
close(). Cancelling from a snapshot taken partway through races a moving
target rather than closing the window.

So cancel all four before close_async() instead. With nothing left alive
to enter connect(), no rebinding can happen during teardown and the
window cannot open at all. The client's task attributes are read with
getattr so a rename inside the SDK degrades to a no-op instead of raising
during shutdown, and the wait is asyncio.wait with a timeout rather than
an unbounded await, so a task wedged in a network call cannot hold up
shutdown.

The underlying SDK defect is tracked at slackapi/python-slack-sdk#1913.

This replaces the earlier version of this change, which added a closed
session check when building a handler and another in the watchdog. Both
are unnecessary once teardown stops leaking tasks: each
AsyncSocketModeHandler builds its own SocketModeClient with a fresh
ClientSession, so a new handler cannot inherit a closed session, and the
current handler's session is only closed by the teardown path itself.
Dropping the watchdog check also keeps this off the ping/pong staleness
trigger proposed in #52923, which addresses a wedged live connection
rather than a leaked one.

Fixes #46990
2026-07-22 07:14:42 -07:00
Yuan Li
54a0f07101 fix(gateway): mark unconfigured platforms as non-retryable to stop reconnect loop
A platform with a missing dependency or missing credentials can never
succeed on retry, but connect() returned bare False, so the gateway
treated the failure as transient and queued it for background
reconnection — looping forever at the backoff cap. Set
_set_fatal_error(..., retryable=False) for missing-dependency and
missing-credential failures in the Slack, Telegram, and Discord
adapters so the reconnect watcher drops them from the retry queue.

Salvaged from PR #31057 by @dskwe (reapplied onto the plugin-migrated
adapter paths). Fixes #31049.
2026-07-22 07:14:42 -07:00
x7peeps
77beb6a085 fix(slack): set non-retryable fatal error on missing Slack credentials
Missing SLACK_BOT_TOKEN / SLACK_APP_TOKEN is a permanent configuration
error, not a transient outage. Without a fatal-error marker the gateway
queued Slack for background reconnection and looped forever (#66696).
Set _set_fatal_error(..., retryable=False) so the reconnect watcher
drops it from the retry queue, and point the log/error text at
`hermes gateway setup` / the profile's ~/.hermes/.env.

Salvaged from PR #66720 by @x7peeps. Fixes #66696.
2026-07-22 07:14:42 -07:00
Teknium
d358280ad7 test(gateway): thread follow-ups survive a pending native clarify
Gateway-level regression coverage for #62034 on top of the
clarify_gateway prose-rejection fix: drives GatewayRunner
._handle_message with a pending native multi-choice clarify and proves

- arbitrary thread prose is NOT swallowed (falls through the clarify
  text-intercept and continues as a normal turn),
- typed numeric selections and exact choice labels still resolve,
- 'Other' text-capture mode and open-ended clarifies still accept
  free text.

Incident analysis and repro by @brandician (#62034).
2026-07-22 07:00:47 -07:00
Teknium
76283a9ee4 fix(gateway): suppress tool-progress bubble for clarify prompts
The adapter's send_clarify IS the user-facing rendering of a clarify
prompt (interactive buttons, or the numbered-text fallback). The
gateway's tool-progress callback additionally rendered a progress
bubble for the clarify tool.started event — in verbose mode that
bubble contains the raw tool-call args JSON
({"question": ..., "choices": [...]}), and because the progress
queue drains on a background task, the JSON landed right underneath
the rendered interactive prompt on Slack.

Skip clarify in the progress callback entirely: the prompt rendering
already covers every mode, so a progress line is pure duplication at
best and a raw-JSON leak at worst.

Regression test proves no clarify progress content (raw JSON, verb
line, or question text) reaches the chat in verbose or all modes,
while unrelated tools still render progress normally.

Reported by @alexgrama-dev.

Fixes #52374
2026-07-22 07:00:47 -07:00
liuhao1024
07cbb500b6 fix(clarify): reject arbitrary prose for native interactive multi-choice clarifies
In Slack threads, ordinary follow-up messages sent while a native
multi-choice clarify was pending were consumed as clarify answers —
_coerce_text_response accepted arbitrary text for any pending entry, so
the gateway text-intercept swallowed unrelated thread messages and the
user's messages appeared to be ignored.

Tighten resolve_text_response_for_session for native interactive
multi-choice prompts (buttons rendered, awaiting_text=False):

- numeric selections ("2") still resolve to the canonical choice
- exact choice-label matches (case-insensitive) still resolve
- arbitrary prose is now REJECTED (returns False) so the message
  continues as a normal turn instead of vanishing into the clarify

Behavior is preserved everywhere free text is legitimately the answer:
open-ended clarifies, explicit 'Other' text-capture mode, and the base
adapter's numbered-text fallback (which flips awaiting_text at send
time).

Salvaged from PR #62042 by @liuhao1024.

Fixes #62034
2026-07-22 07:00:47 -07:00
Eva
95aad9229b feat(slack): Block Kit buttons for clarify prompts
Slack now overrides send_clarify to render multi-choice clarify prompts
as native Block Kit buttons (one per choice + a final '✏️ Other…'
free-text button), mirroring the Telegram/Discord adapters and the
existing Slack approval-button pattern.

- Unique hermes_clarify_choice_<idx> action_ids (Slack rejects
  duplicate action_ids within one actions block); dispatch via a
  compiled-regex action matcher plus hermes_clarify_other.
- Chunks elements across actions blocks in groups of 5 so a larger
  choice list degrades gracefully instead of 400ing (invalid_blocks).
- Choice taps resolve through tools.clarify_gateway
  .resolve_gateway_clarify with the canonical registered choice text —
  the same applier the typed-reply path uses — then edit the message
  to show the outcome and drop the buttons.
- 'Other' flips the entry into text-capture via mark_awaiting_text
  (only on tap, never at send time) so the gateway text-intercept
  captures the next typed message.
- Auth-gated via _is_interactive_user_authorized; atomic-pop
  double-click guard mirrors _approval_resolved; late taps on evicted
  entries surface an honest expiry notice instead of a false ✓.
- Open-ended prompts delegate to the base plain-text render.

Salvaged from PR #61943 by @100yenadmin. Earliest implementation of
this feature was PR #28885 by @cypres0099; sibling implementations
#66606 (@jaaro-ai) and #51547 (@Mongol-Jimmi) are superseded.

Closes #52369
2026-07-22 07:00:47 -07:00
Teknium
f944e84858 fix: close review gaps for per-model threshold overrides (#63020)
Follow-up to the salvaged contributor commit, closing the three gaps
flagged in the sweeper review:

1. Init ordering: assign compression.model_thresholds to a selected
   plugin context engine BEFORE the initial update_model() call in
   agent_init.py, so the initial model's override applies from init
   (previously it only took effect after the first /model switch).
   Base-class ContextEngine.update_model() now snapshots the
   pre-override percent once so repeated switches fall back to the
   engine's configured threshold, not a previous model's override.
2. DEFAULT_CONFIG: add compression.model_thresholds (empty map) to
   hermes_cli/config.py — additive key, no _config_version bump.
3. Docs: document the key in
   website/docs/developer-guide/context-compression-and-caching.md
   (yaml example, parameter table, dedicated section) and update the
   plugin-boundary note in context-engine-plugin.md to state the
   explicit context-engine contract for model_thresholds.

Adds tests/run_agent/test_per_model_threshold_init_ordering.py:
plugin-engine AIAgent init regression (override applies at init,
empty map unchanged), DEFAULT_CONFIG key presence, floor interaction
on the model-switch path (override below the small-context floor is
raised to the floor; above the floor wins), and base-class config
snapshot across repeated switches. Also maps @bennybuoy in
contributors/emails/.
2026-07-22 07:00:27 -07:00
Ben Kamholtz
5f2fdf66bf feat: per-model compression threshold overrides (v2, rebased on main)
Addresses teknium1 review feedback on PR #60781:

1. Gateway cache invalidation: added ('compression', 'model_thresholds')
   to _CACHE_BUSTING_CONFIG_KEYS so a live config edit to the map
   invalidates the cached compressor (previously kept stale thresholds).

2. Integrated resolver with small-context floor: per-model overrides are
   resolved FIRST, then the existing 75% floor for <512K models is applied
   on top. The floor is no longer replaced — it stacks. An override below
   75% on a small-context model still gets floored to 75% (raise-only);
   an override above 75% wins.

3. Clean rebase on upstream main — no unrelated deletions or anti-thrashing
   changes. Only the per-model threshold feature is added.

Changes:
- resolve_model_threshold() module-level helper (longest substring match)
- ContextCompressor.__init__ accepts model_thresholds dict
- _base_threshold_percent stores the per-model resolved value
- _config_threshold_percent stores the raw config value (fallback base)
- update_model() re-resolves on /model switch, falls back to config value
- ContextEngine base class update_model() applies overrides for plugin engines
- agent_init.py reads compression.model_thresholds from config, passes to ctor
- gateway/run.py cache busting key added
- cli-config.yaml.example documents the feature
- 17 tests covering resolve helper, compressor init (large/small context,
  override above/below floor), update_model (re-resolve, fallback), base class

Co-authored-by: Copilot <copilot@github.com>
2026-07-22 07:00:27 -07:00
Teknium
f453c50b6f test(memory): behavioral check — memory tool handler works with skip_memory=True, provider stays skipped
Follow-up for salvaged PR #65453: extend the regression test to dispatch a
real memory_tool add through the store the tool executor wires in, assert the
write persists to memories/MEMORY.md, and assert the external memory provider
(MemoryManager) is still skipped under skip_memory=True. Also map the
contributor email for attribution CI.

Fixes #65429.
2026-07-22 07:00:16 -07:00
Ella CEO
45182401fa fix(agent): add regression test for #65429 (memory store with skip_memory + memory toolset)
Behavioral test constructing a real AIAgent with skip_memory=True and
enabled_toolsets=["memory"] asserts the built-in MemoryStore is created
(store is not None). Also covers the negative case (no memory toolset -> None)
and the normal case (skip_memory=False -> store created).
2026-07-22 07:00:16 -07:00
Ella CEO
596dda907f fix(agent): create built-in memory store when memory toolset is enabled despite skip_memory (#65429)
skip_memory=True was meant to skip the external memory *provider* for flush/
background agents, but it also suppressed creation of the built-in file-backed
MemoryStore. When a caller still enables the "memory" toolset, the memory tool
dispatched with store=None and every call failed with "Memory is not available",
silently losing the main automatic memory-capture path.

Now the built-in store is created whenever memory is enabled in config OR the
memory toolset is explicitly enabled, while the external-provider block stays
gated on skip_memory (preserving flush-agent intent).
2026-07-22 07:00:16 -07:00
liuhao1024
9bb253d4fa fix(tools): filter compaction summaries from session_search bookends and cap content length
Context-compaction handoff summaries (prefixed with [CONTEXT COMPACTION])
were being returned as normal bookend_start/bookend_end messages in
session_search discovery mode. A single compaction handoff could be 57K+
chars, immediately bloating a fresh session prompt to 73K+ chars from one
search hit.

Changes:
- Add _COMPACTION_PREFIXES and _is_compaction_summary() helper
- Filter compaction summaries from bookend_start and bookend_end in _discover()
- Cap bookend content to 1200 chars and window content to 4000 chars
- Add content_truncated/original_content_chars metadata when truncation occurs
- Add 6 regression tests covering prefix detection, bookend filtering,
  content capping, and legacy [CONTEXT SUMMARY] prefix

Fixes #43175
2026-07-22 07:00:04 -07:00
Teknium
75099ca0ef fix(state): inherit git_branch + gateway origin columns on compression children
Follow-ups on top of #64731's cwd/git_repo_root inheritance:

- git_branch joins the parent-row backfill (same NULL-only COALESCE hop):
  the Desktop sidebar branch chip otherwise vanishes at every compaction
  boundary even though the workspace didn't change.
- Belt-and-suspenders for #59527: compression forks (parent already ended
  with end_reason='compression') also inherit the gateway origin columns
  (user_id/session_key/chat_id/chat_type/thread_id/display_name/
  origin_json) at DB-level child creation. The gateway re-records the peer
  after rotation (d5b4879d4), but a hard crash in the window between child
  creation and that write left the child unrecoverable by
  find_latest_gateway_session_for_peer. Scoped to compression forks only —
  delegate/subagent children (parent still live) must NOT inherit routing
  keys, or peer recovery could repoint gateway traffic into a subagent's
  session.
- Behavioral test driving the real _compress_context rotation path,
  asserting the child row carries cwd/git_repo_root/git_branch and the
  origin columns.
2026-07-22 06:59:44 -07:00
Simplicio, Wesley (ext)
3c74c12554 fix(state): inherit cwd/git_repo_root on parent_session_id children
_insert_session_row never copied cwd/git_repo_root from a parent row when
parent_session_id was set, and git_repo_root wasn't even in the INSERT's
column list. The compression-fork path (and delegate/subagent spawns,
branch continuations) creates a child session without passing cwd/
git_repo_root at all, so the child's tip is born NULL — and since the
Desktop project sidebar groups sessions by cwd, the whole project silently
drops out of the sidebar every time a long conversation compresses. A
lineage that compresses repeatedly compounds this across generations.

Add git_repo_root to _insert_session_row's INSERT/COALESCE-on-conflict
column set, and backfill both cwd and git_repo_root from the immediate
parent row (single non-recursive hop, matching the existing COALESCE
"never overwrite an explicit value" contract) inside the same write
transaction whenever parent_session_id is set. A multi-generation chain
resolves correctly because each generation's own create_session call
already backfills from its (already-resolved) immediate parent.

Fixes #64709
2026-07-22 06:59:44 -07:00