- _install_plugin_core accepts ref= (full-depth clone + git checkout,
PluginOperationError on failure) and skip_removed_check=
- installs are refused when the identifier or resolved repo URL matches
plugin-catalog/removed.yaml, with the recorded reason and date
- plugin.yaml gains requires_hermes (version spec) and config: (list of
{key, prompt, type, default, secret}) parsed onto PluginManifest
- PluginManager skips loading (clean error, no traceback) when the
running Hermes version does not satisfy requires_hermes; local
_version_satisfies helper supports >=,>,<=,<,==,!= and comma specs
- PluginContext.plugin_config merges config-spec defaults under
plugins.entries.<plugin_id> values from config.yaml
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).
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
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
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
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/.
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>
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.
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).
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).
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
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.
_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
The salvaged guard from #52276 only fired when a system prompt was
present in messages[] (system is not None after extraction). The live
repro of #52160 is the auto path: the system prompt is passed outside
messages[], so after the second compaction messages[0] is the
assistant-role summary with system=None — and the guard never ran.
Make _ensure_leading_user_turn unconditional, exactly mirroring the
Bedrock Converse adapter ('Converse requires the first message to be
from the user' — convert_messages_to_converse). Add a regression test
building the exact post-double-compaction shape (no system in
messages, messages[0]=assistant summary) asserting the converted
payload leads with a user turn, and update existing fixtures that
started with a bare assistant message to locate roles instead of
indexing result[0].
Anthropic extracts the system prompt into a separate `system` field and
requires messages[0] to be role="user"; a leading assistant turn is rejected
with HTTP 400. After a second context compaction the only surviving leading
anchor is the system prompt, so a summary/handoff message emitted as
role="assistant" becomes the first messages entry once the system prompt is
extracted. Anthropic reports this with a misleading error —
`messages.N: tool_use ids were found without tool_result blocks immediately
after: toolu_...` — even when every tool_use/tool_result pair is adjacent and
matched; the real structural defect is the leading assistant role (#52160).
This is engine-agnostic: it fires for any producer of a leading-assistant
transcript (built-in compressor, the DAG/LCM context engine, session
truncation), unlike the compressor-scoped fix in #52167. The native Bedrock
Converse adapter already guards the same invariant
(convert_messages_to_converse); this mirrors it for the native Anthropic path.
Add _ensure_leading_user_turn() to the convert_messages_to_anthropic
post-processing chain, scoped to the system-extracted case (the production
trigger) so bare assistant-only unit fixtures are unaffected. Adds regression
tests (leading-assistant, leading-assistant-with-adjacent-tool_use, and a
no-op negative control).
teknium1 review on #57690: harvesting logic was skipping the ENTIRE merged
row when a compaction summary was appended to the tail message, discarding
real prior user content that context_compressor retains before the
_MERGED_SUMMARY_DELIMITER. Extract and harvest that pre-delimiter segment
instead of dropping it wholesale.
Revert-to-fail: reverting plugins/memory/holographic/__init__.py alone
drops test_merged_into_tail_preserves_genuine_pre_delimiter_preference
(19 passed, 1 failed); restoring the fix returns 20/20 passed.
Two compounding defects in the holographic memory provider (#57682):
1. The on_session_end gate used plain truthiness on auto_extract, but the
plugin's own config schema declares it as a string enum with default
"false" — and not "false" is False, so extraction ran for users who
had it configured off. Coerce with the shared utils.is_truthy_value
(same fix class as the merged byterover no-op fix).
2. _auto_extract_facts scanned every role=user message. Context-compaction
handoff summaries can be inserted as role=user messages and their prose
reliably matches the decision patterns (we decided/agreed, the project
uses), so the compactor's own output was persisted as a durable project
fact on every rollover following a compaction — recreated even after
manual deletion.
Adds agent.context_compressor.is_compaction_summary_message(), a public
helper that prefers the in-process COMPRESSED_SUMMARY_METADATA_KEY marker
and falls back to _is_context_summary_content() (covers merged-into-tail
and historical prefixes), since the metadata key is stripped by wire
sanitizers and doesn't survive all session-store round-trips. The plugin
skips summary messages before pattern matching.
Fixes#57682
Review follow-up on #67431 (hermes-sweeper):
- The viability check compared the floored percentage against the raw
context window, but the built-in trigger recomputation also applies the
output-token reservation, the 64K floor, and the degenerate-window
guard (_compute_threshold_tokens). Mirror that math exactly, so e.g. a
200K window with max_tokens=120K recomputes to max(0.75*80K, 64K)=64K
and the suggestion is correctly KEPT for an 80K aux model instead of
being suppressed by the raw-window percentage.
- Gate the built-in policy behind isinstance(ContextCompressor): external
context engines own compaction policy (#44439), so plugin engines keep
the plain suggestion untouched.
- The non-viable explanation now names the recomputed trigger instead of
hardcoding the 75%/512K wording, so it stays accurate when the
reservation (not the percentage floor) is what makes the value
unreachable.
Tests: reservation-viability regression and plugin-engine passthrough,
per the review; the floored-branch assertion updated to the recomputed
number.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The auxiliary-compression feasibility warning computes its
compression.threshold suggestion as aux_context / main_context,
independently of ContextCompressor._effective_threshold_percent()'s
raise-only small-context floor. For main windows under 512K the floor
raises any configured value below 75% back up, so a suggestion like
'threshold: 0.40' is silently ignored and the same warning returns every
session.
Derive the suggestion's viability through the compressor's own floor
logic: offer the 'lower the threshold' option only when the floored value
still fits the auxiliary model's context; otherwise recommend only a
larger compression model and explain the floor, so the guidance is always
actionable.
Tests: the updated auto-correct test pins the floored branch (no
threshold suggestion, floor explained); two new tests pin the surviving
suggestion at/above the floor on a small window and below 75% on a
512K+ window where no floor applies. The updated test fails against the
previous code.
Fixes#67422
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Children inherit the parent's env, repo, and toolsets but were denied
execute_code ('children should reason step-by-step, not write scripts').
That forces subagents doing mechanical multi-step work (batch file
reads, fetch-N-pages loops, filter-before-context reductions) to burn
reasoning iterations one tool call at a time.
- Remove execute_code from DELEGATE_BLOCKED_TOOLS
- Stop stripping the code_execution toolset from child bundles
- No recursion risk: the sandbox bridges only the 7 SANDBOX_ALLOWED_TOOLS
(web/file/terminal) — delegate_task and execute_code itself are not
reachable from inside a sandbox script
- Update schema text, AGENTS.md, and delegation docs
- Tests: blocked-constant, strip, and child-assembly tests updated;
new test pins execute_code as intentionally unblocked
Consolidated defensive layer for the invalid_blocks / msg_too_long bug
class (#56615, #62054, #53693): one malformed or oversized block fails
the ENTIRE chat.postMessage / chat.update call, so approval cards never
update and messages silently drop.
New block_kit.sanitize_blocks() is applied at every call site that
attaches blocks (send/edit via _maybe_blocks, send_exec_approval,
send_slash_confirm, and the approval/slash-confirm chat.update button
handlers). It:
- truncates section/context text to the 3000-char cap with an ellipsis
(covers interaction payloads where Slack's HTML-escaping of < > &
inflates text past the limit budgeted at send time)
- truncates header text to its 150-char cap
- drops empty blocks (no text / elements / rows)
- replaces null table column_settings entries with {} and trims
default trailing entries (Slack requires every entry be an object)
- caps the payload at Slack's 50-block maximum
- returns None when nothing valid remains so callers fall back to the
plain-text payload; never raises
Also registers contributor mappings for the salvaged PRs in this
cluster (tw0316, kamonspecial, sowork-skills).
Slack's chat.update enforces the same ~40k character limit as
chat.postMessage, but edit_message sent the formatted text unchunked —
an oversized edit failed outright with msg_too_long and the message
was never updated. Unlike send() we cannot split an edit into multiple
messages, so truncate to MAX_MESSAGE_LENGTH via the shared chunker
(which preserves code-block boundaries) and keep the first chunk.
Reapplied from PR #33224 (targeted the pre-plugin gateway/platforms/slack.py
path) onto plugins/platforms/slack/adapter.py.
Slack API returns `no_text` error when `chat.postMessage` is called
with empty or whitespace-only text. This happens when cron delivery
posts a response before the agent produces content (e.g. malformed
turn before [SILENT]).
Add early-return guards in both code paths:
- `_standalone_send()`: out-of-process cron delivery via Web API
- `send()`: in-process gateway adapter
Both paths now skip the API call and return success when the formatted
message is empty or whitespace-only.
Fixes#52663
Slack re-escapes HTML entities in the interaction payload
(< -> <, > -> >, & -> &), inflating the section text
past the 3000-char Block Kit limit when the button handler
rebuilds updated_blocks for chat.update.
The send path already budget-truncates, but the click handlers
used the echoed original_text verbatim. Cap it to 3000 chars
in both _handle_approval_action and _handle_slash_confirm_action.
Fixes#53693
assert statements are stripped when Python runs with -O flag. Replace
the assert cur is not None in _rich_text_list_block() with an explicit
if/continue guard. The assert is technically unreachable (first loop
iteration always sets cur), but using assert for invariant checking in
production code is fragile under optimization.
Slack rejects a rich_text_section / rich_text_preformatted / rich_text_quote
whose elements list is empty or contains a zero-length text element, and a
header whose plain_text is empty. The Block Kit renderer emits exactly those
shapes for common inputs: a markdown table with a blank cell or a ragged
(short) row, an empty fenced code block, a blank quote line, an empty list
item, and an emphasis-only header ("# ***"). Any one of them poisons the
whole payload — chat.postMessage fails with invalid_blocks ("missing element"
/ "must be more than 0 characters") and the message loses its rich rendering
entirely.
Route every rich_text builder's child elements through _nonempty_elements
(drop zero-length text elements; substitute a single space when nothing
remains) and skip headers that reduce to empty after markdown-marker
stripping. Observed in production via live chat.postMessage rejections;
regression tests cover each case plus a well-formed-content control.
Complementary to #56618 (column_settings hardening + no-blocks retry): that
change makes Block Kit rejections recoverable, this one makes the common
empty-content cases render correctly in the first place. No overlapping hunks.
Follow-up to the salvaged #28840 change: forward original timestamps in
_persist_branch_seed (TUI first-turn branch persist), add behavioral
round-trip tests for branch copy and compression-style replace, TUI
protocol tests asserting session.branch and _persist_branch_seed forward
timestamps, and register the contributor email mapping.
The salvaged deferred-finalize wiring (#65670) covered cli.py, the gateway
slash command, and the three in-process tui_gateway/server.py compress
sites — but the dashboard compute-host isolated session.compress /
slash.compress routes run the compress mirror inside the host child, where
a control-handler exception after compress_context() queued the boundary
notification would leak it: never fired on success paths, or worse, fired
by a LATER compress against a boundary the host had rejected.
- tui_gateway/compute_host.py: _handle_control discards any pending
context-engine compression notification (committed=False) when a
session.compress / slash.compress control frame errors. finalize is
exactly-once, so this is a no-op when the mirror already emitted or
discarded it.
- tui_gateway/server.py: _mirror_slash_side_effects normalizes the
/compact alias onto the compress branch so isolated-session compacts
actually compress and hit the same finalize/discard wiring instead of
silently no-oping.
- tests/tui_gateway/test_compute_host_phase1.py: compute-host route tests —
commit-then-notify ordering, discard on host commit failure, /compact
alias routing.
- tui_gateway commands.catalog: skip _TUI_EXTRA entries that collide with a
registry command or alias (the /compact class of bug, #57133; also removes
the pre-existing /sessions duplicate) — registry entry is canonical.
- apps/desktop: /compact now dispatches as an alias of /compress (matching
the registry's canonical alias) instead of dead-ending; /density (the
renamed TUI display toggle) is marked terminal-only so the desktop palette
doesn't advertise a command its dispatcher can't run.
- ui-tui/README.md: document /density instead of the old /compact toggle.
- tests: commands.catalog regression test asserting no duplicate advertised
names and no command shadowing another command's alias; desktop routing test.
- acp_adapter/server.py: rename compact -> compress for context compression command
- tui_gateway/server.py: rename /compact -> /density for display density toggle
- ui-tui/core.ts: rename compact -> density for display density toggle
- Internal config keys (tui_compact) and UI state (ctx.ui.compact) unchanged
Bot-posted alerts (Honeycomb, PagerDuty, Datadog, GitHub bot, etc.) carry
their actionable content — section text, button URLs — in Block Kit
blocks, while the plain text field holds only the alert title.
_fetch_thread_context and _fetch_thread_parent_text only read
msg.get('text'), so that content never reached the agent.
Add a _render_message_text helper that merges top-level text with
readable block content, section/header/context text, actionable URLs,
and (folded in from #61261 during conflict resolution) legacy
attachment fields, and use it for thread-context and parent-text
rendering.
Salvaged from #29541.
`_fetch_thread_context` and `_fetch_thread_parent_text` only read each
message's plain `text` field, so messages posted by apps (Alertmanager,
Grafana, PagerDuty, CI bots) — which carry their content in legacy
`attachments` or Block Kit `blocks` with an empty `text` — were dropped
entirely. When such a message *starts* a thread (e.g. an alert), a bot
mentioned mid-thread to investigate sees an empty thread and can only ask
"what should I investigate?".
Fall back to the existing `_extract_text_from_slack_blocks` and a new
`_extract_text_from_slack_attachments` helper when `text` is empty, so
app-posted alerts and notifications are visible in fetched thread history.
Adds TestThreadContextAppMessages (attachment-only, blocks-only, and
empty-message cases).
Closes#52387
Slack messages can carry the bot @mention only inside Block Kit `blocks`
(a `rich_text` section with a `user` element), with the flat top-level
`text` containing just a fallback string. Both mention gates read only the
flat `text`:
- the `allow_bots: mentions` bot-message filter, and
- the `is_mentioned` channel router (`routing_text = original_text`)
so such messages were silently dropped — `allow_bots: mentions` was
effectively non-functional for Block-Kit senders, and the same blind spot
hit `require_mention` / `strict_mention`.
Add `_collect_slack_block_mentions()` (walks blocks, recovers `<@UID>`
tokens from non-quoted rich-text `user` elements) and
`_slack_mention_detection_text()` (flat text + recovered mentions). Both
gates now use the merged detection text. Mentions nested in
`rich_text_quote` are deliberately ignored, preserving the existing
contract that quoted/forwarded content can't trick the bot. Also emit a
debug line when a bot message is dropped so silent drops are diagnosable.
Tests: 4 new cases in tests/gateway/test_slack_mention.py (Block-Kit
mention recovered, flat-text passthrough, no-mention, quoted-mention
ignored). 275 slack tests pass.
Main no longer catches TypeError to fall back — _supported_compression_kwargs
inspects the engine signature and calls once with only accepted kwargs.
Rework the salvaged test to assert a single correctly-shaped call while
keeping heartbeat start/stop and lock-release coverage.