From e860b8e4e4e232a503cee28c715cbedbfb1de0f0 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:18:33 -0700 Subject: [PATCH] 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). --- agent/context_file_sources.py | 21 +++++--- apps/desktop/src/types/hermes.ts | 10 ++++ apps/shared/src/gateway-contract.generated.ts | 12 ++++- apps/shared/src/gateway-contract.openrpc.json | 50 ++++++++++++++++++- tests/agent/test_context_file_sources.py | 8 +++ tui_gateway/contracts/sessions.py | 15 +++++- tui_gateway/methods_session.py | 6 ++- tui_gateway/methods_slash.py | 10 ++++ website/docs/reference/slash-commands.md | 2 +- 9 files changed, 122 insertions(+), 12 deletions(-) diff --git a/agent/context_file_sources.py b/agent/context_file_sources.py index 0b2ed4766e..114bc78b50 100644 --- a/agent/context_file_sources.py +++ b/agent/context_file_sources.py @@ -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 diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index eac2e664a7..c4a52f5c44 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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 { diff --git a/apps/shared/src/gateway-contract.generated.ts b/apps/shared/src/gateway-contract.generated.ts index e3b1baa0f9..e6bd64dc3a 100644 --- a/apps/shared/src/gateway-contract.generated.ts +++ b/apps/shared/src/gateway-contract.generated.ts @@ -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 diff --git a/apps/shared/src/gateway-contract.openrpc.json b/apps/shared/src/gateway-contract.openrpc.json index 76bad2cddc..543fda53b7 100644 --- a/apps/shared/src/gateway-contract.openrpc.json +++ b/apps/shared/src/gateway-contract.openrpc.json @@ -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": [ diff --git a/tests/agent/test_context_file_sources.py b/tests/agent/test_context_file_sources.py index ec761a803e..8452e04194 100644 --- a/tests/agent/test_context_file_sources.py +++ b/tests/agent/test_context_file_sources.py @@ -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) diff --git a/tui_gateway/contracts/sessions.py b/tui_gateway/contracts/sessions.py index 9687b77ce6..e1e6a99854 100644 --- a/tui_gateway/contracts/sessions.py +++ b/tui_gateway/contracts/sessions.py @@ -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, diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index d111623a89..78eef412f0 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -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: diff --git a/tui_gateway/methods_slash.py b/tui_gateway/methods_slash.py index 46e7b4de03..25d27ba111 100644 --- a/tui_gateway/methods_slash.py +++ b/tui_gateway/methods_slash.py @@ -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) diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 2569f5992c..ddd8e0bf0c 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -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 ` | 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 ` | 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`. |