From 06c7f9b26f3fd83a34b179e7e7a709d8e172916e Mon Sep 17 00:00:00 2001 From: fangliquanflq Date: Fri, 31 Jul 2026 16:44:58 +0000 Subject: [PATCH] fix(agent): clarify compress_context ceiling is pre-commit only Once begin_commit() wins, SessionDB mutation cannot be fence-cancelled; document that context_total_ceiling_seconds covers the summary phase only and pin the hang-wait contract in tests. --- agent/conversation_compression.py | 20 ++++++++ hermes_cli/config_defaults.py | 9 ++-- run_agent.py | 6 +-- .../test_compress_context_progress_timeout.py | 49 +++++++++++++++++++ website/docs/user-guide/configuration.md | 4 +- .../current/user-guide/configuration.md | 4 +- 6 files changed, 82 insertions(+), 10 deletions(-) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index bca3fe2d1eb3d..ed1f187838276 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -606,6 +606,15 @@ def run_compress_context_with_progress_timeout( state. When the worker already entered the commit boundary, waits for that commit to finish and returns its result. + Timeout budgets (``idle_timeout_seconds`` / ``total_ceiling_seconds``) cover + the **pre-commit** wait only — the summary / stream phase before + :meth:`CompressionCommitFence.begin_commit`. Once the worker holds the + commit fence, SessionDB mutation is already in flight and cannot be safely + abandoned without risking transcript divergence; the caller therefore waits + for ``future.result()`` with no additional host ceiling (same contract as + gateway session-hygiene after a lost cancel race). A hung commit can still + stall the turn; that is a SessionDB / I/O failure mode outside this wrapper. + ``system_prompt_fallback`` may be a string or a zero-arg callable resolved only on the timeout path, so successful compression never pays for (or fails on) an eager prompt rebuild. @@ -665,6 +674,17 @@ def run_compress_context_with_progress_timeout( if cancelled is None: time.sleep(0.001) if not cancelled: + # Pre-commit ceiling already elapsed, but begin_commit() won the race. + # Waiting is intentional: SessionDB mutation cannot be fence-cancelled. + waited = time.monotonic() - wait_started + if waited >= ceiling: + logger.warning( + "Context compression crossed the commit boundary after the " + "pre-commit ceiling (waited %.1fs, ceiling %.1fs); waiting for " + "SessionDB commit to finish before continuing", + waited, + ceiling, + ) return future.result() waited = time.monotonic() - wait_started diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index c0c9e4ef82fc7..f0ff6505e173d 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -647,9 +647,12 @@ DEFAULT_CONFIG = { # worker is cut off. 0 = disable the owned wrapper # (callers that already pass commit_fence, e.g. gateway # hygiene, never use this path). - "context_total_ceiling_seconds": 600, # absolute cap on in-agent compress_context wait - # even while tokens are still moving. Clamped to - # >= context_timeout_seconds when the idle budget is > 0. + "context_total_ceiling_seconds": 600, # absolute cap on the *pre-commit* + # in-agent compress_context wait (summary / + # stream phase) even while tokens are still + # moving. Clamped to >= context_timeout_seconds + # when the idle budget is > 0. Does NOT bound + # an already-started SessionDB commit fence. "protect_first_n": 3, # non-system head messages always preserved # verbatim, in ADDITION to the system prompt # (which is always implicitly protected). Set to diff --git a/run_agent.py b/run_agent.py index 6c7b305453e89..2f76c66572e78 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7113,11 +7113,11 @@ class AIAgent: token = set_conversation_context(root) try: def _run(fence=None): - return compress_context( - self, messages, system_message, + return compress_context( + self, messages, system_message, approx_tokens=approx_tokens, task_id=task_id, focus_topic=focus_topic, - force=force, + force=force, defer_context_engine_notification=( defer_context_engine_notification ), diff --git a/tests/agent/test_compress_context_progress_timeout.py b/tests/agent/test_compress_context_progress_timeout.py index 2cf32f12a39c7..5d7488a8e3fc3 100644 --- a/tests/agent/test_compress_context_progress_timeout.py +++ b/tests/agent/test_compress_context_progress_timeout.py @@ -148,6 +148,55 @@ class TestRunCompressContextWithProgressTimeout: assert result_msgs == compressed assert result_prompt == "committed" + def test_never_finishing_commit_waits_past_pre_commit_ceiling(self): + """Once begin_commit() wins, the host waits without a ceiling. + + context_total_ceiling_seconds only bounds the pre-commit (summary) + phase. A hung SessionDB commit cannot be fence-cancelled; returning + early would diverge live messages from durable session state. This + pins that contract so docs and the wrapper stay aligned. + """ + original = [{"role": "user", "content": "a"}] + compressed = [{"role": "assistant", "content": "late-commit"}] + entered = threading.Event() + release = threading.Event() + + def worker(fence: CompressionCommitFence): + assert fence.begin_commit() + entered.set() + try: + assert release.wait(timeout=2) + return (compressed, "committed-late") + finally: + fence.finish_commit() + + ceiling = 0.05 + started = time.monotonic() + done = {} + + def run(): + done["result"] = run_compress_context_with_progress_timeout( + worker=worker, + messages=original, + system_prompt_fallback="fallback", + idle_timeout_seconds=ceiling, + total_ceiling_seconds=ceiling, + ) + + t = threading.Thread(target=run, name="commit-hang-waiter") + t.start() + assert entered.wait(timeout=1) + # Still blocked past the pre-commit ceiling while commit holds the fence. + time.sleep(ceiling + 0.15) + assert t.is_alive(), "waiter must block on an in-flight commit past ceiling" + release.set() + t.join(timeout=2) + assert not t.is_alive() + waited = time.monotonic() - started + assert waited >= ceiling + 0.1 + assert done["result"][0] == compressed + assert done["result"][1] == "committed-late" + def test_rejects_non_positive_idle(self): with pytest.raises(ValueError): run_compress_context_with_progress_timeout( diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 421f00071bb84..be9ab99b0b768 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -796,7 +796,7 @@ compression: hygiene_total_ceiling_seconds: 600 # Absolute cap on the hygiene wait even while tokens are still streaming hygiene_failure_cooldown_seconds: 300 # Skip repeated failed hygiene attempts for this session context_timeout_seconds: 120 # Inactivity budget for in-agent compress_context (loop /compress / preflight) — see below - context_total_ceiling_seconds: 600 # Absolute cap on in-agent compress_context wait even while tokens are still streaming + context_total_ceiling_seconds: 600 # Absolute cap on the *pre-commit* in-agent compress_context wait even while tokens are still streaming (does not bound an already-started SessionDB commit) proactive_prune_tokens: 0 # Opt-in tokens trigger for the no-LLM tool-result prune (0 = off; see below) proactive_prune_min_result_chars: 8000 # Prune's summarize pass only touches tool results larger than this (clamped >= 200) proactive_prune_min_reclaim_tokens: 4096 # Prune only commits when it reclaims at least this many tokens (0 = commit any) @@ -825,7 +825,7 @@ Older configs with `compression.summary_model`, `compression.summary_provider`, `context_timeout_seconds` (default `120`) is the same **inactivity budget** for in-agent `compress_context` — the conversation loop, preflight compaction, and manual `/compress` — so a hung summary model cannot stall a session indefinitely. Streamed summary tokens extend the wait; only a silent worker is cut off. On timeout Hermes skips compaction, keeps the existing messages, and warns the user. Set to `0` to disable. Gateway session hygiene keeps its own `hygiene_timeout_seconds` path and is not double-wrapped. -`context_total_ceiling_seconds` (default `600`) bounds the in-agent wait even while tokens are still moving. It is clamped to at least `context_timeout_seconds`. +`context_total_ceiling_seconds` (default `600`) bounds the in-agent **pre-commit** wait (summary / stream phase) even while tokens are still moving. It is clamped to at least `context_timeout_seconds`. Once the worker has entered the compression commit fence and SessionDB mutation is in flight, the host waits for that commit to finish without an additional ceiling — abandoning mid-commit would risk transcript divergence (same contract as gateway session hygiene). `protect_first_n` controls how many **non-system** head messages are pinned across every compaction. Default `3` — the opening user/assistant exchange survives every summarizer pass so the original goal stays visible. On long-running rolling-compaction sessions where the opening turn is no longer relevant, set `protect_first_n: 0` to pin nothing but the system prompt + summary + tail. The system prompt itself is always preserved regardless of this setting. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md index 8fe25147735e7..f3e82933618e1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md @@ -601,7 +601,7 @@ compression: protect_last_n: 20 # 保持未压缩的最少最近消息数 hygiene_hard_message_limit: 5000 # Gateway 安全阀 —— 见下文 context_timeout_seconds: 120 # Agent 侧 compress_context 无进展超时(秒)—— 见下文 - context_total_ceiling_seconds: 600 # Agent 侧 compress_context 总等待上限(秒) + context_total_ceiling_seconds: 600 # Agent 侧 compress_context 预提交等待上限(秒;不含已进入 SessionDB commit fence 的等待) # 摘要模型/provider 在 auxiliary: 下配置: auxiliary: @@ -619,7 +619,7 @@ auxiliary: `context_timeout_seconds`(默认 `120`)是 agent 侧 `compress_context`(对话循环、预检压缩、手动 `/compress`)的**无进展超时**,语义与 gateway 会话预压缩(session hygiene)的 inactivity 预算相同:摘要模型仍在流式出 token 时会延长等待;仅当完全无输出时才跳过压缩并保留原消息。设为 `0` 可关闭。Gateway 会话预压缩仍使用自己的 `hygiene_timeout_seconds`,不会被双重包装。 -`context_total_ceiling_seconds`(默认 `600`)限制即使仍有 token 推进时的 agent 侧总等待时间,并会被钳制为至少等于 `context_timeout_seconds`。 +`context_total_ceiling_seconds`(默认 `600`)限制即使仍有 token 推进时的 agent 侧**预提交**等待时间(摘要 / 流式阶段),并会被钳制为至少等于 `context_timeout_seconds`。一旦 worker 已进入 compression commit fence 且 SessionDB 变更正在进行,宿主会无额外上限地等待该提交完成——中途放弃会导致 transcript 分叉(与 gateway 会话 hygiene 同一契约)。 :::tip Gateway 热重载压缩和上下文长度 从最近的版本开始,在运行中的 gateway 上编辑 `config.yaml` 中的 `model.context_length` 或任何 `compression.*` 键将在下一条消息时生效 —— 无需 gateway 重启、`/reset` 或会话轮换。缓存的 agent 签名包含这些键,因此 gateway 在检测到更改时会透明地重建 agent。API 密钥和工具/技能配置仍需要通常的重载路径。