From a451766d97746461722a92011db40c65c4ec0d18 Mon Sep 17 00:00:00 2001 From: 0xallam Date: Mon, 25 May 2026 15:02:24 -0700 Subject: [PATCH] Restore load_skill + surface skill catalog in system prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's load_skill tool was deleted during the SDK migration along with the prompt-mutation pattern it relied on. Re-add the capability without the mutation: load_skill(skills=[...]) now returns the skill markdown bodies as a tool result, so the content lands in conversation history as in-context reference rather than as patched-in system prompt content. Same source of truth (load_skills + skill files), same validation (validate_requested_skills) as create_agent. Tool result format is plain markdown (## Skill: headers joined with ---), not the XML wrapping used at agent-build time. The XML framing was deliberately reserved for prompt-level privileged context; tool-loaded skills are honestly labelled as just-fetched reference material. Close the discovery loop by surfacing the full skill catalog in the system prompt. Without it the model could only guess skill names — discovering them via validation errors on misses. Now every agent sees a categorised block right after the block with a short hint pointing at create_agent / load_skill. Skills module: factored _iter_user_skill_files() so get_all_skill_names (set, for validation) and get_available_skills (dict by category, for the prompt) share one source of truth on what counts as user-selectable. Internal categories (scan_modes, coordination) stay excluded from both. Co-Authored-By: Claude Opus 4.7 (1M context) --- strix/agents/factory.py | 3 ++ strix/agents/prompt.py | 3 +- strix/agents/prompts/system_prompt.jinja | 10 +++++++ strix/skills/__init__.py | 33 +++++++++++++--------- strix/tools/load_skill/__init__.py | 0 strix/tools/load_skill/tool.py | 36 ++++++++++++++++++++++++ 6 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 strix/tools/load_skill/__init__.py create mode 100644 strix/tools/load_skill/tool.py diff --git a/strix/agents/factory.py b/strix/agents/factory.py index be5075d..6eceef3 100644 --- a/strix/agents/factory.py +++ b/strix/agents/factory.py @@ -40,6 +40,7 @@ from strix.tools.agents_graph.tools import ( wait_for_message, ) from strix.tools.finish.tool import finish_scan +from strix.tools.load_skill.tool import load_skill from strix.tools.notes.tools import ( create_note, delete_note, @@ -342,6 +343,8 @@ def _finish_tool_use_behavior( _BASE_TOOLS: tuple[Tool, ...] = ( # Thinking + planning think, + # On-demand skill reference (returns the skill markdown inline) + load_skill, # Per-agent todos create_todo, list_todos, diff --git a/strix/agents/prompt.py b/strix/agents/prompt.py index bccfe78..5263e82 100644 --- a/strix/agents/prompt.py +++ b/strix/agents/prompt.py @@ -12,7 +12,7 @@ from typing import Any from jinja2 import Environment, FileSystemLoader, select_autoescape -from strix.skills import load_skills +from strix.skills import get_available_skills, load_skills from strix.utils.resource_paths import get_strix_resource_path @@ -114,6 +114,7 @@ def render_system_prompt( rendered = env.get_template("system_prompt.jinja").render( loaded_skill_names=list(skill_content.keys()), + available_skills=get_available_skills(), interactive=interactive, system_prompt_context=system_prompt_context or {}, **skill_content, diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index 3fbfcc7..32738a7 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -437,3 +437,13 @@ Default user: pentester (sudo available) {% endfor %} {% endif %} + +{% if available_skills %} + +On-demand specialist skills. Spawn a specialist via `create_agent(skills=[...])`, or pull guidance inline for yourself via `load_skill(skills=[...])`. Anything wrapped in `` above is already loaded for you. + +{% for category, names in available_skills | dictsort -%} +- {{ category }}: {{ names | join(', ') }} +{% endfor -%} + +{% endif %} diff --git a/strix/skills/__init__.py b/strix/skills/__init__.py index 1eac077..d17c76b 100644 --- a/strix/skills/__init__.py +++ b/strix/skills/__init__.py @@ -1,5 +1,6 @@ import logging import re +from collections.abc import Iterator from strix.utils.resource_paths import get_strix_resource_path @@ -14,25 +15,31 @@ _FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) _INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"}) -def get_all_skill_names() -> set[str]: - """Return every user-selectable skill name (bare, no category prefix). - - Used by :func:`validate_requested_skills` so ``create_agent`` can - reject typos / hallucinated names with a useful list, and by callers - that need to enumerate the catalog. - """ +def _iter_user_skill_files() -> Iterator[tuple[str, str]]: + """Yield ``(category_name, skill_name)`` for every user-selectable skill.""" skills_dir = get_strix_resource_path("skills") if not skills_dir.exists(): - return set() - names: set[str] = set() - for category_dir in skills_dir.iterdir(): + return + for category_dir in sorted(skills_dir.iterdir()): if not category_dir.is_dir() or category_dir.name.startswith("__"): continue if category_dir.name in _INTERNAL_SKILL_CATEGORIES: continue - for file_path in category_dir.glob("*.md"): - names.add(file_path.stem) - return names + for file_path in sorted(category_dir.glob("*.md")): + yield category_dir.name, file_path.stem + + +def get_all_skill_names() -> set[str]: + """Return every user-selectable skill name (bare, no category prefix).""" + return {name for _, name in _iter_user_skill_files()} + + +def get_available_skills() -> dict[str, list[str]]: + """Return user-selectable skills grouped by category, alphabetised.""" + grouped: dict[str, list[str]] = {} + for category, name in _iter_user_skill_files(): + grouped.setdefault(category, []).append(name) + return grouped def validate_requested_skills(skill_list: list[str], max_skills: int = 5) -> str | None: diff --git a/strix/tools/load_skill/__init__.py b/strix/tools/load_skill/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/strix/tools/load_skill/tool.py b/strix/tools/load_skill/tool.py new file mode 100644 index 0000000..3ef6f9e --- /dev/null +++ b/strix/tools/load_skill/tool.py @@ -0,0 +1,36 @@ +"""``load_skill`` — fetch skill reference material into the conversation.""" + +from __future__ import annotations + +from agents import RunContextWrapper, function_tool + +from strix.skills import load_skills, validate_requested_skills + + +@function_tool(timeout=10) +async def load_skill(ctx: RunContextWrapper, skills: list[str]) -> str: + """Return the markdown body of one or more skills as reference material. + + Use this when you need exact syntax / workflow / payload guidance + right before acting on a technology that wasn't preloaded for your + agent. The skill content lands inline as a tool result — no + permanent prompt change, just in-conversation reference. + + For permanent skill assignment, pass ``skills=[…]`` to + ``create_agent`` when spawning a specialist child instead. + + Args: + skills: List of skill names (e.g. ``["xss", "sql_injection"]``). + Max 5. Names match the bare files under + ``strix/skills//.md``. + """ + del ctx + requested = list(skills or []) + err = validate_requested_skills(requested) + if err: + return f"load_skill: {err}" + contents = load_skills(requested) + if not contents: + return "load_skill: no content loaded for requested skills." + sections = [f"## Skill: {name}\n\n{body}" for name, body in contents.items()] + return "\n\n---\n\n".join(sections)