The kanban board has had full attachment storage and a dashboard HTTP
API (upload/list/download/delete) since #35338, but there was no agent
toolset tool and no `hermes kanban` CLI verb for attachments. Agents and
scripts that don't go through the dashboard server (or can't touch the DB
directly) had no way to create or read real attachments — only links in
comments.
Close that gap by mirroring the existing comment surface:
- `kanban_db.store_attachment_bytes()` — one shared write path (validate
name, enforce the 25 MB cap, write the blob under the per-task dir with
collision-free naming, insert the metadata row, clean up an orphan blob
if the insert fails). `_MAX_ATTACHMENT_BYTES`, `_safe_attachment_name`,
and a new `_collision_free_path` move here so the dashboard, the tool,
and the CLI all share one implementation and can't drift.
- Tools (`tools/kanban_tools.py`): `kanban_attach` (inline base64),
`kanban_attach_url` (server-side http/https fetch with the same cap),
`kanban_attachments` (list). Write tools respect worker task-ownership;
list is read-only. Registered in the `kanban` toolset.
- CLI (`hermes_cli/kanban.py`): `attach <id> <path>`, `attachments <id>`,
`attach-rm <attachment_id>`.
- Dashboard `upload_task_attachment` now imports the shared helpers and
uses `_collision_free_path` — behavior identical (still streams to disk
with the cap, still 413 on overflow).
- Docs (AGENTS.md, kanban-worker skill) and toolset membership updated.
Tests: tool round-trip + oversize + bad base64 + ownership; attach_url
against a local HTTP fixture incl. oversize-mid-stream and non-http
scheme rejection; CLI attach/attachments/attach-rm; shared-helper unit
tests; dashboard parity preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Server-side coverage for the critical-CSS shim (PR #36024 salvage):
- user theme → style block emitted with ONLY real bundle variable names
(--background-base/--midground-base from layerVars(),
--theme-font-sans/--theme-base-size from typographyVars()/index.css),
and an html,body rule expressed via those vars so runtime theme
switches never leave a stale canvas/font
- built-in / unknown / non-string active theme → no block
- malformed theme YAML and load_config() exceptions → no crash, index
still serves
- </style> breakout attempt in a theme value stays escaped
- mount_spa integration: block present in <head> for user themes,
absent for built-ins
A fallback chain entry can name its API key via key_env (or the
api_key_env alias) per the fallback-providers docs, but only the gateway
path resolved it — TUI/desktop, cron, and CLI setup fallbacks ignored it,
so a fallback provider whose key lives in a non-standard env var never
resolved on those surfaces.
Centralize the inline-api_key-then-key_env lookup in
hermes_cli/fallback_config.resolve_entry_api_key() and use it at all four
fallback resolution sites (tui_gateway, cron scheduler, gateway runner,
CLI setup mixin); the CLI mixin also gains the base_url passthrough the
other surfaces already had.
Salvaged from PR #43861 (surgical reapply — the original branch predates
the #65264 fallback restructuring).
The #62803 branch predates PR #64636's Telegram token-shape validation
on the messaging platform PUT endpoint; align the new guard test's
fixture with the validated format.
The Channels API (PUT /api/messaging/platforms/{id}) accepted and persisted
enabling a port-binding platform on a secondary profile while
gateway.multiplex_profiles is on — a config the gateway only rejects on its
next start, aborting startup with MultiplexConfigError for every multiplexed
profile.
Validate before any .env/config.yaml write and return 409 for the enable
attempt. Disabling and clearing env stay allowed so an already-invalid
profile can be repaired. The port-binding platform set moves to
gateway/config.py (PORT_BINDING_PLATFORM_VALUES) as the single source of
truth shared by gateway startup validation and the dashboard, so the two
policies cannot drift. Platform config mutations now get a names-only audit
log line.
Fixes#62791
_clean_reasoning_effort kept its own whitelist that stopped at 'max',
silently dropping 'ultra' from MoA slot configs. Route it through
hermes_constants.parse_reasoning_effort — the same one-source-of-truth
fix the salvaged commit applies to the gateway — so future effort
levels can't drift here either. Docs updated to list ultra.
Follow-up to salvaged PR #64012.
Switch provider and model together after setup-time auth failure. Serialize global auth-store merges under target-specific locks and preserve auth-to-shared lock ordering for profile OAuth refreshes.
tools_disable_enable_command filters platform-restricted toolsets out of
toolset_targets and prints an error for each, but the success summary at
the end is built from the raw targets list and only excludes unknown
toolsets and failed MCP servers. Running e.g.
hermes tools enable discord --platform telegram
prints the 'not available on platform' error followed by 'Enabled:
discord' for a toolset that was never written to the config.
Exclude restricted_targets from the success summary, matching how
unknown toolsets and failed MCP servers are already handled.
Two regression tests: a restricted toolset alone must not print
'Enabled', and a mixed allowed+restricted invocation must report only
the allowed toolset (both fail before the fix).
Disable address reuse so an existing family-specific listener cannot silently split traffic with the webhook server. Normalize wildcard bind hosts for local CLI URLs and align setup documentation with the dual-stack default.
Bug 2 of #28156: the picker offered us./global. inference profiles to
EU-region endpoints (unroutable — AWS rejects them regardless of
credentials) and _RECOMMENDED hardcoded us.anthropic.* ids, so non-US
pickers pinned profiles their endpoint can't invoke.
- bedrock_model_routable_from_region(): geo-prefixed profiles are only
offered in their own geography (full AWS prefix set incl. apac./jp./
ca./sa./me./af.); bare ids and global.* pass everywhere; unknown
region shapes hide nothing.
- Recommendations match geo-agnostically on the base model id, so an EU
picker pins eu.anthropic.claude-sonnet-4-6; in-region geo profiles
sort above global.* for the same model (addresses the global.*-first
ordering complaint from the issue thread).
- Dedup generalized from (us., global.) to all profile prefixes.
Follow-up hardening on top of #64158 (@DavidMetcalfe):
Backend (the root-cause fix):
- hermes_cli/moa_config.py: add validate_moa_payload() — strict write-time
counterpart to the deliberately tolerant normalize_moa_config(). Flags
half-filled slots, empty reference lists, recursive moa slots, naming the
exact preset/slot.
- hermes_cli/web_server.py: PUT /api/model/moa validates before normalizing
and returns 422 with the specific problems instead of silently swapping the
user's preset for hardcoded defaults (#64156). Also declares
fanout / reference_max_tokens / reasoning_effort on the Pydantic payload so
client round-trips no longer erase hand-set values.
Desktop:
- Replace sanitize-then-send with hold-while-incomplete: the debounced
autosave is deferred (not repaired) while any slot is half-filled, and
flushes once the model pick completes the edit. Mid-edit UI state is never
repainted by a save response (generation guard covers held edits too).
- updateMoaSlot only clears the model when the provider actually changed.
- Explicit preset ops (set default / add / delete) cancel the pending
autosave and invalidate in-flight responses so the two writers can't race.
- Stable row keys (preset+index) so mid-edit rows don't remount; cleared
model shows the 'Model' placeholder instead of vanishing.
Both TS clients' MoaConfigResponse types now declare the round-tripped
fields (fanout, reference_max_tokens, reasoning_effort).
Tests: 12 new backend unit tests (validate_moa_payload contract incl.
validate/normalize agreement), 3 new web_server endpoint tests (422 on
half-filled ref/aggregator, fanout round-trip), 3 new desktop vitest cases
(autosave held while half-filled, flush on completion, same-provider
reselect no-op). E2E validated against a live TestClient with isolated
HERMES_HOME: bug sequence now 422s with config untouched.
Fixes#64156
* fix(auxiliary): route direct-create aux callers through call_llm (#35566)
Five callers (kanban_decompose, kanban_specify, profile_describer, and
goals.py's judge + draft-contract) built raw clients via
get_text_auxiliary_client() and passed extra_body=get_auxiliary_extra_body()
— which only returns Nous portal tags and ignores
auxiliary.<task>.extra_body from config.yaml entirely. That was the
remaining half of #35566 after the call_llm path was fixed.
Routing them through call_llm(task=...) gives each caller the full
auxiliary contract for free: task extra_body, the reasoning_effort
shorthand, transient retries, provider-profile projection, and fallback
chains. goal_judge gains a DEFAULT_CONFIG block (it had none — its
provider/model overrides silently didn't exist as documented keys).
get_auxiliary_extra_body() now has zero non-test callers; kept for
plugin back-compat.
Fixes#35566.
* test: migrate kanban dashboard + CLI specify mocks to call_llm
Two more consumers of specify_task mocked the old
get_text_auxiliary_client symbol (missed in the first sibling sweep —
they live outside tests/hermes_cli's kanban files): the dashboard
plugin's /specify endpoint tests and the /kanban slash-command E2E.
Same migration as the rest: mock call_llm at the source, no-provider
now surfaces via the LLM-error branch.
Catalog entries now follow the same supply-chain rules as pyproject
dependencies:
- n8n: install.ref main -> full commit SHA 7a9ae007 (2026-05-23,
branches/tags can be moved by the upstream owner; SHAs cannot)
- new contract test: every shipped manifest must pin exactly —
git installs need a 40-char SHA, uvx/npx-style launchers need
pkg==X / pkg@X with a digit-leading version (rejects bare names,
ranges, and npm dist-tags like @latest)
- module docstring documents the pin policy (exact version, 2-week
cooldown)
unreal-engine and linear are http transports (server runs elsewhere)
so there is nothing to pin at the transport layer.
Verified: unpinning blender-mcp in the manifest makes the contract
test fail with a named diagnostic; restoring the pin passes.
Adds optional-mcps/blender (ahujasid/blender-mcp, stdio via uvx). The
server advertises 22 tools; 18 front optional asset services with no
upstream trim mechanism, so tools.default_enabled pins the install to
the core surface (scene/object info, viewport screenshot, code exec)
and the rest stay opt-in through 'hermes mcp configure blender'.
Manifests can now declare transport.env (static, non-secret subprocess
env vars), parsed/validated in _parse_manifest and written by
_build_server_config — used here to ship DISABLE_TELEMETRY=true per
the no-telemetry-without-opt-in policy. Runtime already honored
per-server env; manifests just couldn't declare it.
The remote model catalog (website/static/api/model-catalog.json) now labels
exactly one entry per provider block with "default": true — z-ai/glm-5.2 for
both OpenRouter and Nous Portal. That labeled entry is the model Hermes
silently lands on when the user never picked one, and it can be rotated by
editing the manifest alone: no release needed.
- model_catalog.py: get_default_model_from_cache() reads the label from the
in-process/disk cache only — never triggers a network fetch, so hot
resolution paths (agent build, gateway session setup) stay network-free.
- models.py: get_preferred_silent_default_model() resolves catalog label
first, PREFERRED_SILENT_DEFAULT_MODEL constant second (offline/fresh
install). _PROVIDER_SILENT_DEFAULT_OVERRIDES dict replaced by
_SILENT_DEFAULT_PROVIDERS routing through the shared resolver.
fetch_openrouter_models() preserves the "default" badge through live
/v1/models refreshes so the picker shows it.
- scripts/build_model_catalog.py: generator emits the default label so
regeneration can't drop it.
- website/docs/reference/model-catalog.md: schema documents the new field.
- Salvaged from PR #61141 (@HumphreySun98): bare-provider /model switches
(/model nous) route through the cost-safe default instead of curated
entry [0].
- tests: catalog-label precedence, constant fallback, stale-label fallback,
cache-only (no network) guarantee, and a shipped-manifest contract test
pinning the labeled entry to PREFERRED_SILENT_DEFAULT_MODEL.
E2E (temp HERMES_HOME): fresh-install constant fallback, shipped-manifest
label read, release-free rotation (relabeled cache -> new default across
models.py, tui_gateway, and gateway empty-model paths) all verified.
Review findings from the 4-angle pass:
- Unknown-but-enabled effort levels now collapse to Solar's strongest
(high) instead of silently downgrading to the medium default — guards
against the next #62650-style vocabulary addition. Explicit-empty
effort keeps the medium default.
- fallback_models test now asserts the behavior contract (non-empty, no
denied families) instead of freezing the exact model tuple
(change-detector, AGENTS.md reject reason).
- Drop unused pytest import in test_upstage_provider.py.
`UPSTAGE_API_KEY` / `UPSTAGE_BASE_URL` were wired through the provider
resolver, auth registry, and the EnvPage grouping, but never added to
`OPTIONAL_ENV_VARS` in hermes_cli/config.py. The dashboard/desktop
Providers page builds its list from that catalog (`/api/env` iterates
`OPTIONAL_ENV_VARS`), so with no entry the keys were never emitted and
"Upstage Solar" never rendered — the EnvPage prefix group stayed empty.
Add both keys under `category: "provider"` (matching gmi/minimax) so they
show up in `hermes dashboard` / `hermes desktop` under "Upstage Solar".
Adds a regression test asserting the catalog contains them, mirroring the
existing GMI coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Upstage Solar as a bundled model-provider plugin. Solar exposes an
OpenAI-compatible chat-completions endpoint at https://api.upstage.ai/v1, so
the generic chat_completions transport handles request/response/streaming/tool
calls — the profile is the core integration.
Provider registration (Upstage isn't in models.dev, so each registry that does
not auto-wire from the plugin layer needs an explicit entry — same pattern as
nvidia/gmi):
- plugins/model-providers/upstage/: UpstageProfile + plugin.yaml. Picker default
and offline catalog list only the agentic Solar Pro models, led by `solar-pro`
(rolling alias for the latest Pro). default_aux_model empty so aux tasks use
the main model. `solar` alias. UPSTAGE_BASE_URL overrides the host.
- hermes_cli/providers.py: HERMES_OVERLAYS + label + `solar` alias, so
resolve_provider_full('upstage') resolves (without this, an explicit
`provider: upstage` in config was dropped and fell through to auto-detect).
- hermes_cli/auth.py: PROVIDER_REGISTRY entry + `solar` alias, so `hermes
doctor` / resolve_provider recognise upstage (the static-registry path the
lazy profile-extension doesn't reliably cover at validation time).
- hermes_cli/models.py: CANONICAL_PROVIDERS entry places Upstage Solar in the
curated picker order (above the auto-appended `custom`).
- agent/model_metadata.py: context-window fallbacks (/v1/models omits
context_length); `solar-pro` carries the 128K Pro context as the catch-all.
Reasoning: UpstageProfile.build_api_kwargs_extras wires Solar's top-level
`reasoning_effort` (low|medium|high; xhigh/max→high). Reasoning-capable families
are solar-pro* and solar-open*; solar-mini/syn-pro never receive it. Defaults ON
at medium when unset (matches the /reasoning "medium (default)" label);
`/reasoning none` disables; explicit/saved settings are honored. No
reasoning_content echo handling needed (unlike DeepSeek/Kimi).
Web dashboard:
- web/src/pages/EnvPage.tsx: add an "Upstage Solar" provider group so
UPSTAGE_API_KEY / UPSTAGE_BASE_URL appear under LLM Providers (not "Other").
Docs/tests:
- .env.example: documents UPSTAGE_API_KEY / UPSTAGE_BASE_URL.
- tests: profile wiring, reasoning_effort mapping (pro/open/mini, efforts,
disabled, default-on), provider-resolver regression (resolve_provider_full /
get_provider / solar alias / overlay), `solar-pro` default.
Testing: pytest tests/providers tests/plugins/model_providers
tests/hermes_cli/test_upstage_provider.py tests/run_agent/test_provider_parity.py
tests/hermes_cli/test_api_key_providers.py; ruff clean. Verified end-to-end:
`hermes doctor` shows "Upstage Solar", and live chat works via both
`--provider upstage` and `--provider solar`. Reasoning wire format per
https://console.upstage.ai/api/docs/for-agents/raw. Platforms tested: macOS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review round 2 from @ethernet8023 on #61580:
1. The manifest list was a hardcoded root/ui-tui/web trio — desktop and
any future workspace escaped the skip key even though step 1's root
install hoists deps for every workspace. The list is now expanded
from the root package.json 'workspaces' globs (npm's own source of
truth): on the real repo that yields all 8 manifests incl.
apps/desktop, apps/bootstrap-installer, apps/shared, and the nested
ui-tui/packages/hermes-ink. Unreadable package.json falls back to
root manifests only (never skips more than main would install).
2. --prefer-offline dropped entirely (this branch no longer carries
#39399): local 3-run benchmarks on the repo's real manifests show
the flag is noise on npm ci with a warm cache (root: 0.90s vs 0.84s
avg; ws: 4.02s vs 4.00s avg) — npm ci does no resolution and the
content-addressed cache already serves tarballs locally. It also
carried the stale-resolution risk on the npm install fallback the
reviewer flagged. All the real win is the skip itself (0s vs ~5s+).
Tests: workspace-glob edit (desktop), literal-listed edit, and
new-workspace-under-glob all defeat the skip; verified against the
real repo's workspace config (8 manifests picked up).
Address the hermes-sweeper review of #61233: the bounded retry budget
is now a clean-exit-specific streak, not a share of the unified
consecutive_failures counter.
- detect_crashed_workers stamps a protocol_violation marker into the
violation run's metadata (via the event payload _end_run copies);
_protocol_violation_streak derives the streak from run history:
consecutive most-recent violation runs, rate_limited runs neutral
(mirroring their unified-counter treatment), any other closed run
resets it. Mixed failure kinds can neither consume nor extend the
budget.
- Below-budget violations no longer call _record_task_failure at all:
the task returns to ready with last_failure_error stamped directly
(including the corrective retry guidance wording adopted from #61817,
which build_worker_context surfaces to the retry worker) and the
unified counter is untouched, keeping the two budgets independent.
- At the bound the trip funnels through _record_task_failure with a new
keyword-only force_trip=True: the reaper has already resolved the
per-task max_retries override against the violation streak itself, so
the threshold comparison is skipped rather than double-applied.
max_retries keeps its documented top precedence in both directions:
max_retries=1 blocks on the first violation, max_retries=5 blocks on
the fifth consecutive one, unset uses the default bound of 3.
- Replace the first-violation-blocks regression test with five tests:
first occurrence retries (ready + guidance stamped + no gave_up +
unified counter untouched); streak trips exactly at the bound with
protocol_violations/protocol_violation_limit in the gave_up payload
and the auto-blocked side channel set; a prior nonzero crash does not
consume the violation budget; a non-violation failure between
violations resets the streak; max_retries precedence both directions.
All five fail against the previously reviewed diff and pass with this
follow-up. The test harness resolves hermes_cli.kanban_db fresh and
uses that single module object for the exit registry, liveness patch,
and reaper — earlier suite tests reload the module, and the old
mixed-object harness made _classify_worker_exit return unknown (the
reason the old test failed in full-suite runs on main).
Kanban suite: zero introduced failures vs upstream/main tip (62 vs 63
pre-existing environmental failures — the one no longer failing is the
old violation test this replaces; 662 passed vs 657).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
test_probe_sends_client_context_to_gemini and
test_probe_omits_gemini_client_context_for_other_providers (added in
b8eb89f5c) patch hermes_cli.models.urllib.request.urlopen, but
probe_api_models routes requests through the
_urlopen_model_catalog_request wrapper (open_credentialed_url from the
urllib_security hardening), so the mock is never invoked and
mock_urlopen.call_args is None -> TypeError. Every CI run on main and
every PR has been failing test slice 7/8 on these two tests.
Point the patches at _urlopen_model_catalog_request, the same target
every sibling test in TestProbeApiModelsUserAgent already uses.
89/89 tests in the file now pass.
Include the Hermes client name and version with Gemini inference, model and tier checks, and TTS requests. Add focused coverage for the request headers and keep the Gemini-specific context scoped to Google Gemini endpoints.
The desktop provider panel previously rendered the curated declarations
from hermes_cli/memory_providers.py: five hindsight fields, and no panel
at all for undeclared providers like honcho (OAuth connect only). The
dashboard provider-switching rework re-pointed the shared config route
at raw plugin schemas, so the desktop began dumping every internal field
(35 for hindsight) and grew a bespoke honcho panel.
Serve both surfaces from the same route: ?surface=declared returns the
curated schema (empty for undeclared providers) with the original
config-file + env-store write semantics; the dashboard keeps the raw
plugin schema unchanged. The desktop client opts into declared.
The dashboard's dedicated memory-provider UI (4b184cbe5) excluded
memory.provider from /api/config/schema server-side. Desktop's settings
page builds its field list from that schema, so the Memory Provider
dropdown silently vanished from Desktop after v0.18.1.
- web_server.py: restore memory.provider as a select in _SCHEMA_OVERRIDES,
with options built from plugins.memory discovery (was a stale hardcoded
[builtin, honcho] list before the removal)
- plugins/memory: add list_memory_provider_names() — directory-scan-only
name listing, safe at module import time (no provider imports)
- web ConfigPage: hide memory.provider client-side instead — the Plugins
page owns the dedicated provider-switching UI there
- tests: schema contract (select present, category memory, builtin
sentinel) + invariant that every discoverable provider is selectable
Reject metadata that would make session queries fail, bound import work, and detach cyclic lineage links. Guard lineage traversal against pre-existing corrupt cycles.