Adds an optional skill so Hermes can build a custom openWakeWord model for a
phrase other than the bundled "hey hermes" (defaults to "hey <profile>"). The
skill drives openWakeWord's own trainer via the terminal tool — no heavy
training stack ships in core, and the existing resolver already loads the
resulting .onnx by path, so there are zero changes to the wake runtime.
- SKILL.md: end-to-end procedure (synthetic positives, augmentation/negatives,
train, export into the profile's ~/.hermes/wakewords/, wire config, restart).
- scripts/generate_positives.py: OpenAI-TTS positives (default when
OPENAI_API_KEY is set) with import-lazy synthesis; pure planning helpers.
- scripts/make_training_config.py: emits the openWakeWord training YAML.
- references/platforms.md: Colab fast path + macOS caveats + dataset sources.
- tests: frontmatter contract + pure helper logic, stdlib-only, no network.
- docs: point the wake-word "different phrase" section at the skill.
From #53378: ships hey_hermes.onnx/.tflite (openWakeWord pipeline,
Apache-2.0) under tools/wakewords/, resolves the default (and hey_hermes
aliases) to the bundled file, ensures openWakeWord base feature models
are fetched for custom paths too, and updates config defaults + docs
from hey_jarvis to hey hermes.
Regenerate uv.lock for the [wake] extra (openwakeword, pvporcupine,
onnxruntime) so uv lock --check passes. Replace angle-bracket URLs in
wake-word.md with markdown links — MDX treats <https://...> as JSX.
start_new_session was respected only by the CLI; the TUI and desktop GUI
always opened a fresh session on wake, ignoring the config. The gateway
now carries the flag in the wake.detected payload and both clients honor
it (open a fresh session vs. continue the current one), matching the CLI.
Two bugs surfaced by the desktop wake conversation:
1. Runaway loop: wake -> voice -> resume -> wake fired again within
~200ms. openWakeWord keeps its rolling feature buffer across
pause/resume, so on resume it immediately re-scored the "hey jarvis"
captured before the pause and re-fired, reopening a session and
restarting voice in a tight cycle. Reset the engine buffer on every
detector (re)start so resume begins from clean audio.
2. Empty-transcript toast: a silent re-listen returns
success:false / "… STT returned empty transcript", which the desktop
transcribe endpoint turned into a 400 -> thrown error -> "Voice
transcription failed" notification on every silent gap. Treat an empty
transcript as no-speech: return {ok, transcript: ""} so the voice loop
quietly re-listens. Real failures still 4xx/5xx.
Wake opened a fresh session but voice didn't start: the start intent was
a fire-once window CustomEvent, and the fresh-session remount tore down /
recreated the composer's subscription, so the deferred dispatch landed in
the gap and was lost.
Replace it with a latched nanostore ($voiceConversationStartRequest +
takeVoiceConversationStart): the controller sets it on wake.detected, and
the composer claims it once on (re)mount when the gateway is open, waiting
out any transient `disabled`. Drop the now-unused composer voice-start
window event.
Ending a voice conversation manually left the wake detector paused for
good, so the wake word couldn't be used again. The composer paused the
detector on voice start but only resumed on the voiceConversationActive
-> false render; if ending voice tore the composer down first, that
render never landed and the resume was skipped.
Resume on unmount as well (latched on wakePausedRef so it fires exactly
once), and stop early-returning when the $gateway atom is momentarily
null. Add wake.pause/resume INFO logs for visibility.
write_json routes via the request-scoped transport ContextVar, but the
wake detector's callback runs on a background thread where that var is
unset — so wake.detected fell back to _stdio_transport and was dumped to
the backend's stdout (visible as raw [hermes] {...} frames in desktop
logs) instead of crossing the desktop/dashboard websocket. The TUI was
unaffected because it IS stdio.
Capture the arming request's transport at wake.start and bind it around
the emit in _wake_on_detect so the background thread routes to the right
peer. Re-armed on each wake.start, so reconnects pick up the new socket.
The GUI armed the detector (wake.start) and the gateway fired
wake.detected, but the desktop never reacted: detection was wired through
a side-registered gatewayRef.current.on('wake.detected', …) listener that
was instance/timing-fragile (and silently dead across reconnects/HMR),
even though the raw events were arriving on the socket.
Route wake.detected through handleGatewayEventWithWake — the same onEvent
pipeline every gateway socket already feeds via useGatewayBoot — and open
a fresh session + start back-and-forth voice there. Drop the separate
.on() listener; the open-effect now only arms wake.start.
The detector logged listen/detect/close at debug, invisible at the
default level. Promote listen-start, phrase-detected, stream-closed, and
the wake.start outcome (disabled / unavailable / listening) to INFO, and
log wake.detected emission, so a non-triggering setup is diagnosable from
gateway/gui.log without flipping global log levels.
On wake, the desktop GUI now opens a fresh session AND starts the
browser voice conversation (continuous, with TTS), matching the CLI/TUI
hands-free flow instead of just opening a session.
- Add an explicit requestVoiceStart() intent to the composer bus
(idempotent start; toggle could stop an active loop).
- Composer owns mic hand-off: pause the server-side wake detector while
the browser voice loop is live, resume after (server no-ops when the
wake word isn't armed) — via the $gateway store accessor.
- Controller fires startFreshSessionDraft() + requestVoiceStart() on
wake.detected.
Makes the wake word a tri-surface feature with one configurable owner.
- wake_word.surface ("auto" | "cli" | "tui" | "gui") + shared
wake_surface_enabled() gate consulted by every surface, so exactly one
place owns the listener and the new session it opens.
- tui_gateway: wake.start/stop/pause/resume/status RPCs + a wake.detected
event, sharing one server-side detector for both TUI and desktop. The
detector yields the mic to voice.record (pause on capture start, resume
on terminal) and to the desktop's browser mic (wake.pause/resume).
- TUI (Ink): arm wake.start on gateway.ready; on wake.detected open a
fresh session and start voice capture.
- Desktop (Electron): arm wake.start on connect; on wake.detected open a
fresh session.
- CLI now gates on wake_surface_enabled("cli"); /wake status shows surface.
- Tests for the surface gate; docs cover the surface knob + cross-surface.
Adds an opt-in, on-device hotword listener for the CLI. With
wake_word.enabled (or /wake on), Hermes listens in the background for a
wake phrase; on detection it starts a fresh session, captures one
utterance through the existing voice pipeline, and answers — the
"Hey Siri" pattern.
- tools/wake_word.py: provider-pluggable detector (openWakeWord, free
local default; Porcupine, premium) over the shared 16 kHz sounddevice
capture path. Background daemon thread with pause/resume so it yields
the mic during a voice turn.
- CLI wiring: startup listener (off-thread), on-wake flow, an idle
watchdog that resumes the detector after each turn, cleanup hook, and
a /wake [on|off|status] command.
- config.yaml wake_word section; PORCUPINE_ACCESS_KEY as an optional
secret. Engines lazy-install via the [wake] extra.
- Hands a transcript to the input queue exactly like voice mode, so no
system-prompt/cache mutation. No new core model tool.
- Tests (mocked, no live audio/network) + feature docs.
Audited every reference against ground truth (COMMAND_REGISTRY, argparse
--help output, TOOLSETS dict, provider profiles, DEFAULT_CONFIG) and
corrected drift the restructure had carried over verbatim:
- slash-commands.md: rebuilt from the live registry. Removes the phantom
/skill command (never existed — skills load via /skills, hermes -s, or a
skill's own /<name> command) and the wrong /q alias on /quit (/q belongs
to /queue) — both reported by @liuhao1024 in #50608 and @gauravsaxena1997
in #50613. Adds ~25 real commands that were missing (/learn, /memory,
/pet, /hatch, /bundles, /moa, /suggestions, /blueprint, /whoami, …) with
correct aliases and CLI/GW scoping.
- cli-reference.md: verified flags (-z/--oneshot, --tui/--cli, --safe-mode),
real subcommand sets for config/setup/sessions/skills/gateway/mcp/profile/
auth/webhook/cron/skin/pets, added missing top-level commands (fallback,
project, moa, logs, console, hooks, security, backup); condensed tables.
- providers-and-models.md: rebuilt from the 36 shipped provider profiles
with their actual env vars (was 21 rows, several stale); folds in the
alias system and fallback-chain command.
- configuration.md: toolset table matches TOOLSETS (adds coding,
computer_use, video_gen, context_engine, project; drops nonexistent
messaging/rl), config sections match DEFAULT_CONFIG, STT/TTS provider
lists match the shipped set.
- background-systems.md: curator verbs match the argparse surface.
- troubleshooting/security-privacy/windows-quirks/contributor-guide: fixed
dangling 'section above/below' references from the body split; moved the
reset-permissions playbook to security-privacy.md (its routing home).
Reported-by: liuhao1024 <sunsky.lau@gmail.com>
Reported-by: gauravsaxena1997
Flips the default fan-out cadence from per_iteration (advisors re-run on
every tool iteration, multiplying advisor spend by tool-loop depth) to
user_turn (advisors run once on the first message of each user turn; the
acting aggregator works the rest of the tool loop with that turn's
advice). Until per-mode benchmarks justify a costlier default, MoA
defaults to the cheapest, lowest-impact cadence (#67199).
One default for everyone — no split legacy/new-preset semantics; presets
that want per-step advising set fanout: per_iteration explicitly. All
three modes (user_turn / per_iteration / every_n:N) remain selectable;
every_n:1 still collapses to per_iteration (semantic identity), while
unparseable values now fall to user_turn (the default).
Docs updated with a default-change note; the per-iteration rerun test
pins its mode explicitly.
Co-authored-by: skyer-flyyy <188930297+skyer-flyyy@users.noreply.github.com>
Every SKILL.md description over 60 chars was silently truncated to
57 chars + '...' in the system-prompt skill index
(extract_skill_description, agent/skill_utils.py), destroying the
routing signal for 69 of 179 skills — some descriptions ran to
1,005 chars.
Rewrites follow the authoring standard: <=60 chars, one sentence,
ends with a period, trigger front-loaded, no marketing words, no
skill-name repetition. Excess detail already lives in each skill's
body.
Includes the touchdesigner-mcp trim from PR #32361 (credit:
@JeliTron) and aligns with the router-precision direction of PR
#48780 (@John-Lussier). Docs catalogs + per-skill pages regenerated
via website/scripts/generate-skill-docs.py.
Co-authored-by: JeliTron <287797501+JeliTron@users.noreply.github.com>
The skill-authoring guide and curator prompt both reference
descriptions as the primary discovery mechanism but never mentioned
the 57-char system prompt truncation. Add explicit guidance:
- Authoring guide: frontmatter docs, template comment, size limits,
pitfall #3 with good/bad examples, verification checklist
- Curator prompt: parenthetical noting the 57-char window when
writing umbrella skill descriptions
When a skill is created or edited with a description longer than
SKILL_PROMPT_DESC_LIMIT (60 chars), the tool response now includes a
system_prompt_preview field showing exactly what the system prompt
skill index will display. This gives the agent immediate feedback to
self-correct truncated trigger phrases.
Also adds tool schema guidance about the 57-char window and fixes a
stale docstring in skill_commands.py that incorrectly claimed the
system prompt renders the full description.
The system prompt skill index truncates long descriptions to 57 chars,
but this limit was a hardcoded magic number. Extract it as a named
constant and factor the normalization logic into a shared private
helper so the extraction function and the new truncation predicate
cannot drift.
No behaviour change — pure refactor.
Follow-up on the salvaged commit: the xurl skill loads even when x_search
isn't registered (check_fn-gated on xAI credentials), so per the
cross-toolset reference rule the skill must not name it. Replaced the
skill's x_search routing block and workflow step with skill-native
wording (raw engageable posts, authenticated context, write-evidence
rule). The cross-surface comparison stays on surfaces where both are
known to exist: x-search feature docs, toolset description, tools-config
setup note, and the x_search tool schema (kept generic, no tool names).
Rewrote the routing tests to pin the placement contract, including that
the skill never names credential-gated surfaces.
Make the x_search / xurl boundary explicit in the skill, feature docs,
toolset metadata, setup note, and reference pages, while keeping the
model-facing x_search schema generic (no static xurl name).
Regression tests assert behavioral routing invariants rather than frozen
prose snapshots. Drop the stale CI-only plugin/hangup hunks already on
main so this rebases cleanly.
skills/computer-use/SKILL.md sat at the root of the bundled skills
tree as an uncategorized single-skill directory. Move it to
skills/autonomous-ai-agents/computer-use/ alongside claude-code,
codex, opencode, and hermes-agent.
- git mv skills/computer-use -> skills/autonomous-ai-agents/computer-use
(history preserved)
- root-level-skill docstring examples (commands.py,
generate-skill-docs.py) now use a generic placeholder instead of
naming a specific skill, since categorizing root-level skills is
ongoing
- docs: move the bundled page to autonomous-ai-agents-computer-use,
update sidebars.ts, skills-catalog.md, and the two skill-path
references in features/computer-use.md
E2E validated: fresh sync_skills() copies to the new nested path;
existing installs with the old flat copy keep it untouched (manifest
name-keyed, hash unchanged -> skipped, no duplicate);
_get_category_from_path resolves 'autonomous-ai-agents'; docusaurus
build green; 396 targeted tests pass.
The 'queues an Enter-submitted draft while compaction is active' test
pastes a large message to push the session over the fixture's 22k
threshold_tokens. At repeat(500) the payload is only ~4k tokens — the
other ~18k came from the ambient system prompt (tool schemas + skills
index + memory), leaving the trigger margin-less. The hermes-agent
skill hub restructure (e3d524b482) shrank the bundled skills index by
~160 tokens and dropped the total just under threshold: compaction never
started, waitForHeldCompletion() hung, and the test timed out at 90s on
every branch since — including main (first red run: 9a4d1a0130).
Bump the payload to repeat(1500) (~12.4k tokens, total ~30k) so the
test crosses the threshold on its own weight with ~8k tokens of margin,
and document the invariant so the next prompt-weight change doesn't
resurrect this.
A direct run (cronjob action='run' / webhook-triggered manual fire) takes
the job's fire_claim and advances next_run_at. When it races the external
provider's scheduled fire for the same occurrence, Chronos loses the claim
and — by design — does not re-arm (the winner owns the re-arm). But the
direct-run winner never notified the provider, so the NAS one-shot for the
consumed occurrence was left stale forever and the recurring job silently
stopped firing.
Observed in production: a managed 1-minute review job stalled for 20 hours
because a GitHub-webhook direct run claimed the job 2s before the Chronos
fire arrived; every subsequent occurrence was orphaned while /api/status
stayed green.
Fix: after a *claimed* direct execution completes (success or failure —
next_run_at advances at claim time either way), call
_notify_provider_jobs_changed_safe() so the active provider re-arms the
post-run next_run_at. No-op for the built-in ticker; claim-lost direct
runs still never notify (the winning scheduler owns the re-arm).
Claude Code's /simplify was renamed away, community-restored, and rebuilt
since our 3-agent port (v2.1.63 -> v2.1.154+). This brings simplify-code
in line with the current upstream design, re-expressed in our own wording
and layered onto our existing risk-tier/confidence machinery:
- New Reviewer 4 (Altitude): flags band-aid fixes layered on shared
infrastructure — special cases, symptom patches with unfixed sibling
sites, workaround stacks — and points at the deeper mechanism fix.
Matches our AGENTS.md 'fix the class, not the site' rubric.
- Explicit cleanup-vs-bug-hunt boundary: this skill improves working
code; correctness review stays with requesting-code-review.
- Inline single-pass fallback when delegate_task is unavailable (leaf
subagents, delegation disabled) — previously the skill just broke;
now all four angles run sequentially with honest disclosure.
- Finding format gains a concrete-cost field.
- Efficiency reviewer adds closure-capture scope-retention leaks.
- Pitfalls: fan-out cap 3 -> 4, band-aid-vs-deliberate-boundary caveat,
no-bug-hunting drift guard.
Kept our value-adds upstream lacks: SAFE/CAREFUL/RISKY tiers,
Chesterton's Fence via git blame, dry-run/focus/scope modifiers.
With the built-in x_search tool (xAI-backed, returns a synthesized
answer) now shipping, the xurl skill's plain 'searching posts' wording
no longer disambiguated the two search surfaces. Describe xurl search
in its own terms — authenticated X index query returning raw post JSON
with IDs suitable for immediate engagement — so agents route correctly
whether or not x_search is enabled. No cross-toolset reference added,
per the schema/skill cross-reference rule.
Add two pitfalls discovered when running the skill against a fresh
Jupyter server:
- Pitfall #9: When the websocket reply channel hangs on every execute
even though the kernel actually ran (REST shows execution_state=idle
and execution_count increments), force zmq transport with
--transport zmq. The zmq transport uses jupyter_client directly and
sidesteps the broken websocket layer.
- Pitfall #10: A fresh ServerApp rejects POST /api/sessions with
"_xsrf argument missing from POST" unless you start it with
--ServerApp.disable_check_xsrf=True. Needed for REST-only flows
where no browser/cookie is establishing the XSRF token.
The yuanbao skill is platform-specific guidance for Tencent Yuanbao
group chats — niche for the default bundled set. Per the
'when in doubt, optional' rule, ship it as an optional skill.
Install via: hermes skills install official/yuanbao/yuanbao
The nano-pdf description repeated the skill name ('via nano-pdf CLI'),
violating the skill authoring standard, and spent its char budget
restating the name instead of distinguishing the skill from the
structural pdf skill. New description: 'Edit text in existing PDFs via
natural-language prompts.' (56 chars) — no name repetition, and
'text in existing PDFs' contrasts cleanly with pdf's
'Create, merge, split, fill, and secure PDF files.'
Propagated to the auto-generated docs pages and zh-Hans locale
(catalog row + per-skill page) for locale parity.
Per the 'when in doubt, optional' rule — zero-shot image segmentation
via SAM is a niche computer-vision capability with heavy deps
(torch, transformers), not something most users load.
Install via: hermes skills install official/mlops/segment-anything
Both are heavy-dep, GPU-bound local music-generation skills (8-16GB VRAM,
torch stacks, manual source patches) that were bundled in two different
categories (media/ and mlops/models/). Per the 'when in doubt, optional'
rule they now sit side by side in optional-skills/creative/, and the
bundled songwriting-and-ai-music skill points at them for local generation.
- git mv skills/media/heartmula -> optional-skills/creative/heartmula
- git mv skills/mlops/models/audiocraft -> optional-skills/creative/audiocraft
- cross-link related_skills both ways + songwriting-and-ai-music
- new section 10 in songwriting-and-ai-music with install commands
- docs: bundled pages -> optional pages (en + zh-Hans), catalogs, sidebar
Install via:
hermes skills install official/creative/heartmula
hermes skills install official/creative/audiocraft
The hermes-agent skill body was a 51KB monolith loaded in full on every
trigger. It is now a lightweight hub (~12KB): identity, quick start, the
tmux orchestration guide (kept in-body — the autonomous-ai-agents category
contract), surface orientation, and hard invariants, with a routing table
into 18 reference files that carry the depth.
Absorbed four Hermes-specific skills as first-class references so their
content gains a discoverable home and room to grow without bloating any
primary body:
- skills/hermes-themes -> references/themes.md + templates/skin.yaml
- skills/hermes-desktop-plugins -> references/desktop-plugins.md + templates/plugin.js
- skills/productivity/tui-widgets -> references/tui-widgets.md + templates/clock.mjs
- skills/productivity/petdex -> references/petdex.md
New references extracted from the old body: cli-reference, slash-commands,
providers-and-models, configuration, project-context-files,
security-privacy, background-systems, windows-quirks, troubleshooting,
contributor-guide (native-mcp and webhooks already existed). Also commits
delegate-task-concurrency-diagnosis and portal-auth-for-third-party-apps,
which the old body referenced but the repo never shipped.
Description updated to cover the widened scope:
'Use, configure, theme, extend, and orchestrate Hermes Agent.' (60 chars)
skills/dogfood/SKILL.md sat at the root of the bundled skills tree,
making it one of the few uncategorized skills (Discord /skill
autocomplete listed it under 'uncategorized'; hermes_cli/commands.py
cited it as the example). Move it to
skills/software-development/dogfood/ alongside the other QA/testing
skills (test-driven-development, systematic-debugging,
requesting-code-review).
- git mv skills/dogfood -> skills/software-development/dogfood
(history preserved)
- fix test fixture path in tests/tools/test_browser_console.py
- update root-level-skill docstring examples (commands.py,
generate-skill-docs.py) to cite computer-use, which is still root-level
- drop 'dogfood' from the category list in hermes-agent-skill-authoring
SKILL.md (en + zh-Hans docs mirrors)
- docs: regenerate/move the bundled page to
software-development-dogfood (en + zh-Hans), update sidebars.ts,
skills-catalog.md, and the adversarial-ux-test related-skills link
E2E validated: fresh sync_skills() copies to the new nested path;
existing installs with the old flat copy keep it untouched (manifest is
name-keyed, hash unchanged -> skipped, no duplicate);
_get_category_from_path resolves 'software-development'; docusaurus
build green.
Maintainer scoping decision for the #51226 salvage: document that
select_context() is for engines that must REPLACE per-request context
(retrieval/routing) — pre_llm_call is inject-only by documented cache
design; that observation-only plugins should implement a MemoryProvider
(sync_turn) rather than a context engine, with on_turn_complete scoped
as the observation mirror for engines that already select; and that a
non-no-op select_context naturally changes the prompt-cache prefix on
turns where the selection changes — engines should return stable
selections when nothing changed.
Verification follow-up for the #51226 salvage: the host call site guarded
select_context with hasattr(), but the ABC defines a default on every
engine, so the built-in ContextCompressor (and any non-implementing
engine) still paid per-request shallow copies of the conversation
history plus a hook call on every provider request. Identity-check the
bound method against ContextEngine.select_context and return the
request untouched — mirroring the existing base-method short-circuit in
_notify_context_engine_turn_complete — so the default path does zero
work, not just produces an identical result.
Adds two pins: the base no-op is never invoked (patched-to-raise base
stays silent), and ContextCompressor.__dict__ contains neither new verb.
Also registers the contributor email mapping for @chaos-xxl.
- website guide: on_turn_complete() now carries the same best-effort coverage
caveat as the ABC docstring (fires from the finalization seam; abnormal
early-return paths bypass it) — removes the doc/code inconsistency.
- test: finalization seam emits on_turn_complete with usage=None + the
interrupted flag for an interrupted finalized turn. Docstring records that
the negative early-return-bypass half is best-effort and deferred to a
shared-seam follow-up rather than pinned via a full run_conversation harness.
Addresses the hermes-sweeper review on #51226:
- _apply_context_engine_selection now passes shallow copies of the read-only
conversation_messages / incoming_message to the hook, so an engine mutating
them in place cannot corrupt persisted transcript state (enforces the
request-only contract, not just documents it). Adds a mutation-regression
test asserting persisted history + incoming message are untouched.
- on_turn_complete docstring: scope the coverage claim to the standard
finalization seam. Some abnormal early-return paths (content-policy block,
provider terminal failure) currently persist+return without finalization and
don't emit the hook; documented as best-effort with a shared-seam follow-up,
rather than over-promising a guaranteed callback for every early exit.
- _apply_context_engine_selection: reject an empty list. all([]) is True, so
a [] returned by a failing/buggy engine previously replaced a valid request
with an empty message list the downstream sanitizers can't restore; now it
falls open to the unmodified request (honors the fail-open contract).
Thanks @johnnykor82 for catching this on #41918's review.
- test: empty list keeps the original request (fail-open regression).
- docs: document select_context()/on_turn_complete() in the public
context-engine plugin guide (were still describing only the old contract).
- context_engine.py: document that select_context() runs before cache-control
and all request sanitizers, so (a) replacements still pass host validation
and (b) the no-op default keeps the request byte-stable (AGENTS.md prompt-
cache invariant). Note the hook is evaluated per provider request.
- tests: no-op path is byte-stable for cache-control; a role-unusual
replacement is passed through for the existing downstream sanitizers to
normalize (select_context does structural validation only).
The on_turn_complete() observation hook is the engine's post-turn signal,
so it should receive the completed turn's canonical token usage when the
host has it, not a hardcoded None. Per @johnnykor82's #41918 contract: the
engine uses prompt/completion + cache_read/write/reasoning buckets to judge
how large/expensive the selected context was before the next select_context().
- conversation_loop.py: stash the most recent provider response's usage_dict
(the same canonical shape fed to update_from_response) on the agent as
_last_turn_usage; reset to None at turn start so turns that never reach a
provider response (early failure / interrupt) forward None, not a stale
prior turn's usage.
- turn_finalizer.py: forward agent._last_turn_usage instead of usage=None.
- context_engine.py: document the usage param contract on the ABC hook.
- tests: cover both ends through the real finalize_turn path — completed turn
forwards the full canonical bucket set intact; no-response turn forwards None.
Co-authored-by: johnnykor82 <johnnykor82@users.noreply.github.com>
Adds the post-turn observation verb as the companion to select_context():
an optional, no-op-default on_turn_complete() called once after the
assistant/tool loop finishes, with the finalized transcript snapshot. Lets
an engine ingest/index/summarize the completed turn to inform the next
select_context(). Wired via _notify_context_engine_turn_complete() from
turn_finalizer.finalize_turn(); fail-open, base no-op short-circuited so
non-implementing engines (incl. the built-in compressor) pay nothing.
This is the request-assembly + observation pair from #41918; with this
commit the PR fully subsumes #41918's two hooks (prepare_request_messages
-> select_context, on_turn_complete) rather than only the selection half.
Co-authored-by: johnnykor82 <johnnykor82@users.noreply.github.com>
Adds an optional, no-op-default select_context() hook to the ContextEngine
ABC, called every turn after the request messages are assembled and before
provider dispatch — independent of should_compress(). Lets an engine select
or replace which context enters the prompt for a single request (retrieval,
topic routing, role/branch switching) without mutating persisted history,
removing the need to abuse should_compress()=True as a per-turn callback.
The host call site (_apply_context_engine_selection) is fail-open: a missing
hook, an exception, or an invalid return value leaves the assembled request
untouched. Additive and non-breaking: the built-in compressor and every
existing engine are unaffected.
Consolidates the per-turn request-assembly surface proposed across #41918,
Related: #36765#41918#24949#47109#50053#23837#25115#29370
Routine automatic compression stays silent-by-design on chat platforms
(default unchanged, byte-identical). New opt-in config key
compression.progress_notices (bool, default false) opens a gate on the
gateway noise filter (_prepare_gateway_status_message) that lets ROUTINE
compression progress statuses through to chat surfaces.
- Membership is derived from the #69550 status template constants in
agent/conversation_compression.py (compiled to a literal-escaped regex,
never re-inlined wording), so unrelated noisy statuses (aux failures,
provider retry/rate-limit chatter) stay suppressed even when enabled.
- The compaction completion notice (COMPACTION_DONE_STATUS, #69546
lifecycle 'compacted' edge) already flows through the status path and
passes the filter — no new emit site needed.
- Config wired everywhere: hermes_cli/config.py DEFAULT_CONFIG,
cli-config.yaml.example, gateway raw-YAML read (live, mtime-cached),
gateway hot-reload cache-busting key list, website configuration docs.
- Failure notices and manual /compress feedback remain always-visible;
VISIBLE_COMPRESSION_MESSAGES and emit sites untouched.
Design by @havok-training (issue #52995).
_render_tool_calls only handled dict-shaped entries; a SimpleNamespace-
shaped tool_call (SDK-style stream-stitched responses) rendered as
'[called tool: tool]', silently losing the function name and arguments
from the advisory view. Handle both shapes (including a namespace-shaped
nested function inside a dict entry).
One-hunk hardening salvaged from closed#59712.
Co-authored-by: SquabbyZ <601709253@qq.com>
Reference models may have a smaller context window than the aggregator
(e.g. kimi-k2.7-code @ 262K advising a glm-5.2 @ 1M conversation).
Without context-length protection, a reference whose window is exceeded
gets a hard HTTP 400 from the provider, which _run_reference's
try/except silently converts to a [failed: …] note — the MoA turn
silently degrades to fewer references (#60345).
Redesigned implementation of #60387:
- Estimate AFTER the advisory system prompt is prepended, so the
request that is actually sent is what gets budgeted.
- Reserve output headroom: the preset's reference_max_tokens when set,
else an 8192-token constant, plus a 10% estimator-error fraction.
- Trim on advisory-view boundaries (text-only user/assistant turns; no
tool-result frames to orphan), preserving the system prompt, the
user-first invariant after every pop (never assistant-first), and the
trailing synthetic user turn.
- Cache get_model_context_length per (provider, model) in a per-fan-out
dict shared across the worker threads, so a turn resolves each
window once instead of probing metadata sources
per-reference-per-iteration (failures are cached too).
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Follow-ups for salvaged #56344:
- A reference that completes between the interrupt check and the reap
keeps its REAL output and accounting (the provider call billed) instead
of being zeroed with a placeholder.
- A reference still in flight at interrupt time gets a placeholder in the
results, but its future now carries a done-callback that folds the
eventual real usage/cost into the facade's pending accounting
(late_accounting_sink -> _record_late_reference_accounting), so billed
spend is never silently dropped. Pending totals are folded (not
overwritten) and guarded by a lock since done-callbacks fire on
executor worker threads.
- Interrupted placeholder results are no longer written into the facade's
turn-scoped reference cache: a cache HIT never re-runs references, so
caching a partial snapshot would replay '[skipped: interrupted by
user]' notes for the rest of the turn. The cache is left empty and the
next create() re-runs the fan-out.
agent/tool_executor.py's concurrent tool batch checks agent._interrupt_requested
and aborts the wait early; agent/moa_loop.py's _run_references_parallel had
no equivalent, so a MoA-enabled turn blocked on ThreadPoolExecutor.result()
until every reference model finished or hit its own individual
auxiliary.moa_reference timeout -- there was no way for the user to abort a
live turn mid-fanout.
Thread an optional `agent` parameter through aggregate_moa_context ->
_run_references_parallel (used when MoA references run alongside the main
model) and MoAClient/MoAChatCompletions (used when the MoA preset itself is
the acting model), then poll concurrent.futures.wait() in
_REFERENCE_POLL_INTERVAL_S slices instead of blocking on future.result() per
reference, checking agent._interrupt_requested each cycle.
Deliberately scoped to interrupt/cancel only -- no new or changed timeout
value, so this doesn't overlap open PRs #53784/#53875 (which lower the
per-reference timeout default but don't add interrupt support). `agent` is
optional and defaults to None, so any caller that doesn't pass it keeps
today's uninterruptible blocking behavior unchanged.
Extends the all-references-failed short-circuit (#56975) to the
persistent `provider: moa` facade path: MoAChatCompletions.create()
previously attached 'use the reference responses below' guidance built
entirely from failure sentinels and called the aggregator with it. Now
an all-failed turn attaches either the sanitized unavailability notice
(loud policy) or nothing (silent policy), and the aggregator — which IS
the acting model — simply acts alone. Advisor accounting for the failed
fan-out is still recorded.
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
When every MoA reference model returns a failure (HTTP error, timeout,
etc.) or is skipped by the recursion guard, the one-shot aggregator
synthesis call is now skipped entirely. Previously it would try to
synthesise a wall of failure sentinels, which could block for the full
provider timeout (observed ~6 min on SenseNova) before returning a
non-retryable error that left the session hanging.
The early return carries the sanitized unavailability notice (never raw
provider error text, per the failed-reference containment) so the main
agent loop can still act in single-model mode.
Salvaged from #56975, reworked atop the _is_failed_reference helpers.
Follow-ups for salvaged #53784:
- reference_timeout now defaults to None = no per-preset override, so the
reference fan-out inherits auxiliary.moa_reference.timeout (900s default)
via call_llm's own per-task timeout resolution. The PR's 30.0s default
would have cut off long-thinking advisors mid-response, and its 300s max
cap capped legitimate explicit values — both removed. Explicit per-preset
values are still honored as-is.
- _is_failed_reference also treats '[skipped: …]' recursion-guard notes as
internal sentinels, keeping them out of both aggregator prompts.
- Dashboard/desktop TS types updated to number | null; web_server validator
accepts null/empty as 'inherit'.
Keep MoA reference display events off the machine-readable -Q stdout
surface (platform=cli with tool_progress_mode=off) while preserving them
everywhere else. Extracts the relay into module-level helpers so the
policy is testable.
Salvaged from #67334.
Per the 'when in doubt, optional' rule — the live-kernel workflow needs
uv + JupyterLab + a running server + a cloned hamelnb repo, a niche
setup that shouldn't ship active by default.
Renamed jupyter-live-kernel -> jupyter-notebook (skill name, page slugs,
catalogs, sidebar, zh-Hans mirror, darwinian-evolver related_skills).
Install via: hermes skills install official/data-science/jupyter-notebook
Follow-up to the #67690 salvage (@m4r13y). The PR's tools/env_probe.py
hunk was written against the old capture_output=True _run(); #67964/#67999
rewrote _run to temp-file capture on July 20, so that hunk no longer
applied — but the rewritten _run still lacked creationflags and kept
flashing one console per probe (~5 per kanban worker start) from
windowless parents. Re-implement the one-line fix against the current
shape: creationflags=windows_hide_flags() on the temp-file subprocess.run,
preserving the #67964 grandchild-can't-wedge-the-pipe contract.
Also add the tests the PR didn't ship, in
tests/test_windows_subprocess_no_window_flags.py:
- env_probe._run passes CREATE_NO_WINDOW and keeps temp-file (non-PIPE)
stdout/stderr + DEVNULL stdin
- lazy_deps uv install / pip --version probe / pip install fallback /
ensurepip bootstrap all pass CREATE_NO_WINDOW
- suppress_platform_ver_console: POSIX no-op (platform._syscmd_ver
untouched, win32_ver() still returns), and simulated-Windows stubbing
(echo stub installed, idempotent, never raises)
From windowless processes (the pythonw gateway and the kanban workers it
spawns), three spawn paths flash visible console windows on Windows:
1. tools/env_probe.py::_run() ran its interpreter/pip probes
(python3 / python / pip / 'python3 -m pip' / PEP-668 check, ~5 per
worker start) without creationflags — one console flash per probe.
2. tools/lazy_deps.py had four spawn sites with the same defect:
'uv pip install', the 'pip --version' probe, ensurepip, and the
pip install fallback.
Both now pass creationflags=windows_hide_flags() (CREATE_NO_WINDOW on
Windows, 0 on POSIX) — stdio capture still works because the child is
hidden, not detached.
3. CPython 3.11's platform.win32_ver() unconditionally calls
_syscmd_ver(), which runs 'cmd /c ver' via
subprocess.check_output(shell=True) with no window suppression. Any
dependency touching platform.uname()/version()/platform() at import
time flashes one 'cmd' window per windowless process. New helper
_subprocess_compat.suppress_platform_ver_console() (Windows-only,
never raises) stubs platform._syscmd_ver so win32_ver() falls back to
sys.getwindowsversion().platform_version — verified byte-identical
platform.platform() output on CPython 3.11
('Windows-10-10.0.26100-SP0' either way). Called at the top of
hermes_cli/main.py, right after the hermes_bootstrap guard, before
heavyweight imports.
Verified on Windows 11 by polling EnumWindows at ~15 ms and attributing
new visible HWNDs to the suspect process tree (conhost child presence is
NOT evidence of a visible window — it appears even with
CREATE_NO_WINDOW). Tests: tests/tools/test_windows_native_support.py,
test_env_probe.py, test_lazy_deps.py, test_lazy_deps_durable_target.py —
153 passed; the 3 failures are pre-existing on upstream/main in a
Windows environment (POSIX-only assertions and NTFS chmod semantics).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-advisor enabled toggle adds enabled=True to normalized slots;
the JSON-string-parse and per-slot max_tokens tests from sibling
clusters asserted exact dicts. Compare against the enabled-augmented
expectation instead.
The per-reference-model enabled toggle (#59753 salvage) intentionally adds
'enabled' to normalized slot dicts. The two endpoint tests asserted the
exact key set {provider, model} — convert them to subset + round-trip
contracts so optional slot keys (enabled, reasoning_effort, max_tokens)
don't break them again.
Follow-up for salvaged PR #59753 rebased over the per-slot
reasoning_effort feature: _clean_slot now round-trips reasoning_effort
AND enabled together; add a normalize→normalize regression test, update
the validate/normalize agreement contract for the canonical enabled
default, restore the desktop per-slot toggle test on the current
autosave editor, and map oppenheimor's contributor email.
Frontend consumers for the events added by PR #59646: the TUI shows a
replace-in-place 'MoA: refs k/n' activity line (swapped for 'MoA:
aggregating…' on the aggregator phase), and desktop streams '◇ MoA refs
k/n' lines into the reasoning disclosure, self-cleaned by the first
moa.reference block.
Adds per-reference progress events and a phase-transition marker to the
MoA display pipeline so TUI / CLI / desktop surfaces can render a status
bar like `MOA: 2/3 refs done` and surface which phase (reference vs
aggregator) is currently active.
- `moa.progress` — fired once per reference completion with
`refs_done`, `refs_total`, and the source label
- `moa.phase` — fired on phase transitions (currently the single
`phase="aggregator"` transition once the fan-out
finishes)
Plumbed through the existing `reference_callback` →
`tool_progress_callback` → gateway path; no new UI surface. The legacy
`moa.reference` / `moa.aggregating` events are unchanged for backwards
compatibility.
AI-assisted fix by https://github.com/SquabbyZ/peaks-loop
Salvaged from PR #59743. Original author email was malformed
(sr@samirusani, not resolvable to a GitHub account), so the commit is
re-authored with credit via trailer.
Co-authored-by: Sami Rusani <samrusani@users.noreply.github.com>
Maintainer review (hermes-sweeper) on this PR found the fix was
incomplete: two paths still hid the MoA reference panel under
thinking: hidden.
1. thinking.tsx: the mount useState correctly seeds openThinking from
(visible.thinking === 'expanded' || reasoningAlwaysVisible), but the
re-sync effect on [visible] fires after the FIRST render too, not
just later updates, and lacks the reasoningAlwaysVisible OR — so it
immediately collapsed a just-opened MoA panel right after mount.
Skip only the effect's very first run (a ref flag); every later
visible change still re-syncs without the override, preserving the
documented no-OR-at-effect-time contract (manual collapse sticks).
2. useMainApp.ts: showProgressArea's streamSegments predicate gated
thinking content on thinkingPanelVisible alone, so an MoA reference
segment (segment.isMoaReference, same flag messageLine.tsx's
shouldShowThinkingTrail already honors per #64657) never kept the
live progress area up when thinking was hidden — StreamingAssistant
then returned early before MessageLine was ever reached. Added the
same override.
Added tests/thinkingMoaReferenceVisibility.test.tsx: mounts ToolTrail
with reasoningAlwaysVisible + sections.thinking: hidden, awaits queued
effects, and asserts the chevron is still open (▾, not ▸) once they
settle.
Validation:
npx vitest run src/__tests__/thinkingMoaReferenceVisibility.test.tsx
-> 1 passed
Fail-before: reverting only the thinking.tsx ref-guard reproduces the
exact regression -- the same test's frame capture shows the panel
open on first paint then collapsing to ▸ once the effect fires, and
the 'not.toContain(▸)' assertion fails as expected.
npx vitest run (full ui-tui suite): 1115 passed, 8 failed -- all 8
pre-existing and unrelated (terminalSetup/terminalParity/editor
resolution env-path tests), confirmed by running them in isolation
with the same result regardless of this diff.
npx tsc --noEmit: clean.
npx eslint src/components/thinking.tsx src/app/useMainApp.ts: clean.
Every moa.reference gateway event stores its labelled reference-model
output in a Msg's generic `thinking` field (turnController's
recordMoaReference), which messageLine.tsx and the ToolTrail component gate
on `display.sections.thinking`'s resolved mode. When that mode resolves to
`hidden`, MoA reference blocks were suppressed along with ordinary model
reasoning — even though (per #53855) references are the mixture-of-agents
process the user explicitly opted into, not private reasoning, and should
stay visible regardless of the thinking-section setting.
Adds Msg.isMoaReference (set by recordMoaReference), a shouldShowThinkingTrail
helper mirroring the existing shouldShowResponseSeparator pattern, and a
reasoningAlwaysVisible prop threaded into ToolTrail to bypass the two
suppression gates (the trail-wrapper return-null check and the
allHidden/panel-push checks) plus the panel's initial open state and the
shift-click expand-all gesture, so a MoA reference panel is not just present
in the tree but actually visible and openable on first paint.
Fixes#64657
Every moa.reference event called appendReasoningDelta(..., replace=true),
which wipes ALL existing reasoning-type message parts and seeds exactly one
new part. With two or more MoA reference models, each later reference
erased the reasoning disclosure built by earlier references, so only the
last advisor's output ever stayed visible instead of one labelled block per
reference (contradicting the multi-reference visibility behavior from
#53855).
Only the first reference (index <= 1, or missing) now replaces — preserving
the original "clear stale reasoning from before this turn" behavior. Every
later reference accumulates via the existing queue-then-flush path instead,
applied immediately since each reference arrives as one complete block
rather than incremental tokens.
Fixes#64658
The extracted findGitBash builds Windows-style candidate paths, but the
vitest suite (and any POSIX CI host) runs with posix path.join, which
mangles 'C:\Program Files' + segments into slash-joined paths and broke
the invalid-override fallback test. Use path.win32.join explicitly so
candidate construction is host-independent.
Port the HERMES_GIT_BASH_PATH env var check from main.cjs to main.ts
after the TS conversion. Also extract findGitBash to a dedicated module
for testability and add focused regression tests for override precedence
and invalid-override fallback.
Regression tests for the #47971 salvage: the LSP language-server spawn
must pass windows_hide_flags() creationflags while keeping PIPE stdio
and start_new_session, and the npm/go LSP auto-installer subprocess.run
calls must carry the same hide flags with DEVNULL stdin and
capture_output intact.
Salvaged from PR #47971 (LSP subset). On Windows, .cmd-wrapped language
servers (e.g. pyright-langserver.CMD launched via cmd.exe /c) and the
npm/go/pip LSP auto-installers spawn without CREATE_NO_WINDOW, so a
console window flashes whenever the spawn happens under a console-less
parent — e.g. a VS Code/Zed extension host running the ACP adapter.
- agent/lsp/client.py::_spawn: pass creationflags=windows_hide_flags()
to the language-server asyncio subprocess (inert 0 on POSIX;
start_new_session is kept — it is POSIX-only and ignored on Windows).
- agent/lsp/install.py: same flags on the npm and go installer
subprocess.run calls. The pip path goes through
hermes_cli.tools_config._pip_install, which already hides its windows.
Adapted from the PR's hand-rolled _NO_WINDOW constant to the repo's
hermes_cli._subprocess_compat.windows_hide_flags() convention.
The Windows /restart watcher's outer Popen spawns the watcher with
windows_detach_popen_kwargs() (which carries CREATE_BREAKAWAY_FROM_JOB),
but a restrictive parent job object can reject that bit with OSError and
the current call has no retry. Preserve the current watcher
implementation and add a focused breakaway-denied fallback.
Preserved from current main: watcher_python / pythonw.exe selection, the
str(restart_after_s) deadline, the scrubbed watcher_env, the intentional
no-breakaway inline respawn, and the entire POSIX setsid/bash path.
- primary keeps **windows_detach_popen_kwargs()
- on OSError, retry the same argv/env with
creationflags=windows_detach_flags_without_breakaway()
- on dual failure, log a definitive, path-safe warning (interpreter
basename + numeric winerror/errno only) and return without crashing
Replace the superseded breakaway-first inline design and its AST tests
with focused behavioral coverage that drives the real coroutine with a
mocked subprocess.Popen (retry, argv/env/DEVNULL preservation, POSIX
single-session kwarg, no-breakaway inline respawn, secret-safe logging).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds moa.privacy_filter ('' | display | full, default off — issue #59959):
- display: redact user-visible surfaces only (reference blocks emitted to
the UI + saved MoA trace records, including per-advisor full input/output
and the aggregator-input copy); the aggregator sees raw advisor text so
synthesis quality is unaffected.
- full: additionally redact the advisor text injected into the aggregator
prompt, on both the persistent facade path and the one-shot /moa
synthesis path (the issue's literal ask). Legacy boolean true maps here.
Secret/credential shapes (API-key prefixes, JWTs, private keys, DB
connection strings) are delegated to the central redactor
(agent.redact.redact_sensitive_text, force=True + code_file=True); the MoA
filter adds only email and clearly delimited phone-number patterns. No
bare 10-digit matching: line numbers, timestamps, epoch values, git SHAs,
IPs, versions, and source-code assignments in code-review-shaped advisory
text pass through byte-identical. The reference cache always holds raw
text — redaction happens at each consuming surface, so a mid-session mode
change never leaks or double-redacts.
Reworked from PR #60463: replaced its hand-rolled pattern list (which
matched bare digit runs and re-implemented key shapes) with central-
redactor reuse + safe patterns, and split the single boolean into
display/full modes. Credited for the feature framing.
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Extends the fanout enum with 'every_n:<N>' (N >= 2): advisors run on the
first iteration of each user turn and every Nth tool iteration after it;
off-cadence iterations REUSE the cached guidance from the last on-cadence
run via the same cache mechanism the user_turn fanout uses, so the
aggregator still gets advice on every step. The cadence counter is scoped
per user turn (resets on a new user message) and only advances when the
advisory state actually changes, so streaming retries never consume a
cadence slot. Mapping form {mode: every_n, n: N} normalizes to the
canonical string. Unknown/degenerate values fall back to per_iteration.
Addresses issue #63393 (advisor fan-out multiplies turn latency/cost by
the tool-iteration count). Redesigned from PR #63448: the submitted shape
skipped references entirely on off-cadence iterations (aggregator ran
advice-less); this version keeps the last advice in play, credited for
the idea and cadence framing.
Config-gated, default-off (default fanout remains per_iteration).
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Adds test_moa_gemini_aggregator_sanitize_uses_real_model: drives a full MoA
tool-call turn (virtual-provider mode) with a Gemini aggregator and asserts
the strict-API sanitize pass is invoked with the resolved aggregator model
(gemini-3-pro-preview), never the virtual preset name once a slot is
resolved — the exact path that stripped extra_content/thought_signature and
made Gemini aggregators 400 (#65092).
Writing the test surfaced a gap in the salvaged #66212 fix: in virtual-
provider MoA mode (provider=moa, no moa_config threaded through
run_conversation) the conversation-loop branch never fired because it only
consulted moa_config. Extend it to fall back to the facade's
last_aggregator_slot — the same source the handle_max_iterations fix uses —
so both MoA entry modes resolve the real aggregator model.
Also adds the contributors/emails mapping for the #15676 credit base.
Follow-ups to the salvaged core of #60293:
- Gate the x-initiator header on _normalize_aux_provider() instead of a
literal 'copilot' string compare, so slot configs spelled github /
github-copilot / github-models / copilot-acp / mixed case all get the
user-turn attribution.
- Thread extra_headers through _retry_same_provider_sync/_async so the
credential-refresh and pool-rotation retry rebuilds don't silently drop
the header (the rebuilt kwargs previously started from scratch).
- Add a transport-boundary test asserting the header reaches the SDK
client's create() kwargs (no call_llm mocking), an alias-spelling
matrix test, and a retry-rebuild preservation test.
Follow-up to the salvaged core of #53802: a naive MoAClient(preset) rebuild
restores a working facade but silently drops the reference_callback relay
wired in agent_init, so moa.reference / moa.aggregating display events stop
reaching every frontend for the rest of the session.
Introduce agent.moa_loop.build_moa_facade(agent, preset) as the single
construction point for the MoA facade and use it at:
- initial client construction (agent_init.py)
- turn-start fallback restore (restore_primary_runtime)
- transient transport recovery (try_recover_primary_transport — previously
fell through to _create_openai_client with MoA's empty client_kwargs and
died with 'api_key client option must be set')
- mid-session model switches (switch_model)
The relay reads agent.tool_progress_callback at emit time, so callbacks
attached after construction are picked up automatically.
Adds test_moa_restored_facade_still_emits_reference_events covering event
delivery through a restored facade.
When Hermes fails over from a non-Gemini provider (xAI, Anthropic, etc.) to
Gemini mid-conversation, the existing assistant tool_calls in history carry
no Gemini ``extra_content.google.thought_signature`` (the originating provider
never emits one). The native adapter's ``_translate_tool_call_to_gemini``
omitted ``thoughtSignature`` entirely in that case, so Gemini 3 thinking
models rejected every replayed turn with::
HTTP 400 INVALID_ARGUMENT
Function call is missing a thought_signature in functionCall parts.
Additional data, function call default_api:<tool_name>, position N.
The Cloud Code Assist sibling adapter already handles this exact case by
emitting a sentinel ``"skip_thought_signature_validator"`` (see
``agent/gemini_cloudcode_adapter.py:106``, originally added in #11270 and
documented as matching ``opencode-gemini-auth``'s approach). This change
mirrors that fallback in the native adapter so the two paths behave
identically when replaying cross-provider history.
Verified live against ``generativelanguage.googleapis.com/v1beta`` with
``gemini-3-pro-preview``: synthetic 2-turn conversation with no real
``thoughtSignature`` returns 400 without the sentinel and 200 with it.
Test added: ``test_build_native_request_emits_sentinel_for_cross_provider_tool_call``.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When MoA mode is active with a Gemini model as the aggregator,
agent.model holds the virtual preset name (e.g. "closed"), not the
actual aggregator model name. The _sanitize_tool_calls_for_strict_api
call uses agent.model to decide whether to keep extra_content
(thought_signature) on tool_calls — since "closed" doesn't contain
"gemini", the thought_signature is stripped and the Gemini aggregator
rejects the next request with HTTP 400 (INVALID_ARGUMENT):
"Function call is missing a thought_signature in functionCall parts."
Fix: resolve the actual aggregator model name from moa_config
(conversation_loop) or last_aggregator_slot (chat_completion_helpers)
and pass it to _sanitize_tool_calls_for_strict_api so the
_model_consumes_thought_signature check sees the real Gemini model.
Closes#65092
Bare ContextCompressor.__new__ doubles (test_compress_focus,
cross_session_guard, image_tokens, pre_compress_memory_context) skip
__init__ and lack the attribute — the documented compression-path
test-double pitfall. Guard with getattr default 1 + int type pin
(bool excluded).
Follow-up fixes on top of the salvaged #22566 mechanism:
- N-collector now counts only REAL actionable user turns via
_is_actionable_user_turn + _is_synthetic_compression_user_turn —
the same filter pair _find_last_user_message_idx uses post-#69291.
The contributor's bare role=='user' + _is_context_summary_content
check let blank platform echoes and continuation/todo rows consume
N slots, silently degrading the guarantee.
- Default flipped 3 -> 1 (behavior-preserving): a default of 3 was
measured to change the tail cut on transcripts whose budget covers
only the last turn. min_tail_user_messages=1 delegates to the
existing single-user anchor; N>1 is opt-in, and the call site is
gated so the default path is byte-identical to main.
- Hardened config parse in agent_init (bool rejected, fractional
floats rejected, floor 1) matching the max_attempts parser shape.
- Wired the recurring external-PR config gaps: hermes_cli/config.py
DEFAULT_CONFIG + cli-config.yaml.example (PR only had cli.py).
- Regression tests: blank echoes / synthetic rows don't count toward
N; tool-call/result pairs never split by the N-boundary (no-orphan
both directions); N-guarantee wins over tail_token_budget and the
_MAX_TAIL_MESSAGE_FLOOR (floor is a minimum, not a cap); default
parity pin; DEFAULT_CONFIG pin.
Add _ensure_last_n_user_messages_in_tail to guarantee the last N user
messages survive compression in the uncompressed tail, with surrounding
assistant/tool context preserved.
- Add min_tail_user_messages parameter (default 3) to ContextCompressor
- New _ensure_last_n_user_messages_in_tail method generalizes single-user protection
- Skip context-summary handoff banners when counting user messages
- User messages are clean boundaries — skip _align_boundary_backward
- Wire through cli.py, agent_init.py, and gateway cache busting keys
Config:
compression:
min_tail_user_messages: 3
Co-Authored-By: Claude <noreply@anthropic.com>
_collect_ghosted_skill_names() covers both ghost-skill shapes in the
compressed middle window: rows already demoted to a [SKILL_PRUNED: ...]
marker AND raw skill_view bodies (> _SKILL_VIEW_PRUNE_MIN_CHARS) that
survived Phase-1 inside an earlier protected tail and then aged into the
compression window — the summarizer paraphrases those instructions away
too. Shared threshold constant between the emit site and the scan.
Pinned by a live-probe-shaped test (real compress(), mocked aux LLM).
21 tests pinning the salvaged #44166 behavior:
- marker emit + extractor round trip (patterns adapted from PR #32375
by @LeonSGP43, with credit)
- no-duplicate re-injection when the canonical marker survived (the
original PR's presence-check defect)
- Phase-1 protection for just-loaded / user-referenced skills, and the
Pass-4 pressure override that keeps #61932 fixed
- deterministic marker survival through a REAL compress() with a mocked
aux LLM: drop → re-injected, keep → not duplicated, static-fallback
path, iterative re-compression via rehydrated handoff
- markers never classify as handoff content (classify_summary_content /
_strip_context_summary_handoff_message untouched)
- SKILLS_GUIDANCE Skill Safety Rule renders with real newlines
Salvage rework of PR #44166 (@dolphin-creator) onto current main:
- ONE canonical prune marker: _skill_pruned_marker(name) builds
'[SKILL_PRUNED: ... reload with skill_view(name='X')]'; both emit
sites and the survival presence check use the same string, fixing the
original PR's defect where the emitted marker was '[SKILL_PRUNED:'
but the presence check looked for '[SKILL_PRUNED]' (re-injection
duplicated markers that had survived).
- Phase-1 prune (_prune_old_tool_results) now threads a protected-skill
set: skills whose skill_view call is within the last 10 messages, in
the protected tail, or named in a tail user message keep their full
bodies. Pass-4 pressure demotion deliberately overrides the guard so
the #61932 dead-end shape cannot return.
- P2 deterministic marker survival: skill names are extracted from the
summarizer INPUT (and the previous summary) before the aux LLM call
and any dropped canonical markers are re-injected afterward under a
'## Pruned Skills' section — routed through _redact_compaction_text,
appended to the summary body only (never in front of SUMMARY_PREFIX
or scaffolding start-of-content markers; classify_summary_content is
unaffected). Same treatment on the static fallback path, re-applied
after its size cap since truncation cuts exactly where markers land.
- Summarizer prompt gains a '## Pruned Skills' copy-verbatim section.
Fixes#32106.
Surgical reapply of the marker-alignment and dedup-guidance halves of
PR #44166 commits 52341f6ca3 / 3d8a31432d / ae07412e4b onto current main:
- the [SKILL_PRUNED: ...] marker embeds the exact reload call
skill_view(name='<skill>') so the model can act without guessing
- SKILLS_GUIDANCE Skill Safety Rule gains rule 4 (DEDUP): after one
reload, remaining markers for the same skill are historical artifacts
Fixes#32106 (part).
Community verification of #56688 (zmack12344321) found two follow-up gaps
that kept Vertex invisible in the /model menu even after registry
registration:
1. hermes_cli/model_switch.py: list_authenticated_providers() had a
credential gate hard-coded to API keys (with an aws_sdk special case
only) — add a vertex branch using has_vertex_credentials(), mirroring
the aws_sdk shape.
2. hermes_cli/models.py: Vertex's OpenAI-compatible endpoint has no
/models listing route, so without a curated _PROVIDER_MODELS entry the
picker only ever showed the current model — add a Gemini curated list.
Follow-up to #56688.
The Vertex AI provider (added same-day, commit c73e74386) was never added to
either of the two provider registries that agent/auxiliary_client.py and the
MoA slot-resolution chain depend on, breaking Vertex outside the main
conversation loop:
1. hermes_cli/auth.py::PROVIDER_REGISTRY had no "vertex" entry. The
plugin-auto-extend loop that normally fills gaps explicitly skips
non-api_key auth types (`if _pp.auth_type != "api_key": continue`), and
Vertex was never hand-declared like "bedrock" is. Because
resolve_provider_client() in agent/auxiliary_client.py gates everything
on `pconfig = PROVIDER_REGISTRY.get(provider)` and returns (None, None)
immediately when pconfig is None, its `elif pconfig.auth_type == "vertex"`
branch was permanently dead code — every auxiliary Vertex call (vision,
title generation, reflection, context compression, MoA reference/
aggregator slots) failed outright, not just a MoA-specific edge case.
2. hermes_cli/providers.py::HERMES_OVERLAYS also had no "vertex" entry, so
hermes_cli.providers.get_provider("vertex") returned None. This backs
_preserve_provider_with_base_url() in agent/auxiliary_client.py, which a
MoA slot's resolved (base_url, api_key) pair needs to keep its "vertex"
identity instead of silently collapsing to "custom" — losing the
identity _refresh_provider_credentials() needs to re-mint an expired
OAuth2 token (~1h lifetime) on a 401, and permanently breaking every
subsequent call in that MoA preset for the rest of the session.
Fix mirrors the existing "bedrock"/aws_sdk entries in both registries
exactly, plus adds a "vertex" branch to _refresh_provider_credentials() (it
had branches for openai-codex/nous/anthropic/xai-oauth but not vertex,
so a 401 fell through to `return False` without evicting the stale cached
client).
- hermes_cli/auth.py: hand-declared vertex ProviderConfig(auth_type="vertex")
in PROVIDER_REGISTRY, matching bedrock's shape.
- hermes_cli/providers.py: vertex HermesOverlay(auth_type="vertex") in
HERMES_OVERLAYS + "Google Vertex AI" label override.
- agent/auxiliary_client.py: vertex branch in _refresh_provider_credentials
that re-mints the token via get_vertex_config() and evicts the stale
cached client.
- 8 new regression tests across tests/hermes_cli/test_vertex_provider.py and
tests/agent/test_auxiliary_client.py: registry membership, end-to-end
resolve_provider_client("vertex", ...) building a working client (proving
the previously-dead branch is now reachable), and the 401-refresh/cache-
eviction path.
MoaConfigPayload does not declare save_traces or trace_dir, so
set_moa_models() overwrites cfg["moa"] with a dict that lacks these
hand-edited keys. Use dict.update() to merge instead of replace.
Fixes#58819
When reference_models is stored as a JSON string (e.g. from hermes moa
configure or hand-edited config.yaml), _normalize_preset silently
falls back to hardcoded defaults because the string fails both
isinstance(x, list) and isinstance(x, dict) checks.
Add json.loads() parsing before the type checks so both formats work.
MoA (Mixture of Agents) is a legitimate internal provider used by
Diagnosis presets and multi-model aggregation. When a MoA preset sets
model.provider to 'moa', hermes doctor incorrectly reports it as
'unrecognised' and suggests changing it, which would break the MoA setup.
Add 'moa' to the known_providers set alongside 'openrouter', 'custom',
and 'auto' so doctor recognises it as valid.
Fixes#58759
The existing tests cover _normalize_moa_model() in isolation and a local
precedence expression, but not the __init__ wiring itself. Add two
init-level regression tests: constructing HermesCLI(model='moa:strategy')
strips the prefix to model='strategy' and forces requested_provider='moa',
and the moa: prefix wins over an explicit --provider. Both fail if the
override is dropped from the requested_provider resolution.
Refs #56828
hermes chat -Q -m moa:strategy failed with 'model moa:strategy is not
supported' (HTTP 401/400): the raw model string was passed straight to
the real provider. The MoA virtual provider only got wired up through the
interactive /moa command and the model picker, never through the -Q
one-shot startup path.
resolve_runtime_provider already handles requested_provider == 'moa', and
agent_init builds the MoAClient off provider == 'moa' (surface-agnostic).
The only gap was mapping the moa:<preset> model string to that provider.
Add _normalize_moa_model() and apply it in HermesCLI.__init__ before
provider resolution: a moa:<preset> model sets requested_provider='moa'
and model=<preset>, so the existing MoA path runs in non-interactive mode
too. The moa: prefix wins over an explicit --provider (previously
--provider deepseek -m moa:strategy silently dropped MoA).
Fixes#56828
Follow-ups on top of the cherry-picked #27748 mechanism:
- move the cap constant to module level with full rationale comment
(class attribute aliases it so subclasses/tests can override)
- bound the iterative-update path too: the PREVIOUS SUMMARY block is
passed through _bound_summary_input so a pathological rehydrated
handoff cannot blow up the prompt (previous summary + new turns each
capped)
- extra regression tests: byte-identical small-input passthrough
(identity), direct bound+marker unit check, bound-after-per-message-
truncation shape (hundreds of under-_CONTENT_MAX turns), iterative
path bounded, marker vs classify_summary_content non-collision
- contributor email mapping for @robgfl45
Follow-ups on top of the cherry-picked #62644 mechanism, porting it to
current main and closing the salvage-review requirements:
- proactive_prune_min_reclaim_tokens (default 4096): a prune only COMMITS
when it reclaims a meaningful token batch, measured on the pruned output.
A committed prune rewrites already-sent history and invalidates the
provider prompt-cache prefix; this hysteresis gate keeps those breaks
episodic/amortized (like a compression boundary) instead of firing every
tool iteration. 0 disables the gate. (Design point credited to the
#62389 review cycle's prune_minimum_tokens.)
- Standard no-op caller contract: every skip path returns the INPUT list
object; the loop commits only on 'result is not messages' + non-zero count.
- Loop call is getattr+callable guarded (plugin engines predating the hook,
SimpleNamespace test doubles) and exception-swallowed at debug level.
- Config parse follows the compression.max_attempts hardened semantics:
booleans rejected, fractional floats rejected, integral floats/numeric
strings accepted; negative trigger = disabled.
- cli-config.yaml.example documented (all three keys) and gateway
_CACHE_BUSTING_CONFIG_KEYS extended so hot-reload rebuilds the agent.
- Tests: min-reclaim gate both directions, input-object no-op contract,
no-orphan tool_call_id pairing in BOTH directions (#69830 pin rule),
default-off zero-behavior-change pin, config parse seam, and behavioral
loop-wiring tests (consulted/commit/no-op/absent-method/raising).
The phase-1 tool-result prune only runs inside compress(), which fires
near 50% of the context window, so it never triggers on large-window
models; old tool outputs then ride in history and are re-sent every turn.
Add prune_tool_results_only(): the same no-LLM prune on a separate, low
proactive_prune_tokens trigger, run as an elif to the compression branch.
Opt-in (default 0), protects the recent tail by message count.
Add the method to the ContextEngine base as a no-op default so pluggable
engines inherit it safely (the post-tool-call path never AttributeErrors on
a non-built-in engine); the built-in compressor supplies the real prune.
Register both keys under the top-level compression config with defaults and
document them.
Follow-up to the #54805 cherry-pick (skill guard rules):
- turn_context.py: snapshot_preflight_display_tokens gets a
getattr+callable guard (SimpleNamespace compressor doubles / plugin
context engines lack the ContextCompressor-only method) and the
snapshot value is type-pinned to a real int (bool excluded) before
arming the rollback — MagicMock compressors return truthy Mocks.
- turn_finalizer.py: interrupted pinned 'is True', the
_turn_received_provider_response read pinned 'is not True' (MagicMock
auto-attrs are truthy), and the compressor rollback method call gets a
getattr+callable guard. Rollback stays display-only: it never touches
_ineffective_compression_count or any durable guard, and preserves the
-1 post-compaction sentinel.
Interrupted turns can seed a speculative display token count before the provider receives the request. Restore that display-only seed when interruption wins the race, while preserving completed post-compaction state and treating a successful provider response independently of optional usage metadata.
Constraint: #54776 remains reproducible on current main, while review #4702305384 identifies anti-thrashing rollback as stale and usage receipt as an unreliable response-completion signal.
Rejected: Restore anti-thrashing counters from a preflight snapshot | current main derives their verdict from real provider usage after a completed compaction boundary.
Confidence: high
Scope-risk: narrow
Directive: Keep interrupted preflight rollback display-only, and never infer provider completion from the presence of usage metadata.
Tested: ./.venv/bin/python -m pytest -q tests/run_agent/test_413_compression.py (29 passed); turn-finalizer/conversation-loop tests (31 passed); context-compressor targeted tests (12 passed); infinite-compaction targeted tests (3 passed); ruff; git diff --check.
Not-tested: End-to-end interactive interrupt through CLI or gateway transport.
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).
A lock-loser compression pass returns its input unchanged, which the
automatic compression sites misread as 'cannot compress further': the
preflight loop armed the insufficient-progress blocker, the pre-API gate
burned a shared attempt, and a lock-contended 413/overflow retried into
the attempt cap and returned compression_exhausted — which the gateway
answers with a full session auto-reset (#9893/#35809). A temporary
concurrent-compression defer wiped the session.
Consume the landed #69870 lock-skip signal on every automatic path
(preflight in turn_context, pre-API pressure gate, 413 handler, overflow
handler, post-tool compaction): when a pass no-ops AND the type-pinned
lock-skip flag is set, refund the attempt (never count it toward the cap
or the insufficient-progress blocker), and when the turn cannot proceed
(provider already proved the request does not fit) end it with a soft
compression_deferred result — distinct from compression_exhausted — so
the gateway keeps the session intact and the next message retries after
the concurrent compressor finishes.
The new compression_skipped_due_to_lock() reader is type-pinned
(is True or isinstance(str)) per the MagicMock auto-attribute rule, and
compress_context() now also clears the signal at the very top of every
attempt (per-attempt state rule, #58629/#69853) so a stale value can
never make a later breaker/codex no-op look like lock contention.
Salvaged from PR #49874; rebuilt on main's #69870
_compression_skipped_due_to_lock signal instead of the PR's parallel
_compression_deferred_by_lock triple.
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>
_build_call_kwargs omitted max_tokens for every provider except
anthropic-compat endpoints and NVIDIA NIM. Gemini's native
generateContent maps max_tokens -> maxOutputTokens and, when it is
omitted, applies a fixed 65,535-token ceiling (not "the model's full
budget"), so dropping the value made MoA's reference_max_tokens a
silent no-op for gemini advisors — they ran effectively uncapped
(observed ~2900 output tokens against a configured cap of 600),
inflating per-turn MoA latency.
Forward max_tokens for the gemini-native path (provider name or native
base_url). Gemini supports maxOutputTokens, so the cap is safe here;
providers that reject max_tokens (Copilot, GPT-5 max_completion_tokens,
ZAI vision) are unaffected — they still omit it as before.
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.
Every npm workspace package now defines check:* scripts (check:unit,
check:lint, check:bundle, check:typecheck, etc.) that fan out to
separate matrix runners in CI. The check umbrella script chains all
shards for local dev.
The matrix discovery in the workspaces job queries npm workspaces,
finds check:* scripts (in package.json insertion order), falls back to
check when none exist, and emits an include matrix. No hardcoded
package names — the workflow is fully auto-derived from workspace
metadata.
Previously every package ran a single check script on one worker, and
the fix step (lint:fix + prettier) ran as a separate CI step with
special-cased run_fix gating to avoid running on every shard. Now that
lint is just another check:lint shard, the run_fix field and the fix
step are gone entirely — lint runs in its own runner like everything
else.
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
The renderer showed two assistant bubbles for one turn — a partial
streamed copy plus the clean final copy (#63679). A reload fixed it, so
the persisted transcript was correct; this was live reconciliation.
Root cause is in completeAssistantMessage (use-message-stream/index.ts).
message.interim fires for BOTH verify-on-stop candidates AND ordinary
tool-call turns (tui_gateway _load_interim_assistant_messages), and the
interim seal clears streamId + sets interimBoundaryPending. So at
message.complete the streamId fast-path is skipped and it enters the
fallback. There the settle-onto-interim branch was gated on
responsePreviewed — true ONLY for verify-on-stop. A normal tool-call
turn whose final text matched its sealed interim satisfied neither
settle branch and fell through to append a brand-new bubble. Two rows,
distinct ids, id-based dedup cannot collapse them → renders twice.
Fix: settle onto the sealed interim whenever the final CONTINUES it —
final == interim, final starts with interim (streamed + trailing delta),
or interim starts with final (streaming dropped characters). This is
gated on existing.interim so it only ever collapses a genuine sealed
interim, and a genuinely DIFFERENT final still appends as its own
bubble. responsePreviewed is retained as an OR so the verify-on-stop
continuation-budget case (final text rewritten, no shared prefix) still
settles as before.
The prior test that asserted a non-previewed identical interim+final
produced TWO bubbles was encoding the bug; rewritten to assert one.
Added coverage: prefix-extended non-previewed final collapses; a
genuinely different final still appends (no over-collapse).
Community analysis on the issue (seedSeenBubbleKeys / kind-guard) was
against a pre-refactor v0.7.0 bundle — those symbols no longer exist;
this is the current-code root cause.
Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
Gateway tests construct GatewayRunner via object.__new__ without __init__;
the new #51899 guard accessed self.config directly and crashed those
runners (AttributeError). Use getattr with None default — the guard
no-ops without config, matching the documented object.__new__ pitfall.
Note: #51899's commit was authored as a placeholder identity
(hermes-agent@example.invalid); it was re-stamped to @byshubham's
numeric noreply on this branch, so no mapping file is needed. Other
salvaged commits use GitHub noreply addresses.
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
Previously, Slack sessions and the channel directory stored raw channel
IDs (e.g. C0ATFHY907L) as chat_name, making it impossible for operators
to identify channels in send_message listings or session data.
Changes:
- Add _channel_name_cache and _resolve_channel_name() to SlackAdapter
that calls conversations.info (channels) or users.info (DMs) with
in-memory caching to avoid repeated API calls
- Use resolved names in _handle_slack_message() build_source() calls
- Use cached names in _seed_assistant_thread_session()
- Enrich session-sourced entries in channel_directory._build_slack()
by cross-referencing API results and falling back to conversations.info
+ users.info for DMs and private channels not in the bot's scope
Fixes: channel directory and session origins now show readable names
like 'general' or 'John Doe' instead of 'C0xxx' / 'D0xxx'.
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.
.get("key", {}) only applies the default when the key is ABSENT.
When the key exists with value None (null in JSON), .get() returns
None and the subsequent .get() raises AttributeError.
Fix: replace .get("key", {}).get(...) with (.get("key") or {}).get(...)
which handles both missing keys AND None values.
8 instances across 6 files:
- gateway/run.py: tool_call function name check
- acp_adapter/server.py: tool name/description extraction
- gateway/platforms/qqbot/onboard.py: API response task_id
- gateway/platforms/yuanbao.py: message content parsing (x2)
- gateway/platforms/slack.py: block text extraction (x2)
- tui_gateway/server.py: error message extraction
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
The C13 log-noise test (merged while this branch was in flight) builds a
bare SlackAdapter without __init__; the new _channel_teams ambiguity map
crashed it. getattr-guard, same pattern as the sibling defensive inits.
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.
Salvaged from #59742 (downloads half only). Main already resolves the
event-level team id from the channel→workspace cache before downloads
(the file-EVENT half was covered by #30456), but when neither the event
nor the cache knows the workspace, both download helpers silently fell
back to the PRIMARY workspace token — Slack then returns an HTML login
page instead of file bytes for any non-primary workspace file.
Slack private file URLs embed the owning workspace id
(files-pri/<TEAM_ID>-<FILE_ID>/...), so _resolve_download_token now
prefers: explicit team_id -> URL-embedded team id -> primary token.
Both _download_slack_file and _download_slack_file_bytes route through
it.
Deferred from #59742 (out of this correctness cluster's scope): the
channel→team disk persistence, the pre-send conversations.info probe
loop, and the thread-file ingestion feature.
Reapplied from #59742 by @benjamin2026-dot onto the current adapter
(original targeted the pre-#30456 download sites).
Conflict-resolution follow-up composing #68925 (Bob) with the already-
applied #20583/#66398 (jordanhubbard) recovery design:
- #68925's caller-level second _query_recoverable_session pass (via
lookup_session_key=) referenced a variable that no longer exists —
the legacy exact-key fallback now lives INSIDE
_query_recoverable_session, which also claims the legacy key once per
process and rewrites the peer row to the scoped key. Drop the dead
caller-level pass.
- Keep #68925's _recovered_row_matches_source_scope origin guard wired
into both recovery paths: a scoped channel lookup refuses rows whose
recorded origin names another workspace (or no workspace at all).
- Routing-index migration adoption policy documented at the site:
origin names a workspace -> exact match only; scope-less DM -> first
workspace claims once; scope-less channel -> refuse.
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.
Follow-up to the salvaged PR #39439:
- Replace the hardcoded English gateway-auth summary with a
notifications.errors.gatewayAuthFailed i18n key (en/ja/zh/zh-hant + types)
- Fix the malformed ['"'] regex character classes (duplicate quote) in
notifications.ts error matchers
- Add regression test: gateway_auth_failed maps to the gateway auth
summary, provider invalid_api_key still maps to the OpenAI summary (#39365)
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
The gateway ignored config when defined through
YAML (config.yaml). The API server only started when environment
variables like and were set,
even though other gateway subsections like were
processed correctly.
Two changes in gateway/config.py's load_gateway_config():
1. Merge platform configs placed directly under gateway.*
(e.g. gateway.api_server) via _merge_platform_map, matching
the existing gateway.platforms.* and platforms.* merge
paths. Only keys matching known Platform values are picked up;
non-platform keys like streaming are safely ignored.
2. Bridge api_server-specific keys (port, key, host, cors_origins,
model_name) from the top-level config block into the extra
dict so PlatformConfig.from_dict preserves them — matching what
_apply_env_overrides already does for env var values.
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.
Adds `ensure_closed_code_fences()` helper to detect text with an odd
count of triple-backtick markers (indicating an unclosed code block)
and append a closing fence.
Applies the fix to all four identified gap paths:
G1: truncate_message early-break path for final chunk
G2: _send_or_edit streaming edit path (most commonly hit)
G3: overflow split first chunk (covered by G2's fix)
G4: _send_fallback_final fallback send path
Closes: #TBD
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 parser can fail to recognize the closing * of a bold span
when it is immediately preceded by a non-word character (), ], }, ., :,
em-dash, ...), mis-rendering the span and in reported cases truncating
the rest of the message. Insert a zero-width space (U+200B) between the
last character and the closing * whenever the last character is not
alphanumeric or underscore.
Reapplied from #35144 by @gonzalofrancoceballos — the original patched
gateway/platforms/slack.py, which was migrated to
plugins/platforms/slack/adapter.py in the plugin migration.
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.
Test doubles and third-party adapter objects don't all implement the new
set_reaction_handler; calling it unconditionally hung the multiplex
secondary-reconnect test (the AttributeError was swallowed into an
awaited-forever path). getattr-guard all three wiring sites — same
duck-typing convention the sibling setters rely on for MagicMock, but
explicit, so bare objects work too.
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>
Salvaged from PR #45702 (heartbeat half). A multi-minute turn showed a
static 'is thinking...' assistant status that reads as stuck and provokes
mid-turn 'you there?' pings. Derive the fallback status label from the
turn's elapsed time (>=30s → 'still working… (NmSSs)'), riding the
existing _keep_typing refresh — zero extra API calls.
Ported onto the current plugin adapter: the start time rides the tracked
_active_status_threads entry (workspace-scoped key), so it shares the
existing bounds/eviction and resets when stop_typing clears the status.
Explicit live-status phrases (set_status_text) and configured
typing_status_text always win; only the built-in default label changes.
The PR's other half (top-level channel follow-up coalescing) is NOT
included — dispatch semantics changed on main (busy-input active-turn
redirect, #30170 demotion) and need a fresh design pass.
Refs #45702. Co-authored-by: MrAbsaroka <mrabsaroka@gmail.com>
Issue #47699 reported Slack /goal continuations being enqueued by
_post_turn_goal_continuation -> _enqueue_fifo but never drained until the
next real inbound message woke the session. On the current tree the
continuation lands in the adapter pending slot while the
_process_message_background frame is still live, so the in-band pending
drain (and the finally-block late-arrival drain) spawns the follow-up
turn automatically — the reported stall is not reproducible on main.
Pin the two halves of that contract so it can't silently regress:
1. a continuation placed in the FIFO during the handler frame is
consumed as a second turn without any new user message, and
2. the runner's goal hook enqueues under the same session key the
adapter drain resolves (key mismatch would orphan the event).
Refs #47699. Reported-by: joesu-angible
process(wait) marks a completion consumed and returns the exit code +
output inline. The gateway process watcher's agent-notify branch honored
that (skipping the synthetic agent turn), but its skip FELL THROUGH to
the plain text-notification branch, which re-sent the same completion to
the chat as a raw '[Background process ... finished with exit code ...]'
message — a duplicate delivery of output the agent had already read and
was summarizing (observed on Slack with
display.background_process_notifications: all, but platform-agnostic).
Guard the raw-notification branch on is_completion_consumed(), same as
the agent-notify branch. poll() stays read-only and never marks consumed
(#10156), so status checks still can't suppress autonomous delivery.
Fixes#65379. Reported-by: hergert
* fix(desktop): hide persisted agent-only history scaffolding
Filter verification-stop nudges and context-compaction handoffs at the
stored-history mapper boundary. Preserve a real reply when a compaction
handoff shares its stored message.
* test(desktop): build persisted E2E sessions through the real agent
Drive tui_gateway.entry over its stdio JSON-RPC transport against the mock
provider, wait for real completion events, and persist normal session history
through AIAgent and SessionDB. Migrate resume and hidden-history coverage,
including real compression and live verify-on-stop scaffolding, then remove
the unused direct SessionDB import scripts.
* fix(desktop): use the provisioned Python for real-session E2Es
Run the stdio gateway through uv's synced project environment outside the
Nix dev shell, while retaining the fully provisioned Nix Python when the
shell advertises HERMES_PYTHON_SRC_ROOT.
* fix(nix): expose the provisioned Python environment to uv
Mark the Nix-built Python environment active in the dev shell so the shared
E2E session builder can always run through `uv run --active --no-sync`.
* fix(timeline): persist typed display events
* fix(timeline): strip display-only fields from provider payloads, preserve through rewrites, fix /resume display history
Three review findings from PR #69771:
1. Provider payload leak: display_kind and display_metadata were forwarded
to the provider API as unknown message fields. Strict OpenAI-compatible
backends can reject the next request after a model switch or resumed
typed event. Strip both from the per-request api_msg copy in
conversation_loop alongside the existing api_content pop.
2. Rewrite/import data loss: _insert_message_rows preserved display_kind
but silently dropped display_metadata. After replace_messages,
archive_and_compact, or session import, async-delegation completion
events lost their task counts and fell back to generic display text.
Add display_metadata to the INSERT columns and bind tuple.
3. CLI /resume stale recap: startup --resume A set _resume_display_history
from A's lineage. A subsequent in-session /resume B loaded B only into
conversation_history via get_messages_as_conversation, leaving the stale
A display projection. _display_resumed_history preferentially read the
stale attribute, showing A's recap for B. Switch /resume to
get_resume_conversations and update _resume_display_history alongside
conversation_history.
Tests: 890 Python (5 files), 35 desktop TS — all green.
* feat(tui): render typed display events as ◈ markers in the Ink TUI
The TUI was not handling display_kind at all — model switch markers and
async delegation completions rendered as opaque user messages with the
full [System: ...] text, and hidden compaction handoffs were visible.
Wire display_kind through the full TUI chain:
- _history_to_messages (tui_gateway/server.py) forwards display_kind
and display_metadata to the gateway transcript payload.
- GatewayTranscriptMessage (gatewayTypes.ts) gains both fields.
- Msg.kind (types.ts) gains 'event' value.
- toTranscriptMessages (domain/messages.ts) maps:
- hidden → skip entirely
- model_switch → event "model changed"
- async_delegation_complete → event "N background agents finished"
(or "background agent work finished" without metadata)
- messageGroup (blockLayout.ts) routes event to its own group, with
SELF_SPACED + PAINTS_TRAILING_GAP so it owns its margins.
- messageLine.tsx renders event-kind as a dim ◈ marker with no gutter,
matching the CLI's ◈ event rendering.
- 4 new TUI tests for hidden/model_switch/async_delegation mapping.
TUI typecheck: clean. TUI lint: 0 errors (2 pre-existing warnings).
TUI tests: 9 passed (1 pre-existing failure on main, unrelated).
Verified line-by-line against agent/bedrock_adapter.py on this branch:
allowlist split (Nova primary, Claude bearer-token fallback only,
everything else rejected), exact system→tools→messages[-2] insertion
order, and the usage normalization formula/field mapping.
Claude-on-Bedrock already gets prompt caching via the AnthropicBedrock
SDK path. This adds it to the raw Converse API path used for non-Claude
models (Amazon Nova, and Claude when bearer-token auth forces Converse
routing, #28156) — a conservative model allowlist inserts cachePoint
blocks after tools, system, and the message before the newest turn, and
extracts cacheReadInputTokens/cacheWriteInputTokens into usage so cost
accounting picks them up through the existing Anthropic-style fallback.
Ref: relatorio-cache-performance-provedores-ia.md, P0 item 1.
Follow-up hardening on top of the C14 cherry-picks (#57860/#44026/#66742/#60009):
- Slack file downloads (_download_slack_file/_download_slack_file_bytes)
now require an https URL on a Slack CDN host (files.slack.com,
*.slack.com Enterprise Grid, *.slack-files.com legacy shares) before
attaching the bot token. url_private/url_private_download only ever
point at the Slack CDN, so a forged file object from a malicious
workspace app or compromised event stream pointing the Bearer-token
download at an arbitrary PUBLIC host (token exfiltration) is now
refused — a hole #44026's generic private-IP SSRF check alone could
not close.
- The same two download paths now use create_ssrf_safe_async_client
(from #57860) so the preflight-validated hostname is resolved once,
validated, and dialed by IP — closing the DNS-rebinding TOCTOU window
for the token-bearing inbound fetches as well.
- #60009's slack_tokens.json permission warning is generalized into
utils.warn_if_credential_file_broadly_readable() (POSIX-only,
fail-quiet) and wired into the other read path with the same gap:
google_chat's load_user_credentials(). google_chat already writes
0o600 via _write_private_json; the read-time warning covers
hand-provisioned/legacy files. Nothing in-repo writes
slack_tokens.json (user/OAuth-provisioned), so there is no write
path to chmod for Slack.
Security tests both directions: non-CDN/lookalike/http URLs and
connect-time DNS rebinds are blocked before any TCP connect; real
files.slack.com, Enterprise Grid, and slack-files.com URLs still reach
the network layer; 0o600 files stay silent while 0o644/0o640 warn with
a chmod hint. A/B: all 10 new download-guard tests fail with the
hardening reverted and pass with it applied.
The OAuth multi-workspace token file contains plaintext bot tokens for
all saved Slack workspaces. Unlike the Google Chat adapter which sets
0o600 when writing credentials, the Slack token file has no permission
enforcement — a default umask 022 makes it world-readable.
Fix: check file permissions on read and emit a warning log with remediation
instructions if the file is group- or world-readable.
`SlackAdapter._fetch_thread_context` formats each prior thread message as
`{name}: {msg_text}` and joins them with newlines into the block the call
site prepends *raw* into the model turn (`text = thread_context + text`).
Both fields are attacker-influenceable — any thread participant sets their
own Slack display name and message text — and neither was neutralized, so
an embedded newline let a thread message break out of its line and pose as
a fresh markdown section (a fake "## SYSTEM" / "## Override" heading) inside
the context the model reads when first mentioned mid-thread.
This is the same indirect-prompt-injection vector already closed for the
sibling untrusted sinks: the sender-name prefix
(`neutralize_untrusted_inline_text`), the reply quote, and the relay
channel-context renderer. The Slack thread-context backfill — the default
whenever the bot is mentioned in a thread with no active session — was the
missed sink. (The existing `[unverified]` tagging marks *who* a message is
from; it does nothing about newline structure, so an authorized sender can
inject just as easily.)
Flatten both fields with `neutralize_untrusted_inline_text` before
interpolation. The body uses `max_chars=0` so message text is not truncated
(thread context caps the message *count*, never per-message length); the
display name keeps the default bound. `parent_text` keeps the raw message
(its own reply-context sink neutralizes separately). A well-behaved message
is preserved byte-for-byte.
Adds a regression test covering a hostile display name, a hostile message
body, benign passthrough, and the no-truncation guarantee.
_download_slack_file and _download_slack_file_bytes fetched Slack-supplied URLs with the bot token attached and follow_redirects=True, but without the is_safe_url pre-flight check or per-redirect guard that the outbound send_image path already uses. A URL that resolves to (or 3xx-redirects into) a private/internal address could reach internal services and leak the bot token (CWE-918). Add the same pre-flight + _ssrf_redirect_guard hook to both inbound sibling paths.
Several platform fetch paths called is_safe_url before constructing ordinary httpx clients, leaving a second DNS lookup at connection time. This preserved the rebinding window for Slack batch images, Feishu documents, Telegram URL-photo fallback, and WeCom remote media.
Route each path through create_ssrf_safe_async_client and the shared redirect guard so direct connections validate and dial vetted IPs while configured proxies remain an explicit trusted egress boundary. Add per-path regressions that change DNS from public at preflight to metadata at connect time.
The Skills Hub provenance fixture intentionally serves content over loopback. Opt that test-scoped server into private-address access so it keeps exercising the real HTTP transport without weakening production blocking.
Related #8033
Co-authored-by: teknium1 <127238744+teknium1@users.noreply.github.com>
Install connect-time DNS validation for Hermes-owned direct httpx clients so SSRF-sensitive fetch paths dial a vetted IP instead of re-resolving after preflight. This preserves Host/SNI semantics for direct HTTP(S) connections and keeps proxy routing as an explicit trusted egress boundary.
Wire the guarded clients into media cache downloads, vision downloads, Skills Hub direct/raw fetches, and platform attachment fetch paths that already perform SSRF preflight and redirect validation.
Fixes#8033
Co-authored-by: Tom Qiao <zqiao@microsoft.com>
CI shards run without the slack extras; the two #64267 tests import the
real SDK (SlackApiError, the lazy-rebind path) and errored with
ModuleNotFoundError. Skip on bare environments — classification coverage
for stdlib exception types (OSError/TimeoutError/cert errors) still runs
everywhere.
Widening for #24117 ('is thinking...' stuck after the response was
sent): the stuck-thinking class is a missing-cleanup-on-error-path bug.
send()'s status clear was gated on thread_ts already being resolved and
on reaching the normal post-message path, which left the Slack
Assistant status visible when a turn ended through any sibling exit:
- exception BEFORE _resolve_thread_ts (slash-context handling,
formatting, DM resolution) — the 'if thread_ts: stop_typing' clear
never ran
- empty/whitespace-only final response (no_text guard early-return)
- ephemeral slash replies (Slack only auto-clears assistant status on
real thread replies; ephemerals never count)
Add _clear_thread_status_quietly() — a best-effort stop_typing wrapper
that never masks the caller's SendResult — and wire it at every send()
exit plus the finalize paths of edit_message. stop_typing already
handles the untracked-thread fallback (clearing an unset status is a
no-op on Slack's side), so this is pure coverage widening.
Also add the #18859 unit tests for _resolve_progress_thread_id's new
reply_in_thread gate (synthetic-thread drop, real-thread keep, event-id
fallback suppression, default unchanged).
A/B: 3 of the 4 new status-clear tests fail with the adapter widening
reverted and pass with it applied; the fourth pins the existing
cleanup-must-not-mask-result contract.
The Slack adapter honours platforms.slack.extra.reply_in_thread=false
in _resolve_thread_ts, but the Gateway's progress-message path forced
event_message_id as the thread_id for Slack regardless. The first
progress message ('terminal: …', 'Processing…') created a thread that
all subsequent edits and the final answer inherited, defeating the
user's reply_in_thread=false setting.
Check the live Slack adapter's reply_in_thread flag before applying the
event_message_id fallback, and treat a synthetic source.thread_id (==
the event's own message ts, used only for session keying) as 'no
thread' so progress messages stay at the channel/DM top level.
Folds both #18859 commits (reply_in_thread gate + synthetic thread_id
drop) into main's extracted _resolve_progress_thread_id helper — the
original patched the pre-refactor inline block; the gate now composes
as a keyword argument so Mattermost/other platforms keep the default
fallback behavior.
When reply_in_thread=false, top-level channel events carry their own
message ts as metadata.thread_id for session keying. Calling
assistant.threads.setStatus on that ts activated a Slack assistant
thread ('is thinking...') before the actual response was sent, and the
flat reply then never cleared it.
send_typing now routes through the same _resolve_thread_ts synthetic-
thread guard as message sending, and the gateway threads message_id
through progress/status metadata so the adapter can distinguish real
threads from synthetic top-level session keys.
Reapplied from #18859-sibling PR #17184 by @dorukardahan (both commits:
fix + progress-metadata test) onto current main via 3-way apply — the
original patched gateway/platforms/slack.py, moved to
plugins/platforms/slack/adapter.py in the plugin migration.
Progress/status callbacks (context-pressure, compression retries,
model fallback) route through _send_or_update_status_coro, which
edits the previous bubble for the same status_key when the adapter
implements send_or_update_status — but only Telegram did. On Slack
every status event posted a fresh thread message, so a compression
retry loop spammed a dozen out-of-order bubbles into the thread
('Context too large 1/3... 2/3... 3/3', fallback switches, etc.).
Implement send_or_update_status on the Slack adapter following the
Telegram pattern (#30045): first call posts and caches the message ts
per (channel, thread, status_key); subsequent calls edit that message
via chat.update. Edit failure drops the cached ts and falls back to a
fresh send. Cache is FIFO-bounded.
Covers cluster C1-images (#69185, #32315, #66136):
- _slack_file_marker unit tests: typed markers per mimetype family,
hostile-filename sanitization (newlines/brackets can't fake context
structure).
- _render_message_text appends markers; a caption-less image post no
longer vanishes from thread context.
- Cold-start hydrate integration: prior-message images surface as
markers in channel_context; the thread root's image is downloaded,
delivered as media_urls, and upgrades message_type to PHOTO.
- Failure path: root-image download failure degrades to the marker,
never blocks the turn.
- Bounds: root delivery capped at _THREAD_ROOT_IMAGE_MAX; non-image
root attachments stay marker-only (no download).
- One-time delivery: active thread session skips the hydrate → no
re-download/re-delivery on later turns.
- Composition: the trigger's own event files still ride alongside a
delivered root image; Slack Connect stubs resolve via files.info;
the collector never issues its own conversations.replies call.
- Delta refresh (#23918 path): images in new replies past the watermark
surface as markers, with no root re-download.
A/B: 14 of 15 tests fail with the adapter fix reverted, all pass with
it applied.
When the bot is mentioned mid-thread for the first time, the thread root
is very often the artifact the mention is about ("@bot what's in this
chart?" posted as a reply under an image) — but the root's image never
reached the agent, so it answered blind.
On the cold-start hydrate path (and only there), _collect_thread_root_images
reads the root message from the thread-context cache the immediately
preceding _fetch_thread_context call just populated (zero extra Slack API
calls in the normal case), downloads its image/* attachments through the
existing authenticated _download_slack_file helper, and delivers them as
media_urls/media_types on the same MessageEvent — upgrading the message
type to PHOTO so vision routing engages.
Scope and safety:
- One-time delivery by construction: the cold-start path is guarded by
_has_active_session_for_thread, so later turns in the same session can
never re-download or re-deliver. No new gateway/session plumbing needed.
- Bounded by _THREAD_ROOT_IMAGE_MAX (4); non-image root attachments stay
text-only markers.
- Slack Connect stubs (file_access=check_file_info) resolve via files.info.
- Best-effort: a failed download degrades to the [image: ...] marker from
the thread context — never an error turn.
- Also hardens the video mimetype fallback (mimetype can be empty) so
media_types entries are always non-None strings.
Adapted from #69185 by @KCAYAAI — the original plumbed MessageEvent media
through gateway/base.py, run.py and session.py with durable one-time
delivery markers (2,441 lines); this lands the user-visible behavior
adapter-locally by reusing the session guard already on the hydrate path.
Images and files posted in a Slack thread before the bot joins were
invisible to the agent: _fetch_thread_context renders text only, and a
caption-less image post was dropped from context entirely (empty text →
skip). "@bot what do you think of the chart above?" read as a question
about nothing.
_render_message_text now appends a compact, sanitized marker per file
attachment — [image: chart.png], [video: demo.mp4], [audio: note.m4a],
[file: report.pdf (application/pdf)] — so the agent can SEE that prior
thread messages carried attachments and ask for a re-share when it needs
the bytes. Filenames are stripped of newlines/brackets so a hostile name
can't fake context structure. Because both thread-context formatting and
parent-text rendering go through _render_message_text, markers appear on
the cold-start hydrate, the explicit-mention delta refresh, restart
rehydration, and reply_to_text.
Reapplied from #32315 onto the current adapter (original patched the
pre-plugin gateway/platforms/slack.py, moved in the plugin migration;
annotation labels reworked to per-file typed markers, download side
handled separately).
#58478's caplog test predates main's unlabeled-bot users.info probe
(#69xxx wave-2 gating): events without client_msg_id now hit
_resolve_user_is_bot, which the fixture's mock client doesn't wire up
(AttributeError on _user_is_bot_cache). Real human-authored Slack
messages carry client_msg_id — add it to the fixture so the test
exercises the intended block-extraction path.
Follow-up hardening for the C13 log-noise cluster:
- plugins/platforms/slack/adapter.py: clarify button resolution logged
the full chosen option text at INFO (choice=%r). Choice text is user
content — log the choice INDEX at INFO and the (truncated, %.100r)
text at DEBUG only. Widens #58478's principle: no message content
above DEBUG level anywhere in the adapter.
- tests/gateway/test_slack_log_noise.py (new): behavioral suite pinning
the cluster's invariants:
* catch-all ack registered AFTER every named handler (registration
order is bolt's dispatch priority — no shadowing);
* catch-all fires for an unsubscribed event type (reaction_added),
logs only a DEBUG line naming the type, and never logs content;
* named handlers still dispatch (message → _handle_slack_message);
* end-to-end inbound message run leaves NO message text or block
content in any adapter log record (caplog at DEBUG);
* #30185's event-arrival diagnostic is metadata-only;
* clarify resolution: INFO carries index/user only, text is
DEBUG-only (fails with the adapter fix reverted — A/B verified).
Content-leak audit of all 139 logger call sites in the adapter found
two above-DEBUG leaks: the clarify choice line (fixed here) and none
else carrying message text; remaining sites log error strings, URLs
via safe_url_for_log, ids, and counts. The block-extraction DEBUG
preview was already removed by #58478 (chars= length only).
Regression test for the catch-all ack: a re.compile(r'.*') event matcher
must be registered (after every named handler, so it never shadows
message/app_mention/reaction/file routing) and must match unhandled
subscribed event types like member_joined_channel / channel_archive /
pin_added.
Salvaged from #64218 (test half only — its adapter-side catch-all is a
duplicate of #38847, which landed as the base commit of this cluster
with first-submitter credit). Fixes#6572.
Co-authored-by: shivasymbl <sdevinarayanan@asymbl.com>
Slack posts are durable workspace messages, not an ephemeral terminal
status area. Default long_running_notifications and busy_ack_detail to
off for Slack so long-running agent work does not leave permanent
operational breadcrumbs like 'Working — 9 min — iteration 12/90' in
channels. Both remain opt-in per platform via
display.platforms.slack.*.
Also covers the platform-generic shutdown-notification mute path with a
regression test (gateway_restart_notification=false must suppress both
the active-session interruption notice and the home-channel copy).
Salvaged from #69028 (quiet-defaults half only). The PR's other half —
the channel_session_scope_channels session-scoping feature — is a new
config feature outside this log-noise cluster and overlaps the session
scoping territory reworked by merged wave-1/2 Slack session work; it is
deliberately not taken here.
Without a catch-all handler, slack-bolt returns HTTP 404 for every
unhandled bot event (user_change, user_huddle_changed, reaction_added,
etc.) and never sends the Socket Mode ack. On active Slack workspaces
where the app is subscribed to high-volume events, this produces a
near-100% un-acked failure rate that crosses Slack's >95%/60-min
threshold and triggers automatic disabling of the app's Event
Subscriptions — silently killing all inbound event delivery.
Place a catch-all re.compile(r".*") handler AFTER the specific event
handlers so bolt's router matches those first. Truly unhandled events
are silently acked (200) and logged at DEBUG. The failure rate stays
near 0% regardless of which events the Slack app manifest subscribes to.
Fixes#6572
Treat Slack users.conversations missing_scope as an expected limited-scope app condition and fall back to session history without recurring warnings.
Add tests for not-ok and SlackApiError-like missing_scope responses.
Follow-up to the #68627 cherry-pick (cluster C15 — Slack platform
capability-note accuracy; earliest report/fix: #6545 by @daikeren):
1. Session/prompt stability: the pinned session-context render
(_pinned_session_context_prompt) is keyed by _ephemeral_change_key,
whose contract requires every rendered input to appear in the key.
The new _slack_tools_loaded() gate reads config + the live MCP
registration map, so its state is now hashed into the key exactly
like the existing Discord gate — a gate flip re-renders ONCE (a
legitimate bust); within a session the note stays byte-stable for
the life of the conversation (A/B: the new parity test fails with
this key change reverted, passes with it).
2. Derived, non-overpromising positive note: rather than hardcoding a
capability list that can drift stale again (the original bug class),
the tools-present note tells the agent to consult the actual loaded
Slack tool schemas for supported operations — the schemas ARE the
source of truth, so the note cannot overclaim ops a given Slack
toolset/MCP server doesn't expose (e.g. a read-only history server).
3. Tests: parity test proving a gate flip changes both render and key;
byte-stability test proving three consecutive turns in one Slack
session return the identical pinned object (sha256-equal); autouse
fixture pins the new gate so key<->render parity is env-independent.
Ports #63234 forward onto current main per teknium1's review.
gateway/session.py hard-coded the stale-API disclaimer for every Slack
session regardless of whether Slack tools were actually loaded. This
contradicted the system prompt when MCP or native slack tools were
present, causing the agent to refuse Slack API actions it could
actually perform (issue #6536).
Per review, the original predicate only checked the native 'slack'
toolset, missing Slack MCP servers (registered under mcp-<server> in
tools/mcp_tool.py) entirely. _slack_tools_loaded() now checks two
independent paths:
1. Native 'slack' toolset + SLACK_BOT_TOKEN (as before, but now calls
_get_platform_tools() with include_default_mcp_servers=True instead
of False, so a default-enabled MCP server also counts).
2. A connected MCP server that has ACTUALLY registered tools into the
live registry (new tools.mcp_tool.get_registered_mcp_server_names()),
whose name suggests Slack. This is session-scoped in the sense that
matters here: MCP servers connect once per gateway process (not
per-session), so checking the live per-server tool-registration map
is the correct availability-filtered signal -- unlike the earlier
get_all_tool_names() approach this replaces, which conflated ALL
built-in tool names process-wide, this only inspects the small,
purpose-built MCP server-name map.
Added a real regression test that registers a tool via the actual
tools.mcp_tool._track_mcp_tool_server() tracking function (not a mock
of the capability check) to verify a genuine Slack MCP server is
detected, plus a negative case for an unrelated MCP server.
5/5 Slack-specific tests pass; 126/126 in the full
tests/gateway/test_session.py file.
Follow-up to the salvaged PR #57320 commit: the PR bridged port/host/secret
for MSGRAPH_WEBHOOK but only tested webhook and api_server. Adds a test
covering the msgraph_webhook branch including extra-precedence, plus the
contributors/emails mapping for kjames2001.
WebhookAdapter and ApiServerAdapter read port/host from config.extra, but
PlatformConfig.from_dict only populates extra from the 'extra:' sub-key in
the YAML platform section. Top-level keys like port and host are silently
ignored, causing the adapter to fall back to DEFAULT_PORT (8644).
This causes silent port conflicts in multi-profile setups: a profile that
configures 'platforms.webhook.port: 8649' still binds 8644, colliding with
the default profile's webhook on the same port.
Fix: extend the shared-key bridging loop in load_gateway_config() to bridge
top-level port/host/secret into extra for WEBHOOK, MSGRAPH_WEBHOOK, and
API_SERVER platforms, following the same pattern already used for
dm_policy, allow_from, gateway_restart_notification, and other keys.
The extra dict takes precedence: if port is already under 'extra:', the
top-level value does not clobber it.
Documents user-facing wave-1+2 Slack behavior that had no docs coverage:
- decision table for require_mention / free_response_channels /
require_mention_channels / thread_require_mention / strict_mention /
ignore_other_user_mentions and how they compose
- 'Accepting messages from other bots' section with the post-#69483
semantics: allow_bots=mentions requires a CURRENT mention from
peer bots (text or Block Kit blocks); thread state never admits them
- clarify one-tap buttons (choice buttons + Other free-text mode,
in-place resolution, double-click guard, expiry message)
- slash replies are ephemeral: replace-ack, chunking, 5-post cap with
explicit truncation notice, postEphemeral fallback, never-public rule
- cron deliver targeting (slack -> home channel, slack:C... channel,
slack:U... resolved to DM) incl. standalone sender + MEDIA uploads
- send_message media + bare-user-ID DM resolution and caption behavior
Refs #26184.
Salvaged from #45765 by @navahc09 — kept the PR's callout structure
and placement, rewrote the content to match current behavior:
Slack blocks native slash commands in threads and never delivers them,
so Hermes recognises a leading '!' as an alternate command prefix.
Post-C3 command fixes the bang form also works behind a mention
(@Hermes !cmd) and with leading whitespace; unknown '!' tokens pass
through to the agent unchanged. Cross-linked the detailed
slack.md section.
0.15s missed by 1-8ms on busy CI shards twice today (runs 30025889952,
30026770278 — branches not touching this file). The assertion pins
'handler did not block on the timeout path' (blocking = seconds); 2.0s
keeps the contract per the loose-bounds flake policy.
A user who configured a provider only inside a MoA preset (advisor or
aggregator slot) has explicitly opted into that provider — the consent
gate (is_provider_explicitly_configured) now scans moa.reference_models,
moa.aggregator, and all moa.presets.* slots, so Claude Code OAuth pool
seeding and the auxiliary auto-fallback chain treat MoA-only Anthropic
users consistently with model.provider users.
Salvaged from PR #57778 (trimmed): the auxiliary_client fallback half of
the original PR was independently landed on main in ddd3a2d247 and is
dropped here; a secret-scrubber artifact in the gate test fixture is
restored to the real placeholder token.
_apply_env_overrides() force-set ``api_server.enabled = True`` whenever
API_SERVER_KEY (or API_SERVER_ENABLED) was present in the environment.
In multiplex mode, a secondary profile pins
``platforms.api_server.enabled: false`` in its config.yaml so that it
shares the default profile's API-server listener instead of binding its
own port. That profile still inherits the process-level env, including
API_SERVER_KEY, so the unconditional re-enable flipped api_server back on
and tripped the MultiplexConfigError check.
Honor an explicit disable, flagged by ``_enabled_explicit`` in the
platform's extra. Use ``extra.pop("_enabled_explicit", False)``: the
api_server branch is terminal (unlike the migrated plugin platforms, no
later registry pass re-enables api_server), so popping consumes the flag
in a single read and avoids the double-read hazard, while the final
per-platform cleanup remains a no-op.
Adds a regression test asserting that with API_SERVER_KEY set, a config
with api_server explicitly enabled:false + _enabled_explicit:true survives
_apply_env_overrides() as enabled=False (fails without the fix), while the
key is still wired through for the shared listener.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to #60168's salvage: aggregate_moa_context() is the third
independent MoA call path; assert its aggregator call receives the
custom-provider request_overrides.extra_body via **agg_runtime.
'auto' is a sentinel meaning "inherit from main runtime / auto-detect",
not a literal model id -- already handled for cfg_model (config-derived)
in _resolve_task_provider_model, but not for the explicit `model` kwarg.
MoA reference/aggregator slots (agent/moa_loop.py's _slot_runtime) forward
a preset's `model:` field as this explicit argument rather than through
auxiliary.<task> config, so a MoA preset configured with `model: auto`
(a natural thing to try given the existing auxiliary.*.model: auto
convention) reached this function as the explicit `model` arg and took
the `model or cfg_model` branch, bypassing the cfg_model-only sentinel
check entirely -- sending the literal string "auto" to the wire as a
model id.
Normalize both the explicit `model` and `cfg_model` the same way, fixing
this at the single chokepoint every caller (MoA included) already goes
through, rather than patching moa_loop.py separately.
Extends the desktop backend's root-cause fix (aa2ae36c3f) to all remaining
console-less parent launch paths. The Windows console-flash class
(#54220/#56747) is governed by the PARENT's console: a DETACHED_PROCESS or
pythonw.exe daemon has no console, so every console-subsystem descendant
(git, gh, cmd, node, wmic, powershell) allocates its own visible conhost —
one flash per spawn, unreachable by any per-call-site CREATE_NO_WINDOW
sweep. Worse, MSDN specifies CREATE_NO_WINDOW is IGNORED when combined
with DETACHED_PROCESS, so the hide bit in the old detach bundle was dead.
Changes:
- _subprocess_compat: drop DETACHED_PROCESS from windows_detach_flags()
and windows_detach_flags_without_breakaway(); the daemon now owns a
single hidden console (CREATE_NO_WINDOW) that all descendants inherit.
- gateway_windows: _resolve_detached_python() returns the venv console
python.exe (no pythonw/base-interpreter detour — the uv-shim flash
premise only held while DETACHED_PROCESS was masking the hide bit);
UAC handoff launches console python under SW_HIDE; cmd/vbs launchers
render console python (vbs runs it window-style 0).
- gateway/run.py: restart watcher keeps sys.executable instead of
swapping in GUI-subsystem pythonw.
- web_server: dashboard actions spawn sys.executable (already carries
windows_detach_flags()).
Tests updated to pin the new invariants, including an explicit
DETACHED_PROCESS-must-stay-out regression guard.
Follow-up to the #62614 salvage: try_refresh_matching (added by the
#69843 salvage after this PR's base) calls self.current() while already
holding the now-locking non-reentrant pool lock — a guaranteed deadlock
that git merges silently (no textual conflict). Use _current_unlocked()
and cover the method in the no-deadlock test.
Follow-up to review feedback:
- Acquire self._lock in the remaining public pool-state methods:
has_credentials, reset_statuses, remove_index, resolve_target, and
add_entry. All of them read or rebind self._entries (and the mutating
ones persist auth.json), so they now hold the same lock as select()
and the query methods. None are called from within the lock, so no
unlocked helpers are needed.
- Make the blocking test deterministic: an instrumented lock records the
acquire attempt, and the test first waits for the worker to actually
reach self._lock before asserting it blocks. Previously an unlocked
method could pass if the worker thread was scheduled late.
- Extend the lock test matrix to all nine public methods; the five newly
locked ones fail the test without this fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`has_available()`, `peek()`, `current()` and `entries()` read (and, via
`_available_entries()`, mutate and persist) `self._entries` without holding
`self._lock`, while every other entry point — `select()`,
`mark_exhausted_and_rotate()`, `acquire_lease()`, `try_refresh_current()` —
guards the exact same access with the lock.
`_available_entries()` is not read-only: it prunes aged-out DEAD manual
entries (rebinding `self._entries` at the prune step) and calls `_persist()`
(writes auth.json). The gateway runs platform adapters in threads and cron
runs jobs in a ThreadPoolExecutor, so a status probe via `has_available()`
or `peek()` can race a concurrent `select()`/rotation: torn iteration of
`self._entries`, interleaved auth.json writes, or a lost token rotation.
Fix: take `self._lock` in all four query methods. Because the lock is
non-reentrant and `peek()` composes `current()` + `_available_entries()`,
add a lock-free `_current_unlocked()` helper and route the already-locked
internal callers (`_select_unlocked`, `mark_exhausted_and_rotate`,
`_try_refresh_current_unlocked`) through it to avoid self-deadlock.
Added regression tests: a no-deadlock check (peek re-entrancy) and a
lock-held-blocks-the-call check for each of the four methods.
Decompose children inherit the root's literal workspace_path (#37172),
so every sibling of a worktree-kind root points at the SAME checkout.
_resolve_worktree_workspace's existing-checkout shortcut then reuses
that directory on whatever branch is currently checked out, ignoring
the task's own branch_name. Net effect: sibling workers — which can be
promoted and dispatched concurrently — run in one directory on the
first sibling's branch, with no lock. Work lands on the wrong task's
branch (provenance corruption) and concurrent siblings trample each
other's index/tree.
Fix, two layers:
- decompose_triage_task: worktree-kind children no longer inherit the
root's literal path; each child materializes its own
<repo>/.worktrees/<child-id> at dispatch (dir/scratch inheritance
unchanged — children legitimately share those).
- _resolve_worktree_workspace: when the requested path is an existing
checkout of a DIFFERENT branch, fall back to a fresh
<repo>/.worktrees/<task-id> instead of silently reusing it (heals
rows that already carry a shared path). Same-branch reuse and the
no-repo/own-path degenerate cases keep the legacy behaviour.
Tests: tests/hermes_cli/test_kanban_worktree_isolation.py (5); full
test_kanban_db.py + test_kanban_decompose_db.py suites pass unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the #65844 salvage: the new anthropic pool test must stub
read_claude_code_credentials like the sibling tests, otherwise a dev
machine's live claude_code singleton seeds a third entry and the
no-benching assertion fails outside CI.
Two related races in credential-pool cooldown state:
1. Lost update across processes: write_credential_pool merged only
entries missing from the caller's snapshot; for entries present on
both sides the caller's in-memory copy won wholesale. A process
holding a snapshot taken before another process marked a key
exhausted would, on its next persist (e.g. a round-robin rotation),
write the key back as healthy — erasing the cooldown so every
process resumes hammering a rate-limited key. Merge status fields by
last_status_at recency: adopt the on-disk status only when it is
strictly newer AND still binding (DEAD, or EXHAUSTED with an
unexpired cooldown), and never onto re-authed (token-changed)
entries, so legitimate expiry-clears and fresh logins are preserved.
2. Wrong-key quarantine: when mark_exhausted_and_rotate received an
api_key_hint that matched no entry, it fell through to
current()/_select_unlocked() — on a freshly loaded pool that selects
the NEXT healthy key and benches it for the full cooldown TTL,
punishing an innocent credential. When a hint is provided but
unmatched, rotate without marking anything instead of guessing.
Includes regression tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
git stash push --include-untracked exits non-zero when it saved
everything but could not DELETE some swept untracked files from the
working tree (e.g. a root-owned packaging/ directory left behind by a
sudo'd build: 'warning: failed to remove ...: Permission denied').
The updater ran the push with check=True, so this benign partial
failure raised CalledProcessError and aborted the whole update before
it even fetched — reliably, on every run, for any user with an
undeletable untracked path in the checkout.
Fix, both ends of the class:
- _stash_local_changes_if_needed: probe refs/stash before/after the
push. Non-zero push + fresh stash entry = changes are saved; warn,
reset the tracked-side leftovers (they're in the stash), and
continue the update. Non-zero push + NO stash entry = real failure;
keep aborting.
- _restore_stashed_changes: on restore, those same undeletable files
still sit in the tree, so 'git stash apply' exits 1 with 'already
exists, no checkout' even though every tracked change applied and
nothing was lost. Classify that stderr shape (strictly — any other
error line still routes to the conflict path) as restored instead
of resetting the tree and telling the user the restore failed.
Repro'd both halves with real git; behavioral E2E test covers
stash -> checkout -> restore round-trip with an undeletable dir.
A 402/429/401 is an API-key–level failure (account out of balance,
rate-limited, or key rejected), but the same key can back more than one
pool entry — e.g. an explicit pool entry plus a `model_config` entry
auto-seeded from `model.api_key`, both carrying the identical
`runtime_api_key`.
`mark_exhausted_and_rotate(api_key_hint=...)` only marked the *first*
matching entry, leaving the sibling OK. `_select_unlocked()` then kept
handing back the same depleted key, so the billing-recovery `continue`
loop in the conversation retry path never converged: the request hung
until the client disconnected (~2.5min observed against DeepSeek),
emitting only `response.created` with no 402 ever surfaced to the user.
Mark every entry sharing the failed key so the pool can reach the
"no available entries" state and let the error propagate immediately.
Adds a regression test covering two entries backed by the same key.
The dedup-reset calls assumed a full AIAgent; gateway/loop test doubles
built via object.__new__ lack _clear_context_overflow_warn and crashed
in build_turn_context (caught by test_api_content_sidecar on CI slice 3).
getattr-guard all four call sites per the established test-double pitfall
pattern (AGENTS.md #17).
Follow-up fixes for the #62625 salvage:
- Dedup-reset gap (sweeper review): when the block clears while the
context is STILL over threshold, execution enters the compression
branch — the PR's 'else' reset never ran, so the warning stayed
suppressed forever after the first block. _clear_context_overflow_warn()
now fires on every automatic compression path: turn-context preflight,
conversation_loop pre-API gate, and the post-tool loop-compaction gate.
- should_compress_info on current main: main refactored should_compress
into _automatic_compression_blocked()/_locally(); the tuple variant now
derives its reason from the same in-memory state via
_compression_block_reason(), keeping cooldown:<s>/ineffective shapes.
- ContextEngine.should_compress_info ABC default now actually returns
(should_compress(tokens), None) — the PR's default had a docstring but
no return (returned None, would crash tuple-unpacking call sites).
- Below-threshold guard: the turn-context persisted-cooldown branch and
the conversation_loop pre-API cooldown branch no longer warn when the
estimate is under threshold (should_compress_info returns a None
reason; the preflight pre-check is not a threshold guarantee). The
pre-API guard also honors compression.max_attempts instead of a
hardcoded 3, and no longer fabricates a cooldown reason.
- Noise-filter survival (#69550 composition): warning text is now a
template constant (CONTEXT_OVERFLOW_BLOCKED_WARNING_TEMPLATE) marked
FAILURE-CLASS, pinned un-swallowed in VISIBLE_COMPRESSION_MESSAGES and
in new tests that execute the real _TELEGRAM_NOISY_STATUS_RE +
_prepare_gateway_status_message.
- Contributor mapping for stanislav@local -> sl4m3.
- ContextEngine.should_compress_info() default impl so plugin engines
(e.g. _StubEngine) don't raise AttributeError at the call site.
- Centralise warning/reset in AIAgent._warn_context_overflow_blocked /
_clear_context_overflow_warn so turn-context and conversation-loop guards
share identical dedup logic and reset on the real compression boundary.
- Cover conversation_loop.py pre-API (~L1007) and loop-compaction (~L4774)
guards, not just the turn-context preflight.
- _FakeAgent mirrors the two helpers; test suite green (219 passed).
Fixes#62708
Previously, when a session crossed the compression threshold but compression
was skipped (summary-LLM cooldown, #11529, or anti-thrashing, #40803), the
model kept accumulating context until it hit the hard provider token limit and
silently stopped answering — with no signal to the user about why.
Changes:
- context_compressor.should_compress_info() returns a (should_compress, reason)
tuple. reason is 'cooldown:<seconds>' or 'ineffective' when compression is
needed but blocked. should_compress() keeps its bool contract so existing
callers (conversation_loop.py) and regression #29335 are unaffected.
- turn_context.build_turn_context() emits a deduped _emit_warning when the
context is over threshold but compression is blocked, advising /new or
/compress. Dedup keys on the block *kind* (cooldown/ineffective), not the
ticking countdown, so a cooldown doesn't re-fire the warning every turn.
- Adds tests/agent/test_turn_context_overflow_warning.py covering the tuple
shape, both block kinds, dedup, and re-fire-after-clear.
Default kanban_create children now keep fresh scratch paths, while explicit dir sharing remains supported and project context resolves to a per-task worktree. Surface resolved workspace fields in create responses/events and cover scratch mutation, nesting, explicit sharing, and project inheritance.
Fixes#67567
Dispatcher-spawned Kanban workers are finite one-shot processes, so detached delegation completions can outlive their only consumer. Mark that runtime as unable to deliver async completions and reuse the synchronous delegation fallback, returning required child results before the worker exits.\n\nAlso make unsupported-session notes runtime-generic and cover the delayed-child lifecycle regression.\n\nRefs #63169
Extend the existing candidate-name resolver in _supports_vision_override
to accept 'vision' as an alias for 'supports_vision' on per-model config,
for both the providers.<name>.models dict and the legacy list-style
custom_providers form.
Per review feedback on #31912: this extends the current resolver rather
than replacing its candidate-name logic. Named custom providers resolve
to the runtime value provider='custom' while the config keeps the
user-declared name under model.provider; that lookup path is preserved.
Adds regression tests covering model.provider=my-vllm with runtime
provider='custom' for both config shapes.
The bare truthiness test on _compression_skipped_due_to_lock is fooled
by MagicMock auto-attributes on test-double agents (skill pitfall:
MagicMock defeats hasattr/truthiness duck-typing) — the type-ahead CLI
test's MagicMock agent took the lock-skip branch and skipped the
transcript commit. Real values are None/True/holder-string; pin the
check to 'is True or isinstance(str)' at all three consumer sites.
Follow-up to the salvaged #57634 commits:
- agent/manual_compression_feedback.py: new describe_compression_lock_skip()
— single source of truth for lock-skip wording. A descriptive holder
string means another compressor CONFIRMED holds the lock ('already in
progress (holder: ...)'); True/None means acquisition failed without a
confirmed holder (hermes_state.try_acquire_compression_lock catches
sqlite3.Error internally and returns False), so the message says
'could not acquire ... the lock check failed' instead of falsely
claiming a concurrent compression is running.
- cli.py, gateway/slash_commands.py, tui_gateway/server.py (all three
in-process consumers: session.compress RPC, command.dispatch compress
branch, slash.exec mirror) now route through the shared helper.
- tui_gateway/server.py command.dispatch compress branch: catch
CompressionLockHeld explicitly — it previously fell into the generic
'compress failed' error handler.
- Deferred-notify contract (#69324): lock-skip discards the pending
context-engine notification (committed=False) in _compress_session_history
and the CLI path before returning.
- tests: lock-skip wording pins per surface, VISIBLE_COMPRESSION_MESSAGES
noise-filter carve-outs for both wordings, MagicMock signal opt-outs for
sibling tests added on main after the original PR.
Advisor review found a critical stale-signal leak: if auto-compress
sets _compression_skipped_due_to_lock during a lock-skip, a subsequent
successful manual /compress will see the stale signal, falsely report
'Compression already in progress', and discard the compression results.
Fix:
- compress_context clears _compression_skipped_due_to_lock = None at
entry so each call's outcome alone determines the signal.
- Unified gateway 'holder: unknown' drift to match CLI/TUI pattern
(omit holder clause when not a descriptive string).
- Added MagicMock opt-outs in 3 sibling test files broken by the new
signal check (test_compress_here, test_compress_focus,
test_compress_plugin_engine).
- Added stale-signal-leak invariant test proving the fix.
Treat cua-driver's Linux `is_on_screen: null` as unknown instead of
off-screen, and skip GNOME Shell desktop/backdrop helper windows
(ding "Desktop Icons", @!x,y;BDHF) when selecting the default capture
target — they are targetable X11 windows but capture as empty.
Reconciled with the _NET_ACTIVE_WINDOW fallback from #58030: helper
windows are filtered out of the candidate pool first, then the tied
z-order active-window probe runs on the remaining real app windows.
Also falls back to the requested app name for _last_app when Linux
windows carry no app_name.
Salvaged from #54173 by @dnth.
The anti-thrash guard (_ineffective_compression_count) was in-memory
only: a fresh compressor bound to a resumed, already-compacted session
started with compression_count=0 and a disarmed guard, so a
near-threshold session could legally re-compact once per process
restart, forever.
Persist the counter through the durable session-state channel,
mirroring the failure-cooldown (#54465) and fallback-streak (af7dceaf7)
pattern:
- hermes_state.py: sessions.compression_ineffective_count column
(declarative reconciliation adds it on existing DBs) +
get/set_compression_ineffective_count accessors.
- context_compressor.py: every strike/clear verdict routes through
_record_ineffective_compression_verdict() which writes through to the
session row (no-change verdicts skip the DB write);
bind_session_state() loads the persisted value; the compression
rotation boundary carries the counter onto the child row;
update_model()'s reset also clears the durable copy; the
ineffective-only fast path in _automatic_compression_blocked() is
removed because the counter is now durable and another agent's clear
must unblock a stale local snapshot.
- conversation_compression.py: _refresh_persisted_compression_guards
re-reads the counter alongside cooldown + fallback streak.
Reset semantics are unchanged: any real provider reading below the
threshold still clears the counter — and now clears it durably too.
Resolves the residual gap identified in #54923 by @lanyusea (the
second-threshold mechanism was superseded by persisting the existing
guard state).
Co-authored-by: lanyusea <lanyusea@gmail.com>
The skill claimed Chromium/Electron 'reject synthetic clicks' so computer-use
can't drive the canvas. The Cua team disproved this on the exact v1.11.0
AppImage (Linux/X11): background delivery returns background_unavailable, but
that's the first rung, not a wall — cua-driver returns escalation:'foreground'
and its X11 XTest path (x11_xtest_fg) with delivery_mode:'foreground' clicks
through, dismissing the consent dialog and landing canvas clicks.
Corrected the note to say: climb to foreground on background_unavailable, don't
conclude Electron is unclickable. Ref: NousResearch/hermes-agent#67052.
Optional skill for driving the tldraw offline desktop app via its local
HTTP control API (the same curl-based path the app's own agent skills use
for Codex/Claude Code/Cursor/Gemini) — read the canvas, make live edits,
and write embedded document scripts.
Grounded in the app's bundled script-context.d.ts and agent playbook, and
in the real tldraw SDK v5 shape schema:
- document-script contract: export default function ({ editor, helpers, signal })
- HTTP API: /api/search, /api/doc/:id/exec, /api/doc/:id/script-workspace,
/api/doc/:id/script-status (bearer token from server.json, re-read per call)
- shape schema table validated against @tldraw/tlschema (scripts/validate_shapes.mjs, 3/3)
- interactive-UI example (scripts/counter.js) + diagram-generation (scripts/main.js)
- honest verification boundary: click->state logic verified via /exec dispatch
(0->1->2->1->0), with documented host caveats (inotify watcher, Electron
background-click rejection)
Tests: tests/skills/test_tldraw_offline_skill.py (15 passing).
`_api_key_passes_startup_guard` refuses to start the API server on a weak
`API_SERVER_KEY`, and its own log says why:
This endpoint dispatches terminal-capable agent work — a guessable key
is remote code execution.
But the check is wrapped so that a failure to import it starts the server
anyway:
try:
from hermes_cli.auth import has_usable_secret
if not has_usable_secret(self._api_key, min_length=16):
... return False
except ImportError:
pass
return True
`hermes_cli.auth` imports httpx at module scope and pulls in a large slice of
the CLI, so an import failure is not hypothetical — a trimmed image, a partial
install, or a circular import during gateway startup all produce one. When it
happens the strength check silently disappears and only the presence check
above it remains, so a placeholder key passes.
Reproduced against the real guard with the import blocked:
weak key, normal : False
weak key, ImportError : True <-- starts on a 4-char key
strong key, normal : True
Fail closed instead: an unverifiable key does not get to expose the endpoint,
and the log names the actual problem so the operator can repair the install.
This is the posture tools/credential_files.py already takes — it refuses a
mount when its deny-list cannot be consulted rather than risking it. The catch
also widens from ImportError to Exception, so an AttributeError or an error
raised inside the check cannot reopen the same hole.
Both happy paths are untouched: a strong key still starts, a weak or missing
key is still refused with the existing messages.
Unrelated to #38803, which fixes the retry behaviour after this guard rejects
and assumes the guard ran.
tests/gateway/test_api_server.py: new TestApiKeyStartupGuardFailsClosed — a
weak key is refused when the check is unavailable, a strong key is refused too
(fail-closed), plus three controls pinning the unchanged normal paths. The two
fail-open tests fail on main; the three controls pass there. 222 passed in the
api_server suites; 1475 passed across every suite touching api_server, with
the same 8 pre-existing failures on clean main.
Follow-up to srojk34's explicit-provider unwrap (PR #56691):
- Extract _resolve_moa_aggregator() as the single preset->aggregator
resolver shared by _resolve_auto(), _resolve_task_provider_model(),
and resolve_provider_client() so preset lookup/validation can't drift.
- When the main provider is moa, the aggregator model is now the default
for every UNSET auxiliary model: _read_main_model_for_aux() substitutes
the preset's acting (aggregator) model wherever fallback chains
pre-filled from _read_main_model() (router prefill, custom-endpoint
fallback, named-custom default, external-process default,
_try_main_agent_model_fallback).
- Unwrap moa at the resolve_provider_client() chokepoint so direct
callers (vision auto-detect, plugin code) can't dead-end in the
unknown-provider branch, and unwrap the vision auto-detect main
provider before capability probes run against the preset name.
- Real-config tests: temp HERMES_HOME + actual config.yaml exercising
the genuine load_config()/resolve_moa_preset() boundary.
_resolve_task_provider_model() returned an explicit provider="moa" override
(from a caller-passed arg, or auxiliary.<task>.provider: moa in config.yaml)
verbatim, with no MoA-preset unwrap. Only the *implicit* "main provider is
moa" path inside _resolve_auto() unwraps to the aggregator slot (#53827) —
this function never goes through _resolve_auto() at all, so the explicit
case was never covered.
MoA is a virtual provider with no real HTTP endpoint: resolve_provider_client()
looks "moa" up in PROVIDER_REGISTRY (no such entry), falls to the
unknown-provider dead end, and call_llm surfaces a nonsensical "Provider
'moa' is set in config.yaml but no API key was found. Set the MOA_API_KEY
environment variable..." error for a provider that was never meant to be
reached over the wire.
Fix mirrors #53827's aggregator-resolution approach exactly: when either the
explicit `provider` arg or the config-derived `cfg_provider` is "moa",
resolve the named (or default) MoA preset via resolve_moa_preset() and
continue with its aggregator's real provider+model, dropping any explicit
base_url/api_key (the moa:// virtual endpoint and placeholder key belong to
the facade, not the aggregator's real provider). If the preset can't be
resolved (renamed/deleted), degrades gracefully to the pre-fix behavior
instead of raising harder.
- agent/auxiliary_client.py: _unwrap_moa_provider() helper + call sites for
both the explicit-arg and config-derived provider="moa" cases in
_resolve_task_provider_model(). Also tightened base_url/api_key parameter
types to Optional[str] (matching their actual None-accepting behavior),
which incidentally resolved 5 pre-existing ty diagnostics at call sites.
- 5 new regression tests in tests/agent/test_auxiliary_client.py: explicit
arg unwrap, config-derived unwrap, default-preset fallback when no model
is configured, graceful degradation on preset-resolution failure, and a
non-moa regression guard.
The three subprocess tests spawn 'sys.executable -c' children that import
hermes_cli. From a worktree, the child resolved the MAIN checkout's editable
install instead of the tree under test, so the new DB/CLI guards appeared
missing and the tests failed with rc=0. Route the spawns through a helper
that pins the repo root under test on PYTHONPATH.
Mocked-subprocess tests asserting creationflags == CREATE_NO_WINDOW for
each path salvaged from PR #56877: tui_gateway cli.exec / shell.exec /
quick-command dispatch, the CLI quick-command exec handler, and the
Copilot ACP + Codex app-server Popen transports (pipes asserted intact).
Verified: all 6 fail with the fix reverted, pass with it applied.
Six spawn sites reachable from the desktop GUI / TUI gateway lacked CREATE_NO_WINDOW, so a windowless parent (pythonw/Electron) flashed a conhost per spawn: cli.exec RPC, quick-commands exec dispatch, and shell.exec RPC in tui_gateway/server.py; the CLI REPL quick-commands exec in cli.py; and the per-session provider transports in agent/copilot_acp_client.py and agent/transports/codex_app_server.py (Popen, hide-only so PIPE stdio stays intact).
All use hermes_cli._subprocess_compat.windows_hide_flags() (no-op on POSIX), matching the pattern already used at three other sites in tui_gateway/server.py. Deliberately hide-only — no detach flags, no Electron changes (per the #54220 revert history).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Follow-up to the #58738 salvage: the pre-exhausted check now enumerates
pool.entries() to find the failing key, so the MagicMock pool double must
expose entries as a callable, not a bare list.
Review follow-up: the auth path called pool.try_refresh_current() before
the hinted rotation, so a stale current() pointer could force-refresh a
different, healthy entry — consuming its single-use refresh token, or
(for non-OAuth entries, where a forced refresh marks the entry exhausted
outright) killing it entirely before api_key_hint was ever consulted.
Use try_refresh_matching(api_key_hint=...) to resolve and refresh the
entry that supplied the failing key under the pool lock, falling back to
the previous behavior when no key is known.
Adds a regression test with current() deliberately pointed at the
healthy entry: on the old code the healthy entry is exhausted by the
forced refresh and the pool ends up fully offline; with the fix the
failing entry is exhausted and recovery rotates to the healthy one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
recover_with_credential_pool identified "which credential failed" via
pool.current(), a shared mutable pointer that is advanced by every
select() (round-robin rotation, concurrent turns, and other processes
reloading the pool reset it to None). By the time recovery ran, it
routinely pointed at a different, healthy entry — mark_exhausted_and_rotate
then stamped the failing request's error message and reset time onto that
innocent entry. With round_robin and one hard-capped key this
deterministically exhausted the healthy key too and took the entire pool
offline ("no available entries") from a single rate-limited credential.
mark_exhausted_and_rotate already supports api_key_hint for exactly this
(the auxiliary-client path passes it); the main conversation-loop path
never did. Pass agent.api_key — kept in sync with the entry in use by
_swap_credential — as the hint on all four rotation call sites, and make
the "already exhausted → rotate immediately" pre-check look up the failing
entry by key with the same fallback to current().
Adds regression tests that fail on the old attribution logic: a fresh
pool (current() is None) failing on key B must mark entry B, never
entry A.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The hermes console entry point is hermes_cli.main:main, and main.py imports
dotenv (via env_loader) and yaml (via config) at module level. In the #57828
failure state — a failed lazy backend refresh wiping a core package's import
files while metadata survives — a normal launch crashed while importing
main.py, before _recover_from_interrupted_install() and the recovery markers
from PR #58004 could act.
- hermes_cli/_early_recovery.py: stdlib-only bootstrap repair invoked at the
very top of main.py, before any third-party import. Probes the fragile
core packages via real imports, force-reinstalls broken ones using the
pyproject.toml pins, shares main.py's single-flight recovery lock, and
never clears markers (the confirmed lifecycle stays with the full recovery
path in main.py).
- Probe/repair tables now have one canonical home in _early_recovery, reused
by main.py so the two layers cannot drift.
- Manual --force-reinstall fallback commands now print pinned specs via
_lazy_refresh_repair_specs() instead of bare package names.
- tests: entry-point lifecycle coverage proving a broken dotenv import
crashes main.py without repair and imports cleanly with it, a stdlib-only
import guard for _early_recovery, and unit coverage for marker gating,
lock single-flight, pinned specs, and marker preservation.
Keep .update-incomplete for full .[all] recovery only. Lazy refresh uses
.lazy-refresh-incomplete and clears only after confirmed import probes;
unavailable probes are indeterminate, not healthy (#58004 review).
Keep .update-incomplete across normal hermes.exe launches, heal via
package-only import probes first, and only clear the marker after repair
succeeds (#57828 / #58004 review).
Upgrade pip before lazy refreshes, probe core imports when a lazy
install fails, force-reinstall corrupted packages with pyproject pins,
use package-only install (no shim quarantine) for repair, and keep the
.update-incomplete marker until refresh/repair succeeds (#57828).
The startup pruner only considered directories named hermes-* (the
hermes -w scratch trees), so salvage/review/port lanes created with raw
'git worktree add' accumulated forever — a real checkout reached 117
directories / 26 GB with trees dating back months. Two further leaks:
squash-merged branches' local commits stay unreachable from
refs/remotes/* forever, so the unpushed-commits guard preserved fully
merged scratch trees indefinitely; and preserved trees rotted silently
with no visibility.
- Pruner now covers every directory under .worktrees/ except kanban
task trees (t_<hex>, owned by 'hermes kanban gc'). Named (non
hermes-*) trees get a 3x timeline (72h soft / 9d hard) since they
were created deliberately.
- New _worktree_commits_all_merged_upstream(): git-cherry
patch-equivalence check against origin/HEAD|main|master, bounded at
20 commits ahead, fails safe toward preserve. Lets the pruner reap
trees whose every local-only commit already landed upstream via
squash-merge/cherry-pick.
- Dirty guard now applies at every tier (previously the 24-72h tier
skipped it — it only survived because the unpushed check usually
caught the same trees).
- Trees preserved for unpushed/dirty reasons older than 7 days are
listed in a single WARNING so in-flight work can't rot silently.
- tips.py text updated; 13 new behavior-contract tests.
- gateway/run.py: use _adapter_for_source(source) instead of the raw
adapters.get(source.platform) map so the compression-timeout warning
respects transport provenance, relay ingress, and multiplexed profiles
(matches every other user-facing send in the hygiene block).
- tests: add a lock-release verification regression — a fence-cancelled
hygiene compression must leave the per-session compression lock free so
the next attempt (manual /compress retry) acquires it and commits
normally.
Follow-up for the salvaged #35191: the mid-turn pre-API pressure emit in
conversation_loop.py and the idle-resume emit in turn_context.py were not
routed through automatic_compaction_status_message, so an engine with
emit_automatic_compaction_status=False still leaked those lines. Both now
resolve through the hook (phases "pre_api" and "idle") while keeping the
#69550 template constants as the default wording. Suppression also skips the
#69546 structured 'compacted' terminal edge for compress-phase events that
opened no visible phase; failure warnings (_emit_warning) remain never
suppressible, pinned by test.
Covers the follow-up hardening: continuation-marker and summary-as-user
tails keep the flagged standalone snapshot (zero-user provenance #69292
verified via _transcript_has_real_user_turn on the projected rows),
stale snapshot rows are refreshed in place, a previously merged snapshot
is stripped before re-injection, and an all-completed todo store injects
nothing (#26981).
Follow-up hardening on the salvaged merge-into-trailing-turn fix:
- Merge only into REAL user tails (_is_real_user_message probe). Merging
into scaffolding tails (continuation marker, summary-as-user handoff)
would upgrade them to real-user evidence after SessionDB projection
strips the flags, breaking zero-user provenance (#69292 -
_is_synthetic_compression_user_turn keys on the TODO_INJECTION_HEADER
content marker, which merge-at-tail would bury mid-content).
- Strip a previously merged snapshot block before re-injection so
repeated boundaries refresh rather than accumulate todo state, and
refresh a bare stale snapshot row in place instead of stacking a
duplicate (empty/stale-skip semantics from #26981 by @YLChen-007).
- Scaffolding tails keep the flagged standalone append (pre-#53890
status quo; adjacent user rows are repaired downstream by
repair_message_sequence / _merge_consecutive_roles).
After context compression, the preserved todo list was unconditionally
appended as a standalone user message. When the compressed transcript
already ends with a user message (common case), this creates consecutive
user/user turns — a role-alternation violation some providers reject.
Fix: fold the snapshot into the trailing user message (blank-line separated)
when one exists with plain-string content. Falls back to append when the
tail is non-user, empty, or has structured (list) content.
Rebased on current upstream/main.
Closes#53890
Regression test for the exact issue #61932 report: head + an 8-message
protected tail made exclusively of oversized tool pairs. Pre-fix,
compress_start >= compress_end made compress() a pure no-op and the
retry loop ended in 'Cannot compress further'; post-fix the Phase-1
pressure demotion reclaims the tail in one pass while preserving
tool_call/tool_result pairing.
After multiple in-place compactions, short tool-heavy sessions can leave
nearly every remaining message inside protect_last_n while those messages
are huge completed file/tool outputs. The middle compress window then
makes no material token progress and the turn dies with
"Cannot compress further" (#61932).
Cap the prune message floor at the same bound as tail-cut, and under
pressure demote bulky protected-tail tool bodies (keeping a short recent
floor) so preflight can reclaim headroom without wiping the active ask.
Follow-up for salvaged #58629: thread the previous flush baseline through
the idle-compaction caller in turn_context.py (the one caller the original
PR predates), and add regression coverage that every early-return path in
compress_context (breaker skip, no-progress, plus the completed-rotation
boundary) resets the per-attempt in-place outcome so a stale
_last_compaction_in_place from an earlier successful in-place compaction
can never baseline unflushed turns as persisted.
Relocates the #20424 wiring: the preflight region moved out of
run_agent.py into agent/turn_context.py (and through the
compression.max_attempts unification, #69315), so the contributor's
elif branch is reapplied at its current home as the else arm of the
threshold dispatch chain.
Integration contracts:
- Byte-identical default: the built-in ContextCompressor inherits
ContextEngine.should_compress_preflight() -> False, so the default
path performs no compression and touches no turn bookkeeping
(pinned by test_builtin_compressor_default_sub_threshold_path_unchanged).
- Attempt-cap: the engine gets exactly ONE compress() pass per turn,
mutually exclusive with the cap-bounded threshold multi-pass loop,
so turn-start passes stay within the resolved
compression.max_attempts budget in every case.
- No-op blocking (#64382 / 377244f7c): an engine pass that no-ops
(_compress_context returns the input list object) neither sets nor
clears preflight_compression_blocked and does not re-baseline the
flush history — a sub-threshold maintenance no-op proves nothing
about over-threshold compressibility.
- Engine exceptions are swallowed at debug level; cooldown/defer/
codex-native gates run before the hook is ever consulted.
Salvaged from #20424 by @Beandon13. Fixes#20316.
Context engines that override ``should_compress_preflight()`` (e.g. the
hermes-lcm plugin's incremental leaf-chunk compaction) never had their
hook fired by ``run_conversation`` because the preflight block exited
early once the hardcoded ``>= threshold_tokens`` check failed. As a
result, ``LCM_DEFERRED_MAINTENANCE_ENABLED=1`` and friends were inert
and accumulated raw_backlog debt indefinitely.
Add an ``elif`` branch that delegates to the engine's preflight hook
when the legacy threshold check does not fire. The default
``ContextEngine.should_compress_preflight()`` returns ``False`` so the
built-in ``ContextCompressor`` is unaffected; engines opting in get a
chance to ingest messages and request a single ``compress()`` pass for
deferred maintenance. Exceptions are swallowed at debug level so a
buggy engine cannot break an otherwise-healthy turn.
Closes#20316
- tests/cli/test_compress_type_ahead.py: end-to-end proof of the PR #68284
docstring claim — a prompt queued into _pending_input while /compress runs
survives compaction untouched and is the next item process_loop drains,
i.e. it is processed against the compacted history. Plus a structural
guard that handle_enter never gates on _command_running /
_command_blocks_input (read-only enforcement belongs solely to the
TextArea Condition; a busy-gate in handle_enter would silently drop
type-ahead input).
- tests/test_cli_manual_compress.py: update the one remaining _busy_command
stub (added on main after the PR branched) to accept the new
blocks_input kwarg.
- contributors/emails: map lucas@policastromd.com -> enzo2.
Follow-up to the salvaged #35533 fix: instead of parsing 'here [N]' only in
_mirror_slash_side_effects, parse it inside _compress_session_history — the
single helper all three manual-compress routes converge on (session.compress
RPC, command.dispatch /compress|/compact, slash-exec mirror). Every route now
honors the boundary-aware forms (here [N], up to here, --keep N) with the
same head/tail split + rejoin as cli.py and gateway/slash_commands.py, and
keeps the choke point's existing guards (lock-free LLM call, history_version
race check, deferred context-engine notification).
Tests: choke-point unit tests for 'here N', degenerate-split fallback, and
focus-topic passthrough, plus endpoint-level tests on each of the three
routes.
_mirror_slash_side_effects() passed the raw argument after /compress
directly as focus_topic to _compress_session_history. So /compress here
3 silently used "here 3" as a summary focus topic and did a full
compress instead of preserving the last 3 exchanges verbatim.
Fix: call parse_partial_compress_args() on the argument first. When it
detects a boundary-aware form (here, here N, up to here, --keep N),
split the history into head/tail using split_history_for_partial_compress,
compress only the head via agent._compress_context, and rejoin with
rejoin_compressed_head_and_tail — exactly mirroring cli.py and
gateway/run.py's /compress here implementation (PR #35252).
Non-boundary forms (plain /compress, /compress <focus>) fall through
to _compress_session_history unchanged.
Photon (managed iMessage) shipped without a _PLATFORM_DEFAULTS entry, so it
inherited the noisy global ('all') display defaults and narrated tool
progress / heartbeats / busy-ack detail into a permanent-message iMessage
thread. Register it as TIER_LOW alongside BlueBubbles and Signal, plus a
regression test guarding the tier.
Salvaged from #50511 (Photon tier piece only).
The PR's inline toolset resolution (checking 'memory' in cli_toolsets
list) produced wrong results for composite toolsets like 'hermes-cli'
which expand to include the memory tool. Replace with the canonical
_get_platform_tools() from tools_config.py which correctly handles
composite toolsets and all edge cases.
Update tests to mock _get_platform_tools instead of raw config.
The 'Built-in: always active' label was a hardcoded string that never
reflected the user's actual configuration. It now shows three separate
indicators, each reading from the real source of truth:
- Memory injection: reads memory.memory_enabled from config.yaml
- User profile: reads memory.user_profile_enabled from config.yaml
- Memory tool: checks if 'memory' is in platform_toolsets.cli
(or defaults to enabled if no explicit list)
Before:
Built-in: always active
After:
Built-in (MEMORY.md / USER.md):
Memory injection: disabled ✗
User profile: disabled ✗
Memory tool: disabled ✗
Invalid-tool exhaustion and truncated-tool early returns skipped finalize_turn, leaving role=tool transcripts that become tool→user on the next turn for strict providers. Call close_interrupted_tool_sequence before persist on those paths (same as interrupt aborts).
Hoist the duplicated check_info(source_id) call out of both
if/else branches into a single call after the branch. Remove
trailing whitespace on the blank line after the except block.
Follow-up cleanup for PR #69981.
Consolidate the two near-identical warning strings in
_log_wal_reset_bug_once into a single logger.warning call with an
action variable. Remove overengineered defensive tuple-length handling
in is_sqlite_wal_reset_vulnerable (sqlite3.sqlite_version_info always
returns a 3-tuple). Remove extra blank line.
Follow-up cleanup for PR #69981.
Assert the version matrix, fresh-DB DELETE fallback, already-WAL left
alone (no checkpoint/DELETE), fixed-SQLite WAL path, and warn-only
doctor output for vulnerable builds (#69784).
On vulnerable SQLite (e.g. 3.50.4), do not enable WAL for fresh/non-WAL
shared databases — prefer DELETE instead. Leave existing on-disk WAL
alone (no live downgrade under concurrent gateway/cron openers). Surface
Python/SQLite version details as a doctor warning (#69784).
Guard the globalThis gateway-state container and the survivor park/adopt
calls on the import.meta.hot literal (not the runtime hmrActive() helper),
so Vite dead-code-eliminates every HMR path in production. Prod now uses a
plain module-local singleton — no globalThis, no Symbol.for — and the
survivor module drops out of the bundle entirely. Verified: gatewayRegistryState,
gatewaySurvivor, and import.meta.hot are all absent from the prod build.
Removes the now-unused hmrActive() export.
Park the live primary gateway socket on Fast Refresh dispose and re-adopt it
on remount so dev UI edits don't tear down the WebSocket. Hold gateway store
singletons on globalThis + self-accept HMR on store/gateway.ts. Prod strips
import.meta.hot — live unmount unchanged.
console.error("::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently).");
"skill with skill_manage so you can reuse it next time.\n"
"When using a skill and finding it outdated, incomplete, or wrong, "
"patch it immediately with skill_manage(action='patch') — don't wait to be asked. "
"Skills that aren't maintained become liabilities."
"Skills that aren't maintained become liabilities.\n"
"\n"
"## Skill Safety Rule\n"
"1. **UNAVAILABLE** — If a skill placeholder contains `[SKILL_PRUNED]`, the skill content was lost in compression and is inaccessible.\n"
"2. **RELOAD** — Before performing any action that depends on a skill, re-check its content with `skill_view(name='...')` if it shows `[SKILL_PRUNED]`.\n"
"3. **WAIT** — If a skill is loading or was just pruned, wait for the reload confirmation before proceeding.\n"
"4. **DEDUP** — After reloading a pruned skill, **ignore any remaining `[SKILL_PRUNED]` markers for that same skill** — they are historical artifacts from previous compactions and do not need further action."
)
KANBAN_GUIDANCE=(
@@ -884,7 +890,14 @@ PLATFORM_HINTS = {
"You're responding through an API server. The rendering layer is unknown — "
"assume plain text. No markdown formatting (no asterisks, bullets, headers, "
"code fences). Treat this like a conversation, not a document. Keep responses "
"brief and natural."
"brief and natural."
"File/media delivery: images referenced as MEDIA:/absolute/path tags "
"(.png/.jpg/.jpeg/.gif/.webp/.bmp, up to 5MB) are inlined as base64 data "
"URLs in responses on the chat, completions, and responses endpoints. "
"Non-image files are NOT intercepted anywhere, and the runs endpoint "
"intercepts nothing — a MEDIA: tag there renders as literal text exposing "
"a raw host filesystem path. For those cases, state the plain file path "
"in your response text instead of a MEDIA: tag."
),
"webui":(
"You are in the Hermes WebUI, a browser-based chat interface. "
consttimer=setTimeout(()=>reject(newError(`Timed out after ${DEFAULT_TIMEOUT_MS/1000}s waiting for ${operation}:\n${this.stderr.join('\n')}`)),DEFAULT_TIMEOUT_MS)
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.