fix(agent): hint-preview truncation log names the real remedy; invariant tests

Follow-up to the two salvaged commits (#111777, #111781 by @KoNit-K):

- agent/prompt_builder.py::_truncate_content — with queue_warning=False the
  logged line no longer tells the operator to "pin a larger
  context_file_max_chars, or use a larger-context model": the subdirectory
  hint cap is a constant neither knob raises. It now points at the read_file
  recovery the marker already discloses.
- tests/gateway/test_startup_environment_probe.py — replace the
  call-detection test with the behavioural invariant: an oversized SOUL.md in
  HERMES_HOME and a warm-up leave the truncation-warning queue empty for the
  next default-executor task (the api_server turn path runs on that executor
  without copy_context, which is how the boot warning reached a foreign
  session).
- tests/agent/test_subdirectory_hints.py — fold the new drain assertion into
  the existing oversized-hint test (same fixture) and pin that the log carries
  no context_file_max_chars advice.
- agent/AGENTS.md, website/docs/.../context-files.md — the hint cap is 32,000
  (docs said 8,000) and is fixed; document that it is logged, not surfaced as a
  chat warning.
This commit is contained in:
teknium1
2026-09-15 11:49:15 -07:00
committed by Teknium
parent fc7cbc7d8e
commit c1bbcf9712
5 changed files with 22 additions and 28 deletions

View File

@@ -57,7 +57,8 @@ Adding one: register in that table (no `if name == ...` chain); `tools/todo_tool
a conversation; the ONLY context mutation is compression. Anything that must inject content
mid-conversation rides a **user message or tool result**, never the system prompt: skill slash
commands (`agent/skill_commands.py`) inject as a user message; subdirectory `AGENTS.md` hints
(`agent/subdirectory_hints.py`) append to the tool result (head+tail truncated past `_MAX_HINT_CHARS = 32_000`, with a warning).
(`agent/subdirectory_hints.py`) append to the tool result (head+tail truncated past `_MAX_HINT_CHARS = 32_000`;
the truncation is logged, never queued as a chat status warning — `context_file_max_chars` does not raise that cap).
- **Strict role alternation.** Never two same-role messages in a row; never a synthetic user
message injected mid-loop. The one exception is `/steer`, delivered as a standalone user row
after a tool result (`assistant(tool_calls) → tool → user` is legal on every provider path) —

View File

@@ -1425,16 +1425,18 @@ def _truncate_content(
read_path: Optional[str] = None, queue_warning: bool = True,
) -> str:
"""Head/tail truncation with a marker in the middle; ``read_path`` (default ``filename``) is what the
agent is told to ``read_file`` to recover the full content. ``queue_warning`` controls whether startup
context-file callers surface the truncation through the chat warning queue."""
agent is told to ``read_file`` to recover the full content. ``queue_warning=False`` is for bounded
previews (subdirectory hints) whose fixed cap no config key or model raises: the truncation is logged
with the marker as the only disclosure, never queued for the chat status line."""
if max_chars is None:
max_chars = _get_context_file_max_chars(context_length)
if len(content) <= max_chars:
return content
msg = (
f"⚠️ Context file {filename} TRUNCATED: {len(content)} chars exceeds limit of {max_chars} — "
f"trim the file, pin a larger context_file_max_chars, or use a larger-context model!"
remedy = (
"trim the file, pin a larger context_file_max_chars, or use a larger-context model!" if queue_warning
else f"the full file stays readable with read_file: {read_path or filename}"
)
msg = f"⚠️ Context file {filename} TRUNCATED: {len(content)} chars exceeds limit of {max_chars} — {remedy}"
logger.warning(msg)
if queue_warning:
if (warnings := _truncation_warnings.get()) is None:

View File

@@ -128,6 +128,7 @@ class TestSubdirectoryHintTracker:
(sub / "AGENTS.md").write_text(body, encoding="utf-8")
tracker = SubdirectoryHintTracker(working_dir=str(tmp_path))
drain_truncation_warnings()
with caplog.at_level(logging.WARNING, logger="agent.prompt_builder"):
result = tracker.check_tool_call("read_file", {"path": str(sub / "file.py")})
assert result is not None
@@ -135,22 +136,10 @@ class TestSubdirectoryHintTracker:
assert "truncated AGENTS.md" in result and "bigdir/AGENTS.md" in result
assert len(result) < len(body)
assert any("TRUNCATED" in r.message and "AGENTS.md" in r.message for r in caplog.records)
def test_truncation_of_large_hints_does_not_queue_context_file_warning(self, tmp_path):
"""Hint previews retain their marker without surfacing a startup-context warning in chat (#111772)."""
from agent import subdirectory_hints as sh
drain_truncation_warnings()
sub = tmp_path / "bigdir"
sub.mkdir()
(sub / "AGENTS.md").write_text("x" * (sh._MAX_HINT_CHARS + 1), encoding="utf-8")
tracker = SubdirectoryHintTracker(working_dir=str(tmp_path))
result = tracker.check_tool_call("read_file", {"path": str(sub / "file.py")})
assert result is not None
assert "truncated AGENTS.md" in result
# A preview capped by a constant is not a context_file_max_chars problem: no chat status warning is
# queued and the log does not send the user to a knob that cannot raise the cap (#111772).
assert drain_truncation_warnings() == []
assert "context_file_max_chars" not in caplog.text
def test_area_file_under_ceiling_is_delivered_whole(self, tmp_path):
"""An area AGENTS.md sized like ours (well under the ceiling) arrives intact — no marker."""

View File

@@ -38,15 +38,17 @@ def test_warmup_leaves_probe_cached_for_first_prompt(tmp_path, monkeypatch):
assert calls == [1] # single worker; the first turn reuses the cache
def test_warmup_does_not_build_context_files_without_turn_context(tmp_path, monkeypatch):
monkeypatch.setattr(
prompt_builder,
"build_context_files_prompt",
lambda: pytest.fail("gateway warm-up must not build context files"),
)
def test_warmup_queues_no_context_file_warning_for_later_turns(tmp_path, monkeypatch):
"""Boot has no agent and no model, so the warm-up must not read context files: an identity file over
the model-less default cap would otherwise queue a truncation warning into the executor thread's context,
where the next default-executor turn drains it as its own (#111773)."""
(tmp_path / "SOUL.md").write_text("x" * (prompt_builder.CONTEXT_FILE_MAX_CHARS + 1), encoding="utf-8")
prompt_builder.drain_truncation_warnings()
_warm(tmp_path, monkeypatch, {}, "local")
assert prompt_builder.drain_truncation_warnings() == []
@pytest.mark.parametrize("agent_section,backend", [({"environment_probe": False}, "local"), ({}, "ssh")])
def test_warmup_skips_probe_when_disabled_or_remote(tmp_path, monkeypatch, agent_section, backend):

View File

@@ -139,7 +139,7 @@ Context files are loaded by `build_context_files_prompt()` in `agent/prompt_buil
2. **Ancestor walk** — the directory and up to 5 parent directories are checked (stopping at already-visited directories)
3. **Hint loading** — if an `AGENTS.md`, `CLAUDE.md`, or `.cursorrules` is found, it's loaded (first match per directory)
4. **Security scan** — same prompt injection scan as startup files
5. **Truncation** — capped at 8,000 characters per file
5. **Truncation** — capped at 32,000 characters per file (a fixed preview cap; `context_file_max_chars` and the model's context window do not change it). An oversized hint keeps its head/tail marker pointing at the full file and is logged, but does not raise the chat truncation warning that startup context files do
6. **Injection** — appended to the tool result, so the model sees it in context naturally
The final prompt section looks roughly like: