mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
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.
This commit is contained in:
parent
b7bd177105
commit
7de3c86c5a
11 changed files with 557 additions and 17 deletions
230
agent/i18n.py
Normal file
230
agent/i18n.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
"""Lightweight internationalization (i18n) for Hermes static user-facing messages.
|
||||
|
||||
Scope (thin slice, by design): only the highest-impact static strings shown
|
||||
to the user by Hermes itself -- approval prompts, a handful of gateway slash
|
||||
command replies, restart-drain notices. Agent-generated output, log lines,
|
||||
error tracebacks, tool outputs, and slash-command descriptions all stay in
|
||||
English.
|
||||
|
||||
Catalog files live under ``locales/<lang>.yaml`` at the repo root. Each
|
||||
catalog is a flat dict keyed by dotted paths (e.g. ``approval.choose`` or
|
||||
``gateway.approval_expired``). Missing keys fall back to English; if English
|
||||
is missing too, the key path itself is returned so a broken catalog never
|
||||
crashes the agent.
|
||||
|
||||
Usage::
|
||||
|
||||
from agent.i18n import t
|
||||
print(t("approval.choose_long")) # current lang
|
||||
print(t("gateway.draining", count=3)) # {count} formatted
|
||||
print(t("approval.choose_long", lang="zh")) # explicit override
|
||||
|
||||
Language resolution order:
|
||||
1. Explicit ``lang=`` argument passed to :func:`t`
|
||||
2. ``HERMES_LANGUAGE`` environment variable (for tests / quick override)
|
||||
3. ``display.language`` from config.yaml
|
||||
4. ``"en"`` (baseline)
|
||||
|
||||
Supported languages: en, zh, ja, de, es. Unknown values fall back to en.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es")
|
||||
DEFAULT_LANGUAGE = "en"
|
||||
|
||||
# Accept a few natural aliases so users who type "chinese" / "zh-CN" / "jp"
|
||||
# get the right catalog instead of silently falling back to English.
|
||||
_LANGUAGE_ALIASES: dict[str, str] = {
|
||||
"english": "en", "en-us": "en", "en-gb": "en",
|
||||
"chinese": "zh", "mandarin": "zh", "zh-cn": "zh", "zh-tw": "zh", "zh-hans": "zh", "zh-hant": "zh",
|
||||
"japanese": "ja", "jp": "ja", "ja-jp": "ja",
|
||||
"german": "de", "deutsch": "de", "de-de": "de",
|
||||
"spanish": "es", "español": "es", "espanol": "es", "es-es": "es", "es-mx": "es",
|
||||
}
|
||||
|
||||
_catalog_cache: dict[str, dict[str, str]] = {}
|
||||
_catalog_lock = threading.Lock()
|
||||
|
||||
|
||||
def _locales_dir() -> Path:
|
||||
"""Return the directory containing locale YAML files.
|
||||
|
||||
Lives next to the repo root so both the bundled install and editable
|
||||
checkouts find it without PYTHONPATH gymnastics.
|
||||
"""
|
||||
# agent/i18n.py -> agent/ -> repo root
|
||||
return Path(__file__).resolve().parent.parent / "locales"
|
||||
|
||||
|
||||
def _normalize_lang(value: Any) -> str:
|
||||
"""Normalize a user-supplied language value to a supported code.
|
||||
|
||||
Accepts supported codes directly, common aliases (``chinese`` -> ``zh``),
|
||||
and case-insensitive regional tags (``zh-CN`` -> ``zh``). Returns the
|
||||
default language for unknown values.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return DEFAULT_LANGUAGE
|
||||
key = value.strip().lower()
|
||||
if not key:
|
||||
return DEFAULT_LANGUAGE
|
||||
if key in SUPPORTED_LANGUAGES:
|
||||
return key
|
||||
if key in _LANGUAGE_ALIASES:
|
||||
return _LANGUAGE_ALIASES[key]
|
||||
# Try stripping a region suffix (e.g. "pt-br" -> "pt" won't be supported,
|
||||
# but "zh-CN" -> "zh" will).
|
||||
base = key.split("-", 1)[0]
|
||||
if base in SUPPORTED_LANGUAGES:
|
||||
return base
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _load_catalog(lang: str) -> dict[str, str]:
|
||||
"""Load and flatten one locale YAML file into a dotted-key dict.
|
||||
|
||||
YAML files can be nested for human readability; this produces the flat
|
||||
key space :func:`t` expects. Cached per-language for the process.
|
||||
"""
|
||||
with _catalog_lock:
|
||||
cached = _catalog_cache.get(lang)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
path = _locales_dir() / f"{lang}.yaml"
|
||||
if not path.is_file():
|
||||
logger.debug("i18n catalog missing for %s at %s", lang, path)
|
||||
with _catalog_lock:
|
||||
_catalog_cache[lang] = {}
|
||||
return {}
|
||||
|
||||
try:
|
||||
import yaml # PyYAML is already a hermes dependency
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load i18n catalog %s: %s", path, exc)
|
||||
with _catalog_lock:
|
||||
_catalog_cache[lang] = {}
|
||||
return {}
|
||||
|
||||
flat: dict[str, str] = {}
|
||||
_flatten_into(raw, "", flat)
|
||||
with _catalog_lock:
|
||||
_catalog_cache[lang] = flat
|
||||
return flat
|
||||
|
||||
|
||||
def _flatten_into(node: Any, prefix: str, out: dict[str, str]) -> None:
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
child_key = f"{prefix}.{key}" if prefix else str(key)
|
||||
_flatten_into(value, child_key, out)
|
||||
elif isinstance(node, str):
|
||||
out[prefix] = node
|
||||
# Non-string, non-dict leaves are ignored -- catalogs are text-only.
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _config_language_cached() -> str | None:
|
||||
"""Read ``display.language`` from config.yaml once per process.
|
||||
|
||||
Cached because ``t()`` is called in hot paths (every approval prompt,
|
||||
every gateway reply) and re-reading YAML each call would be wasteful.
|
||||
``reset_language_cache()`` clears this when config changes at runtime
|
||||
(e.g. after the setup wizard).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
cfg = load_config()
|
||||
lang = (cfg.get("display") or {}).get("language")
|
||||
if lang:
|
||||
return _normalize_lang(lang)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not read display.language from config: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def reset_language_cache() -> None:
|
||||
"""Invalidate cached language resolution and catalogs.
|
||||
|
||||
Call after :func:`hermes_cli.config.save_config` if a running process
|
||||
needs to pick up a changed ``display.language`` without restart.
|
||||
"""
|
||||
_config_language_cached.cache_clear()
|
||||
with _catalog_lock:
|
||||
_catalog_cache.clear()
|
||||
|
||||
|
||||
def get_language() -> str:
|
||||
"""Resolve the active language using env > config > default order."""
|
||||
env_lang = os.environ.get("HERMES_LANGUAGE")
|
||||
if env_lang:
|
||||
return _normalize_lang(env_lang)
|
||||
cfg_lang = _config_language_cached()
|
||||
if cfg_lang:
|
||||
return cfg_lang
|
||||
return DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def t(key: str, lang: str | None = None, **format_kwargs: Any) -> str:
|
||||
"""Translate a dotted key to the active language.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
key
|
||||
Dotted path into the catalog, e.g. ``"approval.choose_long"``.
|
||||
lang
|
||||
Explicit language override. Takes precedence over env + config.
|
||||
**format_kwargs
|
||||
``str.format`` substitution arguments (``t("gateway.drain", count=3)``
|
||||
expects a catalog entry with a ``{count}`` placeholder).
|
||||
|
||||
Returns
|
||||
-------
|
||||
The translated string, or the English fallback if the key is missing in
|
||||
the target language, or the bare key if English is also missing.
|
||||
"""
|
||||
target = _normalize_lang(lang) if lang else get_language()
|
||||
catalog = _load_catalog(target)
|
||||
value = catalog.get(key)
|
||||
|
||||
if value is None and target != DEFAULT_LANGUAGE:
|
||||
# Fall through to English rather than showing a key path to the user.
|
||||
value = _load_catalog(DEFAULT_LANGUAGE).get(key)
|
||||
|
||||
if value is None:
|
||||
# Last-ditch: return the key itself. A broken catalog should not
|
||||
# crash anything; it just looks ugly until someone fixes it.
|
||||
logger.debug("i18n miss: key=%r lang=%r", key, target)
|
||||
value = key
|
||||
|
||||
if format_kwargs:
|
||||
try:
|
||||
return value.format(**format_kwargs)
|
||||
except (KeyError, IndexError, ValueError) as exc:
|
||||
logger.warning(
|
||||
"i18n format failed for key=%r lang=%r kwargs=%r: %s",
|
||||
key, target, format_kwargs, exc,
|
||||
)
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SUPPORTED_LANGUAGES",
|
||||
"DEFAULT_LANGUAGE",
|
||||
"t",
|
||||
"get_language",
|
||||
"reset_language_cache",
|
||||
]
|
||||
|
|
@ -39,6 +39,7 @@ from typing import Dict, Optional, Any, List, Union
|
|||
# gateway is a long-running daemon, so its boot cost matters less than
|
||||
# preserving the established test-patch surface.
|
||||
from agent.account_usage import fetch_account_usage, render_account_usage_lines
|
||||
from agent.i18n import t
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
# --- Agent cache tuning ---------------------------------------------------
|
||||
|
|
@ -7377,7 +7378,7 @@ class GatewayRunner:
|
|||
if self._restart_requested or self._draining:
|
||||
count = self._running_agent_count()
|
||||
if count:
|
||||
return f"⏳ Draining {count} active agent(s) before restart..."
|
||||
return t("gateway.draining", count=count)
|
||||
return EphemeralReply("⏳ Gateway restart already in progress...")
|
||||
|
||||
# Save the requester's routing info so the new gateway process can
|
||||
|
|
@ -7429,7 +7430,7 @@ class GatewayRunner:
|
|||
else:
|
||||
self.request_restart(detached=True, via_service=False)
|
||||
if active_agents:
|
||||
return f"⏳ Draining {active_agents} active agent(s) before restart..."
|
||||
return t("gateway.draining", count=active_agents)
|
||||
return EphemeralReply("♻ Restarting gateway. If you aren't notified within 60 seconds, restart from the console with `hermes gateway restart`.")
|
||||
|
||||
def _is_stale_restart_redelivery(self, event: MessageEvent) -> bool:
|
||||
|
|
@ -8099,7 +8100,7 @@ class GatewayRunner:
|
|||
if lower in ("clear", "stop", "done"):
|
||||
had = mgr.has_goal()
|
||||
mgr.clear()
|
||||
return "✓ Goal cleared." if had else "No active goal."
|
||||
return t("gateway.goal_cleared") if had else t("gateway.no_active_goal")
|
||||
|
||||
# Otherwise — treat the remaining text as the new goal.
|
||||
try:
|
||||
|
|
@ -9317,7 +9318,7 @@ class GatewayRunner:
|
|||
try:
|
||||
user_config: dict = _load_gateway_config()
|
||||
except Exception as e:
|
||||
return f"⚠️ Could not read config.yaml: {e}"
|
||||
return t("gateway.config_read_failed", error=e)
|
||||
|
||||
effective = resolve_footer_config(user_config, platform_key)
|
||||
|
||||
|
|
@ -9350,7 +9351,7 @@ class GatewayRunner:
|
|||
atomic_yaml_write(config_path, user_config)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save runtime_footer.enabled: %s", e)
|
||||
return f"⚠️ Could not save config: {e}"
|
||||
return t("gateway.config_save_failed", error=e)
|
||||
|
||||
state = "ON" if new_state else "OFF"
|
||||
example = ""
|
||||
|
|
@ -10788,7 +10789,7 @@ class GatewayRunner:
|
|||
if not has_blocking_approval(session_key):
|
||||
if session_key in self._pending_approvals:
|
||||
self._pending_approvals.pop(session_key)
|
||||
return "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again."
|
||||
return t("gateway.approval_expired")
|
||||
return "No pending command to approve."
|
||||
|
||||
# Parse args: support "all", "all session", "all always", "session", "always"
|
||||
|
|
|
|||
|
|
@ -781,6 +781,11 @@ DEFAULT_CONFIG = {
|
|||
"inline_diffs": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
# handful of gateway slash-command replies). Does NOT affect agent
|
||||
# responses, log lines, tool outputs, or slash-command descriptions.
|
||||
# Supported: en, zh, ja, de, es. Unknown values fall back to en.
|
||||
"language": "en",
|
||||
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
|
||||
# spinner), or ascii. Live-swappable via `/indicator <style>`.
|
||||
"tui_status_indicator": "kaomoji",
|
||||
|
|
|
|||
24
locales/de.yaml
Normal file
24
locales/de.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Hermes-Katalog für statische Meldungen -- Deutsch
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ GEFÄHRLICHER BEFEHL: {description}"
|
||||
choose_long: " [o]einmal | [s]sitzung | [a]immer | [d]ablehnen"
|
||||
choose_short: " [o]einmal | [s]sitzung | [d]ablehnen"
|
||||
prompt_long: " Auswahl [o/s/a/D]: "
|
||||
prompt_short: " Auswahl [o/s/D]: "
|
||||
timeout: " ⏱ Zeitüberschreitung – Befehl wird abgelehnt"
|
||||
allowed_once: " ✓ Einmalig erlaubt"
|
||||
allowed_session: " ✓ Für diese Sitzung erlaubt"
|
||||
allowed_always: " ✓ Zur dauerhaften Erlaubnisliste hinzugefügt"
|
||||
denied: " ✗ Abgelehnt"
|
||||
cancelled: " ✗ Abgebrochen"
|
||||
blocklist_message: "Dieser Befehl steht auf der unbedingten Sperrliste und kann nicht genehmigt werden."
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ Genehmigung abgelaufen (Agent wartet nicht mehr). Bitten Sie den Agenten, es erneut zu versuchen."
|
||||
draining: "⏳ Warte auf {count} aktive(n) Agent(en) vor dem Neustart..."
|
||||
goal_cleared: "✓ Ziel gelöscht."
|
||||
no_active_goal: "Kein aktives Ziel."
|
||||
config_read_failed: "⚠️ config.yaml konnte nicht gelesen werden: {error}"
|
||||
config_save_failed: "⚠️ Konfiguration konnte nicht gespeichert werden: {error}"
|
||||
35
locales/en.yaml
Normal file
35
locales/en.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Hermes static-message catalog -- English (baseline / source of truth)
|
||||
#
|
||||
# Only user-facing static messages from the CLI approval prompt and a handful
|
||||
# of gateway slash-command replies live here. Agent-generated output, log
|
||||
# lines, error tracebacks, tool outputs, and slash-command descriptions stay
|
||||
# in English and are NOT translated -- see agent/i18n.py for scope rationale.
|
||||
#
|
||||
# Keys are dotted paths; nesting below is purely for readability. Values may
|
||||
# contain {placeholder} tokens for str.format substitution. When adding a
|
||||
# new key, add it to EVERY locale file (en/zh/ja/de/es) in the same commit --
|
||||
# tests/agent/test_i18n.py asserts catalog parity.
|
||||
|
||||
approval:
|
||||
# CLI approval prompt -- shown when a dangerous command needs user review.
|
||||
dangerous_header: "⚠️ DANGEROUS COMMAND: {description}"
|
||||
choose_long: " [o]nce | [s]ession | [a]lways | [d]eny"
|
||||
choose_short: " [o]nce | [s]ession | [d]eny"
|
||||
prompt_long: " Choice [o/s/a/D]: "
|
||||
prompt_short: " Choice [o/s/D]: "
|
||||
timeout: " ⏱ Timeout - denying command"
|
||||
allowed_once: " ✓ Allowed once"
|
||||
allowed_session: " ✓ Allowed for this session"
|
||||
allowed_always: " ✓ Added to permanent allowlist"
|
||||
denied: " ✗ Denied"
|
||||
cancelled: " ✗ Cancelled"
|
||||
blocklist_message: "This command is on the unconditional blocklist and cannot be approved."
|
||||
|
||||
gateway:
|
||||
# Messenger replies to slash commands and implicit state changes.
|
||||
approval_expired: "⚠️ Approval expired (agent is no longer waiting). Ask the agent to try again."
|
||||
draining: "⏳ Draining {count} active agent(s) before restart..."
|
||||
goal_cleared: "✓ Goal cleared."
|
||||
no_active_goal: "No active goal."
|
||||
config_read_failed: "⚠️ Could not read config.yaml: {error}"
|
||||
config_save_failed: "⚠️ Could not save config: {error}"
|
||||
24
locales/es.yaml
Normal file
24
locales/es.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Catálogo de mensajes estáticos de Hermes -- Español
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ COMANDO PELIGROSO: {description}"
|
||||
choose_long: " [o]una vez | [s]sesión | [a]siempre | [d]denegar"
|
||||
choose_short: " [o]una vez | [s]sesión | [d]denegar"
|
||||
prompt_long: " Opción [o/s/a/D]: "
|
||||
prompt_short: " Opción [o/s/D]: "
|
||||
timeout: " ⏱ Tiempo agotado — comando denegado"
|
||||
allowed_once: " ✓ Permitido una vez"
|
||||
allowed_session: " ✓ Permitido en esta sesión"
|
||||
allowed_always: " ✓ Añadido a la lista de permitidos permanente"
|
||||
denied: " ✗ Denegado"
|
||||
cancelled: " ✗ Cancelado"
|
||||
blocklist_message: "Este comando está en la lista de bloqueo incondicional y no se puede aprobar."
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ La aprobación ha caducado (el agente ya no está esperando). Pida al agente que lo intente de nuevo."
|
||||
draining: "⏳ Esperando a que terminen {count} agente(s) activo(s) antes de reiniciar..."
|
||||
goal_cleared: "✓ Objetivo eliminado."
|
||||
no_active_goal: "No hay objetivo activo."
|
||||
config_read_failed: "⚠️ No se pudo leer config.yaml: {error}"
|
||||
config_save_failed: "⚠️ No se pudo guardar la configuración: {error}"
|
||||
24
locales/ja.yaml
Normal file
24
locales/ja.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Hermes 静的メッセージカタログ -- 日本語
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ 危険なコマンド: {description}"
|
||||
choose_long: " [o]今回のみ | [s]セッション中 | [a]常に許可 | [d]拒否"
|
||||
choose_short: " [o]今回のみ | [s]セッション中 | [d]拒否"
|
||||
prompt_long: " 選択 [o/s/a/D]: "
|
||||
prompt_short: " 選択 [o/s/D]: "
|
||||
timeout: " ⏱ タイムアウト — コマンドを拒否しました"
|
||||
allowed_once: " ✓ 今回のみ許可"
|
||||
allowed_session: " ✓ このセッション中は許可"
|
||||
allowed_always: " ✓ 永続的な許可リストに追加"
|
||||
denied: " ✗ 拒否しました"
|
||||
cancelled: " ✗ キャンセルしました"
|
||||
blocklist_message: "このコマンドは無条件ブロックリストに含まれており、承認できません。"
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ 承認の有効期限が切れました(エージェントはもう待機していません)。エージェントに再試行を依頼してください。"
|
||||
draining: "⏳ 再起動前に {count} 個のアクティブエージェントの終了を待っています..."
|
||||
goal_cleared: "✓ 目標をクリアしました。"
|
||||
no_active_goal: "アクティブな目標はありません。"
|
||||
config_read_failed: "⚠️ config.yaml を読み込めませんでした: {error}"
|
||||
config_save_failed: "⚠️ 設定を保存できませんでした: {error}"
|
||||
24
locales/zh.yaml
Normal file
24
locales/zh.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Hermes 静态消息目录 -- 中文(简体)
|
||||
# See locales/en.yaml for the source of truth; keep keys in sync.
|
||||
|
||||
approval:
|
||||
dangerous_header: "⚠️ 危险命令: {description}"
|
||||
choose_long: " [o]仅此一次 | [s]本次会话 | [a]永久允许 | [d]拒绝"
|
||||
choose_short: " [o]仅此一次 | [s]本次会话 | [d]拒绝"
|
||||
prompt_long: " 选择 [o/s/a/D]: "
|
||||
prompt_short: " 选择 [o/s/D]: "
|
||||
timeout: " ⏱ 超时 — 已拒绝命令"
|
||||
allowed_once: " ✓ 本次允许"
|
||||
allowed_session: " ✓ 本次会话内允许"
|
||||
allowed_always: " ✓ 已加入永久允许列表"
|
||||
denied: " ✗ 已拒绝"
|
||||
cancelled: " ✗ 已取消"
|
||||
blocklist_message: "此命令位于无条件拦截列表中,无法被批准。"
|
||||
|
||||
gateway:
|
||||
approval_expired: "⚠️ 批准已过期(代理不再等待)。请让代理重试。"
|
||||
draining: "⏳ 正在等待 {count} 个活跃代理结束后重启..."
|
||||
goal_cleared: "✓ 目标已清除。"
|
||||
no_active_goal: "当前没有活跃的目标。"
|
||||
config_read_failed: "⚠️ 无法读取 config.yaml:{error}"
|
||||
config_save_failed: "⚠️ 无法保存配置:{error}"
|
||||
156
tests/agent/test_i18n.py
Normal file
156
tests/agent/test_i18n.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""Tests for agent.i18n -- catalog parity, fallback, language resolution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from agent import i18n
|
||||
|
||||
|
||||
LOCALES_DIR = Path(__file__).resolve().parents[2] / "locales"
|
||||
|
||||
|
||||
def _load_raw(lang: str) -> dict:
|
||||
with (LOCALES_DIR / f"{lang}.yaml").open("r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def _flatten(d, prefix="") -> dict:
|
||||
flat = {}
|
||||
for k, v in (d or {}).items():
|
||||
key = f"{prefix}.{k}" if prefix else k
|
||||
if isinstance(v, dict):
|
||||
flat.update(_flatten(v, key))
|
||||
else:
|
||||
flat[key] = v
|
||||
return flat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog completeness -- this is the key invariant test. If someone adds a
|
||||
# new key to en.yaml they MUST add it to every other locale, else runtime
|
||||
# falls back to English for those users and defeats the feature.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_all_locales_exist():
|
||||
"""Every supported language must have a catalog file on disk."""
|
||||
for lang in i18n.SUPPORTED_LANGUAGES:
|
||||
assert (LOCALES_DIR / f"{lang}.yaml").is_file(), f"missing locales/{lang}.yaml"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang", [l for l in i18n.SUPPORTED_LANGUAGES if l != "en"])
|
||||
def test_catalog_keys_match_english(lang: str):
|
||||
"""Every non-English catalog must have exactly the same key set as English."""
|
||||
en_keys = set(_flatten(_load_raw("en")).keys())
|
||||
lang_keys = set(_flatten(_load_raw(lang)).keys())
|
||||
missing = en_keys - lang_keys
|
||||
extra = lang_keys - en_keys
|
||||
assert not missing, f"{lang}.yaml missing keys: {sorted(missing)}"
|
||||
assert not extra, f"{lang}.yaml has keys not in en.yaml: {sorted(extra)}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lang", list(i18n.SUPPORTED_LANGUAGES))
|
||||
def test_catalog_placeholders_match_english(lang: str):
|
||||
"""Every translated value must use the same {placeholder} tokens as English.
|
||||
|
||||
A mistranslated placeholder (e.g. ``{description}`` typoed as ``{descricao}``)
|
||||
would either raise KeyError at runtime or silently drop the interpolated
|
||||
value. Pin parity at the test layer.
|
||||
"""
|
||||
import re
|
||||
placeholder_re = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
||||
en_flat = _flatten(_load_raw("en"))
|
||||
lang_flat = _flatten(_load_raw(lang))
|
||||
for key, en_value in en_flat.items():
|
||||
en_placeholders = set(placeholder_re.findall(en_value))
|
||||
lang_value = lang_flat.get(key, "")
|
||||
lang_placeholders = set(placeholder_re.findall(lang_value))
|
||||
assert en_placeholders == lang_placeholders, (
|
||||
f"{lang}.yaml key={key!r}: placeholders {lang_placeholders} "
|
||||
f"don't match English {en_placeholders}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Language resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_normalize_lang_accepts_supported():
|
||||
assert i18n._normalize_lang("zh") == "zh"
|
||||
assert i18n._normalize_lang("EN") == "en"
|
||||
|
||||
|
||||
def test_normalize_lang_accepts_aliases():
|
||||
assert i18n._normalize_lang("chinese") == "zh"
|
||||
assert i18n._normalize_lang("zh-CN") == "zh"
|
||||
assert i18n._normalize_lang("Deutsch") == "de"
|
||||
assert i18n._normalize_lang("español") == "es"
|
||||
assert i18n._normalize_lang("jp") == "ja"
|
||||
|
||||
|
||||
def test_normalize_lang_unknown_falls_back():
|
||||
assert i18n._normalize_lang("klingon") == "en"
|
||||
assert i18n._normalize_lang("") == "en"
|
||||
assert i18n._normalize_lang(None) == "en"
|
||||
|
||||
|
||||
def test_env_var_override(monkeypatch):
|
||||
"""HERMES_LANGUAGE wins over config."""
|
||||
i18n.reset_language_cache()
|
||||
monkeypatch.setenv("HERMES_LANGUAGE", "ja")
|
||||
assert i18n.get_language() == "ja"
|
||||
|
||||
|
||||
def test_env_var_normalized(monkeypatch):
|
||||
i18n.reset_language_cache()
|
||||
monkeypatch.setenv("HERMES_LANGUAGE", "Chinese")
|
||||
assert i18n.get_language() == "zh"
|
||||
|
||||
|
||||
def test_default_when_nothing_set(monkeypatch):
|
||||
"""With no env var and no config override, falls back to English."""
|
||||
monkeypatch.delenv("HERMES_LANGUAGE", raising=False)
|
||||
# Force config lookup to return None -- patch the cached reader.
|
||||
i18n.reset_language_cache()
|
||||
monkeypatch.setattr(i18n, "_config_language_cached", lambda: None)
|
||||
assert i18n.get_language() == "en"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# t() semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_t_explicit_lang():
|
||||
assert i18n.t("approval.denied", lang="en").endswith("Denied")
|
||||
assert i18n.t("approval.denied", lang="zh").endswith("已拒绝")
|
||||
|
||||
|
||||
def test_t_formats_placeholders():
|
||||
msg = i18n.t("gateway.draining", lang="en", count=3)
|
||||
assert "3" in msg
|
||||
|
||||
|
||||
def test_t_missing_key_returns_key():
|
||||
"""A missing key returns its own path -- ugly but never crashes."""
|
||||
result = i18n.t("nonexistent.key.path", lang="en")
|
||||
assert result == "nonexistent.key.path"
|
||||
|
||||
|
||||
def test_t_missing_key_in_non_english_falls_back_to_english(tmp_path, monkeypatch):
|
||||
"""If a key exists in English but not in the target locale, fall back."""
|
||||
# Stand up a fake incomplete locale under a temp locales dir.
|
||||
fake_locales = tmp_path / "locales"
|
||||
fake_locales.mkdir()
|
||||
(fake_locales / "en.yaml").write_text("foo: English Foo\n", encoding="utf-8")
|
||||
(fake_locales / "zh.yaml").write_text("# intentionally empty\n", encoding="utf-8")
|
||||
monkeypatch.setattr(i18n, "_locales_dir", lambda: fake_locales)
|
||||
i18n.reset_language_cache()
|
||||
assert i18n.t("foo", lang="zh") == "English Foo"
|
||||
|
||||
|
||||
def test_t_unknown_language_uses_english():
|
||||
"""Unknown lang codes normalize to English, not to a key-path fallback."""
|
||||
assert i18n.t("approval.denied", lang="klingon") == i18n.t("approval.denied", lang="en")
|
||||
|
|
@ -628,15 +628,18 @@ def prompt_dangerous_approval(command: str, description: str,
|
|||
|
||||
os.environ["HERMES_SPINNER_PAUSE"] = "1"
|
||||
try:
|
||||
# Resolve the active UI language once per prompt so we don't re-read
|
||||
# config/YAML inside the retry loop below.
|
||||
from agent.i18n import t
|
||||
while True:
|
||||
print()
|
||||
print(f" ⚠️ DANGEROUS COMMAND: {description}")
|
||||
print(f" {t('approval.dangerous_header', description=description)}")
|
||||
print(f" {command}")
|
||||
print()
|
||||
if allow_permanent:
|
||||
print(" [o]nce | [s]ession | [a]lways | [d]eny")
|
||||
print(t("approval.choose_long"))
|
||||
else:
|
||||
print(" [o]nce | [s]ession | [d]eny")
|
||||
print(t("approval.choose_short"))
|
||||
print()
|
||||
sys.stdout.flush()
|
||||
|
||||
|
|
@ -644,7 +647,7 @@ def prompt_dangerous_approval(command: str, description: str,
|
|||
|
||||
def get_input():
|
||||
try:
|
||||
prompt = " Choice [o/s/a/D]: " if allow_permanent else " Choice [o/s/D]: "
|
||||
prompt = t("approval.prompt_long") if allow_permanent else t("approval.prompt_short")
|
||||
result["choice"] = input(prompt).strip().lower()
|
||||
except (EOFError, OSError):
|
||||
result["choice"] = ""
|
||||
|
|
@ -654,28 +657,28 @@ def prompt_dangerous_approval(command: str, description: str,
|
|||
thread.join(timeout=timeout_seconds)
|
||||
|
||||
if thread.is_alive():
|
||||
print("\n ⏱ Timeout - denying command")
|
||||
print("\n" + t("approval.timeout"))
|
||||
return "deny"
|
||||
|
||||
choice = result["choice"]
|
||||
if choice in ('o', 'once'):
|
||||
print(" ✓ Allowed once")
|
||||
print(t("approval.allowed_once"))
|
||||
return "once"
|
||||
elif choice in ('s', 'session'):
|
||||
print(" ✓ Allowed for this session")
|
||||
print(t("approval.allowed_session"))
|
||||
return "session"
|
||||
elif choice in ('a', 'always'):
|
||||
if not allow_permanent:
|
||||
print(" ✓ Allowed for this session")
|
||||
print(t("approval.allowed_session"))
|
||||
return "session"
|
||||
print(" ✓ Added to permanent allowlist")
|
||||
print(t("approval.allowed_always"))
|
||||
return "always"
|
||||
else:
|
||||
print(" ✗ Denied")
|
||||
print(t("approval.denied"))
|
||||
return "deny"
|
||||
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n ✗ Cancelled")
|
||||
print("\n" + t("approval.cancelled"))
|
||||
return "deny"
|
||||
finally:
|
||||
if "HERMES_SPINNER_PAUSE" in os.environ:
|
||||
|
|
|
|||
|
|
@ -1167,6 +1167,20 @@ display:
|
|||
show_cost: false # Show estimated $ cost in the CLI status bar
|
||||
tool_preview_length: 0 # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
runtime_metadata_footer: false # Gateway: append a runtime-context footer to final replies
|
||||
language: en # UI language for static messages (approval prompts, some gateway replies). en | zh | ja | de | es
|
||||
```
|
||||
|
||||
### UI language for static messages
|
||||
|
||||
The `display.language` setting translates a small set of static user-facing messages — the CLI approval prompt, a handful of gateway slash-command replies (e.g. restart-drain notices, "approval expired", "goal cleared"). It does **not** translate agent responses, log lines, tool output, error tracebacks, or slash-command descriptions — those stay in English. If you want the agent itself to reply in another language, just tell it in your prompt or system message.
|
||||
|
||||
Supported values: `en` (default), `zh` (Simplified Chinese), `ja` (Japanese), `de` (German), `es` (Spanish). Unknown values fall back to English.
|
||||
|
||||
You can also set this per-session with the `HERMES_LANGUAGE` env var, which overrides the config value.
|
||||
|
||||
```yaml
|
||||
display:
|
||||
language: zh # CLI approval prompts appear in Chinese
|
||||
```
|
||||
|
||||
| Mode | What you see |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue