Files
hermes-agent/hermes_cli/profile_describer.py
T
Edizzier 1f16bbf108 fix(cli): refuse to persist a truncated JSON reply as profile description
hermes_cli/profile_describer.py's aux-LLM response parser is documented as
"lenient, never raises": when the reply doesn't parse as JSON via
_extract_json_blob, it falls back to treating the WHOLE raw reply as plain
prose and persists it (truncated to 280 chars) as profile.yaml's
description.

That fallback exists for models that ignore the JSON-output instruction and
just answer in prose -- reasonable. But it doesn't distinguish that case
from a reply that DID start out as the requested JSON object and was cut
off mid-object by the aux model or transport. _extract_json_blob requires a
matching closing brace, so a truncated object (no `}` at all, or one cut
off mid-string) returns None just like plain prose does, and the fallback
then persists the raw JSON fragment verbatim: literal leading `{`, `\n`
escapes, a sentence chopped mid-word (#104067).

Fix: when parsing fails, check whether the reply (after the existing
code-fence strip) still looks JSON-shaped (starts with `{`). If so, it's a
malformed/truncated structured reply, not prose -- refuse it and return
ok=False instead of persisting the fragment. Only fall through to the
raw-text-as-prose fallback when the reply never looked like JSON to begin
with, so genuinely prose-only aux replies keep working exactly as before.
Also logs one INFO line on the refusal path, per the issue's note that the
silent fallback made this undiagnosable.

Update (review feedback from @jerrygooch): profile_describer._FENCE_RE was
case-sensitive, so an uppercase ```JSON fence wasn't stripped -- stripping
left "JSON\n{..." which doesn't start with "{", so a truncated response
under that fence variant still fell through to the prose fallback and got
persisted, defeating the fix for that case. Added re.IGNORECASE to
_FENCE_RE, aligning it with kanban_specify._FENCE_RE (which already used
re.IGNORECASE), and added a regression test for the uppercase-fence case.

Added tests/hermes_cli/test_profile_describer.py coverage: a truncated
object cut off mid-sentence (the real-world shape from the issue), one cut
off right after the opening brace, the same truncation under a ```json
fence, the same truncation under an uppercase ```JSON fence, and a
regression guard that a genuine plain-prose reply still hits the existing
lenient fallback unchanged.

Fixes #104067

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUtGTop5iGKi5vfWnKnwET
2026-09-06 05:34:01 -07:00

214 lines
8.8 KiB
Python

"""Profile describer — auto-generate ``description`` for a profile.
Mirrors ``hermes_cli/kanban_specify.py``: lazy aux client import, lenient response parse,
never raises on expected failure modes. Reads at most ``MAX_SKILLS_FOR_PROMPT`` skill
names to keep the prompt bounded.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from hermes_cli import profiles as profiles_mod
from agent.skill_utils import is_excluded_skill_path
logger = logging.getLogger(__name__)
# Cap on skill names fed to the LLM (200+ skill profiles would blow context).
MAX_SKILLS_FOR_PROMPT = 60
_SYSTEM_PROMPT = """You are a profile-describer for the Hermes Agent kanban board.
A user runs multiple "profiles" — distinct agent identities, each with their
own skills, model, and configuration. The kanban board's orchestrator routes
work to whichever profile best fits each task. To do that well, every
profile needs a short, concrete description of what it's good at.
You are given a profile's:
- Name
- Model / provider
- List of installed skill names (a strong signal of role / domain)
Produce a single JSON object with exactly one key:
{
"description": "<1-2 sentence description, plain prose, no preamble>"
}
Rules:
- The description is what an orchestrator will read to decide whether to
route a task here. Lead with the profile's strongest capability.
- Stay concrete. Bad: "an AI agent that helps users."
Good: "Reads and modifies Python codebases — runs tests,
refactors functions, opens GitHub PRs."
- 1-2 sentences, <= 280 characters total.
- Never invent capabilities the skills don't suggest.
- Never write "Hermes Agent profile" or other meta-narration.
- No code fences, no preamble, no closing remarks. Output only JSON.
"""
_USER_TEMPLATE = """Profile name: {name}
Default model: {model}
Provider: {provider}
Installed skill count: {skill_count}
Notable skills (up to {skill_cap}):
{skill_list}
"""
_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.MULTILINE | re.IGNORECASE)
@dataclass
class DescribeOutcome:
"""Result of describing a single profile."""
profile_name: str
ok: bool
reason: str = ""
description: Optional[str] = None
def _collect_skills(profile_dir: Path) -> list[str]:
"""Sorted non-excluded skill names: ``category/skill_name`` (category = immediate subdir
under ``skills/``), or bare ``skill_name`` for skills directly under ``skills/``."""
skills_dir = profile_dir / "skills"
if not skills_dir.is_dir():
return []
names: list[str] = []
for md in skills_dir.rglob("SKILL.md"):
if is_excluded_skill_path(md):
continue
try:
parts = md.relative_to(skills_dir).parts[:-1] # drop SKILL.md
except ValueError:
continue
if parts:
names.append(parts[0] if len(parts) == 1 else f"{parts[0]}/{parts[-1]}")
names.sort()
return names
def _sample_skills(names: list[str]) -> list[str]:
"""Cap *names* to the prompt budget with evenly-spaced picks: alphabetical position isn't
importance, so a profile with skills A..Z must not read as "starts with A"."""
if len(names) <= MAX_SKILLS_FOR_PROMPT:
return names
step = len(names) / MAX_SKILLS_FOR_PROMPT
return [names[int(i * step)] for i in range(MAX_SKILLS_FOR_PROMPT)]
def _extract_json_blob(raw: str) -> Optional[dict]:
from hermes_cli.kanban_specify import _extract_json_blob as _extract
return _extract(raw, _FENCE_RE)
def describe_profile(profile_name: str, *, overwrite: bool = False, timeout: Optional[int] = None) -> DescribeOutcome:
"""Auto-generate a description for one profile. Expected failures (profile missing, no aux
client, API error, malformed response) return ``ok=False`` so a sweep continues.
``overwrite`` allows replacing a user-authored (``description_auto: false``) description;
auto-generated ones are always replaceable."""
canon = profiles_mod.normalize_profile_name(profile_name)
if not profiles_mod.profile_exists(canon): # handles the virtual "default" name
return DescribeOutcome(canon, False, "profile not found")
try:
if canon == "default":
from hermes_constants import get_hermes_home # type: ignore
profile_dir = Path(get_hermes_home())
else:
profile_dir = profiles_mod.get_profile_dir(canon)
except Exception as exc:
return DescribeOutcome(canon, False, f"cannot resolve profile dir: {exc}")
existing = profiles_mod.read_profile_meta(profile_dir)
if existing.get("description") and not existing.get("description_auto") and not overwrite:
return DescribeOutcome(
canon, False, "profile already has a user-authored description (use --overwrite to replace)"
)
all_skills = _collect_skills(profile_dir)
skill_list = "\n".join(f" - {n}" for n in _sample_skills(all_skills)) or " (no skills installed)"
try:
model, provider = profiles_mod._read_config_model(profile_dir)
except Exception:
model, provider = None, None
try:
from agent.auxiliary_client import call_llm # type: ignore
except Exception as exc:
logger.debug("describe: auxiliary client import failed: %s", exc)
return DescribeOutcome(canon, False, "auxiliary client unavailable")
user_msg = _USER_TEMPLATE.format(
name=canon, model=(model or "(unset)"), provider=(provider or "(unset)"), skill_count=len(all_skills),
skill_cap=MAX_SKILLS_FOR_PROMPT, skill_list=skill_list,
)
try:
# call_llm applies auxiliary.profile_describer.* config (provider/model/base_url,
# extra_body, reasoning_effort, retries); the direct-create path dropped extra_body.
# See #35566.
resp = call_llm(
task="profile_describer",
messages=[{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_msg}],
temperature=0.3,
max_tokens=400,
timeout=timeout or 60,
)
except Exception as exc:
logger.info("describe: API call failed for %s (%s)", canon, exc)
return DescribeOutcome(canon, False, f"LLM error: {type(exc).__name__}")
try:
raw = resp.choices[0].message.content or ""
except Exception:
raw = ""
parsed = _extract_json_blob(raw)
if parsed is None:
# A response that is JSON-SHAPED (starts with `{`, once code fences are stripped) but
# failed to parse is a malformed/truncated structured reply -- e.g. the aux model or
# transport cut it off mid-object -- not free-form prose. Persisting it verbatim writes
# a raw JSON fragment (literal `{`, `\n` escapes, a sentence chopped mid-word) into
# profile.yaml's description field (#104067). Only fall back to "whole reply is prose"
# when the reply never looked like JSON in the first place.
stripped = _FENCE_RE.sub("", raw.strip())
if stripped.startswith("{"):
logger.info(
"describe: %s aux response looked JSON-shaped but failed to parse "
"(likely truncated) -- refusing to persist the raw fragment", canon,
)
return DescribeOutcome(canon, False, "LLM returned malformed/truncated JSON response")
# Fall back: raw text trimmed to one paragraph.
text = raw.strip().split("\n\n", 1)[0]
if not text:
return DescribeOutcome(canon, False, "LLM returned an empty response")
description = text[:280]
else:
val = parsed.get("description")
if not isinstance(val, str) or not val.strip():
return DescribeOutcome(canon, False, "LLM response missing 'description' field")
description = val.strip()[:280]
try:
profiles_mod.write_profile_meta(profile_dir, description=description, description_auto=True)
except Exception as exc:
return DescribeOutcome(canon, False, f"failed to write profile.yaml: {exc}")
return DescribeOutcome(canon, True, "described", description=description)
def list_describable_profiles(*, missing_only: bool = True) -> list[str]:
"""Profile names that can be described; ``missing_only`` keeps only those without a
user-authored description."""
return [
p.name for p in profiles_mod.list_profiles()
if not (missing_only and (p.description or "").strip() and not p.description_auto)
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import json # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----