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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
Reference in New Issue
Block a user