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.
This commit is contained in:
fangliquanflq 2026-07-31 16:44:58 +00:00 committed by Teknium
parent 240148b440
commit 06c7f9b26f
6 changed files with 82 additions and 10 deletions

View File

@ -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

View File

@ -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

View File

@ -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
),

View File

@ -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(

View File

@ -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.

View File

@ -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 密钥和工具/技能配置仍需要通常的重载路径。