From 6ebc4499150b78a983054c01dcfa31f78a677314 Mon Sep 17 00:00:00 2001 From: teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:38:35 -0700 Subject: [PATCH] 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. --- agent/prompt_builder.py | 31 ++++++++++++++++++++++++------ tests/agent/test_prompt_builder.py | 28 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index e095857545b..82051ef4968 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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] diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 0fc727f2af5..178695e0258 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -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