diff --git a/.env.example b/.env.example
index d35c829d41..98b7aae932 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/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000000..a724dfef89
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,151 @@
+name: Lint (ruff + ty)
+
+# Surface ruff and ty diagnostics as a diff vs the target branch.
+# This check is advisory only ATM it always exits zero and never blocks merge.
+# It posts a Markdown summary to the workflow run and, for pull requests,
+# comments the same summary on the PR.
+
+on:
+ push:
+ branches: [main]
+ paths-ignore:
+ - "**/*.md"
+ - "docs/**"
+ - "website/**"
+ pull_request:
+ branches: [main]
+ paths-ignore:
+ - "**/*.md"
+ - "docs/**"
+ - "website/**"
+
+permissions:
+ contents: read
+ pull-requests: write # needed to post/update PR comments
+
+concurrency:
+ group: lint-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint-diff:
+ name: ruff + ty diff
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ fetch-depth: 0 # need full history for merge-base + worktree
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
+
+ - name: Install ruff + ty
+ run: |
+ uv tool install ruff
+ uv tool install ty
+
+ - name: Determine base ref
+ id: base
+ run: |
+ # For PRs, diff against the merge base with the target branch.
+ # For pushes to main, diff against the previous commit on main.
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
+ BASE_REF="origin/${{ github.base_ref }}"
+ else
+ BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD)
+ BASE_REF="HEAD~1"
+ fi
+ echo "sha=${BASE_SHA}" >> "$GITHUB_OUTPUT"
+ echo "ref=${BASE_REF}" >> "$GITHUB_OUTPUT"
+ echo "Base SHA: ${BASE_SHA}"
+ echo "Base ref: ${BASE_REF}"
+
+ - name: Run ruff + ty on HEAD
+ run: |
+ mkdir -p .lint-reports/head
+ ruff check --output-format json --exit-zero \
+ > .lint-reports/head/ruff.json || true
+ ty check --output-format gitlab --exit-zero \
+ > .lint-reports/head/ty.json || true
+ echo "HEAD ruff: $(wc -c < .lint-reports/head/ruff.json) bytes"
+ echo "HEAD ty: $(wc -c < .lint-reports/head/ty.json) bytes"
+
+ - name: Run ruff + ty on base (via git worktree)
+ run: |
+ mkdir -p .lint-reports/base
+ # Use a worktree so we don't clobber the main checkout. If the basex
+ # SHA is identical to HEAD (e.g. first commit), skip and leave the
+ # base reports empty — the diff script handles missing files.
+ HEAD_SHA=$(git rev-parse HEAD)
+ BASE_SHA="${{ steps.base.outputs.sha }}"
+ if [ "$BASE_SHA" = "$HEAD_SHA" ]; then
+ echo "Base SHA == HEAD SHA, skipping base scan."
+ echo '[]' > .lint-reports/base/ruff.json
+ echo '[]' > .lint-reports/base/ty.json
+ else
+ git worktree add --detach /tmp/lint-base "$BASE_SHA"
+ (
+ cd /tmp/lint-base
+ ruff check --output-format json --exit-zero \
+ > "$GITHUB_WORKSPACE/.lint-reports/base/ruff.json" || true
+ ty check --output-format gitlab --exit-zero \
+ > "$GITHUB_WORKSPACE/.lint-reports/base/ty.json" || true
+ )
+ git worktree remove --force /tmp/lint-base
+ fi
+ echo "base ruff: $(wc -c < .lint-reports/base/ruff.json) bytes"
+ echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes"
+
+ - name: Generate diff summary
+ run: |
+ python scripts/lint_diff.py \
+ --base-ruff .lint-reports/base/ruff.json \
+ --head-ruff .lint-reports/head/ruff.json \
+ --base-ty .lint-reports/base/ty.json \
+ --head-ty .lint-reports/head/ty.json \
+ --base-ref "${{ steps.base.outputs.ref }}" \
+ --head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
+ --output .lint-reports/summary.md
+ cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Upload reports as artifact
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: lint-reports
+ path: .lint-reports/
+ retention-days: 14
+
+ - name: Post / update PR comment
+ if: github.event_name == 'pull_request'
+ uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
+ with:
+ script: |
+ const fs = require('fs');
+ const body = fs.readFileSync('.lint-reports/summary.md', 'utf8');
+ const marker = '';
+ const fullBody = marker + '\n' + body;
+
+ const { data: comments } = await github.rest.issues.listComments({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ });
+ const existing = comments.find(c => c.body && c.body.includes(marker));
+ if (existing) {
+ await github.rest.issues.updateComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ comment_id: existing.id,
+ body: fullBody,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: fullBody,
+ });
+ }
diff --git a/AGENTS.md b/AGENTS.md
index b3bd8032aa..d3ac9d60a7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -44,6 +44,7 @@ hermes-agent/
├── plugins/ # Plugin system (see "Plugins" section below)
│ ├── memory/ # Memory-provider plugins (honcho, mem0, supermemory, ...)
│ ├── context_engine/ # Context-engine plugins
+│ ├── model-providers/ # Inference backend plugins (openrouter, anthropic, gmi, ...)
│ ├── kanban/ # Multi-agent board dispatcher + worker plugin
│ ├── hermes-achievements/ # Gamified achievement tracking
│ ├── observability/ # Metrics / traces / logs plugin
@@ -537,6 +538,31 @@ generic plugin surface (new hook, new ctx method) — never hardcode
plugin-specific logic into core. PR #5295 removed 95 lines of hardcoded
honcho argparse from `main.py` for exactly this reason.
+### Model-provider plugins (`plugins/model-providers//`)
+
+Every inference backend (openrouter, anthropic, gmi, deepseek, nvidia, …)
+ships as a plugin here. Each plugin's `__init__.py` calls
+`providers.register_provider(ProviderProfile(...))` at module load.
+`providers/__init__.py._discover_providers()` is a **lazy, separate
+discovery system** — scanned on first `get_provider_profile()` or
+`list_providers()` call, NOT by the general PluginManager.
+
+Scan order:
+1. Bundled: `/plugins/model-providers//`
+2. User: `$HERMES_HOME/plugins/model-providers//`
+3. Legacy: `/providers/.py` (back-compat)
+
+User plugins of the same name override bundled ones — `register_provider()`
+is last-writer-wins. This lets third parties swap out any built-in
+profile without a repo patch.
+
+The general PluginManager records `kind: model-provider` manifests but does
+NOT import them (would double-instantiate `ProviderProfile`). Plugins
+without an explicit `kind:` get auto-coerced via a source-text heuristic
+(`register_provider` + `ProviderProfile` in `__init__.py`).
+
+Full authoring guide: `website/docs/developer-guide/model-provider-plugin.md`.
+
### Dashboard / context-engine / image-gen plugin directories
`plugins/context_engine/`, `plugins/image_gen/`, `plugins/example-dashboard/`,
diff --git a/README.md b/README.md
index 11390fb2b2..2674cabe77 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 0000000000..ea7fea8dcc
--- /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/agent/auxiliary_client.py b/agent/auxiliary_client.py
index 54a1d63a7f..1e3d39c7ba 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 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = {
"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
@@ -1157,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)
@@ -1173,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
@@ -1184,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)
@@ -1200,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
@@ -1572,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)
@@ -2408,7 +2470,7 @@ def resolve_provider_client(
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":
diff --git a/agent/context_compressor.py b/agent/context_compressor.py
index 20f35fed5f..4212085fc6 100644
--- a/agent/context_compressor.py
+++ b/agent/context_compressor.py
@@ -43,6 +43,9 @@ SUMMARY_PREFIX = (
"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 @@ The user has requested that this compaction PRIORITISE preserving all informatio
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
index 98d7ebce9a..0196439bb4 100644
--- a/agent/i18n.py
+++ b/agent/i18n.py
@@ -25,7 +25,7 @@ Language resolution order:
3. ``display.language`` from config.yaml
4. ``"en"`` (baseline)
-Supported languages: en, zh, ja, de, es. Unknown values fall back to en.
+Supported languages: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.
"""
from __future__ import annotations
@@ -39,7 +39,7 @@ from typing import Any
logger = logging.getLogger(__name__)
-SUPPORTED_LANGUAGES: tuple[str, ...] = ("en", "zh", "ja", "de", "es")
+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"
@@ -50,6 +50,9 @@ _LANGUAGE_ALIASES: dict[str, str] = {
"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]] = {}
diff --git a/agent/memory_manager.py b/agent/memory_manager.py
index ea9b7425fc..1319681d3b 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 @@ _INTERNAL_CONTEXT_RE = re.compile(
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 1c8dbaf682..c9abc48c7a 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 12117f1446..c362a9ec93 100644
--- a/agent/model_metadata.py
+++ b/agent/model_metadata.py
@@ -318,6 +318,17 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"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 d1c8251ed2..b606da7fec 100644
--- a/agent/transports/__init__.py
+++ b/agent/transports/__init__.py
@@ -6,9 +6,16 @@ Usage:
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 9a115e4547..ca29b39ffe 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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
# 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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
# 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 @@ class ChatCompletionsTransport(ProviderTransport):
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 @@ class ChatCompletionsTransport(ProviderTransport):
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 68a807b47c..f0da1eb6f8 100644
--- a/agent/transports/types.py
+++ b/agent/transports/types.py
@@ -12,7 +12,7 @@ from __future__ import annotations
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 @@ class ToolCall:
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 @@ class NormalizedResponse:
# 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/apps/dashboard/src/themes/presets.ts b/apps/dashboard/src/themes/presets.ts
index 956bb68c21..7baf6319db 100644
--- a/apps/dashboard/src/themes/presets.ts
+++ b/apps/dashboard/src/themes/presets.ts
@@ -183,8 +183,30 @@ export const roseTheme: DashboardTheme = {
},
};
+/**
+ * Same look as ``defaultTheme`` but with a larger root font size, looser
+ * line-height, and ``spacious`` density so every rem-based size in the
+ * dashboard scales up. For users who find the default 15px UI too dense.
+ */
+export const defaultLargeTheme: DashboardTheme = {
+ name: "default-large",
+ label: "Hermes Teal (Large)",
+ description: "Hermes Teal with bigger fonts and roomier spacing",
+ palette: defaultTheme.palette,
+ typography: {
+ ...DEFAULT_TYPOGRAPHY,
+ baseSize: "18px",
+ lineHeight: "1.65",
+ },
+ layout: {
+ ...DEFAULT_LAYOUT,
+ density: "spacious",
+ },
+};
+
export const BUILTIN_THEMES: Record = {
default: defaultTheme,
+ "default-large": defaultLargeTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,
diff --git a/cli.py b/cli.py
index 0292a2b943..31ba863f9f 100644
--- a/cli.py
+++ b/cli.py
@@ -27,6 +27,7 @@ import tempfile
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:
@@ -1450,7 +1550,21 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
except Exception:
resolved = path
- if not resolved.exists() or not resolved.is_file():
+ # Path.exists() / is_file() invoke os.stat(), which raises OSError when
+ # the candidate string is structurally invalid as a path — most commonly
+ # ENAMETOOLONG (errno 63 on macOS, errno 36 on Linux) when the input
+ # exceeds NAME_MAX (typically 255 bytes). This bites pasted slash
+ # commands like `/goal ` because `_detect_file_drop()`'s
+ # `starts_like_path` prefilter accepts any input starting with `/`,
+ # then this resolver tries to stat it before short-circuiting on the
+ # slash-command path. Without this guard the OSError propagates up to
+ # the process_loop catch-all in _interactive_loop and the user input
+ # is silently lost (the warning ends up in agent.log but the user sees
+ # nothing — the prompt just hangs).
+ try:
+ if not resolved.exists() or not resolved.is_file():
+ return None
+ except OSError:
return None
return resolved
@@ -2035,6 +2149,10 @@ class HermesCLI:
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).
@@ -2170,7 +2288,9 @@ class HermesCLI:
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
@@ -2312,6 +2432,9 @@ class HermesCLI:
# 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] = {}
@@ -2319,6 +2442,8 @@ class HermesCLI:
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
@@ -2342,11 +2467,25 @@ class HermesCLI:
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
@@ -2355,10 +2494,57 @@ class HermesCLI:
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:
@@ -2576,9 +2762,12 @@ class HermesCLI:
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}"
@@ -3673,6 +3862,8 @@ class HermesCLI:
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,
@@ -4030,7 +4221,26 @@ class HermesCLI:
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.
@@ -6389,6 +6599,7 @@ class HermesCLI:
_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
@@ -7119,7 +7330,20 @@ class HermesCLI:
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")
@@ -11643,23 +11867,7 @@ class HermesCLI:
_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
@@ -11850,8 +12058,22 @@ class HermesCLI:
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/environments/README.md b/environments/README.md
index 9677fdb70e..3936e1f35b 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/platforms/api_server.py b/gateway/platforms/api_server.py
index b460754331..ae77100f6a 100644
--- a/gateway/platforms/api_server.py
+++ b/gateway/platforms/api_server.py
@@ -56,7 +56,7 @@ logger = logging.getLogger(__name__)
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
@@ -1349,6 +1349,22 @@ class APIServerAdapter(BasePlatformAdapter):
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
@@ -1669,20 +1685,54 @@ class APIServerAdapter(BasePlatformAdapter):
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:
@@ -1707,11 +1757,21 @@ class APIServerAdapter(BasePlatformAdapter):
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
@@ -1762,6 +1822,31 @@ class APIServerAdapter(BasePlatformAdapter):
# 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",
@@ -1852,6 +1937,30 @@ class APIServerAdapter(BasePlatformAdapter):
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
@@ -2935,7 +3044,7 @@ class APIServerAdapter(BasePlatformAdapter):
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/discord.py b/gateway/platforms/discord.py
index ecfa38c723..e30c4478ef 100644
--- a/gateway/platforms/discord.py
+++ b/gateway/platforms/discord.py
@@ -2654,9 +2654,14 @@ class DiscordAdapter(BasePlatformAdapter):
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 e1528b9bca..e1c1a731c6 100644
--- a/gateway/platforms/feishu.py
+++ b/gateway/platforms/feishu.py
@@ -4089,15 +4089,18 @@ class FeishuAdapter(BasePlatformAdapter):
reply_to: Optional[str],
metadata: Optional[Dict[str, Any]],
) -> Any:
+ effective_reply_to = reply_to
+ if not effective_reply_to and metadata and metadata.get("thread_id"):
+ effective_reply_to = metadata.get("reply_to_message_id")
reply_in_thread = bool((metadata or {}).get("thread_id"))
- if reply_to:
+ if effective_reply_to:
body = self._build_reply_message_body(
content=payload,
msg_type=msg_type,
reply_in_thread=reply_in_thread,
uuid_value=str(uuid.uuid4()),
)
- request = self._build_reply_message_request(reply_to, body)
+ request = self._build_reply_message_request(effective_reply_to, body)
return await asyncio.to_thread(self._client.im.v1.message.reply, request)
body = self._build_create_message_body(
diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py
index ad5ed66920..83e8173687 100644
--- a/gateway/platforms/telegram.py
+++ b/gateway/platforms/telegram.py
@@ -353,7 +353,10 @@ class TelegramAdapter(BasePlatformAdapter):
@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 @@ class TelegramAdapter(BasePlatformAdapter):
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 ed3bd47b96..1c125d9aff 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -939,6 +939,52 @@ import weakref as _weakref
_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.
@@ -1114,6 +1160,7 @@ class GatewayRunner:
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)
@@ -3577,6 +3624,11 @@ class GatewayRunner:
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.
@@ -3605,7 +3657,7 @@ class GatewayRunner:
_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
@@ -6271,6 +6323,10 @@ class GatewayRunner:
_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:
@@ -6439,33 +6495,11 @@ class GatewayRunner:
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.
@@ -9476,6 +9510,9 @@ class GatewayRunner:
_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:
@@ -12892,12 +12929,19 @@ class GatewayRunner:
# - Slack DM threading needs event_message_id fallback (reply thread)
# - Telegram uses message_thread_id only for forum topics; passing a
# normal DM/group message id as thread_id causes send failures
+ # - Feishu only honors reply_in_thread when sending a reply, so topic
+ # progress uses the triggering event message as the reply target
# - Other platforms should use explicit source.thread_id only
if source.platform == Platform.SLACK:
_progress_thread_id = source.thread_id or event_message_id
else:
_progress_thread_id = source.thread_id
_progress_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
+ _progress_reply_to = (
+ event_message_id
+ if source.platform == Platform.FEISHU and source.thread_id and event_message_id
+ else None
+ )
async def send_progress_messages():
if not progress_queue:
@@ -13011,15 +13055,30 @@ class GatewayRunner:
adapter.name,
)
can_edit = False
- await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata)
+ await adapter.send(
+ chat_id=source.chat_id,
+ content=msg,
+ reply_to=_progress_reply_to,
+ metadata=_progress_metadata,
+ )
else:
if can_edit:
# First tool: send all accumulated text as new message
full_text = "\n".join(progress_lines)
- result = await adapter.send(chat_id=source.chat_id, content=full_text, metadata=_progress_metadata)
+ result = await adapter.send(
+ chat_id=source.chat_id,
+ content=full_text,
+ reply_to=_progress_reply_to,
+ metadata=_progress_metadata,
+ )
else:
# Editing unsupported: send just this line
- result = await adapter.send(chat_id=source.chat_id, content=msg, metadata=_progress_metadata)
+ result = await adapter.send(
+ chat_id=source.chat_id,
+ content=msg,
+ reply_to=_progress_reply_to,
+ metadata=_progress_metadata,
+ )
if result.success and result.message_id:
progress_msg_id = result.message_id
@@ -13119,7 +13178,17 @@ class GatewayRunner:
# Bridge sync status_callback → async adapter.send for context pressure
_status_adapter = self.adapters.get(source.platform)
_status_chat_id = source.chat_id
- _status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
+ if source.platform == Platform.FEISHU and source.thread_id and event_message_id:
+ # Feishu topics only keep messages inside the topic when they are
+ # sent via the reply API with reply_in_thread=true. Status/interim,
+ # approval, and stream-consumer paths usually only receive metadata,
+ # so carry the triggering message id as a Feishu-specific fallback.
+ _status_thread_metadata: Optional[Dict[str, Any]] = {
+ "thread_id": _progress_thread_id,
+ "reply_to_message_id": event_message_id,
+ }
+ else:
+ _status_thread_metadata = {"thread_id": _progress_thread_id} if _progress_thread_id else None
def _status_callback_sync(event_type: str, message: str) -> None:
if not _status_adapter or not _run_still_current():
@@ -13263,7 +13332,7 @@ class GatewayRunner:
adapter=_adapter,
chat_id=source.chat_id,
config=_consumer_cfg,
- metadata={"thread_id": _progress_thread_id} if _progress_thread_id else None,
+ metadata=_status_thread_metadata,
on_new_message=(
(lambda: progress_queue.put(("__reset__",)))
if progress_queue is not None
diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py
index 5b63d41eb1..48abb1fa12 100644
--- a/hermes_cli/auth.py
+++ b/hermes_cli/auth.py
@@ -416,6 +416,40 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
),
}
+# Auto-extend PROVIDER_REGISTRY with any api-key provider registered in
+# providers/ that is not already declared above. New providers only need a
+# plugins/model-providers// plugin — 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 plugins/model-providers// 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 0000000000..cac5cd0979
--- /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/config.py b/hermes_cli/config.py
index aa978b60dc..c46b476767 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -544,12 +544,25 @@ DEFAULT_CONFIG = {
# via TERMINAL_LOCAL_PERSISTENT env var.
"persistent_shell": True,
},
-
+
+ "web": {
+ "backend": "", # shared fallback — applies to both search and extract
+ "search_backend": "", # per-capability override for web_search (e.g. "searxng")
+ "extract_backend": "", # per-capability override for web_extract (e.g. "native")
+ },
+
"browser": {
"inactivity_timeout": 120,
"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 +580,39 @@ DEFAULT_CONFIG = {
},
# 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,
@@ -778,13 +809,18 @@ DEFAULT_CONFIG = {
"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. Unknown values fall back to en.
+ # 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