Follow-up to the #77848 salvage: mirror the curated model lists onto
the alibaba-cn / alibaba-coding-plan-cn / alibaba-token-plan-cn
profiles registered in #73345, and add all Alibaba variants to the
qwen provider group so they appear in the drill-down picker.
Token Plan (Personal Edition) model catalog for hermes model /
provider pickers, verified against a live Token Plan subscription
(2026-08-03). Provider profiles landed separately in #73345; this
carries the picker-list half of #77848.
Co-salvaged-from: PR #77848
The self-improvement review fork advertises the parent's full tool schema
(deliberate — tools[] must stay byte-identical for prompt-cache parity)
but denied everything except memory/skill tools at dispatch. Models
naturally reach for read_file to inspect a SKILL.md before patching, got
denied, then attempted a blind skill_manage patch which the
read-before-write guard correctly refused. One deployment logged ~142
denials + ~204 refusals over 2 days: the self-improvement loop ran
continuously but almost never landed a skill patch.
Fix is dispatch-side ONLY — zero request-body change, cache untouched:
- Whitelist read_file + search_files on the review fork (reads are
side-effect-free). Write tools (write_file/patch/terminal) stay denied:
autonomous maintenance must go through skill_manage's validation.
- read_file now registers full reads with the review fork's
read-before-write guard (same as skill_view), so the natural
read_file -> skill_manage(patch) sequence lands. Partial reads
(offset>1 / truncated) don't count. No-op outside review forks.
- Self-correcting deny message: names skill_view/skill_manage/memory as
substitutes so one denial redirects the model instead of a storm
(the actionable half of #61521's proposal 2).
Rejects #39997's alternative (narrow the advertised schema on local
endpoints): local backends have KV/prefix caches too, and re-prefilling
a large snapshot is most expensive exactly there.
Live A/B (real dispatch path, isolated HERMES_HOME): on main,
read_file DENIED -> patch REFUSED (read-before-write); on this branch,
read_file OK -> patch LANDED. tools[] identical in both.
The rename sweep in the base commit missed the sibling-test blast radius
(18 red files on CI). Three classes, all fixed:
1. Stale old names in tests (todo/cronjob/process/tour/tip) — updated to
todo_list/cronjob_manage/process_manage/gui_tour/show_tip at every
registry.get_entry/dispatch/coerce/preview/allowlist call site, plus
the coding-brief sentence in agent/coding_context.py now names
todo_list (and its gating test).
2. Missed rename in production: AGENT_RUNTIME_POST_HOOK_TOOL_NAMES still
held 'tour' — post-hook ownership would have double-emitted for
gui_tour via the bridge path.
3. Tests pinning pre-deferral assembly (blank-slate surface, modal
sandbox resolution, desktop diet, HUD note) now pin their ACTUAL
contract under the legacy defer:[] override, or assert on granted
tool names instead of visible schemas.
Also fixes a pre-existing ordering flake surfaced by the sweep:
test_holds_exactly_the_gui_affordances depended on whether an earlier
test had imported apply_layout_tool (registry-registered, not in the
static desktop_ui list) — now forces discovery and pins the full set.
649 tests green locally across all touched files, both orderings.
Review follow-up on #93911: the previous constant was set to 1_320_000 ms,
which is exactly the backend's maximum work budget (120s turn-lock wait plus a
600s attempt and its policy-gated re-run) rather than something greater than
it. After those bounded waits the handler still classifies the failure, builds
and runs the retry, serializes the terminal result, unwinds the temp-file and
lock scopes, and returns through the event loop -- so a turn that consumes
nearly the whole budget could still lose the race to the client timer and
resurface #93911 at the upper boundary, with the backend holding a typed
reason while Desktop reported its generic timeout.
The deadline is now composed from the three mirrored backend values plus an
explicit settlement/transport margin, so the arithmetic is visible instead of
being a magic number, and bot-relay-deliver-budget.test.mjs reads
config_defaults.py and methods_bot_relay.py to fail when a mirror drifts or
the margin stops being positive. Nothing in the type system links a JS
constant to a Python default; that test is the seam.
Also adds an adversarial virtual-clock regression: a gateway that answers only
after the full ceiling plus settlement is rejected by a deadline set at the
ceiling and accepted by one with margin.
host.requestProfile() had no way to express a per-call timeout, so every
routed plugin RPC fell to the gateway pool's generic 30s deadline. The
bot_relay.deliver contract is much longer: the backend holds the turn lock
(bot_mode.turn_wait_seconds, default 120s) and then runs a 600s turn, doubled
when the retry policy grants one bounded re-run, so methods_bot_relay.py
documents ~1320s as the bound a client must tolerate. Long turns (Computer
Use, deep research) were therefore killed at 30s and reported back as
unclassified failures rather than the typed reason the backend had classified.
requestGatewayForAgent()/requestGatewayForProfile() already accept timeoutMs;
only the two SDK layers above them dropped it. Thread it through and pass the
documented bound at the bot_relay.deliver call site. The argument is omitted
entirely when unset, so every other caller stays on the pool default.
Extends the real-profile machinery (PR #95620) to Brave Origin — Brave's
standalone paid build with a fully separate install identity:
- new canonical key 'brave-origin' in _CHROMIUM_BROWSERS
- Windows: BraveOHTML ProgId -> brave-origin; channel ProgIds BraveOBHTML/
BraveODHTML/BraveOSHTM fail closed (identifiers from brave-core
install_static)
- macOS: com.brave.Browser.origin bundle id (exact match); .beta/.dev/
.nightly channel bundles fail closed; /Applications/Brave Origin.app
- Linux: brave-origin.desktop matched BEFORE the bare 'brave' fragment
(substring scan would otherwise resolve an Origin default to stable
Brave and drive the wrong profile — #95549 wrong-principal invariant);
brave-origin-{beta,nightly,dev} fail closed
- profile dirs: BraveSoftware/Brave-Origin on all three OSes (per
brave-core kProductPathName + Homebrew cask zap paths)
- /browser connect launch tables: Brave Origin split into its OWN group
so a 'brave' executable lookup can never resolve to the Origin binary
- user-facing strings/docs/desktop tooltip updated
Tests: progid/bundle/desktop map params + data-dir resolution for all
three OSes; 125 passed in the three browser test files.
The synced identity text carries em-dashes, but install.ps1 must stay
pure ASCII (Windows PowerShell 5.1 reads BOM-less .ps1 in the ANSI code
page; a non-ASCII byte in a string literal desyncs the parser — see
tests/test_install_ps1_ascii_only.py, issues #66994/#67000). Seed the
ASCII-dashed variant there instead, and register that variant in
_LEGACY_TEMPLATE_SOULS so Windows installs converge onto the canonical
em-dash text on first run.
DEFAULT_AGENT_IDENTITY was rewritten in agent/prompt_builder.py (behavior
spec, exploration-thrift line deliberately removed) but the actual seed
written to disk on first run, hermes_cli/default_soul.py's
DEFAULT_SOUL_MD, was never updated. ensure_hermes_home() writes
DEFAULT_SOUL_MD into SOUL.md on every fresh install before the agent's
first turn, so virtually all real users end up as "SOUL.md users" seeded
with the pre-rewrite text -- including the exact "targeted and efficient
exploration" line the rewrite explicitly banned -- while the new
DEFAULT_AGENT_IDENTITY fallback essentially never serves the "fresh
install" audience its own PR body named as the target.
- DEFAULT_SOUL_MD now matches DEFAULT_AGENT_IDENTITY exactly.
- The pre-rewrite text is added to _LEGACY_TEMPLATE_SOULS so installs
already seeded with it self-heal via the existing upgrade-in-place
mechanism (same guarantee as the comment-only scaffold entries: the
string carries zero user intent, so it's safe to replace).
- Synced the other places install.sh's own comment says "MUST match
DEFAULT_SOUL_MD": scripts/install.sh, scripts/install.ps1,
docker/SOUL.md, and the docs/i18n pages that quote the fallback text
verbatim.
Live-reproduced on main: /handoff poll-waited a flat 60s for a TERMINAL
state, but the gateway's dispatch is a full synthetic agent turn (whole
transcript replay + delivery) that routinely exceeds 60s on long sessions.
The CLI then printed "Timed out waiting for the gateway. Is `hermes
gateway` running?" (false diagnosis), called fail_handoff() on the RUNNING
row (stomping the gateway's claim), and promised "Your CLI session is
intact" after switch_session had already re-pointed the session. The
watcher later overwrote failed -> completed: split-brain.
- hermes_state.fail_handoff gains only_states CAS; waiters can only fail
rows still pending. Owner (gateway watcher) keeps the unconditional form.
- CLI wait loop is two-phase: 60s for the CLAIM (pending) — a timeout
there really does mean no gateway — then up to 15 min for the claimed
dispatch with 30s heartbeats; a running row is never failed by the CLI.
- Desktop handoff.fail RPC now CAS-fails pending rows only; a running row
returns {failed: false, state: running} instead of stomping the claim.
Repro (real _handoff_watcher, real state.db, CLI as separate process,
75s dispatch): before — CLI timeout @60s + false message + row stomped;
after — pending->running@5s->completed@80s, clean CLI exit.
Sweeper review, all three points:
- Placement: no new plugins/model-providers/ directory. The Token Plan
profiles register from the existing alibaba plugin module — one module
per vendor, matching how the kimi module carries both of its endpoint
variants. Token Plan is the same vendor/service (Model Studio), same
OpenAI-compatible protocol, its own key + endpoints; splitting to a
standalone repo remains a 5-minute change if maintainers prefer.
- Runtime coverage: TestRuntimeAlibabaRegionalAndTokenPlan exercises
resolve_runtime_provider() for all four variants — provider, api_key,
api_mode, base_url — alongside the existing zai/minimax/kilocode
runtime regressions.
- Docs: providers.md, environment-variables.md, cli-commands.md updated
with the bundled variants and their env keys/base-url overrides.
The models.dev catalog advertises alibaba-cn, alibaba-coding-plan-cn, and
alibaba-token-plan(-cn), and resolve_provider_full()'s catalog chain lets
the CLI --provider path resolve them — but auth.resolve_provider() (the
credential/runtime path used by 'hermes chat') consults only
PROVIDER_REGISTRY and raised "Unknown provider 'alibaba-coding-plan-cn'"
(hermes_cli/auth.py:1937). PROVIDER_REGISTRY auto-extends from provider
profiles (auth.py:461-490), so the fix registers the missing profiles at
that chokepoint: alibaba-cn joins the alibaba plugin, alibaba-coding-plan-cn
joins alibaba-coding-plan, and a new alibaba-token-plan plugin registers
both regional token-plan tiers. Names match the catalog keys exactly.
No core edits — plugins/model-providers is the designed extension path.
9d9f44d638 removed the desktop platform hint's "never hand-edit
mcp_servers config for them" sentence, reasoning it was a "word-for-word
duplicate of the setup_mcp tool schema... taught on every call." The
schema has never contained that instruction — only "never re-ask after
a decline." setup_mcp is desktop_ui-toolset-only and no runtime guard
in agent/file_safety.py covers mcp_servers config, so removing the only
place teaching this left a real gap: a model asked to add/configure an
MCP server could just write_file into mcp_servers config directly,
bypassing the consent-card/OAuth flow the tool exists to enforce.
Restored the instruction directly in SETUP_MCP_SCHEMA's description —
completing the original commit's stated intent (move it to the schema)
rather than reverting to the platform hint, since the schema reaches
every setup_mcp call regardless of platform hint wording changes.
Added a regression test asserting the schema description forbids
hand-editing mcp_servers config, so a future prompt-diet pass can't
silently drop it again without a test failing.
74a95a3ddf promoted /bg and /btw to independent canonical commands and
retired /background entirely (hermes_cli/commands.py's COMMAND_REGISTRY
has no "background" command or alias). Three places still taught the
old name:
- skills/autonomous-ai-agents/hermes-agent/references/slash-commands.md:
the bundled hermes-agent skill's own slash-command reference — the
skill's SKILL.md explicitly routes the model here for in-session
command questions, so a model following it would emit the dead
`/background <prompt>` and never learn /btw exists.
- ui-tui/README.md: listed /btw as an alias of /background, which is
simply wrong post-split (both are independent, alias-free commands).
- tests/cli/test_cli_background_status_indicator.py: docstring/comments
described the ▶ indicator by the retired command name.
No behavior change; corrects documentation only.
CLI, ACP, and the desktop app all got nested-subtask rendering (the
optional `parent` field on a todo item), but the TUI never did. Its
TodoItem type had no `parent` field, parseTodos() in turnController.ts
dropped it even if the tool payload sent it, and TodoPanel rendered the
list with a flat map() and a single fixed indent — a session using
nested subtasks showed every subtask at the same visual level as its
parent, with no hierarchy cue, in the terminal UI.
- types.ts: add the optional `parent` field to TodoItem, matching
apps/desktop/src/lib/todos.ts's TodoItem exactly.
- turnController.ts: parseTodos() now preserves parent (trimmed,
dropped if empty or self-referential), the same normalization
desktop's parseArray() applies.
- lib/todo.ts: port todoTree() from apps/desktop/src/lib/todos.ts
verbatim — same DFS-with-depth algorithm, same dangling/cycle
handling, so both surfaces render identical hierarchy from the same
`parent` field.
- todoPanel.tsx: render todoTree(todos) instead of a flat map(), with
per-row indentation scaled by depth (capped at 4 levels, mirroring
desktop's status-row.tsx cap).
Maintainer A/B (288 live runs, 3 model tiers, results in the PR body):
with the clarify schema visible, models used structured ask-the-user
18/18 on ambiguous tasks (score 1.00 all models). Deferred, usage
collapsed to 7/18 (gpt-terra 0/6) — models still asked, but as
plain-text turn-ending questions: no structured choices, no recommended
option, an extra user round-trip. The ask-the-user affordance has to be
ambient to fire; a catalog stub is not enough (~250 tok to keep eager).
- _DEFAULT_DEFERRED_TOOLS: remove clarify (19 -> 18 deferred)
- regression test pins clarify ∉ default defer set AND assembles direct
while the bridge is active (sabotage-verified: fails with clarify
in the set)
Flip the salvaged --start-now behavior (PR #97958) into the unconditional
default: /loop's first iteration is due the moment the loop is set, then
recurs on the normal cadence. The flag is dropped — it was never released,
so there is nothing to deprecate.
- LoopManager.set(): next_due_at = now for both cadence modes
- drop --start-now parsing, the persisted LoopState.start_now field, and
the flag from help text; confirmation now always says the first wakeup
fires now
- tests updated to pin the new default (incl. the TUI not-due test, which
now has to push next_due_at out explicitly)
- docs: quick-start and command table describe the immediate first run
/loop [interval] <prompt> currently schedules the first wakeup one full
interval after the command runs (next_due_at = now + interval). When the
user just told Hermes what to check, waiting the whole interval before
any output feels like the command was ignored.
Add an opt-in --start-now flag that keeps Claude Code parity as the
default but lets the user run the first iteration immediately, then
continue on the cadence:
/loop 1h check the deploy status # first run in 1h (unchanged)
/loop 1h --start-now check the deploy # first run now, then hourly
- parse_loop_args(): parse and strip --start-now (leading or trailing)
- LoopState: new persisted start_now field (default False, survives
serialization round-trip and old rows missing the field)
- LoopManager.set(): next_due_at = now when start_now, for both fixed
interval and self-paced modes
- dispatch_loop_command(): wire start_now through, update help text, and
report "First wakeup fires now" in the confirmation
- website/docs: document the flag in the /loop guide
- tests: parse (trailing/leading/absent/self-paced/combo/prompt-word),
tick lifecycle (due immediately vs after interval), serde round-trip,
and dispatch-level confirmation
The inline set-comprehension re-implemented modelName extraction that
_extract_model_name() already provides (and that both
union_with_portal_free/paid_recommendations already use). Beyond the
duplication, the inline str(entry.get("modelName", "")) stringified
non-string values — a malformed Portal entry with modelName 5 would have
produced a garbage "5" match that discard("") does not filter. The helper
isinstance-checks and returns None for those, so routing through it makes
the validation tier semantically identical to the union helpers.
Adds test_non_string_model_name_entries_ignored locking the behavior
(mutation-checked: fails on the raw-stringify form, passes on the helper).
Fixes#71312 (duplicate #71313).
When selecting a model via the Telegram /model picker (or any other
messaging-platform slash command, since they all share
validate_requested_model() through gateway/slash_commands.py ->
model_switch.switch_model()), a model available via Nous Portal's live
/api/nous/recommended-models endpoint but not yet in the hardcoded
curated catalog (_PROVIDER_MODELS["nous"]) was rejected with "was not
found in this provider's model listing" -- even though the exact same
model works fine via `hermes chat -m <model> --provider nous`.
Root cause: `hermes chat` merges Portal recommendations into its model
list via union_with_portal_free_recommendations() /
union_with_portal_paid_recommendations() at model-list build time
(hermes_cli/auth.py, web_server.py, model_setup_flows.py,
model_switch.py), so the model already appears "known" by the time
validation runs for that path. validate_requested_model() itself,
which every per-message /model command goes through, only checked the
live /v1/models listing and the curated catalog (_model_in_provider_catalog) --
never the Portal recommendations feed -- so a model that exists only
in Portal Recommendations was rejected on that path specifically.
Fix: add a Nous-specific fallback tier in validate_requested_model(),
checked after the curated-catalog fallback and before the final
rejection, reading the same fetch_nous_recommended_models() feed
(free + paid tiers) the CLI union helpers already use. Scoped to
provider == "nous" only; short-circuits before the network call when
an earlier tier already accepted the model; fails closed (rejects,
doesn't crash) if the Portal feed is unreachable.
Reported two issues filed 3 minutes apart with identical content by
the same author (#71312, #71313) -- commented on #71313 marking it a
duplicate of #71312 and pointing to this fix (could not close it
directly, no admin rights on the repo from this token).
6/6 new tests pass in TestValidateRequestedModelNousPortalRecommendations;
95/95 in the full tests/hermes_cli/test_model_validation.py file;
87/87 in tests/hermes_cli/test_models.py (unaffected, confirmed).
Closes#75364.
`_compress_context_via_codex_app_server` returns the transcript unchanged
when the codex thread reports `interrupted` or `error`. The session is
therefore still above threshold, and nothing records that the attempt
failed — so the next turn retries immediately, and keeps retrying for as
long as the condition persists.
Every other compression path arms the shared failure cooldown, records an
ineffective-compression strike, or both. This path records neither:
* `_hygiene_compression_failure_cooldowns` is set only on
`asyncio.TimeoutError`, or behind `_last_compress_aborted`, which is
assigned exclusively in `context_compressor.py` on the Hermes summarizer
path.
* `compression_ineffective_count` lives in `ContextCompressor`, and this
path returns before any compressor bookkeeping runs.
`compress_context` already documents the rule this path was missing —
"Every automatic entrypoint must honor compressor-owned cooldown and
breaker state" — but the codex branch dispatches above that block and
returns from inside it.
`result.interrupted` needs no unusual configuration to occur: an ordinary
user message arriving mid-compaction sets it (see
`codex_app_server_session.py`, which produces the "compact turn
interrupted" string). Observed in production on a Discord gateway session
at ~315k tokens against a 258k window, where compaction was attempted on
essentially every turn for ~70 minutes; the session's
`compression_ineffective_count` was still 0 afterwards.
This reuses the existing cooldown rather than adding a new mechanism:
* arm `_record_compression_failure_cooldown` with the existing
`_SUMMARY_FAILURE_COOLDOWN_SECONDS` when compaction returns
interrupted/error;
* honor an active cooldown on entry, matching the Hermes path.
`force=True` bypasses both, so an explicit /compress is never braked by a
failure it did not cause, and a successful compaction arms nothing.
The chat_completions and transport-parity Mistral tests pinned the
same branch with different reasoning_config shapes ({effort:none} vs
{enabled:False, effort:none}) — behaviorally identical since effort
short-circuits first, but the drift reads as a semantic difference.
Align both to the explicit form. Also scope the port-guard comment to
the try/except shape it actually shares with hermes_cli/models.py.
urlparse raises ValueError on non-integer / out-of-range ports, and
http://myhost:99999/v1 passes OpenAI-client construction (only httpx
rejects it later), so the crash was reachable from build_kwargs on
every request for such a URL. Wrap the parsed.port check in the same
try/except ValueError guard hermes_cli.models already uses around its
11434 check, and pin it with parametrized tests.
reasoning_effort: none was injecting extra_body.think=false for every
custom provider. Mistral (and other extra=forbid hosts) reject that
field with HTTP 422. Keep think=false on Ollama URLs only; still send
top-level reasoning_effort=none so /v1 thinking-off keeps working.
Companion to the connector's delete op (gateway-gateway 119a228). The
fresh-final unfurl route re-posts the completed reply and previously left
the sealed streamed preview behind (double delivery). delete_message now
emits op=delete when the negotiated descriptor advertises it; without the
advertisement it returns False with zero wire traffic, degrading to the
old leave-the-preview behavior against older connectors.
Consumer-level test drives placeholder -> stamped fresh final -> delete
of the original preview id.
Slack evaluates link previews exactly once, at chat.postMessage (live
probe 2026-08-28: URL at post + stamps unfurls; a chat.update that
INTRODUCES the URL never does, stamped or not). Edit-based streaming
posts its first frame before the model produces any URL — on flat DMs
with tool_progress=accumulate that frame is the task card — so a
configured unfurl_links/media: true could never surface a preview:
the only post Slack evaluates carries no link.
RelayAdapter now implements prefers_fresh_final_streaming(): True only
when the Slack unfurl hints contain an explicit True AND the final text
carries a link. The stream consumer then delivers the completed reply
as one fresh send — URL and stamps present at the single moment Slack
looks. False-only hints (enterprise fail-closed posture) keep the edit
lane untouched: suppression rides the placeholder post and edits can
never add a preview, so false inherits with zero streaming-UX cost.
Consumer-level contract test drives the exact regression shape
(placeholder frame -> URL-bearing final) and asserts op=send + stamps;
verified RED against the unfixed adapter, GREEN with the hook.
The initial /btw implementation (#97937) answered from a rendered
plain-text transcript digest — truncated context, cold-written tokens on
every question. Teknium's call: reuse the self-improvement review fork
instead, which keeps the entire prompt cache stable for the fork and
gives it the complete conversation for very cheap.
- agent/background_review.py: extract the review-fork construction into
build_cache_parity_fork() — same runtime/credentials as the parent,
byte-identical system prompt / tools[] / reasoning config on the
same-model path, shared session_id for prefix warmth, full persistence
detachment (no state.db writes, no rotation, no external memory,
in-place-only compaction). The review thread now calls the helper;
behavior unchanged (full review test suite green).
- agent/side_question.py: /btw prefers the fork when a live parent
AIAgent exists — replays the untruncated snapshot as warm cache reads,
denies every tool at dispatch via an empty thread whitelist (tools[]
stays byte-identical for cache parity), attributes usage to the parent,
and trims a mid-turn snapshot tail so role alternation holds. The
one-shot digest remains as fallback (no live agent = cold cache anyway,
and any fork failure degrades gracefully).
- CLI passes self.agent, TUI passes the session agent, gateway looks up
the chat's cached agent (parity with how turns reuse it).
Live-verified: /btw on the worktree runs the fork path (agent.log shows
the side question as a forked conversation turn on the parent session_id
with the full history replayed), answers correctly from context.
Follow-up for salvaged PR #96939: main already added hy4-preview at
1_048_576 (f7c79efbac); the cherry-picked duplicate key later in the
dict silently overrode it with 1024000.
Review finding on salvaged #28253: the hand-rolled mapping sent
ultra -> medium while xhigh -> high (stronger request, weaker wire
value). Declare NEBIUS_EFFORTS in agent/reasoning_effort.py and use
clamp_effort like the zai/kimi/tokenhub call sites; disable detection
stays ahead of the clamp since clamp_effort('none', ...) returns the
floor, not off. Adds a monotonicity regression test.