Hermes 架构全解Hermes Architecture, in Full第 7 章 · 14 章Chapter 7 of 14
全文目录Contents
  1. 0 · 项目全景与代码地图
    1. 0.1 这个软件是什么
    2. 0.2 目录地图
    3. 0.3 从这张地图能读出的三件事
    4. 0.4 一条消息的完整旅程
    5. 0.5 全文章节索引
  2. 1 · 网关层
    1. 1.1 它解决什么问题
    2. 1.2 平台适配器抽象基类
    3. 1.3 消息事件的归一化
    4. 1.4 网关主循环里的那些防御机制
    5. 1.5 状态消息与进度反馈
    6. 1.6 中断与恢复
    7. 1.7 网关内置钩子
    8. 1.8 这一层的代价
  3. 2 · 身份与会话路由
    1. 2.1 Profile:一个进程承载多个身份
    2. 2.2 四级路由与「具体度」打分
    3. 2.3 显式路由被拒绝的情况
    4. 2.4 SOUL.md:人格文件
    5. 2.5 会话路由:跨平台的连续性
    6. 2.6 智能体实例的缓存
  4. 3 · 主循环 ★
    1. 3.1 循环入口:三重预算闸门
    2. 3.2 每轮的准备工作:build_turn_context
    3. 3.3 中途插话:/steer
    4. 3.4 墙上时钟预算的收尾提醒
    5. 3.5 中断检查
    6. 3.6 步骤回调:给网关的观测点
    7. 3.7 循环里的其他状态
    8. 3.8 循环退出原因
    9. 3.9 一个可选的旁路:Codex 应用服务运行时
  5. 4 · 工具系统
    1. 4.1 最重要的设计:实现与投放分离
    2. 4.2 工具集的组合与解析
    3. 4.3 工具的中心分发
    4. 4.4 参数强制矫正层
    5. 4.5 工具错误消息的净化
    6. 4.6 工具结果的观测与钩子
    7. 4.7 委派上下文识别
    8. 4.8 工具定义的缓存
  6. 5 · 审批与安全红线 ★
    1. 5.1 整体结构
    2. 5.2 12 条硬红线
    3. 5.3 真正的难点:区分「命令」和「数据」
    4. 5.4 引号遮蔽:但要给「真会执行的部分」留后门
    5. 5.5 引号不能成为绕过手段
    6. 5.6 敏感路径与写入目标
    7. 5.7 sudo 标准输入守卫
    8. 5.8 性能:预编译的理由
    9. 5.9 被拦截命令的留存
    10. 5.10 智能审批:可选的模型判断
    11. 5.11 上下文感知:不同场景不同策略
    12. 5.12 这一层的定位:最外面也最薄
  7. 6 · 执行环境
    1. 6.1 七种可选环境
    2. 6.2 必须诚实说明的一件事
    3. 6.3 抽象基类里的共用逻辑
    4. 6.4 文件同步
    5. 6.5 环境的选择时机
  8. 7 · 上下文引擎 ★
    1. 7.1 它定义的是什么
    2. 7.2 生命周期
    3. 7.3 三个必须实现的方法
    4. 7.4 最精辟的设计:select 和 compress 是两个正交动词
    5. 7.5 后置观察钩子
    6. 7.6 其他可选钩子
    7. 7.7 默认参数值
    8. 7.8 用户可见状态的可控性
    9. 7.9 内置实现的体量对比
  9. 8 · 记忆系统 ★
    1. 8.1 记忆提供者接口
    2. 8.2 生命周期与钩子
    3. 8.3 接口的版本化契约
    4. 8.4 琐碎提问过滤器
    5. 8.5 记忆使用指示器
    6. 8.6 内置的全息记忆
    7. 8.7 存储层与信任分
    8. 8.8 SQLite 状态层
    9. 8.9 三种记忆的分工总结
  10. 9 · 插件系统
    1. 9.1 三个发现来源
    2. 9.2 插件能提供什么
    3. 9.3 最重要的设计:区分「可叠加能力」与「互斥策略」
    4. 9.4 插件的存储
    5. 9.5 插件与工具集的联动
    6. 9.6 插件钩子
    7. 9.7 MCP:另一条扩展路径
    8. 9.8 这套扩展体系的整体形状
  11. 10 · 委派与多智能体
    1. 10.1 为什么需要委派
    2. 10.2 深度限制:只允许一层
    3. 10.3 并发限制
    4. 10.4 子智能体的工具限制
    5. 10.5 子智能体的审批策略
    6. 10.6 运行中的控制
    7. 10.7 亲缘关系检查
    8. 10.8 看板:智能体之间的协作
    9. 10.9 委派系统为什么有 5,071 行
  12. 11 · 模型供应商与凭据池
    1. 11.1 为什么不能只支持一家
    2. 11.2 供应商适配器清单
    3. 11.3 凭据池:核心机制
    4. 11.4 PooledCredential:单个凭据的状态
    5. 11.5 「持久化」为什么必要
    6. 11.6 与其他机制的配合
    7. 11.7 供应商抽象要抽象到哪一层
  13. 12 · 定时任务
    1. 12.1 「cron」是什么
    2. 12.2 定时智能体的场景
    3. 12.3 最重要的一个类:CronPromptInjectionBlocked
    4. 12.4 定时任务的工具集收窄
    5. 12.5 失败处理
    6. 12.6 防重复执行
    7. 12.7 为什么 scheduler.py 有 367 KB
    8. 12.8 定时任务在整个架构里的位置
  14. 13 · 技能系统
    1. 13.1 技能是什么
    2. 13.2 前置元数据逐字段解释
    3. 13.3 渐进式披露:技能系统的核心机制
    4. 13.4 技能的支撑设施
    5. 13.5 技能 vs 工具 vs 插件
    6. 13.6 技能系统的一个隐含设计:可组合
    7. 13.7 15 个技能类别
    8. 13.8 全文回顾:Hermes 的整体形状
  1. 0 · The Project at a Glance, and a Map of the Code
    1. 0.1 What This Software Is
    2. 0.2 Directory Map
    3. 0.3 Three Things You Can Read Off This Map
    4. 0.4 The Full Journey of One Message
    5. 0.5 Chapter Index
  2. 1 · The Gateway Layer
    1. 1.1 The Problem It Solves
    2. 1.2 The Platform Adapter Abstract Base Class
    3. 1.3 Normalizing Message Events
    4. 1.4 The Defensive Mechanisms in the Gateway Main Loop
    5. 1.5 Status Messages and Progress Feedback
    6. 1.6 Interruption and Recovery
    7. 1.7 Built-in Gateway Hooks
    8. 1.8 What This Layer Costs
  3. 2 · Identity and Session Routing
    1. 2.1 Profiles: One Process, Many Identities
    2. 2.2 Four-Level Routing and the “Specificity” Score
    3. 2.3 When an Explicit Route Is Rejected
    4. 2.4 SOUL.md: The Persona File
    5. 2.5 Session Routing: Continuity Across Platforms
    6. 2.6 Caching Agent Instances
  4. 3 · The Main Loop ★
    1. 3.1 The Loop Entry: A Triple Budget Gate
    2. 3.2 Per-Turn Preparation: build_turn_context
    3. 3.3 Mid-Turn Interjection: /steer
    4. 3.4 The Wall-Clock Budget Wrap-Up Reminder
    5. 3.5 The Interrupt Check
    6. 3.6 The Step Callback: An Observation Point for the Gateway
    7. 3.7 Other State Inside the Loop
    8. 3.8 Why the Loop Exited
    9. 3.9 An Optional Bypass: The Codex App Server Runtime
  5. 4 · The Tool System
    1. 4.1 The Most Important Design Decision: Implementation Separated from Exposure
    2. 4.2 Composing and Resolving Toolsets
    3. 4.3 Central Tool Dispatch
    4. 4.4 The Argument Coercion Layer
    5. 4.5 Sanitizing Tool Error Messages
    6. 4.6 Observing Tool Results, and Hooks
    7. 4.7 Recognizing a Delegation Context
    8. 4.8 Caching Tool Definitions
  6. 5 · Approval and Safety Red Lines ★
    1. 5.1 The Overall Structure
    2. 5.2 The 12 Hard Red Lines
    3. 5.3 The Real Difficulty: Telling “Command” from “Data”
    4. 5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”
    5. 5.5 Quoting Is Not a Bypass
    6. 5.6 Sensitive Paths and Write Targets
    7. 5.7 The sudo Stdin Guard
    8. 5.8 Performance: Why Precompile
    9. 5.9 Retaining Blocked Commands
    10. 5.10 Smart Approval: An Optional Model Judgment
    11. 5.11 Context Awareness: Different Situations, Different Policies
    12. 5.12 Where This Layer Sits: Outermost, and Thinnest
  7. 6 · Execution Environments
    1. 6.1 Seven Environments to Choose From
    2. 6.2 One Thing That Has to Be Said Plainly
    3. 6.3 Shared Logic in the Abstract Base Class
    4. 6.4 File Synchronization
    5. 6.5 When the Environment Is Chosen
  8. 7 · The Context Engine ★
    1. 7.1 What It Defines
    2. 7.2 Lifecycle
    3. 7.3 The Three Methods You Must Implement
    4. 7.4 The Sharpest Design Decision: select and compress Are Two Orthogonal Verbs
    5. 7.5 The Post-Turn Observation Hook
    6. 7.6 Other Optional Hooks
    7. 7.7 Default Parameter Values
    8. 7.8 Control Over User-Visible Status
    9. 7.9 Size Comparison of the Built-in Implementation
  9. 8 · The Memory System ★
    1. 8.1 The Memory Provider Interface
    2. 8.2 Lifecycle and Hooks
    3. 8.3 The Interface's Versioned Contract
    4. 8.4 The Trivial-Prompt Filter
    5. 8.5 The Memory-Usage Indicator
    6. 8.6 The Built-in Holographic Memory
    7. 8.7 The Storage Layer and Trust Scores
    8. 8.8 The SQLite State Layer
    9. 8.9 Summing Up the Division of Labor Among Three Kinds of Memory
  10. 9 · The Plugin System
    1. 9.1 Three Discovery Sources
    2. 9.2 What a Plugin Can Provide
    3. 9.3 The Most Important Design Decision: “Stackable Capabilities” vs. “Mutually Exclusive Strategies”
    4. 9.4 Plugin Storage
    5. 9.5 How Plugins Tie into Toolsets
    6. 9.6 Plugin Hooks
    7. 9.7 MCP: The Other Extension Path
    8. 9.8 The Overall Shape of This Extension System
  11. 10 · Delegation and Multi-Agent
    1. 10.1 Why Delegation Is Needed
    2. 10.2 The Depth Limit: One Level Only
    3. 10.3 Concurrency Limits
    4. 10.4 Tool Restrictions on Subagents
    5. 10.5 Approval Policy for Subagents
    6. 10.6 In-Flight Control
    7. 10.7 The Lineage Check
    8. 10.8 Kanban: Collaboration Between Agents
    9. 10.9 Why the Delegation System Is 5,071 Lines
  12. 11 · Model Providers and the Credential Pool
    1. 11.1 Why You Can't Support Just One
    2. 11.2 The Provider Adapter Roster
    3. 11.3 The Credential Pool: The Core Mechanism
    4. 11.4 PooledCredential: The State of a Single Credential
    5. 11.5 Why “Persistent” Is Necessary
    6. 11.6 How It Works with Other Mechanisms
    7. 11.7 How Deep the Provider Abstraction Should Go
  13. 12 · Scheduled Tasks (Cron)
    1. 12.1 What “cron” Is
    2. 12.2 Scenarios for Scheduled Agents
    3. 12.3 The Single Most Important Class: CronPromptInjectionBlocked
    4. 12.4 Narrowing the Toolset for Scheduled Tasks
    5. 12.5 Failure Handling
    6. 12.6 Preventing Duplicate Runs
    7. 12.7 Why scheduler.py Is 367 KB
    8. 12.8 Where Scheduled Tasks Sit in the Overall Architecture
  14. 13 · The Skill System
    1. 13.1 What a Skill Is
    2. 13.2 The Front Matter, Field by Field
    3. 13.3 Progressive Disclosure: The Core Mechanism of the Skill System
    4. 13.4 The Infrastructure Around Skills
    5. 13.5 Skills vs. Tools vs. Plugins
    6. 13.6 An Implicit Design in the Skill System: Composability
    7. 13.7 The 15 Skill Categories
    8. 13.8 Looking Back Across the Book: The Overall Shape of Hermes

