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