全文目录Contents
- 0 · The Project at a Glance, and a Map of the Code
- 1 · The Gateway Layer
- 2 · Identity and Session Routing
- 3 · The Main Loop ★
- 3.1 The Loop Entry: A Triple Budget Gate
- 3.2 Per-Turn Preparation: build_turn_context
- 3.3 Mid-Turn Interjection: /steer
- 3.4 The Wall-Clock Budget Wrap-Up Reminder
- 3.5 The Interrupt Check
- 3.6 The Step Callback: An Observation Point for the Gateway
- 3.7 Other State Inside the Loop
- 3.8 Why the Loop Exited
- 3.9 An Optional Bypass: The Codex App Server Runtime
- 4 · The Tool System
- 4.1 The Most Important Design Decision: Implementation Separated from Exposure
- 4.2 Composing and Resolving Toolsets
- 4.3 Central Tool Dispatch
- 4.4 The Argument Coercion Layer
- 4.5 Sanitizing Tool Error Messages
- 4.6 Observing Tool Results, and Hooks
- 4.7 Recognizing a Delegation Context
- 4.8 Caching Tool Definitions
- 5 · Approval and Safety Red Lines ★
- 5.1 The Overall Structure
- 5.2 The 12 Hard Red Lines
- 5.3 The Real Difficulty: Telling “Command” from “Data”
- 5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”
- 5.5 Quoting Is Not a Bypass
- 5.6 Sensitive Paths and Write Targets
- 5.7 The sudo Stdin Guard
- 5.8 Performance: Why Precompile
- 5.9 Retaining Blocked Commands
- 5.10 Smart Approval: An Optional Model Judgment
- 5.11 Context Awareness: Different Situations, Different Policies
- 5.12 Where This Layer Sits: Outermost, and Thinnest
- 6 · Execution Environments
- 7 · The Context Engine ★
- 7.1 What It Defines
- 7.2 Lifecycle
- 7.3 The Three Methods You Must Implement
- 7.4 The Sharpest Design Decision: select and compress Are Two Orthogonal Verbs
- 7.5 The Post-Turn Observation Hook
- 7.6 Other Optional Hooks
- 7.7 Default Parameter Values
- 7.8 Control Over User-Visible Status
- 7.9 Size Comparison of the Built-in Implementation
- 8 · The Memory System ★
- 8.1 The Memory Provider Interface
- 8.2 Lifecycle and Hooks
- 8.3 The Interface's Versioned Contract
- 8.4 The Trivial-Prompt Filter
- 8.5 The Memory-Usage Indicator
- 8.6 The Built-in Holographic Memory
- 8.7 The Storage Layer and Trust Scores
- 8.8 The SQLite State Layer
- 8.9 Summing Up the Division of Labor Among Three Kinds of Memory
- 9 · The Plugin System
- 9.1 Three Discovery Sources
- 9.2 What a Plugin Can Provide
- 9.3 The Most Important Design Decision: “Stackable Capabilities” vs. “Mutually Exclusive Strategies”
- 9.4 Plugin Storage
- 9.5 How Plugins Tie into Toolsets
- 9.6 Plugin Hooks
- 9.7 MCP: The Other Extension Path
- 9.8 The Overall Shape of This Extension System
- 10 · Delegation and Multi-Agent
- 11 · Model Providers and the Credential Pool
- 12 · Scheduled Tasks (Cron)
- 12.1 What “cron” Is
- 12.2 Scenarios for Scheduled Agents
- 12.3 The Single Most Important Class: CronPromptInjectionBlocked
- 12.4 Narrowing the Toolset for Scheduled Tasks
- 12.5 Failure Handling
- 12.6 Preventing Duplicate Runs
- 12.7 Why scheduler.py Is 367 KB
- 12.8 Where Scheduled Tasks Sit in the Overall Architecture
- 13 · The Skill System
- 13.1 What a Skill Is
- 13.2 The Front Matter, Field by Field
- 13.3 Progressive Disclosure: The Core Mechanism of the Skill System
- 13.4 The Infrastructure Around Skills
- 13.5 Skills vs. Tools vs. Plugins
- 13.6 An Implicit Design in the Skill System: Composability
- 13.7 The 15 Skill Categories
- 13.8 Looking Back Across the Book: The Overall Shape of Hermes
7 · 上下文引擎 ★
agent/context_engine.py,490 行。这个文件不做任何实际工作 —— 它只定义一份契约。但它是 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」(不是每轮)。这是一个容易搞错的地方:
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() 每轮都被调用、纯粹当成一个回调用。这就把「选择」和「压缩」混为一谈了,而且当引擎的后端服务不可用时行为会变得很糟。
还原这个故事:
关键约束:只作用于本次请求
「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_grep、lcm_describe、lcm_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.py | 16 KB / 490 行 | 接口定义,零实现 |
agent/context_compressor.py | 419 KB | 内置的一个实现 |
agent/conversation_compression.py | — | 压缩的对话层逻辑 |
trajectory_compressor.py | 70 KB | 轨迹压缩 |
agent/context_compressor.py 相关 | — | compaction_display.py、context_breakdown.py、context_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:
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
| Parameter | Purpose |
|---|---|
focus_topic | Comes 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 |
force | Whether a user-initiated compaction should bypass the engine's own cooldown. Engines with no cooldown mechanism can ignore it |
memory_context | Text returned by memory providers before compaction. Summarizing engines should fold non-empty content into the handoff prompt |
current_tokens | The 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
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:
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
| File | Size | Nature |
|---|---|---|
agent/context_engine.py | 16 KB / 490 lines | Interface definition, zero implementation |
agent/context_compressor.py | 419 KB | The one built-in implementation |
agent/conversation_compression.py | — | Conversation-level compaction logic |
trajectory_compressor.py | 70 KB | Trajectory compression |
Related to agent/context_compressor.py | — | compaction_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.”