7 · 上下文引擎 ★

agent/context_engine.py490 行。这个文件不做任何实际工作 —— 它只定义一份契约。但它是 Hermes 架构立场最集中的体现。

7.1 它定义的是什么

文件开头的说明:「A context engine controls how conversation context is managed when approaching the model's token limit. The built-in ContextCompressor is the default implementation. Third-party engines (e.g. LCM) can replace it via the plugin system or by being placed in the plugins/context_engine/<name>/ directory. Selection is config-driven: context.engine in config.yaml. Default is "compressor". Only one engine is active.

译:上下文引擎控制「当接近模型 token 上限时,对话上下文如何被管理」。内置的 ContextCompressor 是默认实现。第三方引擎可以通过插件系统、或者放在 plugins/context_engine/<名字>/ 目录下来替换它。选择由配置驱动:config.yaml 里的 context.engine。默认是 "compressor"。同一时刻只有一个引擎生效。

「只有一个引擎生效」这句话很重要 —— 它把上下文引擎归类为「互斥策略」而不是「可叠加能力」。第 9 章会讲这个区分为什么必须在插件系统层面就做出来。

7.2 生命周期

"""
Lifecycle:
  1. Engine is instantiated and registered (plugin register() or default)
  2. on_session_start() called when a conversation begins
  3. update_from_response() called after each API response with usage data
  4. should_compress() checked after each turn
  5. compress() called when should_compress() returns True
  6. on_session_end() called at real session boundaries (CLI exit, /reset,
     gateway session expiry) — NOT per-turn
"""

