Restore load_skill + surface skill catalog in system prompt
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: <name> headers joined with ---), not the <specialized_knowledge> 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 <available_skills> block right after the <specialized_knowledge> 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) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ from strix.tools.agents_graph.tools import (
|
|||||||
wait_for_message,
|
wait_for_message,
|
||||||
)
|
)
|
||||||
from strix.tools.finish.tool import finish_scan
|
from strix.tools.finish.tool import finish_scan
|
||||||
|
from strix.tools.load_skill.tool import load_skill
|
||||||
from strix.tools.notes.tools import (
|
from strix.tools.notes.tools import (
|
||||||
create_note,
|
create_note,
|
||||||
delete_note,
|
delete_note,
|
||||||
@@ -342,6 +343,8 @@ def _finish_tool_use_behavior(
|
|||||||
_BASE_TOOLS: tuple[Tool, ...] = (
|
_BASE_TOOLS: tuple[Tool, ...] = (
|
||||||
# Thinking + planning
|
# Thinking + planning
|
||||||
think,
|
think,
|
||||||
|
# On-demand skill reference (returns the skill markdown inline)
|
||||||
|
load_skill,
|
||||||
# Per-agent todos
|
# Per-agent todos
|
||||||
create_todo,
|
create_todo,
|
||||||
list_todos,
|
list_todos,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from typing import Any
|
|||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
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
|
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(
|
rendered = env.get_template("system_prompt.jinja").render(
|
||||||
loaded_skill_names=list(skill_content.keys()),
|
loaded_skill_names=list(skill_content.keys()),
|
||||||
|
available_skills=get_available_skills(),
|
||||||
interactive=interactive,
|
interactive=interactive,
|
||||||
system_prompt_context=system_prompt_context or {},
|
system_prompt_context=system_prompt_context or {},
|
||||||
**skill_content,
|
**skill_content,
|
||||||
|
|||||||
@@ -437,3 +437,13 @@ Default user: pentester (sudo available)
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</specialized_knowledge>
|
</specialized_knowledge>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if available_skills %}
|
||||||
|
<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 `<specialized_knowledge>` above is already loaded for you.
|
||||||
|
|
||||||
|
{% for category, names in available_skills | dictsort -%}
|
||||||
|
- {{ category }}: {{ names | join(', ') }}
|
||||||
|
{% endfor -%}
|
||||||
|
</available_skills>
|
||||||
|
{% endif %}
|
||||||
|
|||||||
+20
-13
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
from strix.utils.resource_paths import get_strix_resource_path
|
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"})
|
_INTERNAL_SKILL_CATEGORIES: frozenset[str] = frozenset({"scan_modes", "coordination"})
|
||||||
|
|
||||||
|
|
||||||
def get_all_skill_names() -> set[str]:
|
def _iter_user_skill_files() -> Iterator[tuple[str, str]]:
|
||||||
"""Return every user-selectable skill name (bare, no category prefix).
|
"""Yield ``(category_name, skill_name)`` for every user-selectable skill."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
skills_dir = get_strix_resource_path("skills")
|
skills_dir = get_strix_resource_path("skills")
|
||||||
if not skills_dir.exists():
|
if not skills_dir.exists():
|
||||||
return set()
|
return
|
||||||
names: set[str] = set()
|
for category_dir in sorted(skills_dir.iterdir()):
|
||||||
for category_dir in skills_dir.iterdir():
|
|
||||||
if not category_dir.is_dir() or category_dir.name.startswith("__"):
|
if not category_dir.is_dir() or category_dir.name.startswith("__"):
|
||||||
continue
|
continue
|
||||||
if category_dir.name in _INTERNAL_SKILL_CATEGORIES:
|
if category_dir.name in _INTERNAL_SKILL_CATEGORIES:
|
||||||
continue
|
continue
|
||||||
for file_path in category_dir.glob("*.md"):
|
for file_path in sorted(category_dir.glob("*.md")):
|
||||||
names.add(file_path.stem)
|
yield category_dir.name, file_path.stem
|
||||||
return names
|
|
||||||
|
|
||||||
|
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:
|
def validate_requested_skills(skill_list: list[str], max_skills: int = 5) -> str | None:
|
||||||
|
|||||||
@@ -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/<category>/<name>.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)
|
||||||
Reference in New Issue
Block a user