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