注意第 6 步那句「NOT per-turn」(不是每轮)。这是一个容易搞错的地方:

"会话结束"的正确定义是什么? ✗ 每一轮对话结束 ← 错。用户还在,对话还在继续 ✗ 网关进程重启 ← 错。会话可以恢复 ✓ 用户退出命令行 ✓ 用户执行 /reset ✓ 网关判定这个会话过期了 ★ 如果引擎在每轮结束时都以为"会话结束了", 它会反复刷盘、反复关连接、反复丢弃缓存 —— 性能和正确性都崩。

7.3 三个必须实现的方法

class ContextEngine(ABC):

    @property
    @abstractmethod
    def name(self) -> str:
        """Short identifier (e.g. 'compressor', 'lcm')."""

    @abstractmethod
    def update_from_response(self, usage: Dict[str, Any]) -> None:
        """Update tracked token usage from an API response."""

    @abstractmethod
    def should_compress(self, prompt_tokens: int = None) -> bool:
        """Return True if compaction should fire this turn."""

    @abstractmethod
    def compress(self, messages, current_tokens=None, focus_topic=None,
                 force=False, memory_context="") -> List[Dict[str, Any]]:
        """Compact the message list and return the new message list."""

用量字典的向后兼容设计

「Called after every LLM call with a normalized usage dict. The legacy keys prompt_tokens, completion_tokens, and total_tokens are always present. Newer hosts also include canonical buckets: input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, and reasoning_tokens. Engines should treat those fields as optional for compatibility with older hosts.

译:每次模型调用后传入一个归一化的用量字典。旧的三个键永远存在。较新的宿主还会包含标准分桶……引擎应该把这些字段当作可选的,以兼容旧宿主。

这是一份「接口演进」的教科书示范:旧字段永不删除(保证老引擎能跑),新字段可选(保证新引擎能用上更细的数据),而且在文档里明确写出兼容性契约

compress 的四个可选参数各有用途

