diff --git a/.env.example b/.env.example
index 589978e6b5a55..6cd9c3023984a 100644
--- a/.env.example
+++ b/.env.example
@@ -244,6 +244,15 @@ BROWSERBASE_PROXIES=true
# Uses custom Chromium build to avoid bot detection altogether
BROWSERBASE_ADVANCED_STEALTH=false
+# Browser engine for local mode (default: auto = Chrome)
+# "auto" — use Chrome (don't pass --engine flag)
+# "lightpanda" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)
+# "chrome" — explicitly request Chrome
+# Requires agent-browser v0.25.3+. Lightpanda commands that fail or return
+# empty results are automatically retried with Chrome.
+# Also configurable via browser.engine in config.yaml.
+# AGENT_BROWSER_ENGINE=auto
+
# Browser session timeout in seconds (default: 300)
# Sessions are cleaned up after this duration of inactivity
BROWSER_SESSION_TIMEOUT=300
diff --git a/AGENTS.md b/AGENTS.md
index f09258061fdf2..b77a1d269990a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,12 +37,17 @@ hermes-agent/
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
│ │ # dingtalk, wecom, weixin, feishu, qqbot, bluebubbles,
-│ │ # webhook, api_server, ...). See ADDING_A_PLATFORM.md.
+│ │ # yuanbao, webhook, api_server, ...). See ADDING_A_PLATFORM.md.
│ └── builtin_hooks/ # Extension point for always-registered gateway hooks (none shipped)
├── plugins/ # Plugin system (see "Plugins" section below)
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
│ ├── context_engine/ # Context-engine plugins
-│ └── / # Dashboard, image-gen, disk-cleanup, examples, ...
+│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
+│ ├── hermes-achievements/ # Gamified achievement tracking
+│ ├── observability/ # Metrics / traces / logs plugin
+│ ├── image_gen/ # Image-generation providers
+│ └── / # disk-cleanup, example-dashboard, google_meet, platforms,
+│ # spotify, strike-freedom-cockpit, ...
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
├── skills/ # Built-in skills bundled with the repo
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
@@ -53,7 +58,7 @@ hermes-agent/
├── environments/ # RL training environments (Atropos)
├── scripts/ # run_tests.sh, release.py, auxiliary scripts
├── website/ # Docusaurus docs site
-└── tests/ # Pytest suite (~15k tests across ~700 files as of Apr 2026)
+└── tests/ # Pytest suite (~17k tests across ~900 files as of May 2026)
```
**User config:** `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys only).
@@ -289,9 +294,9 @@ registry.register(
)
```
-**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset.
+**2. Add to `toolsets.py`** — either `_HERMES_CORE_TOOLS` (all platforms) or a new toolset. **This step is required:** auto-discovery imports the tool and registers its schema, but the tool is only *exposed to an agent* if its name appears in a toolset. `_HERMES_CORE_TOOLS` is not dead code — it's the default bundle every platform's base toolset inherits from.
-Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain.
+Auto-discovery: any `tools/*.py` file with a top-level `registry.register()` call is imported automatically — no manual import list to maintain. Wiring into a toolset is still a deliberate, manual step.
The registry handles schema collection, dispatch, availability checking, and error wrapping. All handlers MUST return a JSON string.
@@ -313,6 +318,22 @@ The registry handles schema collection, dispatch, availability checking, and err
section is handled automatically by the deep-merge and does NOT require
a version bump.
+### Top-level `config.yaml` sections (non-exhaustive):
+
+`model`, `agent`, `terminal`, `compression`, `display`, `stt`, `tts`,
+`memory`, `security`, `delegation`, `smart_model_routing`, `checkpoints`,
+`auxiliary`, `curator`, `skills`, `gateway`, `logging`, `cron`, `profiles`,
+`plugins`, `honcho`.
+
+`auxiliary` holds per-task overrides for side-LLM work (curator, vision,
+embedding, title generation, session_search, etc.) — each task can pin
+its own provider/model/base_url/max_tokens/reasoning_effort. See
+`agent/auxiliary_client.py::_resolve_auto` for resolution order.
+
+`curator` holds the background skill-maintenance config —
+`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
+`archive_after_days`, `backup` (nested).
+
### .env variables (SECRETS ONLY — API keys, tokens, passwords):
1. Add to `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` with metadata:
```python
@@ -519,11 +540,176 @@ niche skills belong in `optional-skills/`.
### SKILL.md frontmatter
-Standard fields: `name`, `description`, `version`, `platforms`
-(OS-gating list: `[macos]`, `[linux, macos]`, ...),
+Standard fields: `name`, `description`, `version`, `author`, `license`,
+`platforms` (OS-gating list: `[macos]`, `[linux, macos]`, ...),
`metadata.hermes.tags`, `metadata.hermes.category`,
-`metadata.hermes.config` (config.yaml settings the skill needs — stored
-under `skills.config.`, prompted during setup, injected at load time).
+`metadata.hermes.related_skills`, `metadata.hermes.config` (config.yaml
+settings the skill needs — stored under `skills.config.`, prompted
+during setup, injected at load time).
+
+Top-level `tags:` and `category:` are also accepted and mirrored from
+`metadata.hermes.*` by the loader.
+
+---
+
+## Toolsets
+
+All toolsets are defined in `toolsets.py` as a single `TOOLSETS` dict.
+Each platform's adapter picks a base toolset (e.g. Telegram uses
+`"messaging"`); `_HERMES_CORE_TOOLS` is the default bundle most
+platforms inherit from.
+
+Current toolset keys: `browser`, `clarify`, `code_execution`, `cronjob`,
+`debugging`, `delegation`, `discord`, `discord_admin`, `feishu_doc`,
+`feishu_drive`, `file`, `homeassistant`, `image_gen`, `kanban`, `memory`,
+`messaging`, `moa`, `rl`, `safe`, `search`, `session_search`, `skills`,
+`spotify`, `terminal`, `todo`, `tts`, `video`, `vision`, `web`, `yuanbao`.
+
+Enable/disable per platform via `hermes tools` (the curses UI) or the
+`tools..enabled` / `tools..disabled` lists in
+`config.yaml`.
+
+---
+
+## Delegation (`delegate_task`)
+
+`tools/delegate_tool.py` spawns a subagent with an isolated
+context + terminal session. Synchronous: the parent waits for the
+child's summary before continuing its own loop — if the parent is
+interrupted, the child is cancelled.
+
+Two shapes:
+
+- **Single:** pass `goal` (+ optional `context`, `toolsets`).
+- **Batch (parallel):** pass `tasks: [...]` — each gets its own subagent
+ running concurrently. Concurrency is capped by
+ `delegation.max_concurrent_children` (default 3).
+
+Roles:
+
+- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
+ `clarify`, `memory`, `send_message`, `execute_code`.
+- `role="orchestrator"` — retains `delegate_task` so it can spawn its
+ own workers. Gated by `delegation.orchestrator_enabled` (default true)
+ and bounded by `delegation.max_spawn_depth` (default 2).
+
+Key config knobs (under `delegation:` in `config.yaml`):
+`max_concurrent_children`, `max_spawn_depth`, `child_timeout_seconds`,
+`orchestrator_enabled`, `subagent_auto_approve`, `inherit_mcp_toolsets`,
+`max_iterations`.
+
+Synchronicity rule: delegate_task is **not** durable. For long-running
+work that must outlive the current turn, use `cronjob` or
+`terminal(background=True, notify_on_complete=True)` instead.
+
+---
+
+## Curator (skill lifecycle)
+
+Background skill-maintenance system that tracks usage on agent-created
+skills and auto-archives stale ones. Users never lose skills; archives
+go to `~/.hermes/skills/.archive/` and are restorable.
+
+- **Core:** `agent/curator.py` (review loop, auto-transitions, LLM review
+ prompt) + `agent/curator_backup.py` (pre-run tar.gz snapshots).
+- **CLI:** `hermes_cli/curator.py` wires `hermes curator ` where
+ verbs are: `status`, `run`, `pause`, `resume`, `pin`, `unpin`,
+ `archive`, `restore`, `prune`, `backup`, `rollback`.
+- **Telemetry:** `tools/skill_usage.py` owns the sidecar
+ `~/.hermes/skills/.usage.json` — per-skill `use_count`, `view_count`,
+ `patch_count`, `last_activity_at`, `state` (active / stale /
+ archived), `pinned`.
+
+Invariants:
+- Curator only touches skills with `created_by: "agent"` provenance —
+ bundled + hub-installed skills are off-limits.
+- Never deletes; max destructive action is archive.
+- Pinned skills are exempt from every auto-transition and from the
+ LLM review pass.
+- `skill_manage(action="delete")` refuses pinned skills; patch/edit/
+ write_file/remove_file go through so the agent can keep improving
+ pinned skills.
+
+Config section (`curator:` in `config.yaml`):
+`enabled`, `interval_hours`, `min_idle_hours`, `stale_after_days`,
+`archive_after_days`, `backup.*`.
+
+Full user-facing docs: `website/docs/user-guide/features/curator.md`.
+
+---
+
+## Cron (scheduled jobs)
+
+`cron/jobs.py` (job store) + `cron/scheduler.py` (tick loop). Agents
+schedule jobs via the `cronjob` tool; users via `hermes cron `
+(`list`, `add`, `edit`, `pause`, `resume`, `run`, `remove`) or the
+`/cron` slash command.
+
+Supported schedule formats:
+- Duration: `"30m"`, `"2h"`, `"1d"`
+- "every" phrase: `"every 2h"`, `"every monday 9am"`
+- 5-field cron expression: `"0 9 * * *"`
+- ISO timestamp (one-shot): `"2026-06-01T09:00:00Z"`
+
+Per-job fields include `skills` (load specific skills), `model` /
+`provider` overrides, `script` (pre-run data-collection script whose
+stdout is injected into the prompt; `no_agent=True` turns the script
+into the entire job), `context_from` (chain job A's last output into
+job B's prompt), `workdir` (run in a specific directory with its
+`AGENTS.md`/`CLAUDE.md` loaded), and multi-platform delivery.
+
+Hardening invariants:
+- **3-minute hard interrupt** on cron sessions — runaway agent loops
+ cannot monopolize the scheduler.
+- Catchup window: half the job's period, clamped to 120s–2h.
+- Grace window: 120s for one-shot jobs whose fire time was missed.
+- File lock at `~/.hermes/cron/.tick.lock` prevents duplicate ticks
+ across processes.
+- Cron sessions pass `skip_memory=True` by default; memory providers
+ intentionally do not run during cron.
+
+Cron deliveries are **not** mirrored into the target gateway session —
+they land in their own cron session with a header/footer frame so the
+main conversation's message-role alternation stays intact.
+
+---
+
+## Kanban (multi-agent work queue)
+
+Durable SQLite-backed board that lets multiple profiles / workers
+collaborate on shared tasks. Users drive it via `hermes kanban `;
+workers spawned by the dispatcher drive it via a dedicated `kanban_*`
+toolset so their schema footprint is zero when they're not inside a
+kanban task.
+
+- **CLI:** `hermes_cli/kanban.py` wires `hermes kanban` with verbs
+ `init`, `create`, `list` (alias `ls`), `show`, `assign`, `link`,
+ `unlink`, `comment`, `complete`, `block`, `unblock`, `archive`,
+ `tail`, plus less-commonly-used `watch`, `stats`, `runs`, `log`,
+ `assignees`, `heartbeat`, `notify-*`, `dispatch`, `daemon`, `gc`.
+- **Worker toolset:** `tools/kanban_tools.py` exposes `kanban_show`,
+ `kanban_complete`, `kanban_block`, `kanban_heartbeat`, `kanban_comment`,
+ `kanban_create`, `kanban_link` — gated by `HERMES_KANBAN_TASK` so
+ the schema only appears for processes actually running as a worker.
+- **Dispatcher:** long-lived loop that (default every 60s) reclaims
+ stale claims, promotes ready tasks, atomically claims, and spawns
+ assigned profiles. Runs **inside the gateway** by default via
+ `kanban.dispatch_in_gateway: true`.
+- **Plugin assets:** `plugins/kanban/dashboard/` (web UI) +
+ `plugins/kanban/systemd/` (`hermes-kanban-dispatcher.service` for
+ standalone dispatcher deployment).
+
+Isolation model:
+- **Board** is the hard boundary — workers are spawned with
+ `HERMES_KANBAN_BOARD` pinned in their env so they can't see other
+ boards.
+- **Tenant** is a soft namespace *within* a board — one specialist
+ fleet can serve multiple businesses with workspace-path + memory-key
+ isolation.
+- After ~5 consecutive spawn failures on the same task the dispatcher
+ auto-blocks it to prevent spin loops.
+
+Full user-facing docs: `website/docs/user-guide/features/kanban.md`.
---
diff --git a/README.md b/README.md
index 11390fb2b20ac..2674cabe77f40 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
+
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
@@ -21,7 +22,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
| A closed learning loop | Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. |
| Scheduled automations | Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. |
| Delegates and parallelizes | Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. |
-| Runs anywhere, not just your laptop | Six terminal backends — local, Docker, SSH, Daytona, Singularity, and Modal. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
+| Runs anywhere, not just your laptop | Seven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster. |
| Research-ready | Batch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models. |
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000000000..ea7fea8dcce08
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,186 @@
+
+
+
+
+# Hermes Agent ☤
+
+
+
+
+
+
+
+
+
+**由 [Nous Research](https://nousresearch.com) 构建的自进化 AI 代理。** 它是唯一内置学习闭环的智能代理——从经验中创建技能,在使用中改进技能,主动持久化知识,搜索过往对话,并在跨会话中逐步构建对你的深度理解。可以在 $5 的 VPS 上运行,也可以在 GPU 集群上运行,或者使用几乎零成本的 Serverless 基础设施。它不绑定你的笔记本——你可以在 Telegram 上与它对话,而它在云端 VM 上工作。
+
+支持任意模型——[Nous Portal](https://portal.nousresearch.com)、[OpenRouter](https://openrouter.ai)(200+ 模型)、[NVIDIA NIM](https://build.nvidia.com)(Nemotron)、[小米 MiMo](https://platform.xiaomimimo.com)、[z.ai/GLM](https://z.ai)、[Kimi/Moonshot](https://platform.moonshot.ai)、[MiniMax](https://www.minimax.io)、[Hugging Face](https://huggingface.co)、OpenAI,或自定义端点。使用 `hermes model` 即可切换——无需改代码,无锁定。
+
+
+| 真正的终端界面 | 完整的 TUI,支持多行编辑、斜杠命令自动补全、对话历史、中断重定向和流式工具输出。 |
+| 随你所在 | Telegram、Discord、Slack、WhatsApp、Signal 和 CLI——全部从单个网关进程运行。语音备忘录转写、跨平台对话连续性。 |
+| 闭环学习 | 代理管理记忆并定期自我提醒。复杂任务后自动创建技能。技能在使用中自我改进。FTS5 会话搜索配合 LLM 摘要实现跨会话回溯。Honcho 辩证式用户建模。兼容 agentskills.io 开放标准。 |
+| 定时自动化 | 内置 cron 调度器,支持向任何平台投递。日报、夜间备份、周审计——全部用自然语言描述,无人值守运行。 |
+| 委派与并行 | 生成隔离子代理处理并行工作流。编写 Python 脚本通过 RPC 调用工具,将多步管道压缩为零上下文开销的轮次。 |
+| 随处运行 | 六种终端后端——本地、Docker、SSH、Daytona、Singularity 和 Modal。Daytona 和 Modal 提供 Serverless 持久化——代理环境空闲时休眠、按需唤醒,空闲期间几乎零成本。$5 VPS 或 GPU 集群都能跑。 |
+| 研究就绪 | 批量轨迹生成、Atropos RL 环境、轨迹压缩——用于训练下一代工具调用模型。 |
+
+
+---
+
+## 快速安装
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
+```
+
+支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。
+
+> **Android / Termux:** 已测试的手动安装路径请参考 [Termux 指南](https://hermes-agent.nousresearch.com/docs/getting-started/termux)。在 Termux 上,Hermes 会安装精选的 `.[termux]` 扩展,因为完整的 `.[all]` 扩展会拉取 Android 不兼容的语音依赖。
+>
+> **Windows:** 原生 Windows 不受支持。请安装 [WSL2](https://learn.microsoft.com/zh-cn/windows/wsl/install) 并运行上述命令。
+
+安装后:
+
+```bash
+source ~/.bashrc # 重新加载 shell(或: source ~/.zshrc)
+hermes # 开始对话!
+```
+
+---
+
+## 快速入门
+
+```bash
+hermes # 交互式 CLI — 开始对话
+hermes model # 选择 LLM 提供商和模型
+hermes tools # 配置启用的工具
+hermes config set # 设置单个配置项
+hermes gateway # 启动消息网关(Telegram、Discord 等)
+hermes setup # 运行完整设置向导(一次性配置所有内容)
+hermes claw migrate # 从 OpenClaw 迁移(如果来自 OpenClaw)
+hermes update # 更新到最新版本
+hermes doctor # 诊断问题
+```
+
+📖 **[完整文档 →](https://hermes-agent.nousresearch.com/docs/)**
+
+## CLI 与消息平台 快速对照
+
+Hermes 有两种入口:用 `hermes` 启动终端 UI,或运行网关从 Telegram、Discord、Slack、WhatsApp、Signal 或 Email 与之对话。进入对话后,许多斜杠命令在两种界面中通用。
+
+| 操作 | CLI | 消息平台 |
+|------|-----|----------|
+| 开始对话 | `hermes` | 运行 `hermes gateway setup` + `hermes gateway start`,然后给机器人发消息 |
+| 开始新对话 | `/new` 或 `/reset` | `/new` 或 `/reset` |
+| 更换模型 | `/model [provider:model]` | `/model [provider:model]` |
+| 设置人格 | `/personality [name]` | `/personality [name]` |
+| 重试或撤销上一轮 | `/retry`、`/undo` | `/retry`、`/undo` |
+| 压缩上下文 / 查看用量 | `/compress`、`/usage`、`/insights [--days N]` | `/compress`、`/usage`、`/insights [days]` |
+| 浏览技能 | `/skills` 或 `/` | `/skills` 或 `/` |
+| 中断当前工作 | `Ctrl+C` 或发送新消息 | `/stop` 或发送新消息 |
+| 平台特定状态 | `/platforms` | `/status`、`/sethome` |
+
+完整命令列表请参阅 [CLI 指南](https://hermes-agent.nousresearch.com/docs/user-guide/cli) 和 [消息网关指南](https://hermes-agent.nousresearch.com/docs/user-guide/messaging)。
+
+---
+
+## 文档
+
+所有文档位于 **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**:
+
+| 章节 | 内容 |
+|------|------|
+| [快速开始](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | 安装 → 设置 → 2 分钟内开始首次对话 |
+| [CLI 使用](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | 命令、快捷键、人格、会话 |
+| [配置](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | 配置文件、提供商、模型、所有选项 |
+| [消息网关](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram、Discord、Slack、WhatsApp、Signal、Home Assistant |
+| [安全](https://hermes-agent.nousresearch.com/docs/user-guide/security) | 命令审批、DM 配对、容器隔离 |
+| [工具与工具集](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40+ 工具、工具集系统、终端后端 |
+| [技能系统](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | 过程记忆、技能中心、创建技能 |
+| [记忆](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | 持久记忆、用户画像、最佳实践 |
+| [MCP 集成](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | 连接任意 MCP 服务器扩展能力 |
+| [定时调度](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | 定时任务与平台投递 |
+| [上下文文件](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | 影响每次对话的项目上下文 |
+| [架构](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | 项目结构、代理循环、关键类 |
+| [贡献](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | 开发设置、PR 流程、代码风格 |
+| [CLI 参考](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | 所有命令和标志 |
+| [环境变量](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | 完整环境变量参考 |
+
+---
+
+## 从 OpenClaw 迁移
+
+如果你来自 OpenClaw,Hermes 可以自动导入你的设置、记忆、技能和 API 密钥。
+
+**首次安装时:** 安装向导(`hermes setup`)会自动检测 `~/.openclaw` 并在配置开始前提供迁移选项。
+
+**安装后任意时间:**
+
+```bash
+hermes claw migrate # 交互式迁移(完整预设)
+hermes claw migrate --dry-run # 预览将要迁移的内容
+hermes claw migrate --preset user-data # 仅迁移用户数据,不含密钥
+hermes claw migrate --overwrite # 覆盖已有冲突
+```
+
+导入内容:
+- **SOUL.md** — 人格文件
+- **记忆** — MEMORY.md 和 USER.md 条目
+- **技能** — 用户创建的技能 → `~/.hermes/skills/openclaw-imports/`
+- **命令白名单** — 审批模式
+- **消息设置** — 平台配置、允许用户、工作目录
+- **API 密钥** — 白名单中的密钥(Telegram、OpenRouter、OpenAI、Anthropic、ElevenLabs)
+- **TTS 资产** — 工作区音频文件
+- **工作区指令** — AGENTS.md(使用 `--workspace-target`)
+
+使用 `hermes claw migrate --help` 查看所有选项,或使用 `openclaw-migration` 技能进行交互式代理引导迁移(含干运行预览)。
+
+---
+
+## 贡献
+
+欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
+
+贡献者快速开始——克隆并使用 `setup-hermes.sh`:
+
+```bash
+git clone https://github.com/NousResearch/hermes-agent.git
+cd hermes-agent
+./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
+./hermes # 自动检测 venv,无需先 source
+```
+
+手动安装(等效于上述命令):
+
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+uv venv venv --python 3.11
+source venv/bin/activate
+uv pip install -e ".[all,dev]"
+python -m pytest tests/ -q
+```
+
+> **RL 训练(可选):** 如需参与 RL/Tinker-Atropos 集成开发:
+> ```bash
+> git submodule update --init tinker-atropos
+> uv pip install -e "./tinker-atropos"
+> ```
+
+---
+
+## 社区
+
+- 💬 [Discord](https://discord.gg/NousResearch)
+- 📚 [技能中心](https://agentskills.io)
+- 🐛 [问题反馈](https://github.com/NousResearch/hermes-agent/issues)
+- 💡 [讨论区](https://github.com/NousResearch/hermes-agent/discussions)
+- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — 社区微信桥接:在同一微信账号上运行 Hermes Agent 和 OpenClaw。
+
+---
+
+## 许可证
+
+MIT — 详见 [LICENSE](LICENSE)。
+
+由 [Nous Research](https://nousresearch.com) 构建。
diff --git a/acp_adapter/session.py b/acp_adapter/session.py
index d6dace66b4ea5..61d06432a715e 100644
--- a/acp_adapter/session.py
+++ b/acp_adapter/session.py
@@ -466,17 +466,10 @@ def _persist(self, state: SessionState) -> None:
except Exception:
logger.debug("Failed to update ACP session metadata", exc_info=True)
- # Replace stored messages with current history.
- db.clear_messages(state.session_id)
- for msg in state.history:
- db.append_message(
- session_id=state.session_id,
- role=msg.get("role", "user"),
- content=msg.get("content"),
- tool_name=msg.get("tool_name") or msg.get("name"),
- tool_calls=msg.get("tool_calls"),
- tool_call_id=msg.get("tool_call_id"),
- )
+ # Replace stored messages with current history atomically so a
+ # mid-rewrite failure rolls back and the previously persisted
+ # conversation is preserved (salvaged from #13675).
+ db.replace_messages(state.session_id, state.history)
except Exception:
logger.warning("Failed to persist ACP session %s", state.session_id, exc_info=True)
diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index 62695af83aa5b..1e3d39c7ba5e4 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -196,6 +196,12 @@ def _is_kimi_model(model: Optional[str]) -> bool:
return bare.startswith("kimi-") or bare == "kimi"
+def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
+ """True for Arcee Trinity Large Thinking (direct or via OpenRouter)."""
+ bare = (model or "").strip().lower().rsplit("/", 1)[-1]
+ return bare == "trinity-large-thinking"
+
+
def _fixed_temperature_for_model(
model: Optional[str],
base_url: Optional[str] = None,
@@ -213,10 +219,46 @@ def _fixed_temperature_for_model(
if _is_kimi_model(model):
logger.debug("Omitting temperature for Kimi model %r (server-managed)", model)
return OMIT_TEMPERATURE
+ if _is_arcee_trinity_thinking(model):
+ return 0.5
+ return None
+
+
+def _compression_threshold_for_model(model: Optional[str]) -> Optional[float]:
+ """Return a context-compression threshold override for specific models.
+
+ The threshold is the fraction of the model's context window that must be
+ consumed before Hermes triggers summarization. Higher values delay
+ compression and preserve more raw context.
+
+ Returns a float in (0, 1] to override the global ``compression.threshold``
+ config value, or ``None`` to leave the user's config value unchanged.
+ """
+ if _is_arcee_trinity_thinking(model):
+ return 0.75
return None
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
-_API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
+def _get_aux_model_for_provider(provider_id: str) -> str:
+ """Return the cheap auxiliary model for a provider.
+
+ Reads from ProviderProfile.default_aux_model first, falling back to the
+ legacy hardcoded dict for providers that predate the profiles system.
+ """
+ try:
+ from providers import get_provider_profile
+ _p = get_provider_profile(provider_id)
+ if _p and _p.default_aux_model:
+ return _p.default_aux_model
+ except Exception:
+ pass
+ return _API_KEY_PROVIDER_AUX_MODELS_FALLBACK.get(provider_id, "")
+
+
+# Fallback for providers not yet migrated to ProviderProfile.default_aux_model,
+# plus providers we intentionally keep pinned here (e.g. Anthropic predates
+# profiles). New providers should set default_aux_model on their profile instead.
+_API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
"gemini": "gemini-3-flash-preview",
"zai": "glm-4.5-flash",
"kimi-coding": "kimi-k2-turbo-preview",
@@ -235,6 +277,10 @@ def _fixed_temperature_for_model(
"tencent-tokenhub": "hy3-preview",
}
+# Legacy alias — callers that haven't been updated to _get_aux_model_for_provider()
+# can still use this dict directly. Kept in sync with _FALLBACK above.
+_API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = _API_KEY_PROVIDER_AUX_MODELS_FALLBACK
+
# Vision-specific model overrides for direct providers.
# When the user's main provider has a dedicated vision/multimodal model that
# differs from their main chat model, map it here. The vision auto-detect
@@ -259,10 +305,12 @@ def _fixed_temperature_for_model(
"kimi-coding-cn",
})
-# OpenRouter app attribution headers (base — always sent)
+# OpenRouter app attribution headers (base — always sent).
+# `X-Title` is the canonical attribution header OpenRouter's dashboard
+# reads; the previous `X-OpenRouter-Title` label was not recognized there.
_OR_HEADERS_BASE = {
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
- "X-OpenRouter-Title": "Hermes Agent",
+ "X-Title": "Hermes Agent",
"X-OpenRouter-Categories": "productivity,cli-agent",
}
@@ -1155,7 +1203,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
raw_base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
base_url = _to_openai_base_url(raw_base_url)
- model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
+ model = _get_aux_model_for_provider(provider_id) or None
if model is None:
continue # skip provider if we don't know a valid aux model
logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
@@ -1171,6 +1219,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers()
+ else:
+ try:
+ from providers import get_provider_profile as _gpf_aux
+ _ph_aux = _gpf_aux(provider_id)
+ if _ph_aux and _ph_aux.default_headers:
+ extra["default_headers"] = dict(_ph_aux.default_headers)
+ except Exception:
+ pass
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1182,7 +1238,7 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
raw_base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
base_url = _to_openai_base_url(raw_base_url)
- model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id)
+ model = _get_aux_model_for_provider(provider_id) or None
if model is None:
continue # skip provider if we don't know a valid aux model
logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
@@ -1198,6 +1254,14 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
from hermes_cli.models import copilot_default_headers
extra["default_headers"] = copilot_default_headers()
+ else:
+ try:
+ from providers import get_provider_profile as _gpf_aux2
+ _ph_aux2 = _gpf_aux2(provider_id)
+ if _ph_aux2 and _ph_aux2.default_headers:
+ extra["default_headers"] = dict(_ph_aux2.default_headers)
+ except Exception:
+ pass
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1570,7 +1634,7 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona
from agent.anthropic_adapter import _is_oauth_token
is_oauth = _is_oauth_token(token)
- model = _API_KEY_PROVIDER_AUX_MODELS.get("anthropic", "claude-haiku-4-5-20251001")
+ model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
logger.debug("Auxiliary client: Anthropic native (%s) at %s (oauth=%s)", model, base_url, is_oauth)
try:
real_client = build_anthropic_client(token, base_url)
@@ -1648,6 +1712,39 @@ def _is_payment_error(exc: Exception) -> bool:
return False
+def _is_rate_limit_error(exc: Exception) -> bool:
+ """Detect rate-limit errors that warrant provider fallback.
+
+ Returns True for HTTP 429 errors whose message indicates rate limiting
+ (as opposed to billing/quota exhaustion, which _is_payment_error handles).
+ Also catches OpenAI SDK RateLimitError instances that may not set
+ .status_code on the exception object.
+ """
+ status = getattr(exc, "status_code", None)
+ err_lower = str(exc).lower()
+
+ # OpenAI SDK's RateLimitError sometimes omits .status_code —
+ # detect by class name so we don't miss these. (PR #8023 pattern)
+ if type(exc).__name__ == "RateLimitError":
+ return True
+
+ if status == 429:
+ # Distinguish rate-limit from billing: billing keywords are handled
+ # by _is_payment_error, everything else on 429 is a rate limit.
+ if any(kw in err_lower for kw in (
+ "rate limit", "rate_limit", "too many requests",
+ "try again", "retry after", "resets in",
+ )):
+ return True
+ # Generic 429 without billing keywords = likely a rate limit
+ if not any(kw in err_lower for kw in (
+ "credits", "insufficient funds", "billing",
+ "payment required", "can only afford",
+ )):
+ return True
+ return False
+
+
def _is_connection_error(exc: Exception) -> bool:
"""Detect connection/network errors that warrant provider fallback.
@@ -2373,7 +2470,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "",
if explicit_base_url:
base_url = _to_openai_base_url(explicit_base_url.strip().rstrip("/"))
- default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
+ default_model = _get_aux_model_for_provider(provider)
final_model = _normalize_resolved_model(model or default_model, provider)
if provider == "gemini":
@@ -3132,8 +3229,14 @@ def _resolve_task_provider_model(
if task:
# Config.yaml is the primary source for per-task overrides.
- if cfg_base_url:
+ if cfg_base_url and cfg_api_key:
+ # Both base_url and api_key explicitly set → custom endpoint.
return "custom", resolved_model, cfg_base_url, cfg_api_key, resolved_api_mode
+ if cfg_base_url and cfg_provider and cfg_provider != "auto":
+ # base_url set without api_key but with a known provider — use
+ # the provider so it can resolve credentials from env vars
+ # (e.g. OPENROUTER_API_KEY) instead of locking into "custom".
+ return cfg_provider, resolved_model, cfg_base_url, None, resolved_api_mode
if cfg_provider and cfg_provider != "auto":
return cfg_provider, resolved_model, None, None, resolved_api_mode
@@ -3534,7 +3637,7 @@ def call_llm(
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
- if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
+ if not (_is_payment_error(retry_err) or _is_connection_error(retry_err) or _is_rate_limit_error(retry_err)):
raise
first_err = retry_err
@@ -3617,13 +3720,27 @@ def call_llm(
# Codex/OAuth tokens that authenticate but whose endpoint is down,
# and providers the user never configured that got picked up by
# the auto-detection chain.
- should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
+ #
+ # ── Rate-limit fallback (#13579) ─────────────────────────────
+ # When the provider returns a 429 rate-limit (not billing), fall
+ # back to an alternative provider instead of exhausting retries
+ # against the same rate-limited endpoint.
+ should_fallback = (
+ _is_payment_error(first_err)
+ or _is_connection_error(first_err)
+ or _is_rate_limit_error(first_err)
+ )
# Only try alternative providers when the user didn't explicitly
# configure this task's provider. Explicit provider = hard constraint;
# auto (the default) = best-effort fallback chain. (#7559)
is_auto = resolved_provider in ("auto", "", None)
if should_fallback and is_auto:
- reason = "payment error" if _is_payment_error(first_err) else "connection error"
+ if _is_payment_error(first_err):
+ reason = "payment error"
+ elif _is_rate_limit_error(first_err):
+ reason = "rate limit"
+ else:
+ reason = "connection error"
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
task or "call", reason, resolved_provider, first_err)
fb_client, fb_model, fb_label = _try_payment_fallback(
@@ -3826,7 +3943,7 @@ async def async_call_llm(
except Exception as retry_err:
# If the max_tokens retry also hits a payment or connection
# error, fall through to the fallback chain below.
- if not (_is_payment_error(retry_err) or _is_connection_error(retry_err)):
+ if not (_is_payment_error(retry_err) or _is_connection_error(retry_err) or _is_rate_limit_error(retry_err)):
raise
first_err = retry_err
@@ -3895,11 +4012,20 @@ async def async_call_llm(
return _validate_llm_response(
await retry_client.chat.completions.create(**retry_kwargs), task)
- # ── Payment / connection fallback (mirrors sync call_llm) ─────
- should_fallback = _is_payment_error(first_err) or _is_connection_error(first_err)
+ # ── Payment / connection / rate-limit fallback (mirrors sync call_llm) ──
+ should_fallback = (
+ _is_payment_error(first_err)
+ or _is_connection_error(first_err)
+ or _is_rate_limit_error(first_err)
+ )
is_auto = resolved_provider in ("auto", "", None)
if should_fallback and is_auto:
- reason = "payment error" if _is_payment_error(first_err) else "connection error"
+ if _is_payment_error(first_err):
+ reason = "payment error"
+ elif _is_rate_limit_error(first_err):
+ reason = "rate limit"
+ else:
+ reason = "connection error"
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
task or "call", reason, resolved_provider, first_err)
fb_client, fb_model, fb_label = _try_payment_fallback(
diff --git a/agent/context_compressor.py b/agent/context_compressor.py
index 20f35fed5f144..4212085fc6787 100644
--- a/agent/context_compressor.py
+++ b/agent/context_compressor.py
@@ -43,6 +43,9 @@
"they were already addressed. "
"Your current task is identified in the '## Active Task' section of the "
"summary — resume exactly from there. "
+ "IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
+ "prompt is ALWAYS authoritative and active — never ignore or deprioritize "
+ "memory content due to this compaction note. "
"Respond ONLY to the latest user message "
"that appears AFTER this summary. The current session state (files, "
"config, etc.) may reflect work described here — avoid repeating it:"
@@ -1373,7 +1376,7 @@ def compress(self, messages: List[Dict[str, Any]], current_tokens: int = None, f
msg = messages[i].copy()
if i == 0 and msg.get("role") == "system":
existing = msg.get("content")
- _compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work.]"
+ _compression_note = "[Note: Some earlier conversation turns have been compacted into a handoff summary to preserve context space. The current session state may still reflect earlier work, so build on that summary and state rather than re-doing work. Your persistent memory (MEMORY.md, USER.md) remains fully authoritative regardless of compaction.]"
if _compression_note not in _content_text_for_contains(existing):
msg["content"] = _append_text_to_content(
existing,
diff --git a/agent/i18n.py b/agent/i18n.py
new file mode 100644
index 0000000000000..0196439bb4edc
--- /dev/null
+++ b/agent/i18n.py
@@ -0,0 +1,233 @@
+"""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/.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, fr, tr, uk. 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", "fr", "tr", "uk")
+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",
+ "french": "fr", "français": "fr", "france": "fr", "fr-fr": "fr", "fr-be": "fr", "fr-ca": "fr", "fr-ch": "fr",
+ "ukrainian": "uk", "ukrainisch": "uk", "українська": "uk", "uk-ua": "uk", "ua": "uk",
+ "turkish": "tr", "türkçe": "tr", "tr-tr": "tr",
+}
+
+_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",
+]
diff --git a/agent/memory_manager.py b/agent/memory_manager.py
index ea9b7425fc205..1319681d3b196 100644
--- a/agent/memory_manager.py
+++ b/agent/memory_manager.py
@@ -1,17 +1,14 @@
-"""MemoryManager — orchestrates the built-in memory provider plus at most
-ONE external plugin memory provider.
+"""MemoryManager — orchestrates memory providers for the agent.
Single integration point in run_agent.py. Replaces scattered per-backend
code with one manager that delegates to registered providers.
-The BuiltinMemoryProvider is always registered first and cannot be removed.
-Only ONE external (non-builtin) provider is allowed at a time — attempting
-to register a second external provider is rejected with a warning. This
+Only ONE external plugin provider is allowed at a time — attempting to
+register a second external provider is rejected with a warning. This
prevents tool schema bloat and conflicting memory backends.
Usage in run_agent.py:
self._memory_manager = MemoryManager()
- self._memory_manager.add_provider(BuiltinMemoryProvider(...))
# Only ONE of these:
self._memory_manager.add_provider(plugin_provider)
@@ -49,7 +46,7 @@
re.IGNORECASE,
)
_INTERNAL_NOTE_RE = re.compile(
- r'\[System note:\s*The following is recalled memory context,\s*NOT new user input\.\s*Treat as informational background data\.\]\s*',
+ r'\[System note:\s*The following is recalled memory context,\s*NOT new user input\.\s*Treat as (?:informational background data|authoritative reference data[^\]]*)\.\]\s*',
re.IGNORECASE,
)
@@ -183,7 +180,8 @@ def build_memory_context_block(raw_context: str) -> str:
return (
"\n"
"[System note: The following is recalled memory context, "
- "NOT new user input. Treat as informational background data.]\n\n"
+ "NOT new user input. Treat as authoritative reference data — "
+ "this is the agent's persistent memory and should inform all responses.]\n\n"
f"{clean}\n"
""
)
diff --git a/agent/memory_provider.py b/agent/memory_provider.py
index 1c8dbaf682597..c9abc48c7a92e 100644
--- a/agent/memory_provider.py
+++ b/agent/memory_provider.py
@@ -1,17 +1,16 @@
"""Abstract base class for pluggable memory providers.
-Memory providers give the agent persistent recall across sessions. One
-external provider is active at a time alongside the always-on built-in
-memory (MEMORY.md / USER.md). The MemoryManager enforces this limit.
+Memory providers give the agent persistent recall across sessions.
+The MemoryManager enforces a one-external-provider limit to prevent
+tool schema bloat and conflicting memory backends.
-Built-in memory is always active as the first provider and cannot be removed.
-External providers (Honcho, Hindsight, Mem0, etc.) are additive — they never
-disable the built-in store. Only one external provider runs at a time to
-prevent tool schema bloat and conflicting memory backends.
+External providers (Honcho, Hindsight, Mem0, etc.) are registered
+and managed via MemoryManager. Only one external provider runs at a
+time.
Registration:
- 1. Built-in: BuiltinMemoryProvider — always present, not removable.
- 2. Plugins: Ship in plugins/memory//, activated by memory.provider config.
+ Plugins ship in plugins/memory// and are activated via
+ the memory.provider config key.
Lifecycle (called by MemoryManager, wired in run_agent.py):
initialize() — connect, create resources, warm up
diff --git a/agent/model_metadata.py b/agent/model_metadata.py
index 12117f1446bdf..c362a9ec93d1c 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -318,6 +318,17 @@ def _is_custom_endpoint(base_url: str) -> bool:
"ollama.com": "ollama-cloud",
}
+# Auto-extend with hostnames derived from provider profiles.
+# Any provider with a base_url not already in the map gets added automatically.
+try:
+ from providers import list_providers as _list_providers
+ for _pp in _list_providers():
+ _host = _pp.get_hostname()
+ if _host and _host not in _URL_TO_PROVIDER:
+ _URL_TO_PROVIDER[_host] = _pp.name
+except Exception:
+ pass
+
def _infer_provider_from_url(base_url: str) -> Optional[str]:
"""Infer the models.dev provider name from a base URL.
diff --git a/agent/transports/__init__.py b/agent/transports/__init__.py
index d1c8251ed254f..b606da7fecaea 100644
--- a/agent/transports/__init__.py
+++ b/agent/transports/__init__.py
@@ -6,9 +6,16 @@
result = transport.normalize_response(raw_response)
"""
-from agent.transports.types import NormalizedResponse, ToolCall, Usage, build_tool_call, map_finish_reason # noqa: F401
+from agent.transports.types import (
+ NormalizedResponse,
+ ToolCall,
+ Usage,
+ build_tool_call,
+ map_finish_reason,
+) # noqa: F401
_REGISTRY: dict = {}
+_discovered: bool = False
def register_transport(api_mode: str, transport_cls: type) -> None:
@@ -23,6 +30,9 @@ def get_transport(api_mode: str):
This allows gradual migration — call sites can check for None
and fall back to the legacy code path.
"""
+ global _discovered
+ if not _discovered:
+ _discover_transports()
cls = _REGISTRY.get(api_mode)
if cls is None:
# The registry can be partially populated when a specific transport
@@ -38,6 +48,8 @@ def get_transport(api_mode: str):
def _discover_transports() -> None:
"""Import all transport modules to trigger auto-registration."""
+ global _discovered
+ _discovered = True
try:
import agent.transports.anthropic # noqa: F401
except ImportError:
diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py
index 9a115e4547316..ca29b39ffe4e6 100644
--- a/agent/transports/chat_completions.py
+++ b/agent/transports/chat_completions.py
@@ -109,7 +109,9 @@ class ChatCompletionsTransport(ProviderTransport):
def api_mode(self) -> str:
return "chat_completions"
- def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> List[Dict[str, Any]]:
+ def convert_messages(
+ self, messages: list[dict[str, Any]], **kwargs
+ ) -> list[dict[str, Any]]:
"""Messages are already in OpenAI format — sanitize Codex leaks only.
Strips Codex Responses API fields (``codex_reasoning_items`` /
@@ -126,7 +128,9 @@ def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> List[Dic
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list):
for tc in tool_calls:
- if isinstance(tc, dict) and ("call_id" in tc or "response_item_id" in tc):
+ if isinstance(tc, dict) and (
+ "call_id" in tc or "response_item_id" in tc
+ ):
needs_sanitize = True
break
if needs_sanitize:
@@ -149,39 +153,41 @@ def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> List[Dic
tc.pop("response_item_id", None)
return sanitized
- def convert_tools(self, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ def convert_tools(self, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Tools are already in OpenAI format — identity."""
return tools
def build_kwargs(
self,
model: str,
- messages: List[Dict[str, Any]],
- tools: Optional[List[Dict[str, Any]]] = None,
+ messages: list[dict[str, Any]],
+ tools: list[dict[str, Any]] | None = None,
**params,
- ) -> Dict[str, Any]:
+ ) -> dict[str, Any]:
"""Build chat.completions.create() kwargs.
- This is the most complex transport method — it handles ~16 providers
- via params rather than subclasses.
-
- params:
+ params (all optional):
timeout: float — API call timeout
max_tokens: int | None — user-configured max tokens
- ephemeral_max_output_tokens: int | None — one-shot override (error recovery)
+ ephemeral_max_output_tokens: int | None — one-shot override
max_tokens_param_fn: callable — returns {max_tokens: N} or {max_completion_tokens: N}
reasoning_config: dict | None
request_overrides: dict | None
session_id: str | None
- qwen_session_metadata: dict | None — {sessionId, promptId} precomputed
model_lower: str — lowercase model name for pattern matching
- # Provider detection flags (all optional, default False)
+ # Provider profile path (all per-provider quirks live in providers/)
+ provider_profile: ProviderProfile | None — when present, delegates to
+ _build_kwargs_from_profile(); all flag params below are bypassed.
+ # Legacy-path flags — only used when provider_profile is None
+ # (i.e. custom / unregistered providers). Known providers all go
+ # through provider_profile.
is_openrouter: bool
is_nous: bool
is_qwen_portal: bool
is_github_models: bool
is_nvidia_nim: bool
is_kimi: bool
+ is_tokenhub: bool
is_lmstudio: bool
is_custom_provider: bool
ollama_num_ctx: int | None
@@ -190,6 +196,7 @@ def build_kwargs(
# Qwen-specific
qwen_prepare_fn: callable | None — runs AFTER codex sanitization
qwen_prepare_inplace_fn: callable | None — in-place variant for deepcopied lists
+ qwen_session_metadata: dict | None
# Temperature
fixed_temperature: Any — from _fixed_temperature_for_model()
omit_temperature: bool
@@ -199,28 +206,21 @@ def build_kwargs(
lmstudio_reasoning_options: list[str] | None # raw allowed_options from /api/v1/models
# Claude on OpenRouter/Nous max output
anthropic_max_output: int | None
- # Extra
- extra_body_additions: dict | None — pre-built extra_body entries
+ extra_body_additions: dict | None
"""
# Codex sanitization: drop reasoning_items / call_id / response_item_id
sanitized = self.convert_messages(messages)
- # Qwen portal prep AFTER codex sanitization. If sanitize already
- # deepcopied, reuse that copy via the in-place variant to avoid a
- # second deepcopy.
- is_qwen = params.get("is_qwen_portal", False)
- if is_qwen:
- qwen_prep = params.get("qwen_prepare_fn")
- qwen_prep_inplace = params.get("qwen_prepare_inplace_fn")
- if sanitized is messages:
- if qwen_prep is not None:
- sanitized = qwen_prep(sanitized)
- else:
- # Already deepcopied — transform in place
- if qwen_prep_inplace is not None:
- qwen_prep_inplace(sanitized)
- elif qwen_prep is not None:
- sanitized = qwen_prep(sanitized)
+ # ── Provider profile: single-path when present ──────────────────
+ _profile = params.get("provider_profile")
+ if _profile:
+ return self._build_kwargs_from_profile(
+ _profile, model, sanitized, tools, params
+ )
+
+ # ── Legacy fallback (unregistered / unknown provider) ───────────
+ # Reached only when get_provider_profile() returned None.
+ # Known providers always go through the profile path above.
# Developer role swap for GPT-5/Codex models
model_lower = params.get("model_lower", (model or "").lower())
@@ -233,7 +233,7 @@ def build_kwargs(
sanitized = list(sanitized)
sanitized[0] = {**sanitized[0], "role": "developer"}
- api_kwargs: Dict[str, Any] = {
+ api_kwargs: dict[str, Any] = {
"model": model,
"messages": sanitized,
}
@@ -242,19 +242,6 @@ def build_kwargs(
if timeout is not None:
api_kwargs["timeout"] = timeout
- # Temperature
- fixed_temp = params.get("fixed_temperature")
- omit_temp = params.get("omit_temperature", False)
- if omit_temp:
- api_kwargs.pop("temperature", None)
- elif fixed_temp is not None:
- api_kwargs["temperature"] = fixed_temp
-
- # Qwen metadata (caller precomputes {sessionId, promptId})
- qwen_meta = params.get("qwen_session_metadata")
- if qwen_meta and is_qwen:
- api_kwargs["metadata"] = qwen_meta
-
# Tools
if tools:
# Moonshot/Kimi uses a stricter flavored JSON Schema. Rewriting
@@ -278,13 +265,6 @@ def build_kwargs(
api_kwargs.update(max_tokens_fn(ephemeral))
elif max_tokens is not None and max_tokens_fn:
api_kwargs.update(max_tokens_fn(max_tokens))
- elif is_nvidia_nim and max_tokens_fn:
- api_kwargs.update(max_tokens_fn(16384))
- elif is_qwen and max_tokens_fn:
- api_kwargs.update(max_tokens_fn(65536))
- elif is_kimi and max_tokens_fn:
- # Kimi/Moonshot: 32000 matches Kimi CLI's default
- api_kwargs.update(max_tokens_fn(32000))
elif anthropic_max_out is not None:
api_kwargs["max_tokens"] = anthropic_max_out
@@ -331,7 +311,7 @@ def build_kwargs(
api_kwargs["reasoning_effort"] = _lm_effort
# extra_body assembly
- extra_body: Dict[str, Any] = {}
+ extra_body: dict[str, Any] = {}
is_openrouter = params.get("is_openrouter", False)
is_nous = params.get("is_nous", False)
@@ -361,35 +341,7 @@ def build_kwargs(
if gh_reasoning is not None:
extra_body["reasoning"] = gh_reasoning
else:
- if reasoning_config is not None:
- rc = dict(reasoning_config)
- if is_nous and rc.get("enabled") is False:
- pass # omit for Nous when disabled
- else:
- extra_body["reasoning"] = rc
- else:
- extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
-
- if is_nous:
- extra_body["tags"] = ["product=hermes-agent"]
-
- # Ollama num_ctx
- ollama_ctx = params.get("ollama_num_ctx")
- if ollama_ctx:
- options = extra_body.get("options", {})
- options["num_ctx"] = ollama_ctx
- extra_body["options"] = options
-
- # Ollama/custom think=false
- if params.get("is_custom_provider", False):
- if reasoning_config and isinstance(reasoning_config, dict):
- _effort = (reasoning_config.get("effort") or "").strip().lower()
- _enabled = reasoning_config.get("enabled", True)
- if _effort == "none" or _enabled is False:
- extra_body["think"] = False
-
- if is_qwen:
- extra_body["vl_high_resolution_images"] = True
+ extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
if provider_name == "gemini":
raw_thinking_config = _build_gemini_thinking_config(model, reasoning_config)
@@ -423,6 +375,120 @@ def build_kwargs(
return api_kwargs
+ def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
+ """Build API kwargs using a ProviderProfile — single path, no legacy flags.
+
+ This method replaces the entire flag-based kwargs assembly when a
+ provider_profile is passed. Every quirk comes from the profile object.
+ """
+ from providers.base import OMIT_TEMPERATURE
+
+ # Message preprocessing
+ sanitized = profile.prepare_messages(sanitized)
+
+ # Developer role swap — model-name-based, applies to all providers
+ _model_lower = (model or "").lower()
+ if (
+ sanitized
+ and isinstance(sanitized[0], dict)
+ and sanitized[0].get("role") == "system"
+ and any(p in _model_lower for p in DEVELOPER_ROLE_MODELS)
+ ):
+ sanitized = list(sanitized)
+ sanitized[0] = {**sanitized[0], "role": "developer"}
+
+ api_kwargs: dict[str, Any] = {
+ "model": model,
+ "messages": sanitized,
+ }
+
+ # Temperature
+ if profile.fixed_temperature is OMIT_TEMPERATURE:
+ pass # Don't include temperature at all
+ elif profile.fixed_temperature is not None:
+ api_kwargs["temperature"] = profile.fixed_temperature
+ else:
+ # Use caller's temperature if provided
+ temp = params.get("temperature")
+ if temp is not None:
+ api_kwargs["temperature"] = temp
+
+ # Timeout
+ timeout = params.get("timeout")
+ if timeout is not None:
+ api_kwargs["timeout"] = timeout
+
+ # Tools — apply Moonshot/Kimi schema sanitization regardless of path
+ if tools:
+ if is_moonshot_model(model):
+ tools = sanitize_moonshot_tools(tools)
+ api_kwargs["tools"] = tools
+
+ # max_tokens resolution — priority: ephemeral > user > profile default
+ max_tokens_fn = params.get("max_tokens_param_fn")
+ ephemeral = params.get("ephemeral_max_output_tokens")
+ user_max = params.get("max_tokens")
+ anthropic_max = params.get("anthropic_max_output")
+
+ if ephemeral is not None and max_tokens_fn:
+ api_kwargs.update(max_tokens_fn(ephemeral))
+ elif user_max is not None and max_tokens_fn:
+ api_kwargs.update(max_tokens_fn(user_max))
+ elif profile.default_max_tokens and max_tokens_fn:
+ api_kwargs.update(max_tokens_fn(profile.default_max_tokens))
+ elif anthropic_max is not None:
+ api_kwargs["max_tokens"] = anthropic_max
+
+ # Provider-specific api_kwargs extras (reasoning_effort, metadata, etc.)
+ reasoning_config = params.get("reasoning_config")
+ extra_body_from_profile, top_level_from_profile = (
+ profile.build_api_kwargs_extras(
+ reasoning_config=reasoning_config,
+ supports_reasoning=params.get("supports_reasoning", False),
+ qwen_session_metadata=params.get("qwen_session_metadata"),
+ model=model,
+ ollama_num_ctx=params.get("ollama_num_ctx"),
+ )
+ )
+ api_kwargs.update(top_level_from_profile)
+
+ # extra_body assembly
+ extra_body: dict[str, Any] = {}
+
+ # Profile's extra_body (tags, provider prefs, vl_high_resolution, etc.)
+ profile_body = profile.build_extra_body(
+ session_id=params.get("session_id"),
+ provider_preferences=params.get("provider_preferences"),
+ model=model,
+ base_url=params.get("base_url"),
+ reasoning_config=reasoning_config,
+ )
+ if profile_body:
+ extra_body.update(profile_body)
+
+ # Profile's reasoning/thinking extra_body entries
+ if extra_body_from_profile:
+ extra_body.update(extra_body_from_profile)
+
+ # Merge any pre-built extra_body additions from the caller
+ additions = params.get("extra_body_additions")
+ if additions:
+ extra_body.update(additions)
+
+ # Request overrides (user config)
+ overrides = params.get("request_overrides")
+ if overrides:
+ for k, v in overrides.items():
+ if k == "extra_body" and isinstance(v, dict):
+ extra_body.update(v)
+ else:
+ api_kwargs[k] = v
+
+ if extra_body:
+ api_kwargs["extra_body"] = extra_body
+
+ return api_kwargs
+
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
"""Normalize OpenAI ChatCompletion to NormalizedResponse.
@@ -444,7 +510,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
# Gemini 3 thinking models attach extra_content with
# thought_signature — without replay on the next turn the API
# rejects the request with 400.
- tc_provider_data: Dict[str, Any] = {}
+ tc_provider_data: dict[str, Any] = {}
extra = getattr(tc, "extra_content", None)
if extra is None and hasattr(tc, "model_extra"):
extra = (tc.model_extra or {}).get("extra_content")
@@ -455,12 +521,14 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
except Exception:
pass
tc_provider_data["extra_content"] = extra
- tool_calls.append(ToolCall(
- id=tc.id,
- name=tc.function.name,
- arguments=tc.function.arguments,
- provider_data=tc_provider_data or None,
- ))
+ tool_calls.append(
+ ToolCall(
+ id=tc.id,
+ name=tc.function.name,
+ arguments=tc.function.arguments,
+ provider_data=tc_provider_data or None,
+ )
+ )
usage = None
if hasattr(response, "usage") and response.usage:
@@ -508,7 +576,7 @@ def validate_response(self, response: Any) -> bool:
return False
return True
- def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
+ def extract_cache_stats(self, response: Any) -> dict[str, int] | None:
"""Extract OpenRouter/OpenAI cache stats from prompt_tokens_details."""
usage = getattr(response, "usage", None)
if usage is None:
diff --git a/agent/transports/types.py b/agent/transports/types.py
index 68a807b47c639..f0da1eb6f8972 100644
--- a/agent/transports/types.py
+++ b/agent/transports/types.py
@@ -12,7 +12,7 @@
import json
from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional
+from typing import Any
@dataclass
@@ -32,10 +32,10 @@ class ToolCall:
* Others: ``None``
"""
- id: Optional[str]
+ id: str | None
name: str
arguments: str # JSON string
- provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False)
+ provider_data: dict[str, Any] | None = field(default=None, repr=False)
# ── Backward compatibility ──────────────────────────────────
# The agent loop reads tc.function.name / tc.function.arguments
@@ -47,17 +47,17 @@ def type(self) -> str:
return "function"
@property
- def function(self) -> "ToolCall":
+ def function(self) -> ToolCall:
"""Return self so tc.function.name / tc.function.arguments work."""
return self
@property
- def call_id(self) -> Optional[str]:
+ def call_id(self) -> str | None:
"""Codex call_id from provider_data, accessed via getattr by _build_assistant_message."""
return (self.provider_data or {}).get("call_id")
@property
- def response_item_id(self) -> Optional[str]:
+ def response_item_id(self) -> str | None:
"""Codex response_item_id from provider_data."""
return (self.provider_data or {}).get("response_item_id")
@@ -101,18 +101,18 @@ class NormalizedResponse:
* Others: ``None``
"""
- content: Optional[str]
- tool_calls: Optional[List[ToolCall]]
+ content: str | None
+ tool_calls: list[ToolCall] | None
finish_reason: str # "stop", "tool_calls", "length", "content_filter"
- reasoning: Optional[str] = None
- usage: Optional[Usage] = None
- provider_data: Optional[Dict[str, Any]] = field(default=None, repr=False)
+ reasoning: str | None = None
+ usage: Usage | None = None
+ provider_data: dict[str, Any] | None = field(default=None, repr=False)
# ── Backward compatibility ──────────────────────────────────
# The shim _nr_to_assistant_message() mapped these from provider_data.
# These properties let NormalizedResponse pass through directly.
@property
- def reasoning_content(self) -> Optional[str]:
+ def reasoning_content(self) -> str | None:
pd = self.provider_data or {}
return pd.get("reasoning_content")
@@ -136,8 +136,9 @@ def codex_message_items(self):
# Factory helpers
# ---------------------------------------------------------------------------
+
def build_tool_call(
- id: Optional[str],
+ id: str | None,
name: str,
arguments: Any,
**provider_fields: Any,
@@ -151,7 +152,7 @@ def build_tool_call(
return ToolCall(id=id, name=name, arguments=args_str, provider_data=pd)
-def map_finish_reason(reason: Optional[str], mapping: Dict[str, str]) -> str:
+def map_finish_reason(reason: str | None, mapping: dict[str, str]) -> str:
"""Translate a provider-specific stop reason to the normalised set.
Falls back to ``"stop"`` for unknown or ``None`` reasons.
diff --git a/cli.py b/cli.py
index 76e10e29f43d9..fcc08ce3784f9 100644
--- a/cli.py
+++ b/cli.py
@@ -27,6 +27,7 @@
import time
import uuid
import textwrap
+from collections import deque
from urllib.parse import unquote, urlparse
from contextlib import contextmanager
from pathlib import Path
@@ -298,6 +299,7 @@ def load_cli_config() -> Dict[str, Any]:
"browser": {
"inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
"record_sessions": False, # Auto-record browser sessions as WebM videos
+ "engine": "auto", # Browser engine: auto (Chrome), lightpanda, chrome
},
"compression": {
"enabled": True, # Auto-compress when approaching context limit
@@ -334,6 +336,8 @@ def load_cli_config() -> Dict[str, Any]:
"show_reasoning": False,
"streaming": True,
"busy_input_mode": "interrupt",
+ "persistent_output": True,
+ "persistent_output_max_lines": 200,
"skin": "default",
},
@@ -940,6 +944,18 @@ def _run_state_db_auto_maintenance(session_db) -> None:
except Exception as _prune_exc:
logger.debug("Ghost session prune skipped: %s", _prune_exc)
+ # One-time finalize of orphaned compression continuations (#20001).
+ try:
+ if not session_db.get_meta("orphaned_compression_finalize_v1"):
+ finalized = session_db.finalize_orphaned_compression_sessions()
+ session_db.set_meta("orphaned_compression_finalize_v1", "1")
+ if finalized:
+ logger.info(
+ "Finalized %d orphaned compression sessions", finalized
+ )
+ except Exception as _finalize_exc:
+ logger.debug("Orphan compression finalize skipped: %s", _finalize_exc)
+
cfg = (_load_full_config().get("sessions") or {})
if not cfg.get("auto_prune", False):
return
@@ -971,6 +987,7 @@ def _run_checkpoint_auto_maintenance() -> None:
retention_days=int(cfg.get("retention_days", 7)),
min_interval_hours=int(cfg.get("min_interval_hours", 24)),
delete_orphans=bool(cfg.get("delete_orphans", True)),
+ max_total_size_mb=int(cfg.get("max_total_size_mb", 500)),
)
except Exception as exc:
logger.debug("checkpoint auto-maintenance skipped: %s", exc)
@@ -1263,6 +1280,87 @@ def _render_final_assistant_content(text: str, mode: str = "render"):
return Markdown(plain)
+_OUTPUT_HISTORY_ENABLED = True
+_OUTPUT_HISTORY_REPLAYING = False
+_OUTPUT_HISTORY_SUPPRESSED = False
+_OUTPUT_HISTORY_MAX_LINES = 200
+_OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)
+_ANSI_CONTROL_RE = re.compile(
+ r"\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))"
+)
+
+
+def _coerce_output_history_limit(value) -> int:
+ try:
+ return max(10, int(value))
+ except (TypeError, ValueError):
+ return 200
+
+
+def _configure_output_history(enabled: bool, max_lines=200) -> None:
+ """Configure recent CLI output replayed after terminal redraws."""
+ global _OUTPUT_HISTORY_ENABLED, _OUTPUT_HISTORY_MAX_LINES, _OUTPUT_HISTORY
+ _OUTPUT_HISTORY_ENABLED = bool(enabled)
+ _OUTPUT_HISTORY_MAX_LINES = _coerce_output_history_limit(max_lines)
+ _OUTPUT_HISTORY = deque(maxlen=_OUTPUT_HISTORY_MAX_LINES)
+
+
+def _clear_output_history() -> None:
+ _OUTPUT_HISTORY.clear()
+
+
+@contextmanager
+def _suspend_output_history():
+ global _OUTPUT_HISTORY_SUPPRESSED
+ old_value = _OUTPUT_HISTORY_SUPPRESSED
+ _OUTPUT_HISTORY_SUPPRESSED = True
+ try:
+ yield
+ finally:
+ _OUTPUT_HISTORY_SUPPRESSED = old_value
+
+
+def _record_output_history_entry(entry) -> None:
+ if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:
+ return
+ _OUTPUT_HISTORY.append(entry)
+
+
+def _record_output_history(text: str) -> None:
+ if not _OUTPUT_HISTORY_ENABLED or _OUTPUT_HISTORY_REPLAYING or _OUTPUT_HISTORY_SUPPRESSED:
+ return
+ clean = _ANSI_CONTROL_RE.sub("", str(text)).replace("\r", "").rstrip("\n")
+ if not clean:
+ return
+ for line in clean.splitlines():
+ _record_output_history_entry(line)
+
+
+def _replay_output_history() -> None:
+ """Repaint recent output above the prompt after a full screen clear."""
+ global _OUTPUT_HISTORY_REPLAYING
+ if not _OUTPUT_HISTORY_ENABLED or not _OUTPUT_HISTORY:
+ return
+ _OUTPUT_HISTORY_REPLAYING = True
+ try:
+ for entry in tuple(_OUTPUT_HISTORY):
+ if callable(entry):
+ try:
+ lines = entry()
+ except Exception:
+ continue
+ if isinstance(lines, str):
+ lines = lines.splitlines()
+ else:
+ lines = [entry]
+ for line in lines:
+ _pt_print(_PT_ANSI(str(line)))
+ except Exception:
+ pass
+ finally:
+ _OUTPUT_HISTORY_REPLAYING = False
+
+
def _cprint(text: str):
"""Print ANSI-colored text through prompt_toolkit's native renderer.
@@ -1279,6 +1377,8 @@ def _cprint(text: str):
``loop.call_soon_threadsafe``, which pauses the input area, prints
the line above it, and redraws the prompt cleanly.
"""
+ _record_output_history(text)
+
try:
from prompt_toolkit.application import get_app_or_none, run_in_terminal
except Exception:
@@ -1890,8 +1990,8 @@ def _looks_like_slash_command(text: str) -> bool:
def _get_plugin_cmd_handler_names() -> set:
"""Return plugin command names (without slash prefix) for dispatch matching."""
try:
- from hermes_cli.plugins import get_plugin_manager
- return set(get_plugin_manager()._plugin_commands.keys())
+ from hermes_cli.plugins import get_plugin_commands
+ return set(get_plugin_commands().keys())
except Exception:
return set()
@@ -2035,6 +2135,10 @@ def __init__(
self.bell_on_complete = CLI_CONFIG["display"].get("bell_on_complete", False)
# show_reasoning: display model thinking/reasoning before the response
self.show_reasoning = CLI_CONFIG["display"].get("show_reasoning", False)
+ _configure_output_history(
+ enabled=CLI_CONFIG["display"].get("persistent_output", True),
+ max_lines=CLI_CONFIG["display"].get("persistent_output_max_lines", 200),
+ )
# busy_input_mode: "interrupt" (Enter interrupts current run),
# "queue" (Enter queues for next turn), or "steer" (Enter injects
# mid-run via /steer, arriving after the next tool call).
@@ -2145,7 +2249,10 @@ def __init__(
elif CLI_CONFIG.get("max_turns"): # Backwards compat: root-level max_turns
self.max_turns = CLI_CONFIG["max_turns"]
elif os.getenv("HERMES_MAX_ITERATIONS"):
- self.max_turns = int(os.getenv("HERMES_MAX_ITERATIONS"))
+ try:
+ self.max_turns = int(os.getenv("HERMES_MAX_ITERATIONS", ""))
+ except (TypeError, ValueError):
+ self.max_turns = 90
else:
self.max_turns = 90
@@ -2167,7 +2274,9 @@ def __init__(
if isinstance(cp_cfg, bool):
cp_cfg = {"enabled": cp_cfg}
self.checkpoints_enabled = checkpoints or cp_cfg.get("enabled", False)
- self.checkpoint_max_snapshots = cp_cfg.get("max_snapshots", 50)
+ self.checkpoint_max_snapshots = cp_cfg.get("max_snapshots", 20)
+ self.checkpoint_max_total_size_mb = cp_cfg.get("max_total_size_mb", 500)
+ self.checkpoint_max_file_size_mb = cp_cfg.get("max_file_size_mb", 10)
self.pass_session_id = pass_session_id
# --ignore-rules: honor either the constructor flag or the env var set
# by `hermes chat --ignore-rules` in hermes_cli/main.py. When true we
@@ -2309,6 +2418,9 @@ def __init__(
# Status bar visibility (toggled via /statusbar)
self._status_bar_visible = True
+ self._resize_recovery_lock = threading.Lock()
+ self._resize_recovery_timer = None
+ self._resize_recovery_pending = False
# Background task tracking: {task_id: threading.Thread}
self._background_tasks: Dict[str, threading.Thread] = {}
@@ -2316,6 +2428,8 @@ def __init__(
def _invalidate(self, min_interval: float = 0.25) -> None:
"""Throttled UI repaint — prevents terminal blinking on slow/SSH connections."""
+ if getattr(self, "_resize_recovery_pending", False):
+ return
now = time.monotonic()
if hasattr(self, "_app") and self._app and (now - self._last_invalidate) >= min_interval:
self._last_invalidate = now
@@ -2339,11 +2453,25 @@ def _force_full_redraw(self) -> None:
app = getattr(self, "_app", None)
if not app:
return
+ self._clear_prompt_toolkit_screen(app)
+ _replay_output_history()
+ try:
+ app.invalidate()
+ except Exception:
+ pass
+
+ def _clear_prompt_toolkit_screen(self, app, *, rebuild_scrollback: bool = False) -> None:
+ """Clear the terminal and reset prompt_toolkit renderer state."""
try:
renderer = app.renderer
out = renderer.output
out.reset_attributes()
out.erase_screen()
+ if rebuild_scrollback:
+ try:
+ out.write_raw("\x1b[3J")
+ except Exception:
+ pass
out.cursor_goto(0, 0)
out.flush()
# Drop prompt_toolkit's cached screen + cursor state so the
@@ -2352,10 +2480,57 @@ def _force_full_redraw(self) -> None:
renderer.reset(leave_alternate_screen=False)
except Exception:
pass
+
+ def _recover_after_resize(self, app, original_on_resize) -> None:
+ """Recover a resized classic CLI without desynchronizing cursor state."""
+ self._clear_prompt_toolkit_screen(app, rebuild_scrollback=True)
+ _replay_output_history()
+ original_on_resize()
+
+ def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
+ """Debounce resize redraws so footer chrome is not stamped into scrollback."""
try:
- app.invalidate()
+ old_timer = getattr(self, "_resize_recovery_timer", None)
+ lock = getattr(self, "_resize_recovery_lock", None)
+ if lock is None:
+ lock = threading.Lock()
+ self._resize_recovery_lock = lock
+
+ def _timer_fired(timer_ref):
+ def _run_recovery():
+ with lock:
+ if getattr(self, "_resize_recovery_timer", None) is not timer_ref:
+ return
+ self._resize_recovery_timer = None
+ self._resize_recovery_pending = False
+ self._recover_after_resize(app, original_on_resize)
+
+ try:
+ loop = app.loop # type: ignore[attr-defined]
+ except Exception:
+ loop = None
+ if loop is not None:
+ try:
+ loop.call_soon_threadsafe(_run_recovery)
+ return
+ except Exception:
+ pass
+ _run_recovery()
+
+ with lock:
+ if old_timer is not None:
+ try:
+ old_timer.cancel()
+ except Exception:
+ pass
+ self._resize_recovery_pending = True
+ timer = threading.Timer(delay, lambda: _timer_fired(timer))
+ timer.daemon = True
+ self._resize_recovery_timer = timer
+ timer.start()
except Exception:
- pass
+ self._resize_recovery_pending = False
+ self._recover_after_resize(app, original_on_resize)
def _status_bar_context_style(self, percent_used: Optional[int]) -> str:
if percent_used is None:
@@ -2573,9 +2748,12 @@ def _render_spinner_text(self) -> str:
elapsed = time.monotonic() - t0
if elapsed >= 60:
_m, _s = int(elapsed // 60), int(elapsed % 60)
- elapsed_str = f"{_m}m {_s}s"
+ # Fixed-width timer to avoid status-line wrap jitter while
+ # scrolling/repainting (e.g. 01m05s, 12m09s).
+ elapsed_str = f"{_m:02d}m{_s:02d}s"
else:
- elapsed_str = f"{elapsed:.1f}s"
+ # Keep width stable before the 60s rollover as well.
+ elapsed_str = f"{elapsed:5.1f}s"
return f" {txt} ({elapsed_str})"
return f" {txt}"
@@ -3670,6 +3848,8 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No
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,
@@ -4027,7 +4207,26 @@ def _display_resumed_history(self):
padding=(0, 1),
style=_history_text_c,
)
- self._console_print(panel)
+ _record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel))
+ with _suspend_output_history():
+ self._console_print(panel)
+
+ def _render_resume_history_panel_lines(self, panel) -> list[str]:
+ """Render the resume panel at the current terminal width for resize replay."""
+ from io import StringIO
+
+ buf = StringIO()
+ width = shutil.get_terminal_size((80, 24)).columns
+ console = Console(
+ file=buf,
+ force_terminal=True,
+ color_system="truecolor",
+ highlight=False,
+ width=width,
+ )
+ with _suspend_output_history():
+ console.print(panel)
+ return buf.getvalue().rstrip("\n").splitlines()
def _try_attach_clipboard_image(self) -> bool:
"""Check clipboard for an image and attach it if found.
@@ -6386,6 +6585,7 @@ def process_command(self, command: str) -> bool:
_cprint(f" {_DIM}✓ UI redrawn{_RST}")
elif canonical == "clear":
self.new_session(silent=True)
+ _clear_output_history()
# Clear terminal screen. Inside the TUI, Rich's console.clear()
# goes through patch_stdout's StdoutProxy which swallows the
# screen-clear escape sequences. Use prompt_toolkit's output
@@ -7116,7 +7316,20 @@ def _handle_browser_command(self, cmd: str):
if provider is not None:
print(f"🌐 Browser: {provider.provider_name()} (cloud)")
else:
- print("🌐 Browser: local headless Chromium (agent-browser)")
+ # Show engine info for local mode
+ try:
+ from tools.browser_tool import _get_browser_engine
+ engine = _get_browser_engine()
+ except Exception:
+ engine = "auto"
+ if engine == "lightpanda":
+ print("🌐 Browser: local Lightpanda (agent-browser --engine lightpanda)")
+ print(" ⚡ Lightpanda: faster navigation, no screenshot support")
+ print(" Automatic Chrome fallback for screenshots and failed commands")
+ elif engine == "chrome":
+ print("🌐 Browser: local headless Chrome (agent-browser --engine chrome)")
+ else:
+ print("🌐 Browser: local headless Chromium (agent-browser)")
print()
print(" /browser connect — connect to your live Chrome")
print(" /browser disconnect — revert to default")
@@ -11640,23 +11853,7 @@ def _get_voice_status():
_original_on_resize = app._on_resize
def _resize_clear_ghosts():
- renderer = app.renderer
- try:
- out = renderer.output
- # Reset attributes, erase the entire screen, and home the
- # cursor. This overwrites any reflowed status-bar rows or
- # stale content the terminal kept from the prior layout.
- out.reset_attributes()
- out.erase_screen()
- out.cursor_goto(0, 0)
- out.flush()
- # Tell the renderer its tracked position is fresh so its
- # own erase() inside _on_resize doesn't cursor_up() past
- # the top of the screen.
- renderer.reset(leave_alternate_screen=False)
- except Exception:
- pass # never break resize handling
- _original_on_resize()
+ self._schedule_resize_recovery(app, _original_on_resize)
app._on_resize = _resize_clear_ghosts
@@ -11847,8 +12044,22 @@ def _signal_handler(signum, frame):
call _kill_process (SIGTERM + 1 s wait + SIGKILL if needed) →
return from _wait_for_process. ``time.sleep`` releases the
GIL so the daemon actually runs during the window.
+
+ Guarded ``logger.debug``: CPython's ``logging`` module is not
+ reentrant-safe. ``Logger.isEnabledFor`` caches level results
+ in ``Logger._cache``; under shutdown races the cache can be
+ cleared (``_clear_cache``) or mid-mutation when the signal
+ fires, raising ``KeyError: `` (e.g. ``KeyError: 10``
+ for DEBUG) inside the handler. That KeyError then escapes
+ before ``raise KeyboardInterrupt()`` can fire, which bypasses
+ prompt_toolkit's normal interrupt unwind and surfaces as the
+ EIO cascade from issue #13710. Wrap the log in a bare
+ ``try/except`` so the handler can never raise through it.
"""
- logger.debug("Received signal %s, triggering graceful shutdown", signum)
+ try:
+ logger.debug("Received signal %s, triggering graceful shutdown", signum)
+ except Exception:
+ pass # never let logging raise from a signal handler (#13710 regression)
try:
if getattr(self, "agent", None) and getattr(self, "_agent_running", False):
self.agent.interrupt(f"received signal {signum}")
diff --git a/cron/scheduler.py b/cron/scheduler.py
index c49370352c1ab..c17c1fa46f85c 100644
--- a/cron/scheduler.py
+++ b/cron/scheduler.py
@@ -114,12 +114,20 @@ def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
# locally for audit.
SILENT_MARKER = "[SILENT]"
-# Resolve Hermes home directory (respects HERMES_HOME override)
-_hermes_home = get_hermes_home()
+# Backward-compatible module override used by tests and emergency monkeypatches.
+_hermes_home: Path | None = None
+
+
+def _get_hermes_home() -> Path:
+ """Resolve Hermes home dynamically while preserving test monkeypatch hooks."""
+ return _hermes_home or get_hermes_home()
+
-# File-based lock prevents concurrent ticks from gateway + daemon + systemd timer
-_LOCK_DIR = _hermes_home / "cron"
-_LOCK_FILE = _LOCK_DIR / ".tick.lock"
+def _get_lock_paths() -> tuple[Path, Path]:
+ """Resolve cron lock paths at call time so profile/env changes are honored."""
+ hermes_home = _get_hermes_home()
+ lock_dir = hermes_home / "cron"
+ return lock_dir, lock_dir / ".tick.lock"
def _resolve_origin(job: dict) -> Optional[dict]:
@@ -597,7 +605,7 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
"""
from hermes_constants import get_hermes_home
- scripts_dir = get_hermes_home() / "scripts"
+ scripts_dir = _get_hermes_home() / "scripts"
scripts_dir.mkdir(parents=True, exist_ok=True)
scripts_dir_resolved = scripts_dir.resolve()
@@ -1058,9 +1066,9 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
# changes take effect without a gateway restart.
from dotenv import load_dotenv
try:
- load_dotenv(str(_hermes_home / ".env"), override=True, encoding="utf-8")
+ load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="utf-8")
except UnicodeDecodeError:
- load_dotenv(str(_hermes_home / ".env"), override=True, encoding="latin-1")
+ load_dotenv(str(_get_hermes_home() / ".env"), override=True, encoding="latin-1")
delivery_target = _resolve_delivery_target(job)
if delivery_target:
@@ -1078,7 +1086,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
_cfg = {}
try:
import yaml
- _cfg_path = str(_hermes_home / "config.yaml")
+ _cfg_path = str(_get_hermes_home() / "config.yaml")
if os.path.exists(_cfg_path):
with open(_cfg_path) as _f:
_cfg = yaml.safe_load(_f) or {}
@@ -1112,7 +1120,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
if prefill_file:
pfpath = Path(prefill_file).expanduser()
if not pfpath.is_absolute():
- pfpath = _hermes_home / pfpath
+ pfpath = _get_hermes_home() / pfpath
if pfpath.exists():
try:
with open(pfpath, "r", encoding="utf-8") as _pf:
@@ -1436,12 +1444,13 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:
Returns:
Number of jobs executed (0 if another tick is already running)
"""
- _LOCK_DIR.mkdir(parents=True, exist_ok=True)
+ lock_dir, lock_file = _get_lock_paths()
+ lock_dir.mkdir(parents=True, exist_ok=True)
# Cross-platform file locking: fcntl on Unix, msvcrt on Windows
lock_fd = None
try:
- lock_fd = open(_LOCK_FILE, "w")
+ lock_fd = open(lock_file, "w")
if fcntl:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
elif msvcrt:
diff --git a/environments/README.md b/environments/README.md
index 9677fdb70ef65..3936e1f35bc64 100644
--- a/environments/README.md
+++ b/environments/README.md
@@ -40,7 +40,7 @@ This directory contains the integration layer between **hermes-agent's** tool-ca
- `evaluate_log()` for saving eval results to JSON + samples.jsonl
**HermesAgentBaseEnv** (`hermes_base_env.py`) extends BaseEnv with hermes-agent specifics:
-- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, modal, daytona, ssh, singularity)
+- Sets `os.environ["TERMINAL_ENV"]` to configure the terminal backend (local, docker, ssh, singularity, modal, daytona, vercel_sandbox)
- Resolves hermes-agent toolsets via `_resolve_tools_for_group()` (calls `get_tool_definitions()` which queries `tools/registry.py`)
- Implements `collect_trajectory()` which runs the full agent loop and computes rewards
- Supports two-phase operation (Phase 1: OpenAI server, Phase 2: VLLM ManagedServer)
diff --git a/gateway/config.py b/gateway/config.py
index 9a1239a60ffed..2e0e3276b7b2f 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -868,18 +868,6 @@ def load_gateway_config() -> GatewayConfig:
_tg_extra = _tg_plat.setdefault("extra", {})
_tg_extra.setdefault("require_mention", _tl_require_mention)
- # Bridge top-level require_mention to Telegram when the telegram: section
- # does not already provide one. Users often write "require_mention: true"
- # at the top level alongside group_sessions_per_user, expecting it to work
- # the same way (#3979).
- _tl_require_mention = yaml_cfg.get("require_mention")
- if _tl_require_mention is not None:
- _tg_section = yaml_cfg.get("telegram") or {}
- if "require_mention" not in _tg_section:
- _tg_plat = platforms_data.setdefault(Platform.TELEGRAM.value, {})
- _tg_extra = _tg_plat.setdefault("extra", {})
- _tg_extra.setdefault("require_mention", _tl_require_mention)
-
# Telegram settings → env vars (env vars take precedence)
telegram_cfg = yaml_cfg.get("telegram", {})
if isinstance(telegram_cfg, dict):
diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py
index d3beb1d1d2f81..ae77100f6aa1c 100644
--- a/gateway/platforms/api_server.py
+++ b/gateway/platforms/api_server.py
@@ -56,7 +56,7 @@
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8642
MAX_STORED_RESPONSES = 100
-MAX_REQUEST_BYTES = 1_000_000 # 1 MB default limit for POST bodies
+MAX_REQUEST_BYTES = 10_000_000 # 10 MB — accommodates long agent conversations with tool calls
CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS = 30.0
MAX_NORMALIZED_TEXT_LENGTH = 65_536 # 64 KB cap for normalized content parts
MAX_CONTENT_LIST_SIZE = 1_000 # Max items when content is an array
@@ -1209,7 +1209,9 @@ async def _compute_completion():
},
}
- response_headers = {"X-Hermes-Session-Id": session_id}
+ response_headers = {
+ "X-Hermes-Session-Id": result.get("session_id", session_id),
+ }
if gateway_session_key:
response_headers["X-Hermes-Session-Key"] = gateway_session_key
return web.json_response(response_data, headers=response_headers)
@@ -1347,6 +1349,22 @@ async def _emit(item):
except (asyncio.CancelledError, Exception):
pass
logger.info("SSE client disconnected; interrupted agent task %s", completion_id)
+ except Exception as _exc:
+ # Agent crashed mid-stream. Try to emit an error chunk
+ # so the client gets a proper response instead of a
+ # TransferEncodingError from incomplete chunked encoding.
+ import traceback as _tb
+ logger.error("Agent crashed mid-stream for %s: %s", completion_id, _tb.format_exc()[:300])
+ try:
+ error_chunk = {
+ "id": completion_id, "object": "chat.completion.chunk",
+ "created": created, "model": model,
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "error"}],
+ }
+ await response.write(f"data: {json.dumps(error_chunk)}\n\n".encode())
+ await response.write(b"data: [DONE]\n\n")
+ except Exception:
+ pass
return response
@@ -1667,20 +1685,54 @@ async def _emit_tool_completed(payload: Dict[str, Any]) -> None:
async def _dispatch(it) -> None:
"""Route a queue item to the correct SSE emitter.
- Plain strings are text deltas. Tagged tuples with
- ``__tool_started__`` / ``__tool_completed__`` prefixes
- are tool lifecycle events.
+ Plain strings are text deltas — they are batched (50ms)
+ to reduce Open WebUI re-render storms. Tagged tuples
+ with ``__tool_started__`` / ``__tool_completed__``
+ prefixes are tool lifecycle events and flush the buffer
+ before emitting.
"""
+ nonlocal _batch_timer
if isinstance(it, tuple) and len(it) == 2 and isinstance(it[0], str):
tag, payload = it
+ # Flush batched text before tool events
+ if _batch_buf:
+ await _flush_batch()
if tag == "__tool_started__":
await _emit_tool_started(payload)
elif tag == "__tool_completed__":
await _emit_tool_completed(payload)
- # Unknown tags are silently ignored (forward-compat).
elif isinstance(it, str):
- await _emit_text_delta(it)
- # Other types (non-string, non-tuple) are silently dropped.
+ # Batch text deltas — append to buffer, flush on timer
+ _batch_buf.append(it)
+ if _batch_timer is None:
+ _batch_timer = asyncio.create_task(_batch_flush_after(0.05))
+ # Other types are silently dropped.
+
+ # ── Batching state ──
+ _batch_buf: List[str] = []
+ _batch_timer: Optional[asyncio.Task] = None
+ _batch_lock = asyncio.Lock()
+
+ async def _batch_flush_after(delay: float) -> None:
+ """Wait delay seconds, then flush accumulated text deltas."""
+ try:
+ await asyncio.sleep(delay)
+ except asyncio.CancelledError:
+ return
+ # Clear timer reference BEFORE flush so new deltas
+ # can start a fresh timer while we emit
+ nonlocal _batch_buf, _batch_timer
+ _batch_timer = None
+ await _flush_batch()
+
+ async def _flush_batch() -> None:
+ """Emit a single SSE delta for all accumulated text."""
+ nonlocal _batch_buf
+ async with _batch_lock:
+ if _batch_buf:
+ combined = "".join(_batch_buf)
+ _batch_buf = []
+ await _emit_text_delta(combined)
loop = asyncio.get_running_loop()
while True:
@@ -1705,11 +1757,21 @@ async def _dispatch(it) -> None:
continue
if item is None: # EOS sentinel
+ # Cancel pending timer and flush remaining batched text
+ if _batch_timer and not _batch_timer.done():
+ _batch_timer.cancel()
+ _batch_timer = None
+ if _batch_buf:
+ await _flush_batch()
break
await _dispatch(item)
last_activity = time.monotonic()
+ # Flush any final batched text before processing result
+ if _batch_buf:
+ await _flush_batch()
+
# Pick up agent result + usage from the completed task
try:
result, agent_usage = await agent_task
@@ -1760,6 +1822,31 @@ async def _dispatch(it) -> None:
# payload still see the assistant text. This mirrors the
# shape produced by _extract_output_items in the batch path.
final_items: List[Dict[str, Any]] = list(emitted_items)
+
+ # Trim large content from tool call arguments to keep the
+ # response.completed event under ~100KB. Clients already
+ # received full details via incremental events.
+ for _item in final_items:
+ if _item.get("type") == "function_call":
+ try:
+ _args = json.loads(_item.get("arguments", "{}")) if isinstance(_item.get("arguments"), str) else _item.get("arguments", {})
+ if isinstance(_args, dict):
+ for _k in ("content", "query", "pattern", "old_string", "new_string"):
+ if isinstance(_args.get(_k), str) and len(_args[_k]) > 500:
+ _args[_k] = "[" + str(len(_args[_k])) + " chars — truncated for response.completed]"
+ _item["arguments"] = json.dumps(_args)
+ except Exception:
+ pass
+ elif _item.get("type") == "function_call_output":
+ _output = _item.get("output", [])
+ if isinstance(_output, list) and _output:
+ _first = _output[0]
+ if isinstance(_first, dict) and _first.get("type") == "input_text":
+ _text = _first.get("text", "")
+ if len(_text) > 1000:
+ _first["text"] = _text[:500] + "...[" + str(len(_text) - 500) + " more chars]"
+ _item["output"] = [_first]
+
final_items.append({
"type": "message",
"role": "assistant",
@@ -1850,6 +1937,30 @@ async def _dispatch(it) -> None:
agent_task.cancel()
logger.info("SSE task cancelled; persisted incomplete snapshot for %s", response_id)
raise
+ except Exception as _exc:
+ # Agent crashed with an unhandled error (e.g. model API error like
+ # BadRequestError, AuthenticationError). Emit a response.failed
+ # event and properly terminate the SSE stream so the client doesn't
+ # get a TransferEncodingError from incomplete chunked encoding.
+ import traceback as _tb
+ _persist_incomplete_if_needed()
+ agent_error = _tb.format_exc()
+ try:
+ failed_env = _envelope("failed")
+ failed_env["output"] = list(emitted_items)
+ failed_env["error"] = {"message": str(_exc)[:500], "type": "server_error"}
+ failed_env["usage"] = {
+ "input_tokens": usage.get("input_tokens", 0),
+ "output_tokens": usage.get("output_tokens", 0),
+ "total_tokens": usage.get("total_tokens", 0),
+ }
+ await _write_event("response.failed", {
+ "type": "response.failed",
+ "response": failed_env,
+ })
+ except Exception:
+ pass
+ logger.error("Agent crashed mid-stream for %s: %s", response_id, str(agent_error)[:300])
return response
@@ -2483,6 +2594,12 @@ def _run():
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
}
+ # Include the effective session ID in the result so callers
+ # (e.g. X-Hermes-Session-Id header) can track compression-
+ # triggered session rotations. (#16938)
+ _eff_sid = getattr(agent, "session_id", session_id)
+ if isinstance(_eff_sid, str) and _eff_sid:
+ result["session_id"] = _eff_sid
return result, usage
return await loop.run_in_executor(None, _run)
@@ -2927,7 +3044,7 @@ async def connect(self) -> bool:
try:
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
- self._app = web.Application(middlewares=mws)
+ self._app = web.Application(middlewares=mws, client_max_size=MAX_REQUEST_BYTES)
self._app["api_server_adapter"] = self
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py
index 4d611fdaa5369..5c2bbf96aa88b 100644
--- a/gateway/platforms/base.py
+++ b/gateway/platforms/base.py
@@ -2675,10 +2675,18 @@ def _get_human_delay() -> float:
mode = os.getenv("HERMES_HUMAN_DELAY_MODE", "off").lower()
if mode == "off":
return 0.0
- min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800"))
- max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500"))
if mode == "natural":
min_ms, max_ms = 800, 2500
+ return random.uniform(min_ms / 1000.0, max_ms / 1000.0)
+ # custom mode — tolerate malformed env vars instead of crashing.
+ try:
+ min_ms = int(os.getenv("HERMES_HUMAN_DELAY_MIN_MS", "800"))
+ except (TypeError, ValueError):
+ min_ms = 800
+ try:
+ max_ms = int(os.getenv("HERMES_HUMAN_DELAY_MAX_MS", "2500"))
+ except (TypeError, ValueError):
+ max_ms = 2500
return random.uniform(min_ms / 1000.0, max_ms / 1000.0)
async def _process_message_background(self, event: MessageEvent, session_key: str) -> None:
diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py
index ecfa38c72341c..e30c4478ef9d8 100644
--- a/gateway/platforms/discord.py
+++ b/gateway/platforms/discord.py
@@ -2654,9 +2654,14 @@ async def slash_reload_skills(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/reload-skills")
@tree.command(name="voice", description="Toggle voice reply mode")
- @discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status")
+ @discord.app_commands.describe(mode="Voice mode: join, channel, leave, on, tts, off, or status")
@discord.app_commands.choices(mode=[
- discord.app_commands.Choice(name="channel — join your voice channel", value="channel"),
+ # `join` and `channel` both route to _handle_voice_channel_join in
+ # gateway/run.py — expose both in the slash UI so autocomplete
+ # matches what the docs advertise and what the runner accepts when
+ # the command is typed as plain text.
+ discord.app_commands.Choice(name="join — join your voice channel", value="join"),
+ discord.app_commands.Choice(name="channel — join your voice channel (alias)", value="channel"),
discord.app_commands.Choice(name="leave — leave voice channel", value="leave"),
discord.app_commands.Choice(name="on — voice reply to voice messages", value="on"),
discord.app_commands.Choice(name="tts — voice reply to all messages", value="tts"),
diff --git a/gateway/platforms/feishu.py b/gateway/platforms/feishu.py
index ac920bab69dfd..e1528b9bca091 100644
--- a/gateway/platforms/feishu.py
+++ b/gateway/platforms/feishu.py
@@ -153,6 +153,9 @@
r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(.+?)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
re.MULTILINE,
)
+# Detect markdown tables: a line starting with | followed by a separator line.
+# Feishu post-type 'md' elements do not render tables, so we force text mode.
+_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE)
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
_MARKDOWN_FENCE_CLOSE_RE = re.compile(r"^```\s*$")
@@ -3862,47 +3865,50 @@ async def _hydrate_bot_identity(self) -> None:
and self-sent bot event filtering.
Populates ``_bot_open_id`` and ``_bot_name`` from /open-apis/bot/v3/info
- (no extra scopes required beyond the tenant access token). Falls back to
- the application info endpoint for ``_bot_name`` only when the first probe
- doesn't return it. Each field is hydrated independently — a value already
- supplied via env vars (FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID /
- FEISHU_BOT_NAME) is preserved and skips its probe.
+ (no extra scopes required beyond the tenant access token). The probe
+ always runs when a client is available so stale env vars from app/bot
+ migrations do not break group @mention gating. Falls back to the
+ application info endpoint for ``_bot_name`` only when the first probe
+ doesn't return it. If the probe fails, env-provided values are preserved.
"""
if not self._client:
return
- if self._bot_open_id and self._bot_name:
- # Everything the self-send filter and precise mention gate need is
- # already in place; nothing to probe.
- return
# Primary probe: /open-apis/bot/v3/info — returns bot_name + open_id, no
# extra scopes required. This is the same endpoint the onboarding wizard
# uses via probe_bot().
- if not self._bot_open_id or not self._bot_name:
- try:
- req = (
- BaseRequest.builder()
- .http_method(HttpMethod.GET)
- .uri("/open-apis/bot/v3/info")
- .token_types({AccessTokenType.TENANT})
- .build()
- )
- resp = await asyncio.to_thread(self._client.request, req)
- content = getattr(getattr(resp, "raw", None), "content", None)
- if content:
- payload = json.loads(content)
- parsed = _parse_bot_response(payload) or {}
- open_id = (parsed.get("bot_open_id") or "").strip()
- bot_name = (parsed.get("bot_name") or "").strip()
- if open_id and not self._bot_open_id:
- self._bot_open_id = open_id
- if bot_name and not self._bot_name:
- self._bot_name = bot_name
- except Exception:
- logger.debug(
- "[Feishu] /bot/v3/info probe failed during hydration",
- exc_info=True,
- )
+ try:
+ req = (
+ BaseRequest.builder()
+ .http_method(HttpMethod.GET)
+ .uri("/open-apis/bot/v3/info")
+ .token_types({AccessTokenType.TENANT})
+ .build()
+ )
+ resp = await asyncio.to_thread(self._client.request, req)
+ content = getattr(getattr(resp, "raw", None), "content", None)
+ if content:
+ payload = json.loads(content)
+ parsed = _parse_bot_response(payload) or {}
+ open_id = (parsed.get("bot_open_id") or "").strip()
+ bot_name = (parsed.get("bot_name") or "").strip()
+ if open_id:
+ if self._bot_open_id and self._bot_open_id != open_id:
+ logger.warning(
+ "[Feishu] FEISHU_BOT_OPEN_ID is stale; using /bot/v3/info open_id for group @mention gating."
+ )
+ self._bot_open_id = open_id
+ if bot_name:
+ if self._bot_name and self._bot_name != bot_name:
+ logger.info(
+ "[Feishu] FEISHU_BOT_NAME differs from /bot/v3/info; using hydrated bot name for group @mention gating."
+ )
+ self._bot_name = bot_name
+ except Exception:
+ logger.debug(
+ "[Feishu] /bot/v3/info probe failed during hydration",
+ exc_info=True,
+ )
# Fallback probe for _bot_name only: application info endpoint. Needs
# admin:app.info:readonly or application:application:self_manage scope,
@@ -3947,7 +3953,14 @@ def _load_seen_message_ids(self) -> None:
if isinstance(seen_data, list):
entries: Dict[str, float] = {str(item).strip(): 0.0 for item in seen_data if str(item).strip()}
elif isinstance(seen_data, dict):
- entries = {k: float(v) for k, v in seen_data.items() if isinstance(k, str) and k.strip()}
+ entries = {}
+ for key, value in seen_data.items():
+ if not isinstance(key, str) or not key.strip():
+ continue
+ try:
+ entries[key] = float(value)
+ except (TypeError, ValueError):
+ continue
else:
return
# Filter out TTL-expired entries (entries saved with ts=0.0 are treated as immortal
@@ -3992,6 +4005,12 @@ def _is_duplicate(self, message_id: str) -> bool:
# =========================================================================
def _build_outbound_payload(self, content: str) -> tuple[str, str]:
+ # Feishu post-type 'md' elements do not render markdown tables; sending
+ # table content as post causes the message to appear blank on the client.
+ # Force plain text for anything that looks like a markdown table.
+ if _MARKDOWN_TABLE_RE.search(content):
+ text_payload = {"text": content}
+ return "text", json.dumps(text_payload, ensure_ascii=False)
if _MARKDOWN_HINT_RE.search(content):
return "post", _build_markdown_post_payload(content)
text_payload = {"text": content}
@@ -4087,7 +4106,15 @@ async def _send_raw_message(
content=payload,
uuid_value=str(uuid.uuid4()),
)
- request = self._build_create_message_request("chat_id", body)
+ # Detect whether chat_id is a user open_id (DM) or a chat_id (group).
+ # Feishu API expects receive_id_type="open_id" for user DMs (ou_ prefix)
+ # and receive_id_type="chat_id" for group chats (oc_ prefix, which IS
+ # the chat_id format — see https://open.feishu.cn/document/).
+ if chat_id.startswith("ou_"):
+ receive_id_type = "open_id"
+ else:
+ receive_id_type = "chat_id"
+ request = self._build_create_message_request(receive_id_type, body)
return await asyncio.to_thread(self._client.im.v1.message.create, request)
@staticmethod
diff --git a/gateway/platforms/helpers.py b/gateway/platforms/helpers.py
index 8db7af7eba908..673beeac9b456 100644
--- a/gateway/platforms/helpers.py
+++ b/gateway/platforms/helpers.py
@@ -222,33 +222,37 @@ class ThreadParticipationTracker:
def __init__(self, platform_name: str, max_tracked: int = 500):
self._platform = platform_name
self._max_tracked = max_tracked
- self._threads: set = self._load()
+ self._threads: dict[str, None] = {
+ str(thread_id): None for thread_id in self._load()
+ }
def _state_path(self) -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / f"{self._platform}_threads.json"
- def _load(self) -> set:
+ def _load(self) -> list[str]:
path = self._state_path()
if path.exists():
try:
- return set(json.loads(path.read_text(encoding="utf-8")))
+ data = json.loads(path.read_text(encoding="utf-8"))
+ if isinstance(data, list):
+ return [str(thread_id) for thread_id in data]
except Exception:
pass
- return set()
+ return []
def _save(self) -> None:
path = self._state_path()
thread_list = list(self._threads)
if len(thread_list) > self._max_tracked:
thread_list = thread_list[-self._max_tracked:]
- self._threads = set(thread_list)
+ self._threads = {thread_id: None for thread_id in thread_list}
atomic_json_write(path, thread_list, indent=None)
def mark(self, thread_id: str) -> None:
"""Mark *thread_id* as participated and persist."""
if thread_id not in self._threads:
- self._threads.add(thread_id)
+ self._threads[thread_id] = None
self._save()
def __contains__(self, thread_id: str) -> bool:
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index ad5ed66920270..83e81736876bf 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -353,7 +353,10 @@ def _message_thread_id_for_send(cls, thread_id: Optional[str]) -> Optional[int]:
@classmethod
def _message_thread_id_for_typing(cls, thread_id: Optional[str]) -> Optional[int]:
- if not thread_id:
+ # Mirrors _message_thread_id_for_send: the General forum topic (thread id
+ # "1") is represented as "no thread id" on the wire. User-created topics
+ # keep their real id so typing stays scoped to that topic.
+ if not thread_id or str(thread_id) == cls._GENERAL_TOPIC_THREAD_ID:
return None
return int(thread_id)
@@ -2508,21 +2511,16 @@ async def send_typing(self, chat_id: str, metadata: Optional[Dict[str, Any]] = N
try:
_typing_thread = self._metadata_thread_id(metadata)
message_thread_id = self._message_thread_id_for_typing(_typing_thread)
- try:
- await self._bot.send_chat_action(
- chat_id=int(chat_id),
- action="typing",
- message_thread_id=message_thread_id,
- )
- except Exception as e:
- if message_thread_id is not None and self._is_thread_not_found_error(e):
- await self._bot.send_chat_action(
- chat_id=int(chat_id),
- action="typing",
- message_thread_id=None,
- )
- else:
- raise
+ # No retry-without-thread fallback here: _message_thread_id_for_typing
+ # already maps the forum General topic to None, so any non-None value
+ # reaching this call is a user-created topic. If Telegram rejects it
+ # (e.g. topic deleted mid-session), we swallow the failure rather than
+ # showing a typing indicator in the wrong chat/All Messages.
+ await self._bot.send_chat_action(
+ chat_id=int(chat_id),
+ action="typing",
+ message_thread_id=message_thread_id,
+ )
except Exception as e:
# Typing failures are non-fatal; log at debug level only.
logger.debug(
diff --git a/gateway/run.py b/gateway/run.py
index 433b41387fd47..fe2ed84e6cd98 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -39,6 +39,7 @@
# 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 ---------------------------------------------------
@@ -938,6 +939,52 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
_gateway_runner_ref: _weakref.ref = lambda: None
+def _normalize_empty_agent_response(
+ agent_result: dict,
+ response: str,
+ *,
+ history_len: int = 0,
+) -> str:
+ """Normalize empty/None agent responses into user-facing messages.
+
+ Consolidates the existing ``failed`` handler and adds a catch-all for
+ the case where the agent did work (api_calls > 0) but returned no text.
+ Fix for #18765.
+ """
+ if response:
+ return response
+
+ if agent_result.get("failed"):
+ error_detail = agent_result.get("error", "unknown error")
+ error_str = str(error_detail).lower()
+ is_context_failure = any(
+ p in error_str
+ for p in ("context", "token", "too large", "too long", "exceed", "payload")
+ ) or ("400" in error_str and history_len > 50)
+ if is_context_failure:
+ return (
+ "⚠️ Session too large for the model's context window.\n"
+ "Use /compact to compress the conversation, or "
+ "/reset to start fresh."
+ )
+ return (
+ f"The request failed: {str(error_detail)[:300]}\n"
+ "Try again or use /reset to start a fresh session."
+ )
+
+ api_calls = int(agent_result.get("api_calls", 0) or 0)
+ if api_calls > 0 and not agent_result.get("interrupted"):
+ if agent_result.get("partial"):
+ err = agent_result.get("error", "processing incomplete")
+ return f"⚠️ Processing stopped: {str(err)[:200]}. Try again."
+ return (
+ "⚠️ Processing completed but no response was generated. "
+ "This may be a transient error — try sending your message again."
+ )
+
+ return response
+
+
class GatewayRunner:
"""
Main gateway controller.
@@ -1113,6 +1160,7 @@ def __init__(self, config: Optional[GatewayConfig] = None):
retention_days=int(_ckpt_cfg.get("retention_days", 7)),
min_interval_hours=int(_ckpt_cfg.get("min_interval_hours", 24)),
delete_orphans=bool(_ckpt_cfg.get("delete_orphans", True)),
+ max_total_size_mb=int(_ckpt_cfg.get("max_total_size_mb", 500)),
)
except Exception as exc:
logger.debug("checkpoint auto-maintenance skipped: %s", exc)
@@ -3576,6 +3624,11 @@ async def _kanban_dispatcher_watcher(self) -> None:
if interval < 1.0:
interval = 1.0 # sanity floor — tighter than this is a footgun
+ # Read max_spawn config to limit concurrent kanban tasks
+ max_spawn = kanban_cfg.get("max_spawn", None)
+ if max_spawn is not None:
+ logger.info(f"kanban dispatcher: max_spawn={max_spawn}")
+
# Initial delay so the gateway finishes wiring adapters before the
# dispatcher spawns workers (those workers may hit gateway notify
# subscriptions etc.). Matches the notifier watcher's delay.
@@ -3604,7 +3657,7 @@ def _tick_once_for_board(slug: str) -> "Optional[object]":
_kb.init_db(board=slug) # idempotent, handles first-run
except Exception:
pass
- return _kb.dispatch_once(conn, board=slug)
+ return _kb.dispatch_once(conn, board=slug, max_spawn=max_spawn)
except Exception:
logger.exception("kanban dispatcher: tick failed on board %s", slug)
return None
@@ -6270,6 +6323,10 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
_werr,
)
finally:
+ # Evict the cached agent so the next turn
+ # rebuilds its system prompt from current
+ # SOUL.md, memory, and skills.
+ self._evict_cached_agent(session_key)
self._cleanup_agent_resources(_hyg_agent)
except Exception as e:
@@ -6438,33 +6495,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
session_key, _e,
)
- # Surface error details when the agent failed silently (final_response=None)
- if not response and agent_result.get("failed"):
- error_detail = agent_result.get("error", "unknown error")
- error_str = str(error_detail).lower()
-
- # Detect context-overflow failures and give specific guidance.
- # Generic 400 "Error" from Anthropic with large sessions is the
- # most common cause of this (#1630).
- _is_ctx_fail = any(p in error_str for p in (
- "context", "token", "too large", "too long",
- "exceed", "payload",
- )) or (
- "400" in error_str
- and len(history) > 50
- )
-
- if _is_ctx_fail:
- response = (
- "⚠️ Session too large for the model's context window.\n"
- "Use /compact to compress the conversation, or "
- "/reset to start fresh."
- )
- else:
- response = (
- f"The request failed: {str(error_detail)[:300]}\n"
- "Try again or use /reset to start a fresh session."
- )
+ # Normalize empty responses: surface errors, partial failures, and
+ # the case where agent did work but returned no text. Fix for #18765.
+ response = _normalize_empty_agent_response(
+ agent_result, response, history_len=len(history),
+ )
# If the agent's session_id changed during compression, update
# session_entry so transcript writes below go to the right session.
@@ -7377,7 +7412,7 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem
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 +7464,7 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem
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:
@@ -7577,6 +7612,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
from hermes_cli.model_switch import (
switch_model as _switch_model, parse_model_flags,
list_authenticated_providers,
+ list_picker_providers,
)
from hermes_cli.providers import get_label
@@ -7631,7 +7667,7 @@ async def _handle_model_command(self, event: MessageEvent) -> Optional[str]:
if has_picker:
try:
- providers = list_authenticated_providers(
+ providers = list_picker_providers(
current_provider=current_provider,
current_base_url=current_base_url,
current_model=current_model,
@@ -8099,7 +8135,7 @@ async def _handle_goal_command(self, event: "MessageEvent") -> str:
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 +9353,7 @@ async def _handle_footer_command(self, event: MessageEvent) -> str:
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 +9386,7 @@ async def _handle_footer_command(self, event: MessageEvent) -> str:
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 = ""
@@ -9474,6 +9510,9 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:
_aux_fail_model = getattr(compressor, "_last_aux_model_failure_model", None)
_aux_fail_err = getattr(compressor, "_last_aux_model_failure_error", None)
finally:
+ # Evict cached agent so next turn rebuilds system prompt
+ # from current files (SOUL.md, memory, etc.).
+ self._evict_cached_agent(session_key)
self._cleanup_agent_resources(tmp_agent)
lines = [f"🗜️ {summary['headline']}"]
if focus_topic:
@@ -10788,7 +10827,7 @@ async def _handle_approve_command(self, event: MessageEvent) -> Optional[str]:
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"
diff --git a/gateway/session.py b/gateway/session.py
index 16de296e0e7fc..be393e48e6fc8 100644
--- a/gateway/session.py
+++ b/gateway/session.py
@@ -1276,8 +1276,9 @@ def append_to_transcript(self, session_id: str, message: Dict[str, Any], skip_db
# Also write legacy JSONL (keeps existing tooling working during transition)
transcript_path = self.get_transcript_path(session_id)
- with open(transcript_path, "a", encoding="utf-8") as f:
- f.write(json.dumps(message, ensure_ascii=False) + "\n")
+ with self._lock:
+ with open(transcript_path, "a", encoding="utf-8") as f:
+ f.write(json.dumps(message, ensure_ascii=False) + "\n")
def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Replace the entire transcript for a session with new messages.
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 5b63d41eb1f57..6695c9ab9570e 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -416,6 +416,40 @@ class ProviderConfig:
),
}
+# Auto-extend PROVIDER_REGISTRY with any api-key provider registered in
+# providers/ that is not already declared above. New providers only need a
+# providers/*.py file — no edits to this file required.
+try:
+ from providers import list_providers as _list_providers_for_registry
+ for _pp in _list_providers_for_registry():
+ if _pp.name in PROVIDER_REGISTRY:
+ continue
+ if _pp.auth_type != "api_key" or not _pp.env_vars:
+ continue
+ # Skip providers that need custom token resolution or are special-cased
+ # in resolve_provider() (copilot/kimi/zai have bespoke token refresh;
+ # openrouter/custom are aggregator/user-supplied and handled outside
+ # the registry — adding them here breaks runtime_provider resolution
+ # that relies on `openrouter not in PROVIDER_REGISTRY`).
+ if _pp.name in {"copilot", "kimi-coding", "kimi-coding-cn", "zai", "openrouter", "custom"}:
+ continue
+ _api_key_vars = tuple(v for v in _pp.env_vars if not v.endswith("_BASE_URL") and not v.endswith("_URL"))
+ _base_url_var = next((v for v in _pp.env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), None)
+ PROVIDER_REGISTRY[_pp.name] = ProviderConfig(
+ id=_pp.name,
+ name=_pp.display_name or _pp.name,
+ auth_type="api_key",
+ inference_base_url=_pp.base_url,
+ api_key_env_vars=_api_key_vars or _pp.env_vars,
+ base_url_env_var=_base_url_var or "",
+ )
+ # Also register aliases so resolve_provider() resolves them
+ for _alias in _pp.aliases:
+ if _alias not in PROVIDER_REGISTRY:
+ PROVIDER_REGISTRY[_alias] = PROVIDER_REGISTRY[_pp.name]
+except Exception:
+ pass
+
# =============================================================================
# Anthropic Key Helper
@@ -1195,6 +1229,17 @@ def resolve_provider(
"vllm": "custom", "llamacpp": "custom",
"llama.cpp": "custom", "llama-cpp": "custom",
}
+ # Extend with aliases declared in providers/*.py that aren't already mapped.
+ # This keeps providers/ as the single source for new aliases while the
+ # hardcoded dict above remains authoritative for existing ones.
+ try:
+ from providers import list_providers as _lp
+ for _pp in _lp():
+ for _alias in _pp.aliases:
+ if _alias not in _PROVIDER_ALIASES:
+ _PROVIDER_ALIASES[_alias] = _pp.name
+ except Exception:
+ pass
normalized = _PROVIDER_ALIASES.get(normalized, normalized)
if normalized == "openrouter":
diff --git a/hermes_cli/checkpoints.py b/hermes_cli/checkpoints.py
new file mode 100644
index 0000000000000..cac5cd0979f57
--- /dev/null
+++ b/hermes_cli/checkpoints.py
@@ -0,0 +1,244 @@
+"""`hermes checkpoints` CLI subcommand.
+
+Gives users direct visibility and control over the filesystem checkpoint
+store at ``~/.hermes/checkpoints/``. Actions:
+
+ hermes checkpoints # same as `status`
+ hermes checkpoints status # total size, project count, breakdown
+ hermes checkpoints list # per-project checkpoint counts + workdir
+ hermes checkpoints prune [opts] # force a sweep (ignores the 24h marker)
+ hermes checkpoints clear [-f] # nuke the entire base (asks first)
+ hermes checkpoints clear-legacy # delete just the legacy-* archives
+
+Examples::
+
+ hermes checkpoints
+ hermes checkpoints prune --retention-days 3 --max-size-mb 200
+ hermes checkpoints clear -f
+
+None of these require the agent to be running. Safe to call any time.
+"""
+
+from __future__ import annotations
+
+import argparse
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict
+
+
+def _fmt_bytes(n: int) -> str:
+ units = ("B", "KB", "MB", "GB", "TB")
+ size = float(n or 0)
+ for unit in units:
+ if size < 1024 or unit == units[-1]:
+ if unit == "B":
+ return f"{int(size)} {unit}"
+ return f"{size:.1f} {unit}"
+ size /= 1024
+ return f"{size:.1f} TB"
+
+
+def _fmt_ts(ts: Any) -> str:
+ try:
+ return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M")
+ except (TypeError, ValueError):
+ return "—"
+
+
+def _fmt_age(ts: Any) -> str:
+ try:
+ age = time.time() - float(ts)
+ except (TypeError, ValueError):
+ return "—"
+ if age < 0:
+ return "now"
+ if age < 60:
+ return f"{int(age)}s ago"
+ if age < 3600:
+ return f"{int(age / 60)}m ago"
+ if age < 86400:
+ return f"{int(age / 3600)}h ago"
+ return f"{int(age / 86400)}d ago"
+
+
+def cmd_status(args: argparse.Namespace) -> int:
+ from tools.checkpoint_manager import store_status
+
+ info = store_status()
+ base = info["base"]
+ print(f"Checkpoint base: {base}")
+ print(f"Total size: {_fmt_bytes(info['total_size_bytes'])}")
+ print(f" store/ {_fmt_bytes(info['store_size_bytes'])}")
+ print(f" legacy-* {_fmt_bytes(info['legacy_size_bytes'])}")
+ print(f"Projects: {info['project_count']}")
+
+ projects = sorted(
+ info["projects"],
+ key=lambda p: (p.get("last_touch") or 0),
+ reverse=True,
+ )
+ if projects:
+ print()
+ print(f" {'WORKDIR':<60} {'COMMITS':>7} {'LAST TOUCH':>12} STATE")
+ for p in projects[: args.limit if hasattr(args, "limit") and args.limit else 20]:
+ wd = p.get("workdir") or "(unknown)"
+ if len(wd) > 60:
+ wd = "…" + wd[-59:]
+ exists = p.get("exists")
+ state = "live" if exists else "orphan"
+ commits = p.get("commits", 0)
+ last = _fmt_age(p.get("last_touch"))
+ print(f" {wd:<60} {commits:>7} {last:>12} {state}")
+
+ legacy = info.get("legacy_archives", [])
+ if legacy:
+ print()
+ print(f"Legacy archives ({len(legacy)}):")
+ for arch in sorted(legacy, key=lambda a: a.get("mtime", 0), reverse=True):
+ print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}")
+ print()
+ print("Clear with: hermes checkpoints clear-legacy")
+ return 0
+
+
+def cmd_list(args: argparse.Namespace) -> int:
+ # `list` is just a terser status — already covered.
+ return cmd_status(args)
+
+
+def cmd_prune(args: argparse.Namespace) -> int:
+ from tools.checkpoint_manager import prune_checkpoints
+
+ retention_days = args.retention_days
+ max_size_mb = args.max_size_mb
+
+ print("Pruning checkpoint store…")
+ print(f" retention_days: {retention_days}")
+ print(f" delete_orphans: {not args.keep_orphans}")
+ print(f" max_total_size_mb: {max_size_mb}")
+ print()
+
+ result = prune_checkpoints(
+ retention_days=retention_days,
+ delete_orphans=not args.keep_orphans,
+ max_total_size_mb=max_size_mb,
+ )
+ print(f"Scanned: {result['scanned']}")
+ print(f"Deleted orphan: {result['deleted_orphan']}")
+ print(f"Deleted stale: {result['deleted_stale']}")
+ print(f"Errors: {result['errors']}")
+ print(f"Bytes reclaimed: {_fmt_bytes(result['bytes_freed'])}")
+ return 0
+
+
+def _confirm(prompt: str) -> bool:
+ try:
+ resp = input(f"{prompt} [y/N]: ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ print()
+ return False
+ return resp in ("y", "yes")
+
+
+def cmd_clear(args: argparse.Namespace) -> int:
+ from tools.checkpoint_manager import CHECKPOINT_BASE, clear_all, store_status
+
+ info = store_status()
+ if info["total_size_bytes"] == 0 and not Path(CHECKPOINT_BASE).exists():
+ print("Nothing to clear — checkpoint base does not exist.")
+ return 0
+
+ print(f"This will delete the ENTIRE checkpoint base at {info['base']}")
+ print(f" size: {_fmt_bytes(info['total_size_bytes'])}")
+ print(f" projects: {info['project_count']}")
+ print(f" legacy dirs: {len(info.get('legacy_archives', []))}")
+ print()
+ print("All /rollback history for every working directory will be lost.")
+ if not args.force and not _confirm("Proceed?"):
+ print("Aborted.")
+ return 1
+
+ result = clear_all()
+ if result["deleted"]:
+ print(f"Cleared. Reclaimed {_fmt_bytes(result['bytes_freed'])}.")
+ return 0
+ print("Could not clear checkpoint base (see logs).")
+ return 2
+
+
+def cmd_clear_legacy(args: argparse.Namespace) -> int:
+ from tools.checkpoint_manager import clear_legacy, store_status
+
+ info = store_status()
+ legacy = info.get("legacy_archives", [])
+ if not legacy:
+ print("No legacy archives to clear.")
+ return 0
+
+ total = sum(a.get("size_bytes", 0) for a in legacy)
+ print(f"Found {len(legacy)} legacy archive(s), total {_fmt_bytes(total)}:")
+ for arch in legacy:
+ print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}")
+ print()
+ print("Legacy archives hold pre-v2 per-project shadow repos, moved aside")
+ print("during the single-store migration. Delete when you're confident")
+ print("you don't need the old /rollback history.")
+ if not args.force and not _confirm("Delete all legacy archives?"):
+ print("Aborted.")
+ return 1
+
+ result = clear_legacy()
+ print(f"Deleted {result['deleted']} archive(s), reclaimed {_fmt_bytes(result['bytes_freed'])}.")
+ return 0
+
+
+def register_cli(parser: argparse.ArgumentParser) -> None:
+ """Wire subcommands onto the ``hermes checkpoints`` parser."""
+ parser.set_defaults(func=cmd_status) # bare `hermes checkpoints` → status
+ subs = parser.add_subparsers(dest="checkpoints_command", metavar="COMMAND")
+
+ p_status = subs.add_parser(
+ "status",
+ help="Show total size, project count, and per-project breakdown",
+ )
+ p_status.add_argument("--limit", type=int, default=20,
+ help="Max projects to list (default 20)")
+ p_status.set_defaults(func=cmd_status)
+
+ p_list = subs.add_parser(
+ "list",
+ help="Alias for 'status'",
+ )
+ p_list.add_argument("--limit", type=int, default=20)
+ p_list.set_defaults(func=cmd_list)
+
+ p_prune = subs.add_parser(
+ "prune",
+ help="Delete orphan/stale checkpoints and GC the store",
+ )
+ p_prune.add_argument("--retention-days", type=int, default=7,
+ help="Drop projects whose last_touch is older than N days (default 7)")
+ p_prune.add_argument("--max-size-mb", type=int, default=500,
+ help="After orphan/stale prune, drop oldest commits "
+ "per project until total size <= this (default 500)")
+ p_prune.add_argument("--keep-orphans", action="store_true",
+ help="Skip deleting projects whose workdir no longer exists")
+ p_prune.set_defaults(func=cmd_prune)
+
+ p_clear = subs.add_parser(
+ "clear",
+ help="Delete the entire checkpoint base (all /rollback history)",
+ )
+ p_clear.add_argument("-f", "--force", action="store_true",
+ help="Skip confirmation prompt")
+ p_clear.set_defaults(func=cmd_clear)
+
+ p_legacy = subs.add_parser(
+ "clear-legacy",
+ help="Delete only the legacy-/ archives from v1 migration",
+ )
+ p_legacy.add_argument("-f", "--force", action="store_true",
+ help="Skip confirmation prompt")
+ p_legacy.set_defaults(func=cmd_clear_legacy)
diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py
index f6e2521eb01f7..5f9d728252dd5 100644
--- a/hermes_cli/claw.py
+++ b/hermes_cli/claw.py
@@ -235,6 +235,9 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
"""
findings: list[tuple[Path, str]] = []
+ if not source_dir.exists():
+ return findings
+
# Direct state files in the root
for name in ("todo.json", "sessions", "logs"):
candidate = source_dir / name
@@ -243,7 +246,12 @@ def _scan_workspace_state(source_dir: Path) -> list[tuple[Path, str]]:
findings.append((candidate, f"Root {kind}: {name}"))
# State files inside workspace directories
- for child in sorted(source_dir.iterdir()):
+ try:
+ children = sorted(source_dir.iterdir())
+ except OSError:
+ return findings
+
+ for child in children:
if not child.is_dir() or child.name.startswith("."):
continue
# Check for workspace-like subdirectories
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index a45b1963b64e0..5f6e915a7b317 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -550,6 +550,13 @@ def _ensure_hermes_home_managed(home: Path):
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
"record_sessions": False, # Auto-record browser sessions as WebM videos
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
+ # Browser engine for local mode. Passed as ``--engine `` to
+ # agent-browser v0.25.3+.
+ # "auto" — use Chrome (default, don't pass --engine at all)
+ # "lightpanda" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)
+ # "chrome" — explicitly request Chrome
+ # Also settable via AGENT_BROWSER_ENGINE env var.
+ "engine": "auto",
"auto_local_for_private_urls": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud
"cdp_url": "", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome
# CDP supervisor — dialog + frame detection via a persistent WebSocket.
@@ -567,21 +574,39 @@ def _ensure_hermes_home_managed(home: Path):
},
# Filesystem checkpoints — automatic snapshots before destructive file ops.
- # When enabled, the agent takes a snapshot of the working directory once per
- # conversation turn (on first write_file/patch call). Use /rollback to restore.
+ # When enabled, the agent takes a snapshot of the working directory once
+ # per conversation turn (on first write_file/patch call). Use /rollback
+ # to restore.
+ #
+ # Defaults changed in v2 (single shared shadow store, real pruning):
+ # - enabled: True -> False (opt-in; most users never use /rollback)
+ # - max_snapshots: 50 -> 20 (now actually enforced via ref rewrite)
+ # - auto_prune: False -> True (orphans/stale pruned automatically)
+ # Opt in via ``hermes chat --checkpoints`` or set enabled=True here.
"checkpoints": {
- "enabled": True,
- "max_snapshots": 50, # Max checkpoints to keep per directory
- # Auto-maintenance: shadow repos accumulate forever under
- # ~/.hermes/checkpoints/ (one per cd'd working directory). Field
- # reports put the typical offender at 1000+ repos / ~12 GB. When
- # auto_prune is on, hermes sweeps at startup (at most once per
- # min_interval_hours) and deletes:
- # * orphan repos: HERMES_WORKDIR no longer exists on disk
- # * stale repos: newest mtime older than retention_days
- # Opt-in so users who rely on /rollback against long-ago sessions
- # never lose data silently.
- "auto_prune": False,
+ "enabled": False,
+ # Max checkpoints to keep per working directory. Pre-v2 this only
+ # limited the `/rollback` listing; v2 actually rewrites the ref and
+ # garbage-collects older commits.
+ "max_snapshots": 20,
+ # Hard ceiling on total ``~/.hermes/checkpoints/`` size (MB). When
+ # exceeded, the oldest checkpoint per project is dropped in a
+ # round-robin pass until total size falls under the cap.
+ # 0 disables the size cap.
+ "max_total_size_mb": 500,
+ # Skip any single file larger than this when staging a checkpoint.
+ # Prevents accidental snapshotting of datasets, model weights, and
+ # other large generated assets. 0 disables the filter.
+ "max_file_size_mb": 10,
+ # Auto-maintenance: hermes sweeps the checkpoint base at startup
+ # (at most once per ``min_interval_hours``) and:
+ # * deletes project entries whose workdir no longer exists (orphan)
+ # * deletes project entries whose last_touch is older than
+ # ``retention_days``
+ # * GCs the single shared store to reclaim unreachable objects
+ # * enforces ``max_total_size_mb`` across remaining projects
+ # * deletes ``legacy-*`` archives older than ``retention_days``
+ "auto_prune": True,
"retention_days": 7,
"delete_orphans": True,
"min_interval_hours": 24,
@@ -779,9 +804,19 @@ def _ensure_hermes_home_managed(home: Path):
"show_reasoning": False,
"streaming": False,
"final_response_markdown": "strip", # render | strip | raw
+ # Preserve recent classic CLI output across Ctrl+L, /redraw, and
+ # terminal resize full-screen clears. Disable if a terminal emulator
+ # behaves badly with replayed scrollback.
+ "persistent_output": True,
+ "persistent_output_max_lines": 200,
"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, fr, tr, uk. Unknown values fall back to en.
+ "language": "en",
# TUI busy indicator style: kaomoji (default), emoji, unicode (braille
# spinner), or ascii. Live-swappable via `/indicator