Why: the tui_gateway live formatter (`_format_live_context_output`, used when the session runs on a compute host) renders its own summary and never got the "Context files" block, and `session.context_breakdown` had no structured rows, so Desktop's popover could not show them. The formatter now appends render_context_file_lines() with the session cwd bound (the RPC thread has no session context, so the discovery walk would key on the backend's cwd), and the RPC payload gains a `context_files` list (contract + generated TS/OpenRPC + Desktop type). The docs sentence is scoped to the surfaces that render it. A file whose content _scan_context_content replaces with a BLOCKED marker was reported "loaded"; the manifest now runs the same scan and reports `blocked`. The module docstring names the frontmatter-strip / chain-cap approximations and drops the product-name attribution (credit stays in the PR body).
123 lines
6.0 KiB
Python
123 lines
6.0 KiB
Python
"""Per-file manifest of the context/instruction files behind the ``/context`` "Rules" figure.
|
|
|
|
Read-only: enumerates the same candidates ``build_context_files_prompt`` loads (through
|
|
``agent.prompt_builder.discover_context_files`` — one discovery walk, so the listing cannot drift from the
|
|
prompt) and reports, per file, its size and whether it was loaded, truncated over the context-file cap,
|
|
shadowed by a higher-priority context type, blocked by the injection scan, empty/unreadable, or suppressed
|
|
by the install-tree guard. Nothing here builds a prompt or touches the truncation-warning ContextVar, so it
|
|
is free of cache impact.
|
|
|
|
Approximations (the manifest re-derives, it does not re-render): the truncation check sizes the raw
|
|
``## label`` section, so a .hermes.md whose YAML frontmatter the builder strips can read a few chars larger
|
|
here, and the AGENTS.md directory-chain cap (applied to the merged chain after per-file caps) is not modelled.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from agent import prompt_builder as _pb
|
|
from agent.model_metadata import estimate_tokens_rough
|
|
|
|
# status -> (glyph, note shown after the token count; "" = none)
|
|
_STATUS_DISPLAY = {
|
|
"loaded": ("✓", ""),
|
|
"truncated": ("◐", "truncated — over context_file_max_chars"),
|
|
"shadowed": ("○", "not loaded — higher-priority context type wins"),
|
|
"blocked": ("✗", "not loaded — blocked by the prompt-injection scan"),
|
|
"empty": ("○", "not loaded — empty file"),
|
|
"unreadable": ("✗", "not loaded — could not be read"),
|
|
"suppressed": ("○", "not loaded — cwd fell back to the Hermes install tree"),
|
|
}
|
|
|
|
|
|
def _entry(label: str, path: Path, content: str, status: str) -> Dict[str, Any]:
|
|
return {
|
|
"label": label, "path": str(path), "chars": len(content), "est_tokens": estimate_tokens_rough(content),
|
|
"loaded": status in ("loaded", "truncated"), "status": status,
|
|
}
|
|
|
|
|
|
def _empty_status(path: Path) -> str:
|
|
"""A file the builder read as "" is either genuinely empty or unreadable (permissions, timeout)."""
|
|
try:
|
|
return "unreadable" if path.stat().st_size > 0 else "empty"
|
|
except OSError:
|
|
return "unreadable"
|
|
|
|
|
|
def _loaded_status(content: str, rendered_len: int, max_chars: int) -> str:
|
|
"""Same scan the builder runs (``_scan_context_content``): a hit replaces the file with a BLOCKED marker."""
|
|
if _pb._scan_for_threats(content.lstrip("\ufeff"), scope="context"):
|
|
return "blocked"
|
|
return "truncated" if rendered_len > max_chars else "loaded"
|
|
|
|
|
|
def list_context_file_sources(
|
|
cwd: Optional[str] = None, context_length: Optional[int] = None, allow_install_tree_fallback: bool = False,
|
|
home_override: "Path | None" = None, skip_soul: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
"""One dict per context file Hermes considered, in the builder's priority order.
|
|
|
|
Same signature semantics as ``build_context_files_prompt`` (``cwd=None`` → launch dir, install-tree guard
|
|
unless *allow_install_tree_fallback*). Keys: ``label``, ``path``, ``chars``, ``est_tokens``, ``loaded``
|
|
and ``status`` ∈ loaded / truncated / shadowed / blocked / empty / unreadable / suppressed.
|
|
"""
|
|
cwd_path = Path(cwd if cwd is not None else os.getcwd()).resolve()
|
|
max_chars = _pb._get_context_file_max_chars(context_length)
|
|
suppressed = _pb._project_context_suppressed(cwd, cwd_path, allow_install_tree_fallback)
|
|
sources: List[Dict[str, Any]] = []
|
|
winner: Optional[str] = None
|
|
for kind, label, path, content in _pb.discover_context_files(cwd_path):
|
|
if not content:
|
|
status = _empty_status(path)
|
|
elif suppressed:
|
|
status = "suppressed"
|
|
elif winner in (None, kind):
|
|
winner = kind
|
|
# The builder caps the rendered ``## label`` section, not the raw file.
|
|
status = _loaded_status(content, len(f"## {label}\n\n{content}"), max_chars)
|
|
else:
|
|
status = "shadowed"
|
|
sources.append(_entry(label, path, content, status))
|
|
|
|
if not skip_soul:
|
|
home = Path(home_override) if home_override is not None else _pb.get_hermes_home()
|
|
soul_path = home / "SOUL.md"
|
|
if _pb._exists_or_denied(soul_path):
|
|
content = _pb._read_context_file(soul_path)
|
|
status = _loaded_status(content, len(content), max_chars) if content else _empty_status(soul_path)
|
|
sources.append(_entry("SOUL.md", soul_path, content, status))
|
|
return sources
|
|
|
|
|
|
def context_file_sources_for_agent(agent: Any) -> List[Dict[str, Any]]:
|
|
"""The manifest for a live agent, resolved exactly like ``agent.system_prompt._context_files_part``
|
|
(session cwd, install-tree policy per platform, the agent's own profile home)."""
|
|
if getattr(agent, "skip_context_files", False):
|
|
return []
|
|
from agent.runtime_cwd import resolve_context_cwd
|
|
from agent.system_prompt import _agent_home
|
|
launch_artifact = getattr(agent, "_context_cwd_is_launch_artifact", False)
|
|
cwd = None if launch_artifact else resolve_context_cwd()
|
|
ctx_len = getattr(getattr(agent, "context_compressor", None), "context_length", None)
|
|
return list_context_file_sources(
|
|
cwd=str(cwd) if cwd is not None else None, context_length=ctx_len if isinstance(ctx_len, int) else None,
|
|
allow_install_tree_fallback=getattr(agent, "platform", None) in ("cli", "tui"), home_override=_agent_home(agent),
|
|
)
|
|
|
|
|
|
def render_context_file_lines(sources: List[Dict[str, Any]]) -> List[str]:
|
|
"""Plain-text ``Context files`` block for ``/context``; [] when nothing was found."""
|
|
if not sources:
|
|
return []
|
|
width = max(len(str(src.get("label") or "")) for src in sources)
|
|
lines = ["Context files"]
|
|
for src in sources:
|
|
glyph, note = _STATUS_DISPLAY.get(str(src.get("status") or ""), ("•", ""))
|
|
suffix = f" ({note})" if note else ""
|
|
lines.append(f"{glyph} {str(src.get('label') or ''):<{width}} ~{int(src.get('est_tokens') or 0):>9,} tokens{suffix}")
|
|
return lines
|