diff --git a/hermes_cli/prompt_size.py b/hermes_cli/prompt_size.py index 9ce06ae5a21e..30c212a429ce 100644 --- a/hermes_cli/prompt_size.py +++ b/hermes_cli/prompt_size.py @@ -26,6 +26,9 @@ _SKILLS_BLOCK_RE = re.compile(r".*?", re.DO # leading spaces, so the four-space + ``- `` prefix isolates skill lines. _SKILL_LINE_PREFIX = " - " +# Posture-demoted categories render all visible skill names on one shared line. +_NAMES_ONLY_LINE_RE = re.compile(r"^ .+ \[names only\]: (?P.+)$") + # Cap the human-readable "Skills by size" table; ``--json`` always has them all. _SKILLS_TABLE_LIMIT = 20 @@ -114,8 +117,11 @@ def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]: Two honest, distinct numbers per skill: - * ``index_line_bytes`` — the skill's actual rendered line in the always-on - index (the fixed per-call cost of *listing* the skill). + * ``index_line_bytes`` — the skill's attributed bytes in the always-on + index (the fixed per-call cost of *listing* the skill). For a compact + ``[names only]`` line, each name keeps its own bytes and receives an + even share of the category prefix and separators. The attributed bytes + therefore sum exactly to the shared rendered line. * ``skill_md_bytes`` — the on-disk size of the skill's ``SKILL.md`` (the real token cost paid only when the model loads it via ``skill_view``). ``None`` when the name can't be mapped to a file (e.g. a plugin skill @@ -126,16 +132,15 @@ def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]: """ name_to_path = _skill_md_paths_by_name() entries: List[Dict[str, Any]] = [] - for line in skills_block.splitlines(): - if not line.startswith(_SKILL_LINE_PREFIX): - continue - rest = line[len(_SKILL_LINE_PREFIX):] - # ``name: desc`` — the first ``": "`` separates name from description. - # Namespaced names (``codex:rescue``) have no space after their colon, - # so partitioning on ``": "`` keeps the full name intact. - name = rest.partition(": ")[0].strip() - if not name: - continue + + def append_entry( + name: str, + *, + attributed_bytes: int, + total_bytes: int, + shared_bytes: int, + skill_count: int, + ) -> None: path = name_to_path.get(name) md_bytes: Optional[int] = None if path is not None: @@ -145,10 +150,56 @@ def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]: md_bytes = None entries.append({ "name": name, - "index_line_bytes": _bytes(line), + "index_line_bytes": attributed_bytes, + "index_line_total_bytes": total_bytes, + "index_line_shared_bytes": shared_bytes, + "index_line_skill_count": skill_count, "skill_md_bytes": md_bytes, "path": str(path) if path is not None else "", }) + + for line in skills_block.splitlines(): + compact_match = _NAMES_ONLY_LINE_RE.match(line) + if compact_match is not None: + names = [ + name.strip() + for name in compact_match.group("names").split(",") + if name.strip() + ] + if not names: + continue + total_bytes = _bytes(line) + name_bytes = [_bytes(name) for name in names] + shared_total = total_bytes - sum(name_bytes) + shared_base, shared_remainder = divmod(shared_total, len(names)) + for index, name in enumerate(names): + shared_bytes = shared_base + (1 if index < shared_remainder else 0) + append_entry( + name, + attributed_bytes=name_bytes[index] + shared_bytes, + total_bytes=total_bytes, + shared_bytes=shared_bytes, + skill_count=len(names), + ) + continue + + if not line.startswith(_SKILL_LINE_PREFIX): + continue + rest = line[len(_SKILL_LINE_PREFIX):] + # ``name: desc`` — the first ``": "`` separates name from description. + # Namespaced names (``codex:rescue``) have no space after their colon, + # so partitioning on ``": "`` keeps the full name intact. + name = rest.partition(": ")[0].strip() + if not name: + continue + line_bytes = _bytes(line) + append_entry( + name, + attributed_bytes=line_bytes, + total_bytes=line_bytes, + shared_bytes=0, + skill_count=1, + ) entries.sort(key=lambda e: (-(e["skill_md_bytes"] or 0), e["name"])) return entries @@ -288,10 +339,10 @@ def render_breakdown(data: Dict[str, Any]) -> str: if skills: lines.append("") lines.append( - " Skills by size (SKILL.md on-disk = read cost; index line = " - "always-on cost, largest first):" + " Skills by size (SKILL.md on-disk = read cost; index cost = " + "attributed always-on bytes, largest first):" ) - lines.append(f" {'skill':<28} {'SKILL.md':>10} {'index line':>10}") + lines.append(f" {'skill':<28} {'SKILL.md':>10} {'index cost':>10}") shown = skills[:_SKILLS_TABLE_LIMIT] for sk in shown: md = sk["skill_md_bytes"] diff --git a/tests/hermes_cli/test_prompt_size.py b/tests/hermes_cli/test_prompt_size.py index c6fb8ed1934f..f95e8c0b3cf8 100644 --- a/tests/hermes_cli/test_prompt_size.py +++ b/tests/hermes_cli/test_prompt_size.py @@ -236,6 +236,32 @@ def test_skills_breakdown_parses_namespaced_names(): assert [e["name"] for e in entries] == ["codex:rescue"] +def test_skills_breakdown_attributes_demoted_category_shared_line(isolated_home): + """A real posture-demoted category retains every skill in the breakdown.""" + from agent.prompt_builder import build_skills_system_prompt + + _seed_skill(isolated_home, "alpha-skill", "alpha description") + _seed_skill(isolated_home, "beta-skill", "beta description") + prompt = build_skills_system_prompt(compact_categories=frozenset({"demo"})) + skills_match = _SKILLS_BLOCK_RE.search(prompt) + assert skills_match is not None + skills_block = skills_match.group(0) + shared_line = next( + line for line in skills_block.splitlines() if "demo [names only]" in line + ) + + entries = _compute_skills_breakdown(skills_block) + by_name = {entry["name"]: entry for entry in entries} + assert set(by_name) == {"alpha-skill", "beta-skill"} + + shared_line_bytes = len(shared_line.encode("utf-8")) + assert sum(entry["index_line_bytes"] for entry in entries) == shared_line_bytes + for entry in entries: + assert entry["index_line_total_bytes"] == shared_line_bytes + assert entry["index_line_shared_bytes"] > 0 + assert entry["index_line_skill_count"] == 2 + + def test_render_includes_per_component_tables(isolated_home): """The rendered report gains the two new sorted tables (additive).""" _seed_skill(isolated_home, "demo-skill", "a demo skill")