diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 2569302882..6c975b0a6b 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1576,14 +1576,32 @@ def _truncate_content( warnings.append(msg) head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO) tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO) + omitted = _omitted_headings(content, head_chars, len(content) - tail_chars) + sections = f" Omitted sections: {'; '.join(omitted)}." if omitted else "" marker = ( f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. The middle is " - f"omitted — if you need the full instructions, read the complete file with the read_file tool: " - f"{read_path or filename}]\n\n" + f"omitted.{sections} If you need the full instructions, read the complete file with the read_file " + f"tool: {read_path or filename}]\n\n" ) return content[:head_chars] + marker + content[-tail_chars:] +def _omitted_headings(content: str, start: int, end: int, limit: int = 15) -> list: + """Markdown headings whose line starts inside ``content[start:end]``, so a truncation marker tells + the agent what it lost; ``#`` lines inside fenced code blocks are comments, not headings.""" + import re + + headings, offset, fenced = [], 0, False + for line in content.splitlines(keepends=True): + stripped = line.strip() + if stripped.startswith(("```", "~~~")): + fenced = not fenced + elif not fenced and start <= offset < end and re.match(r"#{1,6} \S", stripped): + headings.append(stripped.lstrip("#").strip()) + offset += len(line) + return headings[:limit] + (["..."] if len(headings) > limit else []) + + def load_soul_md(context_length: Optional[int] = None, home_override: "Path | None" = None) -> Optional[str]: """SOUL.md from HERMES_HOME (identity slot #1), or None. diff --git a/tests/agent/test_context_file_sources.py b/tests/agent/test_context_file_sources.py index ef51e7cb97..24c4b4a22e 100644 --- a/tests/agent/test_context_file_sources.py +++ b/tests/agent/test_context_file_sources.py @@ -99,3 +99,17 @@ def test_truncated_and_suppressed_statuses_follow_the_builder(project, monkeypat assert "evil identity text" in prompt and "[BLOCKED: SOUL.md" not in prompt and "[BLOCKED: AGENTS.md" in prompt assert any("SOUL.md" in line and "review the file" in line for line in render_context_file_lines(list(entries.values()))) + + +def test_truncation_marker_names_the_omitted_sections_only(): + from agent.prompt_builder import _truncate_content + + pad = "word " * 400 + content = ( + f"# Kept head\n{pad}\n## Lost section\n{pad}\n```bash\n# a shell comment\n```\n" + f"### Lost subsection\n{pad}\n{pad}\n## Kept tail\nshort\n" + ) + out = _truncate_content(content, "AGENTS.md", max_chars=2500, queue_warning=False) + marker = out[out.index("[...truncated"):out.index("]", out.index("[...truncated"))] + assert "Lost section; Lost subsection" in marker + assert "Kept head" not in marker and "Kept tail" not in marker and "shell comment" not in marker