feat: introduce skills management features in AIAgent and CLI

- Added skills configuration options in cli-config.yaml.example, including a nudge interval for skill creation reminders.
- Implemented skills guidance in AIAgent to prompt users to save reusable workflows after complex tasks.
- Enhanced skills indexing in the prompt builder to include descriptions from SKILL.md files for better context.
- Updated the agent's behavior to periodically remind users about potential skills during tool-calling iterations.
This commit is contained in:
teknium1 2026-02-22 13:28:13 -08:00
parent 3c6750f37b
commit db23f51bc6
4 changed files with 112 additions and 10 deletions

View file

@ -37,6 +37,12 @@ SESSION_SEARCH_GUIDANCE = (
"them to repeat themselves."
)
SKILLS_GUIDANCE = (
"After completing a complex task (5+ tool calls), fixing a tricky error, "
"or discovering a non-trivial workflow, consider saving the approach as a "
"skill with skill_manage so you can reuse it next time."
)
PLATFORM_HINTS = {
"whatsapp": (
"You are on a text messaging communication platform, WhatsApp. "
@ -64,11 +70,30 @@ CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
# Skills index
# =========================================================================
def _read_skill_description(skill_file: Path, max_chars: int = 60) -> str:
"""Read the description from a SKILL.md frontmatter, capped at max_chars."""
try:
raw = skill_file.read_text(encoding="utf-8")[:2000]
match = re.search(
r"^---\s*\n.*?description:\s*(.+?)\s*\n.*?^---",
raw, re.MULTILINE | re.DOTALL,
)
if match:
desc = match.group(1).strip().strip("'\"")
if len(desc) > max_chars:
desc = desc[:max_chars - 3] + "..."
return desc
except Exception:
pass
return ""
def build_skills_system_prompt() -> str:
"""Build a compact skill index for the system prompt.
Scans ~/.hermes/skills/ for SKILL.md files grouped by category so the
model can match skills at a glance without extra tool calls.
Scans ~/.hermes/skills/ for SKILL.md files grouped by category.
Includes per-skill descriptions from frontmatter so the model can
match skills by meaning, not just name.
"""
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
skills_dir = hermes_home / "skills"
@ -76,7 +101,9 @@ def build_skills_system_prompt() -> str:
if not skills_dir.exists():
return ""
skills_by_category = {}
# Collect skills with descriptions, grouped by category
# Each entry: (skill_name, description)
skills_by_category: dict[str, list[tuple[str, str]]] = {}
for skill_file in skills_dir.rglob("SKILL.md"):
rel_path = skill_file.relative_to(skills_dir)
parts = rel_path.parts
@ -86,11 +113,13 @@ def build_skills_system_prompt() -> str:
else:
category = "general"
skill_name = skill_file.parent.name
skills_by_category.setdefault(category, []).append(skill_name)
desc = _read_skill_description(skill_file)
skills_by_category.setdefault(category, []).append((skill_name, desc))
if not skills_by_category:
return ""
# Read category-level descriptions from DESCRIPTION.md
category_descriptions = {}
for category in skills_by_category:
desc_file = skills_dir / category / "DESCRIPTION.md"
@ -105,13 +134,21 @@ def build_skills_system_prompt() -> str:
index_lines = []
for category in sorted(skills_by_category.keys()):
desc = category_descriptions.get(category, "")
names = ", ".join(sorted(set(skills_by_category[category])))
if desc:
index_lines.append(f" {category}: {desc}")
cat_desc = category_descriptions.get(category, "")
if cat_desc:
index_lines.append(f" {category}: {cat_desc}")
else:
index_lines.append(f" {category}:")
index_lines.append(f" skills: {names}")
# Deduplicate and sort skills within each category
seen = set()
for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
if name in seen:
continue
seen.add(name)
if desc:
index_lines.append(f" - {name}: {desc}")
else:
index_lines.append(f" - {name}")
return (
"## Skills (mandatory)\n"

View file

@ -178,6 +178,18 @@ memory:
# For exit/reset, only fires if the session had at least this many user turns.
flush_min_turns: 6 # Min user turns to trigger flush on exit/reset (0 = disabled)
# =============================================================================
# Skills Configuration
# =============================================================================
# Skills are reusable procedures the agent can load and follow. The agent can
# also create new skills after completing complex tasks.
#
skills:
# Nudge the agent to create skills after complex tasks.
# Every N tool-calling iterations, remind the model to consider saving a skill.
# Set to 0 to disable.
creation_nudge_interval: 15
# =============================================================================
# Agent Behavior
# =============================================================================

View file

@ -590,6 +590,35 @@ class GatewayRunner:
session_key = f"agent:main:{source.platform.value}:" + \
(f"dm" if source.chat_type == "dm" else f"{source.chat_type}:{source.chat_id}")
# Memory flush before reset: load the old transcript and let a
# temporary agent save memories before the session is wiped.
try:
old_entry = self.session_store._sessions.get(session_key)
if old_entry:
old_history = self.session_store.load_transcript(old_entry.session_id)
if old_history:
from run_agent import AIAgent
loop = asyncio.get_event_loop()
def _do_flush():
tmp_agent = AIAgent(
model=os.getenv("HERMES_MODEL", "anthropic/claude-opus-4.6"),
max_iterations=5,
quiet_mode=True,
enabled_toolsets=["memory"],
session_id=old_entry.session_id,
)
# Build simple message list from transcript
msgs = []
for m in old_history:
role = m.get("role")
content = m.get("content")
if role in ("user", "assistant") and content:
msgs.append({"role": role, "content": content})
tmp_agent.flush_memories(msgs)
await loop.run_in_executor(None, _do_flush)
except Exception as e:
logger.debug("Gateway memory flush on reset failed: %s", e)
# Reset the session
new_entry = self.session_store.reset_session(session_key)

View file

@ -60,7 +60,7 @@ from hermes_constants import OPENROUTER_BASE_URL, OPENROUTER_MODELS_URL
# Agent internals extracted to agent/ package for modularity
from agent.prompt_builder import (
DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS,
MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE,
MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE,
)
from agent.model_metadata import (
fetch_model_metadata, get_model_context_length,
@ -393,6 +393,15 @@ class AIAgent:
except Exception:
pass # Memory is optional -- don't break agent init
# Skills config: nudge interval for skill creation reminders
self._skill_nudge_interval = 15
try:
from hermes_cli.config import load_config as _load_skills_config
skills_config = _load_skills_config().get("skills", {})
self._skill_nudge_interval = int(skills_config.get("creation_nudge_interval", 15))
except Exception:
pass
# Initialize context compressor for automatic context management
# Compresses conversation when approaching model's context limit
# Configuration via environment variables (can be set in .env or cli-config.yaml)
@ -1040,6 +1049,8 @@ class AIAgent:
tool_guidance.append(MEMORY_GUIDANCE)
if "session_search" in self.valid_tool_names:
tool_guidance.append(SESSION_SEARCH_GUIDANCE)
if "skill_manage" in self.valid_tool_names:
tool_guidance.append(SKILLS_GUIDANCE)
if tool_guidance:
prompt_parts.append(" ".join(tool_guidance))
@ -1658,6 +1669,19 @@ class AIAgent:
break
api_call_count += 1
# Periodic skill creation nudge after many tool-calling iterations
if (self._skill_nudge_interval > 0
and api_call_count > 0
and api_call_count % self._skill_nudge_interval == 0
and "skill_manage" in self.valid_tool_names):
messages.append({
"role": "user",
"content": (
"[System: This task has involved many steps. "
"If you've discovered a reusable workflow, consider saving it as a skill.]"
),
})
# Prepare messages for API call
# If we have an ephemeral system prompt, prepend it to the messages