mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
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>
360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""Live session context-window breakdown for UI surfaces.
|
||
|
||
Estimates how the next provider request is composed: system prompt tiers,
|
||
tool schemas, and conversation history. Uses the same rough char/4 heuristic
|
||
as ``agent.model_metadata.estimate_request_tokens_rough`` so numbers align
|
||
with compression thresholds — not exact tokenizer counts.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||
|
||
_SKILLS_BLOCK_RE = re.compile(r"<available_skills>.*?</available_skills>", re.DOTALL)
|
||
|
||
_SUBAGENT_TOOL_NAMES = frozenset({"delegate_task"})
|
||
|
||
_CATEGORY_COLORS = {
|
||
"system_prompt": "var(--context-usage-system)",
|
||
"tool_definitions": "var(--context-usage-tools)",
|
||
"rules": "var(--context-usage-rules)",
|
||
"skills": "var(--context-usage-skills)",
|
||
"mcp": "var(--context-usage-mcp)",
|
||
"subagent_definitions": "var(--context-usage-subagents)",
|
||
"memory": "var(--context-usage-memory)",
|
||
"conversation": "var(--context-usage-conversation)",
|
||
}
|
||
|
||
|
||
def _chars_to_tokens(text: str) -> int:
|
||
if not text:
|
||
return 0
|
||
return (len(text) + 3) // 4
|
||
|
||
|
||
def _json_tokens(value: Any) -> int:
|
||
if not value:
|
||
return 0
|
||
return _chars_to_tokens(json.dumps(value, ensure_ascii=False))
|
||
|
||
|
||
def _tool_name(tool: dict) -> str:
|
||
fn = tool.get("function") if isinstance(tool, dict) else None
|
||
if isinstance(fn, dict):
|
||
return str(fn.get("name") or "")
|
||
return str(tool.get("name") or "")
|
||
|
||
|
||
def _split_tools(tools: Sequence[dict]) -> Tuple[List[dict], List[dict], List[dict]]:
|
||
builtin: List[dict] = []
|
||
mcp: List[dict] = []
|
||
subagent: List[dict] = []
|
||
for tool in tools:
|
||
name = _tool_name(tool)
|
||
if name.startswith("mcp_"):
|
||
mcp.append(tool)
|
||
elif name in _SUBAGENT_TOOL_NAMES:
|
||
subagent.append(tool)
|
||
else:
|
||
builtin.append(tool)
|
||
return builtin, mcp, subagent
|
||
|
||
|
||
def _memory_blocks(agent: Any) -> Tuple[str, str]:
|
||
memory_block = ""
|
||
user_block = ""
|
||
store = getattr(agent, "_memory_store", None)
|
||
if store is None:
|
||
return memory_block, user_block
|
||
try:
|
||
if getattr(agent, "_memory_enabled", True):
|
||
memory_block = store.format_for_system_prompt("memory") or ""
|
||
if getattr(agent, "_user_profile_enabled", True):
|
||
user_block = store.format_for_system_prompt("user") or ""
|
||
except Exception:
|
||
pass
|
||
return memory_block, user_block
|
||
|
||
|
||
def _strip_blocks(text: str, *blocks: str) -> str:
|
||
out = text
|
||
for block in blocks:
|
||
if block:
|
||
out = out.replace(block, "")
|
||
return out.strip()
|
||
|
||
|
||
def compute_session_context_breakdown(
|
||
agent: Any,
|
||
messages: Optional[List[dict]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Return a Cursor-style context usage breakdown for one live agent."""
|
||
from agent.model_metadata import estimate_messages_tokens_rough
|
||
from agent.system_prompt import build_system_prompt_parts
|
||
|
||
parts = build_system_prompt_parts(agent)
|
||
stable = parts.get("stable", "") or ""
|
||
context = parts.get("context", "") or ""
|
||
volatile = parts.get("volatile", "") or ""
|
||
|
||
skills_match = _SKILLS_BLOCK_RE.search(stable)
|
||
skills_index = skills_match.group(0) if skills_match else ""
|
||
|
||
memory_block, user_block = _memory_blocks(agent)
|
||
memory_text = "\n\n".join(part for part in (memory_block, user_block) if part).strip()
|
||
|
||
system_core = _strip_blocks(stable, skills_index)
|
||
system_tail = _strip_blocks(volatile, memory_block, user_block)
|
||
system_prompt_text = "\n\n".join(part for part in (system_core, system_tail) if part).strip()
|
||
|
||
tools = list(getattr(agent, "tools", None) or [])
|
||
builtin_tools, mcp_tools, subagent_tools = _split_tools(tools)
|
||
|
||
conversation_tokens = estimate_messages_tokens_rough(messages or [])
|
||
|
||
categories = [
|
||
("system_prompt", "System prompt", _chars_to_tokens(system_prompt_text)),
|
||
("tool_definitions", "Tool definitions", _json_tokens(builtin_tools)),
|
||
("rules", "Rules", _chars_to_tokens(context)),
|
||
("skills", "Skills", _chars_to_tokens(skills_index)),
|
||
("mcp", "MCP", _json_tokens(mcp_tools)),
|
||
("subagent_definitions", "Subagent definitions", _json_tokens(subagent_tools)),
|
||
("memory", "Memory", _chars_to_tokens(memory_text)),
|
||
("conversation", "Conversation", conversation_tokens),
|
||
]
|
||
|
||
estimated_total = sum(tokens for _, _, tokens in categories)
|
||
|
||
comp = getattr(agent, "context_compressor", None)
|
||
context_max = int(getattr(comp, "context_length", 0) or 0) if comp else 0
|
||
measured_used = int(getattr(comp, "last_prompt_tokens", 0) or 0) if comp else 0
|
||
context_used = measured_used if measured_used > 0 else estimated_total
|
||
context_percent = (
|
||
max(0, min(100, round(context_used / context_max * 100)))
|
||
if context_max
|
||
else 0
|
||
)
|
||
|
||
return {
|
||
"categories": [
|
||
{
|
||
"color": _CATEGORY_COLORS.get(category_id, "var(--ui-text-tertiary)"),
|
||
"id": category_id,
|
||
"label": label,
|
||
"tokens": tokens,
|
||
}
|
||
for category_id, label, tokens in categories
|
||
if tokens > 0
|
||
],
|
||
"context_max": context_max,
|
||
"context_percent": context_percent,
|
||
"context_used": context_used,
|
||
"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
|