参数用途
focus_topic来自用户手动执行 /compress <主题>。支持引导式压缩的引擎应该优先保留和这个主题相关的信息。不支持的引擎可以直接忽略
force用户主动要求的压缩是否应该绕过引擎自己的冷却期。没有冷却机制的引擎可以忽略
memory_context压缩前记忆提供者返回的文本。做摘要的引擎应该把非空内容纳入交接提示词
current_tokens当前 token 数(如果宿主知道的话)

而且文档写明了参数演进的处理方式:「较老的引擎可以省略这个参数;宿主会按签名过滤掉不支持的可选参数。」—— 宿主用反射检查引擎方法的签名,只传它接受的参数。这样新增参数不会破坏老引擎。

7.4 最精辟的设计:select 和 compress 是两个正交动词

def select_context(
    self,
    request_messages: List[Dict[str, Any]],
    *,
    conversation_messages: List[Dict[str, Any]] = None,
    incoming_message: Dict[str, Any] = None,
    budget_tokens: int = 0,
) -> List[Dict[str, Any]]:
    """Optionally choose/replace the context for THIS request, pre-generation."""
    return None      # 默认空操作
两个动词的定义

compress():上下文太长了 → 把它变短。
select_context():这一轮属于另一个上下文 → 换那一个来用。

源码原文:「This lets an engine select which context enters the prompt (retrieval, topic routing, role/branch switching) rather than shrink context that is already there. The two verbs are orthogonal.」

译:这让引擎可以「选择」哪些上下文进入提示词(检索、话题路由、角色/分支切换),而不是「缩小」已经在那里的上下文。这两个动词是正交的。

这个接口是被真实的误用逼出来的

「Without this hook, engines that need per-turn access to the message list have to force should_compress() to return True so that compress() is invoked every turn purely as a callback — which conflates selection with compression and degrades behaviour when the engine's backend is unavailable.」

译:没有这个钩子的话,那些需要每轮都拿到消息列表的引擎,只能强迫 should_compress() 永远返回 True,从而让 compress() 每轮都被调用、纯粹当成一个回调用。这就把「选择」和「压缩」混为一谈了,而且当引擎的后端服务不可用时行为会变得很糟。

还原这个故事:

① 有第三方做了一个基于检索的上下文引擎 它想每一轮都根据当前问题去检索最相关的历史片段 ② 但接口只提供了 should_compress() 和 compress() 没有"每轮回调"这个位置 ③ 于是它只能骗系统: should_compress() → 永远返回 True compress() → 当成"每轮回调",做检索然后返回选好的消息 ④ 后果:一旦检索后端挂了,compress() 抛异常 而系统以为"压缩失败了,上下文还是太长" → 进入错误的恢复流程 (见第 3.7 节的压缩尝试计数器) ⑤ Hermes 加了一个正经的每轮钩子 select_context()

关键约束:只作用于本次请求

「The returned list is request-only: it replaces the messages sent to the provider for this single call and MUST NOT be treated as persisted transcript state. The conversation history in the session DB is left untouched, so nothing leaks across turns.」

译:返回的列表只作用于本次请求:它替换这一次调用发给供应商的消息,绝不能被当成持久化的记录状态。会话数据库里的对话历史不受影响,所以不会跨轮次泄露任何东西。

这个约束把风险控制住了:即使引擎选错了上下文,损失也只是这一轮的回答质量,不会污染永久记录。

缓存契约写得比什么都清楚

「Ordering / cache contract: the host runs this hook before prompt cache-control and before every request sanitizer (orphaned-tool cleanup, thinking-only/role normalization, whitespace/JSON normalization). So (a) whatever the hook returns still passes through the same validation as any request — a malformed replacement cannot reach the provider — and (b) prompt-cache stability (an AGENTS.md invariant) is preserved: the default no-op leaves the request byte-identical, so cache behaviour is unchanged for the built-in compressor and any non-implementing engine.」

译:顺序与缓存契约:宿主在「提示词缓存控制」之前、以及在「每一个请求净化器」之前运行这个钩子(净化器包括:孤儿工具清理、纯思考块与角色规范化、空白字符与 JSON 规范化)。因此:(a) 钩子返回什么,都仍要经过和普通请求一样的全部校验 —— 格式错误的替换结果无法抵达供应商;(b) 提示词缓存的稳定性得到保持:默认的空操作让请求保持字节级完全相同。

翻译成设计原则:插件钩子必须跑在所有校验器之前。

这样插件返回的垃圾数据也过不了校验,不会污染到模型供应商。这是「不完全信任插件」的正确姿势 —— 你给了第三方替换整个上下文的权力,但你保留了最终的把关权。

而且注释还提到这是「AGENTS.md 里的一条不变式」—— 说明「提示词缓存稳定性」在这个项目里是一条被明文记录的、跨模块的架构约束。

7.5 后置观察钩子

def on_turn_complete(self, messages, usage: Dict[str, Any] = None, **kwargs) -> None:
    """Observe a finished user turn (post-turn ingestion / observation)."""
    return None

这是 select_context() 的对称面:选择发生在请求之前,观察发生在轮次之后。

「It lets an engine ingest, index, summarize, or update routing / topic / session state from what actually happened — so the next select_context() can act on it. …Together the two hooks remove the need to abuse should_compress() / compress() as a generic per-turn callback.」

