Gateway half of the #49874 salvage: pass compression_deferred through
both _run_agent_inner result dicts and guard the compression-exhausted
auto-reset block with it — a lock-contended defer keeps the session
intact (the concurrent compressor is actively shrinking it) instead of
wiping it via reset_session.
Regression tests:
- tests/run_agent/test_compression_lock_defer.py — provider-mock 413 and
400-overflow turns whose compression pass lost the lock end as
compression_deferred (failed=False, no compression_exhausted); flag
unset keeps the terminal exhaustion path byte-identical; type-pin
tests vs MagicMock agents and junk flag values; cap=1 e2e proving the
refunded pre-API defer leaves the budget for the provider-proven
413 retry.
- tests/agent/test_preflight_lock_defer.py — a lock-skipped preflight
pass stops the loop WITHOUT arming preflight_compression_blocked;
plain no-op still arms it; MagicMock junk does not defer.
- tests/gateway/test_compression_deferred_soft_result.py — AST pin that
the deferred branch guards the auto-reset chain and performs no
session mutation (mirrors test_35809_auto_reset_clean_context.py).
Hardening on top of the salvaged dead-PID lease reclamation from PR #65775
(@the3asic):
- Probe via psutil.pid_exists (hard dependency; CONTRIBUTING.md critical
rule #1) with the contributor's os.kill(pid, 0) POSIX probe retained
only as a scaffold-phase fallback when psutil is missing.
- Same-process holders (pid == os.getpid()) are never probed and never
self-reclaimed — another thread's live lease is owned by the lease
refresher/release path.
- Any probe doubt (exceptions, permission errors) conservatively keeps
the lease until normal TTL expiry; Windows stays TTL-only.
- Tests: psutil-first dead-pid reclaim (probe call pinned), os.kill
fallback path, probe-doubt keeps lease, same-pid no self-reclaim,
legacy holder + Windows paths assert NO probe via either API.
compress_trajectory (and _async) replaced the compressible middle region with
a [CONTEXT SUMMARY] turn without checking that the region is actually larger
than the summary. When a large protected system prompt dominates the budget,
the compressible middle can be tiny; replacing e.g. a 2-token middle with a
~60-token summary GROWS the trajectory (tokens_saved negative), marks it
was_compressed, and still spends a summarization call — the opposite of the
intent, on exactly the hard over-budget cases.
Add a net-savings guard mirroring the code's own comment (net_savings =
region_tokens - summary_target_tokens): if the safely-compressible region is
no larger than summary_target_tokens, return the trajectory unchanged. Applied
to both the sync and async paths. Add sync+async regression tests.
MoA reference_max_tokens is preset-level — one cap for all reference
models. When mixing a verbose model with a terse one, a single cap is
either too tight for the terse model or too loose for the verbose one.
Now each reference slot can optionally carry its own max_tokens:
reference_models:
- provider: openrouter
model: deepseek/deepseek-v4-pro
max_tokens: *** # per-slot cap, overrides preset-level
- provider: openai-codex
model: gpt-5.5
# no max_tokens → falls back to preset-level reference_max_tokens
_clean_slot (moa_config.py) preserves an optional max_tokens field on
the slot dict, coerced via _coerce_int_or_none. _run_reference
(moa_loop.py) reads slot-level max_tokens first, falling back to the
preset-level cap passed by the caller. Slots without the field are
unaffected — backward compatible.
Type hints on slot-handling functions updated from dict[str, str] to
dict[str, Any] to reflect the now-heterogeneous slot shape.
Requested in review: builder-level assertions that the gemini-native
branch forwards max_tokens (provider names and the native
generativelanguage.googleapis.com base_url, max_tokens=600), plus a
control showing gemini models on OpenAI-compatible endpoints — including
Gemini's own /openai compatibility endpoint — keep the existing omission
behavior (#34530).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review feedback from teknium1: reference_max_tokens is an advisors-only
contract. The aggregator is the acting model and must not be capped by the
reference budget. Changed _is_moa from startswith('moa_') to exact match on
'moa_reference'. Added regression test proving aggregator does NOT receive
max_tokens.
Copilot review pointed out that hardcoding kwargs['max_tokens'] would
400 on models requiring max_completion_tokens (GPT-5 family, Copilot).
The existing auxiliary_max_tokens_param() helper already selects the
correct parameter name per model — use it instead of hardcoding.
Test updated to parametrize expected_key so the Copilot gpt-5.5 case
correctly asserts max_completion_tokens instead of max_tokens.
Addresses Copilot review comments on both files.
PR #56756 added reference_max_tokens to cap MoA advisor output and cut
turn latency. The value is correctly threaded through five layers of MoA
code (moa_config → conversation_loop → aggregate_moa_context →
_run_references_parallel → _run_reference → call_llm(task='moa_reference',
max_tokens=800, ...)).
However, _build_call_kwargs() in auxiliary_client.py silently drops
max_tokens for all OpenAI-compatible providers (PR #34845, which fixed
endpoints and NVIDIA NIM keep it. This means reference_max_tokens never
reached the API for the vast majority of providers.
The bug affects every OpenAI-compatible MoA reference/aggregator slot:
Z.AI (coding plan), OpenRouter, OpenAI, GitHub Copilot, and local
providers. Only Anthropic-compat endpoints (MiniMax, /anthropic URLs)
worked — by coincidence, not MoA-aware design.
Fix: thread the 'task' parameter through all six _build_call_kwargs()
call sites. When task starts with 'moa_', max_tokens is always included
in the request kwargs regardless of provider. Non-MoA auxiliary tasks
(compression, titles, vision, etc.) keep PR #34845 behavior unchanged.
Verified end-to-end:
- Z.AI GLM-5.2 with max_tokens=50 → returned exactly 50 tokens
- Z.AI GLM-5.2 with max_tokens=20 → returned exactly 20 tokens
- Z.AI GLM-5.2 uncapped → returned 315 tokens
- 7 new regression tests covering 4 providers, Anthropic wire, non-MoA
tasks, and prefix-matching boundary
- 288 auxiliary_client tests pass (was 281, +7 new), 84 MoA tests pass
- Zero regressions
aggregate_moa_context's single max_tokens parameter was applied to
both the reference fan-out (_run_references_parallel) and the
aggregator's own synthesis call_llm. #53580 explicitly removed a
hardcoded cap from the aggregator call because it truncated long
aggregator syntheses; #56756 (reference_max_tokens, added to speed up
the advisor fan-out) reintroduced the same shared cap by passing it to
both calls, silently regressing #53580's fix.
Rename the parameter to reference_max_tokens (matching the caller's
own moa_config key) and stop forwarding it to the aggregator's
call_llm invocation, which now always runs uncapped as intended.
An assistant message with an empty or whitespace-only text content block —
produced by context compression or certain tool-call flows — is rejected by
the Anthropic Messages API with HTTP 400 "text content blocks must contain
non-whitespace text". Because the blank block is stored in session history and
replayed verbatim every turn, the session is permanently wedged behind the same
400.
The Bedrock adapter already guards this via _safe_text() (#9486); the native
Anthropic path never got the same treatment. _sanitize_replay_block() rebuilt
text blocks with the raw stored text, and the _convert_assistant_message()
guard only caught a fully empty block list, not a list still containing a
whitespace-only text block.
Add a _safe_text() helper mirroring the Bedrock one and apply it at both points:
the ordered-blocks replay path and a final in-place walk of the converted
content list. Both are self-healing — sessions that stored blank blocks recover
on the next API call. Only text blocks are coerced; thinking/tool_use/image
blocks are untouched.
Fixes#69512
Two related Slack delivery fixes for send_message text sends:
- Route Slack text delivery through _send_via_adapter so the live
in-process gateway adapter (multi-workspace aware, channel→client
mapping, adapter-side gates) is preferred, with the plugin's
_standalone_send as the out-of-process fallback — matching how the
media path already behaves.
- _standalone_send: SLACK_BOT_TOKEN can be a comma-separated list in
multi-workspace installs and slack_tokens.json carries OAuth
per-workspace tokens; the standalone Web-API path used to send the
literal comma-joined string, which Slack rejects as invalid_auth.
Try each token individually, retrying on token-scoped errors
(invalid_auth / not_in_channel / channel_not_found …) and stopping on
terminal ones. User-DM resolution (U…/W… targets) also tries each
token.
Adapted from #47547 by @replygirl — the original patched the legacy
tools/send_message_tool.py::_send_slack helper, which moved to the
Slack plugin's _standalone_send in #41112.
Salvaged from #47547
Follow-up to the #51899 pick, folding in the config-bridge half of the
competing PR #46925 (@bhanusharma, earliest submitter for the
ignored-channel gate):
- _apply_yaml_config: translate config.yaml slack.ignored_channels into
SLACK_IGNORED_CHANNELS (list or CSV), env-var-wins like every other
bridged Slack key.
- SlackAdapter._slack_ignored_channels / gateway.run's
_slack_ignored_channels_from_gateway_config: fall back to the
SLACK_IGNORED_CHANNELS env var when PlatformConfig.extra carries no
value, so top-level slack: blocks (which flow through the env bridge,
not extra) are honored at both the adapter and runner gates.
- conftest: force-clear SLACK_ALLOWED_CHANNELS / SLACK_IGNORED_CHANNELS /
SLACK_DISABLE_DMS between tests (config-loader side-effect leak class).
- Tests: env-bridge translation + precedence in test_config.py, env
fallback + extra-wins in test_slack_runner_ignored_channels.py.
Credit: #46925 by @bhanusharma proposed the same gate with the YAML→env
bridge; #51899 (picked as the base) carries the wider outbound/runner
coverage. Closes#46925 as consolidated here with first-submitter credit.
Add --long-description / --long-description-file to `hermes slack
manifest` so the generated app manifest can carry Slack's
display_information.long_description (175–4,000 characters), with
validation of the length bounds, mutual-exclusion with --slashes-only,
and UTF-8 file input. Also propagate the manifest command's exit status
through cmd_slack so validation failures reach the shell.
Squash of the two commits from PR #65256 — one commit per contributor
on this salvage branch.
Salvaged from #65256
PR #37303 added per-platform streaming defaults and the commit message
explicitly called out "Discord/Slack/etc. only have edit-based streaming
(repeated editMessage), which flickers and is noticeably jankier" — but
only discord.streaming=false was shipped. Slack uses the same edit-based
streaming mechanism and has the same flicker problem, yet it was left to
follow the global switch (default true when streaming is enabled).
Add "slack": {"streaming": False} to DEFAULT_CONFIG["display"]["platforms"]
alongside the Discord default. The same deep-merge semantics apply: a user
who explicitly sets display.platforms.slack.streaming: true keeps their
value unchanged. The dashboard schema gains a slack.streaming toggle
automatically since it is generated from DEFAULT_CONFIG.
Update test_per_platform_streaming_defaults.py to cover slack in all
existing assertions and rename the resolver test to reflect both platforms.
Hermes already identifies itself on outbound calls to model providers
and monitoring endpoints — see hermes_cli/models.py and
plugins/model-providers/gmi/__init__.py
("Attribution so GMI can identify traffic from Hermes Agent"). The
Slack adapter is the one outbound surface that doesn't carry the
same identifier, so HermesAgent-driven Slack traffic is
indistinguishable from any other Bolt-Python app at the Slack
platform layer.
This sets `user_agent_prefix=f"HermesAgent/{_HERMES_VERSION}"` on the
AsyncWebClient instances constructed in gateway/platforms/slack.py
and threads them through AsyncApp via its `client` kwarg. Both
kwargs are first-class in slack-sdk and slack-bolt; no new
dependencies. Resulting header looks like:
HermesAgent/<version> Python/3.x slackclient/3.x ...
No behavioral change for users — the Slack API ignores User-Agent
semantically; it lands in logs and analytics. Reversible.
Tests in tests/gateway/test_slack.py:
- TestSlackUserAgent pins the prefix shape and runs connect()
end-to-end (multi-token config) to assert every AsyncWebClient
carries the prefix and AsyncApp receives the pre-built client.
- TestSlackProxyBehavior fakes updated to tolerate the new kwargs
via **_kwargs so they don't break on future passthroughs.
Blank (or whitespace-only) answers to the home-channel prompt in the
interactive setup wizards previously left any previously saved
*_HOME_CHANNEL / *_HOME_ROOM env value in place, so operators could not
clear a stale home channel by re-running setup. Strip the prompt input
and call remove_env_value() on blank answers across the Discord, Slack,
Feishu, Matrix, Mattermost, WeCom and WhatsApp plugin setup wizards,
with per-adapter wizard tests covering set/clear/whitespace flows.
Squash of the three commits from PR #58421 (setup-wizard fix, matrix/
wecom extension, and 6-adapter test coverage) — one commit per
contributor on this salvage branch.
Fixes#12423
Salvaged from #58421
Five regression tests for _resolve_download_token and the download
helpers: explicit team wins, URL-embedded team id routes to the owning
workspace, unknown/no-match fall back to the primary token, and an
end-to-end _download_slack_file_bytes call asserts the Authorization
header carries the owning workspace's token.
A/B: all five fail with the fix commit reverted, pass with it applied.
Two tests on main pinned the pre-#20583 bare-ts marker mechanism
(_mentioned_threads entries and _slash_command_contexts key shape).
The salvaged workspace scoping intentionally changes both:
- _mentioned_threads now records (team_id, ts) markers when the event
carries a team id, so the top-level-mention test asserts the scoped
tuple.
- _slash_command_contexts keys are (team_id, channel, user) 3-tuples
when the slash payload includes team_id.
Follow-up to the #20583 cherry-pick (jordanhubbard).
origin_session_id (the api_server wake self-post target) lived only in
the in-memory record: durable dispatch persistence and abandoned-
delegation recovery omitted it, leaving completions recovered after a
process restart unroutable to api_server sessions. Persist it in the
async_delegations table (CREATE TABLE + ALTER TABLE migration for legacy
DBs), restore it on recovery, and expose it via get_durable_delegation.
Also adds the contributors mapping for ianks (PR #64998 author).
Follow-up to #64998 (sweeper review F3).
/v1/runs bound only session_key at its _bind_api_server_session call, so
tools.async_delegation._current_origin_session_id() — which reads the
request-scoped HERMES_SESSION_CHAT_ID — returned "" on that route and
runs-originated background delegations stayed forced-sync with no wake
target. Bind chat_id/session_id the same way the other agent-entry routes
do via _run_agent(). Follow-up to #64998 (sweeper review F2).
On non-push adapters (api_server) the wake self-post IS the delivery, but
the cursor advanced before the self-post ran and a failed/exhausted post
was swallowed by the best-effort except — permanently losing the event.
Reorder the else-branch: for non-push adapters run the self-post FIRST and
only advance the cursor once it succeeds. A failure rewinds the pre-send
claim (same guarantee as the existing SendResult(success=False) path) so
the next tick retries, with the same MAX_SEND_FAILURES drop threshold.
Push-capable adapters keep the pre-existing advance-then-best-effort-wake
behavior. Follow-up to #64998 (sweeper review F1).
Wake-ups for kanban notifications and background delegation completions were
injected via handle_message() using a build_session_key()-derived key, which
can never match the raw X-Hermes-Session-Id key that api_server sessions run
under — so the wake landed in a session nobody was reading. On top of that,
ApiServerAdapter.send() reports failure without raising, and that was treated
as a successful delivery, so the notify cursor advanced past events that were
permanently lost; and background delegation was forced synchronous on
api_server since there was no way to wake the session afterward.
Fix: route wake-ups for non-push adapters through a self-post to
/v1/chat/completions with the original session id, treat non-raising send
failures as failures (rewind instead of advancing the cursor), and re-enable
background delegation whenever a session id is available to wake.
The origin session id is captured from the request-scoped api_server chat_id
binding rather than HERMES_SESSION_ID: constructing a child agent calls
set_current_session_id() with the subagent's internal id, clobbering that
variable right before dispatch would read it and misrouting the wake into
the subagent's own session.
Related: #56580, #64609, #53027, #63169, #56531, #50319, #64113
Correction to the previous commit (PR #68402): the claim that api_server
never intercepts MEDIA: tags is inaccurate on current main.
_resolve_media_to_data_urls() (gateway/platforms/api_server.py) DOES
inline image MEDIA: tags (<=5MB, image extensions only) as base64 data
URLs on the four main endpoints (_handle_session_chat,
_handle_session_chat_stream, _handle_chat_completions, _handle_responses).
The real gaps elphamale's PR points at are narrower:
- the /v1/runs output path (_handle_runs) never calls the resolver;
- non-image filetypes are never resolved anywhere (_MEDIA_IMG_EXT is
image-only).
Reword the hint to teach both halves: images via MEDIA: work on the
chat/completions/responses endpoints; non-image files and anything on
the runs endpoint must fall back to plain file paths in the response
text. Update the test to pin the scoped guidance instead of a blanket
prohibition.
Every PLATFORM_HINTS entry for a messaging platform (Telegram, WhatsApp,
Discord, Slack, Signal, WebUI, desktop) teaches the model the MEDIA:/path
convention because an interception mechanism actually resolves it there
(native attachment delivery, or a validated/inlined data URL). The cli
entry, which has no such mechanism, explicitly tells the model NOT to use
it and to state the path in plain text instead.
The api_server entry had neither instruction. Its /v1/runs handler never
routes the final response through any MEDIA: resolver (confirmed against
source: none of the four call sites of the api_server module's media-tag
resolver are inside its runs-endpoint handler), so a MEDIA:/path tag there
renders as inert literal text in the API response — exposing a raw host
filesystem path to the caller with no delivery ever taking place. Nothing
platform-specific told the model not to use a convention it's correctly
taught for several sibling platforms in this same dict, so the general
cross-platform habit could surface here too, unlike cli where an explicit
prohibition already closes the gap.
Mirrors cli's prohibition, adapted for api_server's actual constraint: no
"state the path in plain text" fallback, since a typical API caller has no
filesystem access to the host at all. Points at "a registered file-delivery
tool" generically rather than naming any specific tool, since api_server
toolsets are deployment-defined.
The api_server adapter returned error code "invalid_api_key" for
API_SERVER_KEY authentication failures, which the Desktop error
classifier misidentified as a provider (OpenRouter/OpenAI) key
problem — showing "OpenRouter API key missing" when the real issue
was gateway auth.
Changes:
- gateway/platforms/api_server.py: return "gateway_auth_failed" code
with descriptive message for API_SERVER_KEY auth failures
- apps/desktop/src/store/notifications.ts: add "gateway_auth_failed"
handler before "invalid_api_key" to show correct error message
- agent/error_classifier.py: add "gateway_auth_failed" to auth patterns
- tests: update test_session_api.py to expect new error code
Fixes#39365
When _api_key_passes_startup_guard() rejects the key (missing,
placeholder/too short, or fail-closed unverifiable strength), connect()
returned a bare False with no fatal-error info. gateway.run's reconnect
watcher treats that as transient and re-queues with backoff forever —
each retry re-instantiating the adapter and its ResponseStore sqlite
connection. Observed in production (#37011): ~501 leaked connections
(1002 fds) over ~2.5 days until EMFILE made the whole gateway
unresponsive.
Set a non-retryable fatal error (api_server_key_invalid) in connect()
when the guard rejects, covering all three rejection branches, so the
platform drops from the reconnect queue; recover with
`/platform resume api_server` after fixing the key. Same treatment as
the port-conflict guard (api_server_port_in_use, #65665 / bda8bd76a8).
Tests mirror the port-conflict precedent: each rejection path asserts
connect() is False, has_fatal_error True, fatal_error_retryable False,
and fatal_error_code api_server_key_invalid, plus a strong-key control.
Re-implementation of #38803 by @cifangyiquan against current main —
their patch targeted the old inline guard in connect() which was since
extracted to _api_key_passes_startup_guard() (and gained the
fail-closed branch in 683059feb5), so the original diff no longer
applies. Their production diagnosis and non-retryable direction
preserved.
Refs: #38803, #37011
Salvaged from PR #36180 (commits 68dfeb4b16 and 86f437509b by arimu1),
re-applied onto current main with the incidental black-reformat churn
stripped out (~1,700 lines -> the semantic change + tests).
Previously gateway/config.py enrolled the api_server platform on
`api_server_enabled or api_server_key`, so API_SERVER_ENABLED=true with
no key (or a weak/placeholder key) still loaded the platform: the
adapter is instantiated (ResponseStore/SQLite opened in __init__), the
reconnect watcher spins, and the startup guard refuses at connect() —
logging errors forever. Now the platform is enrolled only when
API_SERVER_KEY passes the same strength bar as the adapter's startup
guard (has_usable_secret, min_length=16), via a shared
_has_usable_api_server_key() helper.
The no-op `lambda cfg: True` connected-checker for API_SERVER is also
replaced with the same key check, so get_connected_platforms() only
reports the platform "up" when it could actually start.
Known limitation (intentionally out of scope): a YAML config with
`platforms.api_server.enabled: true` and no key still loads the
platform; this gate covers the env-override path only.
Dropped from the original PR: EMAIL/SMS checker additions (scope creep
beyond the PR title; absent on current main) and the wholesale black
reformat of gateway/config.py and tests.
Fixes#36111
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
shivasymbl, z23, Skywind5487, 2001Y, gonzalofrancoceballos, kylezh.
briandevans' noreply mapping already present; mzkarami's mapping file
shipped inside #66204's own commit.
Also fixes the TestFormatMessageTableIntegration fixture to match
PlatformConfig's current signature (no 'name' kwarg).
Widen #48476's fence guarantees to the two splitters that still emitted
fence-broken chunks:
* GatewayStreamConsumer._split_text_chunks (fallback final send): close
the orphaned ``` at each chunk boundary and reopen it — with the
original language tag — on the next chunk, mirroring
BasePlatformAdapter.truncate_message's contract. Headroom is reserved
so balanced chunks stay within the platform limit.
* Slack block_kit._split_text (3000-char section chunking): same
close/reopen balancing for mrkdwn section text carrying fences.
With these, every chunk boundary — non-streaming send
(truncate_message), streaming overflow (_truncate_for_stream via
adapter.truncate_message per #45938), fallback final
(_split_text_chunks), final-send balance (ensure_closed_code_fences),
and Block Kit section splits — delivers fence-balanced chunks.
Regression tests probe each path with fenced fixtures, assert per-chunk
balance, limit compliance, language-tag reopening, and prose passthrough.
Slack's Block Kit `markdown` block accepts standard markdown (tables,
headers, task lists, fenced code with syntax highlighting, links) and
lets Slack translate it natively — eliminating the lossy markdown→mrkdwn
conversion for the rendered layout. Enable via
platforms.slack.extra.markdown_blocks.
Safety rails added on top of the original design:
* opt-in (default off) — Slack documents the block for 'apps that use
platform AI features' and does not guarantee availability across all
app types / surfaces, so unconditional adoption is not safe yet
* the mrkdwn-converted text field is ALWAYS kept as the
notification/search/accessibility fallback
* content over Slack's 12k cumulative markdown-block cap declines to the
rich_blocks renderer / plain text path
* the existing block-rejection retry (invalid_blocks / msg_too_long /
too_many_blocks) re-sends the plain mrkdwn payload, so an unsupported
surface degrades gracefully instead of dropping the message
* when both modes are enabled, markdown_blocks is preferred over the
local rich_blocks renderer; rich_blocks remains the fallback
Adapted from #8554 by @shivasymbl — the original patched the deleted
gateway/platforms/slack.py and switched unconditionally; reimplemented
against the plugin adapter's _maybe_blocks/sanitize_blocks pipeline.
Fixes#8552.
Slack mrkdwn has no table syntax — GFM pipe tables render as literal-pipe
noise with a raw |---|---| separator row. Wrap detected tables in ```
fences so they render as monospace preformatted text, and pad cells to
per-column max display width (East-Asian Wide / Full-width chars counted
as 2 columns) so columns stay aligned even with CJK content.
Tables already inside fenced code blocks are left untouched, and the
emitted fences carry no language tag so they compose with the
lang-tag-strip pass. This covers the plain-mrkdwn text path; the opt-in
rich_blocks path already renders native Block Kit table blocks.
Reapplied from #16648 by @kylezh — the original patched
gateway/platforms/slack.py, which was migrated to
plugins/platforms/slack/adapter.py in the plugin migration.
Fixes the non-rich_blocks table path described in #8552.
ensure_closed_code_fences previously only handled triple-backtick
(```) code-block fences. Single backtick (`) inline-code spans have
the same problem: an orphaned opening backtick causes the remainder
of the message to render as inline code on Discord and other platforms.
After balancing triple-backtick fences, strip complete ```...```
regions and count remaining standalone backtick markers. If odd,
append a closing backtick. Same trade-off as the triple-backtick fix:
a stray closing backtick may create a brief empty inline-code span,
which is far less harmful than the rest of the message being inline code.
B1: When reasoning content contains ``` e.g. model quoting code in
its thinking, wrapping it in an outer ``` for display causes the
inner fence to break the outer block.
Adds escape_code_fences_for_display() in gateway/stream_consumer.py,
called from gateway/run.py before wrapping reasoning in the outer
``` display block.
Slack's mrkdwn does not strip the optional language tag from fenced
code blocks like GitHub-flavored markdown does — it renders
```text\nfoo\n``` as a code block whose literal first line is "text".
The agent emitted ```text fences around raw command output, which
surfaced "text" as the first line of every such block.
Drop the tag from the opening fence in format_message() before stashing
the block behind a placeholder. Stripping only fires for a genuine
opening fence — a ``` at the start of a line, tagged with a single
token (no spaces or backticks) — and the original line ending is
preserved. The fence-protection regex deliberately matches loosely, so
a mid-line ``` (e.g. an inline ```span``` wrapping across a newline)
can be grouped as an "opening fence" whose first line is real content;
differential fuzzing against the pre-change formatter (40k generated
messages) confirms the only behavioral delta is the tag strip itself.
The Block Kit renderer is unaffected: render_blocks() intercepts fences
itself before mrkdwn_fn is applied, so this only changes the mrkdwn
surfaces that still go through format_message() — the plain-text
fallback field (notifications, search indexing, accessibility),
slash-command ephemeral replies, and standalone cron delivery.
Originally written against gateway/platforms/slack.py; ported to
plugins/platforms/slack/adapter.py after the adapter migration in
5600105478.
Manually verified against a live Slack workspace (pre-migration
adapter; the ```text case strips identically) — code blocks no longer
carry a literal "text" first line.
format_message unescapes already-escaped input before re-escaping, so that
pre-escaped text doesn't get double-escaped. That unescape was three
sequential str.replace calls, which re-scan each other's output:
"&lt;" --(& -> &)--> "<" --(< -> <)--> "<"
The & produced by the first replace pairs with the following "lt;" and
decodes a second time. "&lt;" is the wire form of the literal text
"<", so the text is silently destroyed: Slack receives "<" and renders
"<". Anyone writing about HTML or markup ("&lt;b&gt;" -> "<b>")
loses their literal text, with no error.
re.sub scans left-to-right and never re-scans its own replacements, so a
single pass fixes it. The escape pass on the next line is left untouched --
it is correctly ordered (& first, so the &s it inserts aren't re-escaped).
Only the double-decode cases change; every other input is byte-identical
before and after. This is the same round-trip invariant the neighbouring
test_pre_escaped_{ampersand,lt,gt}_not_double_escaped tests already assert,
extended to the case they miss. Affects the plain mrkdwn path (send,
edit_message) and Block Kit sections, which route section text through
format_message.
Build the full reaction pipeline on top of the #29916 base:
- Opt-in gate: slack.reaction_triggers (default OFF — reaction events
stay acked-and-dropped so busy channels don't wake the agent on every
emoji). 'true' routes reactions on the bot's OWN messages; an explicit
emoji-name list routes those emojis from any message (handoff flows).
- reaction_removed events now route too, distinguished by the
cross-platform text convention reaction:added:<emoji> /
reaction:removed:<emoji> (matches the Feishu and Photon adapters, so
agents and skills see one shape everywhere).
- Authorization: the reactor becomes the synthesized message's user, so
the early _is_user_authorized gate and allowed_channels whitelist
apply exactly as for typed messages. _hermes_force_process only skips
the mention requirement (a reaction on the bot's own message is
definitionally addressed to the bot), mirroring Feishu/Photon.
- Gateway hooks (#33111 by @johnkattenhorn): every human reaction on a
message item fires reaction:added / reaction:removed through the new
BasePlatformAdapter.set_reaction_handler → GatewayRunner
._handle_reaction_event → HookRegistry.emit, independent of the
routing opt-in. Documented in hooks.md.
- Channel handoff (#45265 by @Kev-fs): slack.reaction_trigger_target
routes the reaction turn to a configured channel (top-level via
_hermes_no_thread_response + reply-anchor suppression in
gateway/platforms/base.py) or C123:<ts> thread.
- Manifest: reaction_removed event subscription added alongside
reaction_added/reactions:read.
- Docs: slack.md Reaction Triggers section; hooks.md event table rows.
Also credits #44508 by @harrisonmedmedmetrics (inbound reaction_added
handling — same plumbing class, superseded by this consolidated shape).
Co-authored-by: johnkattenhorn <john.kattenhorn.personal@gmail.com>
Co-authored-by: Kev-fs <kevin@fleetsmarts.net>
Co-authored-by: harrisonmedmedmetrics <harrison@medmetricsrx.com>
Slack reaction_added events were explicitly acked and dropped, so a user
reacting to a bot message (👍 to approve, ✅ to acknowledge) produced
nothing. Forward them through the normal message pipeline as synthesized
MessageEvents whose text is the reaction emoji (translated to unicode
for common names), keeping the downstream auth gate, thread-context
fetch, dedup, and skill routing unchanged.
- Self-reactions and non-message items are dropped; reactions on
messages not sent by this bot are dropped (Feishu-adapter parity).
- The reacted-to message's thread parent becomes the synthesized
thread_ts so the reaction lands in the same session as a reply would.
- Manifest gains reactions:read scope + reaction_added bot event.
Salvaged from PR #29916 by @bpross.
Related: #33111, #44508, #45265 (same cluster).
Salvaged from PR #36220, ported onto the current SessionStore (SQLite-
backed get_or_create_session; activity check is last_prompt_tokens) and
the sidecar-note reset path (context notes now ride turn_sidecar_notes
instead of prepending to context_prompt).
Long-lived Slack/Discord channels/threads lose their context on
daily/idle session resets, and the agent can bind a new request to an
unrelated recent session (observed: a Discord thread reset caused a PR
in the wrong repository). Record prev_session_id when an auto-reset
replaces a session with real activity, persist it, and append a
deterministic one-line hint to the auto-reset context note pointing the
agent at session_search for that specific prior session. No LLM calls,
no channel-history APIs, no extra DB lookups; other platforms and
activity-free resets are untouched.
Refs #36220. Co-authored-by: metamon <269728612+metamon-p@users.noreply.github.com>