mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
feat(cli,gateway): unify /context into a visual context-usage breakdown
Extends the cherry-picked /context command (PR #52184) and prompt-size attribution helpers (PR #66656) into one visual context view across surfaces, and absorbs the per-component budget-visibility goal of the /tokens proposal (PR #48470): - agent/context_breakdown.py: pure renderers over the existing payload — a 5x20 glyph block grid (1 cell ~= 1% of the model window), an 'Estimated usage by category' table with free space, and expanded per-skill / per-toolset listings via compute_context_details(), which reuses the prompt-size attribution mechanism (skills index-line bytes + registry tool->toolset map) converted to the same chars/4 heuristic. - cli.py: /context [all] renders grid + category table (+ expanded listings) from the live agent and in-memory conversation history. - gateway/slash_commands.py: /context appends the plain-text category table (no grid — monospace not guaranteed on messaging platforms); /context all adds the expanded listings. Fail-open: breakdown errors never break the gauge. - hermes_cli/commands.py: /context gains the 'all' subcommand; /version demoted to /hermes version on Slack to keep the 50-slash cap. - tests: renderer unit tests against synthetic payloads, registry test, gateway /context + /context all + failure-degradation handler tests. - docs: slash-commands reference + CLI guide entries. Read-only and locally computed: no provider calls, no prompt-cache impact. Co-authored-by: RemyFevry <29257684+RemyFevry@users.noreply.github.com> Co-authored-by: joelbrilliant <joelbrilliant1@gmail.com> Co-authored-by: CharlesMcquade <6466275+CharlesMcquade@users.noreply.github.com>
This commit is contained in:
parent
8b3da145f1
commit
07370a9dba
9 changed files with 591 additions and 2 deletions
|
|
@ -154,3 +154,207 @@ def compute_session_context_breakdown(
|
|||
"estimated_total": estimated_total,
|
||||
"model": getattr(agent, "model", "") or "",
|
||||
}
|
||||
|
||||
|
||||
# ── /context rendering (CLI + gateway) ──────────────────────────────────────
|
||||
#
|
||||
# Pure text renderers over the payload above. The CLI shows a glyph block-grid
|
||||
# plus a category table; the gateway uses the same table without the grid
|
||||
# (proportional monospace is not guaranteed on messaging platforms).
|
||||
|
||||
_CATEGORY_GLYPHS = {
|
||||
"system_prompt": "■",
|
||||
"tool_definitions": "▣",
|
||||
"rules": "▩",
|
||||
"skills": "▤",
|
||||
"mcp": "▥",
|
||||
"subagent_definitions": "▦",
|
||||
"memory": "▧",
|
||||
"conversation": "▨",
|
||||
}
|
||||
_FREE_GLYPH = "·"
|
||||
_GRID_COLUMNS = 20
|
||||
_GRID_ROWS = 5 # 100 cells → 1 cell per percent of the context window
|
||||
|
||||
# Human-readable tables cap the expanded listings; nothing is dropped from
|
||||
# the underlying data.
|
||||
_DETAILS_TABLE_LIMIT = 15
|
||||
|
||||
|
||||
def _bytes_to_tokens(size: Optional[int]) -> Optional[int]:
|
||||
if size is None:
|
||||
return None
|
||||
return (int(size) + 3) // 4
|
||||
|
||||
|
||||
def compute_context_details(agent: Any) -> Dict[str, Any]:
|
||||
"""Expanded per-skill / per-toolset cost listing for ``/context all``.
|
||||
|
||||
Reuses the ``hermes prompt-size`` attribution mechanism (PR #66656):
|
||||
per-skill index-line bytes parsed from the live ``<available_skills>``
|
||||
block, and per-toolset schema bytes attributed via the tool registry's
|
||||
canonical tool→toolset map. Byte figures are converted to the same
|
||||
chars/4 token heuristic the categories above use.
|
||||
"""
|
||||
from hermes_cli.prompt_size import (
|
||||
_compute_skills_breakdown,
|
||||
_compute_toolsets_breakdown,
|
||||
)
|
||||
from agent.system_prompt import build_system_prompt_parts
|
||||
|
||||
parts = build_system_prompt_parts(agent)
|
||||
stable = parts.get("stable", "") or ""
|
||||
skills_match = _SKILLS_BLOCK_RE.search(stable)
|
||||
skills_block = skills_match.group(0) if skills_match else ""
|
||||
|
||||
skills: List[Dict[str, Any]] = []
|
||||
if skills_block:
|
||||
for entry in _compute_skills_breakdown(skills_block):
|
||||
skills.append({
|
||||
"name": entry.get("name", ""),
|
||||
"index_tokens": _bytes_to_tokens(entry.get("index_line_bytes")) or 0,
|
||||
"skill_md_tokens": _bytes_to_tokens(entry.get("skill_md_bytes")),
|
||||
})
|
||||
|
||||
toolsets: List[Dict[str, Any]] = []
|
||||
tools = list(getattr(agent, "tools", None) or [])
|
||||
if tools:
|
||||
for group in _compute_toolsets_breakdown(tools):
|
||||
toolsets.append({
|
||||
"toolset": group.get("toolset", ""),
|
||||
"tool_count": int(group.get("tool_count", 0) or 0),
|
||||
"schema_tokens": _bytes_to_tokens(group.get("json_bytes")) or 0,
|
||||
})
|
||||
|
||||
return {"skills": skills, "toolsets": toolsets}
|
||||
|
||||
|
||||
def render_context_grid(payload: Dict[str, Any]) -> List[str]:
|
||||
"""Render the payload as a Claude Code-style glyph block grid.
|
||||
|
||||
100 cells (5×20), each one percent of the model context window. Categories
|
||||
fill in declaration order; the remainder renders as free space.
|
||||
"""
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
categories = payload.get("categories") or []
|
||||
total_cells = _GRID_COLUMNS * _GRID_ROWS
|
||||
|
||||
cells: List[str] = []
|
||||
if context_max > 0:
|
||||
for cat in categories:
|
||||
tokens = int(cat.get("tokens") or 0)
|
||||
n = round(tokens / context_max * total_cells)
|
||||
if tokens > 0 and n == 0:
|
||||
n = 1 # never render a nonzero category as invisible
|
||||
glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪")
|
||||
cells.extend([glyph] * n)
|
||||
cells = cells[:total_cells]
|
||||
cells.extend([_FREE_GLYPH] * (total_cells - len(cells)))
|
||||
|
||||
return [
|
||||
" ".join(cells[row * _GRID_COLUMNS:(row + 1) * _GRID_COLUMNS])
|
||||
for row in range(_GRID_ROWS)
|
||||
]
|
||||
|
||||
|
||||
def render_context_category_lines(payload: Dict[str, Any]) -> List[str]:
|
||||
"""Render the 'Estimated usage by category' table as plain-text lines."""
|
||||
categories = payload.get("categories") or []
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
estimated_total = int(payload.get("estimated_total") or 0)
|
||||
denom = context_max or estimated_total
|
||||
|
||||
lines = ["Estimated usage by category"]
|
||||
if not categories:
|
||||
lines.append(" (no data yet — send a message first)")
|
||||
return lines
|
||||
|
||||
width = max(len(str(cat.get("label") or "")) for cat in categories)
|
||||
width = max(width, len("Free space"))
|
||||
for cat in categories:
|
||||
tokens = int(cat.get("tokens") or 0)
|
||||
glyph = _CATEGORY_GLYPHS.get(str(cat.get("id") or ""), "▪")
|
||||
pct = tokens / denom * 100 if denom else 0.0
|
||||
label = str(cat.get("label") or cat.get("id") or "")
|
||||
lines.append(f"{glyph} {label:<{width}} {tokens:>9,} tokens {pct:>5.1f}%")
|
||||
if context_max > 0:
|
||||
free = max(0, context_max - estimated_total)
|
||||
pct = free / context_max * 100
|
||||
lines.append(f"{_FREE_GLYPH} {'Free space':<{width}} {free:>9,} tokens {pct:>5.1f}%")
|
||||
return lines
|
||||
|
||||
|
||||
def render_context_details_lines(details: Dict[str, Any]) -> List[str]:
|
||||
"""Render the expanded ``/context all`` per-skill / per-toolset tables."""
|
||||
lines: List[str] = []
|
||||
|
||||
toolsets = details.get("toolsets") or []
|
||||
if toolsets:
|
||||
lines.append("Toolsets by schema cost (largest first)")
|
||||
for group in toolsets[:_DETAILS_TABLE_LIMIT]:
|
||||
lines.append(
|
||||
f" {group['toolset']:<24} {group['tool_count']:>3} tools"
|
||||
f" {group['schema_tokens']:>8,} tokens"
|
||||
)
|
||||
remaining = len(toolsets) - _DETAILS_TABLE_LIMIT
|
||||
if remaining > 0:
|
||||
lines.append(f" … and {remaining} more")
|
||||
|
||||
skills = details.get("skills") or []
|
||||
if skills:
|
||||
if lines:
|
||||
lines.append("")
|
||||
lines.append("Skills by cost (index = always-on; SKILL.md = cost when loaded)")
|
||||
for entry in skills[:_DETAILS_TABLE_LIMIT]:
|
||||
name = str(entry.get("name") or "")
|
||||
if len(name) > 28:
|
||||
name = name[:27] + "…"
|
||||
md = entry.get("skill_md_tokens")
|
||||
md_str = f"{md:>8,}" if md is not None else f"{'n/a':>8}"
|
||||
lines.append(
|
||||
f" {name:<28} index {entry['index_tokens']:>6,}"
|
||||
f" SKILL.md {md_str} tokens"
|
||||
)
|
||||
remaining = len(skills) - _DETAILS_TABLE_LIMIT
|
||||
if remaining > 0:
|
||||
lines.append(f" … and {remaining} more")
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def render_context_breakdown_lines(
|
||||
payload: Dict[str, Any],
|
||||
*,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
grid: bool = True,
|
||||
) -> List[str]:
|
||||
"""Render the full /context view as plain-text lines.
|
||||
|
||||
``grid=True`` (CLI) prepends the glyph block grid; the gateway passes
|
||||
``grid=False`` and keeps its own gauge. ``details`` (from
|
||||
:func:`compute_context_details`) appends the expanded listings.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
if grid:
|
||||
lines.extend(render_context_grid(payload))
|
||||
lines.append("")
|
||||
lines.extend(render_context_category_lines(payload))
|
||||
|
||||
context_max = int(payload.get("context_max") or 0)
|
||||
context_used = int(payload.get("context_used") or 0)
|
||||
if context_max > 0:
|
||||
pct = int(payload.get("context_percent") or 0)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"Context window: {context_used:,} / {context_max:,} tokens ({pct}%)"
|
||||
)
|
||||
|
||||
if details is not None:
|
||||
detail_lines = render_context_details_lines(details)
|
||||
if detail_lines:
|
||||
lines.append("")
|
||||
lines.extend(detail_lines)
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("Use /context all for per-skill and per-toolset costs.")
|
||||
return lines
|
||||
|
|
|
|||
51
cli.py
51
cli.py
|
|
@ -9628,6 +9628,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
self._show_gateway_status()
|
||||
elif canonical == "status":
|
||||
self._show_session_status()
|
||||
elif canonical == "context":
|
||||
self._show_context_breakdown(cmd_original)
|
||||
elif canonical == "egress":
|
||||
from hermes_cli.proxy_cli import format_status_text
|
||||
|
||||
|
|
@ -10699,6 +10701,55 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
return
|
||||
print(f" {result.message}")
|
||||
|
||||
def _show_context_breakdown(self, cmd_original: str = ""):
|
||||
"""`/context [all]` — visual context-window usage breakdown.
|
||||
|
||||
Renders a 5×20 glyph block grid (each cell ≈ 1% of the model context
|
||||
window) plus an estimated per-category table: system prompt, tool
|
||||
definitions, rules, skills index, MCP, subagents, memory, and the
|
||||
conversation itself — versus free space. `/context all` appends the
|
||||
expanded per-skill and per-toolset cost listings.
|
||||
|
||||
Read-only: same chars/4 estimation engine as the desktop context
|
||||
popover (agent.context_breakdown) — no provider calls, no prompt-cache
|
||||
impact.
|
||||
"""
|
||||
if not self.agent:
|
||||
print(" (._.) No active agent -- send a message first.")
|
||||
return
|
||||
|
||||
args = cmd_original.split(maxsplit=1)[1].strip().lower() if " " in cmd_original else ""
|
||||
expanded = args in {"all", "full", "details"}
|
||||
|
||||
from agent.context_breakdown import (
|
||||
compute_context_details,
|
||||
compute_session_context_breakdown,
|
||||
render_context_breakdown_lines,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = compute_session_context_breakdown(
|
||||
self.agent, self.conversation_history
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" (._.) Could not compute context breakdown: {e}")
|
||||
return
|
||||
|
||||
details = None
|
||||
if expanded:
|
||||
try:
|
||||
details = compute_context_details(self.agent)
|
||||
except Exception:
|
||||
details = {"skills": [], "toolsets": []}
|
||||
|
||||
model = payload.get("model") or self.model
|
||||
print()
|
||||
print(f" 🧠 Context Usage — {model}")
|
||||
print()
|
||||
for line in render_context_breakdown_lines(payload, details=details, grid=True):
|
||||
print(f" {line}")
|
||||
print()
|
||||
|
||||
def _show_usage(self):
|
||||
"""Rate limits + session token usage (when a live agent exists) + Nous credits.
|
||||
|
||||
|
|
|
|||
|
|
@ -726,12 +726,16 @@ class GatewaySlashCommandsMixin:
|
|||
(between turns), then the SessionStore/SessionDB metadata for a gauge
|
||||
even when no agent is resident. Falls back to a transcript estimate only
|
||||
as a last resort.
|
||||
|
||||
``/context all`` appends the expanded per-skill / per-toolset cost
|
||||
listings (requires a resident agent).
|
||||
"""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
|
||||
source = event.source
|
||||
session_key = self._session_key_for_source(source)
|
||||
session_entry = await self.async_session_store.get_or_create_session(source)
|
||||
expanded = event.get_command_args().strip().lower() in {"all", "full", "details"}
|
||||
|
||||
# Try running agent first (mid-turn), then cached agent (between turns).
|
||||
agent = self._running_agents.get(session_key)
|
||||
|
|
@ -861,6 +865,18 @@ class GatewaySlashCommandsMixin:
|
|||
lines.append("")
|
||||
lines.append(t("gateway.context.detail_after_first"))
|
||||
|
||||
# Per-category estimated breakdown (+ optional expanded listings).
|
||||
# Same chars/4 engine the desktop popover and /usage use; plain
|
||||
# text (no glyph grid — monospace isn't guaranteed on messaging
|
||||
# platforms). Fail-open: rendering errors never break /context.
|
||||
if has_agent:
|
||||
breakdown = await asyncio.to_thread(
|
||||
self._context_breakdown_block, agent, source, expanded
|
||||
)
|
||||
if breakdown:
|
||||
lines.append("")
|
||||
lines.extend(breakdown)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
# Last resort: rough estimate from transcript
|
||||
|
|
@ -4536,6 +4552,43 @@ class GatewaySlashCommandsMixin:
|
|||
lines.append("Top up and manage billing in the browser — your balance updates here after.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _context_breakdown_block(self, agent, source, expanded: bool) -> list[str]:
|
||||
"""Render the /context per-category block (plain text, no grid).
|
||||
|
||||
Estimated (chars/4) — same engine as the desktop popover and /usage.
|
||||
``expanded`` appends the per-skill / per-toolset listings from the
|
||||
prompt-size attribution mechanism. Runs in a thread (sync store reads);
|
||||
returns [] and never raises so /context stays robust.
|
||||
"""
|
||||
try:
|
||||
from agent.context_breakdown import (
|
||||
compute_context_details,
|
||||
compute_session_context_breakdown,
|
||||
render_context_breakdown_lines,
|
||||
)
|
||||
|
||||
history: list[dict] = []
|
||||
try:
|
||||
entry = self.session_store.get_or_create_session(source)
|
||||
history = self.session_store.load_transcript(entry.session_id) or []
|
||||
except Exception:
|
||||
history = []
|
||||
|
||||
payload = compute_session_context_breakdown(agent, history)
|
||||
if not (payload.get("categories") or []):
|
||||
return []
|
||||
|
||||
details = None
|
||||
if expanded:
|
||||
try:
|
||||
details = compute_context_details(agent)
|
||||
except Exception:
|
||||
details = {"skills": [], "toolsets": []}
|
||||
|
||||
return render_context_breakdown_lines(payload, details=details, grid=False)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _context_breakdown_lines(self, agent, source) -> list[str]:
|
||||
"""Render the per-category context breakdown for /usage.
|
||||
|
||||
|
|
|
|||
|
|
@ -120,8 +120,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
CommandDef("status", "Show session, model, token, and context info", "Session"),
|
||||
CommandDef("egress", "Show Docker egress proxy status", "Session",
|
||||
args_hint="[status]", subcommands=("status",)),
|
||||
CommandDef("context", "Show detailed context window view with usage gauge, compression stats, and throughput", "Session",
|
||||
aliases=("ctx",)),
|
||||
CommandDef("context", "Show detailed context window view with usage gauge, category breakdown, compression stats, and throughput", "Session",
|
||||
aliases=("ctx",), args_hint="[all]", subcommands=("all",)),
|
||||
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
|
|
@ -1180,6 +1180,10 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
|
|||
# rare from Slack; reachable as /hermes init. Without this entry, adding
|
||||
# /init clamps /version off the native list and breaks Telegram parity.
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "init"})
|
||||
# - version: low-frequency info command; reachable as /hermes version on
|
||||
# Slack. Demoted when /context claimed a native slot (context is a
|
||||
# recurring inspection surface; version is a one-off lookup).
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"topup", "moa", "debug", "egress", "version"})
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
|
|
|
|||
|
|
@ -58,3 +58,147 @@ def test_breakdown_uses_measured_context_when_available():
|
|||
|
||||
assert data["context_used"] == 42_000
|
||||
assert data["context_percent"] == 21
|
||||
|
||||
# ── /context renderers (pure functions over the payload) ────────────────────
|
||||
|
||||
from agent.context_breakdown import ( # noqa: E402
|
||||
compute_context_details,
|
||||
render_context_breakdown_lines,
|
||||
render_context_category_lines,
|
||||
render_context_details_lines,
|
||||
render_context_grid,
|
||||
)
|
||||
|
||||
|
||||
def _payload(**overrides):
|
||||
base = {
|
||||
"categories": [
|
||||
{"id": "system_prompt", "label": "System prompt", "tokens": 10_000},
|
||||
{"id": "tool_definitions", "label": "Tool definitions", "tokens": 20_000},
|
||||
{"id": "skills", "label": "Skills", "tokens": 5_000},
|
||||
{"id": "conversation", "label": "Conversation", "tokens": 15_000},
|
||||
],
|
||||
"context_max": 200_000,
|
||||
"context_percent": 25,
|
||||
"context_used": 50_000,
|
||||
"estimated_total": 50_000,
|
||||
"model": "openai/gpt-test",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_grid_is_5x20_and_mostly_free():
|
||||
rows = render_context_grid(_payload())
|
||||
assert len(rows) == 5
|
||||
cells = " ".join(rows).split(" ")
|
||||
assert len(cells) == 100
|
||||
# 50k / 200k → 25 used cells, 75 free
|
||||
assert cells.count("·") == 75
|
||||
# Category glyphs proportional: 10k→5, 20k→10, 5k→2-3, 15k→7-8 cells
|
||||
assert cells.count("■") == 5
|
||||
assert cells.count("▣") == 10
|
||||
|
||||
|
||||
def test_grid_nonzero_category_never_invisible():
|
||||
payload = _payload(
|
||||
categories=[{"id": "memory", "label": "Memory", "tokens": 10}],
|
||||
estimated_total=10,
|
||||
context_used=10,
|
||||
)
|
||||
rows = render_context_grid(payload)
|
||||
assert "▧" in " ".join(rows)
|
||||
|
||||
|
||||
def test_grid_without_context_max_is_all_free():
|
||||
rows = render_context_grid(_payload(context_max=0))
|
||||
cells = " ".join(rows).split(" ")
|
||||
assert set(cells) == {"·"}
|
||||
|
||||
|
||||
def test_category_lines_include_tokens_percent_and_free_space():
|
||||
lines = render_context_category_lines(_payload())
|
||||
text = "\n".join(lines)
|
||||
assert "Estimated usage by category" in text
|
||||
assert "System prompt" in text and "10,000 tokens" in text
|
||||
assert "5.0%" in text # 10k / 200k
|
||||
assert "Free space" in text and "150,000 tokens" in text
|
||||
|
||||
|
||||
def test_category_lines_no_categories():
|
||||
lines = render_context_category_lines(_payload(categories=[]))
|
||||
assert any("no data yet" in line for line in lines)
|
||||
|
||||
|
||||
def test_breakdown_lines_grid_toggle():
|
||||
with_grid = render_context_breakdown_lines(_payload(), grid=True)
|
||||
without = render_context_breakdown_lines(_payload(), grid=False)
|
||||
assert any("·" in line for line in with_grid[:5])
|
||||
assert not any("·" in line for line in without[:2])
|
||||
# Both include the window summary and the expand hint
|
||||
for lines in (with_grid, without):
|
||||
text = "\n".join(lines)
|
||||
assert "Context window: 50,000 / 200,000 tokens (25%)" in text
|
||||
assert "/context all" in text
|
||||
|
||||
|
||||
def test_breakdown_lines_with_details_omits_hint():
|
||||
details = {
|
||||
"skills": [
|
||||
{"name": "alpha", "index_tokens": 25, "skill_md_tokens": 800},
|
||||
{"name": "beta", "index_tokens": 30, "skill_md_tokens": None},
|
||||
],
|
||||
"toolsets": [
|
||||
{"toolset": "terminal", "tool_count": 3, "schema_tokens": 4_000},
|
||||
],
|
||||
}
|
||||
lines = render_context_breakdown_lines(_payload(), details=details, grid=False)
|
||||
text = "\n".join(lines)
|
||||
assert "Toolsets by schema cost" in text
|
||||
assert "terminal" in text and "4,000 tokens" in text
|
||||
assert "Skills by cost" in text
|
||||
assert "alpha" in text and "beta" in text
|
||||
assert "n/a" in text # unmapped SKILL.md renders n/a, not a crash
|
||||
assert "Use /context all" not in text
|
||||
|
||||
|
||||
def test_details_lines_caps_listing():
|
||||
details = {
|
||||
"skills": [
|
||||
{"name": f"skill-{i}", "index_tokens": 10, "skill_md_tokens": 100}
|
||||
for i in range(20)
|
||||
],
|
||||
"toolsets": [],
|
||||
}
|
||||
lines = render_context_details_lines(details)
|
||||
assert any("… and 5 more" in line for line in lines)
|
||||
|
||||
|
||||
def test_compute_context_details_maps_bytes_to_tokens():
|
||||
agent, parts = _make_agent(
|
||||
stable=(
|
||||
"base\n<available_skills>\n demo:\n"
|
||||
" - hello: a demo skill\n</available_skills>"
|
||||
),
|
||||
)
|
||||
fake_skills = [{
|
||||
"name": "hello",
|
||||
"index_line_bytes": 40,
|
||||
"index_line_total_bytes": 40,
|
||||
"index_line_shared_bytes": 0,
|
||||
"index_line_skill_count": 1,
|
||||
"skill_md_bytes": 401,
|
||||
"path": "/tmp/hello/SKILL.md",
|
||||
}]
|
||||
fake_toolsets = [{"toolset": "terminal", "tool_count": 2, "json_bytes": 399}]
|
||||
with patch("agent.system_prompt.build_system_prompt_parts", return_value=parts), \
|
||||
patch("hermes_cli.prompt_size._compute_skills_breakdown", return_value=fake_skills), \
|
||||
patch("hermes_cli.prompt_size._compute_toolsets_breakdown", return_value=fake_toolsets):
|
||||
details = compute_context_details(agent)
|
||||
|
||||
assert details["skills"] == [
|
||||
{"name": "hello", "index_tokens": 10, "skill_md_tokens": 101},
|
||||
]
|
||||
assert details["toolsets"] == [
|
||||
{"toolset": "terminal", "tool_count": 2, "schema_tokens": 100},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -969,3 +969,124 @@ async def test_context_command_no_data():
|
|||
result = await runner._handle_context_command(_make_event("/context"))
|
||||
|
||||
assert "No context data available yet" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_command_includes_category_breakdown():
|
||||
"""/context with a live agent appends the per-category estimated
|
||||
breakdown (plain text, no glyph grid) and the /context all hint."""
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
session_id="sess-5",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner = _make_runner(session_entry)
|
||||
agent = _stub_agent()
|
||||
runner._running_agents[session_entry.session_key] = agent
|
||||
|
||||
fake_payload = {
|
||||
"categories": [
|
||||
{"id": "system_prompt", "label": "System prompt", "tokens": 9_000},
|
||||
{"id": "tool_definitions", "label": "Tool definitions", "tokens": 21_000},
|
||||
],
|
||||
"context_max": 200_000,
|
||||
"context_percent": 24,
|
||||
"context_used": 47_231,
|
||||
"estimated_total": 30_000,
|
||||
"model": "openai/gpt-test",
|
||||
}
|
||||
from unittest.mock import patch as _patch
|
||||
with _patch(
|
||||
"agent.context_breakdown.compute_session_context_breakdown",
|
||||
return_value=fake_payload,
|
||||
):
|
||||
result = await runner._handle_context_command(_make_event("/context"))
|
||||
|
||||
assert "Estimated usage by category" in result
|
||||
assert "System prompt" in result
|
||||
assert "9,000 tokens" in result
|
||||
assert "Tool definitions" in result
|
||||
assert "Use /context all" in result
|
||||
# No glyph grid on the gateway (plain-text variant)
|
||||
assert "· · ·" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_all_appends_expanded_listings():
|
||||
"""/context all appends per-toolset and per-skill cost listings."""
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
session_id="sess-6",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner = _make_runner(session_entry)
|
||||
agent = _stub_agent()
|
||||
runner._running_agents[session_entry.session_key] = agent
|
||||
|
||||
fake_payload = {
|
||||
"categories": [
|
||||
{"id": "skills", "label": "Skills", "tokens": 2_000},
|
||||
],
|
||||
"context_max": 200_000,
|
||||
"context_percent": 24,
|
||||
"context_used": 47_231,
|
||||
"estimated_total": 2_000,
|
||||
"model": "openai/gpt-test",
|
||||
}
|
||||
fake_details = {
|
||||
"skills": [
|
||||
{"name": "hermes-agent", "index_tokens": 30, "skill_md_tokens": 2_500},
|
||||
],
|
||||
"toolsets": [
|
||||
{"toolset": "terminal", "tool_count": 4, "schema_tokens": 5_100},
|
||||
],
|
||||
}
|
||||
from unittest.mock import patch as _patch
|
||||
with _patch(
|
||||
"agent.context_breakdown.compute_session_context_breakdown",
|
||||
return_value=fake_payload,
|
||||
), _patch(
|
||||
"agent.context_breakdown.compute_context_details",
|
||||
return_value=fake_details,
|
||||
):
|
||||
result = await runner._handle_context_command(_make_event("/context all"))
|
||||
|
||||
assert "Toolsets by schema cost" in result
|
||||
assert "terminal" in result and "5,100 tokens" in result
|
||||
assert "Skills by cost" in result
|
||||
assert "hermes-agent" in result
|
||||
# Expanded view drops the hint
|
||||
assert "Use /context all" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_breakdown_failure_never_breaks_command():
|
||||
"""A breakdown engine crash degrades gracefully — the gauge still renders."""
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
session_id="sess-7",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
)
|
||||
runner = _make_runner(session_entry)
|
||||
agent = _stub_agent()
|
||||
runner._running_agents[session_entry.session_key] = agent
|
||||
|
||||
from unittest.mock import patch as _patch
|
||||
with _patch(
|
||||
"agent.context_breakdown.compute_session_context_breakdown",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
result = await runner._handle_context_command(_make_event("/context"))
|
||||
|
||||
assert "🧠 **Context Window**" in result
|
||||
assert "In use: 47,231 / 200,000 (24%)" in result
|
||||
assert "Estimated usage by category" not in result
|
||||
|
|
|
|||
|
|
@ -120,6 +120,16 @@ class TestResolveCommand:
|
|||
assert topic.name == "topic"
|
||||
assert "topic" in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
def test_context_command_registered_with_ctx_alias(self):
|
||||
ctx = resolve_command("context")
|
||||
assert ctx is not None
|
||||
assert ctx.name == "context"
|
||||
assert resolve_command("ctx").name == "context"
|
||||
assert "all" in (ctx.subcommands or ())
|
||||
# Available on both CLI and gateway surfaces
|
||||
assert not ctx.cli_only and not ctx.gateway_only
|
||||
assert "context" in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
def test_leading_slash_stripped(self):
|
||||
assert resolve_command("/help").name == "help"
|
||||
assert resolve_command("/bg").name == "background"
|
||||
|
|
|
|||
|
|
@ -58,6 +58,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. `/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. |
|
||||
| `/background <prompt>` (alias: `/bg`, `/btw`) | 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). |
|
||||
| `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) |
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ Common examples:
|
|||
| `/reasoning high` | Increase reasoning effort |
|
||||
| `/title My Session` | Name the current session |
|
||||
| `/status` | Show session info — model/profile/tokens/duration — followed by a local **Session recap** block (recent turn counts, top tools used, files touched, latest user prompt + assistant reply). Pure local compute; no LLM call. |
|
||||
| `/context [all]` | Visual context-usage breakdown — glyph block grid + per-category token table (system prompt / tools / skills / memory / conversation / free space). `/context all` adds per-skill and per-toolset costs. |
|
||||
| `/sessions` | Open an interactive session picker right inside the classic CLI (same surface the TUI uses). Type to filter, arrow keys to navigate, Enter to resume. |
|
||||
|
||||
For the full built-in CLI and messaging lists, see [Slash Commands Reference](../reference/slash-commands.md).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue