* feat(i18n): layered catalogs — plugin packs and user overlay over bundled locales
* feat(tui): i18n layer — en catalog, nanostore runtime, RPC pack loader, _keys.tui.json emitter
ui-tui/src/i18n/: en.ts (facade over topical siblings under en/), types.ts
(Translations + dotted TranslationKey derived from en), runtime.ts ($locale/
$catalog atoms, translateFrom active→en→key, pack merge with string→fn
wrapping for {0}/{1} placeholders), loader.ts (display.language →
i18n.catalog {lang, surface:'tui'}, English when the method is missing),
useT()/useLocale() hooks, t() for non-React code. useConfigSync feeds the
loader from the existing config.get full hydration. `npm run i18n:keys`
writes locales/_keys.tui.json (sorted flat key list) and runs before build.
* feat(plugins): provides_locales manifest field, ctx.register_locale/register_locale_dir, manifest-only language packs
* chore(tui): split en catalog siblings by lane (slash sibling)
* feat(plugins): validate language packs — parse, text-only, key-subset WARN against en / _keys exports
* feat(tui_gateway): i18n.languages / i18n.catalog RPC + regenerated contracts
* feat(config): display.language accepts any supported_languages() id, refuses unknown ids with the list
* docs(i18n): language packs user guide, pluggable display.language, plugin developer section, AGENTS notes
* feat(plugins): report language-pack layers in the mid-run activation summary
* feat(tui): wire status bar, composer placeholders, hotkey help and approval/clarify/confirm prompts through i18n
StatusRule maps compared state values (ready/running…/summoning) to catalog
text at render via displayStatus(); hotkeys()/placeholder() resolve lazily so
a pack that arrives after boot applies. Catalog grows to 81 keys.
* feat(desktop): pluggable app locales — registry, host.i18n.registerAppLocale, backend packs, keys emitter
- Locale widens to string (BundledLocale keeps the union); TRANSLATIONS stays
the bundled record and every consumer resolves through the registry.
- src/i18n/registry.ts: registerAppLocale(id, {endonym, rtl, translations})
layers partial packs (nested or flat dotted) over bundled/en via
mergeTranslations; a string over a function-valued en entry becomes a
positional {0}/{1} formatter; $appLocaleVersion bumps so translators
re-render; per-source disposers + replaceAppLocaleSource for atomic swaps.
- Backend packs: i18n.languages + i18n.catalog {surface:'desktop'} feed the
registry as source 'backend' (method-not-found is silent); re-synced on
socket open, display.language change and profile switch. A saved pack-only
language is promoted once its pack registers.
- SDK: host.i18n.registerAppLocale / languageOptions; ctx.i18n.registerAppLocale
tracked for unload. Docs in the desktop plugin SDK guide + skill reference.
- Language switcher lists bundled ∪ registered ∪ backend, endonym-only; RTL
from the registry (applyDocumentLocale takes rtl).
- npm run i18n:keys emits locales/_keys.desktop.json (wired into build).
* i18n(cli): route /topup + /subscription copy through t() (cli.billing.*, cli.subscription.*)
Module-level copy tables and modal choice tuples in cli_billing_mixin.py froze
English at import, before display.language was known. They are now key tables /
builder functions evaluated at call time; every user-facing line in the /usage
balance block, /subscription and the five /topup screens reads the catalog.
Choice VALUES stay English identifiers. Fragment-assembled status lines
(Plan: … → cancels · $x left · renews …) become full templates.
* i18n(gateway): exec-approval card contract + base/run/run_busy/run_inbound replies through t()
- base_exec_approval: EA_* English constants stay; add ea_header_text()/ea_reason_label_text()/
ea_smart_deny_line_text()/ea_default_reason_text()/ea_action_labels()/approval_timed_out_notice()
accessors; deadline + timed-out notice resolve via gateway.exec_approval.*
- BasePlatformAdapter._EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/_EA_ACTION_LABELS become
properties (adapters still shadow them with markup class attrs)
- run.py: provider error replies table holds catalog keys; _CONTEXT_OVERFLOW_REPLY -> _context_overflow_reply()
- run_busy/run_inbound: typed approval + slash-confirm matchers accept English ∪ approval.inputs.* (t())
- locales/en.yaml: gateway.exec_approval/busy/errors/... namespaces
* i18n(cli): wire modal, loops, agent-setup mixins through t() (cli.* keys)
* i18n(platforms): route Slack, Matrix and Feishu user-facing text through t()
Exec-approval markup overrides (_EA_HEADER/_EA_REASON_LABEL/_EA_SMART_DENY_LINE/
_EA_ACTION_LABELS) become per-call properties over the shared
gateway.exec_approval.* contract keys, so Slack's 3000-char section budget
measures the resolved template. Slack _APPROVAL_DECISIONS/_CONFIRM_DECISIONS,
Feishu _APPROVAL_LABEL_MAP and Matrix _EA_LEGEND/_EA_TYPED_HINT turn into
key tables resolved at click time; the Matrix typed hints become whole
sentences per offered tier instead of spliced fragments. Slack button labels
are cut to 75 chars and select placeholders to 150 after translation; the
model-facing clarify fallback answer ('choice N') stays English while the
card copy localizes.
locales/en.yaml gains the gateway.exec_approval.* contract keys plus the
platform.shared.* / platform.slack.* / platform.matrix.* / platform.feishu.*
namespaces (and the keys for the other adapters wired in follow-up commits).
* i18n(gateway): run_turn / run_turn_runner / approval-settle copy through t()
- status hints, proxy errors, background task notices, progress heartbeats, session info lines
- tool progress chrome (tool_head/tool_pending/tool_preview/tool_verbose) shared by base.format_tool_event
- run_turn_runner:1406 Chinese clarify placeholder -> gateway.clarify.native_stream_placeholder (zh text kept in zh.yaml)
- _UNEXPECTED_SILENCE_REPLY/_CLARIFY_EXPIRED_NOTICE -> accessor functions
* i18n(platforms): route Google Chat and Teams user-facing text through t()
Google Chat clarify card, typing placeholder, orphan-card labels and the whole
/setup-files reply set (module constants become platform.google_chat.setup_files.*
keys resolved at reply time). The attachment-fallback notice that shipped
hardcoded in Spanish is keyed with an English en value; es.yaml carries the
original Spanish text for those four keys.
Teams approval card header/reason use the gateway.exec_approval.* contract,
_APPROVAL_LABELS becomes a key table resolved at click time, and the meeting
summary writer resolves its section headings/fallbacks per render.
* i18n(platforms): route LINE, WeCom, email, DingTalk, IRC and Home Assistant text through t()
LINE default copy constants become catalog keys resolved in __init__ (the
LINE_*_TEXT / extra.* operator overrides still win); the busy-ack bypass
matcher keys on the leading emoji marker only, so it keeps firing once the
gateway busy heads are localized. WeCom media size/format notices that shipped
hardcoded in Chinese are keyed with English en values and zh.yaml carries the
original Chinese text. DingTalk emotion bubbles resolve per send.
* i18n(cli): route /model switch output and -q status lines through t() (cli.model.*, cli.single_query.*)
Switch-summary labels shared with the gateway reuse gateway.model.* keys
(provider/context/max-output/capabilities/prompt-caching); CLI-only variants
(glyph or no-backtick forms) live under cli.model.*. The hand-padded /model usage
block becomes a (form, description-key) table padded at render time so the
command syntax stays fixed while descriptions translate. -q 'Error:' reuses
gateway.model.error_prefix.
* i18n(cli): route TUI panel/hint/placeholder copy through t() (cli.tui.*)
_APPROVAL_CHOICE_LABELS and _TUI_MODAL_HINTS become key tables resolved at
render time; vault/sudo panel bodies are one catalog value per panel split on
newline; inline plurals use <key>_one/<key>_other. Adds the cli.* namespace
(shared/tui/voice/render/subagents/dock) to locales/en.yaml.
* i18n(cli): voice/wake-word CLI copy through t() (cli.voice.*)
RuntimeError texts raised in _voice_start_recording are human copy (callers
print {e}) and are keyed; the Termux requirement-check match stays English.
Wake state ids stay internal, only their labels localize.
* i18n(cli): live-work dock, subagent monitor and render copy through t()
cli.subagents.* / cli.dock.* / cli.render.*; count fragments pluralize via
_one/_other keys, verdict table holds keys resolved at paint time so width
clipping measures the translated text.
* i18n(gateway): unauthorized/pairing, voice, topics, shutdown, startup, notifications, kanban pings through t()
* i18n(cli): move tips + composer placeholders into the catalog (tips.tNNN / tips.placeholder.pNN)
get_random_tip()/get_random_composer_placeholder() pick a key from the English catalog
(the parity baseline, probed once per process) and resolve it through t() for the active
language, so language packs translate tips like any other string. Also lands the cli.*
en.yaml namespace consumed by the CLI info/help/error-copy wiring in the next commit.
* i18n(cli): wire chat-turn + session mixins through t(); kanban log trimmer matches t() output
* test(cli): assert TUI/dock/voice copy via t(key); prove labels resolve at render time
Pinned-English assertions in the approval-UI, live-work dock and voice tests now
go through the catalog. New test swaps the catalog after import and checks the
approval panel + hint row follow it (the reason _APPROVAL_CHOICE_LABELS and
_TUI_MODAL_HINTS became key tables).
* i18n(cli): route CLI info/help/error copy through t() (cli.* namespace)
cli_info_mixin: /help consumes CommandDef.describe() (added to commands.py: slash.<name>.description
with fallback to .description), section titles/skill/quick-command headers, /tools, /toolsets,
/usage labels, /context, /whoami, /insights, /gateway status, tool-progress labels, bang-shell
denials, MCP config-watch + /reload-mcp confirm/reload lines, /reload-skills, and the session-store
warning all read the catalog at call time (module-level label tables became functions so the
active language is honoured after startup). cli.py: worktree cleanup, tirith warning, show_config
(labels re-padded at print time), quick/plugin/skill slash-command errors, ambiguous-command hint,
stdin error, gateway start, profile warning. cli_chat_error_copy / cli_unknown_command /
cli_output / cli_init_mixin: chat error panel copy, did-you-mean lines, n-more / yes-no prompt
(localized affirmative initial alongside 'y'), unknown-toolsets warning.
* i18n(w1a): wire agent display/explainers/approval + slash registry/help through t()
- hermes_cli/commands.py: CommandDef.describe() resolves slash.<name>.description
at call time; category labels via slash.category.*; help/alias/usage suffixes
via slash.shared.*; gateway_help_lines and commands_platforms/slash_exec use them.
- agent/display.py: display.verb.* resolved at call time via get_tool_verb();
bridge/spinner/thinking-verb/cute-row/failure/preview/diff text via display.*.
- agent/turn_explainers.py: exit-reason / persistence-cause tables become call-time
lookups (explainer.exit.*, explainer.persistence.*, explainer.file_mutation.*).
- agent/background_review.py, session_activity.py, context_breakdown.py,
status_output.py: review summaries, iteration progress, context notices.
- tools/approval.py, approval_context.py: approval.summary.*, approval.noun.*,
approval.window.* pluralized keys.
- gateway/slash_commands*.py: remaining raw strings (busy, whoami, platform,
bundles, memory, skills, approvals, set_home, diff, update, debug, profile,
heartbeat, refine, review, subgoal, loop, retry, compress codex path, save,
sessions, model guard/errors, agents rows, topup, login). HISTORY_UNREADABLE
keeps its English constant; callers use history_unreadable() ->
gateway.shared.history_unreadable.
- locales/en.yaml: new approval/display/explainer/slash blocks + gateway leftovers.
* i18n(telegram): route adapter chat copy through t()
Approval card (header/reason/smart-deny as HTML-escaped properties), inline
button labels, callback toasts (cut at Telegram's 200-char cap), model/choice
pickers, clarify/update/slash-confirm prompts, gmail-triage labels and the
inbound-media failure notice now come from the catalog. _UNAUTHORIZED is a
lazy _unauthorized() so the import no longer binds a language. The command
menu carries a language+payload fingerprint (forum scopes re-register on
change) and BotCommand descriptions are cut at 256.
Adds gateway.exec_approval.* (WAVE2 contract), platform.telegram.*,
platform.discord.* and the slash.*.description keys the Discord table shares
with the CLI registry to locales/en.yaml.
* i18n(gateway/platforms): whatsapp_cloud, yuanbao, weixin, signal, api_server copy through t()
- whatsapp_cloud: clarify list/buttons, approve/deny + slash-confirm labels via platform.whatsapp.* (t()-then-truncate at 20/24/72 caps); _EA_HEADER becomes a property wrapping ea_header_text()
- yuanbao: SLOW_RESPONSE_MESSAGE -> slow_response_message() (platform.yuanbao.slow_response_notice; zh keeps the original text); cron-wrapper markers centralized as module constants for strip_cron_wrapper
- api_server: PROVIDER_AUTH_FAILED_LABEL/PROVIDER_RATE_LIMITED_LABEL stay English for run.py matchers; user_text() renders via t()
- signal/_format_wait, weixin voice caption, openai_routes transformed notice
- run_turn: second _UNEXPECTED_SILENCE_REPLY consumer -> accessor
* i18n(discord): route adapter chat copy through t()
Native slash-command table becomes _NATIVE_SLASH_COMMAND_SPECS holding catalog
keys; _native_slash_commands() resolves descriptions, parameter descriptions
and Choice names for the active language, each cut at Discord's 100-char cap,
and the app-command sync fingerprint now includes get_language() so a
display.language change re-syncs. Exec-approval card (gateway.exec_approval.*
contract), slash-confirm / clarify / update views, model+choice pickers,
thread creation, forum titles, voice acks, the response-truncation notice,
the unauthorized-slash security alert and the media upload-size notices all
read from platform.discord.*. Decorator-declared button labels are relabelled
in __init__ (80-char cap); embed titles cut at 256, select placeholders at
150, option label/description at 100. _UNAUTHORIZED is a lazy _unauthorized().
* i18n(cli): wire status-bar, stream, terminal mixins + terminal_input through t(); rename kwargs that shadow t(key)
* i18n: wire hermes_cli/cli_commands_mixin.py slash-command copy through t()
- 431 new leaves under cli.commands.<cmd>.* in locales/en.yaml; 12 rows reuse
existing gateway.* keys (rollback, diff, resume, branch, btw, model, reasoning)
via a _gt() helper so CLI and gateway replies stay identical.
- Module-level English tables (_BUSY_MODE_*, _REASONING_TOGGLES, _HATCH_PROGRESS,
_DIFF_LABELS, _LOCAL_ENGINE_LINES) become call-time catalog lookups keyed by id.
- Verb tables (Enabling/Disabling, Paused/Resumed/Triggered, planned/done,
Updating/Generating) are one full template per variant; plurals use
<key>_one/<key>_other via _tn(); hand-padded column labels (/snapshot list)
translate the value and re-pad at the call site.
- Multi-line usage blocks are single catalog values split with _lines().
- Model-facing system notes and DB-stored reasons stay English (EXCLUDED).
* tests: assert /handoff, /worktree, /login CLI copy via t(key) instead of pinned English
* test(i18n): pin Telegram/Discord adapter catalog wiring
Lazy unauthorized notice, exec-approval contract keys, HTML escaping before
Telegram <b> wrapping, 200-char toast / 256-char BotCommand caps, Discord
100-char app-command text and 80-char button caps, and language-bearing
command-menu fingerprints on both platforms.
* i18n: reconcile cli.shared on/off vs enabled/disabled after lane merge
* i18n: describe() in TUI-gateway slash listings; localize TUI exit resume hint
* i18n(tr): translate bundled catalog + tui pack
* i18n(ja): translate bundled catalog + tui pack
* i18n(ko): translate bundled catalog + tui pack
* i18n(zh): translate bundled catalog + tui pack
* i18n(fr): translate bundled catalog + tui pack
* i18n(af): translate bundled catalog + tui pack
* i18n(uk): translate bundled catalog + tui pack
* i18n(ar): translate bundled catalog + tui pack
* i18n(pt): translate bundled catalog + tui pack
* i18n(it): translate bundled catalog + tui pack
* i18n(es): translate bundled catalog + tui pack
* i18n(zh-hant): translate bundled catalog + tui pack
* i18n(ru): translate bundled catalog + tui pack
* i18n(hu): translate bundled catalog + tui pack
* i18n(hu): translate pre-existing English-valued leftovers (kanban wake, /context, /status, fast labels)
* i18n(de): translate bundled catalog + tui pack
* i18n(ga): translate bundled catalog + tui pack
* test(i18n): fixture matches _normalize_lang(lang, home) signature
* i18n(tui): scaffold userMessages/slashCmd en siblings
* i18n(tui): wire secure prompts + content tables
* feat(tui): i18n — wire billing, subscription, connection-setup and journey overlays
Adds en siblings billing.ts / subscription.ts / connection.ts (namespaces
billing, subscription, connection, journey) and routes every user-facing
literal in billingOverlay, subscriptionOverlay, connectionSetupOverlay and
journey through useT()/messages(). Module-level label tables became lazy
(scopeStillDeniedResult(), verbOf(T, action)); auto-reload rows dispatch on
stable ids instead of label text. Regenerates locales/_keys.tui.json.
* i18n(tui): wire slash ops/wake replies
* i18n(tui): wire pickers (modelPicker, activeSessionSwitcher, petPicker)
* i18n(tui): wire slash core/debug/setup replies
* i18n(tui): wire hubs (agents overlay/panel/controls, skills, plugins)
* i18n(tui): wire slash session/topup/subscription replies
* i18n(tui): wire chat bits (branding, thinking, messageLine, loaders, todo, queued, banner, entry)
* i18n(tui): register t3 siblings (pickers, hubs, secure, content, chatBits) and regenerate keys
* i18n(tui): wire userMessages copy through the userMessages namespace
* i18n(tui): lazy-copy test for userMessages, regenerate _keys.tui.json
* i18n(tui): wire session/gateway/lib text through the TUI catalog (lane t2)
Adds en siblings session.ts, gatewayMsg.ts, libText.ts (namespaces session,
gatewayMsg, libText) and routes user-facing literals in app/{useMainApp,
useSessionLifecycle,useInputHandlers,turnController,createServerRequestHandler,
setupHandoff,createGatewayEventHandler}.ts, gatewayClient displayed reasons,
lib/*, domain/*, hooks/* through t()/messages(). Status-bar state values that
code compares against, backend-matched strings, log lines, model-bound text,
and machine 'error:' prefixes stay literal. Regenerates locales/_keys.tui.json
(232 keys).
* i18n: serve bundled locales/<lang>.tui.yaml under overlay/packs; TUI pack parity test; regen _keys.tui.json (1250)
* i18n: translate pre-existing English stubs in bundled locales (424 leaves, 14 locales)
* tui: i18n-export-en script (English templates for pack translators)
* docs(i18n): bundled TUI packs are the bottom layer of the tui surface
* i18n(ru): translate TUI pack
* i18n(ar): translate TUI pack
* i18n(es): translate TUI pack
* i18n(pt): translate TUI pack
* i18n(ko): translate TUI pack
* i18n(de): translate TUI pack
* i18n(ja): translate TUI pack
* i18n(fr): translate TUI pack
* i18n(tr): translate TUI pack
* i18n(it): translate TUI pack
* i18n(zh): translate TUI pack
* i18n(zh-hant): translate TUI pack
* i18n(hu): translate TUI pack
* i18n(uk): translate TUI pack
1,169 missing keys translated; 81 pre-existing kept byte-identical. Parity OK missing=0 extra=0 placeholder_mismatch=0 empty=0.
Deliberately identical to en: chatBits.branding.mcpSummary ({0} MCP), chatBits.thinking.agentsHint ((/agents)), session.main.voiceStt (◉ STT), session.main.voiceTtsSuffix ( [tts]), slashCmd.core.help.tuiSection (TUI), slashCmd.core.history.hermesTag (Hermes #{0}), slashCmd.debug.heapdump.heapPath (heapdump: {0}), slashCmd.debug.mem.rss (rss), subscription.stepUp.title (Remote Spending — product feature name, as in core catalog), content.faces.* (glyph-only kaomoji).
* i18n(ga): translate TUI pack
* i18n(af): translate TUI pack
* plugin_guard: locale catalogs in language packs step down the agent-config family
A translated status line such as "Updating AGENTS.md" in locales/<lang>.yaml is UI text the loader
reads as a string leaf; it cannot edit a file. The bundled en.yaml itself tripped agent_config_mod
at critical, making any faithful language pack uninstallable. Injection shapes keep full severity.
* plugin_validate_locales: read key exports with utf-8-sig (Windows footgun lint)
* i18n(relay): route relay adapter prompt copy through t(); drop dead import-bound approval header
Adds platform.relay.* (5 keys) to en and all 16 bundled locales, reusing the sibling platform
translations for the confirm buttons and the Other option.
* ci: fix TUI import order, MDX table pipe, main's overflow-warning wording in all locales; fresh-install fixture carries the i18n kernel
- ui-tui/src/i18n/en.ts: perfectionist/sort-imports (slash before slashCmd)
- docs plugins/index.md: escape the | inside the provides_locales table cell (MDX parsed <id> as JSX)
- display.notice.uncompressed_context_overflow: adopt main's wording (names compression.enabled: false
and /compact) in en + 16 locales; the guardrail test pins that phrase
- tests/scripts/test_fresh_source_install.py: the installer tail now resolves CLI text through
agent.i18n, so the fixture tree carries the i18n kernel + en.yaml (not the agent runtime)
* docs(desktop-plugin-sdk): double-backtick the template-literal example (MDX evaluated ${n})
* test(e2e): display.language is validated against the live language set; exclude it from the arbitrary-string set property
* commands: keep the localized COMMANDS/COMMANDS_BY_CATEGORY module __getattr__ after the compat block removal
* build: never write locales/_keys.*.json from the desktop/TUI builds; regenerate the committed desktop key export
The desktop build regenerated locales/_keys.desktop.json in the checkout, so a
hermes update that rebuilt the app left the tree dirty (Desktop update E2E:
'M locales/_keys.desktop.json'). The key exports are committed artifacts pinned
to en.ts by apps/desktop/scripts/i18n-keys.test.mjs and ui-tui i18n:keys:check;
builds read them, never write them. Regenerated after main's new desktop strings.
* test: unbreak two main-red timing tests the PR merge-ref inherits
- test_local_runtime racing fake publishes the modern state record (legacy pid-only
records are rejected since 65ff3ad353; main has been red on this test since)
- test_run_progress_topics ManyProgressLinesAgent waits for the first bubble instead of
a fixed 0.35s, which a loaded CI runner does not always meet
* chore(i18n): regenerate desktop key catalog for main's new strings (model pricing, copy changelog)
* test(e2e): torture-chamber fd monitor confirms a deleted sidecar is still held before calling it a leak
SQLite's WAL last-close unlinks -shm before closing its descriptor (unixShmUnmap, then
unixShmPurge), so a healthy close shows a (deleted) -shm for microseconds; the 20ms poll
occasionally caught that window on the short-lived opener and failed the episode.
* chore(i18n): regenerate desktop key catalog for main's telemetry/consent strings
* chore(i18n): regenerate desktop key catalog after main sync
---------
Co-authored-by: Teknium <teknium@nousresearch.com>
870 lines
47 KiB
Python
870 lines
47 KiB
Python
"""Agent construction + session-resume display for ``HermesCLI``: credential resolution,
|
|
per-turn agent config, first-use build, resume preload + recap. ``cli.py`` helpers are
|
|
imported lazily inside each method (import cycle)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
from rich.markup import escape as _escape
|
|
|
|
from agent.i18n import t
|
|
from utils import base_url_host_matches
|
|
|
|
|
|
def _single_query_clarify_callback(question: str, choices=None, multi_select=False) -> str:
|
|
"""Headless clarify answer for ``hermes chat -q``.
|
|
|
|
A -q turn never builds the prompt_toolkit app, so the interactive clarify modal
|
|
can never be painted or answered — the CLI callback would poll until
|
|
``agent.clarify_timeout`` while the caller sees a silent hang. Mirror the oneshot
|
|
path and answer immediately instead.
|
|
|
|
The oneshot path answers immediately via ``_oneshot_clarify_callback``; single-query turns need the same
|
|
headless behavior (#94943).
|
|
"""
|
|
prefix = f"[single-query mode: no user available to answer {question!r}. "
|
|
if choices:
|
|
what = "subset" if multi_select else "option"
|
|
return f"{prefix}Pick the best {what} from {choices} using your own judgment and continue.]"
|
|
return f"{prefix}Make the most reasonable assumption you can and continue.]"
|
|
|
|
|
|
def _current_runtime(cli) -> dict:
|
|
"""Snapshot the CLI's resolved provider routing as an AIAgent runtime dict.
|
|
|
|
getattr guards stay: tests build minimal shells lacking these attributes."""
|
|
return {
|
|
"api_key": cli.api_key,
|
|
"base_url": cli.base_url,
|
|
"provider": cli.provider,
|
|
"requested_provider": getattr(cli, "requested_provider", cli.provider),
|
|
"api_mode": cli.api_mode,
|
|
"command": cli.acp_command,
|
|
"args": list(cli.acp_args or []),
|
|
"credential_pool": getattr(cli, "_credential_pool", None)}
|
|
|
|
|
|
def _route_signature(model, runtime: dict) -> tuple:
|
|
"""Hashable identity of (model, routing) used to detect when the agent must be rebuilt."""
|
|
return (
|
|
model, runtime.get("provider"), runtime.get("requested_provider"), runtime.get("base_url"),
|
|
runtime.get("api_mode"), runtime.get("command"), tuple(runtime.get("args") or ()))
|
|
|
|
|
|
def _cooldown_cause(entry) -> str:
|
|
"""Why a benched (exhausted) row is cooling down, from what the pool recorded: a rate-limit or
|
|
quota response, a failed token refresh, or another HTTP failure."""
|
|
reason = (entry.last_error_reason or "").lower()
|
|
if entry.last_error_code in (402, 429) or any(k in reason for k in ("rate", "quota", "insufficient")):
|
|
return t("cli.startup.cooldown_cause_rate_limit")
|
|
if entry.last_error_code is None or "refresh" in reason:
|
|
return t("cli.startup.cooldown_cause_token_refresh")
|
|
return t("cli.startup.cooldown_cause_http", code=entry.last_error_code)
|
|
|
|
|
|
def _credential_pool_notice(provider: str) -> tuple:
|
|
"""``(cooling, lines)`` on why *provider*'s pool has nothing selectable right now, for the
|
|
startup notice. *cooling* is True when the first line is a live cooldown with its remaining
|
|
time; a dead (quarantined) sign-in adds a line naming the re-login."""
|
|
import time
|
|
from agent.credential_pool import STATUS_DEAD, STATUS_EXHAUSTED, load_pool
|
|
try:
|
|
pool = load_pool(provider)
|
|
if not pool.has_credentials() or pool.has_available():
|
|
return False, []
|
|
next_at = pool.next_available_at()
|
|
entries = pool.entries()
|
|
except Exception:
|
|
return False, []
|
|
lines = []
|
|
if next_at is not None:
|
|
minutes = max(1, int((next_at - time.time() + 59) // 60))
|
|
benched = [e for e in entries if e.last_status == STATUS_EXHAUSTED]
|
|
cause = _cooldown_cause(benched[0]) if benched else t("cli.startup.cooldown_cause_failed_request")
|
|
lines.append(t("cli.startup.credential_cooling_down", provider=provider, cause=cause, minutes=minutes))
|
|
dead = [e for e in entries if e.last_status == STATUS_DEAD]
|
|
if dead:
|
|
reason = dead[0].last_error_message or dead[0].last_error_reason or t("cli.startup.sign_in_lost")
|
|
lines.append(t("cli.startup.sign_in_was_lost", provider=provider, reason=reason))
|
|
return next_at is not None, lines
|
|
|
|
|
|
def _resume_counts(user_count: int, total: int) -> str:
|
|
"""``(N user message(s), M total messages)`` suffix of the resumed-session line."""
|
|
key = "cli.resume.counts_one" if user_count == 1 else "cli.resume.counts_other"
|
|
return t(key, count=user_count, total=total)
|
|
|
|
|
|
def _keyless_custom_base(base_url) -> bool:
|
|
"""Custom/local endpoints (llama.cpp, ollama, vLLM) often need no auth; only a
|
|
non-OpenRouter base_url qualifies."""
|
|
return bool(
|
|
isinstance(base_url, str)
|
|
and base_url
|
|
and not base_url_host_matches(base_url, "openrouter.ai"))
|
|
|
|
|
|
def _compression_descendant(session_db, session_id):
|
|
"""If ``session_id`` is the (empty) head of a compression chain, return the
|
|
descendant that actually holds the messages; else None. Fails open on DB errors."""
|
|
try:
|
|
resolved_id = session_db.resolve_resume_session_id(session_id)
|
|
except Exception:
|
|
return None
|
|
return resolved_id if resolved_id and resolved_id != session_id else None
|
|
|
|
|
|
def _user_display_text(content) -> str:
|
|
"""Recap text for a user row; multimodal lists become text parts + ``[image]`` markers."""
|
|
if isinstance(content, list):
|
|
return " ".join(
|
|
part.get("text", "") if part.get("type") == "text" else "[image]"
|
|
for part in content
|
|
if isinstance(part, dict) and part.get("type") in ("text", "image_url"))
|
|
return "" if content is None else str(content)
|
|
|
|
|
|
def _tool_calls_summary(tool_calls) -> str:
|
|
"""``[N tool call(s): name, ...]`` with up to 4 distinct names."""
|
|
names = []
|
|
for tc in tool_calls:
|
|
fn = tc.get("function", {})
|
|
name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown"
|
|
if name not in names:
|
|
names.append(name)
|
|
names_str = ", ".join(names[:4]) + (", ..." if len(names) > 4 else "")
|
|
key = "cli.resume.tool_calls_one" if len(tool_calls) == 1 else "cli.resume.tool_calls_other"
|
|
return t(key, count=len(tool_calls), names=names_str)
|
|
|
|
|
|
# display_kind -> catalog key of the recap event line (resolved at call time); ``hidden`` rows
|
|
# are skipped before this lookup.
|
|
_RESUME_EVENT_KEYS = {
|
|
"model_switch": "cli.resume.event_model_changed",
|
|
"async_delegation_complete": "cli.resume.event_delegation_completed",
|
|
"process_complete": "cli.resume.event_process_finished",
|
|
"auto_continue": "cli.resume.event_resumed_interrupted"}
|
|
|
|
def _collect_resume_entries(display_history, disp: dict, clean_assistant):
|
|
"""Displayable ``(role, text)`` recap entries from stored history, truncated per the
|
|
``display.resume_*`` config; system and tool-result rows are skipped. Returns
|
|
``(entries, index of last assistant entry, its un-truncated text)``.
|
|
|
|
Stored history is untrusted for display: text is sanitized so replay can't clear the
|
|
screen, retitle the window or restyle the panel. Pure-reasoning assistant rows with no
|
|
visible output are skipped, as are tool-call-only rows when ``resume_skip_tool_only``.
|
|
"""
|
|
from tools.ansi_strip import sanitize_display_text as _sanitize_display_text
|
|
max_user_len = int(disp.get("resume_max_user_chars", 300))
|
|
max_asst_len = int(disp.get("resume_max_assistant_chars", 200))
|
|
max_asst_lines = int(disp.get("resume_max_assistant_lines", 3))
|
|
skip_tool_only = disp.get("resume_skip_tool_only", True)
|
|
entries: list = []
|
|
last_asst_idx = None
|
|
last_asst_full = None
|
|
for msg in display_history:
|
|
role = msg.get("role", "")
|
|
display_kind = msg.get("display_kind")
|
|
content = msg.get("content")
|
|
tool_calls = msg.get("tool_calls") or []
|
|
if display_kind == "hidden":
|
|
continue
|
|
if display_kind in _RESUME_EVENT_KEYS:
|
|
metadata = msg.get("display_metadata") or {}
|
|
label = metadata.get("display_text") if display_kind in ("async_delegation_complete", "process_complete") else None
|
|
entries.append(("event", _sanitize_display_text(label or t(_RESUME_EVENT_KEYS[display_kind]))))
|
|
continue
|
|
if role == "user":
|
|
text = _sanitize_display_text(_user_display_text(content))
|
|
if len(text) > max_user_len:
|
|
text = text[:max_user_len] + "..."
|
|
entries.append(("user", text))
|
|
elif role == "assistant":
|
|
text = clean_assistant("" if content is None else str(content))
|
|
parts, full_parts = [], []
|
|
if text:
|
|
full_parts.append(text)
|
|
lines = text.splitlines()
|
|
if len(lines) > max_asst_lines:
|
|
text = "\n".join(lines[:max_asst_lines]) + " ..."
|
|
if len(text) > max_asst_len:
|
|
text = text[:max_asst_len] + "..."
|
|
parts.append(text)
|
|
if tool_calls:
|
|
parts.append(_tool_calls_summary(tool_calls))
|
|
full_parts.append(parts[-1])
|
|
if not text and (skip_tool_only or not tool_calls):
|
|
continue
|
|
entries.append(("assistant", " ".join(parts)))
|
|
last_asst_idx = len(entries) - 1
|
|
last_asst_full = " ".join(full_parts)
|
|
return entries, last_asst_idx, last_asst_full
|
|
|
|
|
|
# (skin key, fallback) for recap panel colors: body text, session label, border, assistant label.
|
|
_RESUME_SKIN_COLORS = (
|
|
("banner_text", "#FFF8DC"), ("session_label", "#DAA520"), ("session_border", "#8B8682"),
|
|
("ui_ok", "#8FBC8F"))
|
|
|
|
|
|
def _resume_panel_colors() -> tuple:
|
|
"""Active-skin colors for ``_RESUME_SKIN_COLORS`` (fallbacks when no skin loads)."""
|
|
try:
|
|
from hermes_cli.skin_engine import get_active_skin
|
|
_skin = get_active_skin()
|
|
return tuple(_skin.get_color(key, default) for key, default in _RESUME_SKIN_COLORS)
|
|
except Exception:
|
|
return tuple(default for _, default in _RESUME_SKIN_COLORS)
|
|
|
|
|
|
def _retire_agent(cli) -> None:
|
|
"""Drop ``cli.agent`` so the next turn rebuilds it, releasing its LLM clients first: the Codex
|
|
app-server child (and MCP descendants) belongs to the instance, so ``self.agent = None`` alone
|
|
orphans it for the CLI process lifetime (#72548). Session tool state is kept (soft release)."""
|
|
agent = cli.agent
|
|
if agent is not None and hasattr(agent, "release_clients"):
|
|
agent.release_clients()
|
|
cli.agent = None
|
|
|
|
|
|
class CLIAgentSetupMixin:
|
|
"""Agent construction + session-resume display methods for ``HermesCLI``."""
|
|
|
|
def _ensure_runtime_credentials(self) -> bool:
|
|
"""Re-resolve provider credentials before agent use so key rotation / token
|
|
refresh are picked up without restarting the CLI. False on auth failure."""
|
|
from cli import ChatConsole, logger
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider, format_runtime_provider_error
|
|
_primary_exc = None
|
|
runtime = None
|
|
_model_at_entry = self.model
|
|
self._credentials_rate_limited = False
|
|
self._credentials_terminal = False
|
|
try:
|
|
# target_model: the ladder's model-keyed rungs (Zen/Go api_mode, Copilot/Nous
|
|
# api_mode) must see the model this CLI will actually send, not config's `default`,
|
|
# or `hermes -m mimo-v2.5 --provider opencode-go` resolves an api_mode/base_url the
|
|
# sent model cannot use (#112600).
|
|
runtime = resolve_runtime_provider(
|
|
requested=self.requested_provider, explicit_api_key=self._explicit_api_key,
|
|
explicit_base_url=self._explicit_base_url, target_model=self.model or None)
|
|
except Exception as exc:
|
|
_primary_exc = exc
|
|
if _primary_exc is not None:
|
|
runtime = self._resolve_fallback_runtime(_primary_exc)
|
|
if runtime is not None:
|
|
_primary_exc = None
|
|
if runtime is None:
|
|
from hermes_cli.auth import AuthError, is_rate_limited_auth_error
|
|
self._credentials_rate_limited = bool(_primary_exc) and is_rate_limited_auth_error(_primary_exc)
|
|
# Only an explicit re-authentication requirement is terminal. Unknown
|
|
# resolver/network failures must not acquire the sticky Kanban block.
|
|
self._credentials_terminal = isinstance(_primary_exc, AuthError) and _primary_exc.relogin_required
|
|
message = format_runtime_provider_error(_primary_exc) if _primary_exc else t("cli.startup.provider_resolution_failed")
|
|
if getattr(self, "tool_progress_mode", "full") == "off":
|
|
print(message, file=sys.stderr) # quiet/stream-json: stdout is machine-readable
|
|
else:
|
|
ChatConsole().print(f"[bold red]{message}[/]")
|
|
return False
|
|
api_key = runtime.get("api_key")
|
|
base_url = runtime.get("base_url")
|
|
resolved_provider = runtime.get("provider", "openrouter")
|
|
if resolved_provider != "nous":
|
|
# An explicit provider carries inference. The free-tier identity (for connectors) was
|
|
# created by the boot bootstrap before this point, never here; this prints the one-time
|
|
# "free tier is here" notice the first time an identity is seen beside an own key.
|
|
self._maybe_print_free_tier_available_notice()
|
|
resolved_routing = (
|
|
resolved_provider, runtime.get("api_mode", self.api_mode), runtime.get("command"),
|
|
list(runtime.get("args") or []))
|
|
# A callable api_key is a bearer-token provider (Azure Entra ID): the OpenAI SDK
|
|
# invokes it per request, so skip string validation / placeholder substitution.
|
|
if not callable(api_key) and not (isinstance(api_key, str) and api_key):
|
|
if _keyless_custom_base(base_url):
|
|
# Placeholder key so the SDK doesn't reject the keyless local endpoint.
|
|
api_key = "no-key-required"
|
|
logger.debug(
|
|
"No API key for custom endpoint %s (source=%s), "
|
|
"using placeholder — local servers typically ignore auth",
|
|
base_url, runtime.get("source", ""))
|
|
else:
|
|
_prov = (resolved_provider or self.requested_provider or "").strip()
|
|
if _prov and _prov != "auto":
|
|
print(f"\n{t('cli.startup.no_api_key_for_provider', provider=_prov)}")
|
|
else:
|
|
print(f"\n{t('cli.startup.no_provider_configured')}")
|
|
print(f" {t('cli.startup.run_model_or_setup')}")
|
|
return False
|
|
if not isinstance(base_url, str) or not base_url:
|
|
print(f"\n{t('cli.startup.empty_base_url')}")
|
|
return False
|
|
credentials_changed = api_key != self.api_key or base_url != self.base_url
|
|
routing_changed = resolved_routing != (self.provider, self.api_mode, self.acp_command, self.acp_args)
|
|
self.provider, self.api_mode, self.acp_command, self.acp_args = resolved_routing
|
|
self._credential_pool = runtime.get("credential_pool")
|
|
self._provider_source = runtime.get("source")
|
|
self.api_key = api_key
|
|
self.base_url = base_url
|
|
|
|
# A custom_provider entry's explicit `model` wins when the CLI model is unset or
|
|
# is just the provider slug/display name (`hermes chat --model <provider-name>`
|
|
# would otherwise send the provider name as the model string -> 400).
|
|
runtime_model = runtime.get("model")
|
|
if runtime_model and isinstance(runtime_model, str) and (
|
|
not self.model or self.model == self.provider or self.model == runtime.get("name")):
|
|
self.model = runtime_model
|
|
|
|
# Still empty (e.g. `hermes auth add` without `hermes model`): fall back to the
|
|
# provider's first catalog model so the API doesn't reject an empty model.
|
|
if not self.model and resolved_provider:
|
|
try:
|
|
from hermes_cli.models import get_default_model_for_provider
|
|
_default = get_default_model_for_provider(resolved_provider)
|
|
if _default:
|
|
self.model = _default
|
|
logger.info(
|
|
"No model configured — defaulting to %s for provider %s",
|
|
_default, resolved_provider)
|
|
except Exception:
|
|
pass
|
|
|
|
# Normalize model for the resolved provider (e.g. swap non-Codex models on openai-codex).
|
|
# Fixes #651.
|
|
model_changed = self._normalize_model_for_provider(resolved_provider)
|
|
|
|
# Startup resolved reasoning_config for the launch model; whichever path above moved
|
|
# self.model (auth fallback, custom-entry model, provider default, normalization) leaves a
|
|
# per-model contract the lazily built agent would otherwise miss (an always-thinking model
|
|
# 400s on the primary's effort). Same chokepoint as /model, /new and --resume; an explicit
|
|
# --reasoning is the user's intent for this run and outranks the new model's config.
|
|
if self.model != _model_at_entry and getattr(self, "_explicit_reasoning_config", None) is None:
|
|
from hermes_cli.cli_model_switch_mixin import _resolve_cli_reasoning
|
|
_resolve_cli_reasoning(self)
|
|
logger.info("Model moved to %s: reasoning_config resolved: %s", self.model, self.reasoning_config)
|
|
|
|
# AIAgent/OpenAI client holds auth at init, so rebuild on key/routing/model change.
|
|
if (credentials_changed or routing_changed or model_changed) and self.agent is not None:
|
|
_retire_agent(self)
|
|
self._active_agent_route_signature = None
|
|
return True
|
|
|
|
def _maybe_print_free_tier_available_notice(self) -> None:
|
|
"""One-time notice for installs whose inference is carried by an explicit provider: the free
|
|
tier (inference + connectors) now exists. Printed the first time an identity is present, then
|
|
flagged on that identity so it never repeats. Never blocks or raises."""
|
|
from cli import logger
|
|
try:
|
|
from hermes_cli import anon_auth
|
|
if not anon_auth.guest_notice_pending():
|
|
return
|
|
self._console_print(f"[dim]{anon_auth.FREE_TIER_AVAILABLE_NOTICE}[/]")
|
|
anon_auth.mark_guest_notice_shown()
|
|
except Exception as exc:
|
|
logger.debug("free tier availability notice skipped: %s", exc)
|
|
|
|
def _resolve_fallback_runtime(self, primary_exc):
|
|
"""Primary provider resolution failed: on an AuthError try each fallback entry in
|
|
order and switch the CLI's requested_provider/model to the first that resolves.
|
|
None when the error is not auth-related or no fallback resolves."""
|
|
from cli import _cprint, logger
|
|
from hermes_cli.auth import AuthError, primary_failure_wording
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
if not isinstance(primary_exc, AuthError):
|
|
return None
|
|
_fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []
|
|
for _fb in _fb_chain:
|
|
_fb_provider = (_fb.get("provider") or "").strip().lower()
|
|
_fb_model = (_fb.get("model") or "").strip()
|
|
if not _fb_provider or not _fb_model:
|
|
continue
|
|
try:
|
|
from hermes_cli.fallback_config import resolve_entry_api_key
|
|
# target_model: the fallback entry names the model that will be sent; without it the
|
|
# ladder keys off config `default` (see _ensure_runtime_credentials, #112600).
|
|
_fb_kwargs = {"requested": _fb_provider, "target_model": _fb_model}
|
|
if _fb.get("base_url"):
|
|
_fb_kwargs["explicit_base_url"] = _fb["base_url"]
|
|
_fb_api_key = resolve_entry_api_key(_fb)
|
|
if _fb_api_key:
|
|
_fb_kwargs["explicit_api_key"] = _fb_api_key
|
|
runtime = resolve_runtime_provider(**_fb_kwargs)
|
|
_why_log, _why = primary_failure_wording(primary_exc) # #117482: quota is not auth
|
|
logger.warning(
|
|
"Primary provider %s (%s). Falling through to fallback: %s/%s",
|
|
_why_log, primary_exc, _fb_provider, _fb_model)
|
|
from gateway.warning_notifications import render_notification
|
|
render_notification(
|
|
lambda: _cprint(t("cli.startup.switching_to_fallback", reason=_why, provider=_fb_provider, model=_fb_model)),
|
|
platform="cli")
|
|
self.requested_provider = _fb_provider
|
|
self.model = _fb_model
|
|
# reasoning_config follows the swap in _ensure_runtime_credentials (the only caller).
|
|
return runtime
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
def _runtime_credentials_ready(self) -> bool:
|
|
"""Silently probe whether any inference provider can be resolved.
|
|
|
|
Never prints or mutates CLI state, so the interactive first-run path can route a
|
|
keyless install into onboarding before the user types into a chat that can't work.
|
|
|
|
See #62935.
|
|
"""
|
|
return self._probe_runtime_credentials()[0]
|
|
|
|
def _probe_runtime_credentials(self) -> tuple:
|
|
"""``(ready, error)``: *error* is the exception that stopped resolution — raised, or
|
|
swallowed by the "auto" ladder and stamped on a keyless fallback — ``None`` when a provider
|
|
resolved (usable or merely keyless). Never prints or mutates CLI state."""
|
|
from hermes_cli.runtime_provider import resolve_runtime_provider
|
|
try:
|
|
runtime = resolve_runtime_provider(
|
|
requested=self.requested_provider, explicit_api_key=self._explicit_api_key,
|
|
explicit_base_url=self._explicit_base_url)
|
|
except Exception as exc:
|
|
return False, exc
|
|
if not isinstance(runtime, dict):
|
|
return False, None
|
|
api_key = runtime.get("api_key")
|
|
base_url = runtime.get("base_url")
|
|
if callable(api_key) or (isinstance(api_key, str) and api_key):
|
|
return bool(base_url), None
|
|
return _keyless_custom_base(base_url), runtime.get("auth_error")
|
|
|
|
def _maybe_offer_first_run_setup(self) -> None:
|
|
"""Interactive startup gate: a blank install goes to the provider wizard; a configured
|
|
profile whose credential is benched or signed out gets the reason instead (#113720)."""
|
|
if not sys.stdin.isatty():
|
|
return
|
|
ready, error = self._probe_runtime_credentials()
|
|
if not ready and not self._explain_unusable_credentials(error):
|
|
self._offer_first_run_setup()
|
|
|
|
def _explain_unusable_credentials(self, error) -> bool:
|
|
"""A configured profile whose credential is benched, quarantined or signed out is not a
|
|
blank install: print what is wrong (and the remaining cooldown) instead of the first-run
|
|
wizard, whose "nothing is configured" claim sends operators into a second login that can
|
|
rotate a single-use OAuth grant away from the session that was working (#113720).
|
|
|
|
True when the failure was explained; False when nothing is configured (the wizard's case).
|
|
"""
|
|
from cli import _cprint
|
|
from hermes_cli.auth import format_auth_error
|
|
if error is None or getattr(error, "code", None) == "no_provider_configured":
|
|
return False
|
|
provider = getattr(error, "provider", None) or self.requested_provider
|
|
cooling, lines = _credential_pool_notice(provider) if provider and provider != "auto" else (False, [])
|
|
_cprint("")
|
|
if cooling:
|
|
# A live cooldown is a wait, not a lost login: lead with it and skip the re-auth hint.
|
|
_cprint(f"⚠️ {_escape(lines.pop(0))}")
|
|
_cprint(f" {_escape(str(error))}")
|
|
else:
|
|
_cprint(f"⚠️ {_escape(format_auth_error(error))}")
|
|
for line in lines:
|
|
_cprint(f" {_escape(line)}")
|
|
return True
|
|
|
|
def _offer_first_run_setup(self) -> bool:
|
|
"""Offer the provider picker when no provider is configured at all (interactive
|
|
startup, TTY). Runs the same flow as ``hermes model`` so onboarding has a single
|
|
source of truth. True when a provider was configured."""
|
|
from cli import _cprint, logger
|
|
_cprint("")
|
|
_cprint(t("cli.startup.first_run_no_provider"))
|
|
_cprint(f" {t('cli.startup.first_run_pick_provider')}")
|
|
try:
|
|
answer = input(f" {t('cli.startup.first_run_prompt')} ").strip().lower()
|
|
except (KeyboardInterrupt, EOFError):
|
|
print()
|
|
answer = "n"
|
|
if answer in {"n", "no"}:
|
|
_cprint(f" {t('cli.startup.first_run_skipped')}")
|
|
return False
|
|
try:
|
|
from hermes_cli.main import select_provider_and_model
|
|
from hermes_cli.observability.shared_metrics_setup import provider_setup_surface
|
|
with provider_setup_surface("cli_setup"):
|
|
select_provider_and_model()
|
|
except (KeyboardInterrupt, EOFError, SystemExit):
|
|
print()
|
|
_cprint(f" {t('cli.startup.first_run_cancelled')}")
|
|
return False
|
|
except Exception as exc:
|
|
logger.debug("first-run provider setup failed: %s", exc)
|
|
_cprint(f" {t('cli.startup.provider_setup_failed', error=exc)}")
|
|
_cprint(f" {t('cli.startup.run_model_to_retry')}")
|
|
return False
|
|
|
|
# Re-sync CLI state from what the picker persisted so the next turn uses it without a restart.
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
_model_cfg = (load_config().get("model") or {})
|
|
if isinstance(_model_cfg, dict):
|
|
self.requested_provider = (_model_cfg.get("provider") or "").strip() or self.requested_provider
|
|
_new_model = (_model_cfg.get("default") or _model_cfg.get("model") or "").strip()
|
|
self.model = _new_model or self.model
|
|
# The picker's model has its own per-model reasoning contract (see
|
|
# _resolve_cli_reasoning); an explicit --reasoning stays the user's intent.
|
|
if _new_model and getattr(self, "_explicit_reasoning_config", None) is None:
|
|
from hermes_cli.cli_model_switch_mixin import _resolve_cli_reasoning
|
|
_resolve_cli_reasoning(self)
|
|
except Exception as exc:
|
|
logger.debug("first-run config re-sync failed: %s", exc)
|
|
# Force credential re-resolution + agent rebuild on next use.
|
|
_retire_agent(self)
|
|
self._active_agent_route_signature = None
|
|
if self._runtime_credentials_ready():
|
|
_cprint(f" {t('cli.startup.provider_configured')}")
|
|
return True
|
|
_cprint(f" {t('cli.startup.provider_setup_incomplete')}")
|
|
return False
|
|
|
|
def _resolve_turn_agent_config(self, user_message: str) -> dict:
|
|
"""Effective model/runtime config for one turn — always the session's primary
|
|
provider. With `/fast` on (service_tier == "priority") attach request_overrides;
|
|
auto/cold tiers are applied per request by agent.fast_mode instead."""
|
|
from hermes_cli.models import resolve_fast_mode_overrides
|
|
runtime = _current_runtime(self)
|
|
route = {"model": self.model, "runtime": runtime, "signature": _route_signature(self.model, runtime)}
|
|
overrides = None
|
|
if getattr(self, "service_tier", None) == "priority":
|
|
try:
|
|
overrides = resolve_fast_mode_overrides(
|
|
route["model"], provider=runtime["provider"], base_url=runtime["base_url"])
|
|
except Exception:
|
|
pass
|
|
route["request_overrides"] = overrides
|
|
return route
|
|
|
|
def _follow_compression_chain(self, session_meta, announce):
|
|
"""If the resumed id is an empty compression-chain head, announce and switch to
|
|
the descendant holding the messages; returns the (possibly refreshed) meta."""
|
|
resolved_id = _compression_descendant(self._session_db, self.session_id)
|
|
if resolved_id:
|
|
announce(resolved_id)
|
|
self.session_id = resolved_id
|
|
session_meta = self._session_db.get_session(self.session_id) or session_meta
|
|
return session_meta
|
|
|
|
def _restore_session_state(self, session_meta, *, quiet: bool = False) -> None:
|
|
"""Restore cwd / yolo / model from the resumed session's metadata."""
|
|
self._restore_session_cwd(session_meta, quiet=quiet)
|
|
self._restore_session_yolo(session_meta, quiet=quiet)
|
|
self._restore_session_model(session_meta, quiet=quiet)
|
|
|
|
def _reopen_session(self) -> None:
|
|
"""Clear ended_at so the resumed session is active again (best effort)."""
|
|
try:
|
|
self._session_db.reopen_session(self.session_id)
|
|
except Exception:
|
|
pass
|
|
|
|
def _load_resumed_history_late(self) -> bool:
|
|
"""Late resume path: validate the session and load its history from the DB when
|
|
_preload_resumed_session() (called from run()) did not already populate it.
|
|
False when the resume must abort (missing session / over the safe-resume limit)."""
|
|
from cli import ChatConsole, _DIM, _RST, _accent_hex, _cprint
|
|
session_meta = self._session_db.get_session(self.session_id)
|
|
# Quiet mode (tool_progress_mode == "off") routes resume status lines to
|
|
# stderr so stdout stays machine-readable for `$(hermes chat -Q --resume ...)`.
|
|
# Without this, the resume banner pollutes captured stdout. See #11793.
|
|
_quiet_mode = getattr(self, "tool_progress_mode", "full") == "off"
|
|
|
|
def _say(plain: str, rich: str) -> None:
|
|
if _quiet_mode:
|
|
print(plain, file=sys.stderr)
|
|
else:
|
|
ChatConsole().print(rich)
|
|
if not session_meta:
|
|
hint = t("cli.resume.session_id_hint")
|
|
not_found = t("cli.resume.session_not_found", session_id=self.session_id)
|
|
if _quiet_mode:
|
|
print(not_found, file=sys.stderr)
|
|
print(hint, file=sys.stderr)
|
|
else:
|
|
_cprint(f"\033[1;31m{not_found}{_RST}")
|
|
_cprint(f"{_DIM}{hint}{_RST}")
|
|
return False
|
|
session_meta = self._follow_compression_chain(
|
|
session_meta,
|
|
lambda rid: ChatConsole().print(
|
|
f"[dim]{_escape(t('cli.resume.compressed_into', session_id=self.session_id, descendant=rid))}[/dim]"))
|
|
if getattr(self, "_resume_history_error", None):
|
|
return False
|
|
# Only the TIP session's rows are loaded here (no ancestors), so use the
|
|
# tip-only count — the full-lineage count would over-reject compressed sessions.
|
|
resume_limit_error = self._resume_history_limit_error(tip_only=True)
|
|
if resume_limit_error:
|
|
self._resume_history_error = resume_limit_error
|
|
_say(
|
|
t("cli.resume.cannot_resume", error=resume_limit_error),
|
|
f"[bold red]{_escape(t('cli.resume.cannot_resume_prefix'))}[/] {_escape(resume_limit_error)}")
|
|
return False
|
|
restored = self._session_db.get_messages_as_conversation(self.session_id, repair_alternation=True)
|
|
if restored:
|
|
restored = [m for m in restored if m.get("role") != "session_meta"]
|
|
self.conversation_history = restored
|
|
msg_count = len([m for m in restored if m.get("role") == "user"])
|
|
title_part = f" \"{session_meta['title']}\"" if session_meta.get("title") else ""
|
|
counts = _resume_counts(msg_count, len(restored))
|
|
_say(
|
|
f"{t('cli.resume.resumed_session')} {self.session_id}{title_part} {counts}",
|
|
f"[bold {_accent_hex()}]{_escape(t('cli.resume.resumed_session'))}[/] [bold]{_escape(self.session_id)}[/]"
|
|
f"[bold {_accent_hex()}]{_escape(title_part)}[/] {counts}")
|
|
self._restore_session_state(session_meta, quiet=_quiet_mode)
|
|
else:
|
|
no_messages = t("cli.resume.session_no_messages", session_id=self.session_id)
|
|
_say(no_messages, f"[bold {_accent_hex()}]{_escape(no_messages)}[/]")
|
|
self._reopen_session()
|
|
return True
|
|
|
|
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
|
|
"""Build the agent on first use; when resuming, restore history from SQLite.
|
|
Returns True on success."""
|
|
from cli import ChatConsole, _cprint, _prepare_deferred_agent_startup, logger
|
|
from run_agent import AIAgent
|
|
if self.agent is not None:
|
|
return True
|
|
|
|
# Join the background preloaded-skills load (--skills/-s) BEFORE the agent
|
|
# snapshots self.system_prompt below. No-op when nothing was requested.
|
|
self.finalize_preloaded_skills()
|
|
_prepare_deferred_agent_startup()
|
|
self._install_tool_callbacks()
|
|
self._ensure_tirith_security()
|
|
if not self._ensure_runtime_credentials():
|
|
return False
|
|
from hermes_cli.mcp_startup import ensure_mcp_discovery_before_agent_build
|
|
ensure_mcp_discovery_before_agent_build(
|
|
logger=logger, single_query=getattr(self, "_single_query_mode", False))
|
|
if self._session_db is None:
|
|
try:
|
|
from hermes_state_registry import acquire
|
|
self._session_db = acquire()
|
|
except Exception as e:
|
|
logger.warning("SQLite session store not available — session will NOT be indexed: %s", e)
|
|
if (
|
|
self._resumed and self._session_db and not self.conversation_history
|
|
and not self._load_resumed_history_late()):
|
|
return False
|
|
try:
|
|
runtime = runtime_override or _current_runtime(self)
|
|
effective_model = model_override or self.model
|
|
# -q never builds the prompt_toolkit app, so the clarify modal can't be
|
|
# answered — answer headless instead of polling until clarify_timeout.
|
|
single_query_mode = getattr(self, "_single_query_mode", False)
|
|
clarify_callback = (
|
|
# See #94943.
|
|
_single_query_clarify_callback
|
|
if single_query_mode
|
|
else self._clarify_callback)
|
|
connection_callback = None if single_query_mode else self._connection_callback
|
|
self.agent = AIAgent(
|
|
model=effective_model, api_key=runtime.get("api_key"),
|
|
base_url=runtime.get("base_url"), provider=runtime.get("provider"),
|
|
requested_provider=runtime.get("requested_provider"),
|
|
api_mode=runtime.get("api_mode"), acp_command=runtime.get("command"),
|
|
acp_args=runtime.get("args"), credential_pool=runtime.get("credential_pool"),
|
|
max_iterations=self.max_turns,
|
|
run_budget_seconds=getattr(self, "run_budget_seconds", None),
|
|
enabled_toolsets=self.enabled_toolsets, disabled_toolsets=self.disabled_toolsets,
|
|
verbose_logging=self.verbose, quiet_mode=not self.verbose,
|
|
tool_progress_mode=getattr(self, "tool_progress_mode", "all"),
|
|
ephemeral_system_prompt=self.system_prompt if self.system_prompt else None,
|
|
prefill_messages=self.prefill_messages or None,
|
|
reasoning_config=self.reasoning_config, service_tier=self.service_tier,
|
|
request_overrides=request_overrides, providers_allowed=self._providers_only,
|
|
providers_ignored=self._providers_ignore, providers_order=self._providers_order,
|
|
provider_sort=self._provider_sort,
|
|
provider_require_parameters=self._provider_require_params,
|
|
provider_data_collection=self._provider_data_collection,
|
|
openrouter_min_coding_score=self._openrouter_min_coding_score,
|
|
session_id=self.session_id, platform="cli", session_db=self._session_db,
|
|
clarify_callback=clarify_callback, connection_callback=connection_callback,
|
|
reasoning_callback=self._current_reasoning_callback(),
|
|
fallback_model=self._fallback_model, thinking_callback=self._on_thinking,
|
|
checkpoints_enabled=self.checkpoints_enabled,
|
|
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
|
|
checkpoint_max_total_size_mb=self.checkpoint_max_total_size_mb,
|
|
checkpoint_max_file_size_mb=self.checkpoint_max_file_size_mb,
|
|
pass_session_id=self.pass_session_id, skip_context_files=self.ignore_rules,
|
|
skip_memory=self.ignore_rules, tool_progress_callback=self._on_tool_progress,
|
|
tool_start_callback=self._on_tool_start if self._inline_diffs_enabled else None,
|
|
tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None,
|
|
stream_delta_callback=self._stream_delta if self.streaming_enabled else None,
|
|
tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
|
|
notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear,
|
|
reaction_callback=self._on_reaction)
|
|
# Reference for atexit memory-provider shutdown: ``_run_cleanup`` in cli.py
|
|
# reads ``cli._active_agent_ref``, so this MUST write the ``cli`` module's
|
|
# global — a ``global`` statement here would bind this module's namespace.
|
|
# When this code lived in cli.py a bare ``global _active_agent_ref`` worked; after the god-file
|
|
# extraction into this mixin a ``global`` here would bind *this module's* namespace, leaving
|
|
# ``cli._active_agent_ref`` None forever — so memory shutdown never ran on /exit (#49287).
|
|
import cli as _cli
|
|
_cli._active_agent_ref = self.agent
|
|
# Seed the agent's once-per-lifecycle auto_load cache with the bytes the preload
|
|
# thread rendered, so the shared prompt path never re-reads config or skill files.
|
|
_auto_result = getattr(self, "_auto_load_skills_result", None)
|
|
if _auto_result is not None:
|
|
self.agent._auto_load_skills_result = _auto_result
|
|
self.agent._auto_load_skills_resolved = True
|
|
# Route agent status output through prompt_toolkit so ANSI escapes aren't garbled by
|
|
# patch_stdout's StdoutProxy (#2262), holding lines while a response box streams so a
|
|
# subagent/background completion notice never splits the reply mid-paragraph.
|
|
self.agent._print_fn = self._agent_status_print
|
|
# Hydrate credits notices at session OPEN (parity with the TUI) so a depletion
|
|
# warning shows before the first message. Idempotent + fail-open in the helper.
|
|
try:
|
|
from agent.credits_tracker import seed_credits_at_session_start
|
|
seed_credits_at_session_start(self.agent)
|
|
except Exception:
|
|
pass
|
|
self._active_agent_route_signature = _route_signature(effective_model, runtime)
|
|
|
|
# Force-create DB row on /title intent, then apply title.
|
|
if self._pending_title and self._session_db:
|
|
try:
|
|
self.agent._ensure_db_session()
|
|
if self.agent._session_db_created:
|
|
self._session_db.set_session_title(self.session_id, self._pending_title)
|
|
_cprint(f" {t('cli.resume.title_applied', title=self._pending_title)}")
|
|
self._pending_title = None
|
|
# else: row creation failed transiently — keep _pending_title for retry
|
|
except Exception as e:
|
|
_cprint(f" {t('cli.resume.title_apply_failed', error=e)}")
|
|
# Keep _pending_title so it can be retried after row creation succeeds
|
|
return True
|
|
except Exception as e:
|
|
console = ChatConsole()
|
|
from hermes_cli.cli_chat_error_copy import agent_init_failure_message
|
|
console.print(f"[bold red]{_escape(agent_init_failure_message(e))}[/]")
|
|
from hermes_constants import partial_update_hint
|
|
for line in partial_update_hint(e):
|
|
console.print(line)
|
|
return False
|
|
|
|
def _resume_history_limit_error(self, tip_only: bool = False):
|
|
"""Return a safe-resume error without materializing transcript rows.
|
|
|
|
``tip_only`` matches call sites that load only the tip session's rows — counting
|
|
the full lineage there would over-reject heavily-compressed sessions with a small
|
|
tip. Generic guard failures fail OPEN; only a genuine over-limit result blocks."""
|
|
if not self._session_db:
|
|
return None
|
|
from cli import logger
|
|
from hermes_state import SessionResumeTooLargeError
|
|
try:
|
|
safety_check = getattr(self._session_db, "assert_resume_safe", None)
|
|
if not callable(safety_check):
|
|
return None
|
|
safety_check(self.session_id, **({"tip_only": True} if tip_only else {}))
|
|
except SessionResumeTooLargeError as exc:
|
|
return str(exc)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Resume safety check failed for %s (proceeding without guard): %s",
|
|
self.session_id, exc)
|
|
return None
|
|
|
|
def _preload_resumed_session(self) -> bool:
|
|
"""Load a resumed session's history early (from run(), before the first chat) so
|
|
it can be displayed; ``_init_agent()`` then skips its own DB round-trip. Sets
|
|
``self.conversation_history`` and prints the status line. True if history loaded."""
|
|
from cli import _accent_hex
|
|
if not self._resumed or not self._session_db:
|
|
return False
|
|
session_meta = self._session_db.get_session(self.session_id)
|
|
if not session_meta:
|
|
self._console_print(f"[bold red]{_escape(t('cli.resume.session_not_found', session_id=self.session_id))}[/]")
|
|
self._console_print(f"[dim]{_escape(t('cli.resume.session_id_hint'))}[/]")
|
|
return False
|
|
session_meta = self._follow_compression_chain(
|
|
session_meta,
|
|
lambda rid: self._console_print(
|
|
f"[dim]{_escape(t('cli.resume.compressed_into', session_id=self.session_id, descendant=rid))}[/]"))
|
|
resume_limit_error = self._resume_history_limit_error()
|
|
if resume_limit_error:
|
|
self._resume_history_error = resume_limit_error
|
|
self._console_print(f"[bold red]{_escape(t('cli.resume.cannot_resume_prefix'))}[/] {resume_limit_error}")
|
|
return False
|
|
restored, display_history = self._session_db.get_resume_conversations(self.session_id)
|
|
accent_color = _accent_hex()
|
|
if not restored:
|
|
self._console_print(
|
|
f"[{accent_color}]{_escape(t('cli.resume.session_no_messages', session_id=self.session_id))}[/]")
|
|
return False
|
|
restored = [m for m in restored if m.get("role") != "session_meta"]
|
|
self.conversation_history = restored
|
|
self._resume_display_history = [m for m in display_history if m.get("role") != "session_meta"]
|
|
from agent.context_compressor import is_user_originated_turn
|
|
# Count only user-originated turns: legacy compaction handoffs are durable
|
|
# role=user rows without display_kind.
|
|
msg_count = len([m for m in self._resume_display_history if is_user_originated_turn(m)])
|
|
title_part = f' "{session_meta["title"]}"' if session_meta.get("title") else ""
|
|
self._console_print(
|
|
f"[{accent_color}]{_escape(t('cli.resume.resumed_session'))} [bold]{self.session_id}[/bold]"
|
|
f"{title_part} {_resume_counts(msg_count, len(restored))}[/]")
|
|
self._restore_session_state(session_meta)
|
|
self._reopen_session()
|
|
return True
|
|
|
|
def _display_resumed_history(self):
|
|
"""Render a dim Rich-panel recap of the previous conversation, capped at the last
|
|
``resume_exchanges`` user/assistant exchanges with a hidden-count indicator."""
|
|
from cli import CLI_CONFIG, _record_output_history_entry, _strip_reasoning_tags, _suspend_output_history
|
|
from tools.ansi_strip import sanitize_display_text as _sanitize_display_text
|
|
display_history = getattr(self, "_resume_display_history", self.conversation_history)
|
|
if not display_history or self.resume_display == "minimal":
|
|
return
|
|
_disp = CLI_CONFIG.get("display", {})
|
|
entries, _last_asst_idx, _last_asst_full = _collect_resume_entries(
|
|
display_history, _disp, lambda t: _sanitize_display_text(_strip_reasoning_tags(t)))
|
|
if not entries:
|
|
return
|
|
skipped = max(0, len(entries) - int(_disp.get("resume_exchanges", 10)) * 2)
|
|
entries = entries[skipped:]
|
|
# Show the last assistant entry in full so the user sees where they left off.
|
|
if _last_asst_idx is not None and _last_asst_full:
|
|
adj_idx = _last_asst_idx - skipped
|
|
if 0 <= adj_idx < len(entries):
|
|
entries[adj_idx] = ("assistant_last", _last_asst_full)
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
_history_text_c, _session_label_c, _session_border_c, _assistant_label_c = (
|
|
_resume_panel_colors())
|
|
|
|
# role -> (label, label style, body style, continuation indent)
|
|
you_label = f" {t('cli.resume.label_you')} "
|
|
assistant_label = f" {t('cli.resume.label_assistant', agent_name='Hermes')} "
|
|
role_styles = {
|
|
"user": (you_label, f"dim bold {_session_label_c}", "dim", " " * len(you_label)),
|
|
"assistant": (assistant_label, f"dim bold {_assistant_label_c}", "dim", " " * len(assistant_label)),
|
|
"assistant_last": (assistant_label, f"bold {_assistant_label_c}", "", " " * len(assistant_label)), # full, non-dim
|
|
}
|
|
lines = Text()
|
|
if skipped:
|
|
lines.append(f" {t('cli.resume.earlier_messages', count=skipped)}\n\n", style="dim italic")
|
|
for i, (role, text) in enumerate(entries):
|
|
if role == "event":
|
|
lines.append(f" ◈ {text}\n", style="dim italic")
|
|
else:
|
|
label, label_style, body_style, indent = role_styles[role]
|
|
lines.append(label, style=label_style)
|
|
first, *rest = text.splitlines() or [""] # first line inline, rest indented
|
|
lines.append(first + "\n", style=body_style)
|
|
for ml in rest:
|
|
lines.append(f"{indent}{ml}\n", style=body_style)
|
|
if i < len(entries) - 1:
|
|
lines.append("") # small gap
|
|
panel = Panel(
|
|
lines, title=f"[dim {_session_label_c}]{_escape(t('cli.resume.panel_title'))}[/]",
|
|
border_style=f"dim {_session_border_c}", padding=(0, 1), style=_history_text_c)
|
|
_record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel))
|
|
with _suspend_output_history():
|
|
self._console_print(panel)
|