译:它让引擎可以从「实际发生了什么」中摄取、索引、总结,或更新路由/话题/会话状态 —— 这样下一次 select_context() 就能用上。……这两个钩子合起来,消除了滥用 should_compress()/compress() 当作通用每轮回调的必要。

一段诚实的覆盖范围说明

「Coverage: this fires from the normal finalization seam. Some abnormal early-return paths in the loop (e.g. a content-policy block or a provider terminal failure) persist and return without routing through finalization, and therefore do not currently emit this hook. Treat it as a best-effort post-turn observation for completed turns, not a guaranteed callback for every possible early exit; unifying all terminal paths behind one finalization seam is a separate follow-up.」

译:覆盖范围:这个钩子从正常的收尾接缝处触发。循环里某些异常的提前返回路径(比如内容策略拦截、或供应商终端失败)会直接持久化并返回,不经过收尾流程,因此目前不会发出这个钩子。请把它当作「已完成轮次的尽力而为的后置观察」,而不是「每一种可能的提前退出都保证回调」;把所有终端路径统一到一个收尾接缝之后,是一个独立的后续工作。

这段注释值得单独表扬。它做了三件很少见的事:
· 明确说出接口的不完整之处(有些路径不会触发)
· 说明具体是哪些路径(内容策略拦截、供应商终端失败)
· 说明这是已知的技术债并且有计划(统一收尾接缝是独立的后续工作)

对第三方实现者来说,这比一句「本方法会在每轮结束时调用」有用得多 —— 后者会让人写出依赖「保证被调用」的代码,然后在生产环境里遇到诡异的状态不一致。

7.6 其他可选钩子

# 不调模型的确定性裁剪
def prune_tool_results_only(self, messages, current_tokens=None) -> tuple[List, int]:
    return messages, 0                     # 默认安全空操作

# 便宜的预检
def should_compress_preflight(self, messages) -> bool:
    return False
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
    return False

# 手动 /compress 的预检守卫
def has_content_to_compress(self, messages) -> bool:
    return True

# 会话生命周期
def on_session_start(self, session_id: str, **kwargs) -> None
def on_session_end(self, session_id: str, messages) -> None
def on_session_reset(self) -> None

# ★ 引擎可以自带工具
def get_tool_schemas(self) -> List[Dict[str, Any]]:
    return []
def handle_tool_call(self, name: str, args: Dict[str, Any], **kwargs) -> str

# 状态显示
def get_status(self) -> Dict[str, Any]

# 模型切换
def update_model(self, model, context_length, base_url="", api_key="",
                 provider="", api_mode="") -> None

不调模型的裁剪:为什么要单独一个钩子

「Runs on a low, cost-oriented trigger independent of should_compress so large-window engines can reclaim re-sent tool output long before full compaction would fire. …Default is a safe no-op… so the agent loop's post-tool-call prune path never raises AttributeError on them.」

译:它跑在一个「低阈值、成本导向」的触发器上,独立于 should_compress —— 这样大窗口引擎可以在完整压缩触发之前很久,就回收那些被反复重发的工具输出。……默认是一个安全的空操作……这样智能体循环里那条「工具调用后裁剪」的路径永远不会在它们身上抛属性不存在错误。

关键在于「独立的低触发器」。使用 100 万 token 窗口的模型时,should_compress 可能几十轮都不触发 —— 但那些旧的工具输出每一轮都在被重发、每一轮都在花钱。所以需要一个成本导向的、和「会不会超窗口」无关的裁剪触发器。

引擎可以自带工具

get_tool_schemas() / handle_tool_call() 让引擎向模型暴露自己的工具。文档举的例子是:LCM 引擎可以提供 lcm_greplcm_describelcm_expand 这些工具 —— 也就是让模型能主动去搜索、描述、展开被折叠的上下文。

这是一个很有想象力的设计:上下文管理从「后台自动做的事」变成了「模型可以主动参与的事」。

模型可以说「我记得之前讨论过数据库设计,帮我把那段展开」—— 而不是被动接受一个已经压缩好的摘要。

7.7 默认参数值

threshold_percent: float = 0.75      # 用到窗口的 75% 就开始压缩
protect_first_n:   int   = 3         # 开头保护 3 条(系统提示词之外)
protect_last_n:    int   = 6         # 结尾保护 6 条

emit_automatic_compaction_status: bool = True    # 自动压缩要不要通知用户

protect_first_n 的语义有一条演进说明:

