feat(agent): adaptive model routing — content-based profile selection - #2
Open
TechPrototyper wants to merge 10000 commits into
Open
feat(agent): adaptive model routing — content-based profile selection#2TechPrototyper wants to merge 10000 commits into
TechPrototyper wants to merge 10000 commits into
Conversation
Live overlay always placed sessions under `::branch::main`, while the backend non-git heuristic keys the lane by folder path (label = basename). Overlay missed that lane by id/label and forked a phantom `main` group with the same sessions. Match existing path-keyed isMain lanes before creating a branch-style main lane. Covers the codex-research-guardian-style drill-in duplicate.
…rktree When a session's cwd moves from the main checkout to a newly created worktree, overlayRepoLanes places it into the matching worktree lane but never removed the stale entry from the main lane. The session appeared under both groups until the user left and re-entered the project view. Add a cross-lane eviction loop that removes the session from all other lanes before inserting it into the target lane.
Non-repo explicit projects (plain folders) get a main-checkout lane whose label is the folder basename, not a branch. Clicking "+" (new session) on such a lane calls switchBranchInRepo -> switchBranch, which sanitizes the basename to "" and throws "Branch name is required.", aborting the session creation. Short-circuit switchBranch for roots that are not git work trees so callers proceed with a plain session. Fixes NousResearch#83028
Creating a project whose resolved primary path already belongs to a non-archived project now raises a clear ValueError naming the existing project (create_project) — duplicated projects each seeded an identical copy of the repo subtree, multiplying the duplicate-lane bug per copy. The agent-facing project_create tool is idempotent instead: it re-activates the existing project rather than erroring. allow_duplicate_path=True keeps deliberate duplicates possible. Also updates the legacy non-git lane-id expectation to the branch-style id introduced for NousResearch#53329.
Cover the kanban ::kanban id, the -wt- suffix raw-path lane, and Windows separator/trailing-slash spellings collapsing to one lane key.
…olumn The salvaged NousResearch#76716 adds git_metadata_generation to sessions (54 -> 55 columns). Update the synthetic-rebuild test's pinned widths and row builders to the new current layout.
(cherry picked from commit 40b0e93)
…list With 25+ sessions the recents list virtualizes into its own nested scroller inside the sidebar's scroll container. Both carried overscroll-contain, so once the inner scroller hit a scroll boundary the wheel gesture was consumed instead of chaining to the outer sidebar scroller — read as a mid-list wheel dead-zone while scrollbar drag kept working. Drop the containment on the inner scroller only; the outer sidebar scroller keeps overscroll-contain so the gesture still never escapes the sidebar. Fixes NousResearch#84964
Reference prototype for the upcoming 'replace window.confirm/prompt/alert in the kanban plugin' work. Self-contained HTML — open in any browser, no build step. Served at docs/design/kanban-dialogs/index.html. Four variants side-by-side, each rendered in the same kanban context: - A (Conservative): direct host ConfirmDialog mapping, minimal chrome - B (Strong-fit, Pro's pick): textarea + SVG icon + inline validation - B-refined (synthesis, recommended): auto-focus, dual-validation, cancellable spinner during PATCH, per-task summaries in bulk-many - C (Divergent): undo toast for non-destructive moves Decision matrix at the bottom of the page. Copy is verbatim from web/src/i18n/en.ts (confirmDone, confirmArchive, confirmBlocked, trash.confirm, completionSummary, etc.). Design pass: Gemini 3.1 Pro (initial brief) + GPT-OSS 120B (cross-vendor review). Not shipped to users — review reference only.
…elete on plugin SDK Additive expansion of window.__HERMES_PLUGIN_SDK__. Plugins can now render host-styled dialogs, confirmations, and toasts instead of falling back to window.alert/confirm/prompt. New components: Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, ConfirmDialog, Toast. New hooks: useToast (replaces showToast/toast pair), useConfirmDelete (single-id delete-confirm state machine). SDK_CONTRACT_VERSION unchanged at 1.1.0 — additive surface per sdk.d.ts:23-25 (no major bump required). Consumer: kanban plugin's 'replace native dialogs' work, see issue NousResearch#50547. A reference prototype showing 4 design variants is committed at docs/design/kanban-dialogs/index.html. Adds web/src/plugins/registry.test.ts (3 vitest cases) that smoke-test the new keys are wired and that the version constant is unchanged.
Migrates 8 of 12 native dialog call sites in the kanban dashboard plugin to the SDK's ConfirmDialog primitive (added in PR NousResearch#50550): - moveTask, moveSelected, applyBulk, deleteTask, deleteSelected, archiveBoard, removeAttachment, doPatch The 4 remaining carve-outs (window.prompt for completion summary, window.alert for missing summary, cli_hint clipboard fallback) are documented inline — the host's ConfirmDialog hardcodes onClick → unmount, preventing the keep-open-across-validation behavior the completion-summary form needs. Followup: upstream a `disabled` prop to ConfirmDialog and rebuild the completion body using host Dialog components. New architecture: - useKanbanDialogs(t) — Promise-based dialog state machine at KanbanPage scope. request({kind, ...}) returns {confirmed, summary?}. - KanbanDialog component — renders ConfirmDialog from SDK for kind=confirm. - performMoveTask(taskId, newStatus, count, summary) — extracted shared dispatch path for single + bulk moves (optimistic UI + PATCH/POST + error recovery). - requestDialog prop threading — KanbanPage → BoardSwitcher, TaskDrawer → TaskDetail → doPatch/AttachmentsSection. Every call site has a defensive fallback to window.confirm if the prop is missing (verified by test_dashboard_done_actions_prompt_for_completion_summary counting the cancel guards + destructive:true markers in the bundle). New host i18n keys (web/src/i18n/en.ts + types.ts): - kanban.confirmDoneMany / confirmArchiveMany / confirmBlockedMany - kanban.trash.confirmTitle / confirmManyTitle Tests: - Replaced bundle-string-only completion-summary test with behavioral coverage: bundle cancel-guard count + destructive marker count, plus backend tests that confirm cancel preserves old status and confirm dispatches the expected PATCH/DELETE body. - Removed the SDK_CONTRACT_VERSION snapshot test from web/src/plugins/registry.test.ts (forbidden by AGENTS.md "Don't write change-detector tests"; the two remaining tests in that file already cover the new SDK surface behaviorally). Closes NousResearch#50547 (consumers of NousResearch#50550). Cross-vendor re-review: Gemini 3.5 Flash + GPT-OSS 120B (both SHOULD-FIX, no remaining BLOCKERs after these fixes).
NousResearch#73319) Config settings auto-save on a 550ms debounce with no undo. The 'Enabled Toolsets' list is rendered by the generic ConfigField with no destructive-change guard, so a stray select-all + Backspace (or any edit that empties the list) is persisted the moment Settings closes — silently disabling memory, terminal, web search, delegation, and most tools. Recovery required CLI intervention. Guard the one destructive transition: when the enabled-toolsets list goes from non-empty to empty, window.confirm() before applying it (the same pattern env-var removal already uses in toolset-config-panel.tsx). Every other edit passes through untouched. The decision is a pure helper (clearsEnabledToolsets) so it is unit tested directly rather than through a full settings render. New i18n key toolsetsWipeConfirm added to en + zh; partial locales inherit the English string via defineLocale fallback. Fixes NousResearch#73319
Deleting a session in the desktop app fired instantly on click — the CLI path (hermes sessions delete) asks y/N by default, so one misclick (Archive and Delete sit right next to each other) permanently destroyed a conversation with no dialog and no undo (NousResearch#61470). Route every delete entry point (sidebar rows, tab menus, the chat header, context menus — all share useSessionActions) through a shared DeleteSessionDialog built on ConfirmDialog. ConfirmDialog gains an onOpenAutoFocus prop so dialogs with no input keep focus off the close button (a11y). Tests: menu delete now asks; cancel keeps the session; Enter confirms; Escape cancels; delete item disabled without onDelete; the same guard applies via SessionContextMenu.
…eady took Hermes Console registers `checkpoints prune`, `clear` and `clear-legacy` as mutating, so it takes a console-level confirmation before dispatching any of them. `_apply_confirmed_defaults` then exists to keep the CLI layer from asking a second time — its docstring says so — but it only force-defaults `clear` and `clear-legacy`. `prune` was left out, even though `cmd_prune` gates its orphan preview on the identical `not args.force` shape. `_capture_output` redirects stdout and stderr but never stdin, so the unskipped `_confirm()` call hits `input()` with no terminal behind it: `EOFError` propagates into `_confirm`, which returns False, and `cmd_prune` prints "Aborted." and returns 1. The console turns that non-zero exit into a ConsoleCommandError, so `checkpoints prune` fails outright for any user who has at least one orphan checkpoint project — after that user already confirmed. When the server does happen to inherit a foreground terminal, the same call instead blocks a console worker thread and eats the operator's keystrokes. Forcing the flag is the documented behavior here rather than a weakening of the recent orphan-allowlist hardening. `orphan_allowlist` binds a deletion to the identities shown in the preview, guarding the window where a workdir disappears while the command waits on `input()`. Under the console there is no preview and no wait, which is exactly the `--force` case the comment on `cmd_prune` describes as "no restriction".
…fetches A session deleted in the sidebar disappeared optimistically but flashed back when any list fetch raced the in-flight DELETE RPC — the backend page still carried the doomed row until the transaction committed (NousResearch#50928, reproduced with 'Load more' and auto-refresh). The optimistic tombstone ($removedSessionIds) was only honored by the recents slice of refreshSessions; the messaging slice, the per-platform pager, and refreshMessagingSessions ingested backend pages unfiltered. Extract the tombstone filter into dropTombstoned() and apply it at every session-list ingestion point. Tombstones only exist while a delete or archive is in flight (they self-clear on confirmation and are removed immediately on failure), so non-destructive refresh paths are untouched. Fixes NousResearch#50928
…ack) The salvaged kanban ConfirmDialog added confirmDoneMany/confirmArchiveMany/ confirmBlockedMany to types.ts and en.ts only; the strict locale type requires every locale to carry them. English fallback pending translation.
Check event.nativeEvent.isComposing in Enter-to-submit branches so CJK (Japanese, Chinese, Korean) and other compositional input methods commit the candidate instead of firing the send handler. Applied to all five sites in the desktop renderer that currently handle Enter with no composition guard: - chat composer main submit and trigger popover (apps/desktop/src/app/chat/composer/index.tsx) - message edit composer submit and trigger popover (apps/desktop/src/components/assistant-ui/thread.tsx) - onboarding API key and auth code inputs (apps/desktop/src/components/desktop-onboarding-overlay.tsx) - session rename input (apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx) Fixes NousResearch#37483
macOS Chinese IME (and some 3rd-party Windows IMEs) emit Enter with keyCode 229 (legacy VK_PROCESSKEY) after compositionend, while isComposing is already false. The existing guard only checked isComposing and the composingRef, so this Enter slipped through and submitted the message before the committed text was fully in the DOM. Add an explicit keyCode 229 check in handleEditorKeyDown. keyCode is deprecated, but it is the only reliable signal for this IME commit Enter on Chromium-based browsers. Includes a dom-repro test that simulates the macOS IME sequence. Fixes: "中文混英文按 Enter 直接上屏"
…he composer A missed compositionend (focus jump, input-source switch, programmatic DOM swap mid-preedit) left composingRef stuck true, and the stuck flag silently swallowed every Enter in handleEditorKeyDown and every Send-button submit via the form onSubmit guard — no error, no RPC, until the composer remounted. For CJK IME users (where even ASCII typing runs through composition) this read as "Enter has no effect; messages cannot be sent", degrading composer instance by composer instance. Recover in two places, both grounded in invariants Chromium guarantees: - keydown: every keydown during a genuine composition carries isComposing=true, so when the native flag says we're not composing, clear the stale ref before the guard reads it. - blur: a composition never survives focus loss, so clear the flag unconditionally — this is what unblocks the Send button path, which has no native composition flag to consult. The genuine-IME protection (NousResearch#37483 class) is untouched: Enter with isComposing=true is still swallowed. Fixes NousResearch#44135
…tion Chinese/Japanese/Korean IMEs emit keydown events during composition that carry preedit keystrokes and the commit keypress (Enter/Space/Shift for candidate selection). Treating them as combos fires unrelated keybinds — e.g. typing 你 with a CJK IME could dispatch session.new and silently open a new session. Guard comboFromEvent(): - Bail out entirely while composing (event.isComposing or key === 'Process') - Ignore keydowns whose event.key is a bare modifier name but whose code is a regular key — legacy IMEs that synthesize keystrokes (Q9 2002 sends key="Control" with code="KeyW") would otherwise canonicalize into phantom combos like mod+w that close the active tab. Tested with Q9 (九方) legacy IME on Windows.
…e edit composer Widen the composer-side IME fixes to the inline edit composer: the same missed-compositionend wedge and post-compositionend keyCode 229 Enter apply to its handleKeyDown path.
…tion for wedged gateways A gateway whose asyncio event loop is stalled (e.g. an in-loop compression pass, NousResearch#72707) cannot process SIGTERM/SIGUSR1 shutdown. The updater's drain wait then burned the full 180s budget, warned "Gateway PID X still running after 180.0s — restart may fail", and `hermes update` could deadlock behind the wedged process — the user cannot update their way out of the stall. Fix: before any drain wait, read the loop-liveness heartbeat file the gateway rewrites every 30s (NousResearch#66892). Classification: - alive (fresh heartbeat): busy-but-alive loop — take the normal graceful drain, honoring the in-flight cron drain floor (NousResearch#86684). - wedged (heartbeat for this PID stale >90s = 3 missed beats): the loop is provably dead; drain is pointless. Bounded escalation: SIGTERM + 5s grace, then SIGKILL + 5s wait, then proceed (~10s worst case, far under the 180s drain budget). - unknown (missing/corrupt file, PID mismatch): never escalate on ambiguity — full drain path. Wired into launchd_restart, systemd_restart, and both updater gateway-shutdown sites (systemd unit drain + manual profile gateways). The probe is a local stat + JSON read (well inside the 10s query tier of the subprocess timeout tiering). The cron drain floor from NousResearch#86684 is bypassed ONLY when the loop is provably dead — a merely busy gateway still refreshes its heartbeat and keeps the full drain budget. Root cause of the loop stall itself (compression blocking the loop) is NousResearch#72707 territory and deliberately out of scope here. Fixes NousResearch#81642
…rved todo snapshot Compaction re-injects the todo list verbatim (TODO_INJECTION_HEADER + TodoStore.format_for_injection) while skill instructions are pruned down to [SKILL_PRUNED: ...] markers — the imperative crosses the boundary without the policy that governed it, and the agent keeps executing preserved tasks with the guidance deleted (NousResearch#84718's T6 pattern). Close the retention asymmetry at the injection site: when the compressed transcript carries [SKILL_PRUNED: ...] markers AND a todo snapshot is being re-injected, append a bounded reload notice to the snapshot naming each pruned skill with its exact skill_view() reload call, plus a one-line instruction to re-check that preserved tasks are still justified. Skill guidance recovery now travels in the SAME boundary artifact as the imperative — same message, same stale-snapshot strip lifecycle, so repeated compactions refresh rather than accumulate. Properties: - deterministic: derived only from the compressed transcript (same input, same bytes) — no per-turn nondeterminism in the rebuilt prompt - zero recurring cost when nothing was pruned (clean sessions unchanged) - bounded: shares _MAX_PRUNED_SKILL_MARKERS with the summary re-injection cap; the notice text never contains the canonical marker prefix, so it can never feed the marker extractor at the next boundary - rides after TODO_INJECTION_HEADER, so _strip_stale_todo_snapshot removes snapshot + notice together and the synthetic-row classifier (_is_synthetic_compression_user_turn) is unaffected Tests: tests/agent/test_skill_todo_retention_parity.py — unit contract of the notice builder (naming, dedup/order, cap, determinism, no marker self-feed) and behavioral compaction runs through the real _compress_context path (notice travels with the snapshot, absent when nothing pruned, synthetic-row classification unbroken, strip lifecycle across repeated boundaries). Sabotage-verified: disabling the append flips the 3 behavioral tests red. Part of NousResearch#84718
… fan-out updates Phases 3-5 of the multi-connection campaign in one PR (per Teknium), on top of the registry (NousResearch#86679) and composite-key backend routing (NousResearch#86839). Agents from every registered connection are now usable side by side. Renderer socket registry (phase 3): - backendScopeKey moves to apps/shared (@hermes/shared) so main-process pool keys and renderer socket keys derive from ONE rule; the electron module keeps a byte-identical twin (tsconfig project boundaries) pinned by a cross-copy contract test. - store/gateway secondaries are scope-keyed: entries carry (connectionId, profile); registry-scoped entries dial through getConnectionFor + getGatewayWsUrlFor (fresh per-connect OAuth tickets against the right host); events keep the bare profile plus a connectionId tag; touch/idle keepalive uses the scope key; pruning keeps entries whose PROFILE has live work. New ensureGatewayForAgent/openGatewayForAgent fall through to the profile path for local/null sources — single-source behavior byte-identical. Union roster + plugin SDK (phases 3+4, the Bot Mode door): - hermes:agents:roster enumerates every connection's /api/profiles concurrently (eager REST, lazy sockets; unreachable sources report per-row; undialed ssh boxes stay connect-on-demand) and flattens through buildAgentRoster — the @name-device duplicate-handle rule applied once across all sources, pure + tested. - SDK: host.connections(), host.agents(), host.warmAgent(), host.ensureAgent() — feature-detected so plugins degrade cleanly on older Desktop builds. Fan-out updates (phase 5): - hermes:connections:update-all dispatches hermes update to every eligible source in parallel: local via the app's own applyUpdates pipeline, remote/ssh via the backend's own POST /api/hermes/update; cloud skipped as platform-managed (updateEligibility, pure + tested); per-connection result rows so one dead box can't wedge the batch. Settings → Connections gains the "Update all instances" button (shown with 2+ connections). Also: getJsonForBackend/postJsonForBackend helpers with the token/OAuth-cookie auth split; docs section updated from "staged rollout" to live behavior. Tests: +4 pure cases (cross-copy contract, roster handles, unreachable sources, update eligibility); FULL desktop suite 5115 passed; tsc renderer + electron + shared clean; eslint clean.
Custom providers could only authenticate from a static credential (inline api_key or a key_env env var). Enterprise gateways -- SSO/OIDC brokers, cloud IAM, internal auth proxies -- issue short-lived bearers instead, so a value copied into .env is stale within the hour: long sessions start returning 401s and the user has to restart or run an external cron that rewrites .env. The existing `secrets.command` source does not cover this: it runs once per process at startup (subsequent calls are no-ops by design), so it cannot re-mint a credential mid-session. Add providers.<name>.key_cmd: a command that prints a token, wrapped at resolution in a zero-argument callable. Both wire clients already accept a callable api_key and invoke it per request (the Entra ID path established this), so chat_completions, codex_responses and anthropic_messages all work unchanged and always send a fresh credential. The callable also routes the Anthropic client through its per-request Authorization hook, which is what OAuth-gated gateway routes require -- so no per-vendor auth wiring is needed anywhere in core. - cached until shortly before the advertised expiry (60s leeway), so the helper runs about once per token lifetime rather than once per request - expiry is read from the OAuth 2.0 relative `expires_in` when present, and otherwise from an absolute ISO 8601 deadline (`expiry`, `expiresOn`), which is what CLI token helpers commonly print. Reading only `expires_in` treated those helpers as advertising no TTL at all, cached their token for the life of the process, and returned 401 on every request once the real deadline passed. ISO parsing reuses hermes_cli.auth._parse_iso_timestamp rather than adding another datetime parser. - no synthetic expiry: when no TTL is advertised, or the advertised one is unparseable or already past, the token is used and refreshed on 401 instead of re-minted on an invented schedule - stdout contract matches OAuth 2.0 token endpoints and existing agent helpers (bare token or JSON access_token/expires_in); multi-line output is rejected rather than guessed at, so a misconfigured helper surfaces as a clear error instead of a corrupt-credential 401 - precedence: explicit --api-key still wins; otherwise key_cmd beats a static api_key/key_env on the same entry - failures never include the helper's output (may hold a partial token) or the command string (may embed a client secret) Resolution happens on two paths. agent/auxiliary_client.py resolves named custom providers itself rather than calling _resolve_named_custom_runtime, so key_cmd is honoured in both: wiring only the runtime resolver leaves the main agent turn working while every auxiliary call (title generation, compression, vision, embedding) falls back to the no-key-required placeholder and 401s. Precedence is identical on both paths, so one config entry cannot yield two different credentials depending on which resolver the caller reached. Closes NousResearch#84162 Signed-off-by: LordMelkor <kray@block.xyz>
Follow-ups on the NousResearch#85006 salvage: - A key_cmd token with no advertised expiry was cached for the life of the process. The "refresh on 401" contract it relied on has no implementation (SDK retries cover 429/5xx only), so an expired no-TTL token would 401 every request until restart. Cache on a bounded 15-minute window instead; helpers that want a longer cache can advertise their real expiry. - Test for the no-TTL path updated to pin the bounded-window contract; the remint test's $RANDOM (bash-only, empty under dash) replaced with date +%s%N so it exercises remint under any /bin/sh. - website/docs/integrations/providers.md: document key_cmd in the named custom providers section (contract, precedence, secrets.command contrast).
… run` dispatch Fixes NousResearch#86721. `hermes cron run <job_id>` (a one-shot CLI invocation) dispatches manual runs via the same background-delegation path as an agent's `cronjob(action='run')` tool call (tools/cronjob_tools.py's _try_dispatch_background_run -> dispatch_async_delegation(role= "cron_run", runner=_runner, ...)). The runner thread lives in the calling process's shared daemon executor. When the one-shot process exits right after printing "Triggered job: ...", the in-flight runner dies mid-execution, leaving its cron/executions.db row permanently stuck at status='claimed' -- every subsequent `hermes cron run` on the same job then reports "Ran now: failed" because of the still-claimed row. cron/executions.py already has the exact self-heal this needs: recover_interrupted_executions() correctly identifies and reclassifies 'claimed'/'running' rows whose owner process has provably exited (_owner_is_live checks PID existence AND matches process start-time, so a reused PID isn't mistaken for the original live owner) to 'unknown', unblocking the job for a fresh claim. But it was only ever called once, at the long-lived scheduler ticker's own startup (cron/scheduler.py:379's self.recover_interrupted()) -- a one-shot CLI invocation has no equivalent "startup" moment of its own, so this self-heal never ran for it. Added a call to recover_interrupted_executions() at the top of _try_dispatch_background_run, right after the async-delivery-supported gate and before any claim attempt for the current job -- mirroring exactly what the long-lived scheduler already does at its own startup, just triggered per one-shot invocation instead of once at daemon startup. Wrapped in try/except: pass (best-effort; a failure here must not block the actual dispatch this function exists for). Traced (but did not attempt to fix) the deeper "why does the runner die with the process at all" question -- that's the harder problem options 1/2 in the issue describe (route to the persistent scheduler, or block the one-shot process until completion). This fix addresses the more urgent, more clearly-scoped symptom: a stranded stale claim permanently blocking ALL future manual runs of the affected job, which is option 3 from the issue and the one with an existing, already- correct implementation just needing to be wired into this call site. Added 3 regression tests to a new file, following the established real-subprocess dead-owner pattern already used in tests/cron/test_execution_ledger.py (a genuinely-dead PID, not a mock, matching the real-world failure mode exactly): a sanity test confirming the stale claim sits unrecovered without the fix; a direct test of recover_interrupted_executions() reaping such a claim; and a unit test on _try_dispatch_background_run itself confirming recovery is called before any claim attempt. Verified as a genuine regression by reverting the fix and confirming the unit test fails with recovery never having been called. 35/35 pass across the new test file plus tests/cron/test_execution_ledger.py and tests/tools/test_cronjob_run_background.py (no regression).
…g it Follow-up to the salvaged NousResearch#86862: surface reclaim counts at warning level (mirrors the scheduler tick's reap handling from NousResearch#86853) and keep a debug trace when the best-effort recovery itself fails, instead of a bare pass.
…visor The orphan-reap sweep (_reap_unsupervised_gateway_orphans) must not kill a gateway that Windows Task Scheduler is actively managing. The existing services.exe parent-chain backstop fails open: when the Task-launched conhost bootstrap has already exited, Windows does not reparent the gateway, the chain breaks, and the supervised gateway is treated as an orphan. The reaper then writes the planned-stop marker, the gateway exits cleanly with code 0, and the scheduler never restarts it (RestartCount only fires on non-zero exit) — silently killing A2A/messaging on every desktop-app launch. Querying the task's own state is the authoritative signal and closes the gap without depending on process ancestry: if HermesGateway is Running, skip the reap entirely. Uses PowerShell Get-ScheduledTask (English State enum, locale-stable) rather than schtasks (localized output + codepage mangling).
…aper guard Follow-up to the salvaged NousResearch#86823: the guard queried a hardcoded "HermesGateway" task, but `hermes gateway install` registers Hermes_Gateway (Hermes_Gateway_<profile> for named profiles) via gateway_windows.get_task_name(). Query that name so the supervisor guard is active on standard installs; fall back to the default literal if the module import fails. Test now asserts the profile-aware name is what reaches the task-state query. Also corrects the cherry-picked commit's placeholder author email to the contributor's GitHub noreply address.
…gins (NousResearch#86896) * feat(sdk): export route-decoupled McpTab + ToolsetConfigPanel for plugins Runtime plugins can only import from @hermes/plugin-sdk, but the real Capabilities components (the full per-toolset config panel and the full MCP tab with OAuth/API-key setup) were never exported there — so a plugin could only reimplement bare checkbox lists. Export both, route-decoupled so they render safely outside the Settings react-router context: - toolset-config-panel.tsx: useOptionalNavigate() wraps useNavigate in try/catch (returns null with no router); the 'manage keys' deep link becomes a no-op when embedded outside Settings. In-Settings behavior unchanged. - use-deep-link-highlight.ts: useOptionalSearchParams() degrades to inert params with no router (shared by 4 in-Settings callers incl. McpTab; identical there). - sdk/index.ts: export { McpTab }, export { ToolsetConfigPanel }, export type HermesGateway, and host.getGateway() returning the live $gateway instance (McpTab takes a HermesGateway prop; plugins had no way to get the instance). Both components are already profile-aware (profile?: null|string, NousResearch#86548), so a plugin can scope them to a specific bot profile. tsc: 0 errors (unchanged from baseline). Enables Hermes-Bot-Mode to show the real Tools+MCP config in the bot editor instead of checkbox stand-ins. * fix(lint): sort the new SDK exports into perfectionist/sort-exports order CI check:lint failed — the capabilities exports were grouped by comment instead of interleaved into the file's path-sorted export list. Place them at their natural-ascending positions: ToolsetConfigPanel (@/app/settings) after @/app/routes, McpTab (@/app/skills) after @/app/shell/*, HermesGateway (@/hermes) after @/contrib/types. Verified 0 adjacent-unsorted export pairs. --------- Co-authored-by: Teknium <teknium1@users.noreply.github.com>
…ush state The installer clones with --depth 1, so every default install is shallow. In a shallow repo, an older worktree HEAD (a past snapshot of main) is disconnected from current origin/main by the shallow boundary, so 'git log HEAD --not --remotes' misreports thousands of already-public commits as unpushed. The fail-safe unpushed guard then preserves every aged 'hermes -w' worktree forever, and the git-cherry squash-merge escape hatch never rescues them (22k 'ahead' >> max_ahead=20). Real incident: 21 of 25 hermes-* worktrees stuck on one install. Fix at the root, one owner: - _deepen_shallow_repo(): one-time blobless unshallow (fetch --unshallow --filter=blob:none; plain --unshallow fallback) run from the background startup pruner thread before classification, so history verdicts become correct and the backlog self-clears on the next 'hermes -w' startup. Fail-soft offline: keep preserving. - _cleanup_worktree(): when the unpushed verdict comes from a shallow clone, say 'Shallow clone — cannot verify push state' instead of the misleading 'has unpushed commits' message. - Document the shallow caveat on _worktree_has_unpushed_commits (the primitive stays conservative on purpose). Tests: real shallow clone over file:// reproducing the disconnect shape, covering detection, deepen+verdict flip, pruner E2E reap, offline fail-soft preserve, full-clone noop, and genuine-unpushed-work survival. Sabotage-verified: the E2E test fails with the deepen call disabled.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
session=<name> previously set BU_NAME and then skipped backend resolution entirely — the parameter was documented as cloud-only, so all local/CDP work funneled through the single default daemon and one IPC socket, and concurrent sessions (parallel subagents, simultaneous chats) clobbered each other's browser connection. Reported by @shantanugoel on X. Now a named session composes with whatever browser source is configured: - BU_NAME still namespaces the harness daemon (per-name IPC socket, log, pid — upstream already isolates these), for local Chrome and CDP. - The /browser connect CDP override is now exported for named sessions too; previously a named daemon ignored it and fell back to scanning local Chrome profiles. - On provider backends (Browserbase, Firecrawl, Nous gateway), the name keys its own provider browser via the shared _get_session_info cache (bu-named-<name>), so each name gets its own cloud browser, the same name reuses one across calls and tasks, and unnamed calls keep the per-task key. - Direct-API Browser Use cloud configs keep the native named-daemon path (provider resolution would double-session and double-bill). Tool schema/description updated so models reach for session=<name> for parallel work on any backend, not just cloud. E2E: two named sessions against a real headless Chrome (real browser-use CLI, BU_CDP_URL) ran concurrently, set distinct page state, and read it back intact; sabotage run confirms the new tests fail without the fix.
…ged FAL picks
_select_plugin_image_gen_provider hardcoded image_gen.use_gateway = False.
The managed (Nous-subscription) flow writes use_gateway = True via
_write_provider_config, then this selector runs AFTER it — so picking FAL
through Nous Portal silently persisted provider: fal, use_gateway: false
and every generation billed the user's personal FAL_KEY instead of the
subscription (real incident: key drained to zero-balance lock while the
managed route sat unused).
Fix the class, not the site:
- _select_plugin_image_gen_provider gains the same use_gateway kwarg its
video twin (_select_plugin_video_gen_provider) already had; all four
call sites pass use_gateway=bool(managed_feature), matching the video
call sites, TTS, STT, browser, and web.
- Active-provider detection (the checkmark in `hermes tools`): the
image_gen_plugin_name branch now defers managed entries to the
managed_feature branch and requires use_gateway OFF for direct-key
entries — mirroring the video branch's existing guard, so a managed
FAL pick and a direct-key FAL pick no longer both report active.
Runtime side (prefers_gateway("image_gen")) was already correct; the bug
was purely the setup-time writer.
Tests: new tests/hermes_cli/test_imagegen_managed_gateway.py (3 cases:
managed flag survives, direct pick still clears, image/video selector
contract parity). Sabotage-verified: restoring the hardcoded False fails
2/3. Neighboring hermes_cli provider/managed suites: 180 passed.
New user-guide page for the multi-connection registry (Settings → Connections): connection kinds + auth table, unique device names, v1 migration, union agent roster with @name-device handles, lazy sockets / ssh connect-on-demand, fleet-wide updates (cloud excluded), plugin SDK surface (host.connections/agents/ensureAgent/warmAgent, Bot Mode as reference consumer), troubleshooting. Registered in sidebars.ts; cross-linked from desktop.md and multi-profile-gateways.md. Docusaurus build validated.
Desktop's send path pre-analyzed every attached image with the auxiliary vision model serially, BEFORE dispatching the turn (_enrich_with_attached_ images). Users saw the progress box sit idle 25s-4min for messages that take ~4s in the CLI; failures were silently swallowed, and touching another session during the window killed the turn with zero API calls (NousResearch#83291). The prepended description also poisoned session auto-titles (NousResearch#82339). Replace pre-analysis with _build_image_ref_message: reference the image paths in the message and let the agent analyze them in-loop with vision_analyze — its own retries, visible tool progress, and the turn starts immediately. This is exactly how the @folder: reference path already behaves, which responds in seconds for the same images. Native-vision routing is unchanged; only the "text" mode (non-vision main model / codex_app_server) loses the blocking submit-path calls. Tests: tests/tui_gateway/test_image_ref_message.py (6 cases) including a guard asserting the submit path never invokes the vision tool; sabotage-verified (restoring the old blocking body fails 5/6).
…g for message.start The progress box's timer (turnStartedAt) was only seeded by the backend's message.start event, so the submit RPC -> gateway accept -> WS round trip (seconds under load) showed no timer at all. Seed the per-session clock in seedOptimistic at Enter-time; message.start now keeps an existing seed (?? Date.now()) so backend-originated turns still arm there, the active- session mirror reuses the seeded value instead of snapping to accept-time, and the abort/failure paths retire the seed with the turn. Adds a console.debug submit->accept latency probe at message.start.
Follow-up to NousResearch#86916. That fix gave named sessions their own daemon (socket/log/pid) and their own provider browser — but on a SHARED local Chrome / CDP browser, a fresh named daemon still attaches to the first existing page, the same page a sibling daemon may hold. A named session that never calls new_tab() could still stomp another's tab. browser_exec now prepends a small preamble to the model's code for named sessions on shared browsers: once per daemon process (marker keyed by uid + BU_NAME + daemon pid), it creates a fresh tab via Target.createTarget and switch_tab()s onto it before any model code runs. Private per-name browsers (provider-keyed bu-named-<name>, or direct-API Browser Use cloud) skip the preamble via an internal env sentinel popped before launch — there's nobody to collide with, and the extra tab would leak. Best-effort by design: if the preamble's CDP calls fail, behavior degrades to pre-fix, never blocks the exec. E2E against a shared headless Chrome with the STOCK harness: two named sessions issuing bare js() writes (no new_tab) kept distinct state (EDGE-A/EDGE-B read back intact); the sabotage run without the preamble reproduced the clobber (both read EDGE-B). Removes the dependency on the upstream browser-harness tab-isolation PR for correctness.
…le' on shallow clones Two related failure modes after a crashed/interrupted fetch on a shallow clone (git clone --depth 1 installs): 1. STALE LOCK WEDGES EVERY FETCH. A killed fetch can leave .git/shallow.lock behind; every later 'git fetch' then fails with 'Unable to create .../shallow.lock: File exists'. 'hermes update --check' reported a hard fetch failure, and the passive banner check swallowed the exception and compared stale refs. Add hermes_cli.gitlock.clear_stale_git_locks(), a guarded sweep (age + git-process check so a live fetch is never yanked) wired into the check path, the apply path, and the banner's passive check. 2. SHALLOW TIP-SHA COMPARE FALSE-POSITIVES. On a shallow clone the check cannot count commits, so it compares tip SHAs. Local cherry-picks on top of the remote tip (e.g. re-applied local patches) make HEAD differ from origin/main even though HEAD already contains it — a false 'update available' banner. Add hermes_cli.gitlock.is_ancestor_of_head() and use 'git merge-base --is-ancestor' in the CLI check and banner paths before reporting an update. Mirror in the desktop (update-count.ts gains an isAncestor input; main.ts probes merge-base --is-ancestor). Tests: tests/test_gitlock.py (9) covering stale/young/no-lock/no-repo sweeps and ancestry true/false; update-count.test.ts +3 for the isAncestor path.
…with compare-API status Follow-up on the cherry-picked gitlock work (NousResearch#80501 by @RGerrish, covering the NousResearch#75133 / NousResearch#75168 wedge first reported and fixed by @RelaxJonh): - Drop the PR's ancestor-check halves in banner.py, update-count.ts and main.ts: superseded by the compare-API status recovery that landed in NousResearch#86257/NousResearch#86331 (ahead_by == 0 already reports local-ahead as up to date). The salvaged update_cmd.py check path keeps main's compare-API structure instead of the PR's tip-SHA-plus-ancestry print. - Keep and wire clear_stale_git_locks() at the remaining wedge sites the original PR targeted: hermes update apply, hermes update --check, and the passive banner check. - Add the desktop counterpart (electron/gitlock.ts) so checkUpdates() heals the same wedge instead of reporting fetch-failed forever; mirrored age + git-process guards; vitest coverage. E2E verified: real --depth 1 clone with an aged .git/shallow.lock reproduces "Unable to create '.git/shallow.lock': File exists"; clear_stale_git_locks removes it and the fetch succeeds; a fresh lock (in-flight fetch) is preserved.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Commit 2ae7884 added _EXTENDED_ENTER_KEYS_SEQ which pushes both the Kitty keyboard protocol (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) on supported terminals (Ghostty, iTerm2, WezTerm, kitty). Under the Kitty keyboard protocol, Ctrl+C is encoded as \x1b[99;5u (codepoint 99='c', modifier 5=Ctrl) instead of \x03 (ETX). prompt_toolkit 3.x has no mapping for \x1b[99;5u, so the sequence leaks as literal text '[99;5u' on screen. Worse, the kernel's INTR mechanism looks for the raw \x03 character, so SIGINT never fires either — Ctrl+C is completely dead. Fix: drop the CSI >1u push from _EXTENDED_ENTER_KEYS_SEQ, keeping only modifyOtherKeys (CSI >4;2m). Shift+Enter still works via the \x1b[27;2;13~ sequence that modifyOtherKeys produces and prompt_toolkit already maps (Keys.ControlM). The exit reset sequence still pops both modes for safety. Refs NousResearch#56684.
Route each request to the appropriate LiteLLM profile based on user input and tool context: - qwen3.6-27b (THINK) — default for reasoning - qwen3.6-27b-no-think (FAST) — simple transformations - qwen3.6-27b-tools (EXEC) — tool-calling turns The router runs in build_api_kwargs() before the api_mode branch, covering all three paths (anthropic, provider profile, legacy). Uses a local _model variable to avoid mutating agent.model (cache-safe). Fallback: returns agent.model on any error. No new env vars.
TechPrototyper
force-pushed
the
feat/adaptive-model-routing-v3
branch
from
August 15, 2026 19:38
5627220 to
7346daf
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Route each request to the appropriate LiteLLM profile based on user input and tool context:
The router runs in build_api_kwargs() before the api_mode branch, covering all three paths (anthropic, provider profile, legacy). Uses a local _model variable to avoid mutating agent.model (cache-safe).
Problem
Different types of requests benefit from different model configurations:
Without adaptive routing, all requests use the same model configuration, leading to suboptimal performance and cost.
Solution
Added a content-based router that inspects the user input and tool context to select the appropriate model profile:
The router is cache-safe (uses local _model variable) and has a fallback to agent.model on any error.
Changes
Testing