Skip to content

Commit 7de3c86

Browse files
authored
feat(i18n): add display.language for static message translation (zh/ja/de/es) (#20231)
* revert(gateway): remove stale-code self-check and auto-restart Removes the _detect_stale_code / _trigger_stale_code_restart mechanism introduced in #17648 and iterated in #19740. On every incoming message the gateway compared the boot-time git HEAD SHA to the current SHA on disk, and if they differed it would reply with Gateway code was updated in the background -- restarting this gateway so your next message runs on the new code. Please retry in a moment. and then kick off a graceful restart. This is unwanted behaviour: users who run a long-lived gateway and do their own ad-hoc git operations on the checkout end up with their chat interrupted and the current message dropped every time HEAD moves, with no way to opt out. If an operator really needs the old protection against stale sys.modules after "hermes update", the SIGKILL-survivor sweep in hermes update (hermes_cli/main.py, also tagged #17648) already handles the supervisor-respawn case on its own. Removed: gateway/run.py: - _STALE_CODE_SENTINELS, _GIT_SHA_CACHE_TTL_SECS - _read_git_head_sha(), _compute_repo_mtime() module helpers - class-level _boot_wall_time / _boot_repo_mtime / _boot_git_sha / _stale_code_restart_triggered defaults - __init__ boot-snapshot block (_boot_*, _cached_current_sha*, _repo_root_for_staleness, _stale_code_notified) - _current_git_sha_cached(), _detect_stale_code(), _trigger_stale_code_restart() methods - stale-code check + user-facing restart notice at the top of _handle_message() tests/gateway/test_stale_code_self_check.py (deleted, 412 lines) No new logic added. Zero remaining references to any removed symbol. Gateway test suite passes the same 4589 tests it passed before; the 3 pre-existing unrelated failures (discord free-channel, feishu bot admission, teams typing) are unchanged by this commit. * feat(i18n): add display.language for static message translation (zh/ja/de/es) Adds a thin-slice i18n layer covering the highest-impact static user-facing messages: the CLI dangerous-command approval prompt and a handful of gateway slash-command replies (restart-drain, goal cleared, approval expired, config read/save errors). Out of scope (stays English): agent responses, log lines, tool outputs, slash-command descriptions, error tracebacks. Infrastructure: - agent/i18n.py: catalog loader, t() helper, language resolution (HERMES_LANGUAGE env var > display.language config > en) - locales/{en,zh,ja,de,es}.yaml: ~19 translated strings per language - display.language in DEFAULT_CONFIG (hermes_cli/config.py) Tests: - tests/agent/test_i18n.py: 21 tests covering catalog parity, placeholder parity across locales, fallback behavior, env-var override, alias normalization, missing-key graceful degradation. Docs: - website/docs/user-guide/configuration.md: display.language entry plus a short section explaining scope so users don't expect agent responses to translate via this knob.
1 parent b7bd177 commit 7de3c86

11 files changed

Lines changed: 557 additions & 17 deletions

File tree

agent/i18n.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
"""Lightweight internationalization (i18n) for Hermes static user-facing messages.
2+
3+
Scope (thin slice, by design): only the highest-impact static strings shown
4+
to the user by Hermes itself -- approval prompts, a handful of gateway slash
5+
command replies, restart-drain notices. Agent-generated output, log lines,
6+
error tracebacks, tool outputs, and slash-command descriptions all stay in
7+
English.
8+
9+
Catalog files live under ``locales/<lang>.yaml`` at the repo root. Each
10+
catalog is a flat dict keyed by dotted paths (e.g. ``approval.choose`` or
11+
``gateway.approval_expired``). Missing keys fall back to English; if English
12+
is missing too, the key path itself is returned so a broken catalog never
13+
crashes the agent.
14+
15+
Usage::
16+
17+
from agent.i18n import t
18+
print(t("approval.choose_long")) # current lang
19+
print(t("gateway.draining", count=3)) # {count} formatted
20+
print(t("approval.choose_long", lang="zh")) # explicit override
21+
22+
Language resolution order:
23+
1. Explicit ``lang=`` argument passed to :func:`t`
24+
2. ``HERMES_LANGUAGE`` environment variable (for tests / quick override)
25+
3. ``display.language`` from config.yaml
26+
4. ``"en"`` (baseline)
27+
28+
Supported languages: en, zh, ja, de, es. Unknown values fall back to en.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import logging
34+
import os
35+
import threading
36+
from functools import lru_cache
37+
from pathlib import Path
38+
from typing import Any
39+
40+
logger = logging.getLogger(__name__)
41+
42+
SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es")
43+
DEFAULT_LANGUAGE = "en"
44+
45+
# Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp"
46+
# get the right catalog instead of silently falling back to English.
47+
_LANGUAGE_ALIASES: dict[str, str] = {
48+
"english": "en", "en-us": "en", "en-gb": "en",
49+
"chinese": "zh", "mandarin": "zh", "zh-cn": "zh", "zh-tw": "zh", "zh-hans": "zh", "zh-hant": "zh",
50+
"japanese": "ja", "jp": "ja", "ja-jp": "ja",
51+
"german": "de", "deutsch": "de", "de-de": "de",
52+
"spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es",
53+
}
54+
55+
_catalog_cache: dict[str, dict[str, str]] = {}
56+
_catalog_lock = threading.Lock()
57+
58+
59+
def _locales_dir() -> Path:
60+
"""Return the directory containing locale YAML files.
61+
62+
Lives next to the repo root so both the bundled install and editable
63+
checkouts find it without PYTHONPATH gymnastics.
64+
"""
65+
# agent/i18n.py -> agent/ -> repo root
66+
return Path(__file__).resolve().parent.parent / "locales"
67+
68+
69+
def _normalize_lang(value: Any) -> str:
70+
"""Normalize a user-supplied language value to a supported code.
71+
72+
Accepts supported codes directly, common aliases (``chinese`` -> ``zh``),
73+
and case-insensitive regional tags (``zh-CN`` -> ``zh``). Returns the
74+
default language for unknown values.
75+
"""
76+
if not isinstance(value, str):
77+
return DEFAULT_LANGUAGE
78+
key = value.strip().lower()
79+
if not key:
80+
return DEFAULT_LANGUAGE
81+
if key in SUPPORTED_LANGUAGES:
82+
return key
83+
if key in _LANGUAGE_ALIASES:
84+
return _LANGUAGE_ALIASES[key]
85+
# Try stripping a region suffix (e.g. "pt-br" -> "pt" won't be supported,
86+
# but "zh-CN" -> "zh" will).
87+
base = key.split("-", 1)[0]
88+
if base in SUPPORTED_LANGUAGES:
89+
return base
90+
return DEFAULT_LANGUAGE
91+
92+
93+
def _load_catalog(lang: str) -> dict[str, str]:
94+
"""Load and flatten one locale YAML file into a dotted-key dict.
95+
96+
YAML files can be nested for human readability; this produces the flat
97+
key space :func:`t` expects. Cached per-language for the process.
98+
"""
99+
with _catalog_lock:
100+
cached = _catalog_cache.get(lang)
101+
if cached is not None:
102+
return cached
103+
104+
path = _locales_dir() / f"{lang}.yaml"
105+
if not path.is_file():
106+
logger.debug("i18n catalog missing for %s at %s", lang, path)
107+
with _catalog_lock:
108+
_catalog_cache[lang] = {}
109+
return {}
110+
111+
try:
112+
import yaml # PyYAML is already a hermes dependency
113+
with path.open("r", encoding="utf-8") as f:
114+
raw = yaml.safe_load(f) or {}
115+
except Exception as exc:
116+
logger.warning("Failed to load i18n catalog %s: %s", path, exc)
117+
with _catalog_lock:
118+
_catalog_cache[lang] = {}
119+
return {}
120+
121+
flat: dict[str, str] = {}
122+
_flatten_into(raw, "", flat)
123+
with _catalog_lock:
124+
_catalog_cache[lang] = flat
125+
return flat
126+
127+
128+
def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None:
129+
if isinstance(node, dict):
130+
for key, value in node.items():
131+
child_key = f"{prefix}.{key}" if prefix else str(key)
132+
_flatten_into(value, child_key, out)
133+
elif isinstance(node, str):
134+
out[prefix] = node
135+
# Non-string, non-dict leaves are ignored -- catalogs are text-only.
136+
137+
138+
@lru_cache(maxsize=1)
139+
def _config_language_cached() -> str | None:
140+
"""Read ``display.language`` from config.yaml once per process.
141+
142+
Cached because ``t()`` is called in hot paths (every approval prompt,
143+
every gateway reply) and re-reading YAML each call would be wasteful.
144+
``reset_language_cache()`` clears this when config changes at runtime
145+
(e.g. after the setup wizard).
146+
"""
147+
try:
148+
from hermes_cli.config import load_config
149+
cfg = load_config()
150+
lang = (cfg.get("display") or {}).get("language")
151+
if lang:
152+
return _normalize_lang(lang)
153+
except Exception as exc:
154+
logger.debug("Could not read display.language from config: %s", exc)
155+
return None
156+
157+
158+
def reset_language_cache() -> None:
159+
"""Invalidate cached language resolution and catalogs.
160+
161+
Call after :func:`hermes_cli.config.save_config` if a running process
162+
needs to pick up a changed ``display.language`` without restart.
163+
"""
164+
_config_language_cached.cache_clear()
165+
with _catalog_lock:
166+
_catalog_cache.clear()
167+
168+
169+
def get_language() -> str:
170+
"""Resolve the active language using env > config > default order."""
171+
env_lang = os.environ.get("HERMES_LANGUAGE")
172+
if env_lang:
173+
return _normalize_lang(env_lang)
174+
cfg_lang = _config_language_cached()
175+
if cfg_lang:
176+
return cfg_lang
177+
return DEFAULT_LANGUAGE
178+
179+
180+
def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str:
181+
"""Translate a dotted key to the active language.
182+
183+
Parameters
184+
----------
185+
key
186+
Dotted path into the catalog, e.g. ``"approval.choose_long"``.
187+
lang
188+
Explicit language override. Takes precedence over env + config.
189+
**format_kwargs
190+
``str.format`` substitution arguments (``t("gateway.drain", count=3)``
191+
expects a catalog entry with a ``{count}`` placeholder).
192+
193+
Returns
194+
-------
195+
The translated string, or the English fallback if the key is missing in
196+
the target language, or the bare key if English is also missing.
197+
"""
198+
target = _normalize_lang(lang) if lang else get_language()
199+
catalog = _load_catalog(target)
200+
value = catalog.get(key)
201+
202+
if value is None and target != DEFAULT_LANGUAGE:
203+
# Fall through to English rather than showing a key path to the user.
204+
value = _load_catalog(DEFAULT_LANGUAGE).get(key)
205+
206+
if value is None:
207+
# Last-ditch: return the key itself. A broken catalog should not
208+
# crash anything; it just looks ugly until someone fixes it.
209+
logger.debug("i18n miss: key=%r lang=%r", key, target)
210+
value = key
211+
212+
if format_kwargs:
213+
try:
214+
return value.format(**format_kwargs)
215+
except (KeyError, IndexError, ValueError) as exc:
216+
logger.warning(
217+
"i18n format failed for key=%r lang=%r kwargs=%r: %s",
218+
key, target, format_kwargs, exc,
219+
)
220+
return value
221+
return value
222+
223+
224+
__all__ = [
225+
"SUPPORTED_LANGUAGES",
226+
"DEFAULT_LANGUAGE",
227+
"t",
228+
"get_language",
229+
"reset_language_cache",
230+
]

gateway/run.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
# gateway is a long-running daemon, so its boot cost matters less than
4040
# preserving the established test-patch surface.
4141
from agent.account_usage import fetch_account_usage, render_account_usage_lines
42+
from agent.i18n import t
4243
from hermes_cli.config import cfg_get
4344

4445
# --- Agent cache tuning ---------------------------------------------------
@@ -7377,7 +7378,7 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem
73777378
if self._restart_requested or self._draining:
73787379
count = self._running_agent_count()
73797380
if count:
7380-
return f"⏳ Draining {count} active agent(s) before restart..."
7381+
return t("gateway.draining", count=count)
73817382
return EphemeralReply("⏳ Gateway restart already in progress...")
73827383

73837384
# Save the requester's routing info so the new gateway process can
@@ -7429,7 +7430,7 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem
74297430
else:
74307431
self.request_restart(detached=True, via_service=False)
74317432
if active_agents:
7432-
return f"⏳ Draining {active_agents} active agent(s) before restart..."
7433+
return t("gateway.draining", count=active_agents)
74337434
return EphemeralReply("♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`.")
74347435

74357436
def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool:
@@ -8099,7 +8100,7 @@ async def _handle_goal_command(self, event: "MessageEvent") -> str:
80998100
if lower in ("clear", "stop", "done"):
81008101
had = mgr.has_goal()
81018102
mgr.clear()
8102-
return "✓ Goal cleared." if had else "No active goal."
8103+
return t("gateway.goal_cleared") if had else t("gateway.no_active_goal")
81038104

81048105
# Otherwise — treat the remaining text as the new goal.
81058106
try:
@@ -9317,7 +9318,7 @@ async def _handle_footer_command(self, event: MessageEvent) -> str:
93179318
try:
93189319
user_config: dict = _load_gateway_config()
93199320
except Exception as e:
9320-
return f"⚠️ Could not read config.yaml: {e}"
9321+
return t("gateway.config_read_failed", error=e)
93219322

93229323
effective = resolve_footer_config(user_config, platform_key)
93239324

@@ -9350,7 +9351,7 @@ async def _handle_footer_command(self, event: MessageEvent) -> str:
93509351
atomic_yaml_write(config_path, user_config)
93519352
except Exception as e:
93529353
logger.warning("Failed to save runtime_footer.enabled: %s", e)
9353-
return f"⚠️ Could not save config: {e}"
9354+
return t("gateway.config_save_failed", error=e)
93549355

93559356
state = "ON" if new_state else "OFF"
93569357
example = ""
@@ -10788,7 +10789,7 @@ async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]:
1078810789
if not has_blocking_approval(session_key):
1078910790
if session_key in self._pending_approvals:
1079010791
self._pending_approvals.pop(session_key)
10791-
return "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again."
10792+
return t("gateway.approval_expired")
1079210793
return "No pending command to approve."
1079310794

1079410795
# Parse args: support "all", "all session", "all always", "session", "always"

hermes_cli/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,11 @@ def _ensure_hermes_home_managed(home: Path):
781781
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
782782
"show_cost": False, # Show $ cost in the status bar (off by default)
783783
"skin": "default",
784+
# UI language for static user-facing messages (approval prompts, a
785+
# handful of gateway slash-command replies). Does NOT affect agent
786+
# responses, log lines, tool outputs, or slash-command descriptions.
787+
# Supported: en, zh, ja, de, es. Unknown values fall back to en.
788+
"language": "en",
784789
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
785790
# spinner), or ascii. Live-swappable via `/indicator <style>`.
786791
"tui_status_indicator": "kaomoji",

locales/de.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Hermes-Katalog für statische Meldungen -- Deutsch
2+
# See locales/en.yaml for the source of truth; keep keys in sync.
3+
4+
approval:
5+
dangerous_header: "⚠️ GEFÄHRLICHER BEFEHL: {description}"
6+
choose_long: " [o]einmal | [s]sitzung | [a]immer | [d]ablehnen"
7+
choose_short: " [o]einmal | [s]sitzung | [d]ablehnen"
8+
prompt_long: " Auswahl [o/s/a/D]: "
9+
prompt_short: " Auswahl [o/s/D]: "
10+
timeout: " ⏱ Zeitüberschreitung – Befehl wird abgelehnt"
11+
allowed_once: " ✓ Einmalig erlaubt"
12+
allowed_session: " ✓ Für diese Sitzung erlaubt"
13+
allowed_always: " ✓ Zur dauerhaften Erlaubnisliste hinzugefügt"
14+
denied: " ✗ Abgelehnt"
15+
cancelled: " ✗ Abgebrochen"
16+
blocklist_message: "Dieser Befehl steht auf der unbedingten Sperrliste und kann nicht genehmigt werden."
17+
18+
gateway:
19+
approval_expired: "⚠️ Genehmigung abgelaufen (Agent wartet nicht mehr). Bitten Sie den Agenten, es erneut zu versuchen."
20+
draining: "⏳ Warte auf {count} aktive(n) Agent(en) vor dem Neustart..."
21+
goal_cleared: "✓ Ziel gelöscht."
22+
no_active_goal: "Kein aktives Ziel."
23+
config_read_failed: "⚠️ config.yaml konnte nicht gelesen werden: {error}"
24+
config_save_failed: "⚠️ Konfiguration konnte nicht gespeichert werden: {error}"

locales/en.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Hermes static-message catalog -- English (baseline / source of truth)
2+
#
3+
# Only user-facing static messages from the CLI approval prompt and a handful
4+
# of gateway slash-command replies live here. Agent-generated output, log
5+
# lines, error tracebacks, tool outputs, and slash-command descriptions stay
6+
# in English and are NOT translated -- see agent/i18n.py for scope rationale.
7+
#
8+
# Keys are dotted paths; nesting below is purely for readability. Values may
9+
# contain {placeholder} tokens for str.format substitution. When adding a
10+
# new key, add it to EVERY locale file (en/zh/ja/de/es) in the same commit --
11+
# tests/agent/test_i18n.py asserts catalog parity.
12+
13+
approval:
14+
# CLI approval prompt -- shown when a dangerous command needs user review.
15+
dangerous_header: "⚠️ DANGEROUS COMMAND: {description}"
16+
choose_long: " [o]nce | [s]ession | [a]lways | [d]eny"
17+
choose_short: " [o]nce | [s]ession | [d]eny"
18+
prompt_long: " Choice [o/s/a/D]: "
19+
prompt_short: " Choice [o/s/D]: "
20+
timeout: " ⏱ Timeout - denying command"
21+
allowed_once: " ✓ Allowed once"
22+
allowed_session: " ✓ Allowed for this session"
23+
allowed_always: " ✓ Added to permanent allowlist"
24+
denied: " ✗ Denied"
25+
cancelled: " ✗ Cancelled"
26+
blocklist_message: "This command is on the unconditional blocklist and cannot be approved."
27+
28+
gateway:
29+
# Messenger replies to slash commands and implicit state changes.
30+
approval_expired: "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again."
31+
draining: "⏳ Draining {count} active agent(s) before restart..."
32+
goal_cleared: "✓ Goal cleared."
33+
no_active_goal: "No active goal."
34+
config_read_failed: "⚠️ Could not read config.yaml: {error}"
35+
config_save_failed: "⚠️ Could not save config: {error}"

locales/es.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Catálogo de mensajes estáticos de Hermes -- Español
2+
# See locales/en.yaml for the source of truth; keep keys in sync.
3+
4+
approval:
5+
dangerous_header: "⚠️ COMANDO PELIGROSO: {description}"
6+
choose_long: " [o]una vez | [s]sesión | [a]siempre | [d]denegar"
7+
choose_short: " [o]una vez | [s]sesión | [d]denegar"
8+
prompt_long: " Opción [o/s/a/D]: "
9+
prompt_short: " Opción [o/s/D]: "
10+
timeout: " ⏱ Tiempo agotado — comando denegado"
11+
allowed_once: " ✓ Permitido una vez"
12+
allowed_session: " ✓ Permitido en esta sesión"
13+
allowed_always: " ✓ Añadido a la lista de permitidos permanente"
14+
denied: " ✗ Denegado"
15+
cancelled: " ✗ Cancelado"
16+
blocklist_message: "Este comando está en la lista de bloqueo incondicional y no se puede aprobar."
17+
18+
gateway:
19+
approval_expired: "⚠️ La aprobación ha caducado (el agente ya no está esperando). Pida al agente que lo intente de nuevo."
20+
draining: "⏳ Esperando a que terminen {count} agente(s) activo(s) antes de reiniciar..."
21+
goal_cleared: "✓ Objetivo eliminado."
22+
no_active_goal: "No hay objetivo activo."
23+
config_read_failed: "⚠️ No se pudo leer config.yaml: {error}"
24+
config_save_failed: "⚠️ No se pudo guardar la configuración: {error}"

0 commit comments

Comments
 (0)