fix(context): compute-host /context and session.context_breakdown carry the per-file manifest; report blocked files

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).
This commit is contained in:
teknium1
2026-09-14 21:18:33 -07:00
committed by Teknium
parent f271ba09b0
commit e860b8e4e4
9 changed files with 122 additions and 12 deletions
+14 -7
View File
@@ -3,10 +3,13 @@
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, 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.
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.
Inspired by Copilot CLI 1.0.81's per-file ``/instructions`` view.
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
@@ -23,6 +26,7 @@ _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"),
@@ -44,7 +48,10 @@ def _empty_status(path: Path) -> str:
return "unreadable"
def _loaded_status(rendered_len: int, max_chars: int) -> str:
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"
@@ -56,7 +63,7 @@ def list_context_file_sources(
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 / empty / unreadable / suppressed.
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)
@@ -71,7 +78,7 @@ def list_context_file_sources(
elif winner in (None, kind):
winner = kind
# The builder caps the rendered ``## label`` section, not the raw file.
status = _loaded_status(len(f"## {label}\n\n{content}"), max_chars)
status = _loaded_status(content, len(f"## {label}\n\n{content}"), max_chars)
else:
status = "shadowed"
sources.append(_entry(label, path, content, status))
@@ -81,7 +88,7 @@ def list_context_file_sources(
soul_path = home / "SOUL.md"
if _pb._exists_or_denied(soul_path):
content = _pb._read_context_file(soul_path)
status = _loaded_status(len(content), max_chars) if content else _empty_status(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
+10
View File
@@ -790,6 +790,15 @@ export interface ContextUsageCategory {
tokens: number
}
export interface ContextFileSource {
label: string
path: string
chars: number
est_tokens: number
loaded: boolean
status: string
}
export interface ContextBreakdown {
categories: ContextUsageCategory[]
context_max: number
@@ -799,6 +808,7 @@ export interface ContextBreakdown {
context_used: number
estimated_total: number
model?: string
context_files?: ContextFileSource[]
}
export interface AnalyticsDailyEntry {
+11 -1
View File
@@ -2798,7 +2798,7 @@ export interface SessionContextBreakdownParams {
session_id: string
profile?: string | null
}
/** ``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds). */
/** ``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds) plus the per-file context manifest (empty until the agent exists). */
export interface SessionContextBreakdownResult {
categories: ContextCategory[]
context_max: number
@@ -2808,6 +2808,7 @@ export interface SessionContextBreakdownResult {
context_estimated: boolean
context_source: string
model: string
context_files?: ContextFileSource[]
}
export interface ContextCategory {
color: string
@@ -2815,6 +2816,15 @@ export interface ContextCategory {
label: string
tokens: number
}
/** One row of ``agent.context_file_sources.list_context_file_sources``. */
export interface ContextFileSource {
label: string
path: string
chars: number
est_tokens: number
loaded: boolean
status: string
}
export interface SessionCompressParams {
session_id: string
profile?: string | null
+49 -1
View File
@@ -9039,6 +9039,46 @@
"title": "ContextCategory",
"type": "object"
},
"ContextFileSource": {
"additionalProperties": false,
"description": "One row of ``agent.context_file_sources.list_context_file_sources``.",
"properties": {
"label": {
"title": "Label",
"type": "string"
},
"path": {
"title": "Path",
"type": "string"
},
"chars": {
"title": "Chars",
"type": "integer"
},
"est_tokens": {
"title": "Est Tokens",
"type": "integer"
},
"loaded": {
"title": "Loaded",
"type": "boolean"
},
"status": {
"title": "Status",
"type": "string"
}
},
"required": [
"label",
"path",
"chars",
"est_tokens",
"loaded",
"status"
],
"title": "ContextFileSource",
"type": "object"
},
"ControllerScope": {
"additionalProperties": false,
"properties": {
@@ -24445,7 +24485,7 @@
},
"SessionContextBreakdownResult": {
"additionalProperties": false,
"description": "``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds).",
"description": "``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds)\nplus the per-file context manifest (empty until the agent exists).",
"properties": {
"categories": {
"items": {
@@ -24481,6 +24521,14 @@
"model": {
"title": "Model",
"type": "string"
},
"context_files": {
"default": [],
"items": {
"$ref": "#/components/schemas/ContextFileSource"
},
"title": "Context Files",
"type": "array"
}
},
"required": [
+8
View File
@@ -81,3 +81,11 @@ def test_truncated_and_suppressed_statuses_follow_the_builder(project, monkeypat
lines = render_context_file_lines(list_context_file_sources(cwd=None, home_override=home))
assert lines[0] == "Context files" and "AGENTS.md" in lines[1] and "install tree" in lines[1]
assert render_context_file_lines([]) == []
# Injection scan: the builder swaps the body for a BLOCKED marker, so the manifest must not say "loaded".
monkeypatch.setattr(pb, "_get_context_file_max_chars", lambda *_a: 10_000)
monkeypatch.setattr(pb, "_scan_for_threats", lambda content, scope: ["fake-pattern"] if "evil" in content else [])
(project / "AGENTS.md").write_text("evil")
entry = _by_label(list_context_file_sources(cwd=str(project), home_override=home))["AGENTS.md"]
assert entry["status"] == "blocked" and entry["loaded"] is False
assert "[BLOCKED: AGENTS.md" in build_context_files_prompt(cwd=str(project), home_override=home)
+14 -1
View File
@@ -432,8 +432,20 @@ class ContextCategory(Result):
tokens: int
class ContextFileSource(Result):
"""One row of ``agent.context_file_sources.list_context_file_sources``."""
label: str
path: str
chars: int
est_tokens: int
loaded: bool
status: str
class SessionContextBreakdownResult(Result):
"""``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds)."""
"""``agent.context_breakdown.compute_session_context_breakdown`` (empty categories before the agent builds)
plus the per-file context manifest (empty until the agent exists)."""
categories: list[ContextCategory]
context_max: int
@@ -443,6 +455,7 @@ class SessionContextBreakdownResult(Result):
context_estimated: bool
context_source: str
model: str
context_files: list[ContextFileSource] = []
method("session.context_breakdown", params=SessionContextBreakdownParams, result=SessionContextBreakdownResult,
+5 -1
View File
@@ -1209,7 +1209,11 @@ def _(rid, params: dict, session: dict) -> dict:
tokens = _set_session_context(session["session_key"])
try:
from agent.context_breakdown import compute_session_context_breakdown
return _ok(rid, compute_session_context_breakdown(agent, history))
from agent.context_file_sources import context_file_sources_for_agent
payload = compute_session_context_breakdown(agent, history)
# Structured per-file rows so the Desktop popover can explain "why is my CLAUDE.md ignored?".
payload["context_files"] = context_file_sources_for_agent(agent)
return _ok(rid, payload)
except Exception as exc:
return _err(rid, 5000, f"Could not compute context breakdown: {exc}")
finally:
+10
View File
@@ -152,6 +152,16 @@ def _format_live_context_output(sid: str, session: dict, arg: str) -> str:
lines.append(f"Context usage: {mark}{context_used:,} tokens")
if usage.get("compressions"):
lines.append(f"Compressions: {int(usage.get('compressions') or 0):,}")
if (agent := session.get("agent")) is not None:
from agent.context_file_sources import context_file_sources_for_agent, render_context_file_lines
# RPC thread: bind the session cwd or the discovery walk keys on the backend's cwd, not the workspace.
tokens = _set_session_context(session["session_key"], cwd=_session_cwd(session))
try:
file_lines = render_context_file_lines(context_file_sources_for_agent(agent))
finally:
_clear_session_context(tokens)
if file_lines:
lines += [""] + file_lines
return "\n".join(lines)
+1 -1
View File
@@ -63,7 +63,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/egress [status]` | Show Docker egress proxy status — enabled/configured/running state, credential source, token mappings, uncovered providers, and next remediation step. Works in CLI, TUI, Desktop chat, and messaging gateway. |
| `/redraw` | Force a full UI repaint (recovers from terminal drift after tmux resize, mouse selection artifacts, etc.) |
| `/status` | Show session info — model, provider, profile, session ID, working directory, title, created/updated timestamps, token totals, agent-running state — followed by a local **Session recap** block (recent user/assistant turn counts, tool result count, top tools used, last few files touched, the latest user prompt, and the latest assistant reply). The recap is computed locally from the in-memory conversation; no LLM call, no prompt-cache impact. |
| `/context [all]` (alias: `/ctx`) | Visual context-window breakdown. On the CLI/TUI: a 5×20 glyph block grid (each cell ≈ 1% of the model window) plus an estimated per-category table — system prompt, tool definitions, rules, skills index, MCP, subagents, memory, conversation — versus free space. On messaging platforms: a usage gauge with auto-compression threshold/headroom, compression stats, cumulative throughput, and the same category table in plain text. Both end with a per-file **Context files** listing (.hermes.md, AGENTS.md chain, CLAUDE.md, .cursorrules + .cursor/rules/*.mdc, SOUL.md) showing each file's token estimate and whether it was loaded, truncated over `context_file_max_chars`, shadowed by a higher-priority context type, empty/unreadable, or suppressed by the install-tree guard — the answer to "why is my CLAUDE.md ignored?". `/context all` appends per-skill and per-toolset cost listings (index cost vs SKILL.md load cost; schema tokens per toolset). Read-only and computed locally — no LLM call, no prompt-cache impact. |
| `/context [all]` (alias: `/ctx`) | Visual context-window breakdown. On the CLI/TUI: a 5×20 glyph block grid (each cell ≈ 1% of the model window) plus an estimated per-category table — system prompt, tool definitions, rules, skills index, MCP, subagents, memory, conversation — versus free space. On messaging platforms: a usage gauge with auto-compression threshold/headroom, compression stats, cumulative throughput, and the same category table in plain text. On the CLI, messaging platforms, and TUI/Desktop sessions on a compute host, the output ends with a per-file **Context files** listing (.hermes.md, AGENTS.md chain, CLAUDE.md, .cursorrules + .cursor/rules/*.mdc, SOUL.md) showing each file's token estimate and whether it was loaded, truncated over `context_file_max_chars`, shadowed by a higher-priority context type, blocked by the injection scan, empty/unreadable, or suppressed by the install-tree guard — the answer to "why is my CLAUDE.md ignored?". `/context all` appends per-skill and per-toolset cost listings (index cost vs SKILL.md load cost; schema tokens per toolset). Read-only and computed locally — no LLM call, no prompt-cache impact. |
| `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. |
| `/bg <prompt>` | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/user-guide/cli#background-sessions). |
| `/btw <question>` | Ask a quick side question **about the current conversation** without interrupting it. A one-shot auxiliary LLM call answers from a read-only snapshot of the transcript — the live session's history and prompt cache are untouched, and the current turn keeps running. For independent work with a fresh context, use `/bg`. |