cua_driver_update_check now short-circuits to None when no driver
resolves; CI has none installed, so pin resolve_cua_driver_cmd in the
update-check and env-sanitization tests.
Print gh-image stdout and stderr on attachment failures, then replace the
pending inline-evidence marker in the PR review comment with an escaped
failure notice before preserving the failing workflow result.
Keep thin-PATH resolution while asserting the absolute binary on the
already-installed version check. Map contributor emails for the salvage.
Co-authored-by: Tianqing Yun <yuntianqing@yahoo.com>
Co-authored-by: Trevor Gordon <trevorbgordon@gmail.com>
Co-authored-by: Adrian Soto Mora <adrian.soto6@gmail.com>
Supersedes #52948 and #67402. Closes#52945.
Standalone hermes dashboard/serve was trusting HERMES_WEB_DIST and
HERMES_SERVE_HEADLESS inherited from a Desktop Electron parent, which
could serve the packaged desktop renderer ("Desktop IPC bridge is
unavailable") or disable the SPA. Drop only Electron-packaged WEB_DIST
paths (app.asar*) when HERMES_DESKTOP!=1, and clear inherited headless
for non-serve launches, while preserving caller-managed custom dist
overrides and the desktop-spawned backend path.
Co-authored-by: Bartok9 <danielrpike9@gmail.com>
Co-authored-by: Commander <commander@tianji.local>
Adds the missing write path for the per-task model_override column (which
was previously only settable via manual SQL) and pairs it with a
provider_override so cross-provider switches resolve correctly:
- kanban_db: provider_override column (+migration), set_model_override()
with model_override_set event, create_task(model_override=,
provider_override=), dispatcher spawns worker with -m <model>
[--provider <name>]
- dashboard: Model row in the task drawer — dropdown fed by a new
/model-options endpoint (build_models_payload substrate, provider-grouped,
free-text fallback), PATCH + bulk model override support
- CLI: kanban create --model/--provider, new kanban set-model subcommand,
show prints the provider
- agent tools: kanban_create accepts model/provider; show/list expose
provider_override
Rate-limit recovery flow: override is settable on running tasks and takes
effect on the next dispatch, without touching the worker profile's config.
`no-key-required` is a valid sentinel for local/self-hosted/custom
routers. Warning on it made Desktop treat a working setup as missing
API keys.
Co-authored-by: Yingliang Zhang <zhangyingliang@outlook.com>
Co-authored-by: Brandon R <kingdomwarrior23@gmail.com>
* fix(desktop): stop model-switch dup + route recovery resumes to the owning profile
Fixes two Desktop session-reconciliation symptoms from #67603.
Symptom 1 — duplicated user bubble after a model switch. The gateway
persists model-switch / personality notices as role=user `[System: …]`
rows (tui_gateway/server.py) so strict OpenAI-compatible providers don't
reject a non-leading system message (#48338). `preserveLocalPendingTurnMessages`
paired local optimistic rows with the stored transcript by user-role
ordinal, so a marker between two real user turns shifted every later
ordinal and the optimistic row was re-appended at the bottom. The single
trailing-marker case is already covered by the compression-era
`latestAuthoritativeUser` guard, but two switches around one turn (marker
before AND after the committed prompt) still duplicated it. Exclude
`[System:` bookkeeping markers from ordinal pairing on both sides.
Symptom 2 — a session appearing under two profiles. The main resume path
already resolves a session's owning profile via `resolveStoredSession`
(cache → active backend → cross-profile probe), but the recovery
`session.resume` calls (stale runtime id, session-not-found, wedged loop,
redirect) omitted `profile`, so the gateway fell back to the launch-profile
DB and forked the conversation into the wrong profile. Route every recovery
resume — and an uncached right-click branch — through the same resolver so
the profile is carried even for sessions outside the paginated sidebar
window (the cache-miss gap).
Tests: discriminating two-switch marker test (fails before, passes after);
cache-hit + cross-profile cache-miss coverage for the recovery resume and
for branching an uncached session.
Supersedes #68665 and #63590.
Closes#67603.
Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
* fix(desktop): scope the remembered session id per profile
A single global `hermes.desktop.lastSessionId` key remembered ONE session
across every profile, so a relaunch or cold start under profile B would try
to restore a session owned by profile A — reinforcing the impression that a
conversation had bled between profiles (#67603, second symptom).
Key the remembered id by the session's owning profile (resolved from the
session row's `profile`, falling back to the active gateway profile), read it
back for the active profile on restore, and clear an exhausted session under
its owner. The default profile keeps the original unsuffixed key so existing
installs' remembered session survives the upgrade.
Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
* fix(gateway): hide [System:] bookkeeping markers from every transcript
Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.
Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.
Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
---------
Co-authored-by: Dolverin <5910064+Dolverin@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <274182427+oliviaaaa7788@users.noreply.github.com>
Co-authored-by: oliviaaaa7788 <oliviaaaa7788@users.noreply.github.com>
Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
* fix: serialize TUI gateway websocket sends
* fix(tui_gateway): preserve websocket batch order
* refactor(tui_gateway): drop unused _safe_send wrapper
The batch-serialization fix routes every send through _safe_send_many;
_safe_send became a dead single-line wrapper with no callers. Remove it.
---------
Co-authored-by: supplefrog <78985073+supplefrog@users.noreply.github.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
Post-rebase consolidation over the merged C7/C16/C10 work:
- _ensure_dm_conversation now records workspace ownership via
_remember_channel_team and bounds _dm_conversation_cache (cap 5000)
- module-level _slack_dm_cache (C7 standalone path) bounded oldest-first
- _user_is_bot_cache (C10) bounded with _trim_oldest_dict_entries
- caption-mode contract tests updated for the C7+C8 merged media path
Widen the #19237 send_message fix to the live adapter: send, _upload_file,
send_multiple_images, send_image, send_video, send_document,
send_exec_approval, send_slash_confirm, and send_clarify now route bare
Slack user IDs (U.../W...) through a shared _ensure_dm_conversation helper
before calling chat.postMessage / files_upload_v2, which reject user IDs.
This closes the gap in #17261 where an attachment worked when replying in
a thread but failed when directed at a user DM, and extends the DM-open
fallback to clarify/approval Block Kit prompts so gated actions can reach
a user directly.
Resolution uses the workspace-scoped client (multi-workspace installs open
the DM with the right bot token), caches per (team, user), and records the
opened D... channel in the channel→team map. On failure the original
target passes through so the downstream API call surfaces the real Slack
error.
Fixes#17261
Refs #19236
The post-stream helper (_deliver_media_from_response) rescanned the
already-streamed response and promoted bare local filesystem paths into
real uploads via extract_local_files. Since the visible reply was already
streamed verbatim, any bare path there is either text the user has seen
or stale inspected/tool content — not an attachment request. On Slack this
uploaded images from stale inspected content after otherwise clean replies.
Post-stream delivery now honors only explicit MEDIA: directives. The
non-streaming path in gateway/platforms/base.py keeps its bare-path
auto-detect, because that path controls the visible text and strips the
path from the reply when it attaches — auto-attach is intentional there.
Regression tests: bare image/document paths in a streamed reply produce
no upload; explicit MEDIA: tags still deliver.
Fixes#20834
Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.
Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.
Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
* fix(desktop): keep clarify lifecycle when tool progress is off
* fix(desktop): render clarify prompt from the request event
Re-authored onto the current use-message-stream/gateway-event.ts (the
original patched the pre-split use-message-stream.ts). When the tool.start
row that normally mounts the inline clarify UI is missed (stream reconnect
/ hydration race), upsert a stable pending clarify tool row from
clarify.request itself so the prompt stays answerable; a real
tool.start/complete with the same request id merges rather than duplicates.
Co-authored-by: 정수환 <centerid@naver.com>
* chore(contributors): map centerid@naver.com -> lidises
Attribution mapping for the salvaged #47544 commit.
* fix(desktop): correlate clarify rows by question so hydration can't duplicate
The hydrated row (from clarify.request's request_id) and the real tool.start
row (the model's tool_call_id) have different ids, so id-only matching appended
a second clarify card in the normal path (caught by the BLOCKING_CLARIFY e2e:
'question' resolved to 2 elements). Add 'question' to the tool match-value keys
so a clarify upsert merges into the existing pending clarify row regardless of
id (same request<->args correlation ClarifyToolPending already uses); when no
row exists yet (reconnect/hydration) it still creates one.
---------
Co-authored-by: 정수환 <centerid@naver.com>
* fix(gateway): deliver assistant prose before clarify poll
The clarify poll is sent on a separate, agent-thread-blocking path while
buffered assistant prose (interim commentary / streamed deltas) sits in
the GatewayStreamConsumer queue, drained asynchronously. The poll won the
race, so the question rendered ABOVE its own explanation, and a redundant
'clarify: ...' tool-progress bubble wedged between them.
- Add GatewayStreamConsumer.flush_pending_sync(): a synchronous flush
barrier (_FLUSH sentinel + threading.Event) that blocks the agent
thread until everything queued before it is finalized and delivered.
- Call it in the gateway clarify callback before send_clarify, so prose
always lands before the poll. Best-effort with a 3s timeout.
- Suppress the redundant clarify tool-progress bubble (the poll already
shows the question + options).
Tests: 3 new ordering/timeout cases in test_stream_consumer.py.
(cherry picked from commit 9a6e27badb)
* chore(contributors): map matvey.sakhnenko03@icloud.com -> sakhnenkoff
Attribution mapping for the salvaged #54328 commit (Cluster C).
---------
Co-authored-by: Matvii Sakhnenko <matvey.sakhnenko03@icloud.com>
* test(clarify-gateway): cover signature, timeout fallback, and notify paths for 100% coverage
Fixes#36531
(cherry picked from commit 5265dfe2f5)
* fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway
The clarify wait timeout was resolved three different (wrong) ways:
- CLI (`cli.py`, `hermes_cli/callbacks.py`) read a non-existent top-level
`clarify.timeout`, so it always fell through to a hardcoded 120s instead of
the canonical `agent.clarify_timeout` (default 3600) the gateway uses (#42969).
- The TUI/desktop bridge called `_block("clarify.request", …)` with no timeout,
so it used the hardcoded 300s `_block` default and ignored config (#51960).
- There was no way to disable the auto-skip: a user who wanted the agent to wait
indefinitely while they think couldn't get it.
Collapse all of this onto a single resolver:
- `tools.clarify_gateway.resolve_clarify_timeout(config)` is the one source of
truth. Order: explicit legacy `clarify.timeout` (back-compat) → canonical
`agent.clarify_timeout` → 3600. `<= 0` is preserved verbatim as "unlimited".
- CLI, callbacks, and the TUI bridge (`_clarify_timeout_seconds`) all route
through it, so the three surfaces can't drift.
- `<= 0` means unlimited everywhere: `wait_for_response` and `_block` drop the
deadline (heartbeat still fires), and the CLI hides its countdown.
Tests: resolver order / default / non-numeric / unlimited-sentinel; an
unlimited `wait_for_response` blocks until resolved rather than auto-skipping;
the TUI clarify bridge passes the configured timeout to `_block`.
Supersedes #42974 (CLI key), #51993 (TUI honors config), and #68986 (unlimited
wait); folds in #52031 (clarify_gateway coverage).
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>
---------
Co-authored-by: Christopher-Schulze <210261288+Christopher-Schulze@users.noreply.github.com>
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>
`_block()` bridges four blocking request types — secret, sudo, clarify,
terminal.read — with an identical lifecycle: on timeout the tool gives up and
returns empty, but a slow renderer (or a WebSocket reconnect that dropped
tool.complete) can still answer afterward. Only secret and sudo tolerated that
late reply; clarify and terminal.read still hit the generic 4009 "no pending
request" error, which clients surface as a raw JSON-RPC string (and at least
one desktop fork re-armed the pending request on the error).
Bring the two stragglers in line with the pair that already works:
- `_block` now emits `{event}.expire` on timeout for all four request types.
- `clarify.respond` and `terminal.read.respond` pass `allow_expired=True`, so a
late answer resolves to `{"status": "expired"}` instead of erroring.
Tests parametrize the timeout-expiry and late-idempotent-response cases over
all four bridges; the old test asserting clarify stays a 4009 error is updated
to the new graceful contract.
Supersedes #56571 (clarify) and #64886 (terminal.read) — same root cause, one fix.
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: pierrenode <pierrenode@users.noreply.github.com>
- Add SLACK_THREAD_REQUIRE_MENTION, SLACK_IGNORE_OTHER_USER_MENTIONS and
SLACK_REQUIRE_MENTION_CHANNELS to the conftest behavioral-env force-clear
list so config-loader side effects can't leak between tests (same class
of leak the existing SLACK_* entries guard against).
- Document thread_require_mention and require_mention_channels in the
Slack messaging guide next to the other mention-gating options.
Port of the Slack half of #13855 (by @kshitijk4poor), reimplemented against
the plugin adapter (the original PR targets the deleted
gateway/platforms/slack.py and six other legacy adapters).
Channels listed in slack.require_mention_channels (config.yaml) or
SLACK_REQUIRE_MENTION_CHANNELS ALWAYS require an explicit @mention, even
when require_mention is false globally or the channel is in
free_response_channels — the opposite direction of free_response_channels.
Instead of duplicating the PR's inline reply-to-bot-thread/mentioned-thread/
session checks, the forced channel falls through to the SAME decision chain
as normal mention gating, so all five wake checks in
_should_wake_on_unmentioned_message keep applying (single decision path).
Credit: adapted from #13855 by @kshitijk4poor (Slack half only; the other
platform halves target deleted legacy adapters and are out of scope for
this cluster).
The ignore_other_user_mentions gate relied solely on is_mentioned, which
only recognises exact <@UID> markup, so a message mentioning the bot in
pipe form (<@UID|name>) alongside a leading other-user mention was
wrongly suppressed. The gate now also scans for the bot's own mention in
either markup before ignoring a message.
Claude-Session: https://claude.ai/code/session_01TKsNdptNdo9CqT2u7JMdkH
Once the bot is @mentioned in a Slack thread it auto-follows and replies
to every later message in that thread, including ones a human addresses to
another human (e.g. "@rasha check this out"). The bot butts in.
Add slack.ignore_other_user_mentions (env SLACK_IGNORE_OTHER_USER_MENTIONS,
default off). When on, a channel/thread message whose first token @mentions
someone other than the bot is treated as addressed to that person and the
bot stays silent unless it is also mentioned. This is Slack parity for the
Discord option of the same name (#33501), adapted to Slack's thread model:
the trigger is a leading mention ("addressed to"), so a message that merely
references another user mid-sentence still reaches the bot.
The gate sits ahead of the free-response / require_mention ladder so it also
overrides the mentioned-thread auto-follow. DMs are never filtered.
Follow-up rework on the #51627 cherry-pick:
- Guard the 5th wake check (parent-mentioned-bot, #24848) against a None
parent_text — _fetch_thread_parent_text is typed to return str but tests
(and defensive callers) can surface None; 'in None' raised TypeError.
- Drop the PR's fixture-level _fetch_thread_parent_text/_fetch_thread_context
AsyncMocks from TestThreadReplyHandling/TestAssistantThreadLifecycle: main's
#24848 tests exercise the real parent-text path via conversations_replies
side effects, and the blanket mocks broke them.
- _resolve_user_is_bot reworked to the workspace-scoped (team_id, user_id)
cache key introduced by the multi-workspace name cache on main.
Widening pass over the whole adapter following the cluster-C16 audit
(#51019, #51097, #23676, #23375): every in-memory structure that
accumulates per-message, per-user, or per-thread state is now bounded,
and every eviction is oldest-first — never arbitrary set-iteration
order, which is the #51019 failure mode (bot silently going quiet on
the most ACTIVE thread because set.pop-order eviction removed it).
Newly bounded:
- _approval_resolved / _clarify_resolved (caps 1000): unclicked
approval/clarify prompts leaked their double-click-guard entries
forever; oldest-insertion eviction via _trim_oldest_dict_entries.
- _reacting_message_ids (cap 5000): reaction lifecycle entries leaked
when an exception fired between add and finalize; oldest-ts eviction.
- _active_status_threads (cap 1000): statuses abandoned by error paths
accumulated; oldest-thread-ts eviction so the newest live status is
never cleared.
- _channel_team (cap 10000): grew with every DM channel the bot ever
saw (DM channel IDs are per-user). All four write sites now route
through _remember_channel_team; eviction is safe because entries are
re-learned from the next event and _get_client falls back to the
primary client.
- _slash_command_contexts (cap 1000): TTL cleanup only ran on lookup,
so contexts whose ephemeral replies never happened accumulated;
overflow purges expired entries first, then oldest-stash-first.
Converted from arbitrary set-order eviction to oldest-first:
- _titled_assistant_threads: keys are (team, channel, thread_ts) —
now evicts oldest thread first via _discard_oldest_by_thread_ts.
- _thread_rehydration_checked: keys are team:channel:thread_ts[:user] —
arbitrary eviction here would re-run an ACTIVE thread's restart
rehydration check and re-inject the missed-delta context; now evicts
oldest thread first.
- _reacting_message_ids uses #51097's _discard_oldest_slack_timestamps.
Deliberately NOT bounded (naturally tiny, per-workspace):
_team_clients, _team_bot_user_ids, _team_bot_names (one entry per
installed workspace), _assistant_threads / _agent_view_contexts /
_bot_message_ts / _mentioned_threads / _user_name_cache /
_thread_context_cache (already bounded), _dedup (MessageDeduplicator
has max_size + TTL internally).
New helpers: _trim_oldest_dict_entries (dicts preserve insertion
order, so oldest-first is exact) and _discard_oldest_by_thread_ts
(chronological sort on the embedded Slack ts for keyed sets).
Tests: caps hold under churn, eviction removes OLDEST not arbitrary
entries, newest/active entries survive eviction pressure (regression
shape for #51019), plus end-to-end paths through _resolve_user_name
and _handle_slash_command.
Part of the C16 cache-bounds consolidation with #51097 (markoub),
#23676 and #23375 (EloquentBrush). Fixes#51019.
_fetch_thread_context() caches per-thread Slack history under a
60-second TTL, but expired entries are never removed. On the current
adapter this is worse than when first reported: each entry now also
retains the raw conversations.replies payloads (messages) for
watermark re-formatting, so a busy multi-channel workspace accumulates
full message lists forever. _bot_message_ts, _mentioned_threads, and
_assistant_threads all enforce a MAX constant with active eviction in
the same __init__; _thread_context_cache had the TTL declared but no
matching eviction path.
Fix: add _THREAD_CACHE_MAX = 2500 and purge expired entries
(fetched_at older than _THREAD_CACHE_TTL) whenever a new write pushes
the cache past the limit. TTL-based eviction is used instead of LRU
because fresh entries are still needed; evicting them would
immediately re-trigger a rate-limited conversations.replies call.
Adds two unit tests: one verifying stale entries are purged on
overflow, one verifying fresh entries survive.
Reapplied from #23375 onto the current adapter (moved to
plugins/platforms/slack/adapter.py; cache entries now carry raw
message payloads and parent_user_id).
Companion to gateway-gateway#160 (connector absent-row default flips to
mention-gated / requireAddress=true).
relay_relevance_policy() previously returned None whenever the projected
policy was all-falsy, on the premise that 'the connector's quiet default
already matches'. Once the connector defaults requireAddress to true,
that premise inverts for one case: an agent EXPLICITLY configured with
require_mention: false would stay silent and get mention-gated anyway,
with no way out.
The nothing-to-declare check now keys on 'require_mention is unset'
rather than 'require_mention is falsy': an explicit false IS a
configured knob and is declared to the connector; a genuinely
unconfigured platform still declares nothing and inherits the
connector's default. No wire/contract change.
Cron jobs targeting a Slack user (deliver=slack:U…) failed with
channel_not_found: chat.postMessage and files_upload_v2 require a
conversation ID, and a DM must be opened first via conversations.open
to obtain a D… ID. The tool-level resolution in send_message() does not
cover cron's direct _send_to_platform → standalone_sender_fn path.
Add _resolve_slack_user_dm to the Slack plugin: resolves U…/W… targets
via conversations.open (proxy-aware, cached per token+user so repeated
cron fires don't re-open the DM), wired into _standalone_send ahead of
both the text and media delivery branches so every standalone entry
point benefits.
Adapted from #17444 by @Esther-Zhu023 — the original patched the legacy
_send_slack helper in tools/send_message_tool.py, which has since moved
into the plugin as _standalone_send (#41112).
Closes#17444
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The in_channel flat-delivery clear was gated on `runtime_adapter is not
None`, but the flat continuation session is seeded ONLY on the live-send
path, which also requires a running event loop. When an adapter is
present but the loop is absent/not-running, the live-send/seed block is
skipped and delivery falls through to the standalone path — clearing
thread_id there flattened an unseeded brief with no continuable session
behind it (and bypassed the D6 capability check).
Factor the live-send condition into `live_adapter_ready` and gate both
the thread_id clear and the live-send block on it, so the clear can no
longer drift from the seed. Add an adapter-present/no-running-loop
regression test (verified it fails against the un-scoped clear).
Addresses review r3609147550.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>