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 @@ Discord License: MIT Built by Nous Research + 中文

**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 loopAgent-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 automationsBuilt-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. Delegates and parallelizesSpawn 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 laptopSix 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 laptopSeven 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-readyBatch 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 +

+ +# Hermes Agent ☤ + +

+ Documentation + Discord + License: MIT + Built by Nous Research + English +

+ +**由 [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