fix(prompt): isolate truncation warnings per context

Follow-up to salvaged PR #41619: replace the module-global
_truncation_warnings list with a contextvars.ContextVar so concurrent
gateway-session prompt builds can't drain or clear each other's pending
warnings (cross-session leak). Adds a context-isolation test.
This commit is contained in:
teknium 2026-06-16 10:38:35 -07:00 committed by Teknium
parent f6a42b1acf
commit 6ebc449915
2 changed files with 53 additions and 6 deletions

View file

@ -8,6 +8,7 @@ import json
import logging
import os
import threading
import contextvars
from collections import OrderedDict
from pathlib import Path
@ -976,14 +977,32 @@ def _get_context_file_max_chars() -> int:
return CONTEXT_FILE_MAX_CHARS
# Collect truncation warnings so the caller (run_agent) can surface them.
_truncation_warnings: list = []
# A ContextVar (not a module-global list) isolates accumulation per thread /
# per async task, so concurrent gateway-session prompt builds can't drain or
# clear each other's pending warnings (cross-session leak). Each build runs in
# its own context, collects its own warnings, and drains them synchronously.
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
"context_file_truncation_warnings", default=None
)
def _record_truncation_warning(msg: str) -> None:
"""Append a truncation warning to the current context's accumulator."""
warnings = _truncation_warnings.get()
if warnings is None:
warnings = []
_truncation_warnings.set(warnings)
warnings.append(msg)
def drain_truncation_warnings() -> list:
"""Return and clear any truncation warnings accumulated since last drain."""
warnings = _truncation_warnings.copy()
_truncation_warnings.clear()
return warnings
"""Return and clear any truncation warnings accumulated in this context."""
warnings = _truncation_warnings.get()
if not warnings:
return []
drained = list(warnings)
warnings.clear()
return drained
# =========================================================================
@ -1503,7 +1522,7 @@ def _truncate_content(content: str, filename: str, max_chars: Optional[int] = No
f"increase context_file_max_chars or trim the file!"
)
logger.warning(msg)
_truncation_warnings.append(msg)
_record_truncation_warning(msg)
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
head = content[:head_chars]

View file

@ -190,6 +190,34 @@ class TestTruncateContent:
assert "context_file_max_chars" in warnings[0]
assert "CONTEXT_FILE_MAX_CHARS" not in warnings[0]
def test_warnings_isolated_across_contexts(self, monkeypatch):
"""Truncation warnings accumulate per-context — a concurrent build in
a separate context must not see or drain this context's warnings."""
import contextvars
def fake_load_config():
return {"context_file_max_chars": 120}
monkeypatch.setattr("hermes_cli.config.load_config", fake_load_config)
# Generate a warning in a fresh child context, then assert it did NOT
# leak into the parent context's accumulator.
def _child():
_truncate_content("x" * 180, "child.md")
# Inside the child context, the warning is visible & drainable.
assert any("child.md" in w for w in drain_truncation_warnings())
contextvars.copy_context().run(_child)
# Parent context never saw the child's warning.
assert drain_truncation_warnings() == []
# And a warning raised in the parent stays in the parent.
_truncate_content("y" * 180, "parent.md")
parent_warnings = drain_truncation_warnings()
assert len(parent_warnings) == 1
assert "parent.md" in parent_warnings[0]
# =========================================================================
# _parse_skill_file — single-pass skill file reading