「protect_first_n semantics (since PR #13754): count of non-system head messages always preserved verbatim, IN ADDITION to the system prompt which is always implicitly protected. Default 3 keeps the historical "system + first 3 non-system messages" head shape.」

译:protect_first_n 的语义(自某次改动起):始终原样保留的「非系统消息」头部条数,这是在「系统提示词永远隐式受保护」之外的。默认 3 保持了历史上「系统提示词 + 前 3 条非系统消息」的头部形态。

这条注释存在的原因是语义变过。以前 protect_first_n=3 可能是「包括系统提示词在内的前 3 条」,改成了「系统提示词之外的前 3 条」。这种改动如果不写清楚,所有第三方引擎都会算错一条消息。

7.8 用户可见状态的可控性

def automatic_compaction_status_message(engine, *, phase: str,
                                        default_message: str, **context) -> str | None:
    """Resolve host-visible status for an automatic compaction event.

    Engines can suppress routine automatic status with
    ``emit_automatic_compaction_status = False`` or customize it by defining
    ``get_automatic_compaction_status_message(...)``. Empty strings and
    ``None`` mean "do not emit a lifecycle status".
    """

这个设计考虑的是:不同引擎对「压缩」这件事的定位不同。

  • 内置压缩器:压缩是大事(有损、不可逆),应该通知用户
  • 某个检索型引擎:上下文重组是常规后台维护,每轮都在做,通知用户只会造成噪音

而且分得很细:「警告、错误、以及用户显式执行的手动命令,仍然会通知」 —— 只有「例行的自动成功」可以被静默。

7.9 内置实现的体量对比

文件大小性质
agent/context_engine.py16 KB / 490 行接口定义,零实现
agent/context_compressor.py419 KB内置的一个实现
agent/conversation_compression.py压缩的对话层逻辑
trajectory_compressor.py70 KB轨迹压缩
agent/context_compressor.py 相关compaction_display.pycontext_breakdown.pycontext_references.py

接口 490 行,实现 419 KB —— 比例约 1:26。

这个比例本身就是 Hermes 架构立场的量化表达:把「怎么做」的复杂度全部留在实现里,让接口保持小到任何人都能在半小时内读完并写出自己的实现。

代价是接口必须照顾所有可能的实现,所以有大量「默认安全空操作」和「宿主会按签名过滤参数」这类兼容性设计。

7 · The Context Engine ★

agent/context_engine.py, 490 lines. This file does no actual work — it only defines a contract. But it is the most concentrated expression of Hermes's architectural stance.

7.1 What It Defines

The note at the top of the file: “A context engine controls how conversation context is managed when approaching the model's token limit. The built-in ContextCompressor is the default implementation. Third-party engines (e.g. LCM) can replace it via the plugin system or by being placed in the plugins/context_engine/<name>/ directory. Selection is config-driven: context.engine in config.yaml. Default is "compressor". Only one engine is active.

In plain terms: the context engine controls how conversation context gets managed as you approach the model's token limit. The built-in ContextCompressor is the default. A third-party engine can replace it through the plugin system, or by being dropped into the plugins/context_engine/<name>/ directory. The choice is config-driven: context.engine in config.yaml, defaulting to "compressor". Only one engine is active at a time.

“Only one engine is active” is the important sentence — it classifies the context engine as a “mutually exclusive strategy” rather than a “stackable capability.” Chapter 9 explains why this distinction has to be made at the plugin-system level.

7.2 Lifecycle

"""
Lifecycle:
  1. Engine is instantiated and registered (plugin register() or default)
  2. on_session_start() called when a conversation begins
  3. update_from_response() called after each API response with usage data
  4. should_compress() checked after each turn
  5. compress() called when should_compress() returns True
  6. on_session_end() called at real session boundaries (CLI exit, /reset,
     gateway session expiry) — NOT per-turn
"""

Note the phrase “NOT per-turn” in step 6. This is an easy thing to get wrong:

What is the correct definition of "session end"? ✗ The end of each conversational turn ← Wrong. The user is still here; the conversation continues ✗ The gateway process restarts ← Wrong. Sessions can be resumed ✓ The user exits the CLI ✓ The user runs /reset ✓ The gateway decides this session has expired ★ If the engine believes "the session ended" at the end of every turn, it will flush to disk, close connections, and drop caches over and over — performance and correctness both collapse.

7.3 The Three Methods You Must Implement

class ContextEngine(ABC):

    @property
    @abstractmethod
    def name(self) -> str:
        """Short identifier (e.g. 'compressor', 'lcm')."""

    @abstractmethod
    def update_from_response(self, usage: Dict[str, Any]) -> None:
        """Update tracked token usage from an API response."""

    @abstractmethod
    def should_compress(self, prompt_tokens: int = None) -> bool:
        """Return True if compaction should fire this turn."""

    @abstractmethod
    def compress(self, messages, current_tokens=None, focus_topic=None,
                 force=False, memory_context="") -> List[Dict[str, Any]]:
        """Compact the message list and return the new message list."""

Backward-compatible design of the usage dict

“Called after every LLM call with a normalized usage dict. The legacy keys prompt_tokens, completion_tokens, and total_tokens are always present. Newer hosts also include canonical buckets: input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, and reasoning_tokens. Engines should treat those fields as optional for compatibility with older hosts.

In plain terms: after every model call, a normalized usage dict is passed in. The three legacy keys are always there. Newer hosts also add the canonical buckets… and engines should treat those as optional so they stay compatible with older hosts.

This is a textbook example of “interface evolution”: old fields are never removed (so old engines keep running), new fields are optional (so new engines can use the finer-grained data), and the compatibility contract is spelled out explicitly in the docs.

Each of compress's four optional parameters has a job

ParameterPurpose
focus_topicComes from the user manually running /compress <topic>. Engines that support guided compaction should preferentially keep information related to this topic. Engines that don't support it can simply ignore it
forceWhether a user-initiated compaction should bypass the engine's own cooldown. Engines with no cooldown mechanism can ignore it
memory_contextText returned by memory providers before compaction. Summarizing engines should fold non-empty content into the handoff prompt
current_tokensThe current token count (if the host knows it)

The docs also spell out how parameter evolution is handled: “Older engines may omit this parameter; the host filters unsupported optional parameters by signature.” — the host uses reflection to inspect the engine method's signature and passes only the parameters it accepts. So adding a parameter never breaks an old engine.

7.4 The Sharpest Design Decision: select and compress Are Two Orthogonal Verbs

def select_context(
    self,
    request_messages: List[Dict[str, Any]],
    *,
    conversation_messages: List[Dict[str, Any]] = None,
    incoming_message: Dict[str, Any] = None,
    budget_tokens: int = 0,
) -> List[Dict[str, Any]]:
    """Optionally choose/replace the context for THIS request, pre-generation."""
    return None      # default: no-op
Defining the two verbs

compress(): the context is too long → make it shorter.
select_context(): this turn belongs to a different context → swap that one in.

From the source: “This lets an engine select which context enters the prompt (retrieval, topic routing, role/branch switching) rather than shrink context that is already there. The two verbs are orthogonal.”

In plain terms: this lets the engine “choose” which context goes into the prompt (retrieval, topic routing, role/branch switching) instead of “shrinking” the context that is already there. The two verbs are orthogonal.

This interface was forced into existence by real misuse

“Without this hook, engines that need per-turn access to the message list have to force should_compress() to return True so that compress() is invoked every turn purely as a callback — which conflates selection with compression and degrades behaviour when the engine's backend is unavailable.”

In plain terms: without this hook, engines that need the message list every turn have no choice but to force should_compress() to always return True, so that compress() gets called every turn purely as a callback. That conflates “selection” with “compression,” and it behaves badly when the engine's backend is unavailable.

Reconstructing the story:

① A third party builds a retrieval-based context engine It wants to retrieve the most relevant history fragments for the current question, every turn ② But the interface only offers should_compress() and compress() There is no slot for a "per-turn callback" ③ So it has to trick the system: should_compress() → always return True compress() → treated as the "per-turn callback": run retrieval, return the selected messages ④ Consequence: the moment the retrieval backend goes down, compress() throws The system concludes "compaction failed, the context is still too long" → enters the wrong recovery path (see the compaction-attempt counter in section 3.7) ⑤ Hermes adds a proper per-turn hook: select_context()

The key constraint: request-only

“The returned list is request-only: it replaces the messages sent to the provider for this single call and MUST NOT be treated as persisted transcript state. The conversation history in the session DB is left untouched, so nothing leaks across turns.”

In plain terms: the returned list applies to this request only: it replaces the messages sent to the provider for this one call, and must never be treated as persisted transcript state. The conversation history in the session DB is untouched, so nothing leaks across turns.

This constraint contains the risk: even if the engine picks the wrong context, the damage is limited to the quality of this one answer. It never pollutes the permanent record.

The cache contract is spelled out more clearly than anything else

“Ordering / cache contract: the host runs this hook before prompt cache-control and before every request sanitizer (orphaned-tool cleanup, thinking-only/role normalization, whitespace/JSON normalization). So (a) whatever the hook returns still passes through the same validation as any request — a malformed replacement cannot reach the provider — and (b) prompt-cache stability (an AGENTS.md invariant) is preserved: the default no-op leaves the request byte-identical, so cache behaviour is unchanged for the built-in compressor and any non-implementing engine.”

In plain terms: ordering and cache contract: the host runs this hook before “prompt cache-control” and before “every request sanitizer” (the sanitizers being: orphaned-tool cleanup, thinking-only-block and role normalization, whitespace and JSON normalization). Therefore: (a) whatever the hook returns still goes through exactly the same validation as any ordinary request — a malformed replacement cannot reach the provider; (b) prompt-cache stability is preserved: the default no-op leaves the request byte-for-byte identical.

Translated into a design principle: plugin hooks must run before every validator.

That way, garbage returned by a plugin still fails validation and never contaminates the model provider. This is the right posture for “not fully trusting plugins” — you give a third party the power to replace the entire context, but you keep the final gatekeeping for yourself.

The comment also mentions that this is “an AGENTS.md invariant” — meaning “prompt-cache stability” is an explicitly documented, cross-module architectural constraint in this project.

7.5 The Post-Turn Observation Hook

def on_turn_complete(self, messages, usage: Dict[str, Any] = None, **kwargs) -> None:
    """Observe a finished user turn (post-turn ingestion / observation)."""
    return None

This is the mirror image of select_context(): selection happens before the request; observation happens after the turn.

“It lets an engine ingest, index, summarize, or update routing / topic / session state from what actually happened — so the next select_context() can act on it. …Together the two hooks remove the need to abuse should_compress() / compress() as a generic per-turn callback.”

In plain terms: it lets the engine ingest, index, summarize, or update its routing/topic/session state based on “what actually happened” — so the next select_context() can act on it. …Together, the two hooks remove any need to abuse should_compress()/compress() as a generic per-turn callback.

An honest note on coverage

“Coverage: this fires from the normal finalization seam. Some abnormal early-return paths in the loop (e.g. a content-policy block or a provider terminal failure) persist and return without routing through finalization, and therefore do not currently emit this hook. Treat it as a best-effort post-turn observation for completed turns, not a guaranteed callback for every possible early exit; unifying all terminal paths behind one finalization seam is a separate follow-up.”

In plain terms: coverage: this hook fires from the normal finalization seam. Some abnormal early-return paths in the loop (say, a content-policy block or a provider terminal failure) persist and return directly without going through finalization, so they currently don't emit this hook. Treat it as a “best-effort post-turn observation for completed turns,” not as “a guaranteed callback for every possible early exit.” Unifying all terminal paths behind a single finalization seam is separate follow-up work.

This comment deserves its own round of applause. It does three things you rarely see:
· It states outright where the interface is incomplete (some paths won't fire it)
· It names the specific paths (content-policy block, provider terminal failure)
· It says this is known technical debt with a plan (unifying the finalization seam is separate follow-up work)

For a third-party implementer, this is far more useful than a line like “this method is called at the end of every turn” — which leads people to write code that depends on being “guaranteed to be called,” and then hit bizarre state inconsistencies in production.

7.6 Other Optional Hooks

# Deterministic pruning, no model call
def prune_tool_results_only(self, messages, current_tokens=None) -> tuple[List, int]:
    return messages, 0                     # default: safe no-op

# Cheap preflight
def should_compress_preflight(self, messages) -> bool:
    return False
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
    return False

# Preflight guard for manual /compress
def has_content_to_compress(self, messages) -> bool:
    return True

# Session lifecycle
def on_session_start(self, session_id: str, **kwargs) -> None
def on_session_end(self, session_id: str, messages) -> None
def on_session_reset(self) -> None

# ★ Engines can bring their own tools
def get_tool_schemas(self) -> List[Dict[str, Any]]:
    return []
def handle_tool_call(self, name: str, args: Dict[str, Any], **kwargs) -> str

# Status display
def get_status(self) -> Dict[str, Any]

# Model switching
def update_model(self, model, context_length, base_url="", api_key="",
                 provider="", api_mode="") -> None

Pruning without a model call: why it gets its own hook

“Runs on a low, cost-oriented trigger independent of should_compress so large-window engines can reclaim re-sent tool output long before full compaction would fire. …Default is a safe no-op… so the agent loop's post-tool-call prune path never raises AttributeError on them.”

In plain terms: it runs on a “low, cost-oriented” trigger that is independent of should_compress — so large-window engines can reclaim tool output that keeps getting re-sent, long before full compaction would ever fire. …The default is a safe no-op… so the agent loop's “prune after tool call” path never raises AttributeError on them.

The key is “an independent, low trigger.” With a model that has a one-million-token window, should_compress might not fire for dozens of turns — but that stale tool output is re-sent every turn and costs money every turn. So you need a cost-driven pruning trigger that has nothing to do with “will we overflow the window.”

Engines can bring their own tools

get_tool_schemas() / handle_tool_call() let an engine expose its own tools to the model. The example in the docs: the LCM engine can offer tools like lcm_grep, lcm_describe, and lcm_expand — that is, it lets the model actively search, describe, and expand context that has been folded away.

This is an imaginative design: context management goes from “something done automatically in the background” to “something the model can actively take part in.”

The model can say, “I remember we discussed the database design earlier — expand that part for me,” instead of passively accepting an already-compacted summary.

7.7 Default Parameter Values

threshold_percent: float = 0.75      # start compacting at 75% of the window
protect_first_n:   int   = 3         # protect the first 3 (beyond the system prompt)
protect_last_n:    int   = 6         # protect the last 6

emit_automatic_compaction_status: bool = True    # notify the user on automatic compaction?

The semantics of protect_first_n come with an evolution note:

“protect_first_n semantics (since PR #13754): count of non-system head messages always preserved verbatim, IN ADDITION to the system prompt which is always implicitly protected. Default 3 keeps the historical "system + first 3 non-system messages" head shape.”

In plain terms: protect_first_n semantics (since a particular change): the number of “non-system” head messages that are always preserved verbatim, and this is on top of the system prompt, which is always implicitly protected. The default of 3 keeps the historical “system prompt + first 3 non-system messages” head shape.

This comment exists because the semantics changed. protect_first_n=3 may once have meant “the first 3 including the system prompt”; it became “the first 3 beyond the system prompt.” If a change like that isn't written down, every third-party engine miscounts by one message.

7.8 Control Over User-Visible Status

def automatic_compaction_status_message(engine, *, phase: str,
                                        default_message: str, **context) -> str | None:
    """Resolve host-visible status for an automatic compaction event.

    Engines can suppress routine automatic status with
    ``emit_automatic_compaction_status = False`` or customize it by defining
    ``get_automatic_compaction_status_message(...)``. Empty strings and
    ``None`` mean "do not emit a lifecycle status".
    """

The thinking here: different engines see “compaction” very differently.

  • The built-in compressor: compaction is a big deal (lossy, irreversible), and the user should be told
  • Some retrieval-style engine: reorganizing context is routine background maintenance, done every turn, and notifying the user would just be noise

And it is finely grained: “warnings, errors, and manual commands the user explicitly runs still notify” — only “routine automatic success” can be silenced.

7.9 Size Comparison of the Built-in Implementation

FileSizeNature
agent/context_engine.py16 KB / 490 linesInterface definition, zero implementation
agent/context_compressor.py419 KBThe one built-in implementation
agent/conversation_compression.pyConversation-level compaction logic
trajectory_compressor.py70 KBTrajectory compression
Related to agent/context_compressor.pycompaction_display.py, context_breakdown.py, context_references.py

490 lines of interface, 419 KB of implementation — a ratio of roughly 1:26.

That ratio is itself a quantitative statement of Hermes's architectural stance: keep all the complexity of “how” inside the implementation, and keep the interface small enough that anyone can read it in half an hour and write their own implementation.

The cost is that the interface has to accommodate every possible implementation, hence the abundance of compatibility devices like “default safe no-op” and “the host filters parameters by signature.”