diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index ebe17163e..49395d9fd 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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" diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 7a70c3c85..68642bf16 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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 # ============================================================================= diff --git a/gateway/run.py b/gateway/run.py index 11bb11ca2..30e6fc349 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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) diff --git a/run_agent.py b/run_agent.py index 5ee115ea0..92d53321b 100644 --- a/run_agent.py +++ b/run_agent.py @@ -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