agent/context_engine.py,490 行。这个文件不做任何实际工作 —— 它只定义一份契约。但它是 Hermes 架构立场最集中的体现。
文件开头的说明:「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 章会讲这个区分为什么必须在插件系统层面就做出来。
"""
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」(不是每轮)。这是一个容易搞错的地方:
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.」
译:每次模型调用后传入一个归一化的用量字典。旧的三个键永远存在。较新的宿主还会包含标准分桶……引擎应该把这些字段当作可选的,以兼容旧宿主。
这是一份「接口演进」的教科书示范:旧字段永不删除(保证老引擎能跑),新字段可选(保证新引擎能用上更细的数据),而且在文档里明确写出兼容性契约。
| 参数 | 用途 |
|---|---|
focus_topic | 来自用户手动执行 /compress <主题>。支持引导式压缩的引擎应该优先保留和这个主题相关的信息。不支持的引擎可以直接忽略 |
force | 用户主动要求的压缩是否应该绕过引擎自己的冷却期。没有冷却机制的引擎可以忽略 |
memory_context | 压缩前记忆提供者返回的文本。做摘要的引擎应该把非空内容纳入交接提示词 |
current_tokens | 当前 token 数(如果宿主知道的话) |
而且文档写明了参数演进的处理方式:「较老的引擎可以省略这个参数;宿主会按签名过滤掉不支持的可选参数。」—— 宿主用反射检查引擎方法的签名,只传它接受的参数。这样新增参数不会破坏老引擎。
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 里的一条不变式」—— 说明「提示词缓存稳定性」在这个项目里是一条被明文记录的、跨模块的架构约束。
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.」
译:覆盖范围:这个钩子从正常的收尾接缝处触发。循环里某些异常的提前返回路径(比如内容策略拦截、或供应商终端失败)会直接持久化并返回,不经过收尾流程,因此目前不会发出这个钩子。请把它当作「已完成轮次的尽力而为的后置观察」,而不是「每一种可能的提前退出都保证回调」;把所有终端路径统一到一个收尾接缝之后,是一个独立的后续工作。
这段注释值得单独表扬。它做了三件很少见的事:
· 明确说出接口的不完整之处(有些路径不会触发)
· 说明具体是哪些路径(内容策略拦截、供应商终端失败)
· 说明这是已知的技术债并且有计划(统一收尾接缝是独立的后续工作)
对第三方实现者来说,这比一句「本方法会在每轮结束时调用」有用得多 —— 后者会让人写出依赖「保证被调用」的代码,然后在生产环境里遇到诡异的状态不一致。
# 不调模型的确定性裁剪
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 这些工具 —— 也就是让模型能主动去搜索、描述、展开被折叠的上下文。
这是一个很有想象力的设计:上下文管理从「后台自动做的事」变成了「模型可以主动参与的事」。
模型可以说「我记得之前讨论过数据库设计,帮我把那段展开」—— 而不是被动接受一个已经压缩好的摘要。
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 条」。这种改动如果不写清楚,所有第三方引擎都会算错一条消息。
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".
"""
这个设计考虑的是:不同引擎对「压缩」这件事的定位不同。
而且分得很细:「警告、错误、以及用户显式执行的手动命令,仍然会通知」 —— 只有「例行的自动成功」可以被静默。
| 文件 | 大小 | 性质 |
|---|---|---|
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 架构立场的量化表达:把「怎么做」的复杂度全部留在实现里,让接口保持小到任何人都能在半小时内读完并写出自己的实现。
代价是接口必须照顾所有可能的实现,所以有大量「默认安全空操作」和「宿主会按签名过滤参数」这类兼容性设计。